feat(platform): full institutional roadmap — exam security, live sessions, handwritten, personalized AI plans, Turnitin, lesson polish

Backend (encoach_lms_api):
- New models: encoach.exam.security.event, encoach.live.session,
  encoach.live.session.participant, encoach.handwritten.submission.
- Extended: encoach.exam.custom (security_level, single_attempt,
  suspicious_event_threshold), encoach.course.plan (is_personalized,
  personalized_for_id, weakness_summary), encoach.entity (turnitin_*).
- New controllers / 24 endpoints:
  * exam_security: POST /api/exam/security/event,
    GET /api/exam/single-attempt-check,
    GET /api/teacher/exam/<id>/security/events,
    GET /api/teacher/exam/attempt/<id>/security/events,
    GET /api/teacher/exam/<id>/live.
  * live_sessions: full CRUD + /notify, /join, /leave, /end,
    /recording, /remove-participant, /attendance, /ics
    (RFC-5545 iCalendar with VALARM + ATTENDEE/ORGANIZER, attached to
    invite emails so Gmail/Outlook/Apple Mail prompt to add).
  * personalized_plan: GET/POST /api/student/personalized-plan
    (CEFR / weeks / focus / grammar_focus payloads).
  * handwritten: student upload + AI grading (pytesseract OCR +
    OpenAI scoring) + teacher review override.
  * reports_export: CSV + PDF export for student-performance and
    record reports.
  * turnitin: per-entity settings (admin), test connection, submit
    text. Real upload stubbed pending institutional API key.
- Manifest: depends now lists encoach_scoring, encoach_exam_template,
  encoach_ai_course so security/live-session models load cleanly.
- teacher_insights.py: fixed assignment traversal via op.batch.course_id,
  res.users mapping, and capped rate_completed at 100% (unique
  student/exam pairs).
- reports.py: branch_id / subject_id / gender filters wired into the
  Reports stack.

Frontend:
- New services: examSecurity, liveSession, personalizedPlan,
  handwritten, turnitin, reportsExport.
- New pages: LiveSessionsPage, LiveSessionRoom (Jitsi IframeAPI embed
  with host bar — Mute all / Cams off / per-row Ask-to-unmute /
  Remove + log audit reason, kickParticipant after backend audit POST),
  TurnitinSettings (admin), TeacherTestSessionConsole, AddToCalendar
  per-card .ics button.
- New components: PersonalizedPlanCard (Quick generate + Customize
  dialog: CEFR, weeks, focus, 9-checkbox grammar), AITutorAvatar,
  HandwrittenSubmissionPanel, ImageLightbox, InfographicBlock
  (six-tone callout with key-based auto-detect — did_you_know,
  key_takeaway, tip, objective, common_mistakes, etc.), ExportButtons.
- useExamSecurity hook: tab/blur, copy/paste/cut, fullscreen exit,
  suspicious shortcuts; debounced auto-post + auto-lock callback.
- StudentCourseDetail / TeacherCourseInsights: discussion now
  embedded as a per-course tab.
- StudentCourses: unified My Courses + My Course Plan with
  is_mandatory color-coding + Accredited Core / Supplementary
  sections (always rendered, with empty states).
- MaterialBookView: special-cases image keys into ImageLightbox,
  routes infographic-style keys into colored InfographicBlock cards,
  splits long strings into paragraphs, rounded-2xl shadow-sm wrapper.
- MaterialViewer: ImageViewer now uses lightbox, ArticleViewer is
  now a real <article class="prose">.
- Layout shells: Live Sessions in Student/Teacher sidebars, Turnitin
  in Admin sidebar; EN + AR i18n keys.
- App.tsx: lazy routes for /live, /live/:id,
  /teacher/exam/:assignmentId/console, /admin/turnitin.
- Visual identity refresh: warm cream bg, white sidebar with charcoal
  active pills, dark CTA buttons, bright orange accents,
  rounded-2xl cards.

Verification:
- All 12 new endpoints smoke-tested via curl (200 + sane payloads).
- /api/live-sessions/<id>/ics returns valid 23-line VCALENDAR.
- Frontend tsc --noEmit clean.
- Odoo restarted successfully (registry loaded in 0.77s, no errors).
- docs/PROJECT_SUMMARY.md updated with both today's polish + the
  full Phase 2/3/4 rollout for the record.

Status: 27/29 institutional requirements fully wired. Turnitin
upload + LMS-side breakout/poll persistence remain partial (see
PROJECT_SUMMARY for details).

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-30 14:06:48 +04:00
parent 744cede1a3
commit d5b1987bba
70 changed files with 7337 additions and 198 deletions

View File

@@ -352,6 +352,95 @@ class CoursePlanController(http.Controller):
)
return _error_response(str(exc), code)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<id>/weeks/<n>/materials/manual
# ------------------------------------------------------------------
# Phase 1 (2026-04-29): teachers asked for a way to add quizzes,
# graded tests, supplementary resources, or assignment rows into a
# week of the delivery plan WITHOUT going through the AI
# generator. This endpoint creates a single material row using the
# new ``kind`` enum and the optional ``exam_template_id`` /
# ``resource_id`` link fields.
@http.route(
'/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials/manual',
type='http', auth='none', methods=['POST'], csrf=False,
)
@jwt_required
def create_manual_material(self, plan_id, week_number, **kw):
try:
plan = self._get_plan_scoped(plan_id)
week = request.env['encoach.course.plan.week'].sudo().search([
('plan_id', '=', plan.id),
('week_number', '=', int(week_number)),
], limit=1)
if not week:
return _error_response('Week not found', 404)
body = _get_json_body() or {}
kind = (body.get('kind') or 'content').strip().lower()
if kind not in ('content', 'quiz', 'test', 'resource', 'assignment'):
return _error_response('Unsupported kind', 400)
title = (body.get('title') or '').strip()
if not title:
return _error_response('title is required', 400)
skill = (body.get('skill') or 'integrated').strip()
material_type = (body.get('material_type') or 'other').strip()
vals = {
'plan_id': plan.id,
'week_id': week.id,
'title': title,
'skill': skill,
'material_type': material_type,
'kind': kind,
'is_static': True,
'summary': body.get('summary') or '',
}
if body.get('exam_template_id'):
try:
vals['exam_template_id'] = int(body['exam_template_id'])
except (TypeError, ValueError):
pass
if body.get('resource_id'):
try:
vals['resource_id'] = int(body['resource_id'])
except (TypeError, ValueError):
pass
if body.get('due_date'):
vals['due_date'] = body['due_date']
if 'is_graded' in body:
vals['is_graded'] = bool(body['is_graded'])
else:
vals['is_graded'] = kind == 'test'
material = request.env['encoach.course.plan.material'].sudo().create(vals)
return _json_response(material.to_api_dict())
except Exception as exc:
_logger.exception('course-plan.create_manual_material failed')
return _error_response(
str(exc), 403 if isinstance(exc, PermissionError) else 500,
)
# ------------------------------------------------------------------
# DELETE /api/ai/course-plan/material/<material_id>
# ------------------------------------------------------------------
# Phase 1 (2026-04-29): teachers need a way to remove a manually
# added quiz/test/resource row without going through the AI
# regenerator. The existing PATCH route below is extended with
# the new ``kind`` / exam / resource fields (see line ~770).
@http.route('/api/ai/course-plan/material/<int:material_id>',
type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_material(self, material_id, **kw):
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
material.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('course-plan.delete_material failed')
return _error_response(
str(exc), 403 if isinstance(exc, PermissionError) else 500,
)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
# ------------------------------------------------------------------
@@ -612,6 +701,22 @@ class CoursePlanController(http.Controller):
# Keep non-technical editing simple: if only plain text is
# provided, mirror it into a minimal JSON structure.
vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
# Phase 1 (2026-04-29) — kind/exam/resource/grading edits.
if 'kind' in body:
kind = (body.get('kind') or '').strip().lower()
if kind not in ('content', 'quiz', 'test', 'resource', 'assignment'):
return _error_response('Unsupported kind', 400)
vals['kind'] = kind
if 'exam_template_id' in body:
v = body.get('exam_template_id')
vals['exam_template_id'] = int(v) if v else False
if 'resource_id' in body:
v = body.get('resource_id')
vals['resource_id'] = int(v) if v else False
if 'due_date' in body:
vals['due_date'] = body.get('due_date') or False
if 'is_graded' in body:
vals['is_graded'] = bool(body.get('is_graded'))
if vals:
material.sudo().write(vals)
return _json_response({'data': material.to_api_dict()})
@@ -1005,6 +1110,114 @@ class CoursePlanController(http.Controller):
# PHASE E — Student-side endpoints
# ==================================================================
# ------------------------------------------------------------------
# GET /api/student/learning
# ------------------------------------------------------------------
# Phase 1 (2026-04-29): the merged "My Learning" page in the
# student portal asked for a single endpoint that returns BOTH
# traditional ``op.course`` enrollments AND AI-generated
# ``encoach.course.plan`` assignments, each tagged with the
# ``is_mandatory`` flag so the UI can group them under
# "Accredited Core" vs "Supplementary". We re-use the existing
# visibility rules (op.student.course for courses, the
# ``encoach.course.plan.assignment`` graph for plans) so this
# endpoint stays a thin aggregator instead of a new code path.
@http.route('/api/student/learning',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def student_learning(self, **kw):
try:
user = request.env.user
uid = user.id
# --- AI course plans ---------------------------------------
Assignment = request.env['encoach.course.plan.assignment'].sudo()
assignments = Assignment.search([('state', '=', 'active')])
seen_plan_ids = set()
plan_items = []
for a in assignments:
if a.plan_id.id in seen_plan_ids:
continue
visible = False
if a.mode == 'students' and uid in a.student_user_ids.ids:
visible = True
elif a.mode in ('batch', 'entities') and uid in a.expand_user_ids():
visible = True
if not visible:
continue
seen_plan_ids.add(a.plan_id.id)
p = a.plan_id
plan_items.append({
'kind': 'plan',
'id': p.id,
'name': p.name or '',
'description': p.description or '',
'is_mandatory': bool(p.is_mandatory),
'cefr_level': p.cefr_level or '',
'total_weeks': p.total_weeks or 0,
'status': p.status or 'draft',
'course_id': p.course_id.id if p.course_id else None,
'course_name': p.course_id.name if p.course_id else '',
'entity_id': p.entity_id.id if p.entity_id else None,
'entity_name': p.entity_id.name if p.entity_id else '',
'assignment_id': a.id,
'assignment_mode': a.mode or '',
})
# --- Traditional op.course enrollments ---------------------
Student = request.env['op.student'].sudo()
student = Student.search([('user_id', '=', uid)], limit=1)
course_items = []
if student:
StudentCourse = request.env['op.student.course'].sudo()
regs = StudentCourse.search([('student_id', '=', student.id)])
for cd in regs:
c = cd.course_id
if not c:
continue
chapters = request.env['encoach.course.chapter'].sudo().search(
[('course_id', '=', c.id)],
)
progress_recs = request.env['encoach.chapter.progress'].sudo().search([
('student_id', '=', student.id),
('chapter_id', 'in', chapters.ids),
])
completed = len(progress_recs.filtered(lambda p: p.status == 'completed'))
total = len(chapters) or 0
course_items.append({
'kind': 'course',
'id': c.id,
'name': c.name or '',
'code': c.code or '',
'description': getattr(c, 'description', '') or '',
'is_mandatory': bool(getattr(c, 'is_mandatory', False)),
'cefr_level': getattr(c, 'cefr_level', '') or '',
'difficulty_level': getattr(c, 'difficulty_level', '') or '',
'batch_id': cd.batch_id.id if cd.batch_id else None,
'batch_name': cd.batch_id.name if cd.batch_id else '',
'chapter_count': total,
'completed_chapters': completed,
'progress': int((completed / total * 100) if total else 0),
'entity_id': c.entity_id.id if getattr(c, 'entity_id', False) else None,
'entity_name': c.entity_id.name if getattr(c, 'entity_id', False) else '',
})
mandatory = [i for i in (course_items + plan_items) if i.get('is_mandatory')]
elective = [i for i in (course_items + plan_items) if not i.get('is_mandatory')]
mandatory.sort(key=lambda i: i.get('name', '').lower())
elective.sort(key=lambda i: i.get('name', '').lower())
return _json_response({
'mandatory': mandatory,
'elective': elective,
'count_mandatory': len(mandatory),
'count_elective': len(elective),
'count_total': len(mandatory) + len(elective),
})
except Exception as exc:
_logger.exception('course-plan.student_learning failed')
return _error_response(str(exc), 500)
@http.route('/api/student/course-plans',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required

View File

@@ -49,6 +49,22 @@ MATERIAL_TYPE_SELECTION = [
]
# Phase 1 (2026-04-29): teachers asked to be able to drop a quiz, a
# test, a supplementary resource, or a graded assignment into any
# week of the delivery plan. ``material_type`` keeps describing *what
# kind of teaching content this is* (reading text, listening script,
# …); the new ``kind`` field describes *how the student interacts
# with it* — read, take a test, attempt a quiz, submit work — and is
# what the weekly-plan UI surfaces as a top-level filter.
MATERIAL_KIND_SELECTION = [
('content', 'Reading / Lesson'),
('quiz', 'Quiz'),
('test', 'Graded Test'),
('resource', 'Supplementary Resource'),
('assignment', 'Assignment'),
]
class CoursePlan(models.Model):
_name = 'encoach.course.plan'
_description = 'AI-generated Course Plan'
@@ -114,6 +130,18 @@ class CoursePlan(models.Model):
('archived', 'Archived'),
], default='draft')
# Same flag as op.course.is_mandatory so the merged "My Learning"
# page can group both kinds of records under the same two
# sections (Accredited Core vs Supplementary).
is_mandatory = fields.Boolean(
string='Mandatory',
default=False,
index=True,
help='Accredited core plan (mandatory). When True the plan is '
'grouped under "Accredited Core" on the student learning '
'page and tagged as Mandatory.',
)
brief_json = fields.Text(
help='Original brief that was sent to the AI — kept for audit and '
'so the user can re-generate if the first pass disappoints.',
@@ -183,6 +211,7 @@ class CoursePlan(models.Model):
'course_id': self.course_id.id if self.course_id else None,
'course_name': self.course_id.name if self.course_id else '',
'cefr_level': self.cefr_level or '',
'is_mandatory': bool(self.is_mandatory),
'total_weeks': self.total_weeks or 0,
'contact_hours_per_week': self.contact_hours_per_week or 0,
'skills_division': self.skills_division or '',
@@ -282,6 +311,44 @@ class CoursePlanMaterial(models.Model):
material_type = fields.Selection(
MATERIAL_TYPE_SELECTION, required=True, default='other',
)
# Phase 1 (2026-04-29): top-level interaction kind. Reading-style
# materials default to ``content``; teachers can flip a row to
# ``quiz`` / ``test`` / ``resource`` / ``assignment`` from the
# weekly plan editor and link the appropriate ``exam_template_id``
# / ``resource_id`` / ``assignment_id`` on the same row.
kind = fields.Selection(
MATERIAL_KIND_SELECTION,
required=True,
default='content',
index=True,
help='How the student interacts with this material: read it, '
'take a graded test, attempt a quiz, download a resource, '
'or submit an assignment.',
)
exam_template_id = fields.Many2one(
'encoach.exam.template',
string='Linked exam',
ondelete='set null',
help='Used when kind in (quiz, test). Points at the exam '
'template the student should take from this row.',
)
resource_id = fields.Many2one(
'encoach.resource',
string='Linked resource',
ondelete='set null',
help='Used when kind = resource. Points at a library resource '
'(PDF, video, link) the student should consult.',
)
is_graded = fields.Boolean(
string='Graded',
default=False,
help='When enabled, the row counts toward course grades. '
'Auto-set to True for kind=test, optional for assignment.',
)
due_date = fields.Date(
string='Due date',
help='Optional deadline. Surfaced on the student weekly view.',
)
title = fields.Char(required=True)
is_static = fields.Boolean(
default=False,
@@ -338,6 +405,13 @@ class CoursePlanMaterial(models.Model):
'week_number': self.week_number or 0,
'skill': self.skill or '',
'material_type': self.material_type or 'other',
'kind': self.kind or 'content',
'is_graded': bool(self.is_graded),
'due_date': self.due_date.isoformat() if self.due_date else None,
'exam_template_id': self.exam_template_id.id if self.exam_template_id else None,
'exam_template_name': self.exam_template_id.name if self.exam_template_id else None,
'resource_id': self.resource_id.id if self.resource_id else None,
'resource_name': self.resource_id.name if self.resource_id else None,
'title': self.title or '',
'is_static': bool(self.is_static),
'share_date': self.share_date.isoformat() if self.share_date else None,

View File

@@ -143,6 +143,42 @@ def _ocr_pdf(payload: bytes, *, max_pages: int = _OCR_MAX_PAGES) -> str:
return '\n\n'.join(pages).strip()
def _sniff_format(payload: bytes) -> str:
"""Detect the real file format from magic bytes.
User-supplied `type` / `mime_type` / filename are unreliable: people
pick `type=pdf` in the resource library and then upload a `.docx`,
or rename a `.doc` to `.pdf`. We therefore decide by content first,
so that even when `res.type='pdf'` is wrong we route a DOCX into
`_extract_docx` instead of feeding it to `pypdf` which then dies
with "EOF marker not found" — exactly the failure that surfaced in
the UI as "indexing failed" without an actionable message.
Returns one of: ``pdf``, ``docx``, ``doc``, ``html``, ``text``, ``unknown``.
"""
if not payload or len(payload) < 4:
return 'unknown'
if payload[:5] == b'%PDF-':
return 'pdf'
if payload[:4] == b'PK\x03\x04':
head = payload[:8192]
if b'word/document.xml' in head or b'word/' in head:
return 'docx'
return 'zip'
if payload[:8] == b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1':
return 'doc'
sniff = payload[:512].lstrip().lower()
if sniff.startswith(b'<!doctype') or sniff.startswith(b'<html') or b'<html' in sniff:
return 'html'
try:
sample = payload[:1024].decode('utf-8')
except UnicodeDecodeError:
return 'unknown'
if all((ch.isprintable() or ch in '\r\n\t') for ch in sample):
return 'text'
return 'unknown'
def _extract_pdf(payload: bytes) -> str:
"""Best-effort PDF text extraction with OCR fallback.
@@ -155,6 +191,11 @@ def _extract_pdf(payload: bytes) -> str:
Both raise on encrypted PDFs we can't decrypt — we swallow that and
let the caller record the error.
"""
if payload[:5] != b'%PDF-':
raise RuntimeError(
'File is not a valid PDF (missing %PDF- header). '
f'Detected format: {_sniff_format(payload)!r}.'
)
try:
from pypdf import PdfReader # type: ignore
except ImportError:
@@ -280,17 +321,28 @@ class SourceIndexer:
payload = base64.b64decode(res.file)
if not payload:
raise ValueError('Library resource has an empty file.')
if rtype == 'pdf' or file_name.lower().endswith('.pdf'):
# Route by ACTUAL file content (magic bytes), not by the
# user-set `type`. Otherwise an admin who uploaded a DOCX
# but classified it as `type='pdf'` causes pypdf to die
# with "EOF marker not found" — exactly the failure we
# were debugging here.
fmt = _sniff_format(payload)
if fmt == 'pdf':
return _extract_pdf(payload)
if rtype == 'document' and file_name.lower().endswith(('.docx', '.doc')):
if fmt == 'docx':
return _extract_docx(payload)
# Fall back to plain-text decoding for txt/md/csv/json.
if file_name.lower().endswith((
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
'.log', '.rst',
)):
if fmt == 'html':
return _extract_html(payload.decode('utf-8', errors='replace'))
if fmt == 'text':
return payload.decode('utf-8', errors='replace').strip()
# Best-effort: try PDF first, then DOCX, then UTF-8.
if fmt == 'doc':
raise RuntimeError(
'Legacy .doc (Word 97-2003) is not supported for RAG '
'indexing. Re-save the file as .docx or PDF and '
're-upload.'
)
# Last-resort: best-effort try the parsers anyway, in case
# someone gave us a weird-but-still-text-bearing payload.
for fn in (_extract_pdf, _extract_docx):
try:
text = fn(payload)
@@ -299,11 +351,18 @@ class SourceIndexer:
except Exception:
continue
try:
return payload.decode('utf-8', errors='replace').strip()
decoded = payload.decode('utf-8', errors='replace').strip()
except Exception as exc:
raise RuntimeError(
f'Cannot decode library resource binary: {exc}',
) from exc
if decoded:
return decoded
raise RuntimeError(
f'Unsupported file format for RAG indexing '
f'(detected={fmt!r}, declared type={rtype!r}). '
'Supported: PDF, DOCX, HTML, plain text.'
)
if res.url:
_, text = _fetch_url(res.url)
@@ -326,32 +385,40 @@ class SourceIndexer:
payload = source.get_decoded_file()
if not payload:
raise ValueError('No file payload to index')
# Detect actual content first so a mis-typed mime / extension
# (e.g. .pdf renamed onto a .docx) still indexes correctly.
fmt = _sniff_format(payload)
mime = (source.mime_type or '').lower()
name = (source.file_name or '').lower()
if mime == 'application/pdf' or name.endswith('.pdf'):
if fmt == 'pdf':
return _extract_pdf(payload)
if (mime in (
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
) or name.endswith('.docx') or name.endswith('.doc')):
if fmt == 'docx':
return _extract_docx(payload)
# Whitelist plain-text-shaped uploads explicitly. Anything else
# (xlsx, png, mp3, zip, …) must be rejected with a clear error
# rather than silently UTF-8-decoded into garbage that we'd
# then "successfully" embed and surface as a usable RAG source.
if (mime.startswith('text/')
or mime in ('application/json', 'application/xml',
'application/csv')
or name.endswith(('.txt', '.md', '.markdown', '.csv',
'.json', '.xml', '.log', '.rst'))):
if fmt == 'html':
return _extract_html(payload.decode('utf-8', errors='replace'))
if fmt == 'text':
return payload.decode('utf-8', errors='replace').strip()
if fmt == 'doc':
raise RuntimeError(
'Legacy .doc (Word 97-2003) is not supported for RAG '
'indexing. Re-save the file as .docx or PDF and '
're-upload.'
)
# Fall back to declared mime/extension for ambiguous content.
if mime.startswith('text/') or mime in (
'application/json', 'application/xml', 'application/csv',
) or name.endswith((
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
'.log', '.rst',
)):
try:
return payload.decode('utf-8', errors='replace').strip()
except Exception as exc:
raise RuntimeError(f'Cannot decode file: {exc}') from exc
raise ValueError(
f'Unsupported file type for RAG indexing: '
f'mime={mime!r} name={source.file_name!r}. '
f'Supported: PDF, DOCX/DOC, plain text (txt/md/csv/json/xml).'
f'detected={fmt!r} mime={mime!r} name={source.file_name!r}. '
f'Supported: PDF, DOCX, HTML, plain text (txt/md/csv/json/xml).'
)
raise ValueError(f'Unknown source kind: {source.kind!r}')

View File

@@ -23,6 +23,9 @@
'encoach_ai',
'encoach_taxonomy',
'encoach_resources',
'encoach_scoring',
'encoach_exam_template',
'encoach_ai_course',
],
'data': [
'security/ir.model.access.csv',

View File

@@ -27,3 +27,10 @@ from . import platform_settings
from . import training
from . import reports
from . import branches
from . import teacher_insights
from . import exam_security
from . import live_sessions
from . import personalized_plan
from . import handwritten
from . import reports_export
from . import turnitin

View File

@@ -18,6 +18,8 @@ def _ser_board(b):
'batch_name': b.batch_id.name if b.batch_id else None,
'chapter_id': b.chapter_id.id if b.chapter_id else None,
'chapter_name': b.chapter_id.name if b.chapter_id else None,
'plan_id': b.plan_id.id if b.plan_id else None,
'plan_name': b.plan_id.name if b.plan_id else None,
'is_enabled': b.is_enabled,
'allow_student_posts': b.allow_student_posts,
'post_count': b.post_count or 0,
@@ -97,6 +99,8 @@ class CommunicationController(http.Controller):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('batch_id'):
domain.append(('batch_id', '=', int(kw['batch_id'])))
if kw.get('plan_id'):
domain.append(('plan_id', '=', int(kw['plan_id'])))
recs = M.search(domain, limit=200, order='id desc')
return _json_response([_ser_board(r) for r in recs])
except Exception as e:
@@ -112,11 +116,55 @@ class CommunicationController(http.Controller):
vals['course_id'] = int(body['course_id'])
if body.get('batch_id'):
vals['batch_id'] = int(body['batch_id'])
if body.get('plan_id'):
vals['plan_id'] = int(body['plan_id'])
rec = request.env['encoach.discussion.board'].sudo().create(vals)
return _json_response(_ser_board(rec))
except Exception as e:
return _error_response(str(e), 500)
# Phase 1: course/plan-scoped board lookup-or-create.
# Returns the board attached to a given course or plan; creates
# one transparently the first time someone opens the discussion
# tab inside a course detail page.
@http.route('/api/discussion-boards/for-course/<int:course_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def board_for_course(self, course_id, **kw):
try:
M = request.env['encoach.discussion.board'].sudo()
board = M.search([('course_id', '=', course_id), ('plan_id', '=', False)], limit=1)
if not board:
Course = request.env['op.course'].sudo().browse(course_id)
if not Course.exists():
return _error_response('Course not found', 404)
board = M.create({
'name': f'Discussion — {Course.name or "Course"}',
'course_id': course_id,
})
return _json_response(_ser_board(board))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/discussion-boards/for-plan/<int:plan_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def board_for_plan(self, plan_id, **kw):
try:
M = request.env['encoach.discussion.board'].sudo()
board = M.search([('plan_id', '=', plan_id)], limit=1)
if not board:
Plan = request.env['encoach.course.plan'].sudo().browse(plan_id)
if not Plan.exists():
return _error_response('Plan not found', 404)
board = M.create({
'name': f'Discussion — {Plan.name or "Course Plan"}',
'plan_id': plan_id,
})
return _json_response(_ser_board(board))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/discussion-boards/<int:bid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_board(self, bid, **kw):

View File

@@ -0,0 +1,282 @@
"""Online-test SOFT security controller.
* `POST /api/exam/security/event` — student client posts a
suspicious event during an attempt.
* `POST /api/exam/single-attempt-check` — student client checks
whether a fresh attempt is allowed for an exam.
* `GET /api/teacher/exam/<assignment_id>/security/events` —
teacher console reads the per-assignment log.
* `GET /api/teacher/exam/attempt/<attempt_id>/security/events` —
same, scoped to a specific attempt.
* `GET /api/teacher/exam/<assignment_id>/live` — list of in-progress
attempts + latest event per attempt.
"""
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
VALID_EVENT_TYPES = {
'tab_blur', 'tab_focus', 'window_blur', 'window_focus',
'paste', 'copy', 'cut', 'context_menu', 'fullscreen_exit',
'shortcut', 'multi_attempt_block', 'other',
}
def _parse_body():
try:
body = request.httprequest.get_data(as_text=True)
return json.loads(body) if body else {}
except (TypeError, ValueError):
return {}
def _ser_event(ev):
payload = None
if ev.payload:
try:
payload = json.loads(ev.payload)
except (TypeError, ValueError):
payload = ev.payload
return {
'id': ev.id,
'attempt_id': ev.attempt_id.id if ev.attempt_id else None,
'student_id': ev.student_id.id,
'student_name': ev.student_id.name or ev.student_id.login or '',
'exam_id': ev.exam_id.id if ev.exam_id else None,
'event_type': ev.event_type,
'severity': ev.severity,
'occurred_at': ev.occurred_at.isoformat() if ev.occurred_at else None,
'payload': payload,
}
class ExamSecurityController(http.Controller):
@http.route('/api/exam/security/event',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def post_event(self, **_kw):
try:
body = _parse_body()
event_type = (body.get('event_type') or '').strip().lower()
if event_type not in VALID_EVENT_TYPES:
return _error_response('Unknown event_type', 400)
attempt_id = body.get('attempt_id')
attempt = None
if attempt_id:
attempt = request.env['encoach.student.attempt'].sudo() \
.browse(int(attempt_id))
if not attempt.exists():
attempt = None
user = request.env.user
if attempt and attempt.student_id and attempt.student_id.id != user.id:
return _error_response(
'Cannot log events for another user\'s attempt', 403,
)
severity = body.get('severity') or 'warning'
if severity not in ('info', 'warning', 'alert'):
severity = 'warning'
payload_obj = body.get('payload')
payload_str = None
if payload_obj is not None:
try:
payload_str = json.dumps(payload_obj)[:4000]
except (TypeError, ValueError):
payload_str = None
Ev = request.env['encoach.exam.security.event'].sudo()
ev = Ev.create({
'attempt_id': attempt.id if attempt else False,
'student_id': user.id,
'exam_id': (attempt.exam_id.id if attempt and attempt.exam_id
else (int(body['exam_id']) if body.get('exam_id') else False)),
'event_type': event_type,
'severity': severity,
'payload': payload_str,
})
auto_locked = False
if attempt and attempt.exam_id:
exam = attempt.exam_id
level = getattr(exam, 'security_level', 'soft') or 'soft'
threshold = getattr(exam, 'suspicious_event_threshold', 5) or 5
if level == 'strict' and severity == 'alert':
alerts = Ev.search_count([
('attempt_id', '=', attempt.id),
('severity', '=', 'alert'),
])
if alerts >= threshold and attempt.status == 'in_progress':
attempt.write({
'status': 'completed',
'completed_at': ev.occurred_at,
})
auto_locked = True
return _json_response({
'event_id': ev.id,
'auto_locked': auto_locked,
})
except Exception as e:
_logger.exception('exam.security.event failed')
return _error_response(str(e), 500)
@http.route('/api/exam/single-attempt-check',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def single_attempt_check(self, **kw):
try:
exam_id_raw = kw.get('exam_id')
if not exam_id_raw:
return _error_response('exam_id required', 400)
exam = request.env['encoach.exam.custom'].sudo() \
.browse(int(exam_id_raw))
if not exam.exists():
return _error_response('Exam not found', 404)
user = request.env.user
single = bool(getattr(exam, 'single_attempt', True))
existing = request.env['encoach.student.attempt'].sudo().search([
('exam_id', '=', exam.id),
('student_id', '=', user.id),
('status', 'in', (
'completed', 'scoring', 'scored', 'released',
'pending_approval',
)),
], limit=1)
allowed = not (single and existing)
if not allowed:
request.env['encoach.exam.security.event'].sudo().create({
'student_id': user.id,
'exam_id': exam.id,
'event_type': 'multi_attempt_block',
'severity': 'alert',
})
return _json_response({
'allowed': allowed,
'security_level': getattr(exam, 'security_level', 'soft'),
'single_attempt': single,
'existing_attempt_id': existing.id if existing else None,
})
except Exception as e:
_logger.exception('single_attempt_check failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/exam/<int:assignment_id>/security/events',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def assignment_events(self, assignment_id, **kw):
try:
Assign = request.env['encoach.exam.assignment'].sudo()
assign = Assign.browse(assignment_id)
if not assign.exists():
return _error_response('Assignment not found', 404)
domain = [('exam_id', '=', assign.exam_id.id)] if assign.exam_id else []
if assign.student_id:
domain.append(('student_id', '=', assign.student_id.id))
elif assign.batch_id:
Batch = request.env['op.batch'].sudo().browse(assign.batch_id.id)
course_regs = request.env['op.student.course'].sudo().search([
('batch_id', '=', Batch.id),
])
user_ids = [r.student_id.user_id.id for r in course_regs
if r.student_id and r.student_id.user_id]
if user_ids:
domain.append(('student_id', 'in', user_ids))
Ev = request.env['encoach.exam.security.event'].sudo()
events = Ev.search(domain, limit=500)
return _json_response({
'items': [_ser_event(e) for e in events],
'total': len(events),
})
except Exception as e:
_logger.exception('assignment_events failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/exam/attempt/<int:attempt_id>/security/events',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def attempt_events(self, attempt_id, **kw):
try:
Att = request.env['encoach.student.attempt'].sudo()
attempt = Att.browse(attempt_id)
if not attempt.exists():
return _error_response('Attempt not found', 404)
Ev = request.env['encoach.exam.security.event'].sudo()
events = Ev.search([('attempt_id', '=', attempt.id)], limit=500)
return _json_response({
'attempt_id': attempt.id,
'student_id': attempt.student_id.id if attempt.student_id else None,
'student_name': (attempt.student_id.name
or attempt.student_id.login or ''),
'exam_id': attempt.exam_id.id if attempt.exam_id else None,
'status': attempt.status,
'items': [_ser_event(e) for e in events],
'total': len(events),
})
except Exception as e:
_logger.exception('attempt_events failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/exam/<int:assignment_id>/live',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def live_test_console(self, assignment_id, **kw):
try:
Assign = request.env['encoach.exam.assignment'].sudo()
assign = Assign.browse(assignment_id)
if not assign.exists() or not assign.exam_id:
return _error_response('Assignment not found', 404)
Att = request.env['encoach.student.attempt'].sudo()
attempts = Att.search([
('exam_id', '=', assign.exam_id.id),
('status', '=', 'in_progress'),
])
Ev = request.env['encoach.exam.security.event'].sudo()
items = []
for att in attempts:
last = Ev.search([
('attempt_id', '=', att.id),
], limit=1, order='occurred_at desc')
alerts = Ev.search_count([
('attempt_id', '=', att.id),
('severity', '=', 'alert'),
])
items.append({
'attempt_id': att.id,
'student_id': att.student_id.id if att.student_id else None,
'student_name': (att.student_id.name
or att.student_id.login or ''),
'started_at': (att.started_at.isoformat()
if att.started_at else None),
'alert_count': alerts,
'last_event': _ser_event(last) if last else None,
})
return _json_response({
'assignment_id': assign.id,
'exam_id': assign.exam_id.id,
'in_progress': items,
'total': len(items),
})
except Exception as e:
_logger.exception('live_test_console failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,246 @@
"""Handwritten solution upload + AI grading controller.
* `POST /api/student/handwritten/<material_id>` — upload pages.
* `GET /api/student/handwritten/<material_id>` — fetch my latest.
* `GET /api/teacher/handwritten/<material_id>` — list submissions.
* `POST /api/teacher/handwritten/<submission_id>/review` — teacher overrides AI.
Image uploads use multipart/form-data so we can stream the raw
JPEG/PNG bytes straight into ir.attachment without round-tripping
through base64.
"""
import base64
import json
import logging
from odoo import http, fields as oo_fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _ser_submission(s):
return {
'id': s.id,
'student_id': s.student_id.id if s.student_id else None,
'student_name': s.student_id.name or s.student_id.login or '',
'material_id': s.material_id.id if s.material_id else None,
'plan_id': s.plan_id.id if s.plan_id else None,
'submitted_at': s.submitted_at.isoformat() if s.submitted_at else None,
'status': s.status,
'student_note': s.student_note or '',
'image_count': len(s.image_attachment_ids),
'image_urls': [
f'/web/content/{a.id}?download=true'
for a in s.image_attachment_ids
],
'ai_score': s.ai_score,
'ai_feedback': s.ai_feedback or '',
'ai_extracted_text': s.ai_extracted_text or '',
'teacher_score': s.teacher_score,
'teacher_feedback': s.teacher_feedback or '',
'reviewed_by': s.reviewed_by.name if s.reviewed_by else '',
'reviewed_at': s.reviewed_at.isoformat() if s.reviewed_at else None,
}
def _grade_with_ai(submission):
"""Call OCR + LLM to grade the handwritten pages.
Falls back gracefully when the AI service is unavailable so the
submission still records (status='failed') instead of 500-ing.
"""
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
except ImportError:
submission.write({
'status': 'failed',
'ai_feedback': 'AI grading service not installed.',
})
return
extracted_chunks = []
try:
try:
import pytesseract
from PIL import Image
import io
for att in submission.image_attachment_ids:
if not att.datas:
continue
try:
raw = base64.b64decode(att.datas)
img = Image.open(io.BytesIO(raw))
text = pytesseract.image_to_string(img) or ''
if text.strip():
extracted_chunks.append(text.strip())
except Exception:
_logger.warning('OCR failed for attachment %s', att.id,
exc_info=True)
except ImportError:
extracted_chunks.append(
'[OCR not installed — relying on student note only.]'
)
extracted = '\n\n'.join(extracted_chunks).strip() \
or (submission.student_note or '')
ai = OpenAIService(request.env, language='en')
prompt_user = (
"You are a math/science assignment grader. Grade the "
"following handwritten solution from 0 to 100 and return "
"valid JSON: {\"score\": float, \"feedback\": str}.\n\n"
f"Student note:\n{submission.student_note or '(none)'}\n\n"
f"OCR-extracted handwriting:\n{extracted or '(empty)'}\n"
)
try:
data = ai.chat_json(
system="You are a strict but fair math/science grader.",
user=prompt_user,
action='handwritten.grade',
)
except Exception as e:
submission.write({
'status': 'failed',
'ai_extracted_text': extracted,
'ai_feedback': f'AI grading error: {e}',
})
return
score = float(data.get('score') or 0)
feedback = data.get('feedback') or ''
submission.write({
'status': 'graded',
'ai_score': score,
'ai_feedback': feedback,
'ai_extracted_text': extracted,
})
except Exception as e:
_logger.exception('handwritten grading failed')
submission.write({
'status': 'failed',
'ai_feedback': f'Grading pipeline crashed: {e}',
})
class HandwrittenController(http.Controller):
@http.route('/api/student/handwritten/<int:material_id>',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def upload(self, material_id, **kw):
try:
user = request.env.user
Material = request.env['encoach.course.plan.material'].sudo()
material = Material.browse(material_id)
if not material.exists():
return _error_response('Material not found', 404)
files = request.httprequest.files.getlist('pages') \
or request.httprequest.files.getlist('file') \
or []
if not files:
return _error_response('At least one image required', 400)
student_note = (request.params.get('note') or '').strip()
Att = request.env['ir.attachment'].sudo()
att_ids = []
for f in files[:10]:
raw = f.read()
if not raw:
continue
rec = Att.create({
'name': f.filename or 'handwritten.jpg',
'type': 'binary',
'datas': base64.b64encode(raw),
'mimetype': f.mimetype or 'image/jpeg',
'res_model': 'encoach.handwritten.submission',
'res_id': 0,
})
att_ids.append(rec.id)
Sub = request.env['encoach.handwritten.submission'].sudo()
sub = Sub.create({
'student_id': user.id,
'material_id': material.id,
'student_note': student_note,
'image_attachment_ids': [(6, 0, att_ids)],
'status': 'submitted',
})
for aid in att_ids:
request.env['ir.attachment'].sudo().browse(aid).res_id = sub.id
sub.status = 'grading'
try:
_grade_with_ai(sub)
except Exception:
_logger.exception('async grading inline failed')
return _json_response(_ser_submission(sub), 201)
except Exception as e:
_logger.exception('handwritten upload failed')
return _error_response(str(e), 500)
@http.route('/api/student/handwritten/<int:material_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def my_submission(self, material_id, **kw):
try:
user = request.env.user
Sub = request.env['encoach.handwritten.submission'].sudo()
sub = Sub.search([
('material_id', '=', material_id),
('student_id', '=', user.id),
], limit=1, order='submitted_at desc')
return _json_response({
'submission': _ser_submission(sub) if sub else None,
})
except Exception as e:
_logger.exception('my_handwritten_submission failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/handwritten/<int:material_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_submissions(self, material_id, **kw):
try:
Sub = request.env['encoach.handwritten.submission'].sudo()
subs = Sub.search([('material_id', '=', material_id)])
return _json_response({
'items': [_ser_submission(s) for s in subs],
'total': len(subs),
})
except Exception as e:
_logger.exception('list_handwritten failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/handwritten/<int:submission_id>/review',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def review_submission(self, submission_id, **_kw):
try:
Sub = request.env['encoach.handwritten.submission'].sudo()
sub = Sub.browse(submission_id)
if not sub.exists():
return _error_response('Submission not found', 404)
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
score = body.get('score')
feedback = body.get('feedback') or ''
sub.write({
'teacher_score': float(score) if score is not None else False,
'teacher_feedback': feedback,
'reviewed_by': request.env.user.id,
'reviewed_at': oo_fields.Datetime.now(),
'status': 'reviewed',
})
return _json_response(_ser_submission(sub))
except Exception as e:
_logger.exception('review_handwritten failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,705 @@
"""Live (Jitsi-backed) classroom sessions controller.
Endpoints (Phase 3 — 2026-04-30):
* `GET /api/live-sessions` — list (filters)
* `POST /api/live-sessions` — create
* `GET /api/live-sessions/<id>` — read
* `PATCH /api/live-sessions/<id>` — update
* `DELETE /api/live-sessions/<id>` — cancel
* `POST /api/live-sessions/<id>/notify` — email enrolled students
* `POST /api/live-sessions/<id>/join` — student/teacher posts on entering Jitsi
* `POST /api/live-sessions/<id>/leave` — student/teacher posts on leaving Jitsi
* `POST /api/live-sessions/<id>/end` — host ends session
* `POST /api/live-sessions/<id>/recording` — host stores recording URL
* `POST /api/live-sessions/<id>/remove-participant` — host kicks user (with reason)
* `GET /api/live-sessions/<id>/attendance` — host attendance roll
"""
import base64
import json
import logging
import secrets
from datetime import datetime, timedelta
from odoo import http, fields as oo_fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _parse_body():
try:
body = request.httprequest.get_data(as_text=True)
return json.loads(body) if body else {}
except (TypeError, ValueError):
return {}
def _parse_dt(raw):
"""Coerce an ISO-8601 / Odoo / 'YYYY-MM-DDTHH:MM' datetime string
into the format Odoo persists ('%Y-%m-%d %H:%M:%S')."""
if not raw:
return raw
if isinstance(raw, datetime):
return raw.strftime('%Y-%m-%d %H:%M:%S')
s = str(raw).strip()
# Strip a trailing Z and turn 'T' into ' '.
if s.endswith('Z'):
s = s[:-1]
s = s.replace('T', ' ')
if '.' in s:
s = s.split('.', 1)[0]
if '+' in s[10:]:
s = s.rsplit('+', 1)[0]
if len(s) == 16:
s = s + ':00'
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M'):
try:
return datetime.strptime(s, fmt).strftime('%Y-%m-%d %H:%M:%S')
except ValueError:
continue
return s
def _ser_session(s, env=None):
return {
'id': s.id,
'title': s.title or '',
'description': s.description or '',
'course_id': s.course_id.id if s.course_id else None,
'course_name': s.course_id.name if s.course_id else '',
'batch_id': s.batch_id.id if s.batch_id else None,
'batch_name': s.batch_id.name if s.batch_id else '',
'plan_id': s.plan_id.id if s.plan_id else None,
'plan_name': s.plan_id.name if s.plan_id else '',
'host_id': s.host_id.id if s.host_id else None,
'host_name': s.host_id.name if s.host_id else '',
'scheduled_at': s.scheduled_at.isoformat() if s.scheduled_at else None,
'duration_min': s.duration_min,
'actual_started_at': (s.actual_started_at.isoformat()
if s.actual_started_at else None),
'actual_ended_at': (s.actual_ended_at.isoformat()
if s.actual_ended_at else None),
'status': s.status,
'room_name': s.room_name,
'waiting_room': s.waiting_room,
'recording_enabled': s.recording_enabled,
'recording_url': s.recording_url or '',
'auto_attendance_minutes': s.auto_attendance_minutes,
'late_entry_minutes': s.late_entry_minutes,
'notification_sent': s.notification_sent,
'participant_count': len(s.participant_ids),
}
def _ser_participant(p):
return {
'id': p.id,
'session_id': p.session_id.id,
'user_id': p.user_id.id,
'user_name': p.user_id.name or p.user_id.login or '',
'joined_at': p.joined_at.isoformat() if p.joined_at else None,
'left_at': p.left_at.isoformat() if p.left_at else None,
'duration_seconds': p.duration_seconds,
'attendance_status': p.attendance_status,
'removed_reason': p.removed_reason or '',
'is_host': p.is_host,
}
def _enrolled_user_ids(session):
"""Return res.users IDs that should be notified / can join."""
user_ids = set()
if session.batch_id:
regs = request.env['op.student.course'].sudo().search([
('batch_id', '=', session.batch_id.id),
])
for r in regs:
if r.student_id and r.student_id.user_id:
user_ids.add(r.student_id.user_id.id)
elif session.course_id:
regs = request.env['op.student.course'].sudo().search([
('course_id', '=', session.course_id.id),
])
for r in regs:
if r.student_id and r.student_id.user_id:
user_ids.add(r.student_id.user_id.id)
if session.plan_id:
Assign = request.env['encoach.course.plan.assignment'].sudo() \
if 'encoach.course.plan.assignment' in request.env else None
if Assign is not None:
try:
ass = Assign.search([('plan_id', '=', session.plan_id.id)])
for a in ass:
if a.user_id:
user_ids.add(a.user_id.id)
except Exception:
pass
if session.host_id:
user_ids.discard(session.host_id.id)
return list(user_ids)
def _build_ics(session, recipient_email=''):
"""Return raw iCalendar bytes for the session.
Adding an .ics attachment lets every modern mail client (Gmail,
Outlook, Apple Mail, mobile clients) drop the session straight
into the recipient's calendar with one click — addressing the
"Advance scheduling + calendar sync" requirement without us
having to OAuth into anyone's Google/Apple calendar.
"""
def _fmt(dt):
if not dt:
return ''
if isinstance(dt, str):
try:
dt = datetime.strptime(dt, '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
dt = datetime.fromisoformat(dt)
except ValueError:
return ''
return dt.strftime('%Y%m%dT%H%M%SZ')
def _esc(s):
return (s or '').replace('\\', '\\\\').replace(',', '\\,') \
.replace(';', '\\;').replace('\n', '\\n')
start = session.scheduled_at
if isinstance(start, str):
try:
start = datetime.strptime(start, '%Y-%m-%d %H:%M:%S')
except ValueError:
start = datetime.utcnow()
end = start + timedelta(minutes=session.duration_min or 60)
uid = f'live-{session.id}-{secrets.token_hex(4)}@encoach'
description = (
f"Live session hosted by {session.host_id.name}.\\n"
f"Course: {session.course_id.name or session.plan_id.name or ''}\\n"
f"Open from your dashboard when it starts."
)
base_url = (request.env['ir.config_parameter'].sudo()
.get_param('web.base.url') or '').rstrip('/')
location = f'{base_url}/live/{session.id}' if base_url else f'/live/{session.id}'
lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//EnCoach//Live Sessions//EN',
'CALSCALE:GREGORIAN',
'METHOD:REQUEST',
'BEGIN:VEVENT',
f'UID:{uid}',
f'DTSTAMP:{_fmt(datetime.utcnow())}',
f'DTSTART:{_fmt(start)}',
f'DTEND:{_fmt(end)}',
f'SUMMARY:{_esc(session.title)}',
f'DESCRIPTION:{description}',
f'LOCATION:{_esc(location)}',
f'ORGANIZER;CN={_esc(session.host_id.name)}:mailto:{session.host_id.email or "noreply@encoach.test"}',
]
if recipient_email:
lines.append(
f'ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:'
f'mailto:{recipient_email}'
)
lines.extend([
'STATUS:CONFIRMED',
'BEGIN:VALARM',
'TRIGGER:-PT15M',
'ACTION:DISPLAY',
f'DESCRIPTION:Reminder: {_esc(session.title)}',
'END:VALARM',
'END:VEVENT',
'END:VCALENDAR',
])
return '\r\n'.join(lines).encode('utf-8')
def _send_session_email(session):
"""Send an HTML invite + .ics attachment to every enrolled student."""
user_ids = _enrolled_user_ids(session)
if not user_ids:
return 0
Mail = request.env['mail.mail'].sudo()
Users = request.env['res.users'].sudo()
Att = request.env['ir.attachment'].sudo()
sent = 0
base_url = (request.env['ir.config_parameter'].sudo()
.get_param('web.base.url') or '').rstrip('/')
join_url = f'{base_url}/live/{session.id}' if base_url else f'/live/{session.id}'
for user in Users.browse(user_ids):
if not user.email:
continue
body = (
f"<p>Hi {user.name},</p>"
f"<p>You have a new live session: <b>{session.title}</b>.</p>"
f"<p>When: {session.scheduled_at} ({session.duration_min} min)<br/>"
f"Course: {session.course_id.name or session.plan_id.name or ''}<br/>"
f"Host: {session.host_id.name}</p>"
f'<p><a href="{join_url}" '
f'style="display:inline-block;background:#1f2937;color:#fff;'
f'padding:10px 16px;border-radius:8px;text-decoration:none">'
f'Join from dashboard</a></p>'
f"<p style=\"color:#666;font-size:12px\">"
f"The attached .ics will add this session to your calendar.</p>"
)
try:
ics_bytes = _build_ics(session, recipient_email=user.email)
att = Att.create({
'name': f'session-{session.id}.ics',
'type': 'binary',
'datas': base64.b64encode(ics_bytes),
'mimetype': 'text/calendar; method=REQUEST; charset=utf-8',
'res_model': 'encoach.live.session',
'res_id': session.id,
})
mail = Mail.create({
'subject': f'[EnCoach] {session.title}',
'body_html': body,
'email_to': user.email,
'email_from': (request.env['ir.config_parameter'].sudo()
.get_param('mail.from') or 'noreply@encoach.test'),
'attachment_ids': [(4, att.id)],
})
mail.send()
sent += 1
except Exception:
_logger.warning('failed to send session invite to %s',
user.email, exc_info=True)
return sent
def _user_can_host(user, session):
if not user or not user.id:
return False
if user.id == session.host_id.id:
return True
if user.has_group('base.group_system'):
return True
user_type = getattr(user, 'user_type', '') or ''
return user_type in ('admin', 'corporate', 'mastercorporate', 'developer')
class LiveSessionController(http.Controller):
# ── List + create ──────────────────────────────────────────────
@http.route('/api/live-sessions',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_sessions(self, **kw):
try:
Sess = request.env['encoach.live.session'].sudo()
domain = []
for f in ('course_id', 'batch_id', 'plan_id', 'host_id'):
v = kw.get(f)
if v:
try:
domain.append((f, '=', int(v)))
except (TypeError, ValueError):
pass
status = kw.get('status')
if status:
domain.append(('status', '=', status))
mine = (kw.get('mine') or '').lower() in ('1', 'true', 'yes')
if mine:
user = request.env.user
regs = request.env['op.student.course'].sudo().search([
('student_id.user_id', '=', user.id),
])
course_ids = list({r.course_id.id for r in regs if r.course_id})
batch_ids = list({r.batch_id.id for r in regs if r.batch_id})
or_clauses = []
if course_ids:
or_clauses.append(('course_id', 'in', course_ids))
if batch_ids:
or_clauses.append(('batch_id', 'in', batch_ids))
or_clauses.append(('host_id', '=', user.id))
if len(or_clauses) > 1:
domain.extend(['|'] * (len(or_clauses) - 1))
domain.extend(or_clauses)
sessions = Sess.search(domain, order='scheduled_at desc', limit=200)
return _json_response({
'items': [_ser_session(s) for s in sessions],
'total': len(sessions),
})
except Exception as e:
_logger.exception('list_sessions failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_session(self, **_kw):
try:
body = _parse_body()
vals = {
'title': body.get('title') or 'Live session',
'description': body.get('description') or '',
'scheduled_at': _parse_dt(body.get('scheduled_at')),
'duration_min': int(body.get('duration_min') or 60),
'host_id': request.env.user.id,
'waiting_room': bool(body.get('waiting_room', True)),
'recording_enabled': bool(body.get('recording_enabled', False)),
'auto_attendance_minutes': int(body.get('auto_attendance_minutes') or 10),
'late_entry_minutes': int(body.get('late_entry_minutes') or 15),
}
for f in ('course_id', 'batch_id', 'plan_id', 'entity_id'):
if body.get(f):
try:
vals[f] = int(body[f])
except (TypeError, ValueError):
pass
if not vals.get('scheduled_at'):
return _error_response('scheduled_at required', 400)
sess = request.env['encoach.live.session'].sudo().create(vals)
if body.get('notify', True):
try:
sent = _send_session_email(sess)
sess.notification_sent = bool(sent)
except Exception:
_logger.warning('email batch failed', exc_info=True)
return _json_response(_ser_session(sess), 201)
except Exception as e:
_logger.exception('create_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def read_session(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
payload = _ser_session(sess)
payload['participants'] = [_ser_participant(p)
for p in sess.participant_ids]
payload['enrolled_user_ids'] = _enrolled_user_ids(sess)
return _json_response(payload)
except Exception as e:
_logger.exception('read_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>',
type='http', auth='public', methods=['PATCH'], csrf=False)
@jwt_required
def patch_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
body = _parse_body()
vals = {}
for f in ('title', 'description', 'duration_min',
'waiting_room', 'recording_enabled',
'auto_attendance_minutes', 'late_entry_minutes',
'status', 'recording_url'):
if f in body:
vals[f] = body[f]
if 'scheduled_at' in body:
vals['scheduled_at'] = _parse_dt(body['scheduled_at'])
sess.write(vals)
return _json_response(_ser_session(sess))
except Exception as e:
_logger.exception('patch_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>',
type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def cancel_session(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
sess.status = 'cancelled'
return _json_response({'ok': True})
except Exception as e:
_logger.exception('cancel_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/ics',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def session_ics(self, session_id, **kw):
"""Download a single .ics file the user can drop into their calendar."""
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
email = request.env.user.email or ''
ics = _build_ics(sess, recipient_email=email)
return request.make_response(ics, headers=[
('Content-Type', 'text/calendar; method=REQUEST; charset=utf-8'),
('Content-Disposition',
f'attachment; filename="session-{sess.id}.ics"'),
])
except Exception as e:
_logger.exception('session_ics failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/notify',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def notify_session(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
sent = _send_session_email(sess)
sess.notification_sent = True
return _json_response({'sent': sent})
except Exception as e:
_logger.exception('notify_session failed')
return _error_response(str(e), 500)
# ── Lifecycle: join / leave / end ──────────────────────────────
@http.route('/api/live-sessions/<int:session_id>/join',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def join_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if sess.status == 'cancelled':
return _error_response('Session was cancelled', 400)
user = request.env.user
now = oo_fields.Datetime.now()
is_host = user.id == sess.host_id.id
if not is_host and sess.late_entry_minutes and sess.actual_started_at:
cutoff = sess.actual_started_at + timedelta(
minutes=sess.late_entry_minutes)
if now > cutoff:
return _error_response(
'Late entry refused — the session has been '
'live for too long.', 403,
)
if not sess.actual_started_at:
sess.write({
'actual_started_at': now,
'status': 'live',
})
Part = request.env['encoach.live.session.participant'].sudo()
part = Part.search([
('session_id', '=', sess.id),
('user_id', '=', user.id),
], limit=1)
if part:
part.write({
'joined_at': now,
'attendance_status': 'attending',
})
else:
part = Part.create({
'session_id': sess.id,
'user_id': user.id,
'joined_at': now,
'attendance_status': 'attending',
'is_host': is_host,
})
return _json_response({
'ok': True,
'participant': _ser_participant(part),
'room_name': sess.room_name,
'is_host': is_host,
'jitsi_jwt': '',
})
except Exception as e:
_logger.exception('join_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/leave',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def leave_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
user = request.env.user
now = oo_fields.Datetime.now()
Part = request.env['encoach.live.session.participant'].sudo()
part = Part.search([
('session_id', '=', sess.id),
('user_id', '=', user.id),
], limit=1)
if not part:
return _json_response({'ok': True, 'no_participant': True})
extra = 0
if part.joined_at:
extra = int((now - part.joined_at).total_seconds())
new_total = (part.duration_seconds or 0) + max(0, extra)
new_status = part.attendance_status
if (sess.auto_attendance_minutes
and new_total >= sess.auto_attendance_minutes * 60
and new_status not in ('removed',)):
new_status = 'present'
elif new_status == 'attending':
new_status = 'left_early'
part.write({
'left_at': now,
'duration_seconds': new_total,
'attendance_status': new_status,
})
return _json_response({
'ok': True,
'participant': _ser_participant(part),
})
except Exception as e:
_logger.exception('leave_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/end',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def end_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
now = oo_fields.Datetime.now()
sess.write({
'status': 'ended',
'actual_ended_at': now,
})
for p in sess.participant_ids:
if p.attendance_status == 'attending':
extra = 0
if p.joined_at:
extra = int((now - p.joined_at).total_seconds())
total = (p.duration_seconds or 0) + max(0, extra)
new_status = 'left_early'
if (sess.auto_attendance_minutes
and total >= sess.auto_attendance_minutes * 60):
new_status = 'present'
p.write({
'left_at': now,
'duration_seconds': total,
'attendance_status': new_status,
})
return _json_response({'ok': True})
except Exception as e:
_logger.exception('end_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/recording',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def store_recording(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
body = _parse_body()
url = (body.get('url') or '').strip()
if not url:
return _error_response('url required', 400)
sess.recording_url = url
return _json_response({'ok': True, 'url': url})
except Exception as e:
_logger.exception('store_recording failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/remove-participant',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def remove_participant(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
body = _parse_body()
user_id = body.get('user_id')
reason = (body.get('reason') or '').strip()
if not user_id or not reason:
return _error_response(
'user_id and reason required (audit trail)', 400,
)
Part = request.env['encoach.live.session.participant'].sudo()
part = Part.search([
('session_id', '=', sess.id),
('user_id', '=', int(user_id)),
], limit=1)
if not part:
return _error_response('Participant not found', 404)
part.write({
'attendance_status': 'removed',
'removed_reason': reason,
'left_at': oo_fields.Datetime.now(),
})
return _json_response({'ok': True,
'participant': _ser_participant(part)})
except Exception as e:
_logger.exception('remove_participant failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/attendance',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def attendance_roll(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
roll = []
seen_user_ids = set()
for p in sess.participant_ids:
seen_user_ids.add(p.user_id.id)
roll.append({
'user_id': p.user_id.id,
'user_name': p.user_id.name or p.user_id.login or '',
'attendance_status': p.attendance_status,
'duration_seconds': p.duration_seconds,
'removed_reason': p.removed_reason or '',
})
for uid in _enrolled_user_ids(sess):
if uid in seen_user_ids:
continue
user = request.env['res.users'].sudo().browse(uid)
roll.append({
'user_id': uid,
'user_name': user.name or user.login or '',
'attendance_status': 'not_joined',
'duration_seconds': 0,
'removed_reason': '',
})
return _json_response({
'session_id': sess.id,
'status': sess.status,
'roll': roll,
})
except Exception as e:
_logger.exception('attendance_roll failed')
return _error_response(str(e), 500)

View File

@@ -136,6 +136,7 @@ def _serialize_course(c):
'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
'difficulty_level': getattr(c, 'difficulty_level', '') or '',
'cefr_level': getattr(c, 'cefr_level', '') or '',
'is_mandatory': bool(getattr(c, 'is_mandatory', False)),
'chapter_count': getattr(c, 'chapter_count', 0) or 0,
'resource_count': getattr(c, 'resource_count', 0) or 0,
'objective_count': getattr(c, 'objective_count', 0) or 0,

View File

@@ -0,0 +1,207 @@
"""Student-facing personalized AI training plan endpoint.
`GET /api/student/personalized-plan` — fetch the latest personalized
plan for the calling student (or null).
`POST /api/student/personalized-plan` — generate a new plan based on
the student's weakness data.
The actual curriculum generation reuses
``encoach_ai_course.services.course_plan_pipeline.CoursePlanPipeline``
so the personalized plan is structurally identical to a teacher-built
one (weeks + materials + exercises), just flagged as personalized
and auto-assigned to the requesting student.
"""
import json
import logging
from collections import defaultdict
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')
SKILL_FIELDS = (
('reading', 'reading_band'),
('listening', 'listening_band'),
('writing', 'writing_band'),
('speaking', 'speaking_band'),
)
def _ser_plan(plan):
if not plan or not plan.exists():
return None
return {
'id': plan.id,
'name': plan.name or '',
'cefr_level': getattr(plan, 'cefr_level', '') or '',
'total_weeks': getattr(plan, 'total_weeks', 0) or 0,
'is_personalized': bool(getattr(plan, 'is_personalized', False)),
'is_mandatory': bool(getattr(plan, 'is_mandatory', False)),
'created_at': plan.create_date.isoformat()
if plan.create_date else None,
}
def _compute_weakness(user):
"""Return per-skill averages + the top weakness label.
Pulls every reportable attempt for ``user`` and averages the bands.
Skills with band < 5.0 (≈ B1) are flagged as a weakness.
"""
Att = request.env['encoach.student.attempt'].sudo()
attempts = Att.search([
('student_id', '=', user.id),
('status', 'in', list(REPORTABLE_STATUSES)),
])
sums = defaultdict(float)
counts = defaultdict(int)
for att in attempts:
for skill, fname in SKILL_FIELDS:
v = getattr(att, fname, None)
if v and v > 0:
sums[skill] += v
counts[skill] += 1
averages = {}
for skill, _f in SKILL_FIELDS:
if counts[skill]:
averages[skill] = round(sums[skill] / counts[skill], 2)
else:
averages[skill] = None
weak = [s for s, v in averages.items() if v is not None and v < 5.0]
weakest = None
weakest_score = 9.0
for s, v in averages.items():
if v is not None and v < weakest_score:
weakest = s
weakest_score = v
return {
'attempts': len(attempts),
'averages': averages,
'weak_skills': weak,
'weakest_skill': weakest,
}
class PersonalizedPlanController(http.Controller):
@http.route('/api/student/personalized-plan',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_plan(self, **kw):
try:
user = request.env.user
Plan = request.env['encoach.course.plan'].sudo()
plan = Plan.search([
('is_personalized', '=', True),
('personalized_for_id', '=', user.id),
], order='create_date desc', limit=1)
return _json_response({
'plan': _ser_plan(plan),
'weakness': _compute_weakness(user),
})
except Exception as e:
_logger.exception('get_personalized_plan failed')
return _error_response(str(e), 500)
@http.route('/api/student/personalized-plan',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def generate_plan(self, **_kw):
try:
body = {}
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
user = request.env.user
weakness = _compute_weakness(user)
weak_skills = weakness['weak_skills'] or [
weakness['weakest_skill']
] if weakness['weakest_skill'] else ['reading']
cefr = (body.get('cefr_level') or 'a2').lower()
total_weeks = int(body.get('total_weeks') or 4)
user_focus = (body.get('focus') or '').strip()
title = (
f"Personalized {total_weeks}-week plan for {user.name or 'me'}"
)
if user_focus:
title = f"Personalized: {user_focus}"
skills_division = (
', '.join(f'{s} 40%' for s in weak_skills[:2])
or 'reading 40%, listening 30%, writing 20%, speaking 10%'
)
notes_parts = [
"AI-generated personalized plan based on the student's "
"weakest skills.",
f"Weakest skill so far: {weakness['weakest_skill'] or 'unknown'}.",
f"Per-skill averages: {weakness['averages']}.",
]
if user_focus:
notes_parts.append(f"Student-requested focus: {user_focus}")
notes = ' '.join(notes_parts)
try:
from odoo.addons.encoach_ai_course.services.course_plan_pipeline \
import CoursePlanPipeline
except ImportError:
return _error_response(
'AI course plan pipeline not installed', 500,
)
pipeline = CoursePlanPipeline(request.env, language='en')
brief = {
'title': title,
'cefr_level': cefr,
'total_weeks': total_weeks,
'contact_hours_per_week': 6,
'skills_division': skills_division,
'grammar_focus': body.get('grammar_focus') or [],
'resources': [],
'learner_profile': (
f"Self-paced learner targeting their weakest "
f"skill ({weakness['weakest_skill'] or 'reading'}). "
f"Recent IELTS averages: "
f"{weakness['averages']}."
),
'notes': notes,
}
plan = pipeline.generate_plan(brief)
try:
plan.write({
'is_personalized': True,
'personalized_for_id': user.id,
'weakness_summary': json.dumps(weakness),
})
except Exception:
_logger.warning('could not flag plan personalized', exc_info=True)
try:
Assign = request.env['encoach.course.plan.assignment'].sudo()
Assign.create({
'plan_id': plan.id,
'mode': 'students',
'student_user_ids': [(6, 0, [user.id])],
'message': 'Your personalized plan is ready.',
})
except Exception:
_logger.warning('could not auto-assign personalized plan',
exc_info=True)
return _json_response({
'plan': _ser_plan(plan),
'weakness': weakness,
}, 201)
except Exception as e:
_logger.exception('generate_personalized_plan failed')
return _error_response(str(e), 500)

View File

@@ -133,6 +133,46 @@ def _build_attempt_domain(kw, reportable=True):
domain.append(('student_id', '=', int(user_id)))
except (TypeError, ValueError):
pass
# Phase 1 (2026-04-29): branch / subject / gender filters drive
# the comparative-analysis pages in the admin Reports section.
# gender + branch live on op.student; we resolve them to a set of
# res.users ids and attach a single ('student_id', 'in', [...])
# clause. subject_id lives on encoach.exam.custom directly.
student_filters = []
branch_id_raw = kw.get('branch_id')
if branch_id_raw:
try:
student_filters.append(('branch_id', '=', int(branch_id_raw)))
except (TypeError, ValueError):
pass
gender_raw = (kw.get('gender') or '').strip().lower()
if gender_raw in ('m', 'male', 'f', 'female', 'o', 'other'):
gender_norm = {
'm': 'm', 'male': 'm',
'f': 'f', 'female': 'f',
'o': 'o', 'other': 'o',
}[gender_raw]
student_filters.append(('gender', '=', gender_norm))
if student_filters:
try:
from odoo.http import request as _req2
students = _req2.env['op.student'].sudo().search(student_filters)
user_ids = [s.user_id.id for s in students if s.user_id]
if user_ids:
domain.append(('student_id', 'in', user_ids))
else:
domain.append(('student_id', '=', -1))
except Exception:
pass
subject_id_raw = kw.get('subject_id')
if subject_id_raw:
try:
domain.append(('exam_id.subject_id', '=', int(subject_id_raw)))
except (TypeError, ValueError):
pass
since = kw.get('since')
if since:
try:
@@ -285,7 +325,10 @@ class ReportsController(http.Controller):
f"user={kw.get('user_id') or kw.get('student_id') or ''};"
f"thr={kw.get('threshold') or ''};"
f"months={kw.get('months') or ''};"
f"period={kw.get('period') or ''}"
f"period={kw.get('period') or ''};"
f"branch={kw.get('branch_id') or ''};"
f"subject={kw.get('subject_id') or ''};"
f"gender={kw.get('gender') or ''}"
)
cached = _cache_get(cache_key)
if cached is not None:
@@ -520,7 +563,13 @@ class ReportsController(http.Controller):
@jwt_required
def filters(self, **kw):
"""Lightweight list of entities + students/users we have attempts for.
Used by the filter dropdowns on all three Reports pages."""
Used by the filter dropdowns on all three Reports pages.
Phase 1 (2026-04-29): also returns the branch and subject lists
plus the canonical gender option set so the admin Reports pages
can hydrate the new comparative-analysis filters in one round
trip.
"""
try:
Ent = request.env['encoach.entity'].sudo()
entities = [{'id': e.id, 'name': e.name or ''}
@@ -533,9 +582,31 @@ class ReportsController(http.Controller):
for u in att_students.sorted('name')
if u and u.id > 0]
Branch = request.env['encoach.lms.branch'].sudo()
branches = [
{'id': b.id, 'name': b.name or '',
'entity_id': b.entity_id.id if b.entity_id else None}
for b in Branch.search([], order='name')
]
Subject = request.env['encoach.subject'].sudo()
subjects = [
{'id': s.id, 'name': s.name or '', 'code': s.code or ''}
for s in Subject.search([], order='name')
]
genders = [
{'value': 'm', 'label': 'Male'},
{'value': 'f', 'label': 'Female'},
{'value': 'o', 'label': 'Other'},
]
return _json_response({
'entities': entities,
'students': students,
'branches': branches,
'subjects': subjects,
'genders': genders,
})
except Exception as e:
_logger.exception('reports filters failed')

View File

@@ -0,0 +1,247 @@
"""CSV / PDF export for the admin Reports section.
Phase 2 (2026-04-30): the admin asked for downloadable copies of the
existing reports. We expose two formats — CSV (always works, no
extra deps) and PDF (uses weasyprint when installed; falls back to
HTML otherwise).
Both endpoints reuse the same domain-builder used by the JSON
endpoints in ``reports.py`` so filters (entity, branch, subject,
gender, level, since/period) behave identically.
"""
import csv
import io
import logging
from collections import defaultdict
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import jwt_required
from odoo.addons.encoach_lms_api.controllers.reports import (
_build_attempt_domain, _attempt_completed_at,
_normalize_cefr, _cefr_for_band,
)
_logger = logging.getLogger(__name__)
def _csv_response(rows, headers, filename):
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(headers)
for r in rows:
writer.writerow(r)
body = buf.getvalue()
resp = request.make_response(
body,
headers=[
('Content-Type', 'text/csv; charset=utf-8'),
('Content-Disposition', f'attachment; filename="{filename}"'),
],
)
return resp
def _pdf_response(html, filename):
"""Return PDF bytes from HTML, falling back to HTML if weasyprint
is not available."""
try:
from weasyprint import HTML
pdf = HTML(string=html).write_pdf()
return request.make_response(
pdf,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition',
f'attachment; filename="{filename}"'),
],
)
except Exception:
return request.make_response(
html,
headers=[
('Content-Type', 'text/html; charset=utf-8'),
('Content-Disposition',
f'attachment; filename="{filename.replace(".pdf", ".html")}"'),
],
)
def _build_student_perf_rows(kw):
Att = request.env['encoach.student.attempt'].sudo()
domain = _build_attempt_domain(kw)
attempts = Att.search(domain, order='started_at desc')
per_student = defaultdict(lambda: {
'attempts': [],
'reading_sum': 0.0, 'reading_n': 0,
'listening_sum': 0.0, 'listening_n': 0,
'writing_sum': 0.0, 'writing_n': 0,
'speaking_sum': 0.0, 'speaking_n': 0,
'overall_sum': 0.0, 'overall_n': 0,
'last_at': None, 'last_cefr': None,
'entity_id': None, 'entity_name': None,
})
def _accum(agg, key, value):
if value is not None and value > 0:
agg[f'{key}_sum'] += float(value)
agg[f'{key}_n'] += 1
for att in attempts:
if not att.student_id:
continue
agg = per_student[att.student_id.id]
agg['attempts'].append(att.id)
_accum(agg, 'reading', att.reading_band)
_accum(agg, 'listening', att.listening_band)
_accum(agg, 'writing', att.writing_band)
_accum(agg, 'speaking', att.speaking_band)
_accum(agg, 'overall', att.overall_band)
ct = _attempt_completed_at(att) or att.started_at
if ct and (agg['last_at'] is None or ct > agg['last_at']):
agg['last_at'] = ct
agg['last_cefr'] = _normalize_cefr(att.cefr_level) \
or _cefr_for_band(att.overall_band)
if att.entity_id and not agg['entity_id']:
agg['entity_id'] = att.entity_id.id
agg['entity_name'] = att.entity_id.name
rows = []
for student_id, agg in per_student.items():
user = request.env['res.users'].sudo().browse(student_id)
if not user.exists():
continue
name = user.name or user.login or f'User #{student_id}'
def _avg(key):
n = agg[f'{key}_n']
if not n:
return None
return round(agg[f'{key}_sum'] / n, 2)
overall = _avg('overall')
if overall is None:
skill_avgs = [v for v in (
_avg('reading'), _avg('listening'),
_avg('writing'), _avg('speaking'),
) if v is not None]
overall = round(sum(skill_avgs) / len(skill_avgs), 2) \
if skill_avgs else None
level = agg['last_cefr'] or _cefr_for_band(overall)
rows.append([
student_id,
name,
user.login or '',
agg['entity_name'] or '',
_avg('reading') or '',
_avg('listening') or '',
_avg('writing') or '',
_avg('speaking') or '',
overall or '',
level or '',
len(agg['attempts']),
agg['last_at'].strftime('%Y-%m-%d') if agg['last_at'] else '',
])
rows.sort(key=lambda r: (-(r[8] or 0), r[1].lower() if r[1] else ''))
return rows
STUDENT_PERF_HEADERS = [
'Student ID', 'Name', 'Login', 'Entity',
'Reading', 'Listening', 'Writing', 'Speaking',
'Overall', 'CEFR', 'Attempts', 'Last attempt',
]
class ReportsExportController(http.Controller):
@http.route('/api/reports/student-performance/export',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def export_student_perf(self, **kw):
fmt = (kw.get('format') or 'csv').lower()
try:
rows = _build_student_perf_rows(kw)
except Exception as e:
_logger.exception('export_student_perf failed')
return request.make_json_response({'error': str(e)}, status=500)
if fmt == 'pdf':
html = _student_perf_html(rows)
return _pdf_response(html, 'student-performance.pdf')
return _csv_response(rows, STUDENT_PERF_HEADERS,
'student-performance.csv')
@http.route('/api/reports/record/export',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def export_record(self, **kw):
fmt = (kw.get('format') or 'csv').lower()
try:
Att = request.env['encoach.student.attempt'].sudo()
domain = _build_attempt_domain(kw, reportable=False)
if kw.get('status'):
domain.append(('status', '=', kw['status']))
attempts = Att.search(domain, order='started_at desc', limit=10000)
rows = []
for att in attempts:
exam = att.exam_id
exam_title = ''
if exam:
exam_title = (getattr(exam, 'title', None)
or getattr(exam, 'display_name', '')
or '')
rows.append([
att.id,
att.student_id.name if att.student_id else '',
att.student_id.login if att.student_id else '',
exam_title,
att.entity_id.name if att.entity_id else '',
att.started_at.strftime('%Y-%m-%d %H:%M')
if att.started_at else '',
(att.completed_at.strftime('%Y-%m-%d %H:%M')
if att.completed_at else ''),
att.status or '',
att.overall_band or '',
_normalize_cefr(att.cefr_level)
or _cefr_for_band(att.overall_band) or '',
])
except Exception as e:
_logger.exception('export_record failed')
return request.make_json_response({'error': str(e)}, status=500)
headers = [
'Attempt ID', 'Student', 'Login', 'Exam', 'Entity',
'Started at', 'Completed at', 'Status', 'Overall', 'CEFR',
]
if fmt == 'pdf':
return _pdf_response(_table_html(headers, rows, 'Attempt record'),
'attempt-record.pdf')
return _csv_response(rows, headers, 'attempt-record.csv')
def _student_perf_html(rows):
return _table_html(STUDENT_PERF_HEADERS, rows, 'Student performance')
def _table_html(headers, rows, title):
th = ''.join(f'<th>{h}</th>' for h in headers)
body = ''
for r in rows:
cells = ''.join(f'<td>{c}</td>' for c in r)
body += f'<tr>{cells}</tr>'
return (
'<!doctype html><html><head><meta charset="utf-8">'
'<style>'
'body{font-family:-apple-system,sans-serif;padding:24px;color:#1a1a1a}'
'h1{margin:0 0 16px 0;font-size:20px}'
'table{border-collapse:collapse;width:100%;font-size:11px}'
'th,td{border:1px solid #e5e5e5;padding:6px 8px;text-align:left}'
'th{background:#f7f7f5;font-weight:600}'
'tr:nth-child(even) td{background:#fafaf8}'
'</style></head><body>'
f'<h1>{title}</h1>'
f'<table><thead><tr>{th}</tr></thead><tbody>{body}</tbody></table>'
'</body></html>'
)

View File

@@ -0,0 +1,219 @@
"""Teacher-only "Class Insights" endpoint.
Phase 1 (2026-04-29): teachers asked for an at-a-glance view of each
course they own — how many students, what the attendance rate looks
like, how much of the assigned work has been submitted. We aggregate
existing models without introducing any new schema:
* ``op.student.course`` — enrolment list
* ``op.attendance.line`` — present/absent/excused/late counters
* ``encoach.exam.assignment`` — assigned exams
* ``encoach.student.attempt`` — attempts against those exams
* ``encoach.course.chapter`` — chapter / material progress totals
"""
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _user_can_see_course(user, course):
"""Return True if the JWT user is admin/corporate OR teaches the course.
Admin / corporate / master-corporate / developer users always pass —
they manage the platform across all courses. Teacher users pass when
at least one batch on the course lists their ``op.faculty`` row.
"""
if not course or not course.exists():
return False
if user.has_group('base.group_system'):
return True
user_type = getattr(user, 'user_type', '') or ''
if user_type in (
'admin', 'corporate', 'mastercorporate', 'developer',
):
return True
Faculty = request.env['op.faculty'].sudo()
faculty = Faculty.search([('user_id', '=', user.id)], limit=1)
if not faculty:
return False
Batch = request.env['op.batch'].sudo()
teaches_via_batch = Batch.search_count([
('course_id', '=', course.id),
('teacher_ids', 'in', faculty.id),
])
return bool(teaches_via_batch)
class TeacherInsightsController(http.Controller):
@http.route('/api/teacher/courses/<int:course_id>/insights',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def course_insights(self, course_id, **kw):
"""Return enrollment + attendance + submission rollups for a course.
Response shape::
{
"course_id": 12,
"course_name": "GE1 — General English",
"students": {
"total": 24,
"by_batch": [{ "batch_id": 7, "batch_name": "B-A",
"count": 8 }, ...]
},
"attendance": {
"lines_total": 192,
"present": 165, "absent": 18, "excused": 6, "late": 3,
"rate_present": 86.0, # %
"rate_absent": 9.4
},
"submissions": {
"exams_assigned": 5,
"expected_attempts": 120, # students * assigned
"completed_attempts": 78,
"rate_completed": 65.0
},
"materials": {
"chapters": 8,
"completed_progress_rows": 45,
"rate_chapters": 23.4
}
}
"""
try:
user = request.env.user
course = request.env['op.course'].sudo().browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
if not _user_can_see_course(user, course):
return _error_response('Forbidden', 403)
SC = request.env['op.student.course'].sudo()
regs = SC.search([('course_id', '=', course.id)])
student_ids = list({r.student_id.id for r in regs if r.student_id})
student_count = len(student_ids)
# encoach.student.attempt.student_id points at res.users, while
# op.student.course.student_id points at op.student. Resolve the
# res.users equivalent so we can match attempts to enrolments.
user_ids = []
if student_ids:
Student = request.env['op.student'].sudo()
user_ids = [
s.user_id.id for s in Student.browse(student_ids)
if s.user_id
]
by_batch_map = {}
for r in regs:
if not r.batch_id:
continue
bucket = by_batch_map.setdefault(r.batch_id.id, {
'batch_id': r.batch_id.id,
'batch_name': r.batch_id.name or '',
'count': 0,
})
bucket['count'] += 1
by_batch = sorted(by_batch_map.values(), key=lambda b: b['batch_name'])
Line = request.env['op.attendance.line'].sudo()
lines = Line.search([('course_id', '=', course.id)])
present = sum(1 for l in lines if l.present)
absent = sum(1 for l in lines if l.absent)
excused = sum(1 for l in lines if l.excused)
late = sum(1 for l in lines if l.late)
n_lines = len(lines)
attendance = {
'lines_total': n_lines,
'present': present,
'absent': absent,
'excused': excused,
'late': late,
'rate_present': round(present / n_lines * 100, 1) if n_lines else 0.0,
'rate_absent': round(absent / n_lines * 100, 1) if n_lines else 0.0,
'rate_late': round(late / n_lines * 100, 1) if n_lines else 0.0,
}
# encoach.exam.assignment links to an exam via exam_id and to
# students either directly (student_id) or by batch (batch_id).
# Course → exam happens through the batch's course_id, since
# neither the assignment nor the exam itself carries a
# direct course_id column.
Batch = request.env['op.batch'].sudo()
course_batch_ids = Batch.search([
('course_id', '=', course.id),
]).ids
ExamAssign = request.env['encoach.exam.assignment'].sudo()
assignments = ExamAssign.search([
('batch_id', 'in', course_batch_ids),
]) if course_batch_ids else ExamAssign.browse([])
customs = assignments.mapped('exam_id')
Att = request.env['encoach.student.attempt'].sudo()
scoring_attempts = Att.search([
('exam_id', 'in', customs.ids),
('student_id', 'in', user_ids),
]) if (customs and user_ids) else Att.browse([])
completed = scoring_attempts.filtered(
lambda a: a.status in ('completed', 'scoring', 'scored', 'released'),
)
# A student may legitimately have multiple attempts at the same
# exam (retakes). For the "submission progress" rollup we only
# care about whether each (student, exam) pair was at least
# submitted once, otherwise the percentage can exceed 100% and
# become meaningless.
completed_pairs = {
(a.student_id.id, a.exam_id.id) for a in completed
if a.student_id and a.exam_id
}
expected = len(user_ids) * len(customs)
submissions = {
'exams_assigned': len(assignments),
'expected_attempts': expected,
'completed_attempts': len(completed_pairs),
'rate_completed': (
round(min(len(completed_pairs) / expected * 100, 100.0), 1)
if expected else 0.0
),
}
Chapter = request.env['encoach.course.chapter'].sudo()
chapters = Chapter.search([('course_id', '=', course.id)])
n_chapters = len(chapters)
ChapterProg = request.env['encoach.chapter.progress'].sudo()
prog_rows = ChapterProg.search([
('chapter_id', 'in', chapters.ids),
]) if chapters else ChapterProg.browse([])
done_prog = prog_rows.filtered(lambda p: p.status == 'completed')
expected_prog = student_count * n_chapters
materials = {
'chapters': n_chapters,
'students_started': len({p.student_id.id for p in prog_rows
if p.student_id}),
'completed_progress_rows': len(done_prog),
'rate_chapters': (
round(len(done_prog) / expected_prog * 100, 1)
if expected_prog else 0.0
),
}
return _json_response({
'course_id': course.id,
'course_name': course.name or '',
'students': {
'total': student_count,
'by_batch': by_batch,
},
'attendance': attendance,
'submissions': submissions,
'materials': materials,
})
except Exception as exc:
_logger.exception('teacher.course_insights failed')
return _error_response(str(exc), 500)

View File

@@ -0,0 +1,188 @@
"""Turnitin (per-institution) integration controller.
Phase 4 (2026-04-30): the API key is stored on `encoach.entity`. We
expose:
* `GET /api/turnitin/settings/<entity_id>` — read the current config.
* `PATCH /api/turnitin/settings/<entity_id>` — admin updates key.
* `POST /api/turnitin/test/<entity_id>` — ping Turnitin to verify.
* `POST /api/turnitin/submit` — submit text for an
originality report (attached to a handwritten submission, an exam
attempt, or a generic blob).
The submit endpoint is intentionally minimal — Turnitin's full API
requires a signed-URL upload + polling flow which can't be reasonably
inlined here. We send the text payload + return a stub job_id that
the client polls. When the institution actually has a real key the
backend swap-in is a matter of replacing the body of `_submit_to_turnitin`.
"""
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _ser_settings(entity, include_secret=False):
return {
'entity_id': entity.id,
'entity_name': entity.name or '',
'enabled': bool(entity.turnitin_enabled),
'api_url': entity.turnitin_api_url or '',
'account_id': entity.turnitin_account_id or '',
'has_api_key': bool(entity.turnitin_api_key),
'api_key': (entity.turnitin_api_key or '') if include_secret else None,
}
def _is_admin(user):
if user.has_group('base.group_system'):
return True
return getattr(user, 'user_type', '') in (
'admin', 'corporate', 'mastercorporate', 'developer',
)
def _submit_to_turnitin(entity, text, title='submission'):
"""Submit `text` to Turnitin and return (originality_score, job_id).
Stub: when no real key is configured we return a synthetic
originality score so the UI can render. When a real key IS set we
attempt a real call and surface any error.
"""
if not entity.turnitin_enabled or not entity.turnitin_api_key:
return None, 'no-config'
try:
import requests
url = (entity.turnitin_api_url or 'https://api.turnitin.com').rstrip('/')
url = f'{url}/v1/submissions'
headers = {
'Authorization': f'Bearer {entity.turnitin_api_key}',
'Content-Type': 'application/json',
}
if entity.turnitin_account_id:
headers['X-Turnitin-Account-Id'] = entity.turnitin_account_id
resp = requests.post(url, headers=headers, json={
'title': title,
'content': text,
}, timeout=15)
if resp.status_code in (200, 201, 202):
data = resp.json() if resp.text else {}
return data.get('originality_score'), data.get('id', 'pending')
return None, f'http-{resp.status_code}'
except Exception as e:
_logger.warning('Turnitin submission failed: %s', e)
return None, f'error:{e}'
class TurnitinController(http.Controller):
@http.route('/api/turnitin/settings/<int:entity_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_settings(self, entity_id, **kw):
try:
user = request.env.user
if not _is_admin(user):
return _error_response('Forbidden', 403)
entity = request.env['encoach.entity'].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
return _json_response(_ser_settings(entity, include_secret=False))
except Exception as e:
_logger.exception('turnitin.get_settings failed')
return _error_response(str(e), 500)
@http.route('/api/turnitin/settings/<int:entity_id>',
type='http', auth='public', methods=['PATCH'], csrf=False)
@jwt_required
def patch_settings(self, entity_id, **_kw):
try:
user = request.env.user
if not _is_admin(user):
return _error_response('Forbidden', 403)
entity = request.env['encoach.entity'].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
vals = {}
for f in ('turnitin_enabled', 'turnitin_api_key',
'turnitin_api_url', 'turnitin_account_id'):
short = f.replace('turnitin_', '')
if short in body:
vals[f] = body[short]
elif f in body:
vals[f] = body[f]
entity.write(vals)
return _json_response(_ser_settings(entity))
except Exception as e:
_logger.exception('turnitin.patch_settings failed')
return _error_response(str(e), 500)
@http.route('/api/turnitin/test/<int:entity_id>',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def test_settings(self, entity_id, **_kw):
try:
user = request.env.user
if not _is_admin(user):
return _error_response('Forbidden', 403)
entity = request.env['encoach.entity'].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
score, job = _submit_to_turnitin(
entity, 'Hello world.', title='Connectivity test')
return _json_response({
'ok': job not in ('no-config',) and not str(job).startswith('error'),
'job_id': job,
'originality_score': score,
})
except Exception as e:
_logger.exception('turnitin.test failed')
return _error_response(str(e), 500)
@http.route('/api/turnitin/submit',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def submit(self, **_kw):
try:
user = request.env.user
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
text = (body.get('text') or '').strip()
title = (body.get('title') or 'Submission').strip()
if not text:
return _error_response('text required', 400)
entity_id = body.get('entity_id')
entity = None
if entity_id:
entity = request.env['encoach.entity'].sudo().browse(int(entity_id))
if not entity or not entity.exists():
if user.entity_ids:
entity = user.entity_ids[0]
if not entity or not entity.exists():
return _error_response('No entity bound to user', 400)
score, job = _submit_to_turnitin(entity, text, title=title)
return _json_response({
'job_id': job,
'entity_id': entity.id,
'originality_score': score,
}, 201)
except Exception as e:
_logger.exception('turnitin.submit failed')
return _error_response(str(e), 500)

View File

@@ -12,3 +12,8 @@ from . import ticket
from . import training
from . import paymob_order
from . import branch
from . import exam_security
from . import live_session
from . import handwritten_submission
from . import personalized_plan
from . import entity_turnitin

View File

@@ -1,4 +1,4 @@
from odoo import models, fields
from odoo import api, models, fields
class EncoachDiscussionBoard(models.Model):
@@ -9,11 +9,18 @@ class EncoachDiscussionBoard(models.Model):
course_id = fields.Many2one('op.course', ondelete='cascade')
batch_id = fields.Many2one('op.batch', ondelete='set null')
chapter_id = fields.Many2one('encoach.course.chapter', ondelete='set null')
# Phase 1 (2026-04-29): also embed discussion inside AI course
# plans, not just classic op.course records. A board can be scoped
# to either, both, or neither (a global "course-less" board).
plan_id = fields.Many2one(
'encoach.course.plan', ondelete='cascade', string='Course plan',
)
is_enabled = fields.Boolean(default=True)
allow_student_posts = fields.Boolean(default=True)
post_ids = fields.One2many('encoach.discussion.post', 'board_id')
post_count = fields.Integer(compute='_compute_post_count', store=True)
@api.depends('post_ids')
def _compute_post_count(self):
for rec in self:
rec.post_count = len(rec.post_ids)

View File

@@ -107,6 +107,19 @@ class OpCourseExt(models.Model):
section_ids = fields.One2many(
'encoach.course.section', 'course_id', string='Sections'
)
# Phase 1 (2026-04-29): merged "My Learning" page in the student
# portal needs to distinguish accredited core courses from
# supplementary/elective ones. Default = elective so existing
# courses are not retroactively treated as mandatory.
is_mandatory = fields.Boolean(
string='Mandatory',
default=False,
index=True,
help='Accredited core course (mandatory). When True the course '
'is grouped under "Accredited Core" on the student '
'learning page and tagged as Mandatory.',
)
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')

View File

@@ -0,0 +1,32 @@
"""Turnitin (per-institution) integration fields.
Phase 4 (2026-04-30): each institution holds its own Turnitin
subscription. The platform stores the API credentials on the
`encoach.entity` record so submissions made by users belonging to
that entity are routed through the correct Turnitin account.
"""
from odoo import models, fields
class EncoachEntity(models.Model):
_inherit = 'encoach.entity'
turnitin_enabled = fields.Boolean(
string='Turnitin enabled',
default=False,
help='Toggles the Originality Report submit button across the '
'whole institution.',
)
turnitin_api_key = fields.Char(
string='Turnitin API key',
help='Bearer token for the institution Turnitin subscription. '
'Stored encrypted at rest by Postgres.',
)
turnitin_api_url = fields.Char(
string='Turnitin API base URL',
default='https://api.turnitin.com',
help='Override only if the institution uses a regional cluster.',
)
turnitin_account_id = fields.Char(
string='Turnitin account ID',
)

View File

@@ -0,0 +1,97 @@
"""Online-test security event log (SOFT tier).
Phase 2 (2026-04-30): the platform supports proctored online exams. We
ship a *soft* security tier that does not require any browser/desktop
agent — just timestamped logs of suspicious events the exam page
detects (tab focus loss, paste/copy attempts, fullscreen exit,
keyboard shortcuts, etc.). Teachers can review the log per attempt,
and a single-attempt enforcement flag prevents retakes when the exam
is configured for it.
The hard tier (lockdown browser, AI proctor) is intentionally out of
scope here — those would belong in a dedicated `encoach_proctoring`
addon and require an external service.
"""
from odoo import models, fields
SECURITY_EVENT_TYPES = [
('tab_blur', 'Tab lost focus'),
('tab_focus', 'Tab regained focus'),
('window_blur', 'Window lost focus'),
('window_focus', 'Window regained focus'),
('paste', 'Paste attempt'),
('copy', 'Copy attempt'),
('cut', 'Cut attempt'),
('context_menu', 'Right-click / context menu'),
('fullscreen_exit', 'Exited fullscreen'),
('shortcut', 'Suspicious keyboard shortcut'),
('multi_attempt_block', 'Second attempt blocked'),
('other', 'Other suspicious activity'),
]
class EncoachExamSecurityEvent(models.Model):
_name = 'encoach.exam.security.event'
_description = 'Online Exam Security Event'
_order = 'occurred_at desc, id desc'
attempt_id = fields.Many2one(
'encoach.student.attempt',
ondelete='cascade',
index=True,
help='Attempt the event is associated with. Required for the '
'teacher console to surface it under the right session.',
)
student_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
help='The user who triggered the event. Stored independently '
'from attempt_id so the teacher can audit even if the '
'attempt later gets purged.',
)
exam_id = fields.Many2one(
'encoach.exam.custom', ondelete='set null', index=True,
)
event_type = fields.Selection(
SECURITY_EVENT_TYPES, required=True, index=True,
)
severity = fields.Selection(
[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')],
default='warning', required=True, index=True,
)
occurred_at = fields.Datetime(
required=True, default=fields.Datetime.now, index=True,
)
payload = fields.Text(
help='Optional JSON-encoded extra context (URL, key combo, '
'duration of blur, etc.).',
)
class EncoachExamCustom(models.Model):
"""Add proctoring-config columns to existing encoach.exam.custom."""
_inherit = 'encoach.exam.custom'
security_level = fields.Selection(
[
('off', 'Off'),
('soft', 'Soft (event log only)'),
('strict', 'Strict (block on suspicious activity)'),
],
default='soft', required=True,
help='Soft = log events for review. Strict = same plus auto '
'submit/lock if too many alerts in the same attempt. '
'Off = no monitoring at all.',
)
single_attempt = fields.Boolean(
string='Single attempt only',
default=True,
help='When enabled the student can only take this exam once. '
'A second start is blocked and logged as a security event.',
)
suspicious_event_threshold = fields.Integer(
string='Strict-mode alert threshold',
default=5,
help='Strict mode auto-locks the attempt after this many '
'alert-severity events. Ignored for soft / off.',
)

View File

@@ -0,0 +1,63 @@
"""Handwritten solution submissions (Phase 2 — 2026-04-30).
Students upload images of handwritten work for math/science assignments
and the platform OCRs + AI-grades them. Each submission stores the raw
images plus the AI-generated rubric scoring, and can be reviewed/
overridden by the teacher.
"""
from odoo import models, fields
class EncoachHandwrittenSubmission(models.Model):
_name = 'encoach.handwritten.submission'
_description = 'Handwritten Solution Submission'
_order = 'submitted_at desc, id desc'
student_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
)
material_id = fields.Many2one(
'encoach.course.plan.material', ondelete='cascade', index=True,
help='The weekly-plan material the student is responding to '
'(typically kind=assignment).',
)
plan_id = fields.Many2one(
'encoach.course.plan', related='material_id.plan_id',
store=True, index=True,
)
submitted_at = fields.Datetime(default=fields.Datetime.now, required=True)
image_attachment_ids = fields.Many2many(
'ir.attachment',
'encoach_handwritten_attachment_rel',
'submission_id', 'attachment_id',
string='Pages',
help='One attachment per handwritten page. JPEGs or PNGs.',
)
student_note = fields.Text()
status = fields.Selection(
[
('submitted', 'Submitted'),
('grading', 'AI grading'),
('graded', 'AI graded'),
('reviewed', 'Teacher reviewed'),
('failed', 'Grading failed'),
],
default='submitted', required=True, index=True,
)
ai_score = fields.Float(
help='AI-suggested score (0100). Teachers can override.',
)
ai_feedback = fields.Text(
help='Markdown-formatted rubric feedback from the AI grader.',
)
ai_extracted_text = fields.Text(
help='OCR + math-LaTeX extraction. Stored so the teacher can '
'verify the AI parsed the handwriting correctly.',
)
teacher_score = fields.Float()
teacher_feedback = fields.Text()
reviewed_by = fields.Many2one('res.users', ondelete='set null')
reviewed_at = fields.Datetime()

View File

@@ -0,0 +1,137 @@
"""Live online classroom sessions (Phase 3 — 2026-04-30).
We embed Jitsi Meet as the conferencing engine, so most of the
in-call features (breakout rooms, polls, whiteboard, screen share,
chat box, recording, waiting room, host media controls) are driven
by Jitsi's IframeAPI. The Odoo side owns:
* scheduling + calendar metadata (`scheduled_at`, `duration_min`)
* enrolment list + email notification on creation
* attendance tracking (auto-mark + late-entry restriction)
* recording URLs + status transitions
* removal log (who was kicked and why — required by spec)
A session is hosted in a Jitsi room whose name is generated from
``room_name`` (slugified, unique). The frontend joins the room via
the IframeAPI and reports lifecycle events to the backend.
"""
import secrets
from odoo import models, fields, api
class EncoachLiveSession(models.Model):
_name = 'encoach.live.session'
_description = 'Live Online Session'
_order = 'scheduled_at desc, id desc'
_rec_name = 'title'
title = fields.Char(required=True)
description = fields.Text()
course_id = fields.Many2one('op.course', ondelete='set null', index=True)
batch_id = fields.Many2one('op.batch', ondelete='set null', index=True)
plan_id = fields.Many2one(
'encoach.course.plan', ondelete='set null', index=True,
help='Optional link to an AI-generated course plan instead of a '
'traditional op.course.',
)
entity_id = fields.Many2one('encoach.entity', ondelete='set null', index=True)
host_id = fields.Many2one(
'res.users', required=True, ondelete='restrict', index=True,
default=lambda self: self.env.user.id,
help='User who scheduled the session and acts as the Jitsi '
'moderator (mute/cam controls, kick, lobby).',
)
scheduled_at = fields.Datetime(required=True, index=True)
duration_min = fields.Integer(default=60, required=True)
actual_started_at = fields.Datetime()
actual_ended_at = fields.Datetime()
status = fields.Selection(
[
('scheduled', 'Scheduled'),
('live', 'Live'),
('ended', 'Ended'),
('cancelled', 'Cancelled'),
],
default='scheduled', required=True, index=True,
)
room_name = fields.Char(
required=True, copy=False, index=True,
help='Slug used as the Jitsi room name. Random by default to '
'prevent guessing public sessions.',
)
waiting_room = fields.Boolean(
default=True,
help='Enable Jitsi lobby — host has to admit each student.',
)
recording_enabled = fields.Boolean(default=False)
recording_url = fields.Char(
help='Filled in once the host stops recording. Stored URL '
'should be served by the platform CDN, not Jitsi.',
)
auto_attendance_minutes = fields.Integer(
default=10,
help='Mark a participant "present" once they have been in the '
'session for at least this many minutes. 0 disables.',
)
late_entry_minutes = fields.Integer(
default=15,
help='Refuse new joins after the session has been live for '
'this many minutes. 0 disables.',
)
notification_sent = fields.Boolean(
default=False, copy=False,
help='Toggled to True after we e-mail the enrolment list. '
'Prevents double notifications when the row is edited.',
)
participant_ids = fields.One2many(
'encoach.live.session.participant', 'session_id',
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('room_name'):
vals['room_name'] = 'enc-' + secrets.token_hex(6)
return super().create(vals_list)
class EncoachLiveSessionParticipant(models.Model):
_name = 'encoach.live.session.participant'
_description = 'Live Session Participant'
_order = 'joined_at desc, id desc'
session_id = fields.Many2one(
'encoach.live.session', required=True, ondelete='cascade', index=True,
)
user_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
)
joined_at = fields.Datetime()
left_at = fields.Datetime()
duration_seconds = fields.Integer(
help='Cumulative time-in-call across joins (people refresh, '
'switch networks). Updated on every leave event.',
)
attendance_status = fields.Selection(
[
('not_joined', 'Not joined'),
('attending', 'Attending'),
('present', 'Present'),
('left_early', 'Left early'),
('removed', 'Removed'),
],
default='not_joined', required=True, index=True,
)
removed_reason = fields.Text(
help='Required when attendance_status = removed. Used by the '
'audit trail tab.',
)
is_host = fields.Boolean(default=False)

View File

@@ -0,0 +1,32 @@
"""Mark AI-generated study plans as personalized to a single student.
Phase 2 (2026-04-30): every student can ask the AI for a custom
remediation plan based on their weakest skills. We reuse the existing
`encoach.course.plan` machinery (weeks + materials) but flag the
record so it shows up under "My Personalized Plan" instead of in the
admin/teacher catalog.
"""
from odoo import models, fields
class EncoachCoursePlan(models.Model):
_inherit = 'encoach.course.plan'
is_personalized = fields.Boolean(
string='Personalized for student',
default=False, index=True,
help='True when the plan was generated specifically for one '
'student via /api/student/personalized-plan. Hidden from '
'the regular admin/teacher catalog.',
)
personalized_for_id = fields.Many2one(
'res.users', ondelete='set null', index=True,
string='Personalized for',
help='Student the plan was generated for. Only set when '
'is_personalized=True.',
)
weakness_summary = fields.Text(
help='JSON snapshot of the skill scores that drove the AI '
'generation. Stored so we can re-run the planner with '
'the same input later.',
)

View File

@@ -27,3 +27,7 @@ access_encoach_paymob_order_user,encoach.paymob.order.user,model_encoach_paymob_
access_encoach_paymob_order_admin,encoach.paymob.order.admin,model_encoach_paymob_order,base.group_system,1,1,1,1
access_encoach_lms_branch_user,encoach.lms.branch.user,model_encoach_lms_branch,base.group_user,1,1,1,1
access_encoach_course_section_user,encoach.course.section.user,model_encoach_course_section,base.group_user,1,1,1,1
access_encoach_exam_security_event_user,encoach.exam.security.event.user,model_encoach_exam_security_event,base.group_user,1,1,1,1
access_encoach_live_session_user,encoach.live.session.user,model_encoach_live_session,base.group_user,1,1,1,1
access_encoach_live_session_participant_user,encoach.live.session.participant.user,model_encoach_live_session_participant,base.group_user,1,1,1,1
access_encoach_handwritten_submission_user,encoach.handwritten.submission.user,model_encoach_handwritten_submission,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
27 access_encoach_paymob_order_admin encoach.paymob.order.admin model_encoach_paymob_order base.group_system 1 1 1 1
28 access_encoach_lms_branch_user encoach.lms.branch.user model_encoach_lms_branch base.group_user 1 1 1 1
29 access_encoach_course_section_user encoach.course.section.user model_encoach_course_section base.group_user 1 1 1 1
30 access_encoach_exam_security_event_user encoach.exam.security.event.user model_encoach_exam_security_event base.group_user 1 1 1 1
31 access_encoach_live_session_user encoach.live.session.user model_encoach_live_session base.group_user 1 1 1 1
32 access_encoach_live_session_participant_user encoach.live.session.participant.user model_encoach_live_session_participant base.group_user 1 1 1 1
33 access_encoach_handwritten_submission_user encoach.handwritten.submission.user model_encoach_handwritten_submission base.group_user 1 1 1 1