EnCoach Odoo 19 custom modules

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

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

View File

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

View File

@@ -0,0 +1,17 @@
{
"name": "EnCoach Training",
"version": "19.0.1.0.0",
"category": "Education",
"summary": "Personalized training content and tips for EnCoach",
"description": "FAISS-powered tip retrieval, GPT personalized training, walkthroughs.",
"depends": ["encoach_core", "encoach_ai"],
"data": [
"security/ir.model.access.csv",
"security/rules.xml",
"views/training_views.xml",
"views/tip_views.xml",
"views/walkthrough_views.xml",
],
"installable": True,
"license": "LGPL-3",
}

View File

@@ -0,0 +1,3 @@
from . import training
from . import training_tip
from . import walkthrough

View File

@@ -0,0 +1,28 @@
from odoo import models, fields
class EncoachTraining(models.Model):
_name = "encoach.training"
_description = "EnCoach Training Record"
user_id = fields.Many2one(
"res.users", string="User", required=True, ondelete="cascade",
)
exams = fields.Json()
tips = fields.Json()
weak_areas = fields.Json()
created_at = fields.Datetime(default=fields.Datetime.now)
legacy_id = fields.Char(index=True)
def to_encoach_dict(self):
self.ensure_one()
return {
"id": self.id,
"userId": self.user_id.id if self.user_id else None,
"exams": self.exams,
"tips": self.tips,
"weakAreas": self.weak_areas,
"createdAt": (
self.created_at.isoformat() if self.created_at else None
),
}

View File

@@ -0,0 +1,22 @@
from odoo import models, fields
class EncoachTrainingTip(models.Model):
_name = "encoach.training.tip"
_description = "EnCoach Training Tip"
tip_id = fields.Char(required=True, index=True)
category = fields.Selection(
[
("ct_focus", "CT Focus"),
("language_for_writing", "Language for Writing"),
("reading_skill", "Reading Skill"),
("strategy", "Strategy"),
("writing_skill", "Writing Skill"),
("word_link", "Word Link"),
("word_partners", "Word Partners"),
],
string="Category",
)
content = fields.Text(required=True)
embedding = fields.Binary(string="FAISS Embedding Vector")

View File

@@ -0,0 +1,19 @@
from odoo import models, fields
class EncoachWalkthrough(models.Model):
_name = "encoach.walkthrough"
_description = "EnCoach Walkthrough"
name = fields.Char()
content = fields.Json()
module = fields.Selection(
[
("reading", "Reading"),
("listening", "Listening"),
("writing", "Writing"),
("speaking", "Speaking"),
("level", "Level"),
],
string="Module",
)

View File

@@ -0,0 +1,10 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_training_student,encoach.training.student,model_encoach_training,encoach_core.group_encoach_student,1,0,0,0
access_encoach_training_admin,encoach.training.admin,model_encoach_training,encoach_core.group_encoach_admin,1,1,1,1
access_encoach_training_tip_student,encoach.training.tip.student,model_encoach_training_tip,encoach_core.group_encoach_student,1,0,0,0
access_encoach_training_tip_admin,encoach.training.tip.admin,model_encoach_training_tip,encoach_core.group_encoach_admin,1,1,1,1
access_encoach_walkthrough_student,encoach.walkthrough.student,model_encoach_walkthrough,encoach_core.group_encoach_student,1,0,0,0
access_encoach_walkthrough_admin,encoach.walkthrough.admin,model_encoach_walkthrough,encoach_core.group_encoach_admin,1,1,1,1
access_encoach_training_sysadmin,encoach.training.sysadmin,model_encoach_training,base.group_system,1,1,1,1
access_encoach_training_tip_sysadmin,encoach.training.tip.sysadmin,model_encoach_training_tip,base.group_system,1,1,1,1
access_encoach_walkthrough_sysadmin,encoach.walkthrough.sysadmin,model_encoach_walkthrough,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_training_student encoach.training.student model_encoach_training encoach_core.group_encoach_student 1 0 0 0
3 access_encoach_training_admin encoach.training.admin model_encoach_training encoach_core.group_encoach_admin 1 1 1 1
4 access_encoach_training_tip_student encoach.training.tip.student model_encoach_training_tip encoach_core.group_encoach_student 1 0 0 0
5 access_encoach_training_tip_admin encoach.training.tip.admin model_encoach_training_tip encoach_core.group_encoach_admin 1 1 1 1
6 access_encoach_walkthrough_student encoach.walkthrough.student model_encoach_walkthrough encoach_core.group_encoach_student 1 0 0 0
7 access_encoach_walkthrough_admin encoach.walkthrough.admin model_encoach_walkthrough encoach_core.group_encoach_admin 1 1 1 1
8 access_encoach_training_sysadmin encoach.training.sysadmin model_encoach_training base.group_system 1 1 1 1
9 access_encoach_training_tip_sysadmin encoach.training.tip.sysadmin model_encoach_training_tip base.group_system 1 1 1 1
10 access_encoach_walkthrough_sysadmin encoach.walkthrough.sysadmin model_encoach_walkthrough base.group_system 1 1 1 1

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<record id="rule_training_student_own" model="ir.rule">
<field name="name">Students see own training</field>
<field name="model_id" ref="model_encoach_training"/>
<field name="groups" eval="[(4, ref('encoach_core.group_encoach_student'))]"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
</record>
<record id="rule_training_admin" model="ir.rule">
<field name="name">Admins see all training</field>
<field name="model_id" ref="model_encoach_training"/>
<field name="groups" eval="[(4, ref('encoach_core.group_encoach_admin'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
</data>
</odoo>

View File

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

View File

@@ -0,0 +1,177 @@
import json
import logging
import pickle
import numpy as np
from odoo.addons.encoach_ai.models.constants import GPT_MODELS, TEMPERATURE
from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService
_logger = logging.getLogger(__name__)
TIPS_PROMPT = (
"You are an IELTS tutor. Based on the context, the question, the "
"student's wrong answer, and the correct answer, provide a concise, "
"helpful tip to help the student improve. Use the following reference "
"tips to inform your response but adapt them to the specific situation.\n\n"
"Reference tips:\n{tips}\n\n"
"Context: {context}\n"
"Question: {question}\n"
"Student's answer: {answer}\n"
"Correct answer: {correct_answer}\n\n"
"Provide your response as JSON: "
'{{"tips": "your personalized tip text"}}'
)
TRAINING_PROMPT = (
"You are an IELTS training advisor. Analyze the student's exam history "
"and generate personalized training recommendations.\n\n"
"Student stats:\n{stats}\n\n"
"Generate a JSON response with:\n"
'{{"exams": [recommended practice exams], '
'"tips": [personalized tips], '
'"weakAreas": [identified weak areas with suggestions]}}'
)
class EncoachTrainingService:
def __init__(self, env):
self.env = env
self.ai = EncoachOpenAIService(env)
self._faiss_index = None
self._tip_ids = None
def fetch_tips(self, context, question, answer, correct_answer):
"""Retrieve relevant tips using FAISS + generate personalized tip via GPT."""
query = f"{context} {question} {answer}"
similar_tips = self._query_faiss(query, category=None, top_k=5)
tips_text = "\n".join(
f"- [{t.category}] {t.content}" for t in similar_tips
)
prompt = TIPS_PROMPT.format(
tips=tips_text,
context=context,
question=question,
answer=answer,
correct_answer=correct_answer,
)
result = self.ai.prediction(
model=GPT_MODELS["grading"],
messages=[
{"role": "system", "content": "You are a helpful IELTS tutor. Output JSON."},
{"role": "user", "content": prompt},
],
temperature=TEMPERATURE["tips"],
check_blacklisted=False,
)
return result
def get_training_content(self, user_id, stats):
"""Generate full training content for a user based on their exam stats."""
prompt = TRAINING_PROMPT.format(stats=json.dumps(stats))
result = self.ai.prediction(
model=GPT_MODELS["grading"],
messages=[
{"role": "system", "content": "You are an IELTS training advisor. Output JSON."},
{"role": "user", "content": prompt},
],
temperature=TEMPERATURE["tips"],
check_blacklisted=False,
)
if result:
training = self.env["encoach.training"].sudo().create({
"user_id": user_id,
"exams": result.get("exams"),
"tips": result.get("tips"),
"weak_areas": result.get("weakAreas"),
})
return training
return None
def _query_faiss(self, query, category=None, top_k=5):
"""Search FAISS index for similar training tips.
Falls back to keyword search if FAISS is not available.
"""
try:
import faiss
from sentence_transformers import SentenceTransformer
except ImportError:
_logger.warning("FAISS/sentence-transformers not installed, falling back to keyword search")
return self._keyword_fallback(query, category, top_k)
self._ensure_faiss_index()
if self._faiss_index is None:
return self._keyword_fallback(query, category, top_k)
model = SentenceTransformer("all-MiniLM-L6-v2")
query_vec = model.encode([query]).astype(np.float32)
distances, indices = self._faiss_index.search(query_vec, top_k * 2)
tip_ids = [self._tip_ids[i] for i in indices[0] if i < len(self._tip_ids)]
domain = [("id", "in", tip_ids)]
if category:
domain.append(("category", "=", category))
tips = self.env["encoach.training.tip"].sudo().search(domain, limit=top_k)
return tips
def _ensure_faiss_index(self):
"""Build or load the FAISS index from stored tip embeddings."""
if self._faiss_index is not None:
return
try:
import faiss
except ImportError:
return
tips = self.env["encoach.training.tip"].sudo().search(
[("embedding", "!=", False)]
)
if not tips:
return
vectors = []
ids = []
for tip in tips:
vec = pickle.loads(tip.embedding)
vectors.append(vec)
ids.append(tip.id)
matrix = np.array(vectors, dtype=np.float32)
dim = matrix.shape[1]
index = faiss.IndexFlatL2(dim)
index.add(matrix)
self._faiss_index = index
self._tip_ids = ids
def _keyword_fallback(self, query, category, top_k):
"""Simple keyword-based search when FAISS is unavailable."""
domain = []
if category:
domain.append(("category", "=", category))
tips = self.env["encoach.training.tip"].sudo().search(domain, limit=200)
query_words = set(query.lower().split())
scored = []
for tip in tips:
content_words = set(tip.content.lower().split())
overlap = len(query_words & content_words)
if overlap > 0:
scored.append((overlap, tip))
scored.sort(key=lambda x: x[0], reverse=True)
return self.env["encoach.training.tip"].browse(
[t.id for _, t in scored[:top_k]]
)

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_encoach_training_tip_tree" model="ir.ui.view">
<field name="name">encoach.training.tip.tree</field>
<field name="model">encoach.training.tip</field>
<field name="arch" type="xml">
<list string="Training Tips">
<field name="tip_id"/>
<field name="category"/>
<field name="content"/>
</list>
</field>
</record>
<record id="view_encoach_training_tip_form" model="ir.ui.view">
<field name="name">encoach.training.tip.form</field>
<field name="model">encoach.training.tip</field>
<field name="arch" type="xml">
<form string="Training Tip">
<sheet>
<group>
<group>
<field name="tip_id"/>
<field name="category"/>
</group>
</group>
<group>
<field name="content" widget="text"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_encoach_training_tip" model="ir.actions.act_window">
<field name="name">Training Tips</field>
<field name="res_model">encoach.training.tip</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first training tip.
</p>
<p>
Training tips provide personalized advice for users based on categories like strategy, writing skills, and reading skills.
</p>
</field>
</record>
<menuitem id="menu_encoach_training_tips"
name="Training Tips"
parent="encoach_core.menu_encoach_training"
action="action_encoach_training_tip"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_encoach_training_tree" model="ir.ui.view">
<field name="name">encoach.training.tree</field>
<field name="model">encoach.training</field>
<field name="arch" type="xml">
<list string="Training Records">
<field name="user_id"/>
<field name="created_at"/>
<field name="legacy_id"/>
</list>
</field>
</record>
<record id="view_encoach_training_form" model="ir.ui.view">
<field name="name">encoach.training.form</field>
<field name="model">encoach.training</field>
<field name="arch" type="xml">
<form string="Training Record">
<sheet>
<group>
<group>
<field name="user_id"/>
<field name="created_at"/>
</group>
<group>
<field name="legacy_id"/>
</group>
</group>
<group>
<group>
<field name="exams" widget="json"/>
</group>
<group>
<field name="tips" widget="json"/>
</group>
<group>
<field name="weak_areas" widget="json"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_encoach_training" model="ir.actions.act_window">
<field name="name">Training Records</field>
<field name="res_model">encoach.training</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first training record.
</p>
<p>
Training records store personalized exam data, tips, and weak areas for each user.
</p>
</field>
</record>
<menuitem id="menu_encoach_training_records"
name="Training Records"
parent="encoach_core.menu_encoach_training"
action="action_encoach_training"
sequence="10"/>
</odoo>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_encoach_walkthrough_tree" model="ir.ui.view">
<field name="name">encoach.walkthrough.tree</field>
<field name="model">encoach.walkthrough</field>
<field name="arch" type="xml">
<list string="Walkthroughs">
<field name="name"/>
<field name="module"/>
</list>
</field>
</record>
<record id="view_encoach_walkthrough_form" model="ir.ui.view">
<field name="name">encoach.walkthrough.form</field>
<field name="model">encoach.walkthrough</field>
<field name="arch" type="xml">
<form string="Walkthrough">
<sheet>
<group>
<group>
<field name="name"/>
<field name="module"/>
</group>
</group>
<group>
<field name="content" widget="json"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_encoach_walkthrough" model="ir.actions.act_window">
<field name="name">Walkthroughs</field>
<field name="res_model">encoach.walkthrough</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first walkthrough.
</p>
<p>
Walkthroughs provide guided content for Reading, Listening, Writing, Speaking, and Level modules.
</p>
</field>
</record>
<menuitem id="menu_encoach_walkthroughs"
name="Walkthroughs"
parent="encoach_core.menu_encoach_training"
action="action_encoach_walkthrough"
sequence="30"/>
</odoo>