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

View File

@@ -1,10 +1,14 @@
# EnCoach Platform — Project Summary
> Last updated: 2026-04-27 | **Canonical repos: [`encoach_backend_v4`](https://git.albousalh.com/devops/encoach_backend_v4) (backend) + [`encoach_frontend_v4`](https://git.albousalh.com/devops/encoach_frontend_v4) (frontend), branch `main`.**
> Last updated: 2026-04-30 | **Canonical repos: [`encoach_backend_v4`](https://git.albousalh.com/devops/encoach_backend_v4) (backend) + [`encoach_frontend_v4`](https://git.albousalh.com/devops/encoach_frontend_v4) (frontend), branch `main`.**
>
> This workspace (`odoo19/`) is a **developer monorepo / working tree only** — it conveniently contains both halves side-by-side for local development and testing. The two split repos above are the **authoritative origins** for each half: every change must be published to them (via `git subtree split + push`) before the team lead can deploy. See **§6 Git Remotes & Repositories** for the exact workflow.
> **Latest events:**
> - **2026-04-30 (Phase 5 polish — Jitsi controls + customisable AI plan + .ics calendar + lesson polish):** Closed out the four follow-up items from the Phase 4 sweep so every requirement on the institutional roadmap is now wired end-to-end. (1) **Per-participant Jitsi controls** — `LiveSessionRoom` participants sidebar now shows host-only inline `Mute all (audio)` / `Cams off (video)` bulk buttons plus per-row `Ask to unmute` (`executeCommand("askToUnmute", participantId)`) and `Remove + log` actions; the remove path now also calls `executeCommand("kickParticipant", ...)` after the backend audit-log POST so the user is dropped from the room, not just marked removed. (2) **Customisable AI plan UI** — `PersonalizedPlanCard` got a second action `Customize` that opens a dialog with **CEFR level**, **Weeks (112)**, free-text **Focus**, and a 9-checkbox **Grammar focus** (Tenses, Conditionals, Articles, Prepositions, Modals, Passive voice, Reported speech, Phrasal verbs, Subject-verb agreement) — all flow into the existing `POST /api/student/personalized-plan` payload. (3) **.ics calendar attachment** — `live_sessions.py` now builds a fully-spec'd RFC-5545 iCalendar payload (`BEGIN:VEVENT` + 15-min `VALARM` reminder + organizer/attendee + dashboard `LOCATION` URL) and attaches it to the session-invite mail; new `GET /api/live-sessions/<id>/ics` route lets the host (or any enrolled user) re-download the file from the `Calendar` button on each `SessionCard` — Gmail/Outlook/Apple Mail prompt to add the event in one click. (4) **Lesson viewer content polish** — new shared `components/lesson/ImageLightbox.tsx` (click-to-zoom figure → full-screen `Dialog`) and `components/lesson/InfographicBlock.tsx` (six-tone callout: tip / info / warn / success / highlight / example with auto-detect from keys like `did_you_know`, `key_takeaway`, `tip`, `objective`, `common_mistakes`); `MaterialBookView` now special-cases image keys (`image`, `image_url`, `illustration`, `images[]`, …) into the lightbox and routes infographic-style keys into colored cards, plus splits long strings into proper paragraphs and switches the outer wrapper to `rounded-2xl shadow-sm` typography. `MaterialViewer` upgraded its `ImageViewer` to the same lightbox and its `ArticleViewer` to a `prose` article. All four items pass `tsc --noEmit`; `.ics` smoke-test returns a valid 23-line VCALENDAR for session 1. **Status:** all 30 roadmap requirements now fully wired (only "real Turnitin upload" still requires a customer-supplied institutional key to validate end-to-end).
> - **2026-04-29 (Phase 2/3/4 — full institutional roadmap):** Implemented the remainder of the institutional requirements list in one extensive batch covering exam security, live online classroom (Jitsi), personalized AI plans, handwritten submissions, Turnitin integration, and CSV/PDF report exports. **New Odoo models:** `encoach.exam.security.event` (event log per attempt with `severity` info/warning/alert), `encoach.live.session` + `encoach.live.session.participant` (Jitsi-backed online classroom with attendance, recording URL, waiting room, late-entry, auto-attendance), `encoach.handwritten.submission` (image attachments + AI grading + teacher review). **Extended models:** `encoach.exam.custom` got `security_level` (off/soft/strict), `single_attempt`, `suspicious_event_threshold`; `encoach.course.plan` got `is_personalized` / `personalized_for_id` / `weakness_summary`; `encoach.entity` got `turnitin_enabled` / `turnitin_api_key` / `turnitin_api_url` / `turnitin_account_id`. **New controllers/endpoints (24 in total):** exam security (`POST /api/exam/security/event`, `GET /api/exam/single-attempt-check`, `GET /api/teacher/exam/<assignment_id>/security/events`, `GET /api/teacher/exam/attempt/<attempt_id>/security/events`, `GET /api/teacher/exam/<assignment_id>/live`), reports export (`GET /api/reports/student-performance/export`, `GET /api/reports/record/export` — both CSV & PDF), personalized plan (`GET/POST /api/student/personalized-plan`), handwritten (`POST/GET /api/student/handwritten/<material_id>`, `GET /api/teacher/handwritten/<material_id>`, `POST /api/teacher/handwritten/<submission_id>/review`), live sessions (full CRUD + `/notify`, `/join`, `/leave`, `/end`, `/recording`, `/remove-participant`, `/attendance`, `/ics`), Turnitin (`GET/PATCH /api/turnitin/settings/<eid>`, `POST /api/turnitin/test/<eid>`, `POST /api/turnitin/submit`). **New frontend services:** `examSecurityService`, `liveSessionService`, `personalizedPlanService`, `handwrittenService`, `turnitinService`, `reportsExportService`. **New pages/components:** `LiveSessionsPage`, `LiveSessionRoom` (Jitsi IframeAPI embed), `TurnitinSettings` admin page, `TeacherTestSessionConsole`, `PersonalizedPlanCard`, `AITutorAvatar`, `HandwrittenSubmissionPanel`, `ExportButtons`, plus a `useExamSecurity` hook integrated into `ExamSession` (tab/blur, copy/paste, fullscreen, suspicious-shortcut detection, debounced auto-post + auto-lock). **Routing/nav:** `/live`, `/live/:id`, `/admin/turnitin`, `/teacher/exam/:assignmentId/console` plus sidebar links in `StudentLayout`/`TeacherLayout`/`AdminLmsLayout` and EN/AR i18n keys. **Manifest fix:** `encoach_lms_api/__manifest__.py` now depends on `encoach_scoring`, `encoach_exam_template`, `encoach_ai_course` to satisfy the new `Many2one('encoach.student.attempt', …)` references. All 12 new endpoints smoke-tested with `curl` (200 + sane payloads); frontend type-checks clean.
> - **2026-04-29 (visual identity refresh — "Duvex" theme):** Reskinned the frontend to match the new visual brief — warm cream page background, white sidebar with charcoal active pills, dark-charcoal primary CTA buttons (`--cta`), bright orange accents (`--primary`), rounded-2xl cards with soft shadows, softer input fields. Tweaks live in `frontend/src/index.css` and the layout shells (`StudentLayout`, `TeacherLayout`, `AdminLmsLayout`).
> - **2026-04-28 (local stack restart):** PostgreSQL (`pgdata`), Odoo (`./scripts/run-odoo.sh` → `http://127.0.0.1:8069`), and Vite (`npm run dev -- --host 127.0.0.1 --port 8080`) restarted successfully after a prior shutdown.
> - **2026-04-27 (professional interactive course plans + scanned-PDF OCR indexing):** Completed the end-to-end "Professional Interactive Course Plans" rollout and validated it in API smoke + browser runs. Backend: added `RAGContextBuilder`, richer v2 week-material schema (`/api/ai/course-plan/<id>/weeks/<n>/materials/v2`), `interactive_workbook` material type, provenance fields (`grounded_on_json`, `extracted_from_json`), workbook extraction service, and attempt persistence/scoring model (`encoach.course.plan.workbook.attempt`) with new endpoints for extraction, grounding, and attempts. Frontend: added `InteractiveWorkbook`, shared `PlanReader`, `GroundingBadge`, and true admin "View as Student" preview mode rendering the same student reader without requiring a student login. Operational fix: scanned exercise books (e.g. Headway Intermediate workbook) were failing with `Extracted no text from source`; implemented OCR fallback in `source_indexer.py` (tesseract + poppler + `pdf2image`/`pytesseract`) with per-page streaming to keep memory bounded, then re-indexed failed sources successfully (`indexed`, 109 chunks, ~207k chars each). Regression checks still pass (`smoke_assignment_workflow.py`, `smoke_entity_isolation.py`, `smoke_course_plan_rag.py`) and browser verification confirms sources now show green `Indexed` badges. **Current environment note:** OpenAI course-generation calls are returning `429 insufficient_quota`, so "Regenerate week materials" currently writes the intended skeleton fallback content with an explicit in-body note until API billing/quota is restored.
> - **2026-04-26 (dynamic course-section UX completion):** Wired the dynamic `Course → Sections → Classroom → Batch` structure all the way through the admin UX. New idempotent backend endpoint `POST /api/courses/<id>/sections/generate-defaults` creates the canonical **A/B/C** templates in one call (custom codes also supported via `{ "codes": [...] }`). `POST /api/batches` now auto-generates a unique `code` (built from course + section + term) when the client omits one — fixing the previous NOT-NULL/uniqueness failure on `op.batch.code` and unblocking the AdminBatches form flow. New frontend service helpers `generateDefaultCourseSections`, `updateCourseSection`, `deleteCourseSection`. `AdminCourses` now shows a **Sections** column with the live count + section codes + a “Manage Sections” dialog (list / add / inline edit / delete + one-click `Generate A · B · C`). `AdminBatches` exposes `Section` + `Term Key` columns plus matching selectors in both Create and Edit dialogs (the section list is fetched per-course and disabled until a course is picked). Smoke-tested live: generate-defaults created A/B/C (idempotent re-run added only the new `D`), section PATCH/DELETE worked, batch was created/updated/validated against course mismatch (`400 course_section_id does not belong to course_id`), and the Edit dialog hydrated `course_section_id` + `term_key` cleanly. Combined with the previous classroom-side cascade, the platform now fully implements the diagram: any classroom can host any sections from any courses, each producing exactly one canonical batch per `(classroom × course × section × term)` with roster + teachers auto-propagated.
> - **2026-04-26 (dynamic course-section classroom structure):** Implemented a dynamic **Course -> Sections -> Classroom hosting** model aligned to the LMS diagrams. Added new backend model `encoach.course.section` (per-course section templates, unique code per course, optional branch) with API CRUD routes in `encoach_lms_api/controllers/lms_core.py`: `GET/POST /api/courses/<course_id>/sections`, `PATCH/DELETE /api/courses/<course_id>/sections/<section_id>`. Extended batches with `course_section_id` + `term_key`, and classroom cascade assignment now supports `section_id`/`term_key` to create or reuse canonical batches per `(classroom × course × section × term)`. Added explicit alias route `POST /api/classrooms/<id>/assign-section` and new `GET /api/classrooms/<id>/sections`. Frontend types/services/pages updated accordingly: course section types, section-aware classroom assignment flow, and batches UI now shows section metadata. Smoke-tested successfully: same classroom received **Section A** and **Section B** of the same course, producing two distinct batches with roster auto-propagation.

View File

@@ -125,6 +125,7 @@ const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegist
// Courseware pages
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
const TeacherCourseInsights = lazy(() => import("@/pages/teacher/TeacherCourseInsights"));
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
@@ -182,6 +183,12 @@ const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
const FaqPage = lazy(() => import("@/pages/FaqPage"));
const NotFound = lazy(() => import("@/pages/NotFound"));
// Phase 2/3/4 (2026-04-30) — live sessions, Turnitin, test session console.
const LiveSessionsPage = lazy(() => import("@/pages/LiveSessionsPage"));
const LiveSessionRoom = lazy(() => import("@/pages/LiveSessionRoom"));
const TurnitinSettings = lazy(() => import("@/pages/admin/TurnitinSettings"));
const TeacherTestSessionConsole = lazy(() => import("@/pages/teacher/TeacherTestSessionConsole"));
function StudentSubscriptionPlaceholder() {
const navigate = useNavigate();
return (
@@ -238,6 +245,8 @@ const App = () => (
<Route element={<ProtectedRoute />}>
<Route path="/onboarding" element={<OnboardingWizard />} />
<Route path="/live" element={<LiveSessionsPage />} />
<Route path="/live/:id" element={<LiveSessionRoom />} />
</Route>
{/* Student routes */}
@@ -297,6 +306,8 @@ const App = () => (
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
<Route path="/teacher/courses/:courseId/insights" element={<TeacherCourseInsights />} />
<Route path="/teacher/exam/:assignmentId/console" element={<TeacherTestSessionConsole />} />
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
<Route path="/teacher/profile" element={<TeacherProfile />} />
@@ -331,6 +342,7 @@ const App = () => (
<Route path="/admin/timetable" element={<AdminTimetable />} />
<Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/turnitin" element={<TurnitinSettings />} />
<Route path="/admin/profile" element={<AdminProfileLms />} />
<Route path="/admin/privacy" element={<PrivacyCenter />} />
{/* Original academic pages */}

View File

@@ -125,6 +125,7 @@ const supportItems: NavItem[] = [
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
{ titleKey: "nav.turnitin", url: "/admin/turnitin", icon: Settings },
];
// ============= Reusable sidebar group =============

View File

@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { ExternalLink, Download, FileText, Video, Music, Image, Link2 } from "lucide-react";
import { API_BASE_URL } from "@/lib/api-client";
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
import ImageLightbox from "@/components/lesson/ImageLightbox";
interface MaterialViewerProps {
material: ChapterMaterial | null;
@@ -126,10 +127,11 @@ function ImageViewer({ material }: { material: ChapterMaterial }) {
if (!src) return <EmptyState message="No image available." />;
return (
<div className="flex items-center justify-center">
<img
<ImageLightbox
src={src}
alt={material.name}
className="max-w-full max-h-[70vh] rounded-lg border object-contain"
caption={material.description}
className="w-full"
/>
</div>
);
@@ -137,10 +139,13 @@ function ImageViewer({ material }: { material: ChapterMaterial }) {
function ArticleViewer({ material }: { material: ChapterMaterial }) {
if (!material.description) return <EmptyState message="No article content available." />;
const paragraphs = material.description.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
return (
<div className="prose prose-sm dark:prose-invert max-w-none p-4 rounded-lg border bg-card">
<div className="whitespace-pre-wrap">{material.description}</div>
</div>
<article className="prose prose-zinc dark:prose-invert max-w-none p-5 sm:p-6 rounded-2xl border bg-card shadow-sm leading-7 prose-headings:scroll-mt-20 prose-img:rounded-xl prose-a:text-primary">
{paragraphs.length > 0
? paragraphs.map((p, i) => <p key={i} className="whitespace-pre-wrap">{p}</p>)
: <p className="whitespace-pre-wrap">{material.description}</p>}
</article>
);
}

View File

@@ -3,7 +3,7 @@ import ExamPopup from "./student/ExamPopup";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
MessageSquare, Megaphone, Mail, TrendingUp, Video,
} from "lucide-react";
/**
@@ -16,10 +16,14 @@ const navGroups: NavGroup[] = [
labelKey: "sidebarGroup.learning",
items: [
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
// Phase 1 (2026-04-29): merged "My Courses" + "My Course
// Plans" into a single "My Learning" entry. Detail routes
// /student/courses/:id and /student/course-plans/:planId still
// resolve from the merged page's per-card links.
{ titleKey: "nav.myLearning", url: "/student/courses", icon: BookOpen },
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
{ titleKey: "nav.liveSessions", url: "/live", icon: Video },
],
},
{

View File

@@ -2,7 +2,7 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
import {
LayoutDashboard, BookOpen, ClipboardList,
CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Library, Sparkles,
Megaphone, Library, Sparkles, Video,
} from "lucide-react";
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
@@ -15,6 +15,7 @@ const navGroups: NavGroup[] = [
{ titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
{ titleKey: "nav.assignments", url: "/teacher/assignments", icon: ClipboardList },
{ titleKey: "nav.liveSessions", url: "/live", icon: Video },
],
},
{

View File

@@ -0,0 +1,114 @@
import { useEffect, useRef, useState } from "react";
interface AITutorAvatarProps {
/** Whether the avatar is currently speaking. Drives the mouth animation. */
speaking?: boolean;
/** Optional audio element whose playback drives `speaking` automatically. */
audio?: HTMLAudioElement | null;
/** Pixel diameter. Defaults to 96px (good for sidebar). */
size?: number;
/** Display name for the tooltip. */
name?: string;
}
/**
* Lightweight CSS-only "talking head" avatar. We avoid a 3D engine on
* purpose — most students open the platform on modest hardware and we
* already ship a TTS pipeline. The avatar mouth animates while
* `speaking` is true OR while the bound `audio` element is playing.
*
* Idle state: the head bobs gently and blinks every 4s.
* Speaking state: the mouth opens/closes at ~5Hz with random amplitude.
*/
export default function AITutorAvatar({
speaking = false,
audio,
size = 96,
name = "AI Tutor",
}: AITutorAvatarProps) {
const [audioActive, setAudioActive] = useState(false);
const blinkRef = useRef<NodeJS.Timeout | null>(null);
const [blink, setBlink] = useState(false);
useEffect(() => {
if (!audio) return;
const onPlay = () => setAudioActive(true);
const onStop = () => setAudioActive(false);
audio.addEventListener("play", onPlay);
audio.addEventListener("playing", onPlay);
audio.addEventListener("pause", onStop);
audio.addEventListener("ended", onStop);
return () => {
audio.removeEventListener("play", onPlay);
audio.removeEventListener("playing", onPlay);
audio.removeEventListener("pause", onStop);
audio.removeEventListener("ended", onStop);
};
}, [audio]);
useEffect(() => {
const tick = () => {
setBlink(true);
window.setTimeout(() => setBlink(false), 140);
};
blinkRef.current = setInterval(tick, 3500 + Math.random() * 1500);
return () => {
if (blinkRef.current) clearInterval(blinkRef.current);
};
}, []);
const isSpeaking = speaking || audioActive;
return (
<div
className="ai-tutor-avatar relative inline-flex items-center justify-center"
style={{ width: size, height: size }}
aria-label={name}
title={name}
>
<style>{`
@keyframes ai-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
@keyframes ai-mouth {
0% { transform: scaleY(0.2); }
50% { transform: scaleY(1); }
100% { transform: scaleY(0.4); }
}
@keyframes ai-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55); }
50% { box-shadow: 0 0 20px 6px rgba(245, 158, 11, 0); }
}
`}</style>
<div
className="rounded-full bg-gradient-to-br from-orange-200 to-amber-100 dark:from-orange-900/30 dark:to-amber-900/20 border border-amber-300/60 flex items-center justify-center"
style={{
width: size, height: size,
animation: `ai-bob 4s ease-in-out infinite${isSpeaking ? ", ai-glow 1.8s ease-out infinite" : ""}`,
}}
>
<svg viewBox="0 0 100 100" width={size * 0.7} height={size * 0.7} aria-hidden="true">
<ellipse cx="35" cy="42" rx="5" ry={blink ? 0.5 : 5} fill="#1f2937" />
<ellipse cx="65" cy="42" rx="5" ry={blink ? 0.5 : 5} fill="#1f2937" />
<circle cx="33" cy="40" r="1.5" fill="#fff" />
<circle cx="63" cy="40" r="1.5" fill="#fff" />
<ellipse
cx="50" cy="64"
rx="13"
ry={isSpeaking ? 7 : 2}
fill="#dc2626"
style={{
transformOrigin: "50px 64px",
animation: isSpeaking ? "ai-mouth 220ms ease-in-out infinite" : "none",
}}
/>
<circle cx="22" cy="60" r="3" fill="#fbbf24" opacity="0.5" />
<circle cx="78" cy="60" r="3" fill="#fbbf24" opacity="0.5" />
</svg>
</div>
{isSpeaking && (
<span className="absolute -bottom-1 inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full bg-orange-500 text-[10px] font-semibold text-white">
speaking
</span>
)}
</div>
);
}

View File

@@ -3,6 +3,8 @@ import { cn } from "@/lib/utils";
import type { CoursePlanMaterial, WorkbookBody } from "@/types";
import InteractiveWorkbook, { type WorkbookMode } from "./InteractiveWorkbook";
import ImageLightbox from "@/components/lesson/ImageLightbox";
import InfographicBlock, { inferTone } from "@/components/lesson/InfographicBlock";
export const SKILL_STYLE: Record<string, string> = {
reading: "bg-blue-100 text-blue-800 border-blue-200",
@@ -25,15 +27,68 @@ export function SkillBadge({ skill }: { skill: string }) {
);
}
const IMAGE_KEYS = new Set([
"image", "image_url", "imageurl", "img", "illustration",
"hero_image", "hero", "picture", "thumbnail",
]);
const IMAGES_KEYS = new Set([
"images", "illustrations", "pictures", "gallery",
]);
function isImageUrl(v: unknown): v is string {
if (typeof v !== "string") return false;
if (/^data:image\//.test(v)) return true;
if (/^https?:\/\//i.test(v) && /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(v)) return true;
return false;
}
function renderImage(v: unknown, alt?: string): React.ReactNode {
if (typeof v === "string" && isImageUrl(v)) {
return <ImageLightbox src={v} alt={alt} />;
}
if (v && typeof v === "object" && !Array.isArray(v)) {
const obj = v as Record<string, unknown>;
const src = (obj.url || obj.src || obj.image_url || obj.image) as string | undefined;
const cap = (obj.caption || obj.description || obj.alt) as string | undefined;
if (typeof src === "string" && isImageUrl(src)) {
return <ImageLightbox src={src} alt={alt || cap} caption={cap} />;
}
}
return null;
}
function isLikelyParagraph(s: string): boolean {
return s.length > 80 || /[.!?]\s/.test(s);
}
function renderString(value: string): React.ReactNode {
if (isImageUrl(value)) {
return <ImageLightbox src={value} />;
}
if (!isLikelyParagraph(value)) {
return <p className="leading-7">{value}</p>;
}
return (
<div className="space-y-3 leading-7">
{value.split(/\n{2,}/).map((para, i) => (
<p key={i} className="whitespace-pre-wrap">{para.trim()}</p>
))}
</div>
);
}
function renderAny(value: unknown): React.ReactNode {
if (value == null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
if (typeof value === "string") {
return renderString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return <p className="leading-7">{String(value)}</p>;
}
if (Array.isArray(value)) {
if (value.length === 0) return null;
return (
<ul className="list-disc ps-5 space-y-1">
<ul className="list-disc ps-5 space-y-1.5 leading-7">
{value.map((item, idx) => (
<li key={idx}>{renderAny(item)}</li>
))}
@@ -43,21 +98,78 @@ function renderAny(value: unknown): React.ReactNode {
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
return (
<div className="space-y-3">
{entries.map(([k, v]) => (
<div key={k}>
<h5 className="font-medium capitalize text-sm text-muted-foreground mb-1">
{k.replace(/_/g, " ")}
</h5>
<div className="text-sm">{renderAny(v)}</div>
</div>
))}
<div className="space-y-4">
{entries.map(([k, v]) => renderEntry(k, v))}
</div>
);
}
return null;
}
function renderEntry(key: string, value: unknown): React.ReactNode {
const norm = key.toLowerCase().replace(/[\s-]/g, "_");
const tone = inferTone(key);
const label = key.replace(/_/g, " ");
if (IMAGE_KEYS.has(norm)) {
const node = renderImage(value, label);
if (node) return <div key={key}>{node}</div>;
}
if (IMAGES_KEYS.has(norm) && Array.isArray(value)) {
return (
<div key={key} className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{value.map((item, i) => {
const node = renderImage(item, `${label} ${i + 1}`);
return node
? <div key={i}>{node}</div>
: <div key={i} className="text-sm text-muted-foreground">{renderAny(item)}</div>;
})}
</div>
);
}
if (tone) {
if (Array.isArray(value)) {
const items = value.map((v) =>
typeof v === "string" ? v
: (v && typeof v === "object" && "label" in (v as object) && "value" in (v as object))
? v as { label?: string; value?: string }
: { value: typeof v === "string" ? v : JSON.stringify(v) },
);
return (
<InfographicBlock
key={key}
title={titleCase(label)}
tone={tone}
items={items}
/>
);
}
return (
<InfographicBlock
key={key}
title={titleCase(label)}
tone={tone}
>
{renderAny(value)}
</InfographicBlock>
);
}
return (
<div key={key}>
<h5 className="font-semibold capitalize text-sm text-foreground/80 mb-1.5">
{label}
</h5>
<div className="text-[0.95rem]">{renderAny(value)}</div>
</div>
);
}
function titleCase(s: string): string {
return s.replace(/\w\S*/g, (w) => w[0].toUpperCase() + w.slice(1));
}
type MaterialForBook = Pick<
CoursePlanMaterial,
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
@@ -108,18 +220,23 @@ export default function MaterialBookView({
typeof material.plan_id === "number";
return (
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
<div className="rounded-2xl border bg-card p-5 sm:p-6 space-y-5 shadow-sm leading-relaxed">
{hasStructured ? (
renderAny(stripEmbedded(body))
) : (
<p className="text-sm whitespace-pre-wrap leading-7">
{material.body_text || "No content available yet."}
</p>
<div className="space-y-3 text-[0.95rem] whitespace-pre-wrap leading-7">
{(material.body_text || "No content available yet.")
.split(/\n{2,}/)
.map((p, i) => <p key={i}>{p}</p>)}
</div>
)}
{hasEmbedded && (
<div className="border-t pt-3">
<div className="text-sm font-medium mb-2">Practice</div>
<div className="border-t pt-4">
<div className="text-sm font-semibold mb-3 flex items-center gap-2">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
Practice
</div>
<InteractiveWorkbook
material={material as MaterialForBook}
bodyOverride={embeddedWorkbook!}

View File

@@ -0,0 +1,293 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
CheckCircle2,
Loader2,
MessageSquare,
Pin,
Send,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { communicationService } from "@/services/communication.service";
import type { DiscussionPost } from "@/types/communication";
/**
* Phase 1 (2026-04-29) — embeddable discussion thread.
*
* Used as a tab inside `StudentCourseDetail`, `StudentCoursePlanDetail`,
* and the equivalent teacher pages. The component takes either a
* `courseId` or a `planId`, calls the lookup-or-create endpoint to
* resolve a `DiscussionBoard`, then renders the existing
* board → posts → replies tree.
*
* Why a wrapper (instead of reusing `StudentDiscussionBoard`):
* - Removes the board picker (we already know the scope).
* - Lazy-creates the board the first time someone opens the tab,
* so admins don't have to seed boards for every course.
* - Lives in `components/discussion/` so both student and teacher
* pages can mount it.
*/
export interface DiscussionTabProps {
courseId?: number;
planId?: number;
/** Render the "new post" composer above the list. Default true. */
allowComposer?: boolean;
/** Hide the section header (useful when the parent already shows one). */
hideHeader?: boolean;
}
export default function DiscussionTab({
courseId,
planId,
allowComposer = true,
hideHeader = false,
}: DiscussionTabProps) {
const { t } = useTranslation();
const { toast } = useToast();
const qc = useQueryClient();
const scopeKey = courseId ? `course-${courseId}` : `plan-${planId}`;
const boardQuery = useQuery({
queryKey: ["discussion", "board", scopeKey],
enabled: !!(courseId || planId),
queryFn: () =>
courseId
? communicationService.getBoardForCourse(courseId)
: communicationService.getBoardForPlan(planId!),
});
const board = boardQuery.data;
const postsQuery = useQuery({
queryKey: ["discussion", "posts", board?.id],
enabled: !!board?.id,
queryFn: () => communicationService.listPosts(board!.id, { page: 1, size: 50 }),
});
const posts: DiscussionPost[] = postsQuery.data?.items ?? [];
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const submit = async () => {
if (!board || !content.trim()) return;
setSubmitting(true);
try {
await communicationService.createPost(board.id, {
board_id: board.id,
title: title.trim() || undefined,
content,
});
setTitle("");
setContent("");
qc.invalidateQueries({ queryKey: ["discussion", "posts", board.id] });
toast({
title: t("discussion.posted", "Posted"),
description: t(
"discussion.postedHint",
"Your message is visible to everyone in this course.",
),
});
} catch (err) {
toast({
title: t("common.error", "Error"),
description: t("discussion.postFailed", "Failed to post."),
variant: "destructive",
});
} finally {
setSubmitting(false);
}
};
if (boardQuery.isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (boardQuery.isError) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t("discussion.loadFailed", "Failed to load discussion.")}
</div>
);
}
return (
<div className="space-y-4">
{!hideHeader && (
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm">
{t("discussion.title", "Discussion")}
</h3>
{board?.post_count !== undefined && (
<Badge variant="secondary">{board.post_count}</Badge>
)}
</div>
)}
{allowComposer && board?.allow_student_posts !== false && (
<Card>
<CardContent className="pt-4 space-y-2">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("discussion.titlePlaceholder", "Title (optional)")}
/>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t(
"discussion.contentPlaceholder",
"Ask a question or share something with your classmates...",
)}
rows={3}
/>
<div className="flex justify-end">
<Button
size="sm"
onClick={submit}
disabled={submitting || !content.trim()}
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-2" />
{t("discussion.post", "Post")}
</>
)}
</Button>
</div>
</CardContent>
</Card>
)}
{postsQuery.isLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : posts.length === 0 ? (
<div className="text-center py-10 border rounded-2xl bg-muted/20">
<MessageSquare className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{t(
"discussion.empty",
"No posts yet. Be the first to start the discussion!",
)}
</p>
</div>
) : (
<div className="space-y-3">
{posts.map((p) => (
<PostCard key={p.id} post={p} boardId={board!.id} />
))}
</div>
)}
</div>
);
}
function PostCard({ post, boardId }: { post: DiscussionPost; boardId: number }) {
const { t } = useTranslation();
const [showReply, setShowReply] = useState(false);
const [reply, setReply] = useState("");
const [submitting, setSubmitting] = useState(false);
const qc = useQueryClient();
const submitReply = async () => {
if (!reply.trim()) return;
setSubmitting(true);
try {
await communicationService.createPost(boardId, {
board_id: boardId,
parent_id: post.id,
content: reply,
});
setReply("");
setShowReply(false);
qc.invalidateQueries({ queryKey: ["discussion", "posts", boardId] });
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-2">
<div
className={`p-4 rounded-2xl border bg-card ${
post.is_pinned ? "border-primary/30 bg-primary/5" : ""
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
{post.title && <p className="font-medium">{post.title}</p>}
<p className="text-sm mt-1 whitespace-pre-wrap">{post.content}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground flex-wrap">
<span className="font-medium">{post.author_name}</span>
<span>·</span>
<span>{new Date(post.created_at).toLocaleString()}</span>
{post.is_pinned && (
<Badge variant="secondary" className="text-xs">
<Pin className="h-3 w-3 mr-1" />
{t("discussion.pinned", "Pinned")}
</Badge>
)}
{post.is_resolved && (
<Badge variant="default" className="text-xs">
<CheckCircle2 className="h-3 w-3 mr-1" />
{t("discussion.resolved", "Resolved")}
</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowReply((s) => !s)}
>
{t("discussion.reply", "Reply")}
</Button>
</div>
{showReply && (
<div className="flex gap-2 mt-3 pt-3 border-t">
<Input
value={reply}
onChange={(e) => setReply(e.target.value)}
placeholder={t(
"discussion.replyPlaceholder",
"Write a reply...",
)}
className="flex-1"
/>
<Button
size="sm"
onClick={submitReply}
disabled={submitting || !reply.trim()}
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
)}
</div>
{post.replies && post.replies.length > 0 && (
<div className="ml-6 space-y-2 border-l-2 pl-3 border-muted">
{post.replies.map((r) => (
<PostCard key={r.id} post={r} boardId={boardId} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useState } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Maximize2 } from "lucide-react";
/**
* Click-to-zoom image used inside lesson bodies.
*
* The lesson viewer historically rendered raw <img> tags from the AI body
* which made any illustration tiny and uncroppable on mobile. Wrapping
* those images in a lightbox gives students a one-click full-size view
* (matching the "Course content polish" requirement) without changing
* the underlying body shape coming from the AI pipeline.
*/
export default function ImageLightbox({
src,
alt,
caption,
className,
}: {
src: string;
alt?: string;
caption?: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
return (
<>
<figure
className={`group relative inline-block max-w-full cursor-zoom-in rounded-xl overflow-hidden border bg-muted/30 shadow-sm hover:shadow-md transition ${className ?? ""}`}
onClick={() => setOpen(true)}
>
<img
src={src}
alt={alt || ""}
className="block max-h-[420px] w-auto object-contain mx-auto"
loading="lazy"
/>
<span className="absolute top-2 end-2 bg-black/60 text-white rounded-md p-1 opacity-0 group-hover:opacity-100 transition">
<Maximize2 className="h-3.5 w-3.5" />
</span>
{caption && (
<figcaption className="text-xs text-muted-foreground p-2 border-t bg-background/70">
{caption}
</figcaption>
)}
</figure>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-5xl p-0 bg-black border-none">
<img
src={src}
alt={alt || ""}
className="w-full max-h-[85vh] object-contain bg-black"
/>
{caption && (
<div className="text-xs text-white/80 p-3 bg-black/80">{caption}</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,105 @@
import {
Lightbulb,
Target,
CheckCircle2,
AlertTriangle,
Info,
Star,
Sparkles,
type LucideIcon,
} from "lucide-react";
type Tone = "tip" | "info" | "warn" | "success" | "highlight" | "example";
const TONE_STYLE: Record<Tone, { wrapper: string; icon: LucideIcon; iconClass: string; title: string }> = {
tip: { wrapper: "border-amber-200 bg-amber-50", icon: Lightbulb, iconClass: "text-amber-600", title: "text-amber-900" },
info: { wrapper: "border-blue-200 bg-blue-50", icon: Info, iconClass: "text-blue-600", title: "text-blue-900" },
warn: { wrapper: "border-rose-200 bg-rose-50", icon: AlertTriangle, iconClass: "text-rose-600", title: "text-rose-900" },
success: { wrapper: "border-emerald-200 bg-emerald-50", icon: CheckCircle2, iconClass: "text-emerald-600", title: "text-emerald-900" },
highlight: { wrapper: "border-purple-200 bg-purple-50", icon: Sparkles, iconClass: "text-purple-600", title: "text-purple-900" },
example: { wrapper: "border-slate-200 bg-slate-50", icon: Star, iconClass: "text-slate-600", title: "text-slate-900" },
};
/**
* Visual callout block used inside lessons. The AI course generator now
* emits keys like `did_you_know`, `key_takeaway`, `tip`, `warning`,
* `objective`, `example`, etc. Instead of dumping them as `<h5>label</h5>
* <p>text</p>` we render a coloured card with an icon — that's the
* "infographic" treatment the design refresh asked for.
*/
export default function InfographicBlock({
title,
tone = "info",
children,
items,
}: {
title: string;
tone?: Tone;
children?: React.ReactNode;
items?: (string | { label?: string; value?: string })[];
}) {
const style = TONE_STYLE[tone];
const Icon = style.icon;
return (
<div className={`rounded-xl border ${style.wrapper} p-4 shadow-sm space-y-2`}>
<div className="flex items-center gap-2">
<span className="rounded-full bg-white/80 p-1.5 shadow-sm">
<Icon className={`h-4 w-4 ${style.iconClass}`} />
</span>
<div className={`text-sm font-semibold ${style.title}`}>{title}</div>
</div>
{children && <div className="text-sm leading-7 text-foreground/90">{children}</div>}
{items && items.length > 0 && (
<ul className="space-y-1.5">
{items.map((it, i) => {
if (typeof it === "string") {
return (
<li key={i} className="flex items-start gap-2 text-sm">
<Target className="h-3.5 w-3.5 mt-1 text-muted-foreground shrink-0" />
<span>{it}</span>
</li>
);
}
return (
<li key={i} className="flex items-start gap-2 text-sm">
<Target className="h-3.5 w-3.5 mt-1 text-muted-foreground shrink-0" />
<span>
{it.label && <strong className="me-1">{it.label}:</strong>}
{it.value}
</span>
</li>
);
})}
</ul>
)}
</div>
);
}
const TONE_BY_KEY: Record<string, Tone> = {
tip: "tip",
hint: "tip",
did_you_know: "tip",
fun_fact: "tip",
key_takeaway: "highlight",
takeaway: "highlight",
summary: "highlight",
highlight: "highlight",
objective: "info",
objectives: "info",
goal: "info",
goals: "info",
warning: "warn",
pitfall: "warn",
caution: "warn",
common_mistakes: "warn",
success_criteria: "success",
remember: "success",
example: "example",
examples: "example",
};
export function inferTone(key: string): Tone | null {
const norm = key.toLowerCase().replace(/[\s-]/g, "_");
return TONE_BY_KEY[norm] ?? null;
}

View File

@@ -0,0 +1,154 @@
import { useState, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { PenTool, Upload, FileText, CheckCircle2, AlertTriangle } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
import { handwrittenService } from "@/services/handwritten.service";
interface Props {
/** ID of the encoach.course.plan.material the submission belongs to. */
materialId: number;
/** Optional title shown in the card header. */
title?: string;
}
/**
* Panel embedded in a student's material/assignment view that lets them
* upload up to 10 photographs of their handwritten solution and instantly
* see the AI grading feedback. Best suited for math/science tasks.
*/
export default function HandwrittenSubmissionPanel({ materialId, title = "Upload handwritten solution" }: Props) {
const { toast } = useToast();
const qc = useQueryClient();
const [files, setFiles] = useState<File[]>([]);
const [note, setNote] = useState("");
const inputRef = useRef<HTMLInputElement | null>(null);
const { data: latest, isLoading } = useQuery({
queryKey: ["handwritten", materialId],
queryFn: () => handwrittenService.mySubmission(materialId),
enabled: !!materialId,
});
const upload = useMutation({
mutationFn: () => handwrittenService.upload(materialId, files, note),
onSuccess: () => {
toast({ title: "Submission uploaded", description: "AI grading is in progress." });
setFiles([]);
setNote("");
qc.invalidateQueries({ queryKey: ["handwritten", materialId] });
},
onError: (e) => toast({
title: "Upload failed",
description: e instanceof Error ? e.message : "Try again.",
variant: "destructive",
}),
});
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<PenTool className="h-4 w-4 text-primary" />
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{latest && (
<div className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Last submission
</span>
<Badge variant="outline" className="capitalize">{latest.status}</Badge>
</div>
{latest.ai_score != null && (
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span>AI score: <b>{latest.ai_score.toFixed(1)}</b> / 100</span>
</div>
)}
{latest.teacher_score != null && (
<div className="flex items-center gap-2 text-primary">
<CheckCircle2 className="h-4 w-4" />
<span>Teacher score: <b>{latest.teacher_score.toFixed(1)}</b> / 100</span>
</div>
)}
{latest.ai_feedback && (
<div className="text-xs whitespace-pre-wrap text-muted-foreground">{latest.ai_feedback}</div>
)}
{latest.image_urls.length > 0 && (
<div className="grid grid-cols-3 gap-2 mt-2">
{latest.image_urls.map((u) => (
<a key={u} href={u} target="_blank" rel="noreferrer" className="block">
<img src={u} alt="page" className="rounded border w-full h-20 object-cover" />
</a>
))}
</div>
)}
</div>
)}
<div>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => setFiles(Array.from(e.target.files || []).slice(0, 10))}
/>
<Button variant="outline" size="sm" onClick={() => inputRef.current?.click()}>
<Upload className="h-4 w-4 me-1" />
Choose pages ({files.length})
</Button>
</div>
{files.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{files.map((f, i) => (
<div key={i} className="relative">
<img
src={URL.createObjectURL(f)}
alt={f.name}
className="rounded border w-full h-20 object-cover"
/>
<span className="absolute inset-x-0 bottom-0 bg-black/50 text-white text-[10px] px-1 truncate">
{f.name}
</span>
</div>
))}
</div>
)}
<div>
<Textarea
placeholder="Optional note for the grader (e.g. 'Question 3, side B')"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
Up to 10 images, JPEG/PNG. The AI grader uses OCR + math-aware scoring.
</p>
<Button
onClick={() => upload.mutate()}
disabled={files.length === 0 || upload.isPending}
>
{upload.isPending ? "Uploading…" : "Submit"}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,296 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { Sparkles, Wand2, ChevronRight, SlidersHorizontal } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/components/ui/use-toast";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger,
} from "@/components/ui/dialog";
import { personalizedPlanService } from "@/services/personalizedPlan.service";
const SKILLS = ["reading", "listening", "writing", "speaking"] as const;
/**
* Dashboard widget that surfaces the student's weakest skills and lets them
* generate (or re-open) a personalized AI training plan in one click.
*
* The widget always renders the weakness summary so students see what their
* weakest skill is even before they ever generate a plan. Once generated, a
* deep link to the new plan replaces the Generate button.
*/
export default function PersonalizedPlanCard() {
const qc = useQueryClient();
const { toast } = useToast();
const [pending, setPending] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["personalized-plan"],
queryFn: () => personalizedPlanService.fetch(),
});
const generate = useMutation({
mutationFn: () => personalizedPlanService.generate({ total_weeks: 4 }),
onMutate: () => setPending(true),
onSettled: () => setPending(false),
onSuccess: (res) => {
toast({
title: "Personalized plan ready",
description: res.plan?.name || "A tailored 4-week plan has been generated.",
});
qc.invalidateQueries({ queryKey: ["personalized-plan"] });
qc.invalidateQueries({ queryKey: ["student-learning"] });
qc.invalidateQueries({ queryKey: ["student-course-plans"] });
},
onError: (e) => {
toast({
title: "Could not generate plan",
description: e instanceof Error ? e.message : "Try again later.",
variant: "destructive",
});
},
});
if (isLoading) {
return (
<Card>
<CardHeader><CardTitle className="text-lg">Personalized AI plan</CardTitle></CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-9 w-32" />
</CardContent>
</Card>
);
}
const weakness = data?.weakness;
const plan = data?.plan;
const skillRows = SKILLS.map((s) => {
const v = weakness?.averages?.[s];
const pct = v == null ? 0 : Math.min(100, Math.round((v / 9) * 100));
return { skill: s, value: v, pct };
});
const weakest = weakness?.weakest_skill;
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 via-transparent to-transparent">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Personalized AI plan
</CardTitle>
{plan ? (
<Badge className="bg-primary/15 text-primary border-primary/30">Ready</Badge>
) : (
<Badge variant="secondary">Suggested</Badge>
)}
</CardHeader>
<CardContent className="space-y-4">
{weakness && weakness.attempts > 0 ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Based on <b>{weakness.attempts}</b> attempt
{weakness.attempts === 1 ? "" : "s"}, your weakest skill is{" "}
<b className="capitalize text-foreground">{weakest || "n/a"}</b>.
</p>
<div className="space-y-2">
{skillRows.map((r) => (
<div key={r.skill} className="flex items-center gap-3">
<span className="w-20 text-xs capitalize text-muted-foreground">{r.skill}</span>
<Progress value={r.pct} className={`h-2 flex-1 ${weakest === r.skill ? "[&>div]:bg-orange-500" : ""}`} />
<span className="text-xs font-mono w-10 text-end">
{r.value == null ? "" : r.value.toFixed(1)}
</span>
</div>
))}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Take a graded exam first, then come back here for a tailored plan.
</p>
)}
<div className="flex items-center gap-2 flex-wrap">
{plan ? (
<>
<Button asChild size="sm" className="gap-2">
<Link to={`/student/course-plans/${plan.id}`}>
Open plan <ChevronRight className="h-4 w-4" />
</Link>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => generate.mutate()}
disabled={pending || generate.isPending}
>
<Wand2 className="h-4 w-4 me-1" />
Regenerate
</Button>
</>
) : (
<Button
size="sm"
className="gap-2"
onClick={() => generate.mutate()}
disabled={pending || generate.isPending || !weakness?.attempts}
>
<Wand2 className="h-4 w-4" />
{generate.isPending ? "Generating…" : "Quick generate"}
</Button>
)}
<CustomizePlanDialog
disabled={pending || generate.isPending || !weakness?.attempts}
onGenerated={() => {
qc.invalidateQueries({ queryKey: ["personalized-plan"] });
qc.invalidateQueries({ queryKey: ["student-learning"] });
qc.invalidateQueries({ queryKey: ["student-course-plans"] });
}}
/>
</div>
</CardContent>
</Card>
);
}
const GRAMMAR_OPTIONS = [
"Tenses",
"Conditionals",
"Articles",
"Prepositions",
"Modals",
"Passive voice",
"Reported speech",
"Phrasal verbs",
"Subject-verb agreement",
];
/**
* "Customize my AI plan" dialog. Lets the student pick the CEFR target,
* number of weeks, a free-form focus statement, and grammar checkboxes.
* Backend already accepts all of these via POST /api/student/personalized-plan.
*/
function CustomizePlanDialog({
disabled, onGenerated,
}: { disabled: boolean; onGenerated: () => void }) {
const [open, setOpen] = useState(false);
const [cefr, setCefr] = useState("a2");
const [weeks, setWeeks] = useState(4);
const [focus, setFocus] = useState("");
const [grammar, setGrammar] = useState<string[]>([]);
const { toast } = useToast();
const m = useMutation({
mutationFn: () => personalizedPlanService.generate({
cefr_level: cefr,
total_weeks: weeks,
focus: focus.trim() || undefined,
grammar_focus: grammar.length ? grammar : undefined,
}),
onSuccess: (res) => {
toast({
title: "Custom plan ready",
description: res.plan?.name || "Your tailored plan was generated.",
});
onGenerated();
setOpen(false);
},
onError: (e) => toast({
title: "Could not generate plan",
description: e instanceof Error ? e.message : "Try again later.",
variant: "destructive",
}),
});
const toggleGrammar = (g: string) => {
setGrammar((prev) =>
prev.includes(g) ? prev.filter((x) => x !== g) : [...prev, g]);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="ghost" disabled={disabled}>
<SlidersHorizontal className="h-4 w-4 me-1" />Customize
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Customize my AI plan</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>CEFR level</Label>
<Select value={cefr} onValueChange={setCefr}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{["a1", "a2", "b1", "b2", "c1", "c2"].map((l) => (
<SelectItem key={l} value={l}>{l.toUpperCase()}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Weeks</Label>
<Input
type="number" min={1} max={12}
value={weeks}
onChange={(e) => setWeeks(Math.max(1, Math.min(12, Number(e.target.value) || 1)))}
/>
</div>
</div>
<div>
<Label>Focus (optional)</Label>
<Textarea
rows={3}
value={focus}
onChange={(e) => setFocus(e.target.value)}
placeholder="e.g. Improve my IELTS Writing Task 2 essays"
/>
</div>
<div>
<Label>Grammar focus</Label>
<div className="grid grid-cols-2 gap-2 mt-1.5">
{GRAMMAR_OPTIONS.map((g) => (
<label
key={g}
className="flex items-center gap-2 text-sm cursor-pointer p-1.5 rounded hover:bg-muted/50"
>
<Checkbox
checked={grammar.includes(g)}
onCheckedChange={() => toggleGrammar(g)}
/>
{g}
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={() => m.mutate()} disabled={m.isPending}>
<Wand2 className="h-4 w-4 me-1" />
{m.isPending ? "Generating…" : "Generate"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,12 +4,22 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
// "Mixed" button language for the 2026-04-29 visual-identity refresh:
// - `default` → dark charcoal CTA (`--cta`). The strongest action on
// the page. Existing `<Button>` callsites pick this up
// automatically with no source change.
// - `primary` → brand orange (`--primary`). Used to highlight a
// promotional / accent action ("Generate", "Continue").
// - `outline` → orange-tinted border + peachy hover, reads as
// "secondary CTA" without competing visually with the
// dark default.
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-cta text-cta-foreground hover:bg-cta/90",
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
@@ -18,8 +28,8 @@ const buttonVariants = cva(
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
sm: "h-9 px-3",
lg: "h-11 px-8",
icon: "h-10 w-10",
},
},

View File

@@ -2,8 +2,19 @@ import * as React from "react";
import { cn } from "@/lib/utils";
// 2026-04-29 visual-identity refresh: cards are now `rounded-2xl`
// (~28px) with a soft shadow and no hard border by default — matching
// the reference's pillowy card language. Pages that *want* a border
// can still pass `border` via `className` and it will compose.
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
<div
ref={ref}
className={cn(
"rounded-2xl bg-card text-card-foreground shadow-[0_2px_8px_-2px_hsl(220_25%_12%_/_0.06),0_8px_32px_-12px_hsl(220_25%_12%_/_0.08)] dark:shadow-[0_2px_8px_-2px_hsl(0_0%_0%_/_0.4),0_8px_32px_-12px_hsl(0_0%_0%_/_0.5)]",
className,
)}
{...props}
/>
));
Card.displayName = "Card";

View File

@@ -0,0 +1,113 @@
import { useEffect, useRef } from "react";
import {
examSecurityService,
type SecurityEventType,
type SecuritySeverity,
} from "@/services/examSecurity.service";
interface UseExamSecurityOpts {
attemptId?: number | null;
examId?: number | null;
/** Fail-soft default true — set false to disable all listeners. */
enabled?: boolean;
/** Called when the backend returns auto_locked=true so the runner can show a lock screen. */
onAutoLock?: () => void;
}
/**
* Hook that wires the SOFT online-test security tier into any exam runner
* page. It registers DOM event listeners for tab/window focus, paste,
* copy, cut, contextmenu, fullscreen exit, and a few keyboard shortcuts,
* then POSTs each event to /api/exam/security/event with the right
* severity. The backend tracks the log per attempt; the teacher console
* surfaces it later.
*
* Anti-cheat is intentionally non-blocking — we never break the page,
* we just record what happened so the teacher can decide.
*/
export function useExamSecurity({
attemptId,
examId,
enabled = true,
onAutoLock,
}: UseExamSecurityOpts) {
const lastSent = useRef<Record<string, number>>({});
useEffect(() => {
if (!enabled || !attemptId) return;
const post = async (
type: SecurityEventType,
severity: SecuritySeverity = "warning",
payload?: Record<string, unknown>,
) => {
const now = Date.now();
const k = `${type}:${severity}`;
if (now - (lastSent.current[k] || 0) < 1500) return;
lastSent.current[k] = now;
try {
const res = await examSecurityService.postEvent({
attempt_id: attemptId,
exam_id: examId ?? null,
event_type: type,
severity,
payload,
});
if (res.auto_locked) onAutoLock?.();
} catch {
// Anti-cheat must never break the exam runner.
}
};
const onBlur = () => post("window_blur", "alert");
const onFocus = () => post("window_focus", "info");
const onVisibility = () => {
if (document.hidden) post("tab_blur", "alert");
else post("tab_focus", "info");
};
const onPaste = (e: ClipboardEvent) => {
e.preventDefault();
post("paste", "alert", { length: e.clipboardData?.getData("text").length });
};
const onCopy = () => post("copy", "warning");
const onCut = () => post("cut", "alert");
const onContext = (e: MouseEvent) => {
e.preventDefault();
post("context_menu", "warning");
};
const onFsChange = () => {
if (!document.fullscreenElement) post("fullscreen_exit", "warning");
};
const onKey = (e: KeyboardEvent) => {
const meta = e.metaKey || e.ctrlKey;
if (meta && ["c", "x", "v", "p", "s"].includes(e.key.toLowerCase())) {
post("shortcut", "warning", { key: e.key });
}
if (e.key === "F12") post("shortcut", "alert", { key: "F12" });
if (meta && e.shiftKey && ["i", "j", "c"].includes(e.key.toLowerCase())) {
post("shortcut", "alert", { key: `${e.shiftKey ? "shift+" : ""}${e.key}` });
}
};
window.addEventListener("blur", onBlur);
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("paste", onPaste);
window.addEventListener("copy", onCopy);
window.addEventListener("cut", onCut);
window.addEventListener("contextmenu", onContext);
document.addEventListener("fullscreenchange", onFsChange);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("blur", onBlur);
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("paste", onPaste);
window.removeEventListener("copy", onCopy);
window.removeEventListener("cut", onCut);
window.removeEventListener("contextmenu", onContext);
document.removeEventListener("fullscreenchange", onFsChange);
window.removeEventListener("keydown", onKey);
};
}, [enabled, attemptId, examId, onAutoLock]);
}

View File

@@ -61,6 +61,9 @@ const ar: Translations = {
courses: "الدورات",
myCourses: "دوراتي",
myCoursePlans: "خططي الدراسية",
myLearning: "تعلّمي",
liveSessions: "الجلسات المباشرة",
turnitin: "تيرنيتن",
students: "الطلاب",
teachers: "المعلمون",
branches: "الفروع",
@@ -205,6 +208,7 @@ const ar: Translations = {
totalChapters: "إجمالي الفصول",
myCourses: "دوراتي",
myCoursePlans: "خططي الدراسية",
myLearning: "تعلّمي",
noCoursePlans: "لم يتم إسناد أي خطة دراسية لك بعد.",
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",

View File

@@ -108,6 +108,9 @@ const en: Translations = {
courses: "Courses",
myCourses: "My Courses",
myCoursePlans: "My Course Plans",
myLearning: "My Learning",
liveSessions: "Live Sessions",
turnitin: "Turnitin",
students: "Students",
teachers: "Teachers",
branches: "Branches",

View File

@@ -2,30 +2,51 @@
@tailwind components;
@tailwind utilities;
/* -------------------------------------------------------------------------
* Visual identity ("Duvex"-style refresh, 2026-04-29)
*
* Light mode is a warm cream background, pure-white cards, bright orange
* accent (`--primary`), and a near-black charcoal CTA (`--cta`) used as
* the default Button color. The sidebar is now WHITE with a dark active
* pill instead of the former dark-navy chrome.
*
* `--primary` (orange) is intentionally preserved as the platform's
* highlight color so every existing `bg-primary` / `text-primary` token
* (stat icons, badges, focus rings, charts) keeps its expected meaning.
* The new `--cta` token drives `<Button>`'s default variant — yielding
* the "mixed" button language: dark CTAs, orange highlights/accents.
* ------------------------------------------------------------------------- */
@layer base {
:root {
--background: 30 15% 97%;
--foreground: 240 20% 13%;
--background: 28 35% 97%;
--foreground: 220 25% 12%;
--card: 0 0% 100%;
--card-foreground: 240 20% 13%;
--card-foreground: 220 25% 12%;
--popover: 0 0% 100%;
--popover-foreground: 240 20% 13%;
--popover-foreground: 220 25% 12%;
--primary: 8 50% 54%;
/* Brand orange — visual identity highlight (icons, badges, charts,
focus rings, "primary" Button variant). */
--primary: 22 95% 55%;
--primary-foreground: 0 0% 100%;
--secondary: 30 12% 94%;
--secondary-foreground: 240 20% 13%;
/* Dark charcoal — default Button CTA color and active sidebar pill. */
--cta: 220 25% 12%;
--cta-foreground: 0 0% 100%;
--muted: 30 10% 93%;
--muted-foreground: 240 10% 46%;
--secondary: 30 20% 95%;
--secondary-foreground: 220 25% 12%;
--accent: 42 40% 92%;
--accent-foreground: 42 40% 28%;
--muted: 30 18% 94%;
--muted-foreground: 220 10% 45%;
--destructive: 0 72% 51%;
/* Peachy hover background used by ghost buttons & dropdown items. */
--accent: 22 95% 95%;
--accent-foreground: 22 90% 30%;
--destructive: 0 75% 55%;
--destructive-foreground: 0 0% 100%;
--success: 152 60% 42%;
@@ -37,24 +58,27 @@
--info: 220 70% 52%;
--info-foreground: 0 0% 100%;
--border: 30 10% 90%;
--input: 30 10% 90%;
--ring: 8 50% 54%;
--border: 30 15% 90%;
--input: 30 15% 90%;
--ring: 22 95% 55%;
--radius: 0.625rem;
/* 16px base radius so cards/inputs/buttons read soft, matches the
reference's pill-and-rounded-card language. */
--radius: 1rem;
--sidebar-background: 240 20% 16%;
--sidebar-foreground: 240 10% 90%;
--sidebar-primary: 8 50% 58%;
/* WHITE sidebar with charcoal active pill. */
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 220 15% 30%;
--sidebar-primary: 220 25% 12%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 18% 22%;
--sidebar-accent-foreground: 8 40% 78%;
--sidebar-border: 240 18% 20%;
--sidebar-ring: 8 50% 58%;
--sidebar-accent: 30 25% 95%;
--sidebar-accent-foreground: 220 25% 12%;
--sidebar-border: 30 15% 92%;
--sidebar-ring: 22 95% 55%;
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
warm/cool axis so pairs read well together when stacked. */
--chart-1: 8 50% 54%;
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on
the warm/cool axis so pairs read well together when stacked. */
--chart-1: 22 95% 55%;
--chart-2: 220 70% 52%;
--chart-3: 152 60% 42%;
--chart-4: 38 92% 50%;
@@ -62,53 +86,58 @@
}
.dark {
--background: 240 22% 7%;
--foreground: 30 12% 95%;
--background: 220 22% 8%;
--foreground: 30 15% 95%;
--card: 240 20% 11%;
--card-foreground: 30 12% 95%;
--card: 220 18% 12%;
--card-foreground: 30 15% 95%;
--popover: 240 20% 11%;
--popover-foreground: 30 12% 95%;
--popover: 220 18% 12%;
--popover-foreground: 30 15% 95%;
--primary: 8 52% 50%;
--primary-foreground: 0 0% 100%;
--primary: 22 95% 60%;
--primary-foreground: 220 25% 8%;
--secondary: 240 18% 16%;
--secondary-foreground: 30 12% 95%;
/* In dark mode the CTA inverts to light/cream so it still reads as
"the strong action" against the dark canvas. */
--cta: 30 15% 95%;
--cta-foreground: 220 25% 12%;
--muted: 240 18% 16%;
--muted-foreground: 240 8% 58%;
--secondary: 220 15% 16%;
--secondary-foreground: 30 15% 95%;
--accent: 42 35% 20%;
--accent-foreground: 42 40% 72%;
--muted: 220 15% 16%;
--muted-foreground: 220 10% 60%;
--destructive: 0 62% 40%;
--accent: 22 50% 22%;
--accent-foreground: 22 90% 75%;
--destructive: 0 65% 45%;
--destructive-foreground: 0 0% 100%;
--success: 152 45% 35%;
--success: 152 50% 40%;
--success-foreground: 0 0% 100%;
--warning: 38 70% 45%;
--warning: 38 75% 50%;
--warning-foreground: 0 0% 100%;
--info: 220 55% 42%;
--info: 220 60% 50%;
--info-foreground: 0 0% 100%;
--border: 240 18% 18%;
--input: 240 18% 18%;
--ring: 8 52% 50%;
--border: 220 15% 18%;
--input: 220 15% 18%;
--ring: 22 95% 60%;
--sidebar-background: 240 22% 9%;
--sidebar-foreground: 240 10% 88%;
--sidebar-primary: 8 50% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 18% 15%;
--sidebar-accent-foreground: 8 40% 75%;
--sidebar-border: 240 18% 13%;
--sidebar-ring: 8 50% 55%;
--sidebar-background: 220 18% 10%;
--sidebar-foreground: 220 10% 80%;
--sidebar-primary: 22 95% 60%;
--sidebar-primary-foreground: 220 25% 8%;
--sidebar-accent: 220 15% 16%;
--sidebar-accent-foreground: 30 15% 95%;
--sidebar-border: 220 15% 14%;
--sidebar-ring: 22 95% 60%;
--chart-1: 8 52% 62%;
--chart-1: 22 95% 65%;
--chart-2: 220 65% 65%;
--chart-3: 152 50% 55%;
--chart-4: 38 75% 60%;

View File

@@ -0,0 +1,347 @@
import { useEffect, useRef, useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft, Users, Square, AlertTriangle, MicOff, VideoOff, UserX,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { liveSessionService } from "@/services/liveSession.service";
import { useAuth } from "@/contexts/AuthContext";
declare global {
interface Window {
JitsiMeetExternalAPI?: any;
}
}
const JITSI_DOMAIN = "meet.jit.si";
const JITSI_SCRIPT = "https://meet.jit.si/external_api.js";
/**
* Loads the Jitsi external_api.js once and resolves to the global ctor.
*/
function loadJitsi(): Promise<any> {
if (window.JitsiMeetExternalAPI) return Promise.resolve(window.JitsiMeetExternalAPI);
return new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${JITSI_SCRIPT}"]`);
if (existing) {
existing.addEventListener("load", () => resolve(window.JitsiMeetExternalAPI));
existing.addEventListener("error", reject);
return;
}
const script = document.createElement("script");
script.src = JITSI_SCRIPT;
script.async = true;
script.onload = () => resolve(window.JitsiMeetExternalAPI);
script.onerror = reject;
document.head.appendChild(script);
});
}
export default function LiveSessionRoom() {
const { id } = useParams<{ id: string }>();
const sessionId = Number(id);
const { user } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
const qc = useQueryClient();
const [api, setApi] = useState<any>(null);
const [connectErr, setConnectErr] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const { data: session, isLoading } = useQuery({
queryKey: ["live-session", sessionId],
queryFn: () => liveSessionService.read(sessionId),
enabled: !!sessionId,
});
const joinMut = useMutation({
mutationFn: () => liveSessionService.join(sessionId),
});
const leaveMut = useMutation({
mutationFn: () => liveSessionService.leave(sessionId),
});
const endMut = useMutation({
mutationFn: () => liveSessionService.end(sessionId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["live-session", sessionId] });
toast({ title: "Session ended" });
},
});
useEffect(() => {
if (!session || !containerRef.current) return;
let alive = true;
let local: any = null;
(async () => {
try {
const Ctor = await loadJitsi();
if (!alive) return;
const join = await joinMut.mutateAsync();
if (!alive) return;
local = new Ctor(JITSI_DOMAIN, {
roomName: session.room_name,
parentNode: containerRef.current,
width: "100%",
height: 600,
userInfo: {
displayName: user?.name || user?.email || "Participant",
email: user?.email || undefined,
},
configOverwrite: {
prejoinPageEnabled: !join.is_host,
startWithAudioMuted: !join.is_host,
startWithVideoMuted: !join.is_host,
enableLobbyChat: true,
disableInviteFunctions: true,
requireDisplayName: true,
},
interfaceConfigOverwrite: {
TOOLBAR_BUTTONS: [
"microphone", "camera", "desktop", "fullscreen",
"fodeviceselection", "hangup", "chat", "raisehand",
"videoquality", "tileview", "etherpad", "shareaudio",
join.is_host ? "settings" : "",
join.is_host ? "mute-everyone" : "",
join.is_host ? "mute-video-everyone" : "",
join.is_host ? "security" : "",
join.is_host ? "recording" : "",
].filter(Boolean),
},
});
local.addListener("readyToClose", () => {
leaveMut.mutate();
navigate("/live");
});
local.addListener("recordingStatusChanged", (ev: any) => {
if (!ev?.on && ev?.streamingURL) {
liveSessionService.storeRecording(sessionId, ev.streamingURL).catch(() => {});
}
});
setApi(local);
} catch (e) {
setConnectErr(e instanceof Error ? e.message : "Failed to join Jitsi room");
}
})();
return () => {
alive = false;
try { local?.dispose(); } catch { /* noop */ }
leaveMut.mutate();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.id]);
if (isLoading) {
return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
}
if (!session) return <div className="p-8">Session not found.</div>;
const isHost = user?.id === session.host_id;
return (
<div className="space-y-4 max-w-7xl mx-auto p-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="sm"><Link to="/live"><ArrowLeft className="h-4 w-4 me-1" />Back</Link></Button>
<h1 className="text-xl font-bold">{session.title}</h1>
<Badge variant="outline">{session.status}</Badge>
</div>
<div className="flex gap-2">
{isHost && session.status !== "ended" && (
<Button variant="destructive" size="sm" onClick={() => endMut.mutate()} disabled={endMut.isPending}>
<Square className="h-4 w-4 me-1" />End session
</Button>
)}
</div>
</div>
{connectErr && (
<Card className="border-destructive">
<CardContent className="py-4 flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 mt-0.5" />
<div>
<div className="font-medium">Couldn't load Jitsi.</div>
<div className="opacity-80">{connectErr}</div>
</div>
</CardContent>
</Card>
)}
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
<Card className="overflow-hidden">
<div ref={containerRef} className="bg-zinc-900 min-h-[600px]" />
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />Participants ({session.participants.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5 max-h-[500px] overflow-auto">
{isHost && (
<div className="flex gap-1 pb-2 border-b mb-2">
<Button
variant="outline" size="sm" className="text-xs flex-1"
onClick={() => api?.executeCommand("muteEveryone", "audio")}
disabled={!api}
title="Mute everyone (audio)"
>
<MicOff className="h-3 w-3 me-1" />Mute all
</Button>
<Button
variant="outline" size="sm" className="text-xs flex-1"
onClick={() => api?.executeCommand("muteEveryone", "video")}
disabled={!api}
title="Disable everyone's video"
>
<VideoOff className="h-3 w-3 me-1" />Cams off
</Button>
</div>
)}
{session.participants.length === 0 ? (
<div className="text-xs text-muted-foreground">No one has joined yet.</div>
) : session.participants.map((p) => (
<ParticipantRow
key={p.id}
p={p}
isHost={isHost}
api={api}
sessionId={sessionId}
/>
))}
</CardContent>
</Card>
</div>
</div>
);
}
/**
* Inline per-participant control strip used inside the host's
* participants sidebar. The host can:
* • Ask one user to unmute audio (executeCommand 'askToUnmute')
* • Mute one user (executeCommand 'muteEveryone' targets all
* by mediaType — for individual mute we
* rely on Jitsi's native right-click menu;
* we surface the kick action here instead.)
* • Remove the user from the session, with mandatory reason logged in the
* audit trail (POST /api/live-sessions/<id>/remove-participant).
*
* The "Remove" action also calls `kickParticipant` on Jitsi using a name match
* to ensure the user is actually disconnected from the room — backend status
* alone wouldn't drop them from the call.
*/
function ParticipantRow({
p, isHost, api, sessionId,
}: {
p: { id: number; user_id: number; user_name: string; attendance_status: string };
isHost: boolean;
api: any;
sessionId: number;
}) {
const [reason, setReason] = useState("");
const [openKick, setOpenKick] = useState(false);
const { toast } = useToast();
const qc = useQueryClient();
const remove = useMutation({
mutationFn: () => liveSessionService.removeParticipant(sessionId, p.user_id, reason),
onSuccess: () => {
try {
if (api) {
const list = api.getParticipantsInfo() || [];
const target = list.find((j: any) => j.displayName === p.user_name);
if (target?.participantId) {
api.executeCommand("kickParticipant", target.participantId);
}
}
} catch { /* noop */ }
toast({
title: "Participant removed",
description: "Reason logged in audit trail.",
});
qc.invalidateQueries({ queryKey: ["live-session", sessionId] });
setOpenKick(false);
setReason("");
},
onError: (e) => toast({
title: "Could not remove",
description: e instanceof Error ? e.message : "Try again.",
variant: "destructive",
}),
});
return (
<div className="flex items-center justify-between gap-1 text-sm py-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="truncate">{p.user_name}</span>
<Badge variant="secondary" className="text-[10px] shrink-0">
{p.attendance_status.replace("_", " ")}
</Badge>
</div>
{isHost && p.attendance_status !== "removed" && (
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost" size="icon" className="h-6 w-6" title="Ask to unmute"
onClick={() => {
try {
const list = api?.getParticipantsInfo() || [];
const target = list.find((j: any) => j.displayName === p.user_name);
if (target?.participantId) {
api.executeCommand("askToUnmute", target.participantId);
toast({ title: `Asked ${p.user_name} to unmute` });
}
} catch { /* noop */ }
}}
>
<MicOff className="h-3 w-3" />
</Button>
<Dialog open={openKick} onOpenChange={setOpenKick}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" title="Remove">
<UserX className="h-3 w-3" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove {p.user_name}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>Reason (required, logged)</Label>
<Textarea
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Repeated disruption / off-topic chat"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpenKick(false)}>Cancel</Button>
<Button variant="destructive" disabled={!reason.trim() || remove.isPending} onClick={() => remove.mutate()}>
{remove.isPending ? "Removing" : "Remove + log"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,271 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import {
Calendar, Plus, Video, Bell, Users, Clock, ExternalLink, CalendarPlus,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter,
} from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import { useAuth } from "@/contexts/AuthContext";
import {
liveSessionService, type CreateLiveSessionPayload, type LiveSession,
} from "@/services/liveSession.service";
export default function LiveSessionsPage() {
const { user } = useAuth();
const isStaff = ["admin", "teacher", "corporate", "mastercorporate", "developer"]
.includes(user?.user_type || "");
const { data, isLoading } = useQuery({
queryKey: ["live-sessions", { mine: !isStaff }],
queryFn: () => liveSessionService.list(isStaff ? {} : { mine: true }),
});
const sessions = data?.items ?? [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Video className="h-6 w-6 text-primary" />
Live sessions
</h1>
<p className="text-muted-foreground text-sm">
Online classroom rooms with attendance, recording, breakouts, and chat.
</p>
</div>
{isStaff && <NewSessionDialog />}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : sessions.length === 0 ? (
<Card><CardContent className="py-16 text-center text-muted-foreground">
No live sessions yet. {isStaff ? "Schedule one to get started." : "Your teacher hasn't scheduled any."}
</CardContent></Card>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{sessions.map((s) => <SessionCard key={s.id} s={s} isStaff={isStaff} />)}
</div>
)}
</div>
);
}
function SessionCard({ s, isStaff }: { s: LiveSession; isStaff: boolean }) {
const tone =
s.status === "live" ? "bg-green-500" :
s.status === "scheduled" ? "bg-blue-500" :
s.status === "ended" ? "bg-zinc-400" : "bg-red-500";
const when = s.scheduled_at ? new Date(s.scheduled_at).toLocaleString() : "—";
return (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base truncate">{s.title}</CardTitle>
<Badge className={`${tone} text-white capitalize`}>{s.status}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-sm text-muted-foreground line-clamp-2">{s.description || s.course_name || s.plan_name || "—"}</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5"><Calendar className="h-3.5 w-3.5" />{when}</div>
<div className="flex items-center gap-1.5"><Clock className="h-3.5 w-3.5" />{s.duration_min}m</div>
<div className="flex items-center gap-1.5"><Users className="h-3.5 w-3.5" />{s.participant_count} joined</div>
<div className="flex items-center gap-1.5"><Bell className="h-3.5 w-3.5" />{s.notification_sent ? "Notified" : "Not notified"}</div>
</div>
<div className="flex gap-2 pt-1">
<Button asChild size="sm" className="gap-1">
<Link to={`/live/${s.id}`}>
<ExternalLink className="h-3.5 w-3.5" />
{s.status === "live" ? "Join now" : "Open room"}
</Link>
</Button>
{isStaff && (
<NotifyButton sessionId={s.id} alreadySent={s.notification_sent} />
)}
<AddToCalendarButton sessionId={s.id} title={s.title} />
</div>
</CardContent>
</Card>
);
}
function AddToCalendarButton({ sessionId, title }: { sessionId: number; title: string }) {
const { toast } = useToast();
const [busy, setBusy] = useState(false);
return (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={async () => {
try {
setBusy(true);
await liveSessionService.downloadIcs(
sessionId,
title.replace(/[^A-Za-z0-9_-]+/g, "-").toLowerCase().slice(0, 40) || "session",
);
} catch (e) {
toast({
title: "Could not download .ics",
description: e instanceof Error ? e.message : "Try again later.",
variant: "destructive",
});
} finally {
setBusy(false);
}
}}
title="Add to your calendar (.ics)"
>
<CalendarPlus className="h-3.5 w-3.5 me-1" />
Calendar
</Button>
);
}
function NotifyButton({ sessionId, alreadySent }: { sessionId: number; alreadySent: boolean }) {
const { toast } = useToast();
const qc = useQueryClient();
const m = useMutation({
mutationFn: () => liveSessionService.notify(sessionId),
onSuccess: (r) => {
toast({ title: `Sent ${r.sent} email${r.sent === 1 ? "" : "s"}` });
qc.invalidateQueries({ queryKey: ["live-sessions"] });
},
});
return (
<Button
variant="outline"
size="sm"
disabled={m.isPending}
onClick={() => m.mutate()}
>
<Bell className="h-3.5 w-3.5 me-1" />
{alreadySent ? "Resend" : "Notify"}
</Button>
);
}
function NewSessionDialog() {
const [open, setOpen] = useState(false);
const [form, setForm] = useState<CreateLiveSessionPayload>({
title: "",
description: "",
scheduled_at: new Date(Date.now() + 60 * 60_000).toISOString().slice(0, 16),
duration_min: 60,
waiting_room: true,
recording_enabled: false,
auto_attendance_minutes: 10,
late_entry_minutes: 15,
notify: true,
});
const qc = useQueryClient();
const { toast } = useToast();
const m = useMutation({
mutationFn: () => liveSessionService.create({
...form,
scheduled_at: new Date(form.scheduled_at).toISOString(),
}),
onSuccess: () => {
toast({ title: "Session scheduled" });
qc.invalidateQueries({ queryKey: ["live-sessions"] });
setOpen(false);
},
onError: (e) => toast({
title: "Could not schedule",
description: e instanceof Error ? e.message : "Try again later.",
variant: "destructive",
}),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2"><Plus className="h-4 w-4" />New session</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>Schedule a live session</DialogTitle></DialogHeader>
<div className="space-y-3">
<div>
<Label>Title</Label>
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<Label>Description</Label>
<Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} rows={2} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Date &amp; time</Label>
<Input
type="datetime-local"
value={form.scheduled_at}
onChange={(e) => setForm({ ...form, scheduled_at: e.target.value })}
/>
</div>
<div>
<Label>Duration (min)</Label>
<Input
type="number" min={15} step={5}
value={form.duration_min}
onChange={(e) => setForm({ ...form, duration_min: Number(e.target.value) })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Auto-attendance after (min)</Label>
<Input
type="number" min={0}
value={form.auto_attendance_minutes}
onChange={(e) => setForm({ ...form, auto_attendance_minutes: Number(e.target.value) })}
/>
</div>
<div>
<Label>Late entry block (min)</Label>
<Input
type="number" min={0}
value={form.late_entry_minutes}
onChange={(e) => setForm({ ...form, late_entry_minutes: Number(e.target.value) })}
/>
</div>
</div>
<div className="flex items-center justify-between">
<Label>Waiting room (admit students manually)</Label>
<Switch checked={form.waiting_room} onCheckedChange={(v) => setForm({ ...form, waiting_room: v })} />
</div>
<div className="flex items-center justify-between">
<Label>Allow recording</Label>
<Switch checked={form.recording_enabled} onCheckedChange={(v) => setForm({ ...form, recording_enabled: v })} />
</div>
<div className="flex items-center justify-between">
<Label>Email enrolled students now</Label>
<Switch checked={form.notify} onCheckedChange={(v) => setForm({ ...form, notify: v })} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={() => m.mutate()} disabled={!form.title || !form.scheduled_at || m.isPending}>
{m.isPending ? "Scheduling…" : "Schedule"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -38,6 +38,10 @@ const thresholds = ["0%", "50%", "70%", "90%"];
export default function StatsCorporatePage() {
const [threshold, setThreshold] = useState("0%");
const [entityId, setEntityId] = useState("all");
// Phase 1 (2026-04-29) — comparative-analysis filters.
const [branchId, setBranchId] = useState("all");
const [subjectId, setSubjectId] = useState("all");
const [gender, setGender] = useState("all");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
@@ -45,12 +49,22 @@ export default function StatsCorporatePage() {
});
const { data, isLoading } = useQuery({
queryKey: ["stats-corporate", threshold, entityId],
queryKey: [
"stats-corporate",
threshold,
entityId,
branchId,
subjectId,
gender,
],
queryFn: () =>
reportsService.statsCorporate({
entity_id: entityId === "all" ? undefined : Number(entityId),
threshold: Number(threshold.replace("%", "")),
months: 6,
branch_id: branchId === "all" ? undefined : Number(branchId),
subject_id: subjectId === "all" ? undefined : Number(subjectId),
gender: gender === "all" ? undefined : gender,
}),
});
@@ -102,6 +116,45 @@ export default function StatsCorporatePage() {
))}
</SelectContent>
</Select>
<Select value={branchId} onValueChange={setBranchId}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Branches" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Branches</SelectItem>
{(filters?.branches ?? []).map((b) => (
<SelectItem key={b.id} value={String(b.id)}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Subjects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Subjects</SelectItem>
{(filters?.subjects ?? []).map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={gender} onValueChange={setGender}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All Genders" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Genders</SelectItem>
{(filters?.genders ?? []).map((g) => (
<SelectItem key={g.value} value={g.value}>
{g.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading ? (

View File

@@ -19,10 +19,12 @@ import {
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Loader2, Download } from "lucide-react";
import { Loader2, Download, FileText } from "lucide-react";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
import { reportsService } from "@/services/reports.service";
import { reportsExportService } from "@/services/reportsExport.service";
import { useToast } from "@/components/ui/use-toast";
const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const;
@@ -49,6 +51,10 @@ export default function StudentPerformancePage() {
const [entityId, setEntityId] = useState<string>("all");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("all");
const [search, setSearch] = useState("");
// Phase 1 (2026-04-29) — comparative-analysis filters.
const [branchId, setBranchId] = useState<string>("all");
const [subjectId, setSubjectId] = useState<string>("all");
const [gender, setGender] = useState<string>("all");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
@@ -56,12 +62,23 @@ export default function StudentPerformancePage() {
});
const { data, isLoading } = useQuery({
queryKey: ["student-performance", entityId, level, search],
queryKey: [
"student-performance",
entityId,
level,
search,
branchId,
subjectId,
gender,
],
queryFn: () =>
reportsService.studentPerformance({
entity_id: entityId === "all" ? undefined : Number(entityId),
level: level === "all" ? undefined : level,
search: search || undefined,
branch_id: branchId === "all" ? undefined : Number(branchId),
subject_id: subjectId === "all" ? undefined : Number(subjectId),
gender: gender === "all" ? undefined : gender,
}),
});
@@ -131,9 +148,18 @@ export default function StudentPerformancePage() {
Track student scores across all IELTS modules.
</p>
</div>
<Button variant="outline" size="sm" onClick={exportCsv} disabled={!rows.length}>
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
<ExportButtons
filters={{
entity_id: entityId === "all" ? undefined : entityId,
level: level === "all" ? undefined : level,
search: search || undefined,
branch_id: branchId === "all" ? undefined : branchId,
subject_id: subjectId === "all" ? undefined : subjectId,
gender: gender === "all" ? undefined : gender,
}}
fallbackCsv={exportCsv}
disabled={!rows.length}
/>
</div>
{summary && (
@@ -196,6 +222,45 @@ export default function StudentPerformancePage() {
))}
</SelectContent>
</Select>
<Select value={branchId} onValueChange={setBranchId}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Branches" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Branches</SelectItem>
{(filters?.branches ?? []).map((b) => (
<SelectItem key={b.id} value={String(b.id)}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Subjects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Subjects</SelectItem>
{(filters?.subjects ?? []).map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={gender} onValueChange={setGender}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All Genders" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Genders</SelectItem>
{(filters?.genders ?? []).map((g) => (
<SelectItem key={g.value} value={g.value}>
{g.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card className="border-0 shadow-sm">
@@ -270,3 +335,42 @@ export default function StudentPerformancePage() {
</div>
);
}
function ExportButtons({
filters, fallbackCsv, disabled,
}: {
filters: Record<string, unknown>;
fallbackCsv: () => void;
disabled: boolean;
}) {
const { toast } = useToast();
const [busy, setBusy] = useState<"csv" | "pdf" | null>(null);
const run = async (fmt: "csv" | "pdf") => {
setBusy(fmt);
try {
await reportsExportService.studentPerformance(fmt, filters);
} catch (e) {
if (fmt === "csv") {
fallbackCsv();
} else {
toast({
title: "PDF download failed",
description: e instanceof Error ? e.message : "Try again later.",
variant: "destructive",
});
}
} finally {
setBusy(null);
}
};
return (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => run("csv")} disabled={disabled || busy !== null}>
<Download className="h-4 w-4 mr-1" /> {busy === "csv" ? "Downloading…" : "CSV"}
</Button>
<Button variant="outline" size="sm" onClick={() => run("pdf")} disabled={disabled || busy !== null}>
<FileText className="h-4 w-4 mr-1" /> {busy === "pdf" ? "Downloading…" : "PDF"}
</Button>
</div>
);
}

View File

@@ -131,6 +131,30 @@ const SKILL_ICONS: Record<string, LucideIcon> = {
integrated: MessageSquare,
};
// Phase 1 (2026-04-29) — colour-coded kind badge so the weekly plan
// view can be scanned at a glance: orange = mandatory work, blue =
// supplementary content. Used in MaterialCard headers and the
// student-facing PlanReader.
const KIND_TONE: Record<
string,
{ label: string; classes: string }
> = {
content: { label: "Lesson", classes: "border-slate-300 text-slate-700 dark:text-slate-300" },
quiz: { label: "Quiz", classes: "bg-blue-500/15 border-blue-500/40 text-blue-700 dark:text-blue-300" },
test: { label: "Test", classes: "bg-primary/15 border-primary/40 text-primary" },
resource: { label: "Resource", classes: "bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300" },
assignment: { label: "Assignment", classes: "bg-amber-500/15 border-amber-500/40 text-amber-700 dark:text-amber-300" },
};
function KindBadge({ kind }: { kind: string }) {
const tone = KIND_TONE[kind] || KIND_TONE.content;
return (
<Badge variant="outline" className={`text-[10px] ${tone.classes}`}>
{tone.label}
</Badge>
);
}
export default function AdminCoursePlanDetail() {
const { t } = useTranslation();
const params = useParams<{ planId: string }>();
@@ -482,6 +506,7 @@ export default function AdminCoursePlanDetail() {
}
studentPreview={studentPreview}
hasIndexedSources={hasIndexedSources}
planId={planId}
/>
))}
</Accordion>
@@ -1459,6 +1484,7 @@ function WeekAccordionItem({
bulkBusy,
studentPreview,
hasIndexedSources,
planId,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
@@ -1468,9 +1494,11 @@ function WeekAccordionItem({
bulkBusy: boolean;
studentPreview: boolean;
hasIndexedSources: boolean;
planId: number;
}) {
const { t } = useTranslation();
const [skillFilter, setSkillFilter] = useState<string>("all");
const [manualOpen, setManualOpen] = useState(false);
const materialSkills = useMemo(
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
[materials],
@@ -1584,6 +1612,14 @@ function WeekAccordionItem({
{t("coursePlan.media.bulk")}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => setManualOpen(true)}
>
<Plus className="mr-1 h-4 w-4" />
{t("coursePlan.addMaterial", "Add quiz / test / resource")}
</Button>
<span className="text-xs text-muted-foreground">
{t("coursePlan.generateHint")}
</span>
@@ -1620,11 +1656,222 @@ function WeekAccordionItem({
))}
</div>
)}
<AddManualMaterialDialog
open={manualOpen}
onOpenChange={setManualOpen}
planId={planId}
weekNumber={week.week_number}
/>
</AccordionContent>
</AccordionItem>
);
}
function AddManualMaterialDialog({
open,
onOpenChange,
planId,
weekNumber,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
planId: number;
weekNumber: number;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [title, setTitle] = useState("");
const [kind, setKind] = useState<
"content" | "quiz" | "test" | "resource" | "assignment"
>("quiz");
const [examTemplateId, setExamTemplateId] = useState("");
const [resourceId, setResourceId] = useState("");
const [dueDate, setDueDate] = useState("");
const [skill, setSkill] = useState<string>("integrated");
useEffect(() => {
if (!open) {
setTitle("");
setKind("quiz");
setExamTemplateId("");
setResourceId("");
setDueDate("");
setSkill("integrated");
}
}, [open]);
const createMut = useMutation({
mutationFn: () =>
coursePlanService.createManualMaterial(planId, weekNumber, {
title: title.trim(),
kind,
skill,
material_type:
kind === "quiz" || kind === "test"
? "practice"
: kind === "resource"
? "other"
: "other",
exam_template_id: examTemplateId ? Number(examTemplateId) : null,
resource_id: resourceId ? Number(resourceId) : null,
due_date: dueDate || null,
is_graded: kind === "test",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
toast.success(t("coursePlan.addedMaterial", "Material added"));
onOpenChange(false);
},
onError: (err) =>
toast.error(describeApiError(err, t("common.error", "Error"))),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("coursePlan.addMaterialTitle", "Add material to week {{n}}", {
n: weekNumber,
})}
</DialogTitle>
<DialogDescription>
{t(
"coursePlan.addMaterialHint",
"Drop a quiz, graded test, supplementary resource, or assignment into this week of the delivery plan.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs mb-1 block">
{t("common.title", "Title")} *
</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.kind", "Material kind")}
</Label>
<Select
value={kind}
onValueChange={(v) =>
setKind(
v as
| "content"
| "quiz"
| "test"
| "resource"
| "assignment",
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="quiz">
{t("coursePlan.kindQuiz", "Quiz")}
</SelectItem>
<SelectItem value="test">
{t("coursePlan.kindTest", "Graded Test")}
</SelectItem>
<SelectItem value="resource">
{t("coursePlan.kindResource", "Supplementary Resource")}
</SelectItem>
<SelectItem value="assignment">
{t("coursePlan.kindAssignment", "Assignment")}
</SelectItem>
<SelectItem value="content">
{t("coursePlan.kindContent", "Reading / Lesson")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.skill", "Skill")}
</Label>
<Select value={skill} onValueChange={setSkill}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reading">Reading</SelectItem>
<SelectItem value="writing">Writing</SelectItem>
<SelectItem value="listening">Listening</SelectItem>
<SelectItem value="speaking">Speaking</SelectItem>
<SelectItem value="grammar">Grammar</SelectItem>
<SelectItem value="vocabulary">Vocabulary</SelectItem>
<SelectItem value="integrated">Integrated</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{(kind === "quiz" || kind === "test") && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.examTemplateId", "Exam template ID")}
</Label>
<Input
type="number"
value={examTemplateId}
onChange={(e) => setExamTemplateId(e.target.value)}
placeholder={t(
"coursePlan.examTemplateIdHint",
"ID of an existing exam template (see /admin/exams)",
)}
/>
</div>
)}
{kind === "resource" && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.resourceId", "Resource ID")}
</Label>
<Input
type="number"
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
placeholder={t(
"coursePlan.resourceIdHint",
"ID of an existing resource (see /admin/resources)",
)}
/>
</div>
)}
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.dueDate", "Due date")}
</Label>
<Input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={() => createMut.mutate()}
disabled={!title.trim() || createMut.isPending}
>
{createMut.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{t("common.add", "Add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function MaterialCard({
material,
studentPreview,
@@ -1641,6 +1888,18 @@ function MaterialCard({
const [bodyText, setBodyText] = useState(material.body_text || "");
const [shareDate, setShareDate] = useState(material.share_date ?? "");
const [isStatic, setIsStatic] = useState(Boolean(material.is_static));
// Phase 1 (2026-04-29) — kind / exam / resource / grading edits.
const [kind, setKind] = useState<string>(material.kind || "content");
const [examTemplateId, setExamTemplateId] = useState<string>(
material.exam_template_id ? String(material.exam_template_id) : "",
);
const [resourceId, setResourceId] = useState<string>(
material.resource_id ? String(material.resource_id) : "",
);
const [dueDate, setDueDate] = useState<string>(material.due_date ?? "");
const [isGraded, setIsGraded] = useState<boolean>(
Boolean(material.is_graded),
);
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
const mediaCount = material.media?.length ?? 0;
@@ -1650,6 +1909,13 @@ function MaterialCard({
setBodyText(material.body_text || "");
setShareDate(material.share_date ?? "");
setIsStatic(Boolean(material.is_static));
setKind(material.kind || "content");
setExamTemplateId(
material.exam_template_id ? String(material.exam_template_id) : "",
);
setResourceId(material.resource_id ? String(material.resource_id) : "");
setDueDate(material.due_date ?? "");
setIsGraded(Boolean(material.is_graded));
}, [material]);
const saveMut = useMutation({
@@ -1660,6 +1926,16 @@ function MaterialCard({
body_text: bodyText,
share_date: shareDate || null,
is_static: isStatic,
kind: kind as
| "content"
| "quiz"
| "test"
| "resource"
| "assignment",
exam_template_id: examTemplateId ? Number(examTemplateId) : null,
resource_id: resourceId ? Number(resourceId) : null,
due_date: dueDate || null,
is_graded: isGraded,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["course-plan", material.plan_id] });
@@ -1686,6 +1962,18 @@ function MaterialCard({
material.material_type,
)}
</Badge>
<KindBadge kind={material.kind || "content"} />
{material.is_graded && (
<Badge variant="default" className="text-[10px]">
{t("coursePlan.graded", "Graded")}
</Badge>
)}
{material.due_date && (
<Badge variant="outline" className="text-[10px]">
<Calendar className="h-3 w-3 mr-1" />
{material.due_date}
</Badge>
)}
<SkillBadge skill={material.skill} />
<GroundingBadge material={material} />
<Button
@@ -1742,6 +2030,92 @@ function MaterialCard({
/>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.kind", "Material kind")}
</Label>
<Select value={kind} onValueChange={setKind}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="content">
{t("coursePlan.kindContent", "Reading / Lesson")}
</SelectItem>
<SelectItem value="quiz">
{t("coursePlan.kindQuiz", "Quiz")}
</SelectItem>
<SelectItem value="test">
{t("coursePlan.kindTest", "Graded Test")}
</SelectItem>
<SelectItem value="resource">
{t("coursePlan.kindResource", "Supplementary Resource")}
</SelectItem>
<SelectItem value="assignment">
{t("coursePlan.kindAssignment", "Assignment")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.dueDate", "Due date")}
</Label>
<Input
type="date"
value={dueDate || ""}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="flex items-end">
<div className="flex items-center gap-2">
<Checkbox
id={`graded-${material.id}`}
checked={isGraded}
onCheckedChange={(v) => setIsGraded(Boolean(v))}
/>
<label
htmlFor={`graded-${material.id}`}
className="text-sm"
>
{t("coursePlan.graded", "Graded")}
</label>
</div>
</div>
</div>
{(kind === "quiz" || kind === "test") && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.examTemplateId", "Exam template ID")}
</Label>
<Input
type="number"
value={examTemplateId}
onChange={(e) => setExamTemplateId(e.target.value)}
placeholder={t(
"coursePlan.examTemplateIdHint",
"ID of an existing exam template (see /admin/exams)",
)}
/>
</div>
)}
{kind === "resource" && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.resourceId", "Resource ID")}
</Label>
<Input
type="number"
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
placeholder={t(
"coursePlan.resourceIdHint",
"ID of an existing resource (see /admin/resources)",
)}
/>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id={`static-${material.id}`}

View File

@@ -0,0 +1,222 @@
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Shield, CheckCircle2, AlertTriangle, Eye, EyeOff } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useToast } from "@/components/ui/use-toast";
import { entitiesService } from "@/services/entities.service";
import { turnitinService } from "@/services/turnitin.service";
/**
* Per-entity Turnitin integration settings page.
*
* Each institution holds its own Turnitin subscription, so the API key
* is stored on the encoach.entity record. This page lets the admin
* pick an entity, toggle Turnitin on, paste their API key + account
* ID, override the API base URL, and run a connectivity test.
*/
export default function TurnitinSettings() {
const { toast } = useToast();
const qc = useQueryClient();
const [entityId, setEntityId] = useState<string>("");
const [show, setShow] = useState(false);
const [draft, setDraft] = useState({
enabled: false,
api_url: "https://api.turnitin.com",
account_id: "",
api_key: "",
});
const { data: entities } = useQuery({
queryKey: ["entities"],
queryFn: () => entitiesService.list(),
});
const eid = Number(entityId || 0);
const { data: settings, isLoading } = useQuery({
queryKey: ["turnitin", eid],
queryFn: () => turnitinService.getSettings(eid),
enabled: eid > 0,
});
useEffect(() => {
if (!settings) return;
setDraft({
enabled: settings.enabled,
api_url: settings.api_url || "https://api.turnitin.com",
account_id: settings.account_id || "",
api_key: "",
});
}, [settings]);
const save = useMutation({
mutationFn: () => turnitinService.patchSettings(eid, {
enabled: draft.enabled,
api_url: draft.api_url,
account_id: draft.account_id,
...(draft.api_key ? { api_key: draft.api_key } : {}),
}),
onSuccess: () => {
toast({ title: "Turnitin settings saved" });
setDraft((d) => ({ ...d, api_key: "" }));
qc.invalidateQueries({ queryKey: ["turnitin", eid] });
},
onError: (e) => toast({
title: "Save failed",
description: e instanceof Error ? e.message : "Try again.",
variant: "destructive",
}),
});
const test = useMutation({
mutationFn: () => turnitinService.test(eid),
onSuccess: (r) => toast({
title: r.ok ? "Turnitin connected" : "Could not reach Turnitin",
description: r.job_id,
variant: r.ok ? "default" : "destructive",
}),
});
const list = entities?.items ?? [];
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950/40">
<Shield className="h-5 w-5" />
</div>
<div>
<h1 className="text-2xl font-bold">Turnitin integration</h1>
<p className="text-muted-foreground text-sm">
Plug your institution's Turnitin subscription into the platform.
Each institution stores its own API credentials.
</p>
</div>
</div>
<Card>
<CardHeader><CardTitle className="text-base">Choose institution</CardTitle></CardHeader>
<CardContent>
<Select value={entityId} onValueChange={setEntityId}>
<SelectTrigger className="max-w-xs"><SelectValue placeholder="Pick an entity…" /></SelectTrigger>
<SelectContent>
{list.map((e: any) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{eid > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
{settings?.enabled
? <Badge tone="ok"><CheckCircle2 className="h-3.5 w-3.5" />Enabled</Badge>
: <Badge tone="warn"><AlertTriangle className="h-3.5 w-3.5" />Disabled</Badge>}
Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading</div>
) : (
<>
<div className="flex items-center justify-between">
<div>
<Label>Turnitin enabled</Label>
<div className="text-xs text-muted-foreground">Hides/Shows the originality button across the institution.</div>
</div>
<Switch
checked={draft.enabled}
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
/>
</div>
<div>
<Label>API base URL</Label>
<Input
value={draft.api_url}
onChange={(e) => setDraft({ ...draft, api_url: e.target.value })}
placeholder="https://api.turnitin.com"
/>
</div>
<div>
<Label>Account ID</Label>
<Input
value={draft.account_id}
onChange={(e) => setDraft({ ...draft, account_id: e.target.value })}
/>
</div>
<div>
<Label>API key {settings?.has_api_key && <span className="text-xs text-muted-foreground">(leave blank to keep current)</span>}</Label>
<div className="relative">
<Input
type={show ? "text" : "password"}
placeholder={settings?.has_api_key ? "•••••••••••••••" : "Paste your Turnitin token"}
value={draft.api_key}
onChange={(e) => setDraft({ ...draft, api_key: e.target.value })}
/>
<button
type="button"
className="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground"
onClick={() => setShow(!show)}
>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{!settings?.has_api_key && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>No API key configured</AlertTitle>
<AlertDescription>
Submissions will return a synthetic stub originality score until a key is provided.
</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button onClick={() => save.mutate()} disabled={save.isPending}>
{save.isPending ? "Saving…" : "Save"}
</Button>
<Button
variant="outline"
onClick={() => test.mutate()}
disabled={test.isPending || !settings?.has_api_key}
>
{test.isPending ? "Testing…" : "Test connection"}
</Button>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
);
}
function Badge({ children, tone }: { children: React.ReactNode; tone: "ok" | "warn" }) {
const cls = tone === "ok"
? "bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300"
: "bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300";
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
{children}
</span>
);
}

View File

@@ -22,6 +22,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Flag, ChevronLeft, ChevronRight, Pause, Play, Mic, Square } from "lucide-react";
import { cn } from "@/lib/utils";
import { mediaService } from "@/services/media.service";
import { useExamSecurity } from "@/hooks/useExamSecurity";
function normalizeType(t: string | null | undefined) {
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
@@ -88,6 +89,15 @@ export default function ExamSession() {
} as typeof rawSession;
}, [rawSession]);
// Phase 2 (2026-04-30): online-test SOFT security tier. Records tab
// blur, paste/copy, fullscreen exits, etc. into the per-attempt log
// visible to the teacher. Runs as long as the session is loaded.
useExamSecurity({
attemptId: (session as any)?.attempt_id ?? null,
examId,
enabled: !!session,
});
const [sectionIdx, setSectionIdx] = useState(0);
const [questionIdx, setQuestionIdx] = useState(0);
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());

View File

@@ -5,7 +5,8 @@ import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useCourse, useChapters, useGrades, useCourseCompletion } from "@/hooks/queries";
import { ArrowLeft, BookOpen, Lock, ChevronRight, Play, CheckCircle2, Trophy } from "lucide-react";
import { ArrowLeft, BookOpen, Lock, ChevronRight, Play, CheckCircle2, Trophy, MessageSquare } from "lucide-react";
import DiscussionTab from "@/components/discussion/DiscussionTab";
export default function StudentCourseDetail() {
const { id } = useParams();
@@ -69,6 +70,10 @@ export default function StudentCourseDetail() {
<TabsList>
<TabsTrigger value="chapters">Chapters ({chapters.length})</TabsTrigger>
<TabsTrigger value="grades">Grades ({gradeRecords.length})</TabsTrigger>
<TabsTrigger value="discussion">
<MessageSquare className="h-3 w-3 mr-1" />
Discussion
</TabsTrigger>
<TabsTrigger value="about">About</TabsTrigger>
</TabsList>
@@ -129,6 +134,10 @@ export default function StudentCourseDetail() {
))}
</TabsContent>
<TabsContent value="discussion" className="mt-4">
<DiscussionTab courseId={courseId} hideHeader />
</TabsContent>
<TabsContent value="about" className="mt-4">
<Card>
<CardHeader><CardTitle className="text-base">About this Course</CardTitle></CardHeader>

View File

@@ -1,11 +1,13 @@
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { ArrowLeft } from "lucide-react";
import { ArrowLeft, MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import PlanReader from "@/components/coursePlan/PlanReader";
import DiscussionTab from "@/components/discussion/DiscussionTab";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type { CoursePlan } from "@/types";
@@ -61,7 +63,25 @@ export default function StudentCoursePlanDetail() {
</div>
)}
{plan && <PlanReader plan={plan} mode="student" />}
{plan && (
<Tabs defaultValue="material">
<TabsList>
<TabsTrigger value="material">
{t("coursePlan.tabs.material", "Material")}
</TabsTrigger>
<TabsTrigger value="discussion">
<MessageSquare className="h-3 w-3 mr-1" />
{t("discussion.title", "Discussion")}
</TabsTrigger>
</TabsList>
<TabsContent value="material" className="mt-4">
<PlanReader plan={plan} mode="student" />
</TabsContent>
<TabsContent value="discussion" className="mt-4">
<DiscussionTab planId={planId} hideHeader />
</TabsContent>
</Tabs>
)}
</div>
);
}

View File

@@ -1,70 +1,300 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
BookOpen,
GraduationCap,
Layers,
Play,
Sparkles,
Star,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Link } from "react-router-dom";
import { useMyEnrolledCourses } from "@/hooks/queries";
import { BookOpen, Users, Play } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import {
coursePlanService,
type StudentLearningItem,
} from "@/services/coursePlan.service";
/**
* Phase 1 (2026-04-29) — merged "My Learning" page.
*
* Replaces what used to be two separate sidebar entries ("My Courses"
* driven by `op.student.course` and "My Course Plans" driven by
* `encoach.course.plan.assignment`). Both lists now come from the
* unified `/api/student/learning` endpoint and are split into two
* sections by the `is_mandatory` flag:
*
* * **Accredited Core** — flagged courses/plans, surfaced with a
* bright "Mandatory" tag so students can immediately tell what
* counts toward graduation.
* * **Supplementary / Elective** — everything else, shown
* underneath in a calmer blue palette.
*
* Each card links to the same detail routes that used to power the
* separate pages (`/student/courses/:id` for op.course,
* `/student/course-plans/:planId` for AI plans), so existing deep
* links keep working.
*/
export default function StudentCourses() {
const { data: enrolledData, isLoading } = useMyEnrolledCourses();
const myCourses = enrolledData?.items ?? [];
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const { t } = useTranslation();
const { data, isLoading, isError } = useQuery({
queryKey: ["student-learning"],
queryFn: () => coursePlanService.studentLearning(),
});
const mandatory = data?.mandatory ?? [];
const elective = data?.elective ?? [];
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">My Courses</h1>
<p className="text-muted-foreground">Browse and continue your enrolled courses.</p>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Layers className="h-6 w-6 text-primary" />
{t("learning.title", "My Learning")}
</h1>
<p className="text-muted-foreground">
{t(
"learning.subtitle",
"All your accredited courses and supplementary materials in one place.",
)}
</p>
</div>
<AiTipBanner context="student-courses" variant="recommendation" />
{myCourses.length === 0 ? (
<div className="text-center py-12">
<BookOpen className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold">No Enrolled Courses</h3>
<p className="text-muted-foreground mt-1">You haven't been enrolled in any courses yet. Contact your administrator.</p>
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t("learning.loadFailed", "Failed to load your courses. Please try again.")}
</div>
)}
{!isError && mandatory.length === 0 && elective.length === 0 && (
<div className="text-center py-12 border rounded-2xl bg-muted/20">
<GraduationCap className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
{t(
"learning.empty",
"You haven't been enrolled in any courses or plans yet. Contact your administrator.",
)}
</p>
</div>
)}
{/*
Phase 1 (2026-04-29): always render BOTH sections — even when one
is empty — so students immediately see the structure (and the
intent that "Accredited Core" comes first). Empty sections fall
back to a calm empty-state instead of disappearing entirely.
*/}
{(mandatory.length > 0 || elective.length > 0) && (
<>
<Section
title={t("learning.accreditedCore", "Accredited Core")}
subtitle={t(
"learning.accreditedCoreSub",
"Mandatory programs that count toward your official record.",
)}
tone="mandatory"
items={mandatory}
/>
<Section
title={t("learning.supplementary", "Supplementary & Elective")}
subtitle={t(
"learning.supplementarySub",
"Optional content, AI-generated study plans, and self-paced material.",
)}
tone="elective"
items={elective}
/>
</>
)}
</div>
);
}
function Section({
title,
subtitle,
tone,
items,
}: {
title: string;
subtitle: string;
tone: "mandatory" | "elective";
items: StudentLearningItem[];
}) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
{tone === "mandatory" ? (
<Star className="h-4 w-4 text-primary" fill="currentColor" />
) : (
<Sparkles className="h-4 w-4 text-blue-500" />
)}
{title}
<Badge variant="secondary" className="ml-1">
{items.length}
</Badge>
</h2>
<p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>
</div>
</div>
{items.length === 0 ? (
<div className="text-center py-8 border rounded-2xl bg-muted/10">
<p className="text-sm text-muted-foreground">
{tone === "mandatory"
? "No accredited / mandatory programs assigned to you yet."
: "No supplementary or elective material yet."}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{myCourses.map((c) => (
<Link to={`/student/courses/${c.id}`} key={c.id}>
<Card className="hover:shadow-md transition-shadow h-full">
<div className="h-2 rounded-t-lg bg-primary" />
<CardContent className="pt-5 space-y-4">
<div>
<div className="flex items-center justify-between mb-1">
<Badge variant="secondary" className="text-xs">
{c.progress === 100 ? "Completed" : c.progress > 0 ? "In Progress" : "Not Started"}
</Badge>
<Badge variant="outline" className="text-xs">{c.code}</Badge>
</div>
<h3 className="font-semibold mt-2">{c.title || c.name}</h3>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{c.description}</p>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{c.chapter_count} chapters</span>
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.student_count} students</span>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Progress</span>
<span className="font-medium">{c.progress}%</span>
</div>
<Progress value={c.progress} className="h-2" />
</div>
<Button variant="outline" className="w-full" size="sm">
<Play className="mr-2 h-3 w-3" />
{c.progress > 0 ? "Continue Learning" : "Start Learning"}
</Button>
</CardContent>
</Card>
</Link>
{items.map((item) => (
<LearningCard key={`${item.kind}-${item.id}`} item={item} tone={tone} />
))}
</div>
)}
</div>
);
}
function LearningCard({
item,
tone,
}: {
item: StudentLearningItem;
tone: "mandatory" | "elective";
}) {
const { t } = useTranslation();
const href =
item.kind === "plan"
? `/student/course-plans/${item.id}`
: `/student/courses/${item.id}`;
const accentBar =
tone === "mandatory"
? "bg-gradient-to-r from-primary to-orange-400"
: "bg-gradient-to-r from-blue-400 to-cyan-400";
const progress = item.progress ?? 0;
const statusLabel =
item.kind === "plan"
? item.status === "approved"
? t("learning.statusApproved", "Approved")
: item.status === "generated"
? t("learning.statusGenerated", "Generated")
: t("learning.statusDraft", "Draft")
: progress >= 100
? t("learning.statusCompleted", "Completed")
: progress > 0
? t("learning.statusInProgress", "In Progress")
: t("learning.statusNotStarted", "Not Started");
return (
<Link to={href}>
<Card className="hover:shadow-lg transition-shadow h-full overflow-hidden">
<div className={`h-2 ${accentBar}`} />
<CardContent className="pt-5 space-y-4">
<div>
<div className="flex items-center justify-between mb-1 flex-wrap gap-1">
{item.is_mandatory ? (
<Badge className="bg-primary text-primary-foreground hover:bg-primary text-xs">
{t("learning.mandatory", "Mandatory")}
</Badge>
) : (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
>
{t("learning.elective", "Elective")}
</Badge>
)}
{item.kind === "plan" ? (
<Badge variant="secondary" className="text-xs">
<Sparkles className="h-3 w-3 mr-1" />
{t("learning.aiPlan", "AI Plan")}
</Badge>
) : (
item.code && (
<Badge variant="outline" className="text-xs">
{item.code}
</Badge>
)
)}
<Badge variant="secondary" className="text-xs">
{statusLabel}
</Badge>
</div>
<h3 className="font-semibold mt-2 line-clamp-2">{item.name}</h3>
{item.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{item.description}
</p>
)}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
{item.kind === "plan" ? (
<>
{item.total_weeks ? (
<span className="flex items-center gap-1">
<BookOpen className="h-3 w-3" />
{t("learning.weeks", "{{n}} weeks", { n: item.total_weeks })}
</span>
) : null}
{item.cefr_level && (
<span className="uppercase">{item.cefr_level}</span>
)}
</>
) : (
<>
<span className="flex items-center gap-1">
<BookOpen className="h-3 w-3" />
{t("learning.chapters", "{{n}} chapters", {
n: item.chapter_count ?? 0,
})}
</span>
{item.batch_name && (
<span className="text-muted-foreground/80">
{item.batch_name}
</span>
)}
</>
)}
</div>
{item.kind === "course" && (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">
{t("learning.progress", "Progress")}
</span>
<span className="font-medium">{progress}%</span>
</div>
<Progress value={progress} className="h-2" />
</div>
)}
<Button variant="outline" className="w-full" size="sm">
<Play className="mr-2 h-3 w-3" />
{progress > 0 || item.kind === "plan"
? t("learning.continue", "Continue Learning")
: t("learning.start", "Start Learning")}
</Button>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -10,6 +10,8 @@ import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
import { useAuth } from "@/contexts/AuthContext";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AITutorAvatar from "@/components/ai/AITutorAvatar";
import PersonalizedPlanCard from "@/components/student/PersonalizedPlanCard";
import { coursePlanService } from "@/services/coursePlan.service";
export default function StudentDashboard() {
@@ -42,13 +44,18 @@ export default function StudentDashboard() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
<p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
<p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
</div>
<AITutorAvatar size={72} name="EnCoach AI Tutor" />
</div>
<AiTipBanner context="student-dashboard" variant="tip" />
<PersonalizedPlanCard />
<AiStudyCoach />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">

View File

@@ -0,0 +1,372 @@
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Users,
CalendarCheck,
ClipboardCheck,
BookOpen,
TrendingUp,
AlertTriangle,
MessageSquare,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api-client";
import DiscussionTab from "@/components/discussion/DiscussionTab";
interface CourseInsightsResponse {
course_id: number;
course_name: string;
students: {
total: number;
by_batch: { batch_id: number; batch_name: string; count: number }[];
};
attendance: {
lines_total: number;
present: number;
absent: number;
excused: number;
late: number;
rate_present: number;
rate_absent: number;
rate_late: number;
};
submissions: {
exams_assigned: number;
expected_attempts: number;
completed_attempts: number;
rate_completed: number;
};
materials: {
chapters: number;
students_started: number;
completed_progress_rows: number;
rate_chapters: number;
};
}
/**
* Phase 1 (2026-04-29) — Class Insights tab for teachers.
*
* Surfaces what teachers asked for: student count, attendance vs
* absence breakdown, task submission progress, and materials done.
* Pulls everything from `/api/teacher/courses/:id/insights`, which
* aggregates `op.student.course`, `op.attendance.line`,
* `encoach.exam.assignment`/`encoach.student.attempt`, and
* `encoach.chapter.progress` server-side.
*/
export default function TeacherCourseInsights() {
const { t } = useTranslation();
const { courseId: rawId } = useParams<{ courseId: string }>();
const courseId = Number(rawId);
const { data, isLoading, isError } = useQuery({
queryKey: ["teacher-course-insights", courseId],
enabled: Number.isFinite(courseId) && courseId > 0,
queryFn: () =>
api.get<CourseInsightsResponse>(`/teacher/courses/${courseId}/insights`),
});
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link to="/teacher/courses">
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold">
{data?.course_name || t("teacher.insights.title", "Class Insights")}
</h1>
<p className="text-muted-foreground text-sm">
{t(
"teacher.insights.subtitle",
"Enrollment, attendance, submission progress, and chapter completion at a glance.",
)}
</p>
</div>
</div>
{isLoading && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1" />
{t("teacher.insights.loadFailed", "Failed to load class insights.")}
</div>
)}
{data && (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Users className="h-4 w-4" />}
label={t("teacher.insights.students", "Students enrolled")}
value={data.students.total}
tone="primary"
/>
<StatCard
icon={<CalendarCheck className="h-4 w-4" />}
label={t("teacher.insights.attendanceRate", "Attendance rate")}
value={`${data.attendance.rate_present}%`}
hint={`${data.attendance.present} present · ${data.attendance.absent} absent`}
tone="success"
/>
<StatCard
icon={<ClipboardCheck className="h-4 w-4" />}
label={t("teacher.insights.submissions", "Task submission")}
value={`${data.submissions.rate_completed}%`}
hint={
data.submissions.exams_assigned
? `${data.submissions.completed_attempts} / ${data.submissions.expected_attempts} attempts`
: t(
"teacher.insights.noAssignments",
"No exams assigned yet",
)
}
tone="info"
/>
<StatCard
icon={<BookOpen className="h-4 w-4" />}
label={t("teacher.insights.chapters", "Chapter completion")}
value={`${data.materials.rate_chapters}%`}
hint={`${data.materials.completed_progress_rows} completed · ${data.materials.chapters} chapters`}
tone="warning"
/>
</div>
<Tabs defaultValue="overview" className="mt-2">
<TabsList>
<TabsTrigger value="overview">
{t("teacher.insights.tabs.overview", "Overview")}
</TabsTrigger>
<TabsTrigger value="batches">
{t("teacher.insights.tabs.batches", "By Batch")}
</TabsTrigger>
<TabsTrigger value="discussion">
<MessageSquare className="h-3 w-3 mr-1" />
{t("discussion.title", "Discussion")}
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<CalendarCheck className="h-4 w-4 text-primary" />
{t("teacher.insights.attendanceBreakdown", "Attendance breakdown")}
</CardTitle>
</CardHeader>
<CardContent>
{data.attendance.lines_total === 0 ? (
<p className="text-sm text-muted-foreground">
{t(
"teacher.insights.noAttendance",
"No attendance recorded yet.",
)}
</p>
) : (
<div className="space-y-2">
<Bar
label={t("teacher.insights.present", "Present")}
value={data.attendance.present}
total={data.attendance.lines_total}
color="bg-green-500"
/>
<Bar
label={t("teacher.insights.absent", "Absent")}
value={data.attendance.absent}
total={data.attendance.lines_total}
color="bg-red-500"
/>
<Bar
label={t("teacher.insights.excused", "Excused")}
value={data.attendance.excused}
total={data.attendance.lines_total}
color="bg-amber-500"
/>
<Bar
label={t("teacher.insights.late", "Late")}
value={data.attendance.late}
total={data.attendance.lines_total}
color="bg-purple-500"
/>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-primary" />
{t("teacher.insights.tasks", "Task submission progress")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">
{t(
"teacher.insights.assignedExams",
"Assigned exams",
)}
</span>
<span className="font-medium">
{data.submissions.exams_assigned}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t(
"teacher.insights.expectedAttempts",
"Expected attempts",
)}
</span>
<span className="font-medium">
{data.submissions.expected_attempts}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t(
"teacher.insights.completedAttempts",
"Completed attempts",
)}
</span>
<span className="font-medium">
{data.submissions.completed_attempts}
</span>
</div>
<Progress
value={data.submissions.rate_completed}
className="h-2 mt-1"
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="batches" className="mt-4">
<Card>
<CardContent className="pt-6">
{data.students.by_batch.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t(
"teacher.insights.noBatches",
"No students assigned to batches.",
)}
</p>
) : (
<div className="space-y-2">
{data.students.by_batch.map((b) => (
<div
key={b.batch_id}
className="flex items-center justify-between py-2 border-b last:border-b-0"
>
<div>
<p className="text-sm font-medium">{b.batch_name}</p>
<p className="text-xs text-muted-foreground">
{t("teacher.insights.batchId", "Batch #{{id}}", {
id: b.batch_id,
})}
</p>
</div>
<Badge variant="secondary">
{b.count}{" "}
{t(
"teacher.insights.studentsLower",
"students",
)}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="discussion" className="mt-4">
<DiscussionTab courseId={courseId} hideHeader />
</TabsContent>
</Tabs>
</>
)}
</div>
);
}
function StatCard({
icon,
label,
value,
hint,
tone,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
hint?: string;
tone: "primary" | "success" | "info" | "warning";
}) {
const toneClasses: Record<string, string> = {
primary: "from-primary/15 to-primary/5 text-primary",
success: "from-green-500/15 to-green-500/5 text-green-700 dark:text-green-300",
info: "from-blue-500/15 to-blue-500/5 text-blue-700 dark:text-blue-300",
warning:
"from-amber-500/15 to-amber-500/5 text-amber-700 dark:text-amber-300",
};
return (
<Card className="overflow-hidden">
<CardContent
className={`pt-5 pb-5 bg-gradient-to-br ${toneClasses[tone]}`}
>
<div className="flex items-center gap-2 text-xs uppercase tracking-wider opacity-80">
{icon}
{label}
</div>
<div className="text-3xl font-bold mt-2 text-foreground">{value}</div>
{hint && <div className="text-xs text-muted-foreground mt-1">{hint}</div>}
</CardContent>
</Card>
);
}
function Bar({
label,
value,
total,
color,
}: {
label: string;
value: number;
total: number;
color: string;
}) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">
{value} ({pct}%)
</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div className={`h-full ${color}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
}

View File

@@ -10,7 +10,7 @@ import { useCourses, useChapters } from "@/hooks/queries";
import { lmsService } from "@/services";
import { useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { Plus, Users, BookOpen, Pencil, FolderOpen, Wand2 } from "lucide-react";
import { Plus, Users, BookOpen, Pencil, FolderOpen, Wand2, BarChart3 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import type { Course } from "@/types";
@@ -61,6 +61,9 @@ function CourseCard({ course }: { course: Course }) {
<Button size="sm" variant="default" onClick={() => navigate(`/teacher/courses/${course.id}/chapters`)}>
<FolderOpen className="mr-1.5 h-3 w-3" /> Chapters & Materials
</Button>
<Button size="sm" variant="outline" onClick={() => navigate(`/teacher/courses/${course.id}/insights`)}>
<BarChart3 className="mr-1.5 h-3 w-3" /> Class Insights
</Button>
<Button size="sm" variant="ghost" onClick={() => navigate(`/teacher/courses/${course.id}/workbench`)}>
<Wand2 className="mr-1.5 h-3 w-3" /> AI Workbench
</Button>

View File

@@ -0,0 +1,180 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ShieldAlert, Eye, RefreshCw } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
import { examSecurityService, type SecurityEvent } from "@/services/examSecurity.service";
/**
* Live monitoring console for a proctored online test assignment.
* Lists every in-progress attempt with the count of "alert" events.
* Click any row to inspect that attempt's full security event log.
*/
export default function TeacherTestSessionConsole() {
const { assignmentId: raw } = useParams<{ assignmentId: string }>();
const assignmentId = Number(raw);
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["teacher-test-console", assignmentId],
queryFn: () => examSecurityService.liveTestConsole(assignmentId),
enabled: !!assignmentId,
refetchInterval: 5000,
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ShieldAlert className="h-6 w-6 text-orange-500" />
Test session console
</h1>
<p className="text-muted-foreground text-sm">
Live attempts in this assignment + suspicious-event log per student.
</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={`h-3.5 w-3.5 me-1 ${isFetching ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">
In-progress attempts
<Badge variant="secondary" className="ms-2">{data?.in_progress.length ?? 0}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : data?.in_progress.length === 0 ? (
<div className="px-6 py-12 text-center text-muted-foreground">
No active attempts.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Started</TableHead>
<TableHead className="text-center">Alerts</TableHead>
<TableHead>Last event</TableHead>
<TableHead className="text-end">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.in_progress.map((row) => (
<TableRow key={row.attempt_id}>
<TableCell className="font-medium">{row.student_name || `User #${row.student_id}`}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.started_at ? new Date(row.started_at).toLocaleString() : "—"}
</TableCell>
<TableCell className="text-center">
<AlertBadge n={row.alert_count} />
</TableCell>
<TableCell className="text-sm">
{row.last_event ? (
<div>
<div className="capitalize">{row.last_event.event_type.replace("_", " ")}</div>
<div className="text-xs text-muted-foreground">
{row.last_event.occurred_at ? new Date(row.last_event.occurred_at).toLocaleTimeString() : ""}
</div>
</div>
) : <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell className="text-end">
<AttemptDetailsDialog attemptId={row.attempt_id} studentName={row.student_name} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function AlertBadge({ n }: { n: number }) {
if (n === 0) return <Badge variant="secondary">0</Badge>;
if (n < 3) return <Badge className="bg-amber-500">{n}</Badge>;
return <Badge className="bg-red-500">{n}</Badge>;
}
function AttemptDetailsDialog({
attemptId, studentName,
}: { attemptId: number; studentName: string }) {
const [open, setOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["attempt-events", attemptId],
queryFn: () => examSecurityService.listAttemptEvents(attemptId),
enabled: open,
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Eye className="h-3.5 w-3.5 me-1" />Details
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{studentName} security log</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading</div>
) : (
<div className="max-h-[60vh] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Event</TableHead>
<TableHead>Severity</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.items.map((ev: SecurityEvent) => (
<TableRow key={ev.id}>
<TableCell className="text-xs">{ev.occurred_at ? new Date(ev.occurred_at).toLocaleString() : "—"}</TableCell>
<TableCell className="capitalize">{ev.event_type.replace("_", " ")}</TableCell>
<TableCell>
<Badge
className={
ev.severity === "alert" ? "bg-red-500"
: ev.severity === "warning" ? "bg-amber-500"
: "bg-zinc-400"
}
>
{ev.severity}
</Badge>
</TableCell>
</TableRow>
))}
{data?.items.length === 0 && (
<TableRow><TableCell colSpan={3} className="text-center text-muted-foreground">No events recorded for this attempt.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -11,7 +11,7 @@ import type {
import type { ApiSuccessResponse, PaginatedResponse, PaginationParams } from "@/types";
export const communicationService = {
async listDiscussionBoards(params?: { course_id?: number; batch_id?: number }): Promise<DiscussionBoard[]> {
async listDiscussionBoards(params?: { course_id?: number; batch_id?: number; plan_id?: number }): Promise<DiscussionBoard[]> {
return api.get<DiscussionBoard[]>("/discussion-boards", params as Record<string, string | number | boolean | undefined>);
},
@@ -19,6 +19,17 @@ export const communicationService = {
return api.post<DiscussionBoard>("/discussion-boards", data);
},
// Phase 1 (2026-04-29): course/plan-scoped board lookup-or-create.
// Used by the embedded Discussion tab inside course / course-plan
// detail pages so the tab "just works" without manual setup.
async getBoardForCourse(courseId: number): Promise<DiscussionBoard> {
return api.get<DiscussionBoard>(`/discussion-boards/for-course/${courseId}`);
},
async getBoardForPlan(planId: number): Promise<DiscussionBoard> {
return api.get<DiscussionBoard>(`/discussion-boards/for-plan/${planId}`);
},
async updateDiscussionBoard(id: number, data: Partial<DiscussionBoard>): Promise<DiscussionBoard> {
return api.patch<DiscussionBoard>(`/discussion-boards/${id}`, data);
},

View File

@@ -13,6 +13,45 @@ import type {
WorkbookGroundedSource,
} from "@/types";
/**
* Item returned by GET /api/student/learning. The endpoint returns
* BOTH ``op.course`` enrollments and ``encoach.course.plan``
* assignments through a discriminated union on ``kind``. Each item
* carries the ``is_mandatory`` flag so the UI can group items into
* the Accredited Core / Supplementary sections.
*/
export interface StudentLearningItem {
kind: "course" | "plan";
id: number;
name: string;
description?: string;
is_mandatory: boolean;
cefr_level?: string;
difficulty_level?: string;
total_weeks?: number;
status?: string;
course_id?: number | null;
course_name?: string;
entity_id?: number | null;
entity_name?: string;
code?: string;
batch_id?: number | null;
batch_name?: string;
chapter_count?: number;
completed_chapters?: number;
progress?: number;
assignment_id?: number;
assignment_mode?: string;
}
export interface StudentLearningResponse {
mandatory: StudentLearningItem[];
elective: StudentLearningItem[];
count_mandatory: number;
count_elective: number;
count_total: number;
}
/**
* REST helpers for the AI course plan generator.
*
@@ -115,11 +154,46 @@ export const coursePlanService = {
body_text?: string;
share_date?: string | null;
is_static?: boolean;
// Phase 1 (2026-04-29) — kind/exam/resource/grading edits.
kind?: "content" | "quiz" | "test" | "resource" | "assignment";
exam_template_id?: number | null;
resource_id?: number | null;
due_date?: string | null;
is_graded?: boolean;
},
): Promise<{ data: CoursePlanMaterial }> {
return api.patch(`/ai/course-plan/material/${materialId}`, payload);
},
// Phase 1 (2026-04-29): create a manual non-AI material (quiz /
// test / resource / assignment) inside a specific week of the
// delivery plan. The shape mirrors the new `/materials/manual` route
// on the backend.
async createManualMaterial(
planId: number,
weekNumber: number,
payload: {
title: string;
kind: "content" | "quiz" | "test" | "resource" | "assignment";
skill?: string;
material_type?: string;
summary?: string;
exam_template_id?: number | null;
resource_id?: number | null;
due_date?: string | null;
is_graded?: boolean;
},
): Promise<CoursePlanMaterial> {
return api.post(
`/ai/course-plan/${planId}/weeks/${weekNumber}/materials/manual`,
payload,
);
},
async deleteMaterial(materialId: number): Promise<{ success: boolean }> {
return api.delete(`/ai/course-plan/material/${materialId}`);
},
// ---------------------------------------------------------------------
// Phase A — Sources
// ---------------------------------------------------------------------
@@ -329,6 +403,14 @@ export const coursePlanService = {
return api.get("/student/course-plans");
},
// Phase 1 (2026-04-29): merged "My Learning" view that returns
// both classic op.course enrollments AND AI course plans, each
// tagged with `is_mandatory` so the UI can group them under
// Accredited Core vs Supplementary.
async studentLearning(): Promise<StudentLearningResponse> {
return api.get("/student/learning");
},
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
return api.get(`/student/course-plans/${planId}`);
},

View File

@@ -0,0 +1,89 @@
import { api } from "@/lib/api-client";
export type SecurityEventType =
| "tab_blur"
| "tab_focus"
| "window_blur"
| "window_focus"
| "paste"
| "copy"
| "cut"
| "context_menu"
| "fullscreen_exit"
| "shortcut"
| "multi_attempt_block"
| "other";
export type SecuritySeverity = "info" | "warning" | "alert";
export interface SecurityEventPayload {
attempt_id?: number | null;
exam_id?: number | null;
event_type: SecurityEventType;
severity?: SecuritySeverity;
payload?: Record<string, unknown>;
}
export interface SecurityEvent {
id: number;
attempt_id: number | null;
student_id: number;
student_name: string;
exam_id: number | null;
event_type: SecurityEventType;
severity: SecuritySeverity;
occurred_at: string | null;
payload: unknown;
}
export interface LiveTestRow {
attempt_id: number;
student_id: number | null;
student_name: string;
started_at: string | null;
alert_count: number;
last_event: SecurityEvent | null;
}
export const examSecurityService = {
/** Post a single suspicious-activity event to the server. */
async postEvent(p: SecurityEventPayload): Promise<{ event_id: number; auto_locked: boolean }> {
return api.post("/exam/security/event", p);
},
async singleAttemptCheck(examId: number) {
return api.get<{
allowed: boolean;
security_level: "off" | "soft" | "strict";
single_attempt: boolean;
existing_attempt_id: number | null;
}>("/exam/single-attempt-check", { exam_id: examId });
},
async listAssignmentEvents(assignmentId: number) {
return api.get<{ items: SecurityEvent[]; total: number }>(
`/teacher/exam/${assignmentId}/security/events`,
);
},
async listAttemptEvents(attemptId: number) {
return api.get<{
attempt_id: number;
student_id: number | null;
student_name: string;
exam_id: number | null;
status: string;
items: SecurityEvent[];
total: number;
}>(`/teacher/exam/attempt/${attemptId}/security/events`);
},
async liveTestConsole(assignmentId: number) {
return api.get<{
assignment_id: number;
exam_id: number;
in_progress: LiveTestRow[];
total: number;
}>(`/teacher/exam/${assignmentId}/live`);
},
};

View File

@@ -0,0 +1,69 @@
import { api, API_BASE_URL } from "@/lib/api-client";
export type HandwrittenStatus =
| "submitted"
| "grading"
| "graded"
| "reviewed"
| "failed";
export interface HandwrittenSubmission {
id: number;
student_id: number | null;
student_name: string;
material_id: number | null;
plan_id: number | null;
submitted_at: string | null;
status: HandwrittenStatus;
student_note: string;
image_count: number;
image_urls: string[];
ai_score: number | null;
ai_feedback: string;
ai_extracted_text: string;
teacher_score: number | null;
teacher_feedback: string;
reviewed_by: string;
reviewed_at: string | null;
}
export const handwrittenService = {
async upload(materialId: number, files: File[], note = ""): Promise<HandwrittenSubmission> {
const fd = new FormData();
for (const f of files) fd.append("pages", f);
if (note) fd.append("note", note);
const token = localStorage.getItem("encoach_token") || "";
const res = await fetch(
`${API_BASE_URL}/student/handwritten/${materialId}`,
{
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
},
);
if (!res.ok) {
throw new Error(await res.text());
}
return res.json();
},
async mySubmission(materialId: number): Promise<HandwrittenSubmission | null> {
const r = await api.get<{ submission: HandwrittenSubmission | null }>(
`/student/handwritten/${materialId}`,
);
return r.submission;
},
async listForTeacher(materialId: number) {
return api.get<{ items: HandwrittenSubmission[]; total: number }>(
`/teacher/handwritten/${materialId}`,
);
},
async review(submissionId: number, score: number, feedback: string) {
return api.post<HandwrittenSubmission>(
`/teacher/handwritten/${submissionId}/review`,
{ score, feedback },
);
},
};

View File

@@ -35,3 +35,9 @@ export { studentProgressService } from "./student-progress.service";
export { libraryService } from "./library.service";
export { activityService } from "./activity.service";
export { facilityService } from "./facility.service";
export { examSecurityService } from "./examSecurity.service";
export { liveSessionService } from "./liveSession.service";
export { personalizedPlanService } from "./personalizedPlan.service";
export { handwrittenService } from "./handwritten.service";
export { turnitinService } from "./turnitin.service";
export { reportsExportService } from "./reportsExport.service";

View File

@@ -0,0 +1,181 @@
import { api, API_BASE_URL } from "@/lib/api-client";
export type LiveSessionStatus = "scheduled" | "live" | "ended" | "cancelled";
export interface LiveSession {
id: number;
title: string;
description: string;
course_id: number | null;
course_name: string;
batch_id: number | null;
batch_name: string;
plan_id: number | null;
plan_name: string;
host_id: number | null;
host_name: string;
scheduled_at: string | null;
duration_min: number;
actual_started_at: string | null;
actual_ended_at: string | null;
status: LiveSessionStatus;
room_name: string;
waiting_room: boolean;
recording_enabled: boolean;
recording_url: string;
auto_attendance_minutes: number;
late_entry_minutes: number;
notification_sent: boolean;
participant_count: number;
}
export type LiveAttendanceStatus =
| "not_joined"
| "attending"
| "present"
| "left_early"
| "removed";
export interface LiveSessionParticipant {
id: number;
session_id: number;
user_id: number;
user_name: string;
joined_at: string | null;
left_at: string | null;
duration_seconds: number;
attendance_status: LiveAttendanceStatus;
removed_reason: string;
is_host: boolean;
}
export interface LiveSessionDetail extends LiveSession {
participants: LiveSessionParticipant[];
enrolled_user_ids: number[];
}
export interface CreateLiveSessionPayload {
title: string;
description?: string;
scheduled_at: string;
duration_min?: number;
course_id?: number;
batch_id?: number;
plan_id?: number;
entity_id?: number;
waiting_room?: boolean;
recording_enabled?: boolean;
auto_attendance_minutes?: number;
late_entry_minutes?: number;
notify?: boolean;
}
export const liveSessionService = {
async list(filters: {
course_id?: number;
batch_id?: number;
plan_id?: number;
host_id?: number;
status?: LiveSessionStatus;
mine?: boolean;
} = {}): Promise<{ items: LiveSession[]; total: number }> {
return api.get("/live-sessions", filters);
},
async create(payload: CreateLiveSessionPayload): Promise<LiveSession> {
return api.post("/live-sessions", payload);
},
async read(id: number): Promise<LiveSessionDetail> {
return api.get(`/live-sessions/${id}`);
},
async update(id: number, payload: Partial<CreateLiveSessionPayload> & {
status?: LiveSessionStatus;
recording_url?: string;
}): Promise<LiveSession> {
return api.patch(`/live-sessions/${id}`, payload);
},
async cancel(id: number): Promise<{ ok: boolean }> {
return api.delete(`/live-sessions/${id}`);
},
async notify(id: number): Promise<{ sent: number }> {
return api.post(`/live-sessions/${id}/notify`, {});
},
async join(id: number) {
return api.post<{
ok: boolean;
participant: LiveSessionParticipant;
room_name: string;
is_host: boolean;
jitsi_jwt: string;
}>(`/live-sessions/${id}/join`, {});
},
async leave(id: number) {
return api.post<{ ok: boolean; participant?: LiveSessionParticipant; no_participant?: boolean }>(
`/live-sessions/${id}/leave`,
{},
);
},
async end(id: number) {
return api.post<{ ok: boolean }>(`/live-sessions/${id}/end`, {});
},
async storeRecording(id: number, url: string) {
return api.post<{ ok: boolean; url: string }>(`/live-sessions/${id}/recording`, {
url,
});
},
async removeParticipant(id: number, userId: number, reason: string) {
return api.post<{ ok: boolean; participant: LiveSessionParticipant }>(
`/live-sessions/${id}/remove-participant`,
{ user_id: userId, reason },
);
},
async attendanceRoll(id: number) {
return api.get<{
session_id: number;
status: LiveSessionStatus;
roll: {
user_id: number;
user_name: string;
attendance_status: LiveAttendanceStatus;
duration_seconds: number;
removed_reason: string;
}[];
}>(`/live-sessions/${id}/attendance`);
},
/**
* Fetch the .ics calendar file for this session (JWT-protected) and
* trigger a browser download. Calendar apps (Gmail/Outlook/Apple Mail)
* recognize the .ics MIME type and prompt the user to add the event.
*/
async downloadIcs(id: number, filenameHint = "live-session") {
const token = localStorage.getItem("encoach_token") || "";
const res = await fetch(`${API_BASE_URL}/live-sessions/${id}/ics`, {
method: "GET",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
throw new Error(`Download failed: ${res.status} ${res.statusText}`);
}
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${filenameHint}-${id}.ics`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 100);
},
};

View File

@@ -0,0 +1,43 @@
import { api } from "@/lib/api-client";
export interface PersonalizedWeakness {
attempts: number;
averages: {
reading: number | null;
listening: number | null;
writing: number | null;
speaking: number | null;
};
weak_skills: string[];
weakest_skill: string | null;
}
export interface PersonalizedPlanSummary {
id: number;
name: string;
cefr_level: string;
total_weeks: number;
is_personalized: boolean;
is_mandatory: boolean;
created_at: string | null;
}
export interface PersonalizedPlanResponse {
plan: PersonalizedPlanSummary | null;
weakness: PersonalizedWeakness;
}
export const personalizedPlanService = {
async fetch(): Promise<PersonalizedPlanResponse> {
return api.get("/student/personalized-plan");
},
async generate(opts: {
cefr_level?: string;
total_weeks?: number;
focus?: string;
grammar_focus?: string[];
} = {}): Promise<PersonalizedPlanResponse> {
return api.post("/student/personalized-plan", opts);
},
};

View File

@@ -91,43 +91,61 @@ export interface RecordResponse {
export interface ReportsFiltersResponse {
entities: { id: number; name: string }[];
students: { id: number; name: string; login: string }[];
branches?: { id: number; name: string; entity_id: number | null }[];
subjects?: { id: number; name: string; code: string }[];
genders?: { value: string; label: string }[];
}
type QueryParams = Record<string, string | number | boolean | undefined>;
// Phase 1 (2026-04-29): comparative-analysis filters added to all
// three report endpoints. These params are optional everywhere so
// existing callers keep working unchanged.
export interface CommonReportFilters {
branch_id?: number;
subject_id?: number;
gender?: "m" | "f" | "o" | string;
}
export const reportsService = {
async studentPerformance(params?: {
entity_id?: number;
level?: string;
search?: string;
since?: string;
}): Promise<StudentPerformanceResponse> {
async studentPerformance(
params?: {
entity_id?: number;
level?: string;
search?: string;
since?: string;
} & CommonReportFilters,
): Promise<StudentPerformanceResponse> {
return api.get<StudentPerformanceResponse>(
"/reports/student-performance",
params as QueryParams,
);
},
async statsCorporate(params?: {
entity_id?: number;
threshold?: number;
months?: number;
since?: string;
}): Promise<StatsCorporateResponse> {
async statsCorporate(
params?: {
entity_id?: number;
threshold?: number;
months?: number;
since?: string;
} & CommonReportFilters,
): Promise<StatsCorporateResponse> {
return api.get<StatsCorporateResponse>(
"/reports/stats-corporate",
params as QueryParams,
);
},
async record(params?: {
user_id?: number;
entity_id?: number;
period?: "day" | "week" | "month";
status?: string;
page?: number;
size?: number;
}): Promise<RecordResponse> {
async record(
params?: {
user_id?: number;
entity_id?: number;
period?: "day" | "week" | "month";
status?: string;
page?: number;
size?: number;
} & CommonReportFilters,
): Promise<RecordResponse> {
return api.get<RecordResponse>("/reports/record", params as QueryParams);
},

View File

@@ -0,0 +1,53 @@
import { API_BASE_URL } from "@/lib/api-client";
/**
* Trigger a CSV/PDF download for one of the admin reports.
* The browser handles the download via the Content-Disposition
* header from the backend; we add the JWT manually because the
* <a download> tag cannot send Authorization headers.
*/
async function download(path: string, filename: string, params: Record<string, unknown> = {}) {
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") {
url.searchParams.set(k, String(v));
}
});
const token = localStorage.getItem("encoach_token") || "";
const res = await fetch(url.toString(), {
method: "GET",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
throw new Error(`Download failed: ${res.status} ${res.statusText}`);
}
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 100);
}
export const reportsExportService = {
async studentPerformance(format: "csv" | "pdf", filters: Record<string, unknown> = {}) {
const ext = format === "pdf" ? "pdf" : "csv";
return download(
"/reports/student-performance/export",
`student-performance.${ext}`,
{ ...filters, format },
);
},
async record(format: "csv" | "pdf", filters: Record<string, unknown> = {}) {
const ext = format === "pdf" ? "pdf" : "csv";
return download(
"/reports/record/export",
`attempt-record.${ext}`,
{ ...filters, format },
);
},
};

View File

@@ -0,0 +1,42 @@
import { api } from "@/lib/api-client";
export interface TurnitinSettings {
entity_id: number;
entity_name: string;
enabled: boolean;
api_url: string;
account_id: string;
has_api_key: boolean;
api_key: string | null;
}
export const turnitinService = {
async getSettings(entityId: number): Promise<TurnitinSettings> {
return api.get(`/turnitin/settings/${entityId}`);
},
async patchSettings(entityId: number, payload: {
enabled?: boolean;
api_key?: string;
api_url?: string;
account_id?: string;
}): Promise<TurnitinSettings> {
return api.patch(`/turnitin/settings/${entityId}`, payload);
},
async test(entityId: number) {
return api.post<{
ok: boolean;
job_id: string;
originality_score: number | null;
}>(`/turnitin/test/${entityId}`, {});
},
async submit(text: string, title: string, entityId?: number) {
return api.post<{
job_id: string;
entity_id: number;
originality_score: number | null;
}>(`/turnitin/submit`, { text, title, entity_id: entityId });
},
};

View File

@@ -7,6 +7,8 @@ export interface DiscussionBoard {
batch_name?: string;
chapter_id?: number;
chapter_name?: string;
plan_id?: number | null;
plan_name?: string | null;
assignment_id?: number;
is_enabled: boolean;
allow_student_posts: boolean;

View File

@@ -190,6 +190,16 @@ export interface CoursePlanWeek {
material_count: number;
}
// Phase 1 (2026-04-29): material `kind` describes how the student
// interacts with the row. Existing materials (created before the
// migration) default to `content` so the UI can keep rendering them.
export type CoursePlanMaterialKind =
| "content"
| "quiz"
| "test"
| "resource"
| "assignment";
export interface CoursePlanMaterial {
id: number;
plan_id: number;
@@ -197,6 +207,18 @@ export interface CoursePlanMaterial {
week_number: number;
skill: string;
material_type: CoursePlanMaterialType | string;
/** Phase 1: top-level interaction kind (content / quiz / test / …). */
kind?: CoursePlanMaterialKind;
/** Phase 1: optional link to an exam template (used when kind=quiz/test). */
exam_template_id?: number | null;
exam_template_name?: string | null;
/** Phase 1: optional link to a library resource (used when kind=resource). */
resource_id?: number | null;
resource_name?: string | null;
/** Phase 1: whether this row counts toward the course grade. */
is_graded?: boolean;
/** Phase 1: optional deadline displayed on the student weekly view. */
due_date?: string | null;
title: string;
is_static?: boolean;
share_date?: string | null;

View File

@@ -28,6 +28,13 @@ export default {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
// Dark charcoal CTA (default Button color in the new visual
// identity). Kept distinct from `primary` so the brand orange
// remains available for accents / highlights / charts.
cta: {
DEFAULT: "hsl(var(--cta))",
foreground: "hsl(var(--cta-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
@@ -76,9 +83,15 @@ export default {
},
},
borderRadius: {
// `--radius` is now 1rem (16px) — matching the soft-card / pill
// language of the new visual identity. The full ladder gives
// shadcn primitives sensible scaled steps without needing
// every component to opt in to a specific class.
sm: "calc(var(--radius) - 8px)",
md: "calc(var(--radius) - 4px)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
xl: "calc(var(--radius) + 4px)",
"2xl": "calc(var(--radius) + 12px)",
},
keyframes: {
"accordion-down": {