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}')