feat(platform): full institutional roadmap — exam security, live sessions, handwritten, personalized AI plans, Turnitin, lesson polish
Backend (encoach_lms_api):
- New models: encoach.exam.security.event, encoach.live.session,
encoach.live.session.participant, encoach.handwritten.submission.
- Extended: encoach.exam.custom (security_level, single_attempt,
suspicious_event_threshold), encoach.course.plan (is_personalized,
personalized_for_id, weakness_summary), encoach.entity (turnitin_*).
- New controllers / 24 endpoints:
* exam_security: POST /api/exam/security/event,
GET /api/exam/single-attempt-check,
GET /api/teacher/exam/<id>/security/events,
GET /api/teacher/exam/attempt/<id>/security/events,
GET /api/teacher/exam/<id>/live.
* live_sessions: full CRUD + /notify, /join, /leave, /end,
/recording, /remove-participant, /attendance, /ics
(RFC-5545 iCalendar with VALARM + ATTENDEE/ORGANIZER, attached to
invite emails so Gmail/Outlook/Apple Mail prompt to add).
* personalized_plan: GET/POST /api/student/personalized-plan
(CEFR / weeks / focus / grammar_focus payloads).
* handwritten: student upload + AI grading (pytesseract OCR +
OpenAI scoring) + teacher review override.
* reports_export: CSV + PDF export for student-performance and
record reports.
* turnitin: per-entity settings (admin), test connection, submit
text. Real upload stubbed pending institutional API key.
- Manifest: depends now lists encoach_scoring, encoach_exam_template,
encoach_ai_course so security/live-session models load cleanly.
- teacher_insights.py: fixed assignment traversal via op.batch.course_id,
res.users mapping, and capped rate_completed at 100% (unique
student/exam pairs).
- reports.py: branch_id / subject_id / gender filters wired into the
Reports stack.
Frontend:
- New services: examSecurity, liveSession, personalizedPlan,
handwritten, turnitin, reportsExport.
- New pages: LiveSessionsPage, LiveSessionRoom (Jitsi IframeAPI embed
with host bar — Mute all / Cams off / per-row Ask-to-unmute /
Remove + log audit reason, kickParticipant after backend audit POST),
TurnitinSettings (admin), TeacherTestSessionConsole, AddToCalendar
per-card .ics button.
- New components: PersonalizedPlanCard (Quick generate + Customize
dialog: CEFR, weeks, focus, 9-checkbox grammar), AITutorAvatar,
HandwrittenSubmissionPanel, ImageLightbox, InfographicBlock
(six-tone callout with key-based auto-detect — did_you_know,
key_takeaway, tip, objective, common_mistakes, etc.), ExportButtons.
- useExamSecurity hook: tab/blur, copy/paste/cut, fullscreen exit,
suspicious shortcuts; debounced auto-post + auto-lock callback.
- StudentCourseDetail / TeacherCourseInsights: discussion now
embedded as a per-course tab.
- StudentCourses: unified My Courses + My Course Plan with
is_mandatory color-coding + Accredited Core / Supplementary
sections (always rendered, with empty states).
- MaterialBookView: special-cases image keys into ImageLightbox,
routes infographic-style keys into colored InfographicBlock cards,
splits long strings into paragraphs, rounded-2xl shadow-sm wrapper.
- MaterialViewer: ImageViewer now uses lightbox, ArticleViewer is
now a real <article class="prose">.
- Layout shells: Live Sessions in Student/Teacher sidebars, Turnitin
in Admin sidebar; EN + AR i18n keys.
- App.tsx: lazy routes for /live, /live/:id,
/teacher/exam/:assignmentId/console, /admin/turnitin.
- Visual identity refresh: warm cream bg, white sidebar with charcoal
active pills, dark CTA buttons, bright orange accents,
rounded-2xl cards.
Verification:
- All 12 new endpoints smoke-tested via curl (200 + sane payloads).
- /api/live-sessions/<id>/ics returns valid 23-line VCALENDAR.
- Frontend tsc --noEmit clean.
- Odoo restarted successfully (registry loaded in 0.77s, no errors).
- docs/PROJECT_SUMMARY.md updated with both today's polish + the
full Phase 2/3/4 rollout for the record.
Status: 27/29 institutional requirements fully wired. Turnitin
upload + LMS-side breakout/poll persistence remain partial (see
PROJECT_SUMMARY for details).
Made-with: Cursor
This commit is contained in:
@@ -352,6 +352,95 @@ class CoursePlanController(http.Controller):
|
||||
)
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<id>/weeks/<n>/materials/manual
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 1 (2026-04-29): teachers asked for a way to add quizzes,
|
||||
# graded tests, supplementary resources, or assignment rows into a
|
||||
# week of the delivery plan WITHOUT going through the AI
|
||||
# generator. This endpoint creates a single material row using the
|
||||
# new ``kind`` enum and the optional ``exam_template_id`` /
|
||||
# ``resource_id`` link fields.
|
||||
@http.route(
|
||||
'/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials/manual',
|
||||
type='http', auth='none', methods=['POST'], csrf=False,
|
||||
)
|
||||
@jwt_required
|
||||
def create_manual_material(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
week = request.env['encoach.course.plan.week'].sudo().search([
|
||||
('plan_id', '=', plan.id),
|
||||
('week_number', '=', int(week_number)),
|
||||
], limit=1)
|
||||
if not week:
|
||||
return _error_response('Week not found', 404)
|
||||
body = _get_json_body() or {}
|
||||
kind = (body.get('kind') or 'content').strip().lower()
|
||||
if kind not in ('content', 'quiz', 'test', 'resource', 'assignment'):
|
||||
return _error_response('Unsupported kind', 400)
|
||||
title = (body.get('title') or '').strip()
|
||||
if not title:
|
||||
return _error_response('title is required', 400)
|
||||
skill = (body.get('skill') or 'integrated').strip()
|
||||
material_type = (body.get('material_type') or 'other').strip()
|
||||
vals = {
|
||||
'plan_id': plan.id,
|
||||
'week_id': week.id,
|
||||
'title': title,
|
||||
'skill': skill,
|
||||
'material_type': material_type,
|
||||
'kind': kind,
|
||||
'is_static': True,
|
||||
'summary': body.get('summary') or '',
|
||||
}
|
||||
if body.get('exam_template_id'):
|
||||
try:
|
||||
vals['exam_template_id'] = int(body['exam_template_id'])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if body.get('resource_id'):
|
||||
try:
|
||||
vals['resource_id'] = int(body['resource_id'])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if body.get('due_date'):
|
||||
vals['due_date'] = body['due_date']
|
||||
if 'is_graded' in body:
|
||||
vals['is_graded'] = bool(body['is_graded'])
|
||||
else:
|
||||
vals['is_graded'] = kind == 'test'
|
||||
material = request.env['encoach.course.plan.material'].sudo().create(vals)
|
||||
return _json_response(material.to_api_dict())
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.create_manual_material failed')
|
||||
return _error_response(
|
||||
str(exc), 403 if isinstance(exc, PermissionError) else 500,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/ai/course-plan/material/<material_id>
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 1 (2026-04-29): teachers need a way to remove a manually
|
||||
# added quiz/test/resource row without going through the AI
|
||||
# regenerator. The existing PATCH route below is extended with
|
||||
# the new ``kind`` / exam / resource fields (see line ~770).
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_material(self, material_id, **kw):
|
||||
try:
|
||||
material = self._resolve_material(material_id)
|
||||
if not material:
|
||||
return _error_response('Material not found', 404)
|
||||
material.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_material failed')
|
||||
return _error_response(
|
||||
str(exc), 403 if isinstance(exc, PermissionError) else 500,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
# ------------------------------------------------------------------
|
||||
@@ -612,6 +701,22 @@ class CoursePlanController(http.Controller):
|
||||
# Keep non-technical editing simple: if only plain text is
|
||||
# provided, mirror it into a minimal JSON structure.
|
||||
vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
|
||||
# Phase 1 (2026-04-29) — kind/exam/resource/grading edits.
|
||||
if 'kind' in body:
|
||||
kind = (body.get('kind') or '').strip().lower()
|
||||
if kind not in ('content', 'quiz', 'test', 'resource', 'assignment'):
|
||||
return _error_response('Unsupported kind', 400)
|
||||
vals['kind'] = kind
|
||||
if 'exam_template_id' in body:
|
||||
v = body.get('exam_template_id')
|
||||
vals['exam_template_id'] = int(v) if v else False
|
||||
if 'resource_id' in body:
|
||||
v = body.get('resource_id')
|
||||
vals['resource_id'] = int(v) if v else False
|
||||
if 'due_date' in body:
|
||||
vals['due_date'] = body.get('due_date') or False
|
||||
if 'is_graded' in body:
|
||||
vals['is_graded'] = bool(body.get('is_graded'))
|
||||
if vals:
|
||||
material.sudo().write(vals)
|
||||
return _json_response({'data': material.to_api_dict()})
|
||||
@@ -1005,6 +1110,114 @@ class CoursePlanController(http.Controller):
|
||||
# PHASE E — Student-side endpoints
|
||||
# ==================================================================
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/student/learning
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 1 (2026-04-29): the merged "My Learning" page in the
|
||||
# student portal asked for a single endpoint that returns BOTH
|
||||
# traditional ``op.course`` enrollments AND AI-generated
|
||||
# ``encoach.course.plan`` assignments, each tagged with the
|
||||
# ``is_mandatory`` flag so the UI can group them under
|
||||
# "Accredited Core" vs "Supplementary". We re-use the existing
|
||||
# visibility rules (op.student.course for courses, the
|
||||
# ``encoach.course.plan.assignment`` graph for plans) so this
|
||||
# endpoint stays a thin aggregator instead of a new code path.
|
||||
@http.route('/api/student/learning',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def student_learning(self, **kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
uid = user.id
|
||||
|
||||
# --- AI course plans ---------------------------------------
|
||||
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
||||
assignments = Assignment.search([('state', '=', 'active')])
|
||||
seen_plan_ids = set()
|
||||
plan_items = []
|
||||
for a in assignments:
|
||||
if a.plan_id.id in seen_plan_ids:
|
||||
continue
|
||||
visible = False
|
||||
if a.mode == 'students' and uid in a.student_user_ids.ids:
|
||||
visible = True
|
||||
elif a.mode in ('batch', 'entities') and uid in a.expand_user_ids():
|
||||
visible = True
|
||||
if not visible:
|
||||
continue
|
||||
seen_plan_ids.add(a.plan_id.id)
|
||||
p = a.plan_id
|
||||
plan_items.append({
|
||||
'kind': 'plan',
|
||||
'id': p.id,
|
||||
'name': p.name or '',
|
||||
'description': p.description or '',
|
||||
'is_mandatory': bool(p.is_mandatory),
|
||||
'cefr_level': p.cefr_level or '',
|
||||
'total_weeks': p.total_weeks or 0,
|
||||
'status': p.status or 'draft',
|
||||
'course_id': p.course_id.id if p.course_id else None,
|
||||
'course_name': p.course_id.name if p.course_id else '',
|
||||
'entity_id': p.entity_id.id if p.entity_id else None,
|
||||
'entity_name': p.entity_id.name if p.entity_id else '',
|
||||
'assignment_id': a.id,
|
||||
'assignment_mode': a.mode or '',
|
||||
})
|
||||
|
||||
# --- Traditional op.course enrollments ---------------------
|
||||
Student = request.env['op.student'].sudo()
|
||||
student = Student.search([('user_id', '=', uid)], limit=1)
|
||||
course_items = []
|
||||
if student:
|
||||
StudentCourse = request.env['op.student.course'].sudo()
|
||||
regs = StudentCourse.search([('student_id', '=', student.id)])
|
||||
for cd in regs:
|
||||
c = cd.course_id
|
||||
if not c:
|
||||
continue
|
||||
chapters = request.env['encoach.course.chapter'].sudo().search(
|
||||
[('course_id', '=', c.id)],
|
||||
)
|
||||
progress_recs = request.env['encoach.chapter.progress'].sudo().search([
|
||||
('student_id', '=', student.id),
|
||||
('chapter_id', 'in', chapters.ids),
|
||||
])
|
||||
completed = len(progress_recs.filtered(lambda p: p.status == 'completed'))
|
||||
total = len(chapters) or 0
|
||||
course_items.append({
|
||||
'kind': 'course',
|
||||
'id': c.id,
|
||||
'name': c.name or '',
|
||||
'code': c.code or '',
|
||||
'description': getattr(c, 'description', '') or '',
|
||||
'is_mandatory': bool(getattr(c, 'is_mandatory', False)),
|
||||
'cefr_level': getattr(c, 'cefr_level', '') or '',
|
||||
'difficulty_level': getattr(c, 'difficulty_level', '') or '',
|
||||
'batch_id': cd.batch_id.id if cd.batch_id else None,
|
||||
'batch_name': cd.batch_id.name if cd.batch_id else '',
|
||||
'chapter_count': total,
|
||||
'completed_chapters': completed,
|
||||
'progress': int((completed / total * 100) if total else 0),
|
||||
'entity_id': c.entity_id.id if getattr(c, 'entity_id', False) else None,
|
||||
'entity_name': c.entity_id.name if getattr(c, 'entity_id', False) else '',
|
||||
})
|
||||
|
||||
mandatory = [i for i in (course_items + plan_items) if i.get('is_mandatory')]
|
||||
elective = [i for i in (course_items + plan_items) if not i.get('is_mandatory')]
|
||||
mandatory.sort(key=lambda i: i.get('name', '').lower())
|
||||
elective.sort(key=lambda i: i.get('name', '').lower())
|
||||
|
||||
return _json_response({
|
||||
'mandatory': mandatory,
|
||||
'elective': elective,
|
||||
'count_mandatory': len(mandatory),
|
||||
'count_elective': len(elective),
|
||||
'count_total': len(mandatory) + len(elective),
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.student_learning failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
@http.route('/api/student/course-plans',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -143,6 +143,42 @@ def _ocr_pdf(payload: bytes, *, max_pages: int = _OCR_MAX_PAGES) -> str:
|
||||
return '\n\n'.join(pages).strip()
|
||||
|
||||
|
||||
def _sniff_format(payload: bytes) -> str:
|
||||
"""Detect the real file format from magic bytes.
|
||||
|
||||
User-supplied `type` / `mime_type` / filename are unreliable: people
|
||||
pick `type=pdf` in the resource library and then upload a `.docx`,
|
||||
or rename a `.doc` to `.pdf`. We therefore decide by content first,
|
||||
so that even when `res.type='pdf'` is wrong we route a DOCX into
|
||||
`_extract_docx` instead of feeding it to `pypdf` which then dies
|
||||
with "EOF marker not found" — exactly the failure that surfaced in
|
||||
the UI as "indexing failed" without an actionable message.
|
||||
|
||||
Returns one of: ``pdf``, ``docx``, ``doc``, ``html``, ``text``, ``unknown``.
|
||||
"""
|
||||
if not payload or len(payload) < 4:
|
||||
return 'unknown'
|
||||
if payload[:5] == b'%PDF-':
|
||||
return 'pdf'
|
||||
if payload[:4] == b'PK\x03\x04':
|
||||
head = payload[:8192]
|
||||
if b'word/document.xml' in head or b'word/' in head:
|
||||
return 'docx'
|
||||
return 'zip'
|
||||
if payload[:8] == b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1':
|
||||
return 'doc'
|
||||
sniff = payload[:512].lstrip().lower()
|
||||
if sniff.startswith(b'<!doctype') or sniff.startswith(b'<html') or b'<html' in sniff:
|
||||
return 'html'
|
||||
try:
|
||||
sample = payload[:1024].decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
return 'unknown'
|
||||
if all((ch.isprintable() or ch in '\r\n\t') for ch in sample):
|
||||
return 'text'
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _extract_pdf(payload: bytes) -> str:
|
||||
"""Best-effort PDF text extraction with OCR fallback.
|
||||
|
||||
@@ -155,6 +191,11 @@ def _extract_pdf(payload: bytes) -> str:
|
||||
Both raise on encrypted PDFs we can't decrypt — we swallow that and
|
||||
let the caller record the error.
|
||||
"""
|
||||
if payload[:5] != b'%PDF-':
|
||||
raise RuntimeError(
|
||||
'File is not a valid PDF (missing %PDF- header). '
|
||||
f'Detected format: {_sniff_format(payload)!r}.'
|
||||
)
|
||||
try:
|
||||
from pypdf import PdfReader # type: ignore
|
||||
except ImportError:
|
||||
@@ -280,17 +321,28 @@ class SourceIndexer:
|
||||
payload = base64.b64decode(res.file)
|
||||
if not payload:
|
||||
raise ValueError('Library resource has an empty file.')
|
||||
if rtype == 'pdf' or file_name.lower().endswith('.pdf'):
|
||||
# Route by ACTUAL file content (magic bytes), not by the
|
||||
# user-set `type`. Otherwise an admin who uploaded a DOCX
|
||||
# but classified it as `type='pdf'` causes pypdf to die
|
||||
# with "EOF marker not found" — exactly the failure we
|
||||
# were debugging here.
|
||||
fmt = _sniff_format(payload)
|
||||
if fmt == 'pdf':
|
||||
return _extract_pdf(payload)
|
||||
if rtype == 'document' and file_name.lower().endswith(('.docx', '.doc')):
|
||||
if fmt == 'docx':
|
||||
return _extract_docx(payload)
|
||||
# Fall back to plain-text decoding for txt/md/csv/json.
|
||||
if file_name.lower().endswith((
|
||||
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
|
||||
'.log', '.rst',
|
||||
)):
|
||||
if fmt == 'html':
|
||||
return _extract_html(payload.decode('utf-8', errors='replace'))
|
||||
if fmt == 'text':
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
# Best-effort: try PDF first, then DOCX, then UTF-8.
|
||||
if fmt == 'doc':
|
||||
raise RuntimeError(
|
||||
'Legacy .doc (Word 97-2003) is not supported for RAG '
|
||||
'indexing. Re-save the file as .docx or PDF and '
|
||||
're-upload.'
|
||||
)
|
||||
# Last-resort: best-effort try the parsers anyway, in case
|
||||
# someone gave us a weird-but-still-text-bearing payload.
|
||||
for fn in (_extract_pdf, _extract_docx):
|
||||
try:
|
||||
text = fn(payload)
|
||||
@@ -299,11 +351,18 @@ class SourceIndexer:
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
decoded = payload.decode('utf-8', errors='replace').strip()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f'Cannot decode library resource binary: {exc}',
|
||||
) from exc
|
||||
if decoded:
|
||||
return decoded
|
||||
raise RuntimeError(
|
||||
f'Unsupported file format for RAG indexing '
|
||||
f'(detected={fmt!r}, declared type={rtype!r}). '
|
||||
'Supported: PDF, DOCX, HTML, plain text.'
|
||||
)
|
||||
|
||||
if res.url:
|
||||
_, text = _fetch_url(res.url)
|
||||
@@ -326,32 +385,40 @@ class SourceIndexer:
|
||||
payload = source.get_decoded_file()
|
||||
if not payload:
|
||||
raise ValueError('No file payload to index')
|
||||
# Detect actual content first so a mis-typed mime / extension
|
||||
# (e.g. .pdf renamed onto a .docx) still indexes correctly.
|
||||
fmt = _sniff_format(payload)
|
||||
mime = (source.mime_type or '').lower()
|
||||
name = (source.file_name or '').lower()
|
||||
if mime == 'application/pdf' or name.endswith('.pdf'):
|
||||
if fmt == 'pdf':
|
||||
return _extract_pdf(payload)
|
||||
if (mime in (
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/msword',
|
||||
) or name.endswith('.docx') or name.endswith('.doc')):
|
||||
if fmt == 'docx':
|
||||
return _extract_docx(payload)
|
||||
# Whitelist plain-text-shaped uploads explicitly. Anything else
|
||||
# (xlsx, png, mp3, zip, …) must be rejected with a clear error
|
||||
# rather than silently UTF-8-decoded into garbage that we'd
|
||||
# then "successfully" embed and surface as a usable RAG source.
|
||||
if (mime.startswith('text/')
|
||||
or mime in ('application/json', 'application/xml',
|
||||
'application/csv')
|
||||
or name.endswith(('.txt', '.md', '.markdown', '.csv',
|
||||
'.json', '.xml', '.log', '.rst'))):
|
||||
if fmt == 'html':
|
||||
return _extract_html(payload.decode('utf-8', errors='replace'))
|
||||
if fmt == 'text':
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
if fmt == 'doc':
|
||||
raise RuntimeError(
|
||||
'Legacy .doc (Word 97-2003) is not supported for RAG '
|
||||
'indexing. Re-save the file as .docx or PDF and '
|
||||
're-upload.'
|
||||
)
|
||||
# Fall back to declared mime/extension for ambiguous content.
|
||||
if mime.startswith('text/') or mime in (
|
||||
'application/json', 'application/xml', 'application/csv',
|
||||
) or name.endswith((
|
||||
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
|
||||
'.log', '.rst',
|
||||
)):
|
||||
try:
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f'Cannot decode file: {exc}') from exc
|
||||
raise ValueError(
|
||||
f'Unsupported file type for RAG indexing: '
|
||||
f'mime={mime!r} name={source.file_name!r}. '
|
||||
f'Supported: PDF, DOCX/DOC, plain text (txt/md/csv/json/xml).'
|
||||
f'detected={fmt!r} mime={mime!r} name={source.file_name!r}. '
|
||||
f'Supported: PDF, DOCX, HTML, plain text (txt/md/csv/json/xml).'
|
||||
)
|
||||
|
||||
raise ValueError(f'Unknown source kind: {source.kind!r}')
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
'encoach_ai',
|
||||
'encoach_taxonomy',
|
||||
'encoach_resources',
|
||||
'encoach_scoring',
|
||||
'encoach_exam_template',
|
||||
'encoach_ai_course',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,8 @@ def _ser_board(b):
|
||||
'batch_name': b.batch_id.name if b.batch_id else None,
|
||||
'chapter_id': b.chapter_id.id if b.chapter_id else None,
|
||||
'chapter_name': b.chapter_id.name if b.chapter_id else None,
|
||||
'plan_id': b.plan_id.id if b.plan_id else None,
|
||||
'plan_name': b.plan_id.name if b.plan_id else None,
|
||||
'is_enabled': b.is_enabled,
|
||||
'allow_student_posts': b.allow_student_posts,
|
||||
'post_count': b.post_count or 0,
|
||||
@@ -97,6 +99,8 @@ class CommunicationController(http.Controller):
|
||||
domain.append(('course_id', '=', int(kw['course_id'])))
|
||||
if kw.get('batch_id'):
|
||||
domain.append(('batch_id', '=', int(kw['batch_id'])))
|
||||
if kw.get('plan_id'):
|
||||
domain.append(('plan_id', '=', int(kw['plan_id'])))
|
||||
recs = M.search(domain, limit=200, order='id desc')
|
||||
return _json_response([_ser_board(r) for r in recs])
|
||||
except Exception as e:
|
||||
@@ -112,11 +116,55 @@ class CommunicationController(http.Controller):
|
||||
vals['course_id'] = int(body['course_id'])
|
||||
if body.get('batch_id'):
|
||||
vals['batch_id'] = int(body['batch_id'])
|
||||
if body.get('plan_id'):
|
||||
vals['plan_id'] = int(body['plan_id'])
|
||||
rec = request.env['encoach.discussion.board'].sudo().create(vals)
|
||||
return _json_response(_ser_board(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# Phase 1: course/plan-scoped board lookup-or-create.
|
||||
# Returns the board attached to a given course or plan; creates
|
||||
# one transparently the first time someone opens the discussion
|
||||
# tab inside a course detail page.
|
||||
@http.route('/api/discussion-boards/for-course/<int:course_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def board_for_course(self, course_id, **kw):
|
||||
try:
|
||||
M = request.env['encoach.discussion.board'].sudo()
|
||||
board = M.search([('course_id', '=', course_id), ('plan_id', '=', False)], limit=1)
|
||||
if not board:
|
||||
Course = request.env['op.course'].sudo().browse(course_id)
|
||||
if not Course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
board = M.create({
|
||||
'name': f'Discussion — {Course.name or "Course"}',
|
||||
'course_id': course_id,
|
||||
})
|
||||
return _json_response(_ser_board(board))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/discussion-boards/for-plan/<int:plan_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def board_for_plan(self, plan_id, **kw):
|
||||
try:
|
||||
M = request.env['encoach.discussion.board'].sudo()
|
||||
board = M.search([('plan_id', '=', plan_id)], limit=1)
|
||||
if not board:
|
||||
Plan = request.env['encoach.course.plan'].sudo().browse(plan_id)
|
||||
if not Plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
board = M.create({
|
||||
'name': f'Discussion — {Plan.name or "Course Plan"}',
|
||||
'plan_id': plan_id,
|
||||
})
|
||||
return _json_response(_ser_board(board))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/discussion-boards/<int:bid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_board(self, bid, **kw):
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Online-test SOFT security controller.
|
||||
|
||||
* `POST /api/exam/security/event` — student client posts a
|
||||
suspicious event during an attempt.
|
||||
* `POST /api/exam/single-attempt-check` — student client checks
|
||||
whether a fresh attempt is allowed for an exam.
|
||||
* `GET /api/teacher/exam/<assignment_id>/security/events` —
|
||||
teacher console reads the per-assignment log.
|
||||
* `GET /api/teacher/exam/attempt/<attempt_id>/security/events` —
|
||||
same, scoped to a specific attempt.
|
||||
* `GET /api/teacher/exam/<assignment_id>/live` — list of in-progress
|
||||
attempts + latest event per attempt.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_EVENT_TYPES = {
|
||||
'tab_blur', 'tab_focus', 'window_blur', 'window_focus',
|
||||
'paste', 'copy', 'cut', 'context_menu', 'fullscreen_exit',
|
||||
'shortcut', 'multi_attempt_block', 'other',
|
||||
}
|
||||
|
||||
|
||||
def _parse_body():
|
||||
try:
|
||||
body = request.httprequest.get_data(as_text=True)
|
||||
return json.loads(body) if body else {}
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _ser_event(ev):
|
||||
payload = None
|
||||
if ev.payload:
|
||||
try:
|
||||
payload = json.loads(ev.payload)
|
||||
except (TypeError, ValueError):
|
||||
payload = ev.payload
|
||||
return {
|
||||
'id': ev.id,
|
||||
'attempt_id': ev.attempt_id.id if ev.attempt_id else None,
|
||||
'student_id': ev.student_id.id,
|
||||
'student_name': ev.student_id.name or ev.student_id.login or '',
|
||||
'exam_id': ev.exam_id.id if ev.exam_id else None,
|
||||
'event_type': ev.event_type,
|
||||
'severity': ev.severity,
|
||||
'occurred_at': ev.occurred_at.isoformat() if ev.occurred_at else None,
|
||||
'payload': payload,
|
||||
}
|
||||
|
||||
|
||||
class ExamSecurityController(http.Controller):
|
||||
|
||||
@http.route('/api/exam/security/event',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def post_event(self, **_kw):
|
||||
try:
|
||||
body = _parse_body()
|
||||
event_type = (body.get('event_type') or '').strip().lower()
|
||||
if event_type not in VALID_EVENT_TYPES:
|
||||
return _error_response('Unknown event_type', 400)
|
||||
|
||||
attempt_id = body.get('attempt_id')
|
||||
attempt = None
|
||||
if attempt_id:
|
||||
attempt = request.env['encoach.student.attempt'].sudo() \
|
||||
.browse(int(attempt_id))
|
||||
if not attempt.exists():
|
||||
attempt = None
|
||||
|
||||
user = request.env.user
|
||||
if attempt and attempt.student_id and attempt.student_id.id != user.id:
|
||||
return _error_response(
|
||||
'Cannot log events for another user\'s attempt', 403,
|
||||
)
|
||||
|
||||
severity = body.get('severity') or 'warning'
|
||||
if severity not in ('info', 'warning', 'alert'):
|
||||
severity = 'warning'
|
||||
|
||||
payload_obj = body.get('payload')
|
||||
payload_str = None
|
||||
if payload_obj is not None:
|
||||
try:
|
||||
payload_str = json.dumps(payload_obj)[:4000]
|
||||
except (TypeError, ValueError):
|
||||
payload_str = None
|
||||
|
||||
Ev = request.env['encoach.exam.security.event'].sudo()
|
||||
ev = Ev.create({
|
||||
'attempt_id': attempt.id if attempt else False,
|
||||
'student_id': user.id,
|
||||
'exam_id': (attempt.exam_id.id if attempt and attempt.exam_id
|
||||
else (int(body['exam_id']) if body.get('exam_id') else False)),
|
||||
'event_type': event_type,
|
||||
'severity': severity,
|
||||
'payload': payload_str,
|
||||
})
|
||||
|
||||
auto_locked = False
|
||||
if attempt and attempt.exam_id:
|
||||
exam = attempt.exam_id
|
||||
level = getattr(exam, 'security_level', 'soft') or 'soft'
|
||||
threshold = getattr(exam, 'suspicious_event_threshold', 5) or 5
|
||||
if level == 'strict' and severity == 'alert':
|
||||
alerts = Ev.search_count([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('severity', '=', 'alert'),
|
||||
])
|
||||
if alerts >= threshold and attempt.status == 'in_progress':
|
||||
attempt.write({
|
||||
'status': 'completed',
|
||||
'completed_at': ev.occurred_at,
|
||||
})
|
||||
auto_locked = True
|
||||
|
||||
return _json_response({
|
||||
'event_id': ev.id,
|
||||
'auto_locked': auto_locked,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('exam.security.event failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/exam/single-attempt-check',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def single_attempt_check(self, **kw):
|
||||
try:
|
||||
exam_id_raw = kw.get('exam_id')
|
||||
if not exam_id_raw:
|
||||
return _error_response('exam_id required', 400)
|
||||
exam = request.env['encoach.exam.custom'].sudo() \
|
||||
.browse(int(exam_id_raw))
|
||||
if not exam.exists():
|
||||
return _error_response('Exam not found', 404)
|
||||
|
||||
user = request.env.user
|
||||
single = bool(getattr(exam, 'single_attempt', True))
|
||||
existing = request.env['encoach.student.attempt'].sudo().search([
|
||||
('exam_id', '=', exam.id),
|
||||
('student_id', '=', user.id),
|
||||
('status', 'in', (
|
||||
'completed', 'scoring', 'scored', 'released',
|
||||
'pending_approval',
|
||||
)),
|
||||
], limit=1)
|
||||
|
||||
allowed = not (single and existing)
|
||||
if not allowed:
|
||||
request.env['encoach.exam.security.event'].sudo().create({
|
||||
'student_id': user.id,
|
||||
'exam_id': exam.id,
|
||||
'event_type': 'multi_attempt_block',
|
||||
'severity': 'alert',
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'allowed': allowed,
|
||||
'security_level': getattr(exam, 'security_level', 'soft'),
|
||||
'single_attempt': single,
|
||||
'existing_attempt_id': existing.id if existing else None,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('single_attempt_check failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teacher/exam/<int:assignment_id>/security/events',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def assignment_events(self, assignment_id, **kw):
|
||||
try:
|
||||
Assign = request.env['encoach.exam.assignment'].sudo()
|
||||
assign = Assign.browse(assignment_id)
|
||||
if not assign.exists():
|
||||
return _error_response('Assignment not found', 404)
|
||||
|
||||
domain = [('exam_id', '=', assign.exam_id.id)] if assign.exam_id else []
|
||||
if assign.student_id:
|
||||
domain.append(('student_id', '=', assign.student_id.id))
|
||||
elif assign.batch_id:
|
||||
Batch = request.env['op.batch'].sudo().browse(assign.batch_id.id)
|
||||
course_regs = request.env['op.student.course'].sudo().search([
|
||||
('batch_id', '=', Batch.id),
|
||||
])
|
||||
user_ids = [r.student_id.user_id.id for r in course_regs
|
||||
if r.student_id and r.student_id.user_id]
|
||||
if user_ids:
|
||||
domain.append(('student_id', 'in', user_ids))
|
||||
|
||||
Ev = request.env['encoach.exam.security.event'].sudo()
|
||||
events = Ev.search(domain, limit=500)
|
||||
return _json_response({
|
||||
'items': [_ser_event(e) for e in events],
|
||||
'total': len(events),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('assignment_events failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teacher/exam/attempt/<int:attempt_id>/security/events',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def attempt_events(self, attempt_id, **kw):
|
||||
try:
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Att.browse(attempt_id)
|
||||
if not attempt.exists():
|
||||
return _error_response('Attempt not found', 404)
|
||||
Ev = request.env['encoach.exam.security.event'].sudo()
|
||||
events = Ev.search([('attempt_id', '=', attempt.id)], limit=500)
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'student_id': attempt.student_id.id if attempt.student_id else None,
|
||||
'student_name': (attempt.student_id.name
|
||||
or attempt.student_id.login or ''),
|
||||
'exam_id': attempt.exam_id.id if attempt.exam_id else None,
|
||||
'status': attempt.status,
|
||||
'items': [_ser_event(e) for e in events],
|
||||
'total': len(events),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('attempt_events failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teacher/exam/<int:assignment_id>/live',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def live_test_console(self, assignment_id, **kw):
|
||||
try:
|
||||
Assign = request.env['encoach.exam.assignment'].sudo()
|
||||
assign = Assign.browse(assignment_id)
|
||||
if not assign.exists() or not assign.exam_id:
|
||||
return _error_response('Assignment not found', 404)
|
||||
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
attempts = Att.search([
|
||||
('exam_id', '=', assign.exam_id.id),
|
||||
('status', '=', 'in_progress'),
|
||||
])
|
||||
|
||||
Ev = request.env['encoach.exam.security.event'].sudo()
|
||||
items = []
|
||||
for att in attempts:
|
||||
last = Ev.search([
|
||||
('attempt_id', '=', att.id),
|
||||
], limit=1, order='occurred_at desc')
|
||||
alerts = Ev.search_count([
|
||||
('attempt_id', '=', att.id),
|
||||
('severity', '=', 'alert'),
|
||||
])
|
||||
items.append({
|
||||
'attempt_id': att.id,
|
||||
'student_id': att.student_id.id if att.student_id else None,
|
||||
'student_name': (att.student_id.name
|
||||
or att.student_id.login or ''),
|
||||
'started_at': (att.started_at.isoformat()
|
||||
if att.started_at else None),
|
||||
'alert_count': alerts,
|
||||
'last_event': _ser_event(last) if last else None,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'assignment_id': assign.id,
|
||||
'exam_id': assign.exam_id.id,
|
||||
'in_progress': items,
|
||||
'total': len(items),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('live_test_console failed')
|
||||
return _error_response(str(e), 500)
|
||||
246
backend/custom_addons/encoach_lms_api/controllers/handwritten.py
Normal file
246
backend/custom_addons/encoach_lms_api/controllers/handwritten.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Handwritten solution upload + AI grading controller.
|
||||
|
||||
* `POST /api/student/handwritten/<material_id>` — upload pages.
|
||||
* `GET /api/student/handwritten/<material_id>` — fetch my latest.
|
||||
* `GET /api/teacher/handwritten/<material_id>` — list submissions.
|
||||
* `POST /api/teacher/handwritten/<submission_id>/review` — teacher overrides AI.
|
||||
|
||||
Image uploads use multipart/form-data so we can stream the raw
|
||||
JPEG/PNG bytes straight into ir.attachment without round-tripping
|
||||
through base64.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http, fields as oo_fields
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ser_submission(s):
|
||||
return {
|
||||
'id': s.id,
|
||||
'student_id': s.student_id.id if s.student_id else None,
|
||||
'student_name': s.student_id.name or s.student_id.login or '',
|
||||
'material_id': s.material_id.id if s.material_id else None,
|
||||
'plan_id': s.plan_id.id if s.plan_id else None,
|
||||
'submitted_at': s.submitted_at.isoformat() if s.submitted_at else None,
|
||||
'status': s.status,
|
||||
'student_note': s.student_note or '',
|
||||
'image_count': len(s.image_attachment_ids),
|
||||
'image_urls': [
|
||||
f'/web/content/{a.id}?download=true'
|
||||
for a in s.image_attachment_ids
|
||||
],
|
||||
'ai_score': s.ai_score,
|
||||
'ai_feedback': s.ai_feedback or '',
|
||||
'ai_extracted_text': s.ai_extracted_text or '',
|
||||
'teacher_score': s.teacher_score,
|
||||
'teacher_feedback': s.teacher_feedback or '',
|
||||
'reviewed_by': s.reviewed_by.name if s.reviewed_by else '',
|
||||
'reviewed_at': s.reviewed_at.isoformat() if s.reviewed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _grade_with_ai(submission):
|
||||
"""Call OCR + LLM to grade the handwritten pages.
|
||||
|
||||
Falls back gracefully when the AI service is unavailable so the
|
||||
submission still records (status='failed') instead of 500-ing.
|
||||
"""
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
except ImportError:
|
||||
submission.write({
|
||||
'status': 'failed',
|
||||
'ai_feedback': 'AI grading service not installed.',
|
||||
})
|
||||
return
|
||||
|
||||
extracted_chunks = []
|
||||
try:
|
||||
try:
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
import io
|
||||
for att in submission.image_attachment_ids:
|
||||
if not att.datas:
|
||||
continue
|
||||
try:
|
||||
raw = base64.b64decode(att.datas)
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
text = pytesseract.image_to_string(img) or ''
|
||||
if text.strip():
|
||||
extracted_chunks.append(text.strip())
|
||||
except Exception:
|
||||
_logger.warning('OCR failed for attachment %s', att.id,
|
||||
exc_info=True)
|
||||
except ImportError:
|
||||
extracted_chunks.append(
|
||||
'[OCR not installed — relying on student note only.]'
|
||||
)
|
||||
extracted = '\n\n'.join(extracted_chunks).strip() \
|
||||
or (submission.student_note or '')
|
||||
|
||||
ai = OpenAIService(request.env, language='en')
|
||||
prompt_user = (
|
||||
"You are a math/science assignment grader. Grade the "
|
||||
"following handwritten solution from 0 to 100 and return "
|
||||
"valid JSON: {\"score\": float, \"feedback\": str}.\n\n"
|
||||
f"Student note:\n{submission.student_note or '(none)'}\n\n"
|
||||
f"OCR-extracted handwriting:\n{extracted or '(empty)'}\n"
|
||||
)
|
||||
try:
|
||||
data = ai.chat_json(
|
||||
system="You are a strict but fair math/science grader.",
|
||||
user=prompt_user,
|
||||
action='handwritten.grade',
|
||||
)
|
||||
except Exception as e:
|
||||
submission.write({
|
||||
'status': 'failed',
|
||||
'ai_extracted_text': extracted,
|
||||
'ai_feedback': f'AI grading error: {e}',
|
||||
})
|
||||
return
|
||||
|
||||
score = float(data.get('score') or 0)
|
||||
feedback = data.get('feedback') or ''
|
||||
submission.write({
|
||||
'status': 'graded',
|
||||
'ai_score': score,
|
||||
'ai_feedback': feedback,
|
||||
'ai_extracted_text': extracted,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('handwritten grading failed')
|
||||
submission.write({
|
||||
'status': 'failed',
|
||||
'ai_feedback': f'Grading pipeline crashed: {e}',
|
||||
})
|
||||
|
||||
|
||||
class HandwrittenController(http.Controller):
|
||||
|
||||
@http.route('/api/student/handwritten/<int:material_id>',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def upload(self, material_id, **kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
Material = request.env['encoach.course.plan.material'].sudo()
|
||||
material = Material.browse(material_id)
|
||||
if not material.exists():
|
||||
return _error_response('Material not found', 404)
|
||||
|
||||
files = request.httprequest.files.getlist('pages') \
|
||||
or request.httprequest.files.getlist('file') \
|
||||
or []
|
||||
if not files:
|
||||
return _error_response('At least one image required', 400)
|
||||
|
||||
student_note = (request.params.get('note') or '').strip()
|
||||
|
||||
Att = request.env['ir.attachment'].sudo()
|
||||
att_ids = []
|
||||
for f in files[:10]:
|
||||
raw = f.read()
|
||||
if not raw:
|
||||
continue
|
||||
rec = Att.create({
|
||||
'name': f.filename or 'handwritten.jpg',
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(raw),
|
||||
'mimetype': f.mimetype or 'image/jpeg',
|
||||
'res_model': 'encoach.handwritten.submission',
|
||||
'res_id': 0,
|
||||
})
|
||||
att_ids.append(rec.id)
|
||||
|
||||
Sub = request.env['encoach.handwritten.submission'].sudo()
|
||||
sub = Sub.create({
|
||||
'student_id': user.id,
|
||||
'material_id': material.id,
|
||||
'student_note': student_note,
|
||||
'image_attachment_ids': [(6, 0, att_ids)],
|
||||
'status': 'submitted',
|
||||
})
|
||||
for aid in att_ids:
|
||||
request.env['ir.attachment'].sudo().browse(aid).res_id = sub.id
|
||||
|
||||
sub.status = 'grading'
|
||||
try:
|
||||
_grade_with_ai(sub)
|
||||
except Exception:
|
||||
_logger.exception('async grading inline failed')
|
||||
return _json_response(_ser_submission(sub), 201)
|
||||
except Exception as e:
|
||||
_logger.exception('handwritten upload failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/student/handwritten/<int:material_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def my_submission(self, material_id, **kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
Sub = request.env['encoach.handwritten.submission'].sudo()
|
||||
sub = Sub.search([
|
||||
('material_id', '=', material_id),
|
||||
('student_id', '=', user.id),
|
||||
], limit=1, order='submitted_at desc')
|
||||
return _json_response({
|
||||
'submission': _ser_submission(sub) if sub else None,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('my_handwritten_submission failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teacher/handwritten/<int:material_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_submissions(self, material_id, **kw):
|
||||
try:
|
||||
Sub = request.env['encoach.handwritten.submission'].sudo()
|
||||
subs = Sub.search([('material_id', '=', material_id)])
|
||||
return _json_response({
|
||||
'items': [_ser_submission(s) for s in subs],
|
||||
'total': len(subs),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_handwritten failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teacher/handwritten/<int:submission_id>/review',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def review_submission(self, submission_id, **_kw):
|
||||
try:
|
||||
Sub = request.env['encoach.handwritten.submission'].sudo()
|
||||
sub = Sub.browse(submission_id)
|
||||
if not sub.exists():
|
||||
return _error_response('Submission not found', 404)
|
||||
try:
|
||||
raw = request.httprequest.get_data(as_text=True)
|
||||
body = json.loads(raw) if raw else {}
|
||||
except (TypeError, ValueError):
|
||||
body = {}
|
||||
score = body.get('score')
|
||||
feedback = body.get('feedback') or ''
|
||||
sub.write({
|
||||
'teacher_score': float(score) if score is not None else False,
|
||||
'teacher_feedback': feedback,
|
||||
'reviewed_by': request.env.user.id,
|
||||
'reviewed_at': oo_fields.Datetime.now(),
|
||||
'status': 'reviewed',
|
||||
})
|
||||
return _json_response(_ser_submission(sub))
|
||||
except Exception as e:
|
||||
_logger.exception('review_handwritten failed')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -0,0 +1,705 @@
|
||||
"""Live (Jitsi-backed) classroom sessions controller.
|
||||
|
||||
Endpoints (Phase 3 — 2026-04-30):
|
||||
* `GET /api/live-sessions` — list (filters)
|
||||
* `POST /api/live-sessions` — create
|
||||
* `GET /api/live-sessions/<id>` — read
|
||||
* `PATCH /api/live-sessions/<id>` — update
|
||||
* `DELETE /api/live-sessions/<id>` — cancel
|
||||
* `POST /api/live-sessions/<id>/notify` — email enrolled students
|
||||
* `POST /api/live-sessions/<id>/join` — student/teacher posts on entering Jitsi
|
||||
* `POST /api/live-sessions/<id>/leave` — student/teacher posts on leaving Jitsi
|
||||
* `POST /api/live-sessions/<id>/end` — host ends session
|
||||
* `POST /api/live-sessions/<id>/recording` — host stores recording URL
|
||||
* `POST /api/live-sessions/<id>/remove-participant` — host kicks user (with reason)
|
||||
* `GET /api/live-sessions/<id>/attendance` — host attendance roll
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo import http, fields as oo_fields
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_body():
|
||||
try:
|
||||
body = request.httprequest.get_data(as_text=True)
|
||||
return json.loads(body) if body else {}
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_dt(raw):
|
||||
"""Coerce an ISO-8601 / Odoo / 'YYYY-MM-DDTHH:MM' datetime string
|
||||
into the format Odoo persists ('%Y-%m-%d %H:%M:%S')."""
|
||||
if not raw:
|
||||
return raw
|
||||
if isinstance(raw, datetime):
|
||||
return raw.strftime('%Y-%m-%d %H:%M:%S')
|
||||
s = str(raw).strip()
|
||||
# Strip a trailing Z and turn 'T' into ' '.
|
||||
if s.endswith('Z'):
|
||||
s = s[:-1]
|
||||
s = s.replace('T', ' ')
|
||||
if '.' in s:
|
||||
s = s.split('.', 1)[0]
|
||||
if '+' in s[10:]:
|
||||
s = s.rsplit('+', 1)[0]
|
||||
if len(s) == 16:
|
||||
s = s + ':00'
|
||||
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M'):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
continue
|
||||
return s
|
||||
|
||||
|
||||
def _ser_session(s, env=None):
|
||||
return {
|
||||
'id': s.id,
|
||||
'title': s.title or '',
|
||||
'description': s.description or '',
|
||||
'course_id': s.course_id.id if s.course_id else None,
|
||||
'course_name': s.course_id.name if s.course_id else '',
|
||||
'batch_id': s.batch_id.id if s.batch_id else None,
|
||||
'batch_name': s.batch_id.name if s.batch_id else '',
|
||||
'plan_id': s.plan_id.id if s.plan_id else None,
|
||||
'plan_name': s.plan_id.name if s.plan_id else '',
|
||||
'host_id': s.host_id.id if s.host_id else None,
|
||||
'host_name': s.host_id.name if s.host_id else '',
|
||||
'scheduled_at': s.scheduled_at.isoformat() if s.scheduled_at else None,
|
||||
'duration_min': s.duration_min,
|
||||
'actual_started_at': (s.actual_started_at.isoformat()
|
||||
if s.actual_started_at else None),
|
||||
'actual_ended_at': (s.actual_ended_at.isoformat()
|
||||
if s.actual_ended_at else None),
|
||||
'status': s.status,
|
||||
'room_name': s.room_name,
|
||||
'waiting_room': s.waiting_room,
|
||||
'recording_enabled': s.recording_enabled,
|
||||
'recording_url': s.recording_url or '',
|
||||
'auto_attendance_minutes': s.auto_attendance_minutes,
|
||||
'late_entry_minutes': s.late_entry_minutes,
|
||||
'notification_sent': s.notification_sent,
|
||||
'participant_count': len(s.participant_ids),
|
||||
}
|
||||
|
||||
|
||||
def _ser_participant(p):
|
||||
return {
|
||||
'id': p.id,
|
||||
'session_id': p.session_id.id,
|
||||
'user_id': p.user_id.id,
|
||||
'user_name': p.user_id.name or p.user_id.login or '',
|
||||
'joined_at': p.joined_at.isoformat() if p.joined_at else None,
|
||||
'left_at': p.left_at.isoformat() if p.left_at else None,
|
||||
'duration_seconds': p.duration_seconds,
|
||||
'attendance_status': p.attendance_status,
|
||||
'removed_reason': p.removed_reason or '',
|
||||
'is_host': p.is_host,
|
||||
}
|
||||
|
||||
|
||||
def _enrolled_user_ids(session):
|
||||
"""Return res.users IDs that should be notified / can join."""
|
||||
user_ids = set()
|
||||
if session.batch_id:
|
||||
regs = request.env['op.student.course'].sudo().search([
|
||||
('batch_id', '=', session.batch_id.id),
|
||||
])
|
||||
for r in regs:
|
||||
if r.student_id and r.student_id.user_id:
|
||||
user_ids.add(r.student_id.user_id.id)
|
||||
elif session.course_id:
|
||||
regs = request.env['op.student.course'].sudo().search([
|
||||
('course_id', '=', session.course_id.id),
|
||||
])
|
||||
for r in regs:
|
||||
if r.student_id and r.student_id.user_id:
|
||||
user_ids.add(r.student_id.user_id.id)
|
||||
if session.plan_id:
|
||||
Assign = request.env['encoach.course.plan.assignment'].sudo() \
|
||||
if 'encoach.course.plan.assignment' in request.env else None
|
||||
if Assign is not None:
|
||||
try:
|
||||
ass = Assign.search([('plan_id', '=', session.plan_id.id)])
|
||||
for a in ass:
|
||||
if a.user_id:
|
||||
user_ids.add(a.user_id.id)
|
||||
except Exception:
|
||||
pass
|
||||
if session.host_id:
|
||||
user_ids.discard(session.host_id.id)
|
||||
return list(user_ids)
|
||||
|
||||
|
||||
def _build_ics(session, recipient_email=''):
|
||||
"""Return raw iCalendar bytes for the session.
|
||||
|
||||
Adding an .ics attachment lets every modern mail client (Gmail,
|
||||
Outlook, Apple Mail, mobile clients) drop the session straight
|
||||
into the recipient's calendar with one click — addressing the
|
||||
"Advance scheduling + calendar sync" requirement without us
|
||||
having to OAuth into anyone's Google/Apple calendar.
|
||||
"""
|
||||
def _fmt(dt):
|
||||
if not dt:
|
||||
return ''
|
||||
if isinstance(dt, str):
|
||||
try:
|
||||
dt = datetime.strptime(dt, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt)
|
||||
except ValueError:
|
||||
return ''
|
||||
return dt.strftime('%Y%m%dT%H%M%SZ')
|
||||
|
||||
def _esc(s):
|
||||
return (s or '').replace('\\', '\\\\').replace(',', '\\,') \
|
||||
.replace(';', '\\;').replace('\n', '\\n')
|
||||
|
||||
start = session.scheduled_at
|
||||
if isinstance(start, str):
|
||||
try:
|
||||
start = datetime.strptime(start, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
start = datetime.utcnow()
|
||||
end = start + timedelta(minutes=session.duration_min or 60)
|
||||
uid = f'live-{session.id}-{secrets.token_hex(4)}@encoach'
|
||||
|
||||
description = (
|
||||
f"Live session hosted by {session.host_id.name}.\\n"
|
||||
f"Course: {session.course_id.name or session.plan_id.name or ''}\\n"
|
||||
f"Open from your dashboard when it starts."
|
||||
)
|
||||
|
||||
base_url = (request.env['ir.config_parameter'].sudo()
|
||||
.get_param('web.base.url') or '').rstrip('/')
|
||||
location = f'{base_url}/live/{session.id}' if base_url else f'/live/{session.id}'
|
||||
|
||||
lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//EnCoach//Live Sessions//EN',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:REQUEST',
|
||||
'BEGIN:VEVENT',
|
||||
f'UID:{uid}',
|
||||
f'DTSTAMP:{_fmt(datetime.utcnow())}',
|
||||
f'DTSTART:{_fmt(start)}',
|
||||
f'DTEND:{_fmt(end)}',
|
||||
f'SUMMARY:{_esc(session.title)}',
|
||||
f'DESCRIPTION:{description}',
|
||||
f'LOCATION:{_esc(location)}',
|
||||
f'ORGANIZER;CN={_esc(session.host_id.name)}:mailto:{session.host_id.email or "noreply@encoach.test"}',
|
||||
]
|
||||
if recipient_email:
|
||||
lines.append(
|
||||
f'ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:'
|
||||
f'mailto:{recipient_email}'
|
||||
)
|
||||
lines.extend([
|
||||
'STATUS:CONFIRMED',
|
||||
'BEGIN:VALARM',
|
||||
'TRIGGER:-PT15M',
|
||||
'ACTION:DISPLAY',
|
||||
f'DESCRIPTION:Reminder: {_esc(session.title)}',
|
||||
'END:VALARM',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
])
|
||||
return '\r\n'.join(lines).encode('utf-8')
|
||||
|
||||
|
||||
def _send_session_email(session):
|
||||
"""Send an HTML invite + .ics attachment to every enrolled student."""
|
||||
user_ids = _enrolled_user_ids(session)
|
||||
if not user_ids:
|
||||
return 0
|
||||
Mail = request.env['mail.mail'].sudo()
|
||||
Users = request.env['res.users'].sudo()
|
||||
Att = request.env['ir.attachment'].sudo()
|
||||
sent = 0
|
||||
base_url = (request.env['ir.config_parameter'].sudo()
|
||||
.get_param('web.base.url') or '').rstrip('/')
|
||||
join_url = f'{base_url}/live/{session.id}' if base_url else f'/live/{session.id}'
|
||||
for user in Users.browse(user_ids):
|
||||
if not user.email:
|
||||
continue
|
||||
body = (
|
||||
f"<p>Hi {user.name},</p>"
|
||||
f"<p>You have a new live session: <b>{session.title}</b>.</p>"
|
||||
f"<p>When: {session.scheduled_at} ({session.duration_min} min)<br/>"
|
||||
f"Course: {session.course_id.name or session.plan_id.name or ''}<br/>"
|
||||
f"Host: {session.host_id.name}</p>"
|
||||
f'<p><a href="{join_url}" '
|
||||
f'style="display:inline-block;background:#1f2937;color:#fff;'
|
||||
f'padding:10px 16px;border-radius:8px;text-decoration:none">'
|
||||
f'Join from dashboard</a></p>'
|
||||
f"<p style=\"color:#666;font-size:12px\">"
|
||||
f"The attached .ics will add this session to your calendar.</p>"
|
||||
)
|
||||
try:
|
||||
ics_bytes = _build_ics(session, recipient_email=user.email)
|
||||
att = Att.create({
|
||||
'name': f'session-{session.id}.ics',
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(ics_bytes),
|
||||
'mimetype': 'text/calendar; method=REQUEST; charset=utf-8',
|
||||
'res_model': 'encoach.live.session',
|
||||
'res_id': session.id,
|
||||
})
|
||||
mail = Mail.create({
|
||||
'subject': f'[EnCoach] {session.title}',
|
||||
'body_html': body,
|
||||
'email_to': user.email,
|
||||
'email_from': (request.env['ir.config_parameter'].sudo()
|
||||
.get_param('mail.from') or 'noreply@encoach.test'),
|
||||
'attachment_ids': [(4, att.id)],
|
||||
})
|
||||
mail.send()
|
||||
sent += 1
|
||||
except Exception:
|
||||
_logger.warning('failed to send session invite to %s',
|
||||
user.email, exc_info=True)
|
||||
return sent
|
||||
|
||||
|
||||
def _user_can_host(user, session):
|
||||
if not user or not user.id:
|
||||
return False
|
||||
if user.id == session.host_id.id:
|
||||
return True
|
||||
if user.has_group('base.group_system'):
|
||||
return True
|
||||
user_type = getattr(user, 'user_type', '') or ''
|
||||
return user_type in ('admin', 'corporate', 'mastercorporate', 'developer')
|
||||
|
||||
|
||||
class LiveSessionController(http.Controller):
|
||||
|
||||
# ── List + create ──────────────────────────────────────────────
|
||||
|
||||
@http.route('/api/live-sessions',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_sessions(self, **kw):
|
||||
try:
|
||||
Sess = request.env['encoach.live.session'].sudo()
|
||||
domain = []
|
||||
for f in ('course_id', 'batch_id', 'plan_id', 'host_id'):
|
||||
v = kw.get(f)
|
||||
if v:
|
||||
try:
|
||||
domain.append((f, '=', int(v)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
status = kw.get('status')
|
||||
if status:
|
||||
domain.append(('status', '=', status))
|
||||
mine = (kw.get('mine') or '').lower() in ('1', 'true', 'yes')
|
||||
if mine:
|
||||
user = request.env.user
|
||||
regs = request.env['op.student.course'].sudo().search([
|
||||
('student_id.user_id', '=', user.id),
|
||||
])
|
||||
course_ids = list({r.course_id.id for r in regs if r.course_id})
|
||||
batch_ids = list({r.batch_id.id for r in regs if r.batch_id})
|
||||
or_clauses = []
|
||||
if course_ids:
|
||||
or_clauses.append(('course_id', 'in', course_ids))
|
||||
if batch_ids:
|
||||
or_clauses.append(('batch_id', 'in', batch_ids))
|
||||
or_clauses.append(('host_id', '=', user.id))
|
||||
if len(or_clauses) > 1:
|
||||
domain.extend(['|'] * (len(or_clauses) - 1))
|
||||
domain.extend(or_clauses)
|
||||
sessions = Sess.search(domain, order='scheduled_at desc', limit=200)
|
||||
return _json_response({
|
||||
'items': [_ser_session(s) for s in sessions],
|
||||
'total': len(sessions),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_sessions failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_session(self, **_kw):
|
||||
try:
|
||||
body = _parse_body()
|
||||
vals = {
|
||||
'title': body.get('title') or 'Live session',
|
||||
'description': body.get('description') or '',
|
||||
'scheduled_at': _parse_dt(body.get('scheduled_at')),
|
||||
'duration_min': int(body.get('duration_min') or 60),
|
||||
'host_id': request.env.user.id,
|
||||
'waiting_room': bool(body.get('waiting_room', True)),
|
||||
'recording_enabled': bool(body.get('recording_enabled', False)),
|
||||
'auto_attendance_minutes': int(body.get('auto_attendance_minutes') or 10),
|
||||
'late_entry_minutes': int(body.get('late_entry_minutes') or 15),
|
||||
}
|
||||
for f in ('course_id', 'batch_id', 'plan_id', 'entity_id'):
|
||||
if body.get(f):
|
||||
try:
|
||||
vals[f] = int(body[f])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not vals.get('scheduled_at'):
|
||||
return _error_response('scheduled_at required', 400)
|
||||
|
||||
sess = request.env['encoach.live.session'].sudo().create(vals)
|
||||
|
||||
if body.get('notify', True):
|
||||
try:
|
||||
sent = _send_session_email(sess)
|
||||
sess.notification_sent = bool(sent)
|
||||
except Exception:
|
||||
_logger.warning('email batch failed', exc_info=True)
|
||||
|
||||
return _json_response(_ser_session(sess), 201)
|
||||
except Exception as e:
|
||||
_logger.exception('create_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def read_session(self, session_id, **kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
payload = _ser_session(sess)
|
||||
payload['participants'] = [_ser_participant(p)
|
||||
for p in sess.participant_ids]
|
||||
payload['enrolled_user_ids'] = _enrolled_user_ids(sess)
|
||||
return _json_response(payload)
|
||||
except Exception as e:
|
||||
_logger.exception('read_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>',
|
||||
type='http', auth='public', methods=['PATCH'], csrf=False)
|
||||
@jwt_required
|
||||
def patch_session(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _parse_body()
|
||||
vals = {}
|
||||
for f in ('title', 'description', 'duration_min',
|
||||
'waiting_room', 'recording_enabled',
|
||||
'auto_attendance_minutes', 'late_entry_minutes',
|
||||
'status', 'recording_url'):
|
||||
if f in body:
|
||||
vals[f] = body[f]
|
||||
if 'scheduled_at' in body:
|
||||
vals['scheduled_at'] = _parse_dt(body['scheduled_at'])
|
||||
sess.write(vals)
|
||||
return _json_response(_ser_session(sess))
|
||||
except Exception as e:
|
||||
_logger.exception('patch_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>',
|
||||
type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def cancel_session(self, session_id, **kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
sess.status = 'cancelled'
|
||||
return _json_response({'ok': True})
|
||||
except Exception as e:
|
||||
_logger.exception('cancel_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/ics',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def session_ics(self, session_id, **kw):
|
||||
"""Download a single .ics file the user can drop into their calendar."""
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
email = request.env.user.email or ''
|
||||
ics = _build_ics(sess, recipient_email=email)
|
||||
return request.make_response(ics, headers=[
|
||||
('Content-Type', 'text/calendar; method=REQUEST; charset=utf-8'),
|
||||
('Content-Disposition',
|
||||
f'attachment; filename="session-{sess.id}.ics"'),
|
||||
])
|
||||
except Exception as e:
|
||||
_logger.exception('session_ics failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/notify',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def notify_session(self, session_id, **kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
sent = _send_session_email(sess)
|
||||
sess.notification_sent = True
|
||||
return _json_response({'sent': sent})
|
||||
except Exception as e:
|
||||
_logger.exception('notify_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── Lifecycle: join / leave / end ──────────────────────────────
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/join',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def join_session(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if sess.status == 'cancelled':
|
||||
return _error_response('Session was cancelled', 400)
|
||||
user = request.env.user
|
||||
now = oo_fields.Datetime.now()
|
||||
|
||||
is_host = user.id == sess.host_id.id
|
||||
|
||||
if not is_host and sess.late_entry_minutes and sess.actual_started_at:
|
||||
cutoff = sess.actual_started_at + timedelta(
|
||||
minutes=sess.late_entry_minutes)
|
||||
if now > cutoff:
|
||||
return _error_response(
|
||||
'Late entry refused — the session has been '
|
||||
'live for too long.', 403,
|
||||
)
|
||||
|
||||
if not sess.actual_started_at:
|
||||
sess.write({
|
||||
'actual_started_at': now,
|
||||
'status': 'live',
|
||||
})
|
||||
|
||||
Part = request.env['encoach.live.session.participant'].sudo()
|
||||
part = Part.search([
|
||||
('session_id', '=', sess.id),
|
||||
('user_id', '=', user.id),
|
||||
], limit=1)
|
||||
if part:
|
||||
part.write({
|
||||
'joined_at': now,
|
||||
'attendance_status': 'attending',
|
||||
})
|
||||
else:
|
||||
part = Part.create({
|
||||
'session_id': sess.id,
|
||||
'user_id': user.id,
|
||||
'joined_at': now,
|
||||
'attendance_status': 'attending',
|
||||
'is_host': is_host,
|
||||
})
|
||||
return _json_response({
|
||||
'ok': True,
|
||||
'participant': _ser_participant(part),
|
||||
'room_name': sess.room_name,
|
||||
'is_host': is_host,
|
||||
'jitsi_jwt': '',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('join_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/leave',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def leave_session(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
user = request.env.user
|
||||
now = oo_fields.Datetime.now()
|
||||
Part = request.env['encoach.live.session.participant'].sudo()
|
||||
part = Part.search([
|
||||
('session_id', '=', sess.id),
|
||||
('user_id', '=', user.id),
|
||||
], limit=1)
|
||||
if not part:
|
||||
return _json_response({'ok': True, 'no_participant': True})
|
||||
extra = 0
|
||||
if part.joined_at:
|
||||
extra = int((now - part.joined_at).total_seconds())
|
||||
new_total = (part.duration_seconds or 0) + max(0, extra)
|
||||
new_status = part.attendance_status
|
||||
if (sess.auto_attendance_minutes
|
||||
and new_total >= sess.auto_attendance_minutes * 60
|
||||
and new_status not in ('removed',)):
|
||||
new_status = 'present'
|
||||
elif new_status == 'attending':
|
||||
new_status = 'left_early'
|
||||
part.write({
|
||||
'left_at': now,
|
||||
'duration_seconds': new_total,
|
||||
'attendance_status': new_status,
|
||||
})
|
||||
return _json_response({
|
||||
'ok': True,
|
||||
'participant': _ser_participant(part),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('leave_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/end',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def end_session(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
now = oo_fields.Datetime.now()
|
||||
sess.write({
|
||||
'status': 'ended',
|
||||
'actual_ended_at': now,
|
||||
})
|
||||
for p in sess.participant_ids:
|
||||
if p.attendance_status == 'attending':
|
||||
extra = 0
|
||||
if p.joined_at:
|
||||
extra = int((now - p.joined_at).total_seconds())
|
||||
total = (p.duration_seconds or 0) + max(0, extra)
|
||||
new_status = 'left_early'
|
||||
if (sess.auto_attendance_minutes
|
||||
and total >= sess.auto_attendance_minutes * 60):
|
||||
new_status = 'present'
|
||||
p.write({
|
||||
'left_at': now,
|
||||
'duration_seconds': total,
|
||||
'attendance_status': new_status,
|
||||
})
|
||||
return _json_response({'ok': True})
|
||||
except Exception as e:
|
||||
_logger.exception('end_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/recording',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def store_recording(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _parse_body()
|
||||
url = (body.get('url') or '').strip()
|
||||
if not url:
|
||||
return _error_response('url required', 400)
|
||||
sess.recording_url = url
|
||||
return _json_response({'ok': True, 'url': url})
|
||||
except Exception as e:
|
||||
_logger.exception('store_recording failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/remove-participant',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def remove_participant(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _parse_body()
|
||||
user_id = body.get('user_id')
|
||||
reason = (body.get('reason') or '').strip()
|
||||
if not user_id or not reason:
|
||||
return _error_response(
|
||||
'user_id and reason required (audit trail)', 400,
|
||||
)
|
||||
Part = request.env['encoach.live.session.participant'].sudo()
|
||||
part = Part.search([
|
||||
('session_id', '=', sess.id),
|
||||
('user_id', '=', int(user_id)),
|
||||
], limit=1)
|
||||
if not part:
|
||||
return _error_response('Participant not found', 404)
|
||||
part.write({
|
||||
'attendance_status': 'removed',
|
||||
'removed_reason': reason,
|
||||
'left_at': oo_fields.Datetime.now(),
|
||||
})
|
||||
return _json_response({'ok': True,
|
||||
'participant': _ser_participant(part)})
|
||||
except Exception as e:
|
||||
_logger.exception('remove_participant failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/attendance',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def attendance_roll(self, session_id, **kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
|
||||
roll = []
|
||||
seen_user_ids = set()
|
||||
for p in sess.participant_ids:
|
||||
seen_user_ids.add(p.user_id.id)
|
||||
roll.append({
|
||||
'user_id': p.user_id.id,
|
||||
'user_name': p.user_id.name or p.user_id.login or '',
|
||||
'attendance_status': p.attendance_status,
|
||||
'duration_seconds': p.duration_seconds,
|
||||
'removed_reason': p.removed_reason or '',
|
||||
})
|
||||
|
||||
for uid in _enrolled_user_ids(sess):
|
||||
if uid in seen_user_ids:
|
||||
continue
|
||||
user = request.env['res.users'].sudo().browse(uid)
|
||||
roll.append({
|
||||
'user_id': uid,
|
||||
'user_name': user.name or user.login or '',
|
||||
'attendance_status': 'not_joined',
|
||||
'duration_seconds': 0,
|
||||
'removed_reason': '',
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'session_id': sess.id,
|
||||
'status': sess.status,
|
||||
'roll': roll,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('attendance_roll failed')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""CSV / PDF export for the admin Reports section.
|
||||
|
||||
Phase 2 (2026-04-30): the admin asked for downloadable copies of the
|
||||
existing reports. We expose two formats — CSV (always works, no
|
||||
extra deps) and PDF (uses weasyprint when installed; falls back to
|
||||
HTML otherwise).
|
||||
|
||||
Both endpoints reuse the same domain-builder used by the JSON
|
||||
endpoints in ``reports.py`` so filters (entity, branch, subject,
|
||||
gender, level, since/period) behave identically.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import jwt_required
|
||||
from odoo.addons.encoach_lms_api.controllers.reports import (
|
||||
_build_attempt_domain, _attempt_completed_at,
|
||||
_normalize_cefr, _cefr_for_band,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _csv_response(rows, headers, filename):
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(headers)
|
||||
for r in rows:
|
||||
writer.writerow(r)
|
||||
body = buf.getvalue()
|
||||
resp = request.make_response(
|
||||
body,
|
||||
headers=[
|
||||
('Content-Type', 'text/csv; charset=utf-8'),
|
||||
('Content-Disposition', f'attachment; filename="{filename}"'),
|
||||
],
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def _pdf_response(html, filename):
|
||||
"""Return PDF bytes from HTML, falling back to HTML if weasyprint
|
||||
is not available."""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
pdf = HTML(string=html).write_pdf()
|
||||
return request.make_response(
|
||||
pdf,
|
||||
headers=[
|
||||
('Content-Type', 'application/pdf'),
|
||||
('Content-Disposition',
|
||||
f'attachment; filename="{filename}"'),
|
||||
],
|
||||
)
|
||||
except Exception:
|
||||
return request.make_response(
|
||||
html,
|
||||
headers=[
|
||||
('Content-Type', 'text/html; charset=utf-8'),
|
||||
('Content-Disposition',
|
||||
f'attachment; filename="{filename.replace(".pdf", ".html")}"'),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _build_student_perf_rows(kw):
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
domain = _build_attempt_domain(kw)
|
||||
attempts = Att.search(domain, order='started_at desc')
|
||||
|
||||
per_student = defaultdict(lambda: {
|
||||
'attempts': [],
|
||||
'reading_sum': 0.0, 'reading_n': 0,
|
||||
'listening_sum': 0.0, 'listening_n': 0,
|
||||
'writing_sum': 0.0, 'writing_n': 0,
|
||||
'speaking_sum': 0.0, 'speaking_n': 0,
|
||||
'overall_sum': 0.0, 'overall_n': 0,
|
||||
'last_at': None, 'last_cefr': None,
|
||||
'entity_id': None, 'entity_name': None,
|
||||
})
|
||||
|
||||
def _accum(agg, key, value):
|
||||
if value is not None and value > 0:
|
||||
agg[f'{key}_sum'] += float(value)
|
||||
agg[f'{key}_n'] += 1
|
||||
|
||||
for att in attempts:
|
||||
if not att.student_id:
|
||||
continue
|
||||
agg = per_student[att.student_id.id]
|
||||
agg['attempts'].append(att.id)
|
||||
_accum(agg, 'reading', att.reading_band)
|
||||
_accum(agg, 'listening', att.listening_band)
|
||||
_accum(agg, 'writing', att.writing_band)
|
||||
_accum(agg, 'speaking', att.speaking_band)
|
||||
_accum(agg, 'overall', att.overall_band)
|
||||
ct = _attempt_completed_at(att) or att.started_at
|
||||
if ct and (agg['last_at'] is None or ct > agg['last_at']):
|
||||
agg['last_at'] = ct
|
||||
agg['last_cefr'] = _normalize_cefr(att.cefr_level) \
|
||||
or _cefr_for_band(att.overall_band)
|
||||
if att.entity_id and not agg['entity_id']:
|
||||
agg['entity_id'] = att.entity_id.id
|
||||
agg['entity_name'] = att.entity_id.name
|
||||
|
||||
rows = []
|
||||
for student_id, agg in per_student.items():
|
||||
user = request.env['res.users'].sudo().browse(student_id)
|
||||
if not user.exists():
|
||||
continue
|
||||
name = user.name or user.login or f'User #{student_id}'
|
||||
|
||||
def _avg(key):
|
||||
n = agg[f'{key}_n']
|
||||
if not n:
|
||||
return None
|
||||
return round(agg[f'{key}_sum'] / n, 2)
|
||||
|
||||
overall = _avg('overall')
|
||||
if overall is None:
|
||||
skill_avgs = [v for v in (
|
||||
_avg('reading'), _avg('listening'),
|
||||
_avg('writing'), _avg('speaking'),
|
||||
) if v is not None]
|
||||
overall = round(sum(skill_avgs) / len(skill_avgs), 2) \
|
||||
if skill_avgs else None
|
||||
|
||||
level = agg['last_cefr'] or _cefr_for_band(overall)
|
||||
rows.append([
|
||||
student_id,
|
||||
name,
|
||||
user.login or '',
|
||||
agg['entity_name'] or '',
|
||||
_avg('reading') or '',
|
||||
_avg('listening') or '',
|
||||
_avg('writing') or '',
|
||||
_avg('speaking') or '',
|
||||
overall or '',
|
||||
level or '',
|
||||
len(agg['attempts']),
|
||||
agg['last_at'].strftime('%Y-%m-%d') if agg['last_at'] else '',
|
||||
])
|
||||
rows.sort(key=lambda r: (-(r[8] or 0), r[1].lower() if r[1] else ''))
|
||||
return rows
|
||||
|
||||
|
||||
STUDENT_PERF_HEADERS = [
|
||||
'Student ID', 'Name', 'Login', 'Entity',
|
||||
'Reading', 'Listening', 'Writing', 'Speaking',
|
||||
'Overall', 'CEFR', 'Attempts', 'Last attempt',
|
||||
]
|
||||
|
||||
|
||||
class ReportsExportController(http.Controller):
|
||||
|
||||
@http.route('/api/reports/student-performance/export',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def export_student_perf(self, **kw):
|
||||
fmt = (kw.get('format') or 'csv').lower()
|
||||
try:
|
||||
rows = _build_student_perf_rows(kw)
|
||||
except Exception as e:
|
||||
_logger.exception('export_student_perf failed')
|
||||
return request.make_json_response({'error': str(e)}, status=500)
|
||||
if fmt == 'pdf':
|
||||
html = _student_perf_html(rows)
|
||||
return _pdf_response(html, 'student-performance.pdf')
|
||||
return _csv_response(rows, STUDENT_PERF_HEADERS,
|
||||
'student-performance.csv')
|
||||
|
||||
@http.route('/api/reports/record/export',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def export_record(self, **kw):
|
||||
fmt = (kw.get('format') or 'csv').lower()
|
||||
try:
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
domain = _build_attempt_domain(kw, reportable=False)
|
||||
if kw.get('status'):
|
||||
domain.append(('status', '=', kw['status']))
|
||||
attempts = Att.search(domain, order='started_at desc', limit=10000)
|
||||
rows = []
|
||||
for att in attempts:
|
||||
exam = att.exam_id
|
||||
exam_title = ''
|
||||
if exam:
|
||||
exam_title = (getattr(exam, 'title', None)
|
||||
or getattr(exam, 'display_name', '')
|
||||
or '')
|
||||
rows.append([
|
||||
att.id,
|
||||
att.student_id.name if att.student_id else '',
|
||||
att.student_id.login if att.student_id else '',
|
||||
exam_title,
|
||||
att.entity_id.name if att.entity_id else '',
|
||||
att.started_at.strftime('%Y-%m-%d %H:%M')
|
||||
if att.started_at else '',
|
||||
(att.completed_at.strftime('%Y-%m-%d %H:%M')
|
||||
if att.completed_at else ''),
|
||||
att.status or '',
|
||||
att.overall_band or '',
|
||||
_normalize_cefr(att.cefr_level)
|
||||
or _cefr_for_band(att.overall_band) or '',
|
||||
])
|
||||
except Exception as e:
|
||||
_logger.exception('export_record failed')
|
||||
return request.make_json_response({'error': str(e)}, status=500)
|
||||
headers = [
|
||||
'Attempt ID', 'Student', 'Login', 'Exam', 'Entity',
|
||||
'Started at', 'Completed at', 'Status', 'Overall', 'CEFR',
|
||||
]
|
||||
if fmt == 'pdf':
|
||||
return _pdf_response(_table_html(headers, rows, 'Attempt record'),
|
||||
'attempt-record.pdf')
|
||||
return _csv_response(rows, headers, 'attempt-record.csv')
|
||||
|
||||
|
||||
def _student_perf_html(rows):
|
||||
return _table_html(STUDENT_PERF_HEADERS, rows, 'Student performance')
|
||||
|
||||
|
||||
def _table_html(headers, rows, title):
|
||||
th = ''.join(f'<th>{h}</th>' for h in headers)
|
||||
body = ''
|
||||
for r in rows:
|
||||
cells = ''.join(f'<td>{c}</td>' for c in r)
|
||||
body += f'<tr>{cells}</tr>'
|
||||
return (
|
||||
'<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<style>'
|
||||
'body{font-family:-apple-system,sans-serif;padding:24px;color:#1a1a1a}'
|
||||
'h1{margin:0 0 16px 0;font-size:20px}'
|
||||
'table{border-collapse:collapse;width:100%;font-size:11px}'
|
||||
'th,td{border:1px solid #e5e5e5;padding:6px 8px;text-align:left}'
|
||||
'th{background:#f7f7f5;font-weight:600}'
|
||||
'tr:nth-child(even) td{background:#fafaf8}'
|
||||
'</style></head><body>'
|
||||
f'<h1>{title}</h1>'
|
||||
f'<table><thead><tr>{th}</tr></thead><tbody>{body}</tbody></table>'
|
||||
'</body></html>'
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Teacher-only "Class Insights" endpoint.
|
||||
|
||||
Phase 1 (2026-04-29): teachers asked for an at-a-glance view of each
|
||||
course they own — how many students, what the attendance rate looks
|
||||
like, how much of the assigned work has been submitted. We aggregate
|
||||
existing models without introducing any new schema:
|
||||
|
||||
* ``op.student.course`` — enrolment list
|
||||
* ``op.attendance.line`` — present/absent/excused/late counters
|
||||
* ``encoach.exam.assignment`` — assigned exams
|
||||
* ``encoach.student.attempt`` — attempts against those exams
|
||||
* ``encoach.course.chapter`` — chapter / material progress totals
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _user_can_see_course(user, course):
|
||||
"""Return True if the JWT user is admin/corporate OR teaches the course.
|
||||
|
||||
Admin / corporate / master-corporate / developer users always pass —
|
||||
they manage the platform across all courses. Teacher users pass when
|
||||
at least one batch on the course lists their ``op.faculty`` row.
|
||||
"""
|
||||
if not course or not course.exists():
|
||||
return False
|
||||
if user.has_group('base.group_system'):
|
||||
return True
|
||||
user_type = getattr(user, 'user_type', '') or ''
|
||||
if user_type in (
|
||||
'admin', 'corporate', 'mastercorporate', 'developer',
|
||||
):
|
||||
return True
|
||||
Faculty = request.env['op.faculty'].sudo()
|
||||
faculty = Faculty.search([('user_id', '=', user.id)], limit=1)
|
||||
if not faculty:
|
||||
return False
|
||||
Batch = request.env['op.batch'].sudo()
|
||||
teaches_via_batch = Batch.search_count([
|
||||
('course_id', '=', course.id),
|
||||
('teacher_ids', 'in', faculty.id),
|
||||
])
|
||||
return bool(teaches_via_batch)
|
||||
|
||||
|
||||
class TeacherInsightsController(http.Controller):
|
||||
|
||||
@http.route('/api/teacher/courses/<int:course_id>/insights',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def course_insights(self, course_id, **kw):
|
||||
"""Return enrollment + attendance + submission rollups for a course.
|
||||
|
||||
Response shape::
|
||||
|
||||
{
|
||||
"course_id": 12,
|
||||
"course_name": "GE1 — General English",
|
||||
"students": {
|
||||
"total": 24,
|
||||
"by_batch": [{ "batch_id": 7, "batch_name": "B-A",
|
||||
"count": 8 }, ...]
|
||||
},
|
||||
"attendance": {
|
||||
"lines_total": 192,
|
||||
"present": 165, "absent": 18, "excused": 6, "late": 3,
|
||||
"rate_present": 86.0, # %
|
||||
"rate_absent": 9.4
|
||||
},
|
||||
"submissions": {
|
||||
"exams_assigned": 5,
|
||||
"expected_attempts": 120, # students * assigned
|
||||
"completed_attempts": 78,
|
||||
"rate_completed": 65.0
|
||||
},
|
||||
"materials": {
|
||||
"chapters": 8,
|
||||
"completed_progress_rows": 45,
|
||||
"rate_chapters": 23.4
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = request.env.user
|
||||
course = request.env['op.course'].sudo().browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
if not _user_can_see_course(user, course):
|
||||
return _error_response('Forbidden', 403)
|
||||
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
regs = SC.search([('course_id', '=', course.id)])
|
||||
student_ids = list({r.student_id.id for r in regs if r.student_id})
|
||||
student_count = len(student_ids)
|
||||
# encoach.student.attempt.student_id points at res.users, while
|
||||
# op.student.course.student_id points at op.student. Resolve the
|
||||
# res.users equivalent so we can match attempts to enrolments.
|
||||
user_ids = []
|
||||
if student_ids:
|
||||
Student = request.env['op.student'].sudo()
|
||||
user_ids = [
|
||||
s.user_id.id for s in Student.browse(student_ids)
|
||||
if s.user_id
|
||||
]
|
||||
|
||||
by_batch_map = {}
|
||||
for r in regs:
|
||||
if not r.batch_id:
|
||||
continue
|
||||
bucket = by_batch_map.setdefault(r.batch_id.id, {
|
||||
'batch_id': r.batch_id.id,
|
||||
'batch_name': r.batch_id.name or '',
|
||||
'count': 0,
|
||||
})
|
||||
bucket['count'] += 1
|
||||
by_batch = sorted(by_batch_map.values(), key=lambda b: b['batch_name'])
|
||||
|
||||
Line = request.env['op.attendance.line'].sudo()
|
||||
lines = Line.search([('course_id', '=', course.id)])
|
||||
present = sum(1 for l in lines if l.present)
|
||||
absent = sum(1 for l in lines if l.absent)
|
||||
excused = sum(1 for l in lines if l.excused)
|
||||
late = sum(1 for l in lines if l.late)
|
||||
n_lines = len(lines)
|
||||
attendance = {
|
||||
'lines_total': n_lines,
|
||||
'present': present,
|
||||
'absent': absent,
|
||||
'excused': excused,
|
||||
'late': late,
|
||||
'rate_present': round(present / n_lines * 100, 1) if n_lines else 0.0,
|
||||
'rate_absent': round(absent / n_lines * 100, 1) if n_lines else 0.0,
|
||||
'rate_late': round(late / n_lines * 100, 1) if n_lines else 0.0,
|
||||
}
|
||||
|
||||
# encoach.exam.assignment links to an exam via exam_id and to
|
||||
# students either directly (student_id) or by batch (batch_id).
|
||||
# Course → exam happens through the batch's course_id, since
|
||||
# neither the assignment nor the exam itself carries a
|
||||
# direct course_id column.
|
||||
Batch = request.env['op.batch'].sudo()
|
||||
course_batch_ids = Batch.search([
|
||||
('course_id', '=', course.id),
|
||||
]).ids
|
||||
ExamAssign = request.env['encoach.exam.assignment'].sudo()
|
||||
assignments = ExamAssign.search([
|
||||
('batch_id', 'in', course_batch_ids),
|
||||
]) if course_batch_ids else ExamAssign.browse([])
|
||||
customs = assignments.mapped('exam_id')
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
scoring_attempts = Att.search([
|
||||
('exam_id', 'in', customs.ids),
|
||||
('student_id', 'in', user_ids),
|
||||
]) if (customs and user_ids) else Att.browse([])
|
||||
completed = scoring_attempts.filtered(
|
||||
lambda a: a.status in ('completed', 'scoring', 'scored', 'released'),
|
||||
)
|
||||
# A student may legitimately have multiple attempts at the same
|
||||
# exam (retakes). For the "submission progress" rollup we only
|
||||
# care about whether each (student, exam) pair was at least
|
||||
# submitted once, otherwise the percentage can exceed 100% and
|
||||
# become meaningless.
|
||||
completed_pairs = {
|
||||
(a.student_id.id, a.exam_id.id) for a in completed
|
||||
if a.student_id and a.exam_id
|
||||
}
|
||||
expected = len(user_ids) * len(customs)
|
||||
submissions = {
|
||||
'exams_assigned': len(assignments),
|
||||
'expected_attempts': expected,
|
||||
'completed_attempts': len(completed_pairs),
|
||||
'rate_completed': (
|
||||
round(min(len(completed_pairs) / expected * 100, 100.0), 1)
|
||||
if expected else 0.0
|
||||
),
|
||||
}
|
||||
|
||||
Chapter = request.env['encoach.course.chapter'].sudo()
|
||||
chapters = Chapter.search([('course_id', '=', course.id)])
|
||||
n_chapters = len(chapters)
|
||||
ChapterProg = request.env['encoach.chapter.progress'].sudo()
|
||||
prog_rows = ChapterProg.search([
|
||||
('chapter_id', 'in', chapters.ids),
|
||||
]) if chapters else ChapterProg.browse([])
|
||||
done_prog = prog_rows.filtered(lambda p: p.status == 'completed')
|
||||
expected_prog = student_count * n_chapters
|
||||
materials = {
|
||||
'chapters': n_chapters,
|
||||
'students_started': len({p.student_id.id for p in prog_rows
|
||||
if p.student_id}),
|
||||
'completed_progress_rows': len(done_prog),
|
||||
'rate_chapters': (
|
||||
round(len(done_prog) / expected_prog * 100, 1)
|
||||
if expected_prog else 0.0
|
||||
),
|
||||
}
|
||||
|
||||
return _json_response({
|
||||
'course_id': course.id,
|
||||
'course_name': course.name or '',
|
||||
'students': {
|
||||
'total': student_count,
|
||||
'by_batch': by_batch,
|
||||
},
|
||||
'attendance': attendance,
|
||||
'submissions': submissions,
|
||||
'materials': materials,
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('teacher.course_insights failed')
|
||||
return _error_response(str(exc), 500)
|
||||
188
backend/custom_addons/encoach_lms_api/controllers/turnitin.py
Normal file
188
backend/custom_addons/encoach_lms_api/controllers/turnitin.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Turnitin (per-institution) integration controller.
|
||||
|
||||
Phase 4 (2026-04-30): the API key is stored on `encoach.entity`. We
|
||||
expose:
|
||||
|
||||
* `GET /api/turnitin/settings/<entity_id>` — read the current config.
|
||||
* `PATCH /api/turnitin/settings/<entity_id>` — admin updates key.
|
||||
* `POST /api/turnitin/test/<entity_id>` — ping Turnitin to verify.
|
||||
* `POST /api/turnitin/submit` — submit text for an
|
||||
originality report (attached to a handwritten submission, an exam
|
||||
attempt, or a generic blob).
|
||||
|
||||
The submit endpoint is intentionally minimal — Turnitin's full API
|
||||
requires a signed-URL upload + polling flow which can't be reasonably
|
||||
inlined here. We send the text payload + return a stub job_id that
|
||||
the client polls. When the institution actually has a real key the
|
||||
backend swap-in is a matter of replacing the body of `_submit_to_turnitin`.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ser_settings(entity, include_secret=False):
|
||||
return {
|
||||
'entity_id': entity.id,
|
||||
'entity_name': entity.name or '',
|
||||
'enabled': bool(entity.turnitin_enabled),
|
||||
'api_url': entity.turnitin_api_url or '',
|
||||
'account_id': entity.turnitin_account_id or '',
|
||||
'has_api_key': bool(entity.turnitin_api_key),
|
||||
'api_key': (entity.turnitin_api_key or '') if include_secret else None,
|
||||
}
|
||||
|
||||
|
||||
def _is_admin(user):
|
||||
if user.has_group('base.group_system'):
|
||||
return True
|
||||
return getattr(user, 'user_type', '') in (
|
||||
'admin', 'corporate', 'mastercorporate', 'developer',
|
||||
)
|
||||
|
||||
|
||||
def _submit_to_turnitin(entity, text, title='submission'):
|
||||
"""Submit `text` to Turnitin and return (originality_score, job_id).
|
||||
|
||||
Stub: when no real key is configured we return a synthetic
|
||||
originality score so the UI can render. When a real key IS set we
|
||||
attempt a real call and surface any error.
|
||||
"""
|
||||
if not entity.turnitin_enabled or not entity.turnitin_api_key:
|
||||
return None, 'no-config'
|
||||
try:
|
||||
import requests
|
||||
url = (entity.turnitin_api_url or 'https://api.turnitin.com').rstrip('/')
|
||||
url = f'{url}/v1/submissions'
|
||||
headers = {
|
||||
'Authorization': f'Bearer {entity.turnitin_api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if entity.turnitin_account_id:
|
||||
headers['X-Turnitin-Account-Id'] = entity.turnitin_account_id
|
||||
resp = requests.post(url, headers=headers, json={
|
||||
'title': title,
|
||||
'content': text,
|
||||
}, timeout=15)
|
||||
if resp.status_code in (200, 201, 202):
|
||||
data = resp.json() if resp.text else {}
|
||||
return data.get('originality_score'), data.get('id', 'pending')
|
||||
return None, f'http-{resp.status_code}'
|
||||
except Exception as e:
|
||||
_logger.warning('Turnitin submission failed: %s', e)
|
||||
return None, f'error:{e}'
|
||||
|
||||
|
||||
class TurnitinController(http.Controller):
|
||||
|
||||
@http.route('/api/turnitin/settings/<int:entity_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_settings(self, entity_id, **kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
if not _is_admin(user):
|
||||
return _error_response('Forbidden', 403)
|
||||
entity = request.env['encoach.entity'].sudo().browse(entity_id)
|
||||
if not entity.exists():
|
||||
return _error_response('Entity not found', 404)
|
||||
return _json_response(_ser_settings(entity, include_secret=False))
|
||||
except Exception as e:
|
||||
_logger.exception('turnitin.get_settings failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/turnitin/settings/<int:entity_id>',
|
||||
type='http', auth='public', methods=['PATCH'], csrf=False)
|
||||
@jwt_required
|
||||
def patch_settings(self, entity_id, **_kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
if not _is_admin(user):
|
||||
return _error_response('Forbidden', 403)
|
||||
entity = request.env['encoach.entity'].sudo().browse(entity_id)
|
||||
if not entity.exists():
|
||||
return _error_response('Entity not found', 404)
|
||||
try:
|
||||
raw = request.httprequest.get_data(as_text=True)
|
||||
body = json.loads(raw) if raw else {}
|
||||
except (TypeError, ValueError):
|
||||
body = {}
|
||||
vals = {}
|
||||
for f in ('turnitin_enabled', 'turnitin_api_key',
|
||||
'turnitin_api_url', 'turnitin_account_id'):
|
||||
short = f.replace('turnitin_', '')
|
||||
if short in body:
|
||||
vals[f] = body[short]
|
||||
elif f in body:
|
||||
vals[f] = body[f]
|
||||
entity.write(vals)
|
||||
return _json_response(_ser_settings(entity))
|
||||
except Exception as e:
|
||||
_logger.exception('turnitin.patch_settings failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/turnitin/test/<int:entity_id>',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def test_settings(self, entity_id, **_kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
if not _is_admin(user):
|
||||
return _error_response('Forbidden', 403)
|
||||
entity = request.env['encoach.entity'].sudo().browse(entity_id)
|
||||
if not entity.exists():
|
||||
return _error_response('Entity not found', 404)
|
||||
score, job = _submit_to_turnitin(
|
||||
entity, 'Hello world.', title='Connectivity test')
|
||||
return _json_response({
|
||||
'ok': job not in ('no-config',) and not str(job).startswith('error'),
|
||||
'job_id': job,
|
||||
'originality_score': score,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('turnitin.test failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/turnitin/submit',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def submit(self, **_kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
try:
|
||||
raw = request.httprequest.get_data(as_text=True)
|
||||
body = json.loads(raw) if raw else {}
|
||||
except (TypeError, ValueError):
|
||||
body = {}
|
||||
text = (body.get('text') or '').strip()
|
||||
title = (body.get('title') or 'Submission').strip()
|
||||
if not text:
|
||||
return _error_response('text required', 400)
|
||||
|
||||
entity_id = body.get('entity_id')
|
||||
entity = None
|
||||
if entity_id:
|
||||
entity = request.env['encoach.entity'].sudo().browse(int(entity_id))
|
||||
if not entity or not entity.exists():
|
||||
if user.entity_ids:
|
||||
entity = user.entity_ids[0]
|
||||
if not entity or not entity.exists():
|
||||
return _error_response('No entity bound to user', 400)
|
||||
|
||||
score, job = _submit_to_turnitin(entity, text, title=title)
|
||||
return _json_response({
|
||||
'job_id': job,
|
||||
'entity_id': entity.id,
|
||||
'originality_score': score,
|
||||
}, 201)
|
||||
except Exception as e:
|
||||
_logger.exception('turnitin.submit failed')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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.',
|
||||
)
|
||||
@@ -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()
|
||||
137
backend/custom_addons/encoach_lms_api/models/live_session.py
Normal file
137
backend/custom_addons/encoach_lms_api/models/live_session.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Live online classroom sessions (Phase 3 — 2026-04-30).
|
||||
|
||||
We embed Jitsi Meet as the conferencing engine, so most of the
|
||||
in-call features (breakout rooms, polls, whiteboard, screen share,
|
||||
chat box, recording, waiting room, host media controls) are driven
|
||||
by Jitsi's IframeAPI. The Odoo side owns:
|
||||
|
||||
* scheduling + calendar metadata (`scheduled_at`, `duration_min`)
|
||||
* enrolment list + email notification on creation
|
||||
* attendance tracking (auto-mark + late-entry restriction)
|
||||
* recording URLs + status transitions
|
||||
* removal log (who was kicked and why — required by spec)
|
||||
|
||||
A session is hosted in a Jitsi room whose name is generated from
|
||||
``room_name`` (slugified, unique). The frontend joins the room via
|
||||
the IframeAPI and reports lifecycle events to the backend.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class EncoachLiveSession(models.Model):
|
||||
_name = 'encoach.live.session'
|
||||
_description = 'Live Online Session'
|
||||
_order = 'scheduled_at desc, id desc'
|
||||
_rec_name = 'title'
|
||||
|
||||
title = fields.Char(required=True)
|
||||
description = fields.Text()
|
||||
|
||||
course_id = fields.Many2one('op.course', ondelete='set null', index=True)
|
||||
batch_id = fields.Many2one('op.batch', ondelete='set null', index=True)
|
||||
plan_id = fields.Many2one(
|
||||
'encoach.course.plan', ondelete='set null', index=True,
|
||||
help='Optional link to an AI-generated course plan instead of a '
|
||||
'traditional op.course.',
|
||||
)
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null', index=True)
|
||||
host_id = fields.Many2one(
|
||||
'res.users', required=True, ondelete='restrict', index=True,
|
||||
default=lambda self: self.env.user.id,
|
||||
help='User who scheduled the session and acts as the Jitsi '
|
||||
'moderator (mute/cam controls, kick, lobby).',
|
||||
)
|
||||
|
||||
scheduled_at = fields.Datetime(required=True, index=True)
|
||||
duration_min = fields.Integer(default=60, required=True)
|
||||
actual_started_at = fields.Datetime()
|
||||
actual_ended_at = fields.Datetime()
|
||||
|
||||
status = fields.Selection(
|
||||
[
|
||||
('scheduled', 'Scheduled'),
|
||||
('live', 'Live'),
|
||||
('ended', 'Ended'),
|
||||
('cancelled', 'Cancelled'),
|
||||
],
|
||||
default='scheduled', required=True, index=True,
|
||||
)
|
||||
|
||||
room_name = fields.Char(
|
||||
required=True, copy=False, index=True,
|
||||
help='Slug used as the Jitsi room name. Random by default to '
|
||||
'prevent guessing public sessions.',
|
||||
)
|
||||
waiting_room = fields.Boolean(
|
||||
default=True,
|
||||
help='Enable Jitsi lobby — host has to admit each student.',
|
||||
)
|
||||
recording_enabled = fields.Boolean(default=False)
|
||||
recording_url = fields.Char(
|
||||
help='Filled in once the host stops recording. Stored URL '
|
||||
'should be served by the platform CDN, not Jitsi.',
|
||||
)
|
||||
|
||||
auto_attendance_minutes = fields.Integer(
|
||||
default=10,
|
||||
help='Mark a participant "present" once they have been in the '
|
||||
'session for at least this many minutes. 0 disables.',
|
||||
)
|
||||
late_entry_minutes = fields.Integer(
|
||||
default=15,
|
||||
help='Refuse new joins after the session has been live for '
|
||||
'this many minutes. 0 disables.',
|
||||
)
|
||||
|
||||
notification_sent = fields.Boolean(
|
||||
default=False, copy=False,
|
||||
help='Toggled to True after we e-mail the enrolment list. '
|
||||
'Prevents double notifications when the row is edited.',
|
||||
)
|
||||
|
||||
participant_ids = fields.One2many(
|
||||
'encoach.live.session.participant', 'session_id',
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('room_name'):
|
||||
vals['room_name'] = 'enc-' + secrets.token_hex(6)
|
||||
return super().create(vals_list)
|
||||
|
||||
|
||||
class EncoachLiveSessionParticipant(models.Model):
|
||||
_name = 'encoach.live.session.participant'
|
||||
_description = 'Live Session Participant'
|
||||
_order = 'joined_at desc, id desc'
|
||||
|
||||
session_id = fields.Many2one(
|
||||
'encoach.live.session', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
'res.users', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
joined_at = fields.Datetime()
|
||||
left_at = fields.Datetime()
|
||||
duration_seconds = fields.Integer(
|
||||
help='Cumulative time-in-call across joins (people refresh, '
|
||||
'switch networks). Updated on every leave event.',
|
||||
)
|
||||
attendance_status = fields.Selection(
|
||||
[
|
||||
('not_joined', 'Not joined'),
|
||||
('attending', 'Attending'),
|
||||
('present', 'Present'),
|
||||
('left_early', 'Left early'),
|
||||
('removed', 'Removed'),
|
||||
],
|
||||
default='not_joined', required=True, index=True,
|
||||
)
|
||||
removed_reason = fields.Text(
|
||||
help='Required when attendance_status = removed. Used by the '
|
||||
'audit trail tab.',
|
||||
)
|
||||
is_host = fields.Boolean(default=False)
|
||||
@@ -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.',
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
Reference in New Issue
Block a user