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

1
encoach_core/__init__.py Normal file
View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,25 @@
{
"name": "EnCoach Core",
"version": "19.0.1.0.0",
"category": "Education",
"summary": "Core models for the EnCoach IELTS platform",
"description": "User type extensions, entity management, roles, permissions, codes, invites.",
"depends": ["base", "mail"],
"data": [
"security/security.xml",
"security/ir.model.access.csv",
"data/constants.xml",
"views/menu.xml",
"views/entity_views.xml",
"views/role_views.xml",
"views/permission_views.xml",
"views/code_views.xml",
"views/invite_views.xml",
"views/user_entity_rel_views.xml",
"views/res_users_views.xml",
"data/assign_admin_group.xml",
],
"installable": True,
"application": True,
"license": "LGPL-3",
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="base.user_admin" model="res.users" context="{'no_reset_password': True}">
<field name="group_ids" eval="[
(4, ref('encoach_core.group_encoach_developer')),
]"/>
</record>
</odoo>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- JWT signing secret -->
<record id="param_jwt_secret" model="ir.config_parameter">
<field name="key">encoach.jwt_secret</field>
<field name="value">CHANGE_ME_IN_PRODUCTION_RANDOM_256_BIT_STRING</field>
</record>
<record id="param_jwt_expiry_hours" model="ir.config_parameter">
<field name="key">encoach.jwt_expiry_hours</field>
<field name="value">72</field>
</record>
<!-- AI service keys (placeholders) -->
<record id="param_openai_key" model="ir.config_parameter">
<field name="key">encoach.openai_api_key</field>
<field name="value"></field>
</record>
<record id="param_aws_access_key" model="ir.config_parameter">
<field name="key">encoach.aws_access_key_id</field>
<field name="value"></field>
</record>
<record id="param_aws_secret_key" model="ir.config_parameter">
<field name="key">encoach.aws_secret_access_key</field>
<field name="value"></field>
</record>
<record id="param_elai_token" model="ir.config_parameter">
<field name="key">encoach.elai_token</field>
<field name="value"></field>
</record>
<record id="param_gptzero_key" model="ir.config_parameter">
<field name="key">encoach.gptzero_api_key</field>
<field name="value"></field>
</record>
<record id="param_stripe_secret" model="ir.config_parameter">
<field name="key">encoach.stripe_secret_key</field>
<field name="value"></field>
</record>
<record id="param_stripe_webhook" model="ir.config_parameter">
<field name="key">encoach.stripe_webhook_secret</field>
<field name="value"></field>
</record>
<record id="param_paypal_client" model="ir.config_parameter">
<field name="key">encoach.paypal_client_id</field>
<field name="value"></field>
</record>
<record id="param_paypal_secret" model="ir.config_parameter">
<field name="key">encoach.paypal_client_secret</field>
<field name="value"></field>
</record>
<record id="param_paymob_key" model="ir.config_parameter">
<field name="key">encoach.paymob_api_key</field>
<field name="value"></field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,7 @@
from . import res_users
from . import entity
from . import role
from . import permission
from . import code
from . import invite
from . import user_entity_rel

View File

@@ -0,0 +1,57 @@
import uuid
from odoo import models, fields, api
class EncoachCode(models.Model):
_name = "encoach.code"
_description = "EnCoach Registration Code"
code = fields.Char(required=True, index=True, default=lambda self: uuid.uuid4().hex[:8].upper())
entity_id = fields.Many2one("encoach.entity", ondelete="set null")
creator_id = fields.Many2one("res.users", required=True)
expiry_date = fields.Datetime()
code_type = fields.Selection(
[("individual", "Individual"), ("corporate", "Corporate")],
default="individual",
)
max_uses = fields.Integer(default=1)
uses = fields.Integer(default=0)
user_type = fields.Selection(
[
("student", "Student"),
("teacher", "Teacher"),
("corporate", "Corporate"),
],
default="student",
)
legacy_id = fields.Char(index=True)
@api.model
def validate_code(self, code_str):
rec = self.search([("code", "=", code_str)], limit=1)
if not rec:
return {"valid": False, "error": "Code not found"}
if rec.expiry_date and rec.expiry_date < fields.Datetime.now():
return {"valid": False, "error": "Code expired"}
if rec.max_uses and rec.uses >= rec.max_uses:
return {"valid": False, "error": "Code fully used"}
return {
"valid": True,
"code": rec.to_encoach_dict(),
}
def to_encoach_dict(self):
self.ensure_one()
return {
"id": self.id,
"code": self.code,
"entity": self.entity_id.id if self.entity_id else None,
"creator": self.creator_id.id,
"expiryDate": (
self.expiry_date.isoformat() if self.expiry_date else None
),
"type": self.code_type,
"maxUses": self.max_uses,
"uses": self.uses,
"userType": self.user_type,
}

View File

@@ -0,0 +1,37 @@
from odoo import models, fields
class EncoachEntity(models.Model):
_name = "encoach.entity"
_description = "EnCoach Entity (Organization)"
_inherit = ["mail.thread"]
name = fields.Char(required=True, tracking=True)
label = fields.Char()
licenses = fields.Integer(default=0)
expiry_date = fields.Datetime()
payment_currency = fields.Char()
payment_price = fields.Float(digits=(12, 2))
role_ids = fields.One2many("encoach.role", "entity_id", string="Roles")
user_rel_ids = fields.One2many(
"encoach.user.entity.rel", "entity_id", string="Members",
)
legacy_id = fields.Char(index=True)
def to_encoach_dict(self):
self.ensure_one()
payment = None
if self.payment_currency and self.payment_price:
payment = {
"currency": self.payment_currency,
"price": self.payment_price,
}
return {
"id": self.id,
"label": self.label or self.name,
"licenses": self.licenses,
"expiryDate": (
self.expiry_date.isoformat() if self.expiry_date else None
),
"payment": payment,
}

View File

@@ -0,0 +1,46 @@
from odoo import models, fields
class EncoachInvite(models.Model):
_name = "encoach.invite"
_description = "EnCoach Entity Invite"
from_user_id = fields.Many2one("res.users", required=True, ondelete="cascade")
to_user_id = fields.Many2one("res.users", required=True, ondelete="cascade")
entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade")
status = fields.Selection(
[
("pending", "Pending"),
("accepted", "Accepted"),
("declined", "Declined"),
],
default="pending",
)
legacy_id = fields.Char(index=True)
def action_accept(self):
self.ensure_one()
self.status = "accepted"
existing = self.env["encoach.user.entity.rel"].search([
("user_id", "=", self.to_user_id.id),
("entity_id", "=", self.entity_id.id),
], limit=1)
if not existing:
self.env["encoach.user.entity.rel"].create({
"user_id": self.to_user_id.id,
"entity_id": self.entity_id.id,
})
def action_decline(self):
self.ensure_one()
self.status = "declined"
def to_encoach_dict(self):
self.ensure_one()
return {
"id": self.id,
"from": self.from_user_id.id,
"to": self.to_user_id.id,
"entity": self.entity_id.id,
"status": self.status,
}

View File

@@ -0,0 +1,45 @@
from odoo import models, fields
PERMISSION_TOPICS = [
("exam", "Exam Management"),
("user", "User Management"),
("entity", "Entity Management"),
("group", "Group Management"),
("assignment", "Assignment Management"),
("code", "Code Management"),
("invite", "Invite Management"),
("payment", "Payment Management"),
("ticket", "Ticket Management"),
("approval", "Approval Workflow"),
("stats", "Statistics"),
("training", "Training"),
]
PERMISSION_TYPES = [
("create", "Create"),
("read", "Read"),
("update", "Update"),
("delete", "Delete"),
]
class EncoachPermission(models.Model):
_name = "encoach.permission"
_description = "EnCoach Permission"
topic = fields.Selection(PERMISSION_TOPICS, required=True)
perm_type = fields.Selection(PERMISSION_TYPES, required=True)
legacy_id = fields.Char(index=True)
_topic_type_unique = models.Constraint(
"UNIQUE(topic, perm_type)",
"Permission topic/type combination must be unique.",
)
def to_encoach_dict(self):
self.ensure_one()
return {
"id": self.id,
"topic": self.topic,
"type": self.perm_type,
}

View File

@@ -0,0 +1,92 @@
from odoo import models, fields, api
USER_TYPES = [
("student", "Student"),
("teacher", "Teacher"),
("corporate", "Corporate"),
("admin", "Admin"),
("developer", "Developer"),
("agent", "Agent"),
("mastercorporate", "Master Corporate"),
]
DEFAULT_LEVELS = {"reading": 0, "listening": 0, "writing": 0, "speaking": 0}
class ResUsers(models.Model):
_inherit = "res.users"
encoach_type = fields.Selection(
USER_TYPES, string="EnCoach User Type", default="student",
)
focus = fields.Char()
levels = fields.Json(default=DEFAULT_LEVELS)
desired_levels = fields.Json(default=DEFAULT_LEVELS)
is_verified = fields.Boolean(default=False)
subscription_expiration = fields.Datetime()
registration_date = fields.Datetime(default=fields.Datetime.now)
bio = fields.Text()
profile_picture = fields.Char()
is_first_login = fields.Boolean(default=True)
last_login = fields.Datetime()
encoach_status = fields.Selection(
[("active", "Active"), ("inactive", "Inactive"), ("suspended", "Suspended")],
string="EnCoach Status",
default="active",
)
entity_rel_ids = fields.One2many(
"encoach.user.entity.rel", "user_id", string="Entity Memberships",
)
legacy_id = fields.Char(index=True)
def to_encoach_dict(self):
"""Serialize to the JSON shape the frontend expects."""
self.ensure_one()
entities = []
for rel in self.entity_rel_ids:
entities.append({
"id": rel.entity_id.id,
"role": rel.role_id.name if rel.role_id else None,
})
return {
"id": self.id,
"email": self.login,
"name": self.name,
"profilePicture": self.profile_picture or "",
"isFirstLogin": self.is_first_login,
"focus": self.focus or "",
"levels": self.levels or DEFAULT_LEVELS,
"desiredLevels": self.desired_levels or DEFAULT_LEVELS,
"type": self.encoach_type,
"bio": self.bio or "",
"isVerified": self.is_verified,
"subscriptionExpirationDate": (
self.subscription_expiration.isoformat()
if self.subscription_expiration
else None
),
"registrationDate": (
self.registration_date.isoformat()
if self.registration_date
else None
),
"status": self.encoach_status,
"lastLogin": (
self.last_login.isoformat() if self.last_login else None
),
"entities": entities,
"permissions": self._get_encoach_permissions(),
}
def _get_encoach_permissions(self):
self.ensure_one()
perms = {}
for rel in self.entity_rel_ids:
if rel.role_id:
for perm in rel.role_id.permission_ids:
key = perm.topic
if key not in perms:
perms[key] = []
if perm.perm_type not in perms[key]:
perms[key].append(perm.perm_type)
return perms

View File

@@ -0,0 +1,20 @@
from odoo import models, fields
class EncoachRole(models.Model):
_name = "encoach.role"
_description = "EnCoach Role"
name = fields.Char(required=True)
entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade")
permission_ids = fields.Many2many("encoach.permission", string="Permissions")
legacy_id = fields.Char(index=True)
def to_encoach_dict(self):
self.ensure_one()
return {
"id": self.id,
"name": self.name,
"entity": self.entity_id.id,
"permissions": [p.to_encoach_dict() for p in self.permission_ids],
}

View File

@@ -0,0 +1,15 @@
from odoo import models, fields
class EncoachUserEntityRel(models.Model):
_name = "encoach.user.entity.rel"
_description = "EnCoach User-Entity Membership"
user_id = fields.Many2one("res.users", required=True, ondelete="cascade")
entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade")
role_id = fields.Many2one("encoach.role", ondelete="set null")
_user_entity_unique = models.Constraint(
"UNIQUE(user_id, entity_id)",
"A user can only have one membership per entity.",
)

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")

View File

@@ -0,0 +1,21 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_entity_student,encoach.entity.student,model_encoach_entity,group_encoach_student,1,0,0,0
access_encoach_entity_admin,encoach.entity.admin,model_encoach_entity,group_encoach_admin,1,1,1,1
access_encoach_role_student,encoach.role.student,model_encoach_role,group_encoach_student,1,0,0,0
access_encoach_role_admin,encoach.role.admin,model_encoach_role,group_encoach_admin,1,1,1,1
access_encoach_permission_student,encoach.permission.student,model_encoach_permission,group_encoach_student,1,0,0,0
access_encoach_permission_admin,encoach.permission.admin,model_encoach_permission,group_encoach_admin,1,1,1,1
access_encoach_code_student,encoach.code.student,model_encoach_code,group_encoach_student,1,0,0,0
access_encoach_code_teacher,encoach.code.teacher,model_encoach_code,group_encoach_teacher,1,1,1,0
access_encoach_code_admin,encoach.code.admin,model_encoach_code,group_encoach_admin,1,1,1,1
access_encoach_invite_student,encoach.invite.student,model_encoach_invite,group_encoach_student,1,1,0,0
access_encoach_invite_teacher,encoach.invite.teacher,model_encoach_invite,group_encoach_teacher,1,1,1,0
access_encoach_invite_admin,encoach.invite.admin,model_encoach_invite,group_encoach_admin,1,1,1,1
access_encoach_user_entity_rel_student,encoach.user.entity.rel.student,model_encoach_user_entity_rel,group_encoach_student,1,0,0,0
access_encoach_user_entity_rel_admin,encoach.user.entity.rel.admin,model_encoach_user_entity_rel,group_encoach_admin,1,1,1,1
access_encoach_entity_sysadmin,encoach.entity.sysadmin,model_encoach_entity,base.group_system,1,1,1,1
access_encoach_role_sysadmin,encoach.role.sysadmin,model_encoach_role,base.group_system,1,1,1,1
access_encoach_permission_sysadmin,encoach.permission.sysadmin,model_encoach_permission,base.group_system,1,1,1,1
access_encoach_code_sysadmin,encoach.code.sysadmin,model_encoach_code,base.group_system,1,1,1,1
access_encoach_invite_sysadmin,encoach.invite.sysadmin,model_encoach_invite,base.group_system,1,1,1,1
access_encoach_user_entity_rel_sysadmin,encoach.user.entity.rel.sysadmin,model_encoach_user_entity_rel,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_entity_student encoach.entity.student model_encoach_entity group_encoach_student 1 0 0 0
3 access_encoach_entity_admin encoach.entity.admin model_encoach_entity group_encoach_admin 1 1 1 1
4 access_encoach_role_student encoach.role.student model_encoach_role group_encoach_student 1 0 0 0
5 access_encoach_role_admin encoach.role.admin model_encoach_role group_encoach_admin 1 1 1 1
6 access_encoach_permission_student encoach.permission.student model_encoach_permission group_encoach_student 1 0 0 0
7 access_encoach_permission_admin encoach.permission.admin model_encoach_permission group_encoach_admin 1 1 1 1
8 access_encoach_code_student encoach.code.student model_encoach_code group_encoach_student 1 0 0 0
9 access_encoach_code_teacher encoach.code.teacher model_encoach_code group_encoach_teacher 1 1 1 0
10 access_encoach_code_admin encoach.code.admin model_encoach_code group_encoach_admin 1 1 1 1
11 access_encoach_invite_student encoach.invite.student model_encoach_invite group_encoach_student 1 1 0 0
12 access_encoach_invite_teacher encoach.invite.teacher model_encoach_invite group_encoach_teacher 1 1 1 0
13 access_encoach_invite_admin encoach.invite.admin model_encoach_invite group_encoach_admin 1 1 1 1
14 access_encoach_user_entity_rel_student encoach.user.entity.rel.student model_encoach_user_entity_rel group_encoach_student 1 0 0 0
15 access_encoach_user_entity_rel_admin encoach.user.entity.rel.admin model_encoach_user_entity_rel group_encoach_admin 1 1 1 1
16 access_encoach_entity_sysadmin encoach.entity.sysadmin model_encoach_entity base.group_system 1 1 1 1
17 access_encoach_role_sysadmin encoach.role.sysadmin model_encoach_role base.group_system 1 1 1 1
18 access_encoach_permission_sysadmin encoach.permission.sysadmin model_encoach_permission base.group_system 1 1 1 1
19 access_encoach_code_sysadmin encoach.code.sysadmin model_encoach_code base.group_system 1 1 1 1
20 access_encoach_invite_sysadmin encoach.invite.sysadmin model_encoach_invite base.group_system 1 1 1 1
21 access_encoach_user_entity_rel_sysadmin encoach.user.entity.rel.sysadmin model_encoach_user_entity_rel base.group_system 1 1 1 1

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- Security groups matching the 7 user types -->
<record id="group_encoach_student" model="res.groups">
<field name="name">EnCoach Student</field>
</record>
<record id="group_encoach_teacher" model="res.groups">
<field name="name">EnCoach Teacher</field>
</record>
<record id="group_encoach_corporate" model="res.groups">
<field name="name">EnCoach Corporate</field>
</record>
<record id="group_encoach_admin" model="res.groups">
<field name="name">EnCoach Admin</field>
<field name="implied_ids" eval="[(4, ref('group_encoach_teacher'))]"/>
</record>
<record id="group_encoach_developer" model="res.groups">
<field name="name">EnCoach Developer</field>
<field name="implied_ids" eval="[(4, ref('group_encoach_admin'))]"/>
</record>
<record id="group_encoach_agent" model="res.groups">
<field name="name">EnCoach Agent</field>
</record>
<record id="group_encoach_mastercorporate" model="res.groups">
<field name="name">EnCoach Master Corporate</field>
<field name="implied_ids" eval="[(4, ref('group_encoach_corporate'))]"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_encoach_code_tree" model="ir.ui.view">
<field name="name">encoach.code.tree</field>
<field name="model">encoach.code</field>
<field name="arch" type="xml">
<list string="Registration Codes" editable="bottom">
<field name="code"/>
<field name="entity_id"/>
<field name="creator_id"/>
<field name="code_type"/>
<field name="user_type"/>
<field name="max_uses"/>
<field name="uses"/>
<field name="expiry_date"/>
</list>
</field>
</record>
<record id="view_encoach_code_form" model="ir.ui.view">
<field name="name">encoach.code.form</field>
<field name="model">encoach.code</field>
<field name="arch" type="xml">
<form string="Registration Code">
<sheet>
<group>
<group>
<field name="code" readonly="id"/>
<field name="entity_id"/>
<field name="creator_id"/>
<field name="expiry_date"/>
</group>
<group>
<field name="code_type"/>
<field name="user_type"/>
<field name="max_uses"/>
<field name="uses"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_encoach_code" model="ir.actions.act_window">
<field name="name">Registration Codes</field>
<field name="res_model">encoach.code</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first registration code.
</p>
<p>
Registration codes allow users to join entities or register
with a specific user type.
</p>
</field>
</record>
<menuitem id="menu_encoach_code"
name="Registration Codes"
parent="encoach_core.menu_encoach_users"
action="action_encoach_code"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_encoach_entity_tree" model="ir.ui.view">
<field name="name">encoach.entity.tree</field>
<field name="model">encoach.entity</field>
<field name="arch" type="xml">
<list string="Entities" editable="bottom">
<field name="name"/>
<field name="label"/>
<field name="licenses"/>
<field name="expiry_date"/>
<field name="payment_currency"/>
<field name="payment_price"/>
</list>
</field>
</record>
<record id="view_encoach_entity_form" model="ir.ui.view">
<field name="name">encoach.entity.form</field>
<field name="model">encoach.entity</field>
<field name="arch" type="xml">
<form string="Entity">
<sheet>
<group>
<group>
<field name="name"/>
<field name="label"/>
</group>
<group>
<field name="licenses"/>
<field name="expiry_date"/>
</group>
<group>
<field name="payment_currency"/>
<field name="payment_price"/>
</group>
</group>
<notebook>
<page string="Roles" name="roles">
<field name="role_ids">
<list editable="bottom">
<field name="name"/>
<field name="permission_ids" widget="many2many_tags"/>
</list>
</field>
</page>
<page string="Members" name="members">
<field name="user_rel_ids">
<list editable="bottom">
<field name="user_id"/>
<field name="role_id"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_encoach_entity" model="ir.actions.act_window">
<field name="name">Entities</field>
<field name="res_model">encoach.entity</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first entity (organization).
</p>
<p>
Entities represent organizations such as schools or companies.
Each entity has licenses, members, and roles.
</p>
</field>
</record>
<menuitem id="menu_encoach_entity"
name="Entities"
parent="encoach_core.menu_encoach_users"
action="action_encoach_entity"
sequence="10"/>
</odoo>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_encoach_invite_tree" model="ir.ui.view">
<field name="name">encoach.invite.tree</field>
<field name="model">encoach.invite</field>
<field name="arch" type="xml">
<list string="Invites" editable="bottom">
<field name="from_user_id"/>
<field name="to_user_id"/>
<field name="entity_id"/>
<field name="status"/>
</list>
</field>
</record>
<record id="view_encoach_invite_form" model="ir.ui.view">
<field name="name">encoach.invite.form</field>
<field name="model">encoach.invite</field>
<field name="arch" type="xml">
<form string="Invite">
<header>
<button name="action_accept" string="Accept" type="object"
class="btn-primary" invisible="status != 'pending'"/>
<button name="action_decline" string="Decline" type="object"
invisible="status != 'pending'"/>
</header>
<sheet>
<group>
<field name="from_user_id"/>
<field name="to_user_id"/>
<field name="entity_id"/>
<field name="status"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_encoach_invite" model="ir.actions.act_window">
<field name="name">Invites</field>
<field name="res_model">encoach.invite</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No invites yet.
</p>
<p>
Invites allow users to join entities.
Pending invites can be accepted or declined.
</p>
</field>
</record>
<menuitem id="menu_encoach_invite"
name="Invites"
parent="encoach_core.menu_encoach_users"
action="action_encoach_invite"
sequence="30"/>
</odoo>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<menuitem id="menu_encoach_root" name="EnCoach" sequence="100"/>
<menuitem id="menu_encoach_users" name="Users &amp; Entities" parent="menu_encoach_root" sequence="10"/>
<menuitem id="menu_encoach_exams" name="Exams" parent="menu_encoach_root" sequence="20"/>
<menuitem id="menu_encoach_classroom" name="Classroom" parent="menu_encoach_root" sequence="30"/>
<menuitem id="menu_encoach_ai" name="AI &amp; Grading" parent="menu_encoach_root" sequence="40"/>
<menuitem id="menu_encoach_training" name="Training" parent="menu_encoach_root" sequence="50"/>
<menuitem id="menu_encoach_subscription" name="Subscriptions" parent="menu_encoach_root" sequence="60"/>
<menuitem id="menu_encoach_support" name="Support" parent="menu_encoach_root" sequence="70"/>
<menuitem id="menu_encoach_config" name="Configuration" parent="menu_encoach_root" sequence="90"/>
</odoo>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_encoach_permission_tree" model="ir.ui.view">
<field name="name">encoach.permission.tree</field>
<field name="model">encoach.permission</field>
<field name="arch" type="xml">
<list string="Permissions" editable="bottom">
<field name="topic"/>
<field name="perm_type"/>
</list>
</field>
</record>
<record id="view_encoach_permission_form" model="ir.ui.view">
<field name="name">encoach.permission.form</field>
<field name="model">encoach.permission</field>
<field name="arch" type="xml">
<form string="Permission">
<sheet>
<group>
<field name="topic"/>
<field name="perm_type"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_encoach_permission" model="ir.actions.act_window">
<field name="name">Permissions</field>
<field name="res_model">encoach.permission</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first permission.
</p>
<p>
Permissions define what actions can be performed on each topic.
Assign permissions to roles.
</p>
</field>
</record>
<menuitem id="menu_encoach_permission"
name="Permissions"
parent="encoach_core.menu_encoach_config"
action="action_encoach_permission"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_res_users_form_encoach" model="ir.ui.view">
<field name="name">res.users.form.encoach</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="EnCoach" name="encoach">
<group>
<group>
<field name="encoach_type"/>
<field name="focus"/>
<field name="levels" widget="json"/>
<field name="desired_levels" widget="json"/>
<field name="is_verified"/>
<field name="subscription_expiration"/>
<field name="registration_date"/>
</group>
<group>
<field name="bio"/>
<field name="profile_picture"/>
<field name="encoach_status"/>
<field name="is_first_login"/>
<field name="last_login"/>
<field name="legacy_id"/>
</group>
</group>
<field name="entity_rel_ids">
<list editable="bottom">
<field name="entity_id"/>
<field name="role_id"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_encoach_role_tree" model="ir.ui.view">
<field name="name">encoach.role.tree</field>
<field name="model">encoach.role</field>
<field name="arch" type="xml">
<list string="Roles" editable="bottom">
<field name="name"/>
<field name="entity_id"/>
</list>
</field>
</record>
<record id="view_encoach_role_form" model="ir.ui.view">
<field name="name">encoach.role.form</field>
<field name="model">encoach.role</field>
<field name="arch" type="xml">
<form string="Role">
<sheet>
<group>
<field name="name"/>
<field name="entity_id"/>
<field name="permission_ids" widget="many2many_checkboxes"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_encoach_role" model="ir.actions.act_window">
<field name="name">Roles</field>
<field name="res_model">encoach.role</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first role.
</p>
<p>
Roles define permissions within an entity.
Assign permissions to roles to control what users can do.
</p>
</field>
</record>
<menuitem id="menu_encoach_role"
name="Roles"
parent="encoach_core.menu_encoach_config"
action="action_encoach_role"
sequence="10"/>
</odoo>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_encoach_user_entity_rel_tree" model="ir.ui.view">
<field name="name">encoach.user.entity.rel.tree</field>
<field name="model">encoach.user.entity.rel</field>
<field name="arch" type="xml">
<list string="User-Entity Memberships" editable="bottom">
<field name="user_id"/>
<field name="entity_id"/>
<field name="role_id"/>
</list>
</field>
</record>
<record id="view_encoach_user_entity_rel_form" model="ir.ui.view">
<field name="name">encoach.user.entity.rel.form</field>
<field name="model">encoach.user.entity.rel</field>
<field name="arch" type="xml">
<form string="User-Entity Membership">
<sheet>
<group>
<field name="user_id"/>
<field name="entity_id"/>
<field name="role_id"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_encoach_user_entity_rel" model="ir.actions.act_window">
<field name="name">User-Entity Memberships</field>
<field name="res_model">encoach.user.entity.rel</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No memberships yet.
</p>
<p>
User-entity memberships link users to entities with optional roles.
</p>
</field>
</record>
<menuitem id="menu_encoach_user_entity_rel"
name="Memberships"
parent="encoach_core.menu_encoach_users"
action="action_encoach_user_entity_rel"
sequence="40"/>
</odoo>