From d5b1987bba1d80b1b4a37f865dfde9c3c7ca0a71 Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Thu, 30 Apr 2026 14:06:48 +0400 Subject: [PATCH] =?UTF-8?q?feat(platform):=20full=20institutional=20roadma?= =?UTF-8?q?p=20=E2=80=94=20exam=20security,=20live=20sessions,=20handwritt?= =?UTF-8?q?en,=20personalized=20AI=20plans,=20Turnitin,=20lesson=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//security/events, GET /api/teacher/exam/attempt//security/events, GET /api/teacher/exam//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
. - 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//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 --- .../controllers/course_plan.py | 213 ++++++ .../encoach_ai_course/models/course_plan.py | 74 ++ .../services/source_indexer.py | 117 ++- .../encoach_lms_api/__manifest__.py | 3 + .../encoach_lms_api/controllers/__init__.py | 7 + .../controllers/communication.py | 48 ++ .../controllers/exam_security.py | 282 +++++++ .../controllers/handwritten.py | 246 ++++++ .../controllers/live_sessions.py | 705 ++++++++++++++++++ .../encoach_lms_api/controllers/lms_core.py | 1 + .../controllers/personalized_plan.py | 207 +++++ .../encoach_lms_api/controllers/reports.py | 75 +- .../controllers/reports_export.py | 247 ++++++ .../controllers/teacher_insights.py | 219 ++++++ .../encoach_lms_api/controllers/turnitin.py | 188 +++++ .../encoach_lms_api/models/__init__.py | 5 + .../encoach_lms_api/models/communication.py | 9 +- .../encoach_lms_api/models/course_ext.py | 13 + .../encoach_lms_api/models/entity_turnitin.py | 32 + .../encoach_lms_api/models/exam_security.py | 97 +++ .../models/handwritten_submission.py | 63 ++ .../encoach_lms_api/models/live_session.py | 137 ++++ .../models/personalized_plan.py | 32 + .../security/ir.model.access.csv | 4 + docs/PROJECT_SUMMARY.md | 6 +- frontend/src/App.tsx | 12 + frontend/src/components/AdminLmsLayout.tsx | 1 + frontend/src/components/MaterialViewer.tsx | 15 +- frontend/src/components/StudentLayout.tsx | 10 +- frontend/src/components/TeacherLayout.tsx | 3 +- frontend/src/components/ai/AITutorAvatar.tsx | 114 +++ .../coursePlan/MaterialBookView.tsx | 151 +++- .../components/discussion/DiscussionTab.tsx | 293 ++++++++ .../src/components/lesson/ImageLightbox.tsx | 62 ++ .../components/lesson/InfographicBlock.tsx | 105 +++ .../student/HandwrittenSubmissionPanel.tsx | 154 ++++ .../student/PersonalizedPlanCard.tsx | 296 ++++++++ frontend/src/components/ui/button.tsx | 18 +- frontend/src/components/ui/card.tsx | 13 +- frontend/src/hooks/useExamSecurity.ts | 113 +++ frontend/src/i18n/locales/ar.ts | 4 + frontend/src/i18n/locales/en.ts | 3 + frontend/src/index.css | 141 ++-- frontend/src/pages/LiveSessionRoom.tsx | 347 +++++++++ frontend/src/pages/LiveSessionsPage.tsx | 271 +++++++ frontend/src/pages/StatsCorporatePage.tsx | 55 +- frontend/src/pages/StudentPerformancePage.tsx | 114 ++- .../src/pages/admin/AdminCoursePlanDetail.tsx | 374 ++++++++++ frontend/src/pages/admin/TurnitinSettings.tsx | 222 ++++++ frontend/src/pages/student/ExamSession.tsx | 10 + .../src/pages/student/StudentCourseDetail.tsx | 11 +- .../pages/student/StudentCoursePlanDetail.tsx | 24 +- frontend/src/pages/student/StudentCourses.tsx | 322 ++++++-- .../src/pages/student/StudentDashboard.tsx | 13 +- .../pages/teacher/TeacherCourseInsights.tsx | 372 +++++++++ frontend/src/pages/teacher/TeacherCourses.tsx | 5 +- .../teacher/TeacherTestSessionConsole.tsx | 180 +++++ .../src/services/communication.service.ts | 13 +- frontend/src/services/coursePlan.service.ts | 82 ++ frontend/src/services/examSecurity.service.ts | 89 +++ frontend/src/services/handwritten.service.ts | 69 ++ frontend/src/services/index.ts | 6 + frontend/src/services/liveSession.service.ts | 181 +++++ .../src/services/personalizedPlan.service.ts | 43 ++ frontend/src/services/reports.service.ts | 58 +- .../src/services/reportsExport.service.ts | 53 ++ frontend/src/services/turnitin.service.ts | 42 ++ frontend/src/types/communication.ts | 2 + frontend/src/types/coursePlan.ts | 22 + frontend/tailwind.config.ts | 17 +- 70 files changed, 7337 insertions(+), 198 deletions(-) create mode 100644 backend/custom_addons/encoach_lms_api/controllers/exam_security.py create mode 100644 backend/custom_addons/encoach_lms_api/controllers/handwritten.py create mode 100644 backend/custom_addons/encoach_lms_api/controllers/live_sessions.py create mode 100644 backend/custom_addons/encoach_lms_api/controllers/personalized_plan.py create mode 100644 backend/custom_addons/encoach_lms_api/controllers/reports_export.py create mode 100644 backend/custom_addons/encoach_lms_api/controllers/teacher_insights.py create mode 100644 backend/custom_addons/encoach_lms_api/controllers/turnitin.py create mode 100644 backend/custom_addons/encoach_lms_api/models/entity_turnitin.py create mode 100644 backend/custom_addons/encoach_lms_api/models/exam_security.py create mode 100644 backend/custom_addons/encoach_lms_api/models/handwritten_submission.py create mode 100644 backend/custom_addons/encoach_lms_api/models/live_session.py create mode 100644 backend/custom_addons/encoach_lms_api/models/personalized_plan.py create mode 100644 frontend/src/components/ai/AITutorAvatar.tsx create mode 100644 frontend/src/components/discussion/DiscussionTab.tsx create mode 100644 frontend/src/components/lesson/ImageLightbox.tsx create mode 100644 frontend/src/components/lesson/InfographicBlock.tsx create mode 100644 frontend/src/components/student/HandwrittenSubmissionPanel.tsx create mode 100644 frontend/src/components/student/PersonalizedPlanCard.tsx create mode 100644 frontend/src/hooks/useExamSecurity.ts create mode 100644 frontend/src/pages/LiveSessionRoom.tsx create mode 100644 frontend/src/pages/LiveSessionsPage.tsx create mode 100644 frontend/src/pages/admin/TurnitinSettings.tsx create mode 100644 frontend/src/pages/teacher/TeacherCourseInsights.tsx create mode 100644 frontend/src/pages/teacher/TeacherTestSessionConsole.tsx create mode 100644 frontend/src/services/examSecurity.service.ts create mode 100644 frontend/src/services/handwritten.service.ts create mode 100644 frontend/src/services/liveSession.service.ts create mode 100644 frontend/src/services/personalizedPlan.service.ts create mode 100644 frontend/src/services/reportsExport.service.ts create mode 100644 frontend/src/services/turnitin.service.ts diff --git a/backend/custom_addons/encoach_ai_course/controllers/course_plan.py b/backend/custom_addons/encoach_ai_course/controllers/course_plan.py index 8ed0118f..3610f4eb 100644 --- a/backend/custom_addons/encoach_ai_course/controllers/course_plan.py +++ b/backend/custom_addons/encoach_ai_course/controllers/course_plan.py @@ -352,6 +352,95 @@ class CoursePlanController(http.Controller): ) return _error_response(str(exc), code) + # ------------------------------------------------------------------ + # POST /api/ai/course-plan//weeks//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//weeks//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/ + # ------------------------------------------------------------------ + # 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/', + 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//weeks//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 diff --git a/backend/custom_addons/encoach_ai_course/models/course_plan.py b/backend/custom_addons/encoach_ai_course/models/course_plan.py index 9afe38de..5a5c5892 100644 --- a/backend/custom_addons/encoach_ai_course/models/course_plan.py +++ b/backend/custom_addons/encoach_ai_course/models/course_plan.py @@ -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, diff --git a/backend/custom_addons/encoach_ai_course/services/source_indexer.py b/backend/custom_addons/encoach_ai_course/services/source_indexer.py index ccaeeea8..d16ee7ac 100644 --- a/backend/custom_addons/encoach_ai_course/services/source_indexer.py +++ b/backend/custom_addons/encoach_ai_course/services/source_indexer.py @@ -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' 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}') diff --git a/backend/custom_addons/encoach_lms_api/__manifest__.py b/backend/custom_addons/encoach_lms_api/__manifest__.py index 904a2f8b..110d4d07 100644 --- a/backend/custom_addons/encoach_lms_api/__manifest__.py +++ b/backend/custom_addons/encoach_lms_api/__manifest__.py @@ -23,6 +23,9 @@ 'encoach_ai', 'encoach_taxonomy', 'encoach_resources', + 'encoach_scoring', + 'encoach_exam_template', + 'encoach_ai_course', ], 'data': [ 'security/ir.model.access.csv', diff --git a/backend/custom_addons/encoach_lms_api/controllers/__init__.py b/backend/custom_addons/encoach_lms_api/controllers/__init__.py index 7270f61f..b03a1a54 100644 --- a/backend/custom_addons/encoach_lms_api/controllers/__init__.py +++ b/backend/custom_addons/encoach_lms_api/controllers/__init__.py @@ -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 diff --git a/backend/custom_addons/encoach_lms_api/controllers/communication.py b/backend/custom_addons/encoach_lms_api/controllers/communication.py index 8db63eb6..1f8a28cd 100644 --- a/backend/custom_addons/encoach_lms_api/controllers/communication.py +++ b/backend/custom_addons/encoach_lms_api/controllers/communication.py @@ -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/', + 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/', + 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/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) @jwt_required def update_board(self, bid, **kw): diff --git a/backend/custom_addons/encoach_lms_api/controllers/exam_security.py b/backend/custom_addons/encoach_lms_api/controllers/exam_security.py new file mode 100644 index 00000000..029cc333 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/exam_security.py @@ -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//security/events` — + teacher console reads the per-assignment log. +* `GET /api/teacher/exam/attempt//security/events` — + same, scoped to a specific attempt. +* `GET /api/teacher/exam//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//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//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//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) diff --git a/backend/custom_addons/encoach_lms_api/controllers/handwritten.py b/backend/custom_addons/encoach_lms_api/controllers/handwritten.py new file mode 100644 index 00000000..b2d8fc89 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/handwritten.py @@ -0,0 +1,246 @@ +"""Handwritten solution upload + AI grading controller. + +* `POST /api/student/handwritten/` — upload pages. +* `GET /api/student/handwritten/` — fetch my latest. +* `GET /api/teacher/handwritten/` — list submissions. +* `POST /api/teacher/handwritten//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/', + 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/', + 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/', + 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//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) diff --git a/backend/custom_addons/encoach_lms_api/controllers/live_sessions.py b/backend/custom_addons/encoach_lms_api/controllers/live_sessions.py new file mode 100644 index 00000000..fdf20f53 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/live_sessions.py @@ -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/` — read +* `PATCH /api/live-sessions/` — update +* `DELETE /api/live-sessions/` — cancel +* `POST /api/live-sessions//notify` — email enrolled students +* `POST /api/live-sessions//join` — student/teacher posts on entering Jitsi +* `POST /api/live-sessions//leave` — student/teacher posts on leaving Jitsi +* `POST /api/live-sessions//end` — host ends session +* `POST /api/live-sessions//recording` — host stores recording URL +* `POST /api/live-sessions//remove-participant` — host kicks user (with reason) +* `GET /api/live-sessions//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"

Hi {user.name},

" + f"

You have a new live session: {session.title}.

" + f"

When: {session.scheduled_at} ({session.duration_min} min)
" + f"Course: {session.course_id.name or session.plan_id.name or ''}
" + f"Host: {session.host_id.name}

" + f'

' + f'Join from dashboard

' + f"

" + f"The attached .ics will add this session to your calendar.

" + ) + 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/', + 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/', + 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/', + 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//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//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//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//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//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//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//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//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) diff --git a/backend/custom_addons/encoach_lms_api/controllers/lms_core.py b/backend/custom_addons/encoach_lms_api/controllers/lms_core.py index 6debc043..e3e147a9 100644 --- a/backend/custom_addons/encoach_lms_api/controllers/lms_core.py +++ b/backend/custom_addons/encoach_lms_api/controllers/lms_core.py @@ -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, diff --git a/backend/custom_addons/encoach_lms_api/controllers/personalized_plan.py b/backend/custom_addons/encoach_lms_api/controllers/personalized_plan.py new file mode 100644 index 00000000..cf4422bf --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/personalized_plan.py @@ -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) diff --git a/backend/custom_addons/encoach_lms_api/controllers/reports.py b/backend/custom_addons/encoach_lms_api/controllers/reports.py index 1119c4d3..f498ae04 100644 --- a/backend/custom_addons/encoach_lms_api/controllers/reports.py +++ b/backend/custom_addons/encoach_lms_api/controllers/reports.py @@ -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') diff --git a/backend/custom_addons/encoach_lms_api/controllers/reports_export.py b/backend/custom_addons/encoach_lms_api/controllers/reports_export.py new file mode 100644 index 00000000..b8eca7a1 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/reports_export.py @@ -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'{h}' for h in headers) + body = '' + for r in rows: + cells = ''.join(f'{c}' for c in r) + body += f'{cells}' + return ( + '' + '' + f'

{title}

' + f'{th}{body}
' + '' + ) diff --git a/backend/custom_addons/encoach_lms_api/controllers/teacher_insights.py b/backend/custom_addons/encoach_lms_api/controllers/teacher_insights.py new file mode 100644 index 00000000..f207bb7c --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/teacher_insights.py @@ -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//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) diff --git a/backend/custom_addons/encoach_lms_api/controllers/turnitin.py b/backend/custom_addons/encoach_lms_api/controllers/turnitin.py new file mode 100644 index 00000000..263e1eab --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/controllers/turnitin.py @@ -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/` — read the current config. +* `PATCH /api/turnitin/settings/` — admin updates key. +* `POST /api/turnitin/test/` — 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/', + 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/', + 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/', + 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) diff --git a/backend/custom_addons/encoach_lms_api/models/__init__.py b/backend/custom_addons/encoach_lms_api/models/__init__.py index e32bd4db..bb0810a3 100644 --- a/backend/custom_addons/encoach_lms_api/models/__init__.py +++ b/backend/custom_addons/encoach_lms_api/models/__init__.py @@ -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 diff --git a/backend/custom_addons/encoach_lms_api/models/communication.py b/backend/custom_addons/encoach_lms_api/models/communication.py index 0b2c977c..c8680b99 100644 --- a/backend/custom_addons/encoach_lms_api/models/communication.py +++ b/backend/custom_addons/encoach_lms_api/models/communication.py @@ -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) diff --git a/backend/custom_addons/encoach_lms_api/models/course_ext.py b/backend/custom_addons/encoach_lms_api/models/course_ext.py index 8d10560d..7a68fbfd 100644 --- a/backend/custom_addons/encoach_lms_api/models/course_ext.py +++ b/backend/custom_addons/encoach_lms_api/models/course_ext.py @@ -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') diff --git a/backend/custom_addons/encoach_lms_api/models/entity_turnitin.py b/backend/custom_addons/encoach_lms_api/models/entity_turnitin.py new file mode 100644 index 00000000..100fb19a --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/entity_turnitin.py @@ -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', + ) diff --git a/backend/custom_addons/encoach_lms_api/models/exam_security.py b/backend/custom_addons/encoach_lms_api/models/exam_security.py new file mode 100644 index 00000000..509e2d98 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/exam_security.py @@ -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.', + ) diff --git a/backend/custom_addons/encoach_lms_api/models/handwritten_submission.py b/backend/custom_addons/encoach_lms_api/models/handwritten_submission.py new file mode 100644 index 00000000..3eb4dbef --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/handwritten_submission.py @@ -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 (0–100). 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() diff --git a/backend/custom_addons/encoach_lms_api/models/live_session.py b/backend/custom_addons/encoach_lms_api/models/live_session.py new file mode 100644 index 00000000..4e8aab4b --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/live_session.py @@ -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) diff --git a/backend/custom_addons/encoach_lms_api/models/personalized_plan.py b/backend/custom_addons/encoach_lms_api/models/personalized_plan.py new file mode 100644 index 00000000..ae4e7607 --- /dev/null +++ b/backend/custom_addons/encoach_lms_api/models/personalized_plan.py @@ -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.', + ) diff --git a/backend/custom_addons/encoach_lms_api/security/ir.model.access.csv b/backend/custom_addons/encoach_lms_api/security/ir.model.access.csv index 6825cb58..e13c8ed1 100644 --- a/backend/custom_addons/encoach_lms_api/security/ir.model.access.csv +++ b/backend/custom_addons/encoach_lms_api/security/ir.model.access.csv @@ -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 diff --git a/docs/PROJECT_SUMMARY.md b/docs/PROJECT_SUMMARY.md index 82745d4c..78cb45a6 100644 --- a/docs/PROJECT_SUMMARY.md +++ b/docs/PROJECT_SUMMARY.md @@ -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 (1–12)**, 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//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//security/events`, `GET /api/teacher/exam/attempt//security/events`, `GET /api/teacher/exam//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/`, `GET /api/teacher/handwritten/`, `POST /api/teacher/handwritten//review`), live sessions (full CRUD + `/notify`, `/join`, `/leave`, `/end`, `/recording`, `/remove-participant`, `/attendance`, `/ics`), Turnitin (`GET/PATCH /api/turnitin/settings/`, `POST /api/turnitin/test/`, `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//weeks//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//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//sections`, `PATCH/DELETE /api/courses//sections/`. 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//assign-section` and new `GET /api/classrooms//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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9576adf2..51189b84 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => ( }> } /> + } /> + } /> {/* Student routes */} @@ -297,6 +306,8 @@ const App = () => ( } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -331,6 +342,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> {/* Original academic pages */} diff --git a/frontend/src/components/AdminLmsLayout.tsx b/frontend/src/components/AdminLmsLayout.tsx index a5d3d010..2f51e953 100644 --- a/frontend/src/components/AdminLmsLayout.tsx +++ b/frontend/src/components/AdminLmsLayout.tsx @@ -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 ============= diff --git a/frontend/src/components/MaterialViewer.tsx b/frontend/src/components/MaterialViewer.tsx index b8b7ee6b..7cf9eb2d 100644 --- a/frontend/src/components/MaterialViewer.tsx +++ b/frontend/src/components/MaterialViewer.tsx @@ -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 ; return (
- {material.name}
); @@ -137,10 +139,13 @@ function ImageViewer({ material }: { material: ChapterMaterial }) { function ArticleViewer({ material }: { material: ChapterMaterial }) { if (!material.description) return ; + const paragraphs = material.description.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean); return ( -
-
{material.description}
-
+
+ {paragraphs.length > 0 + ? paragraphs.map((p, i) =>

{p}

) + :

{material.description}

} +
); } diff --git a/frontend/src/components/StudentLayout.tsx b/frontend/src/components/StudentLayout.tsx index 1488677b..f3c84282 100644 --- a/frontend/src/components/StudentLayout.tsx +++ b/frontend/src/components/StudentLayout.tsx @@ -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 }, ], }, { diff --git a/frontend/src/components/TeacherLayout.tsx b/frontend/src/components/TeacherLayout.tsx index 5843d7e8..4202ddca 100644 --- a/frontend/src/components/TeacherLayout.tsx +++ b/frontend/src/components/TeacherLayout.tsx @@ -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 }, ], }, { diff --git a/frontend/src/components/ai/AITutorAvatar.tsx b/frontend/src/components/ai/AITutorAvatar.tsx new file mode 100644 index 00000000..1997852d --- /dev/null +++ b/frontend/src/components/ai/AITutorAvatar.tsx @@ -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(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 ( +
+ +
+ +
+ {isSpeaking && ( + + speaking + + )} +
+ ); +} diff --git a/frontend/src/components/coursePlan/MaterialBookView.tsx b/frontend/src/components/coursePlan/MaterialBookView.tsx index 5fa8b311..95a5309b 100644 --- a/frontend/src/components/coursePlan/MaterialBookView.tsx +++ b/frontend/src/components/coursePlan/MaterialBookView.tsx @@ -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 = { 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 ; + } + if (v && typeof v === "object" && !Array.isArray(v)) { + const obj = v as Record; + 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 ; + } + } + return null; +} + +function isLikelyParagraph(s: string): boolean { + return s.length > 80 || /[.!?]\s/.test(s); +} + +function renderString(value: string): React.ReactNode { + if (isImageUrl(value)) { + return ; + } + if (!isLikelyParagraph(value)) { + return

{value}

; + } + return ( +
+ {value.split(/\n{2,}/).map((para, i) => ( +

{para.trim()}

+ ))} +
+ ); +} + 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

{String(value)}

; } if (Array.isArray(value)) { if (value.length === 0) return null; return ( -
    +
      {value.map((item, idx) => (
    • {renderAny(item)}
    • ))} @@ -43,21 +98,78 @@ function renderAny(value: unknown): React.ReactNode { if (typeof value === "object") { const entries = Object.entries(value as Record); return ( -
      - {entries.map(([k, v]) => ( -
      -
      - {k.replace(/_/g, " ")} -
      -
      {renderAny(v)}
      -
      - ))} +
      + {entries.map(([k, v]) => renderEntry(k, v))}
      ); } 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
      {node}
      ; + } + if (IMAGES_KEYS.has(norm) && Array.isArray(value)) { + return ( +
      + {value.map((item, i) => { + const node = renderImage(item, `${label} ${i + 1}`); + return node + ?
      {node}
      + :
      {renderAny(item)}
      ; + })} +
      + ); + } + + 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 ( + + ); + } + return ( + + {renderAny(value)} + + ); + } + + return ( +
      +
      + {label} +
      +
      {renderAny(value)}
      +
      + ); +} + +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 ( -
      +
      {hasStructured ? ( renderAny(stripEmbedded(body)) ) : ( -

      - {material.body_text || "No content available yet."} -

      +
      + {(material.body_text || "No content available yet.") + .split(/\n{2,}/) + .map((p, i) =>

      {p}

      )} +
      )} {hasEmbedded && ( -
      -
      Practice
      +
      +
      + + Practice +
      + 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 ( +
      + +
      + ); + } + + if (boardQuery.isError) { + return ( +
      + {t("discussion.loadFailed", "Failed to load discussion.")} +
      + ); + } + + return ( +
      + {!hideHeader && ( +
      + +

      + {t("discussion.title", "Discussion")} +

      + {board?.post_count !== undefined && ( + {board.post_count} + )} +
      + )} + + {allowComposer && board?.allow_student_posts !== false && ( + + + setTitle(e.target.value)} + placeholder={t("discussion.titlePlaceholder", "Title (optional)")} + /> +