EnCoach Odoo 19 custom modules

Full backend implementation with custom Odoo modules:
- encoach_api: Core API, user management, JWT auth
- encoach_exam: Exam generation (reading, writing, listening, speaking)
- encoach_evaluate: AI-powered evaluation (writing, speaking)
- encoach_training: Training tips and walkthrough
- encoach_storage: File storage management
- encoach_payment: Stripe, PayPal, Paymob integration
- encoach_mail: Email notifications

Made-with: Cursor
This commit is contained in:
Talal Sharabi
2026-03-14 16:46:46 +04:00
commit f5b627256f
168 changed files with 13428 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
Import ELAI avatars from avatars.json into encoach.elai.avatar.
Usage (Odoo shell):
./odoo-bin shell -c odoo.conf -d encoach_prod
>>> exec(open('../addons_extra/encoach_core/scripts/import_avatars.py').read())
"""
import json
import logging
import os
_logger = logging.getLogger("encoach.avatar_import")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
AVATARS_JSON = os.path.join(
os.path.dirname(__file__), "..", "..", "encoach", "encoach_backend", "elai", "avatars.json"
)
def import_avatars(env, json_path=None):
path = json_path or AVATARS_JSON
if not os.path.exists(path):
_logger.error("avatars.json not found at %s", path)
return
with open(path, "r") as f:
avatars = json.load(f)
if isinstance(avatars, dict):
avatars = list(avatars.values())
created = 0
skipped = 0
for av in avatars:
code = av.get("code", av.get("avatar_code", ""))
if not code:
skipped += 1
continue
existing = env["encoach.elai.avatar"].sudo().search([("avatar_code", "=", code)], limit=1)
if existing:
skipped += 1
continue
voice = av.get("voice", {})
voice_id = voice.get("voiceId") if isinstance(voice, dict) else av.get("voice_id")
voice_provider = voice.get("voiceProvider") if isinstance(voice, dict) else av.get("voice_provider")
env["encoach.elai.avatar"].sudo().create({
"name": av.get("name", code),
"avatar_code": code,
"avatar_url": av.get("url", av.get("avatar_url", "")),
"gender": av.get("gender"),
"canvas": av.get("canvas"),
"voice_id": voice_id,
"voice_provider": voice_provider,
})
created += 1
_logger.info("Avatar import complete: created=%d, skipped=%d", created, skipped)
try:
env # noqa: F821
import_avatars(env)
except NameError:
_logger.info("Run this script inside Odoo shell")

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
Firebase Auth -> Odoo res.users migration script.
Exports users from Firebase Auth (via Admin SDK) and creates/updates
corresponding Odoo res.users records with EnCoach extensions.
Usage (Odoo shell):
./odoo-bin shell -c odoo.conf -d encoach_prod
>>> exec(open('../addons_extra/encoach_core/scripts/migrate_firebase_auth.py').read())
Requires: firebase-admin
pip install firebase-admin
"""
import logging
_logger = logging.getLogger("encoach.firebase_migration")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
try:
import firebase_admin
from firebase_admin import auth as fb_auth, credentials
except ImportError:
_logger.error("firebase-admin is required: pip install firebase-admin")
firebase_admin = None
SERVICE_ACCOUNT_PATH = "serviceAccountKey.json"
def migrate_firebase_users(env, service_account_path=SERVICE_ACCOUNT_PATH):
if not firebase_admin:
_logger.error("firebase-admin not installed")
return
cred = credentials.Certificate(service_account_path)
try:
firebase_admin.get_app()
except ValueError:
firebase_admin.initialize_app(cred)
_logger.info("Fetching Firebase users...")
created = 0
updated = 0
skipped = 0
errors = 0
page = fb_auth.list_users()
while page:
for fb_user in page.users:
email = fb_user.email
if not email:
skipped += 1
continue
try:
existing = env["res.users"].sudo().search([("login", "=", email)], limit=1)
if existing:
vals = {}
if fb_user.display_name and not existing.name:
vals["name"] = fb_user.display_name
if fb_user.email_verified and not existing.is_verified:
vals["is_verified"] = True
if fb_user.photo_url and not existing.profile_picture:
vals["profile_picture"] = fb_user.photo_url
if vals:
existing.sudo().write(vals)
updated += 1
else:
skipped += 1
else:
env["res.users"].sudo().create({
"login": email,
"name": fb_user.display_name or email.split("@")[0],
"password": "changeme_firebase_migrated",
"encoach_type": "student",
"is_verified": fb_user.email_verified,
"profile_picture": fb_user.photo_url or "",
"legacy_id": fb_user.uid,
})
created += 1
except Exception as e:
_logger.error("Error migrating %s: %s", email, e)
errors += 1
page = page.get_next_page()
_logger.info(
"Firebase migration complete: created=%d, updated=%d, skipped=%d, errors=%d",
created, updated, skipped, errors,
)
try:
env # noqa: F821
migrate_firebase_users(env)
except NameError:
_logger.info("Run this script inside Odoo shell")

View File

@@ -0,0 +1,731 @@
#!/usr/bin/env python3
"""
MongoDB -> Odoo PostgreSQL data migration script.
Usage (from Odoo shell):
./odoo-bin shell -c odoo.conf -d encoach_prod
>>> exec(open('../addons_extra/encoach_core/scripts/migrate_mongodb.py').read())
Or standalone:
python migrate_mongodb.py --mongo-uri mongodb://localhost:27017 --mongo-db encoach --odoo-url http://localhost:8069 --odoo-db encoach_prod
Requires: pymongo, xmlrpc.client (stdlib)
"""
import argparse
import logging
import sys
from datetime import datetime
_logger = logging.getLogger("encoach.migration")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
try:
from pymongo import MongoClient
except ImportError:
_logger.error("pymongo is required: pip install pymongo")
sys.exit(1)
COLLECTION_MAP = {
"users": "res.users",
"entities": "encoach.entity",
"roles": "encoach.role",
"permissions": "encoach.permission",
"codes": "encoach.code",
"invites": "encoach.invite",
"exams": "encoach.exam",
"groups": "encoach.group",
"assignments": "encoach.assignment",
"sessions": "encoach.session",
"stats": "encoach.stat",
"evaluations": "encoach.evaluation",
"trainings": "encoach.training",
"tips": "encoach.training.tip",
"walkthroughs": "encoach.walkthrough",
"packages": "encoach.package",
"payments": "encoach.subscription.payment",
"discounts": "encoach.discount",
"tickets": "encoach.ticket",
"avatars": "encoach.elai.avatar",
}
LEGACY_ID_CACHE = {}
def _oid(doc, key="_id"):
val = doc.get(key)
if val is None:
return None
return str(val)
def _dt(val):
if isinstance(val, datetime):
return val.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(val, str):
return val
if isinstance(val, (int, float)):
return datetime.fromtimestamp(val / 1000).strftime("%Y-%m-%d %H:%M:%S")
return None
def _resolve_legacy(model, legacy_id, env):
"""Look up Odoo record ID by legacy MongoDB _id."""
if not legacy_id:
return False
cache_key = (model, str(legacy_id))
if cache_key in LEGACY_ID_CACHE:
return LEGACY_ID_CACHE[cache_key]
rec = env[model].sudo().search([("legacy_id", "=", str(legacy_id))], limit=1)
if rec:
LEGACY_ID_CACHE[cache_key] = rec.id
return rec.id
return False
class EncoachMigrator:
def __init__(self, mongo_uri, mongo_db, env):
self.client = MongoClient(mongo_uri)
self.db = self.client[mongo_db]
self.env = env
self.stats = {k: {"total": 0, "created": 0, "skipped": 0, "error": 0} for k in COLLECTION_MAP}
def run(self):
_logger.info("Starting EnCoach data migration")
self._migrate_entities()
self._migrate_permissions()
self._migrate_roles()
self._migrate_users()
self._migrate_codes()
self._migrate_invites()
self._migrate_exams()
self._migrate_groups()
self._migrate_assignments()
self._migrate_sessions()
self._migrate_stats()
self._migrate_evaluations()
self._migrate_trainings()
self._migrate_tips()
self._migrate_walkthroughs()
self._migrate_packages()
self._migrate_payments()
self._migrate_discounts()
self._migrate_tickets()
self._migrate_avatars()
self._print_summary()
def _migrate_entities(self):
coll = "entities"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.entity"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
payment = doc.get("payment", {}) or {}
rec = self.env["encoach.entity"].sudo().create({
"name": doc.get("label", doc.get("name", "Unknown")),
"label": doc.get("label"),
"licenses": doc.get("licenses", 0),
"expiry_date": _dt(doc.get("expiryDate")),
"payment_currency": payment.get("currency"),
"payment_price": payment.get("price", 0),
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.entity", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Entity %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_permissions(self):
coll = "permissions"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.permission"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
rec = self.env["encoach.permission"].sudo().create({
"topic": doc.get("topic", "exam"),
"perm_type": doc.get("type", "read"),
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.permission", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Permission %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_roles(self):
coll = "roles"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.role"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env)
perm_ids = []
for pid in doc.get("permissions", []):
odoo_id = _resolve_legacy("encoach.permission", str(pid), self.env)
if odoo_id:
perm_ids.append(odoo_id)
rec = self.env["encoach.role"].sudo().create({
"name": doc.get("name", "Unknown"),
"entity_id": entity_id or False,
"permission_ids": [(6, 0, perm_ids)],
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.role", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Role %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_users(self):
coll = "users"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
email = doc.get("email", "")
if not email:
self.stats[coll]["skipped"] += 1
continue
existing = self.env["res.users"].sudo().search([
"|", ("login", "=", email), ("legacy_id", "=", lid),
], limit=1)
if existing:
if not existing.legacy_id:
existing.sudo().write({"legacy_id": lid})
LEGACY_ID_CACHE[("res.users", lid)] = existing.id
self.stats[coll]["skipped"] += 1
continue
try:
vals = {
"login": email,
"name": doc.get("name", email.split("@")[0]),
"password": "changeme_migrated",
"encoach_type": doc.get("type", "student"),
"focus": doc.get("focus"),
"levels": doc.get("levels"),
"desired_levels": doc.get("desiredLevels"),
"is_verified": doc.get("isVerified", False),
"bio": doc.get("bio"),
"profile_picture": doc.get("profilePicture"),
"registration_date": _dt(doc.get("registrationDate")),
"subscription_expiration": _dt(doc.get("subscriptionExpirationDate")),
"legacy_id": lid,
}
rec = self.env["res.users"].sudo().create(vals)
LEGACY_ID_CACHE[("res.users", lid)] = rec.id
for ent_info in doc.get("entities", []):
ent_id = ent_info if isinstance(ent_info, str) else ent_info.get("id") if isinstance(ent_info, dict) else str(ent_info)
entity_odoo = _resolve_legacy("encoach.entity", ent_id, self.env)
if entity_odoo:
role_id = None
if isinstance(ent_info, dict) and ent_info.get("role"):
role_id = _resolve_legacy("encoach.role", ent_info["role"], self.env)
self.env["encoach.user.entity.rel"].sudo().create({
"user_id": rec.id,
"entity_id": entity_odoo,
"role_id": role_id or False,
})
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("User %s (%s): %s", lid, email, e)
self.stats[coll]["error"] += 1
def _migrate_codes(self):
coll = "codes"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.code"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
creator_id = _resolve_legacy("res.users", doc.get("creator"), self.env)
entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env)
rec = self.env["encoach.code"].sudo().create({
"code": doc.get("code", lid[:8]),
"entity_id": entity_id or False,
"creator_id": creator_id or self.env.ref("base.user_admin").id,
"expiry_date": _dt(doc.get("expiryDate")),
"code_type": doc.get("type", "individual"),
"max_uses": doc.get("maxUses", 1),
"uses": doc.get("uses", 0),
"user_type": doc.get("userType", "student"),
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.code", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Code %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_invites(self):
coll = "invites"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.invite"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
from_id = _resolve_legacy("res.users", doc.get("from"), self.env)
to_id = _resolve_legacy("res.users", doc.get("to"), self.env)
entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env)
if not (from_id and to_id and entity_id):
self.stats[coll]["skipped"] += 1
continue
rec = self.env["encoach.invite"].sudo().create({
"from_user_id": from_id,
"to_user_id": to_id,
"entity_id": entity_id,
"status": doc.get("status", "pending"),
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.invite", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Invite %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_exams(self):
coll = "exams"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.exam"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
module = doc.get("module", "reading")
owner_ids = []
for oid in doc.get("owners", []):
uid = _resolve_legacy("res.users", str(oid), self.env)
if uid:
owner_ids.append(uid)
entity_ids = []
for eid in doc.get("entities", []):
e_id = _resolve_legacy("encoach.entity", str(eid), self.env)
if e_id:
entity_ids.append(e_id)
rec = self.env["encoach.exam"].sudo().create({
"module": module,
"min_timer": doc.get("minTimer", 0),
"is_diagnostic": doc.get("isDiagnostic", False),
"variant": doc.get("variant"),
"difficulty": doc.get("difficulty"),
"owner_ids": [(6, 0, owner_ids)],
"entity_ids": [(6, 0, entity_ids)],
"shuffle": doc.get("shuffle", False),
"access": doc.get("access", "public"),
"label": doc.get("label", ""),
"requires_approval": doc.get("requiresApproval", False),
"approved": doc.get("approved", False),
"parts": doc.get("parts", []),
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.exam", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Exam %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_groups(self):
coll = "groups"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.group"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
admin_id = _resolve_legacy("res.users", doc.get("admin"), self.env)
participant_ids = []
for pid in doc.get("participants", []):
uid = _resolve_legacy("res.users", str(pid), self.env)
if uid:
participant_ids.append(uid)
entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env)
rec = self.env["encoach.group"].sudo().create({
"name": doc.get("name", "Unnamed"),
"admin_id": admin_id or self.env.ref("base.user_admin").id,
"participant_ids": [(6, 0, participant_ids)],
"disable_editing": doc.get("disableEditing", False),
"entity_id": entity_id or False,
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.group", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Group %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_assignments(self):
coll = "assignments"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.assignment"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
assigner_id = _resolve_legacy("res.users", doc.get("assigner"), self.env)
assignee_ids = [
_resolve_legacy("res.users", str(a), self.env)
for a in doc.get("assignees", [])
]
assignee_ids = [a for a in assignee_ids if a]
exam_ids = [
_resolve_legacy("encoach.exam", str(e), self.env)
for e in doc.get("exams", [])
]
exam_ids = [e for e in exam_ids if e]
teacher_ids = [
_resolve_legacy("res.users", str(t), self.env)
for t in doc.get("teachers", [])
]
teacher_ids = [t for t in teacher_ids if t]
entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env)
rec = self.env["encoach.assignment"].sudo().create({
"name": doc.get("name", "Unnamed"),
"assigner_id": assigner_id or self.env.ref("base.user_admin").id,
"assignee_ids": [(6, 0, assignee_ids)],
"exam_ids": [(6, 0, exam_ids)],
"results": doc.get("results"),
"instructor_gender": doc.get("instructorGender"),
"start_date": _dt(doc.get("startDate")),
"end_date": _dt(doc.get("endDate")),
"teacher_ids": [(6, 0, teacher_ids)],
"archived": doc.get("archived", False),
"released": doc.get("released", False),
"started": doc.get("start", False),
"auto_start": doc.get("autoStart", False),
"entity_id": entity_id or False,
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.assignment", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Assignment %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_sessions(self):
coll = "sessions"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.session"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
user_id = _resolve_legacy("res.users", doc.get("user"), self.env)
if not user_id:
self.stats[coll]["skipped"] += 1
continue
exam_data = {k: v for k, v in doc.items() if k not in ("_id", "user", "__v")}
rec = self.env["encoach.session"].sudo().create({
"user_id": user_id,
"exam_data": exam_data,
"date": _dt(doc.get("date")) or _dt(doc.get("_id").generation_time) if hasattr(doc.get("_id"), "generation_time") else None,
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.session", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Session %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_stats(self):
coll = "stats"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.stat"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
user_id = _resolve_legacy("res.users", doc.get("user"), self.env)
if not user_id:
self.stats[coll]["skipped"] += 1
continue
exam_id = _resolve_legacy("encoach.exam", doc.get("exam"), self.env)
session_id = _resolve_legacy("encoach.session", doc.get("session"), self.env)
assignment_id = _resolve_legacy("encoach.assignment", doc.get("assignment"), self.env)
score = doc.get("score", {})
rec = self.env["encoach.stat"].sudo().create({
"user_id": user_id,
"exam_id": exam_id or False,
"exercise": doc.get("exercise"),
"session_id": session_id or False,
"date": _dt(doc.get("date")),
"module": doc.get("module"),
"solutions": doc.get("solutions"),
"stat_type": doc.get("type"),
"time_spent": doc.get("timeSpent", 0),
"inactivity": doc.get("inactivity", 0),
"assignment_id": assignment_id or False,
"score_correct": score.get("correct", 0) if isinstance(score, dict) else 0,
"score_total": score.get("total", 0) if isinstance(score, dict) else 0,
"score_missing": score.get("missing", 0) if isinstance(score, dict) else 0,
"is_disabled": doc.get("isDisabled", False),
"shuffle_maps": doc.get("shuffleMaps"),
"is_practice": doc.get("isPractice", False),
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.stat", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Stat %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_evaluations(self):
coll = "evaluations"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.evaluation"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
user_id = _resolve_legacy("res.users", doc.get("user"), self.env)
rec = self.env["encoach.evaluation"].sudo().create({
"user_id": user_id or False,
"exercise_id": doc.get("exerciseId"),
"exercise_type": doc.get("exerciseType"),
"status": doc.get("status", "completed"),
"result": doc.get("result"),
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.evaluation", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Evaluation %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_trainings(self):
coll = "trainings"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.training"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
user_id = _resolve_legacy("res.users", doc.get("user"), self.env)
rec = self.env["encoach.training"].sudo().create({
"user_id": user_id or self.env.ref("base.user_admin").id,
"exams": doc.get("exams"),
"tips": doc.get("tips"),
"weak_areas": doc.get("weakAreas"),
"legacy_id": lid,
})
LEGACY_ID_CACHE[("encoach.training", lid)] = rec.id
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Training %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_tips(self):
coll = "tips"
_logger.info("Migrating %s (from training.tip collection)...", coll)
for doc in self.db.get_collection("training_tips").find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.training.tip"].sudo().search([("tip_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
self.env["encoach.training.tip"].sudo().create({
"tip_id": lid,
"category": doc.get("category"),
"content": doc.get("content", ""),
})
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Tip %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_walkthroughs(self):
coll = "walkthroughs"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
try:
self.env["encoach.walkthrough"].sudo().create({
"name": doc.get("name", ""),
"content": doc.get("content"),
"module": doc.get("module"),
})
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Walkthrough: %s", e)
self.stats[coll]["error"] += 1
def _migrate_packages(self):
coll = "packages"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
try:
self.env["encoach.package"].sudo().create({
"name": doc.get("name", "Package"),
"price": doc.get("price", 0),
"currency": doc.get("currency", "USD"),
"duration_days": doc.get("duration", 30),
"features": doc.get("features"),
"active": doc.get("active", True),
})
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Package: %s", e)
self.stats[coll]["error"] += 1
def _migrate_payments(self):
coll = "payments"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
try:
user_id = _resolve_legacy("res.users", doc.get("user"), self.env)
entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env)
self.env["encoach.subscription.payment"].sudo().create({
"user_id": user_id or False,
"provider": doc.get("provider", "stripe"),
"status": doc.get("status", "completed"),
"amount": doc.get("amount", 0),
"currency": doc.get("currency", "USD"),
"transaction_id": doc.get("transactionId"),
"entity_id": entity_id or False,
"legacy_id": lid,
})
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Payment %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_discounts(self):
coll = "discounts"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
try:
self.env["encoach.discount"].sudo().create({
"code": doc.get("code", ""),
"percentage": doc.get("percentage", 0),
"expiry_date": _dt(doc.get("expiryDate")),
"max_uses": doc.get("maxUses", 0),
"uses": doc.get("uses", 0),
})
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Discount: %s", e)
self.stats[coll]["error"] += 1
def _migrate_tickets(self):
coll = "tickets"
_logger.info("Migrating %s...", coll)
for doc in self.db[coll].find():
self.stats[coll]["total"] += 1
lid = _oid(doc)
if self.env["encoach.ticket"].sudo().search([("legacy_id", "=", lid)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
reporter = doc.get("reporter", {})
assigned_id = _resolve_legacy("res.users", doc.get("assignedTo"), self.env)
self.env["encoach.ticket"].sudo().create({
"reporter_name": reporter.get("name") if isinstance(reporter, dict) else None,
"reporter_email": reporter.get("email") if isinstance(reporter, dict) else None,
"subject": doc.get("subject", ""),
"description": doc.get("description", ""),
"ticket_type": doc.get("type", "help"),
"status": doc.get("status", "open"),
"exam_information": doc.get("examInformation"),
"assigned_to_id": assigned_id or False,
"legacy_id": lid,
})
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Ticket %s: %s", lid, e)
self.stats[coll]["error"] += 1
def _migrate_avatars(self):
coll = "avatars"
_logger.info("Migrating %s...", coll)
for doc in self.db.get_collection("avatars").find():
self.stats[coll]["total"] += 1
code = doc.get("code", doc.get("avatar_code", ""))
if self.env["encoach.elai.avatar"].sudo().search([("avatar_code", "=", code)], limit=1):
self.stats[coll]["skipped"] += 1
continue
try:
self.env["encoach.elai.avatar"].sudo().create({
"name": doc.get("name", code),
"avatar_code": code,
"avatar_url": doc.get("url", doc.get("avatar_url", "")),
"gender": doc.get("gender"),
"canvas": doc.get("canvas"),
"voice_id": doc.get("voice", {}).get("voiceId") if isinstance(doc.get("voice"), dict) else doc.get("voice_id"),
"voice_provider": doc.get("voice", {}).get("voiceProvider") if isinstance(doc.get("voice"), dict) else doc.get("voice_provider"),
})
self.stats[coll]["created"] += 1
except Exception as e:
_logger.error("Avatar %s: %s", code, e)
self.stats[coll]["error"] += 1
def _print_summary(self):
_logger.info("\n========== MIGRATION SUMMARY ==========")
total_created = 0
total_skipped = 0
total_errors = 0
for coll, s in self.stats.items():
_logger.info(
" %-15s total=%4d created=%4d skipped=%4d errors=%4d",
coll, s["total"], s["created"], s["skipped"], s["error"],
)
total_created += s["created"]
total_skipped += s["skipped"]
total_errors += s["error"]
_logger.info(" %-15s total=%4d created=%4d skipped=%4d errors=%4d",
"TOTAL", total_created + total_skipped + total_errors,
total_created, total_skipped, total_errors)
_logger.info("========================================\n")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Migrate EnCoach MongoDB to Odoo")
parser.add_argument("--mongo-uri", default="mongodb://localhost:27017")
parser.add_argument("--mongo-db", default="encoach")
args = parser.parse_args()
_logger.info("Standalone mode -- connect to Odoo via xmlrpc or run inside Odoo shell")
_logger.info("Example: ./odoo-bin shell -c odoo.conf -d encoach_prod")
_logger.info("Then: exec(open('path/to/migrate_mongodb.py').read())")
else:
try:
env # noqa: F821 -- available in Odoo shell
migrator = EncoachMigrator("mongodb://localhost:27017", "encoach", env)
migrator.run()
except NameError:
pass

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
Rebuild FAISS indices for EnCoach training tips.
After data migration, this script re-embeds all training tips using
sentence-transformers and builds per-category FAISS indices.
Usage (Odoo shell):
./odoo-bin shell -c odoo.conf -d encoach_prod
>>> exec(open('../addons_extra/encoach_core/scripts/rebuild_faiss.py').read())
Requires: faiss-cpu, sentence-transformers
"""
import logging
import os
import pickle
import numpy as np
_logger = logging.getLogger("encoach.faiss_rebuild")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
try:
import faiss
from sentence_transformers import SentenceTransformer
except ImportError:
_logger.error("Install: pip install faiss-cpu sentence-transformers")
faiss = None
MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
EMBEDDING_DIM = 384
FAISS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "encoach_training", "data", "faiss")
CATEGORIES = [
"ct_focus",
"language_for_writing",
"reading_skill",
"strategy",
"writing_skill",
"word_link",
"word_partners",
]
def rebuild_faiss_indices(env, output_dir=None):
if not faiss:
return
output_dir = output_dir or FAISS_DIR
os.makedirs(output_dir, exist_ok=True)
_logger.info("Loading sentence-transformers model: %s", MODEL_NAME)
model = SentenceTransformer(MODEL_NAME)
for category in CATEGORIES:
tips = env["encoach.training.tip"].sudo().search([("category", "=", category)])
if not tips:
_logger.info(" %s: no tips, skipping", category)
continue
contents = [t.content for t in tips]
tip_ids = [t.tip_id for t in tips]
_logger.info(" %s: encoding %d tips...", category, len(contents))
embeddings = model.encode(contents, show_progress_bar=False, convert_to_numpy=True)
embeddings = embeddings.astype(np.float32)
for tip, emb in zip(tips, embeddings):
tip.sudo().write({"embedding": pickle.dumps(emb)})
index = faiss.IndexFlatL2(EMBEDDING_DIM)
index.add(embeddings)
index_path = os.path.join(output_dir, f"{category}.index")
faiss.write_index(index, index_path)
ids_path = os.path.join(output_dir, f"{category}_ids.pkl")
with open(ids_path, "wb") as f:
pickle.dump(tip_ids, f)
_logger.info(" %s: indexed %d tips -> %s", category, len(tips), index_path)
_logger.info("FAISS rebuild complete. Indices saved to %s", output_dir)
try:
env # noqa: F821
rebuild_faiss_indices(env)
except NameError:
_logger.info("Run this script inside Odoo shell")