feat(backend): course-plan student visibility, multi-voice TTS, OCR sources, branches
Ported from monorepo v4 commit 3b62075d (backend/* portion). encoach_ai_course: - workbook_attempt model + scoring + REST endpoints for student attempts - dialogue_parser splits scripts by speaker, classifies gender, strips labels - media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation, manual media upload endpoint (audio/image/video) with size validation - source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs, page-streaming to stay under memory limit - exercise_extractor + rag_context for RAG-grounded interactive workbooks - course_plan_pipeline: v2 generator that grounds week material on indexed sources and persists grounded_on_json metadata - security: access rules for new models encoach_lms_api: - branches model + controller (entity-scoped LMS branches) - classroom_ext + course_ext (assignment + section workflow) - classrooms controller: students/teachers/assign-course endpoints Made-with: Cursor
This commit is contained in:
@@ -6,7 +6,9 @@ from . import courseware
|
||||
from . import notification
|
||||
from . import faq
|
||||
from . import course_ext
|
||||
from . import classroom_ext
|
||||
from . import asset
|
||||
from . import ticket
|
||||
from . import training
|
||||
from . import paymob_order
|
||||
from . import branch
|
||||
|
||||
49
custom_addons/encoach_lms_api/models/branch.py
Normal file
49
custom_addons/encoach_lms_api/models/branch.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class EncoachLmsBranch(models.Model):
|
||||
_name = "encoach.lms.branch"
|
||||
_description = "EnCoach LMS Branch"
|
||||
_order = "name asc, id asc"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
code = fields.Char(index=True)
|
||||
active = fields.Boolean(default=True)
|
||||
entity_id = fields.Many2one(
|
||||
"encoach.entity",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
index=True,
|
||||
string="Entity",
|
||||
)
|
||||
notes = fields.Text()
|
||||
|
||||
course_ids = fields.One2many("op.course", "branch_id")
|
||||
batch_ids = fields.One2many("op.batch", "branch_id")
|
||||
student_ids = fields.One2many("op.student", "branch_id")
|
||||
teacher_ids = fields.One2many("op.faculty", "branch_id")
|
||||
|
||||
course_count = fields.Integer(compute="_compute_counts")
|
||||
batch_count = fields.Integer(compute="_compute_counts")
|
||||
student_count = fields.Integer(compute="_compute_counts")
|
||||
teacher_count = fields.Integer(compute="_compute_counts")
|
||||
|
||||
_sql_constraints = [
|
||||
("branch_entity_code_uniq", "unique(entity_id, code)", "Branch code must be unique per entity."),
|
||||
]
|
||||
|
||||
@api.depends("course_ids", "batch_ids", "student_ids", "teacher_ids")
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
rec.course_count = len(rec.course_ids)
|
||||
rec.batch_count = len(rec.batch_ids)
|
||||
rec.student_count = len(rec.student_ids)
|
||||
rec.teacher_count = len(rec.teacher_ids)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get("code") and vals.get("name"):
|
||||
vals["code"] = vals["name"].upper().replace(" ", "_")[:24]
|
||||
return super().create(vals_list)
|
||||
|
||||
270
custom_addons/encoach_lms_api/models/classroom_ext.py
Normal file
270
custom_addons/encoach_lms_api/models/classroom_ext.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class OpClassroomExt(models.Model):
|
||||
"""Extend the OpenEduCat classroom into a homeroom (class group).
|
||||
|
||||
Original ``op.classroom`` represents a physical room (with capacity and
|
||||
facilities). We keep that semantic and add organisational fields so the
|
||||
same record also acts as the home of a group of students:
|
||||
* ``student_ids`` — homeroom roster (M2M).
|
||||
* ``teacher_ids`` — homeroom teachers (M2M).
|
||||
* ``course_ids`` — courses taught to this classroom (M2M).
|
||||
* ``batch_ids`` — auto-created batches (one per classroom × course).
|
||||
|
||||
``assign_course`` is idempotent: re-assigning the same course reuses the
|
||||
existing batch and only enrolls students that aren't already enrolled.
|
||||
"""
|
||||
|
||||
_inherit = "op.classroom"
|
||||
|
||||
entity_id = fields.Many2one(
|
||||
"encoach.entity",
|
||||
string="Entity",
|
||||
ondelete="set null",
|
||||
index=True,
|
||||
help="Owning entity/organization for LMS isolation.",
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
"encoach.lms.branch",
|
||||
string="Branch",
|
||||
ondelete="set null",
|
||||
index=True,
|
||||
help="Optional branch/campus inside the owning entity.",
|
||||
)
|
||||
|
||||
student_ids = fields.Many2many(
|
||||
"op.student",
|
||||
"op_classroom_student_rel",
|
||||
"classroom_id",
|
||||
"student_id",
|
||||
string="Students",
|
||||
)
|
||||
teacher_ids = fields.Many2many(
|
||||
"op.faculty",
|
||||
"op_classroom_faculty_rel",
|
||||
"classroom_id",
|
||||
"faculty_id",
|
||||
string="Homeroom Teachers",
|
||||
)
|
||||
course_ids = fields.Many2many(
|
||||
"op.course",
|
||||
"op_classroom_course_rel",
|
||||
"classroom_id",
|
||||
"course_id",
|
||||
string="Courses",
|
||||
)
|
||||
|
||||
batch_ids = fields.One2many(
|
||||
"op.batch",
|
||||
"classroom_id",
|
||||
string="Batches",
|
||||
)
|
||||
|
||||
student_count = fields.Integer(compute="_compute_counts")
|
||||
teacher_count = fields.Integer(compute="_compute_counts")
|
||||
course_count = fields.Integer(compute="_compute_counts")
|
||||
batch_count = fields.Integer(compute="_compute_counts")
|
||||
|
||||
notes = fields.Text(string="Notes")
|
||||
|
||||
@api.depends("student_ids", "teacher_ids", "course_ids", "batch_ids")
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
rec.student_count = len(rec.student_ids)
|
||||
rec.teacher_count = len(rec.teacher_ids)
|
||||
rec.course_count = len(rec.course_ids)
|
||||
rec.batch_count = len(rec.batch_ids)
|
||||
|
||||
@api.constrains("branch_id", "entity_id")
|
||||
def _check_branch_entity(self):
|
||||
for rec in self:
|
||||
if rec.branch_id and rec.entity_id and rec.branch_id.entity_id != rec.entity_id:
|
||||
raise ValidationError(
|
||||
"Classroom branch must belong to the same entity as the classroom."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cascade: assign a course to this classroom
|
||||
# ------------------------------------------------------------------
|
||||
def _ensure_unique_batch_code(self, base_code):
|
||||
"""Return a unique batch code derived from ``base_code``.
|
||||
|
||||
``op.batch`` has a global ``unique(code)`` constraint, so we suffix
|
||||
with a counter when needed.
|
||||
"""
|
||||
Batch = self.env["op.batch"].sudo()
|
||||
candidate = base_code
|
||||
idx = 1
|
||||
while Batch.search_count([("code", "=", candidate)]):
|
||||
idx += 1
|
||||
candidate = f"{base_code}-{idx}"
|
||||
return candidate
|
||||
|
||||
def assign_course(
|
||||
self,
|
||||
course,
|
||||
term_name=None,
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
teacher_ids=None,
|
||||
section_id=None,
|
||||
term_key=None,
|
||||
):
|
||||
"""Assign ``course`` to this classroom and cascade to a batch.
|
||||
|
||||
- Creates (or reuses) the canonical batch for
|
||||
``(classroom, course, section, term_key)``.
|
||||
- Enrolls every classroom student into the batch via ``op.student.course``.
|
||||
- Optionally sets ``teacher_ids`` (M2M) on the batch; if none is
|
||||
provided we default to the classroom's homeroom teachers.
|
||||
|
||||
Returns the ``op.batch`` record.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Course = self.env["op.course"].sudo()
|
||||
Section = self.env["encoach.course.section"].sudo()
|
||||
Batch = self.env["op.batch"].sudo()
|
||||
Enroll = self.env["op.student.course"].sudo()
|
||||
|
||||
if isinstance(course, int):
|
||||
course = Course.browse(course)
|
||||
if not course or not course.exists():
|
||||
raise UserError("Course not found.")
|
||||
|
||||
if (
|
||||
self.entity_id
|
||||
and course.entity_id
|
||||
and self.entity_id.id != course.entity_id.id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Course belongs to a different entity than the classroom."
|
||||
)
|
||||
|
||||
section = None
|
||||
if section_id:
|
||||
section = Section.browse(int(section_id))
|
||||
if not section.exists():
|
||||
raise UserError("Course section not found.")
|
||||
if section.course_id.id != course.id:
|
||||
raise ValidationError("Selected section does not belong to the selected course.")
|
||||
if (
|
||||
self.entity_id
|
||||
and section.entity_id
|
||||
and self.entity_id.id != section.entity_id.id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Course section belongs to a different entity than the classroom."
|
||||
)
|
||||
|
||||
if teacher_ids is not None and self.entity_id:
|
||||
teachers = self.env["op.faculty"].sudo().browse(list(teacher_ids))
|
||||
for t in teachers:
|
||||
if (
|
||||
t.exists()
|
||||
and t.entity_id
|
||||
and t.entity_id.id != self.entity_id.id
|
||||
):
|
||||
raise ValidationError(
|
||||
f"Teacher {t.partner_id.name or t.id} belongs to a "
|
||||
"different entity than the classroom."
|
||||
)
|
||||
|
||||
# Add to classroom course list (idempotent)
|
||||
if course.id not in self.course_ids.ids:
|
||||
self.write({"course_ids": [(4, course.id)]})
|
||||
|
||||
# Find or create the canonical batch (classroom × course × section × term)
|
||||
domain = [
|
||||
("classroom_id", "=", self.id),
|
||||
("course_id", "=", course.id),
|
||||
("course_section_id", "=", section.id if section else False),
|
||||
("term_key", "=", term_key or False),
|
||||
]
|
||||
batch = Batch.search(domain, limit=1)
|
||||
if not batch:
|
||||
section_label = f" — Section {section.code}" if section else ""
|
||||
term_label = f" ({term_name})" if term_name else ""
|
||||
base_name = f"{self.name} — {course.name or course.code or 'Course'}{section_label}{term_label}"
|
||||
base_code = (self.code or self.name or "BATCH").upper().replace(" ", "_")[:12]
|
||||
section_code = (section.code if section else "").upper().replace(" ", "")
|
||||
term_code = (term_key or "").upper().replace(" ", "")
|
||||
base_code = f"{base_code}-{(course.code or '').upper()[:8] or course.id}"
|
||||
if section_code:
|
||||
base_code = f"{base_code}-{section_code[:5]}"
|
||||
if term_code:
|
||||
base_code = f"{base_code}-{term_code[:4]}"
|
||||
today = fields.Date.context_today(self)
|
||||
vals = {
|
||||
"name": base_name,
|
||||
"code": self._ensure_unique_batch_code(base_code[:16]),
|
||||
"course_id": course.id,
|
||||
"classroom_id": self.id,
|
||||
"course_section_id": section.id if section else False,
|
||||
"term_key": term_key or False,
|
||||
"start_date": start_date or today,
|
||||
"end_date": end_date or today.replace(month=12, day=31),
|
||||
"entity_id": self.entity_id.id if self.entity_id else False,
|
||||
"branch_id": self.branch_id.id if self.branch_id else False,
|
||||
}
|
||||
batch = Batch.create(vals)
|
||||
|
||||
# Teacher assignment (M2M) — explicit overrides homeroom defaults
|
||||
target_teachers = teacher_ids if teacher_ids is not None else self.teacher_ids.ids
|
||||
if target_teachers:
|
||||
batch.write({"teacher_ids": [(6, 0, list(target_teachers))]})
|
||||
|
||||
# Enroll classroom roster into the batch (idempotent)
|
||||
for student in self.student_ids:
|
||||
existing = Enroll.search([
|
||||
("student_id", "=", student.id),
|
||||
("course_id", "=", course.id),
|
||||
("batch_id", "=", batch.id),
|
||||
], limit=1)
|
||||
if not existing:
|
||||
Enroll.create({
|
||||
"student_id": student.id,
|
||||
"course_id": course.id,
|
||||
"batch_id": batch.id,
|
||||
"state": "running",
|
||||
})
|
||||
|
||||
return batch
|
||||
|
||||
@api.model
|
||||
def _add_students_to_open_batches(self, classroom_id, student_ids):
|
||||
"""When new students join the classroom, enroll them in every existing
|
||||
course batch under that classroom.
|
||||
"""
|
||||
Batch = self.env["op.batch"].sudo()
|
||||
Enroll = self.env["op.student.course"].sudo()
|
||||
classroom = self.browse(classroom_id)
|
||||
if not classroom.exists():
|
||||
return
|
||||
batches = Batch.search([("classroom_id", "=", classroom.id)])
|
||||
for batch in batches:
|
||||
for sid in student_ids:
|
||||
exists = Enroll.search([
|
||||
("student_id", "=", sid),
|
||||
("course_id", "=", batch.course_id.id),
|
||||
("batch_id", "=", batch.id),
|
||||
], limit=1)
|
||||
if not exists:
|
||||
Enroll.create({
|
||||
"student_id": sid,
|
||||
"course_id": batch.course_id.id,
|
||||
"batch_id": batch.id,
|
||||
"state": "running",
|
||||
})
|
||||
|
||||
def write(self, vals):
|
||||
"""Cascade roster changes into existing classroom batches."""
|
||||
old_students = {rec.id: set(rec.student_ids.ids) for rec in self}
|
||||
res = super().write(vals)
|
||||
if "student_ids" in vals:
|
||||
for rec in self:
|
||||
added = set(rec.student_ids.ids) - old_students.get(rec.id, set())
|
||||
if added:
|
||||
self._add_students_to_open_batches(rec.id, list(added))
|
||||
return res
|
||||
@@ -1,4 +1,61 @@
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EncoachCourseSection(models.Model):
|
||||
_name = "encoach.course.section"
|
||||
_description = "Course Section Template"
|
||||
_order = "course_id, sequence, id"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
code = fields.Char(required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
notes = fields.Text()
|
||||
|
||||
course_id = fields.Many2one("op.course", required=True, ondelete="cascade", index=True)
|
||||
entity_id = fields.Many2one(
|
||||
"encoach.entity",
|
||||
string="Entity",
|
||||
related="course_id.entity_id",
|
||||
store=True,
|
||||
index=True,
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
"encoach.lms.branch",
|
||||
string="Branch",
|
||||
ondelete="set null",
|
||||
index=True,
|
||||
help="Optional default branch for this section template.",
|
||||
)
|
||||
|
||||
batch_ids = fields.One2many("op.batch", "course_section_id", string="Batches")
|
||||
batch_count = fields.Integer(compute="_compute_batch_count")
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"uniq_course_section_code",
|
||||
"unique(course_id, code)",
|
||||
"Section code must be unique per course.",
|
||||
),
|
||||
]
|
||||
|
||||
def _compute_batch_count(self):
|
||||
for rec in self:
|
||||
rec.batch_count = len(rec.batch_ids)
|
||||
|
||||
@api.constrains("branch_id", "course_id")
|
||||
def _check_section_branch_entity(self):
|
||||
for rec in self:
|
||||
if (
|
||||
rec.branch_id
|
||||
and rec.course_id
|
||||
and rec.course_id.entity_id
|
||||
and rec.branch_id.entity_id != rec.course_id.entity_id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Section branch must belong to the same entity as the course."
|
||||
)
|
||||
|
||||
|
||||
class OpCourseExt(models.Model):
|
||||
@@ -13,6 +70,13 @@ class OpCourseExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
|
||||
encoach_subject_id = fields.Many2one(
|
||||
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
|
||||
@@ -40,7 +104,11 @@ class OpCourseExt(models.Model):
|
||||
])
|
||||
|
||||
chapter_ids = fields.One2many('encoach.course.chapter', 'course_id', string='Chapters')
|
||||
section_ids = fields.One2many(
|
||||
'encoach.course.section', 'course_id', string='Sections'
|
||||
)
|
||||
chapter_count = fields.Integer(compute='_compute_chapter_count', store=True)
|
||||
section_count = fields.Integer(compute='_compute_section_count', store=True)
|
||||
objective_count = fields.Integer(compute='_compute_objective_count')
|
||||
resource_count = fields.Integer(compute='_compute_resource_count')
|
||||
|
||||
@@ -49,6 +117,11 @@ class OpCourseExt(models.Model):
|
||||
for rec in self:
|
||||
rec.chapter_count = len(rec.chapter_ids)
|
||||
|
||||
@api.depends('section_ids')
|
||||
def _compute_section_count(self):
|
||||
for rec in self:
|
||||
rec.section_count = len(rec.section_ids)
|
||||
|
||||
def _compute_objective_count(self):
|
||||
for rec in self:
|
||||
rec.objective_count = len(rec.learning_objective_ids)
|
||||
@@ -73,6 +146,76 @@ class OpBatchExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
classroom_id = fields.Many2one(
|
||||
'op.classroom',
|
||||
string='Classroom',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Classroom (homeroom) hosting this batch. When set, the batch '
|
||||
'inherits the classroom roster on course assignment.',
|
||||
)
|
||||
teacher_ids = fields.Many2many(
|
||||
'op.faculty',
|
||||
'op_batch_faculty_rel',
|
||||
'batch_id',
|
||||
'faculty_id',
|
||||
string='Teachers',
|
||||
help='Teachers assigned to this batch. A teacher may be assigned '
|
||||
'to multiple batches.',
|
||||
)
|
||||
student_count = fields.Integer(
|
||||
string='Student Count', compute='_compute_batch_student_count'
|
||||
)
|
||||
course_section_id = fields.Many2one(
|
||||
'encoach.course.section',
|
||||
string='Course Section',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Specific section template of the selected course for this batch.',
|
||||
)
|
||||
term_key = fields.Char(
|
||||
string='Term Key',
|
||||
help='Optional term/semester key used for one-batch-per-term uniqueness.',
|
||||
)
|
||||
|
||||
def _compute_batch_student_count(self):
|
||||
Enroll = self.env['op.student.course'].sudo()
|
||||
for rec in self:
|
||||
rec.student_count = Enroll.search_count([('batch_id', '=', rec.id)])
|
||||
|
||||
@api.constrains('entity_id', 'course_id', 'classroom_id', 'course_section_id', 'branch_id')
|
||||
def _check_batch_entity_consistency(self):
|
||||
for rec in self:
|
||||
ent = rec.entity_id
|
||||
if not ent:
|
||||
continue
|
||||
if rec.course_id and rec.course_id.entity_id and rec.course_id.entity_id != ent:
|
||||
raise ValidationError(
|
||||
"Batch course must belong to the same entity as the batch."
|
||||
)
|
||||
if rec.classroom_id and rec.classroom_id.entity_id and rec.classroom_id.entity_id != ent:
|
||||
raise ValidationError(
|
||||
"Batch classroom must belong to the same entity as the batch."
|
||||
)
|
||||
if (
|
||||
rec.course_section_id
|
||||
and rec.course_section_id.entity_id
|
||||
and rec.course_section_id.entity_id != ent
|
||||
):
|
||||
raise ValidationError(
|
||||
"Batch course section must belong to the same entity as the batch."
|
||||
)
|
||||
if rec.branch_id and rec.branch_id.entity_id != ent:
|
||||
raise ValidationError(
|
||||
"Batch branch must belong to the same entity as the batch."
|
||||
)
|
||||
|
||||
|
||||
class OpStudentExt(models.Model):
|
||||
@@ -85,6 +228,25 @@ class OpStudentExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
|
||||
@api.constrains('branch_id', 'entity_id')
|
||||
def _check_student_branch_entity(self):
|
||||
for rec in self:
|
||||
if (
|
||||
rec.branch_id
|
||||
and rec.entity_id
|
||||
and rec.branch_id.entity_id != rec.entity_id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Student branch must belong to the same entity as the student."
|
||||
)
|
||||
|
||||
|
||||
class OpFacultyExt(models.Model):
|
||||
@@ -97,3 +259,22 @@ class OpFacultyExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
|
||||
@api.constrains('branch_id', 'entity_id')
|
||||
def _check_faculty_branch_entity(self):
|
||||
for rec in self:
|
||||
if (
|
||||
rec.branch_id
|
||||
and rec.entity_id
|
||||
and rec.branch_id.entity_id != rec.entity_id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Teacher branch must belong to the same entity as the teacher."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user