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:
69
encoach_core/scripts/import_avatars.py
Normal file
69
encoach_core/scripts/import_avatars.py
Normal 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")
|
||||
98
encoach_core/scripts/migrate_firebase_auth.py
Normal file
98
encoach_core/scripts/migrate_firebase_auth.py
Normal 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")
|
||||
731
encoach_core/scripts/migrate_mongodb.py
Normal file
731
encoach_core/scripts/migrate_mongodb.py
Normal 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
|
||||
90
encoach_core/scripts/rebuild_faiss.py
Normal file
90
encoach_core/scripts/rebuild_faiss.py
Normal 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")
|
||||
Reference in New Issue
Block a user