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)
|
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
|
# 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
|
# Keep non-technical editing simple: if only plain text is
|
||||||
# provided, mirror it into a minimal JSON structure.
|
# provided, mirror it into a minimal JSON structure.
|
||||||
vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
|
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:
|
if vals:
|
||||||
material.sudo().write(vals)
|
material.sudo().write(vals)
|
||||||
return _json_response({'data': material.to_api_dict()})
|
return _json_response({'data': material.to_api_dict()})
|
||||||
@@ -1005,6 +1110,114 @@ class CoursePlanController(http.Controller):
|
|||||||
# PHASE E — Student-side endpoints
|
# 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',
|
@http.route('/api/student/course-plans',
|
||||||
type='http', auth='none', methods=['GET'], csrf=False)
|
type='http', auth='none', methods=['GET'], csrf=False)
|
||||||
@jwt_required
|
@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):
|
class CoursePlan(models.Model):
|
||||||
_name = 'encoach.course.plan'
|
_name = 'encoach.course.plan'
|
||||||
_description = 'AI-generated Course Plan'
|
_description = 'AI-generated Course Plan'
|
||||||
@@ -114,6 +130,18 @@ class CoursePlan(models.Model):
|
|||||||
('archived', 'Archived'),
|
('archived', 'Archived'),
|
||||||
], default='draft')
|
], 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(
|
brief_json = fields.Text(
|
||||||
help='Original brief that was sent to the AI — kept for audit and '
|
help='Original brief that was sent to the AI — kept for audit and '
|
||||||
'so the user can re-generate if the first pass disappoints.',
|
'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_id': self.course_id.id if self.course_id else None,
|
||||||
'course_name': self.course_id.name if self.course_id else '',
|
'course_name': self.course_id.name if self.course_id else '',
|
||||||
'cefr_level': self.cefr_level or '',
|
'cefr_level': self.cefr_level or '',
|
||||||
|
'is_mandatory': bool(self.is_mandatory),
|
||||||
'total_weeks': self.total_weeks or 0,
|
'total_weeks': self.total_weeks or 0,
|
||||||
'contact_hours_per_week': self.contact_hours_per_week or 0,
|
'contact_hours_per_week': self.contact_hours_per_week or 0,
|
||||||
'skills_division': self.skills_division or '',
|
'skills_division': self.skills_division or '',
|
||||||
@@ -282,6 +311,44 @@ class CoursePlanMaterial(models.Model):
|
|||||||
material_type = fields.Selection(
|
material_type = fields.Selection(
|
||||||
MATERIAL_TYPE_SELECTION, required=True, default='other',
|
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)
|
title = fields.Char(required=True)
|
||||||
is_static = fields.Boolean(
|
is_static = fields.Boolean(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -338,6 +405,13 @@ class CoursePlanMaterial(models.Model):
|
|||||||
'week_number': self.week_number or 0,
|
'week_number': self.week_number or 0,
|
||||||
'skill': self.skill or '',
|
'skill': self.skill or '',
|
||||||
'material_type': self.material_type or 'other',
|
'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 '',
|
'title': self.title or '',
|
||||||
'is_static': bool(self.is_static),
|
'is_static': bool(self.is_static),
|
||||||
'share_date': self.share_date.isoformat() if self.share_date else None,
|
'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()
|
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:
|
def _extract_pdf(payload: bytes) -> str:
|
||||||
"""Best-effort PDF text extraction with OCR fallback.
|
"""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
|
Both raise on encrypted PDFs we can't decrypt — we swallow that and
|
||||||
let the caller record the error.
|
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:
|
try:
|
||||||
from pypdf import PdfReader # type: ignore
|
from pypdf import PdfReader # type: ignore
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -280,17 +321,28 @@ class SourceIndexer:
|
|||||||
payload = base64.b64decode(res.file)
|
payload = base64.b64decode(res.file)
|
||||||
if not payload:
|
if not payload:
|
||||||
raise ValueError('Library resource has an empty file.')
|
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)
|
return _extract_pdf(payload)
|
||||||
if rtype == 'document' and file_name.lower().endswith(('.docx', '.doc')):
|
if fmt == 'docx':
|
||||||
return _extract_docx(payload)
|
return _extract_docx(payload)
|
||||||
# Fall back to plain-text decoding for txt/md/csv/json.
|
if fmt == 'html':
|
||||||
if file_name.lower().endswith((
|
return _extract_html(payload.decode('utf-8', errors='replace'))
|
||||||
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
|
if fmt == 'text':
|
||||||
'.log', '.rst',
|
|
||||||
)):
|
|
||||||
return payload.decode('utf-8', errors='replace').strip()
|
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):
|
for fn in (_extract_pdf, _extract_docx):
|
||||||
try:
|
try:
|
||||||
text = fn(payload)
|
text = fn(payload)
|
||||||
@@ -299,11 +351,18 @@ class SourceIndexer:
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
return payload.decode('utf-8', errors='replace').strip()
|
decoded = payload.decode('utf-8', errors='replace').strip()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'Cannot decode library resource binary: {exc}',
|
f'Cannot decode library resource binary: {exc}',
|
||||||
) from 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:
|
if res.url:
|
||||||
_, text = _fetch_url(res.url)
|
_, text = _fetch_url(res.url)
|
||||||
@@ -326,32 +385,40 @@ class SourceIndexer:
|
|||||||
payload = source.get_decoded_file()
|
payload = source.get_decoded_file()
|
||||||
if not payload:
|
if not payload:
|
||||||
raise ValueError('No file payload to index')
|
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()
|
mime = (source.mime_type or '').lower()
|
||||||
name = (source.file_name or '').lower()
|
name = (source.file_name or '').lower()
|
||||||
if mime == 'application/pdf' or name.endswith('.pdf'):
|
if fmt == 'pdf':
|
||||||
return _extract_pdf(payload)
|
return _extract_pdf(payload)
|
||||||
if (mime in (
|
if fmt == 'docx':
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
'application/msword',
|
|
||||||
) or name.endswith('.docx') or name.endswith('.doc')):
|
|
||||||
return _extract_docx(payload)
|
return _extract_docx(payload)
|
||||||
# Whitelist plain-text-shaped uploads explicitly. Anything else
|
if fmt == 'html':
|
||||||
# (xlsx, png, mp3, zip, …) must be rejected with a clear error
|
return _extract_html(payload.decode('utf-8', errors='replace'))
|
||||||
# rather than silently UTF-8-decoded into garbage that we'd
|
if fmt == 'text':
|
||||||
# then "successfully" embed and surface as a usable RAG source.
|
return payload.decode('utf-8', errors='replace').strip()
|
||||||
if (mime.startswith('text/')
|
if fmt == 'doc':
|
||||||
or mime in ('application/json', 'application/xml',
|
raise RuntimeError(
|
||||||
'application/csv')
|
'Legacy .doc (Word 97-2003) is not supported for RAG '
|
||||||
or name.endswith(('.txt', '.md', '.markdown', '.csv',
|
'indexing. Re-save the file as .docx or PDF and '
|
||||||
'.json', '.xml', '.log', '.rst'))):
|
'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:
|
try:
|
||||||
return payload.decode('utf-8', errors='replace').strip()
|
return payload.decode('utf-8', errors='replace').strip()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError(f'Cannot decode file: {exc}') from exc
|
raise RuntimeError(f'Cannot decode file: {exc}') from exc
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'Unsupported file type for RAG indexing: '
|
f'Unsupported file type for RAG indexing: '
|
||||||
f'mime={mime!r} name={source.file_name!r}. '
|
f'detected={fmt!r} mime={mime!r} name={source.file_name!r}. '
|
||||||
f'Supported: PDF, DOCX/DOC, plain text (txt/md/csv/json/xml).'
|
f'Supported: PDF, DOCX, HTML, plain text (txt/md/csv/json/xml).'
|
||||||
)
|
)
|
||||||
|
|
||||||
raise ValueError(f'Unknown source kind: {source.kind!r}')
|
raise ValueError(f'Unknown source kind: {source.kind!r}')
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
'encoach_ai',
|
'encoach_ai',
|
||||||
'encoach_taxonomy',
|
'encoach_taxonomy',
|
||||||
'encoach_resources',
|
'encoach_resources',
|
||||||
|
'encoach_scoring',
|
||||||
|
'encoach_exam_template',
|
||||||
|
'encoach_ai_course',
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
|
|||||||
@@ -27,3 +27,10 @@ from . import platform_settings
|
|||||||
from . import training
|
from . import training
|
||||||
from . import reports
|
from . import reports
|
||||||
from . import branches
|
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,
|
'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_id': b.chapter_id.id if b.chapter_id else None,
|
||||||
'chapter_name': b.chapter_id.name 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,
|
'is_enabled': b.is_enabled,
|
||||||
'allow_student_posts': b.allow_student_posts,
|
'allow_student_posts': b.allow_student_posts,
|
||||||
'post_count': b.post_count or 0,
|
'post_count': b.post_count or 0,
|
||||||
@@ -97,6 +99,8 @@ class CommunicationController(http.Controller):
|
|||||||
domain.append(('course_id', '=', int(kw['course_id'])))
|
domain.append(('course_id', '=', int(kw['course_id'])))
|
||||||
if kw.get('batch_id'):
|
if kw.get('batch_id'):
|
||||||
domain.append(('batch_id', '=', int(kw['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')
|
recs = M.search(domain, limit=200, order='id desc')
|
||||||
return _json_response([_ser_board(r) for r in recs])
|
return _json_response([_ser_board(r) for r in recs])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -112,11 +116,55 @@ class CommunicationController(http.Controller):
|
|||||||
vals['course_id'] = int(body['course_id'])
|
vals['course_id'] = int(body['course_id'])
|
||||||
if body.get('batch_id'):
|
if body.get('batch_id'):
|
||||||
vals['batch_id'] = int(body['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)
|
rec = request.env['encoach.discussion.board'].sudo().create(vals)
|
||||||
return _json_response(_ser_board(rec))
|
return _json_response(_ser_board(rec))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _error_response(str(e), 500)
|
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)
|
@http.route('/api/discussion-boards/<int:bid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def update_board(self, bid, **kw):
|
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],
|
'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
|
||||||
'difficulty_level': getattr(c, 'difficulty_level', '') or '',
|
'difficulty_level': getattr(c, 'difficulty_level', '') or '',
|
||||||
'cefr_level': getattr(c, 'cefr_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,
|
'chapter_count': getattr(c, 'chapter_count', 0) or 0,
|
||||||
'resource_count': getattr(c, 'resource_count', 0) or 0,
|
'resource_count': getattr(c, 'resource_count', 0) or 0,
|
||||||
'objective_count': getattr(c, 'objective_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)))
|
domain.append(('student_id', '=', int(user_id)))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
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')
|
since = kw.get('since')
|
||||||
if since:
|
if since:
|
||||||
try:
|
try:
|
||||||
@@ -285,7 +325,10 @@ class ReportsController(http.Controller):
|
|||||||
f"user={kw.get('user_id') or kw.get('student_id') or ''};"
|
f"user={kw.get('user_id') or kw.get('student_id') or ''};"
|
||||||
f"thr={kw.get('threshold') or ''};"
|
f"thr={kw.get('threshold') or ''};"
|
||||||
f"months={kw.get('months') 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)
|
cached = _cache_get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
@@ -520,7 +563,13 @@ class ReportsController(http.Controller):
|
|||||||
@jwt_required
|
@jwt_required
|
||||||
def filters(self, **kw):
|
def filters(self, **kw):
|
||||||
"""Lightweight list of entities + students/users we have attempts for.
|
"""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:
|
try:
|
||||||
Ent = request.env['encoach.entity'].sudo()
|
Ent = request.env['encoach.entity'].sudo()
|
||||||
entities = [{'id': e.id, 'name': e.name or ''}
|
entities = [{'id': e.id, 'name': e.name or ''}
|
||||||
@@ -533,9 +582,31 @@ class ReportsController(http.Controller):
|
|||||||
for u in att_students.sorted('name')
|
for u in att_students.sorted('name')
|
||||||
if u and u.id > 0]
|
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({
|
return _json_response({
|
||||||
'entities': entities,
|
'entities': entities,
|
||||||
'students': students,
|
'students': students,
|
||||||
|
'branches': branches,
|
||||||
|
'subjects': subjects,
|
||||||
|
'genders': genders,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception('reports filters failed')
|
_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 training
|
||||||
from . import paymob_order
|
from . import paymob_order
|
||||||
from . import branch
|
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):
|
class EncoachDiscussionBoard(models.Model):
|
||||||
@@ -9,11 +9,18 @@ class EncoachDiscussionBoard(models.Model):
|
|||||||
course_id = fields.Many2one('op.course', ondelete='cascade')
|
course_id = fields.Many2one('op.course', ondelete='cascade')
|
||||||
batch_id = fields.Many2one('op.batch', ondelete='set null')
|
batch_id = fields.Many2one('op.batch', ondelete='set null')
|
||||||
chapter_id = fields.Many2one('encoach.course.chapter', 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)
|
is_enabled = fields.Boolean(default=True)
|
||||||
allow_student_posts = fields.Boolean(default=True)
|
allow_student_posts = fields.Boolean(default=True)
|
||||||
post_ids = fields.One2many('encoach.discussion.post', 'board_id')
|
post_ids = fields.One2many('encoach.discussion.post', 'board_id')
|
||||||
post_count = fields.Integer(compute='_compute_post_count', store=True)
|
post_count = fields.Integer(compute='_compute_post_count', store=True)
|
||||||
|
|
||||||
|
@api.depends('post_ids')
|
||||||
def _compute_post_count(self):
|
def _compute_post_count(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
rec.post_count = len(rec.post_ids)
|
rec.post_count = len(rec.post_ids)
|
||||||
|
|||||||
@@ -107,6 +107,19 @@ class OpCourseExt(models.Model):
|
|||||||
section_ids = fields.One2many(
|
section_ids = fields.One2many(
|
||||||
'encoach.course.section', 'course_id', string='Sections'
|
'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)
|
chapter_count = fields.Integer(compute='_compute_chapter_count', store=True)
|
||||||
section_count = fields.Integer(compute='_compute_section_count', store=True)
|
section_count = fields.Integer(compute='_compute_section_count', store=True)
|
||||||
objective_count = fields.Integer(compute='_compute_objective_count')
|
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_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_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_course_section_user,encoach.course.section.user,model_encoach_course_section,base.group_user,1,1,1,1
|
||||||
|
access_encoach_exam_security_event_user,encoach.exam.security.event.user,model_encoach_exam_security_event,base.group_user,1,1,1,1
|
||||||
|
access_encoach_live_session_user,encoach.live.session.user,model_encoach_live_session,base.group_user,1,1,1,1
|
||||||
|
access_encoach_live_session_participant_user,encoach.live.session.participant.user,model_encoach_live_session_participant,base.group_user,1,1,1,1
|
||||||
|
access_encoach_handwritten_submission_user,encoach.handwritten.submission.user,model_encoach_handwritten_submission,base.group_user,1,1,1,1
|
||||||
|
|||||||
|
@@ -1,10 +1,14 @@
|
|||||||
# EnCoach Platform — Project Summary
|
# EnCoach Platform — Project Summary
|
||||||
|
|
||||||
> Last updated: 2026-04-27 | **Canonical repos: [`encoach_backend_v4`](https://git.albousalh.com/devops/encoach_backend_v4) (backend) + [`encoach_frontend_v4`](https://git.albousalh.com/devops/encoach_frontend_v4) (frontend), branch `main`.**
|
> Last updated: 2026-04-30 | **Canonical repos: [`encoach_backend_v4`](https://git.albousalh.com/devops/encoach_backend_v4) (backend) + [`encoach_frontend_v4`](https://git.albousalh.com/devops/encoach_frontend_v4) (frontend), branch `main`.**
|
||||||
>
|
>
|
||||||
> This workspace (`odoo19/`) is a **developer monorepo / working tree only** — it conveniently contains both halves side-by-side for local development and testing. The two split repos above are the **authoritative origins** for each half: every change must be published to them (via `git subtree split + push`) before the team lead can deploy. See **§6 Git Remotes & Repositories** for the exact workflow.
|
> This workspace (`odoo19/`) is a **developer monorepo / working tree only** — it conveniently contains both halves side-by-side for local development and testing. The two split repos above are the **authoritative origins** for each half: every change must be published to them (via `git subtree split + push`) before the team lead can deploy. See **§6 Git Remotes & Repositories** for the exact workflow.
|
||||||
|
|
||||||
> **Latest events:**
|
> **Latest events:**
|
||||||
|
> - **2026-04-30 (Phase 5 polish — Jitsi controls + customisable AI plan + .ics calendar + lesson polish):** Closed out the four follow-up items from the Phase 4 sweep so every requirement on the institutional roadmap is now wired end-to-end. (1) **Per-participant Jitsi controls** — `LiveSessionRoom` participants sidebar now shows host-only inline `Mute all (audio)` / `Cams off (video)` bulk buttons plus per-row `Ask to unmute` (`executeCommand("askToUnmute", participantId)`) and `Remove + log` actions; the remove path now also calls `executeCommand("kickParticipant", ...)` after the backend audit-log POST so the user is dropped from the room, not just marked removed. (2) **Customisable AI plan UI** — `PersonalizedPlanCard` got a second action `Customize` that opens a dialog with **CEFR level**, **Weeks (1–12)**, free-text **Focus**, and a 9-checkbox **Grammar focus** (Tenses, Conditionals, Articles, Prepositions, Modals, Passive voice, Reported speech, Phrasal verbs, Subject-verb agreement) — all flow into the existing `POST /api/student/personalized-plan` payload. (3) **.ics calendar attachment** — `live_sessions.py` now builds a fully-spec'd RFC-5545 iCalendar payload (`BEGIN:VEVENT` + 15-min `VALARM` reminder + organizer/attendee + dashboard `LOCATION` URL) and attaches it to the session-invite mail; new `GET /api/live-sessions/<id>/ics` route lets the host (or any enrolled user) re-download the file from the `Calendar` button on each `SessionCard` — Gmail/Outlook/Apple Mail prompt to add the event in one click. (4) **Lesson viewer content polish** — new shared `components/lesson/ImageLightbox.tsx` (click-to-zoom figure → full-screen `Dialog`) and `components/lesson/InfographicBlock.tsx` (six-tone callout: tip / info / warn / success / highlight / example with auto-detect from keys like `did_you_know`, `key_takeaway`, `tip`, `objective`, `common_mistakes`); `MaterialBookView` now special-cases image keys (`image`, `image_url`, `illustration`, `images[]`, …) into the lightbox and routes infographic-style keys into colored cards, plus splits long strings into proper paragraphs and switches the outer wrapper to `rounded-2xl shadow-sm` typography. `MaterialViewer` upgraded its `ImageViewer` to the same lightbox and its `ArticleViewer` to a `prose` article. All four items pass `tsc --noEmit`; `.ics` smoke-test returns a valid 23-line VCALENDAR for session 1. **Status:** all 30 roadmap requirements now fully wired (only "real Turnitin upload" still requires a customer-supplied institutional key to validate end-to-end).
|
||||||
|
> - **2026-04-29 (Phase 2/3/4 — full institutional roadmap):** Implemented the remainder of the institutional requirements list in one extensive batch covering exam security, live online classroom (Jitsi), personalized AI plans, handwritten submissions, Turnitin integration, and CSV/PDF report exports. **New Odoo models:** `encoach.exam.security.event` (event log per attempt with `severity` info/warning/alert), `encoach.live.session` + `encoach.live.session.participant` (Jitsi-backed online classroom with attendance, recording URL, waiting room, late-entry, auto-attendance), `encoach.handwritten.submission` (image attachments + AI grading + teacher review). **Extended models:** `encoach.exam.custom` got `security_level` (off/soft/strict), `single_attempt`, `suspicious_event_threshold`; `encoach.course.plan` got `is_personalized` / `personalized_for_id` / `weakness_summary`; `encoach.entity` got `turnitin_enabled` / `turnitin_api_key` / `turnitin_api_url` / `turnitin_account_id`. **New controllers/endpoints (24 in total):** exam security (`POST /api/exam/security/event`, `GET /api/exam/single-attempt-check`, `GET /api/teacher/exam/<assignment_id>/security/events`, `GET /api/teacher/exam/attempt/<attempt_id>/security/events`, `GET /api/teacher/exam/<assignment_id>/live`), reports export (`GET /api/reports/student-performance/export`, `GET /api/reports/record/export` — both CSV & PDF), personalized plan (`GET/POST /api/student/personalized-plan`), handwritten (`POST/GET /api/student/handwritten/<material_id>`, `GET /api/teacher/handwritten/<material_id>`, `POST /api/teacher/handwritten/<submission_id>/review`), live sessions (full CRUD + `/notify`, `/join`, `/leave`, `/end`, `/recording`, `/remove-participant`, `/attendance`, `/ics`), Turnitin (`GET/PATCH /api/turnitin/settings/<eid>`, `POST /api/turnitin/test/<eid>`, `POST /api/turnitin/submit`). **New frontend services:** `examSecurityService`, `liveSessionService`, `personalizedPlanService`, `handwrittenService`, `turnitinService`, `reportsExportService`. **New pages/components:** `LiveSessionsPage`, `LiveSessionRoom` (Jitsi IframeAPI embed), `TurnitinSettings` admin page, `TeacherTestSessionConsole`, `PersonalizedPlanCard`, `AITutorAvatar`, `HandwrittenSubmissionPanel`, `ExportButtons`, plus a `useExamSecurity` hook integrated into `ExamSession` (tab/blur, copy/paste, fullscreen, suspicious-shortcut detection, debounced auto-post + auto-lock). **Routing/nav:** `/live`, `/live/:id`, `/admin/turnitin`, `/teacher/exam/:assignmentId/console` plus sidebar links in `StudentLayout`/`TeacherLayout`/`AdminLmsLayout` and EN/AR i18n keys. **Manifest fix:** `encoach_lms_api/__manifest__.py` now depends on `encoach_scoring`, `encoach_exam_template`, `encoach_ai_course` to satisfy the new `Many2one('encoach.student.attempt', …)` references. All 12 new endpoints smoke-tested with `curl` (200 + sane payloads); frontend type-checks clean.
|
||||||
|
> - **2026-04-29 (visual identity refresh — "Duvex" theme):** Reskinned the frontend to match the new visual brief — warm cream page background, white sidebar with charcoal active pills, dark-charcoal primary CTA buttons (`--cta`), bright orange accents (`--primary`), rounded-2xl cards with soft shadows, softer input fields. Tweaks live in `frontend/src/index.css` and the layout shells (`StudentLayout`, `TeacherLayout`, `AdminLmsLayout`).
|
||||||
|
> - **2026-04-28 (local stack restart):** PostgreSQL (`pgdata`), Odoo (`./scripts/run-odoo.sh` → `http://127.0.0.1:8069`), and Vite (`npm run dev -- --host 127.0.0.1 --port 8080`) restarted successfully after a prior shutdown.
|
||||||
> - **2026-04-27 (professional interactive course plans + scanned-PDF OCR indexing):** Completed the end-to-end "Professional Interactive Course Plans" rollout and validated it in API smoke + browser runs. Backend: added `RAGContextBuilder`, richer v2 week-material schema (`/api/ai/course-plan/<id>/weeks/<n>/materials/v2`), `interactive_workbook` material type, provenance fields (`grounded_on_json`, `extracted_from_json`), workbook extraction service, and attempt persistence/scoring model (`encoach.course.plan.workbook.attempt`) with new endpoints for extraction, grounding, and attempts. Frontend: added `InteractiveWorkbook`, shared `PlanReader`, `GroundingBadge`, and true admin "View as Student" preview mode rendering the same student reader without requiring a student login. Operational fix: scanned exercise books (e.g. Headway Intermediate workbook) were failing with `Extracted no text from source`; implemented OCR fallback in `source_indexer.py` (tesseract + poppler + `pdf2image`/`pytesseract`) with per-page streaming to keep memory bounded, then re-indexed failed sources successfully (`indexed`, 109 chunks, ~207k chars each). Regression checks still pass (`smoke_assignment_workflow.py`, `smoke_entity_isolation.py`, `smoke_course_plan_rag.py`) and browser verification confirms sources now show green `Indexed` badges. **Current environment note:** OpenAI course-generation calls are returning `429 insufficient_quota`, so "Regenerate week materials" currently writes the intended skeleton fallback content with an explicit in-body note until API billing/quota is restored.
|
> - **2026-04-27 (professional interactive course plans + scanned-PDF OCR indexing):** Completed the end-to-end "Professional Interactive Course Plans" rollout and validated it in API smoke + browser runs. Backend: added `RAGContextBuilder`, richer v2 week-material schema (`/api/ai/course-plan/<id>/weeks/<n>/materials/v2`), `interactive_workbook` material type, provenance fields (`grounded_on_json`, `extracted_from_json`), workbook extraction service, and attempt persistence/scoring model (`encoach.course.plan.workbook.attempt`) with new endpoints for extraction, grounding, and attempts. Frontend: added `InteractiveWorkbook`, shared `PlanReader`, `GroundingBadge`, and true admin "View as Student" preview mode rendering the same student reader without requiring a student login. Operational fix: scanned exercise books (e.g. Headway Intermediate workbook) were failing with `Extracted no text from source`; implemented OCR fallback in `source_indexer.py` (tesseract + poppler + `pdf2image`/`pytesseract`) with per-page streaming to keep memory bounded, then re-indexed failed sources successfully (`indexed`, 109 chunks, ~207k chars each). Regression checks still pass (`smoke_assignment_workflow.py`, `smoke_entity_isolation.py`, `smoke_course_plan_rag.py`) and browser verification confirms sources now show green `Indexed` badges. **Current environment note:** OpenAI course-generation calls are returning `429 insufficient_quota`, so "Regenerate week materials" currently writes the intended skeleton fallback content with an explicit in-body note until API billing/quota is restored.
|
||||||
> - **2026-04-26 (dynamic course-section UX completion):** Wired the dynamic `Course → Sections → Classroom → Batch` structure all the way through the admin UX. New idempotent backend endpoint `POST /api/courses/<id>/sections/generate-defaults` creates the canonical **A/B/C** templates in one call (custom codes also supported via `{ "codes": [...] }`). `POST /api/batches` now auto-generates a unique `code` (built from course + section + term) when the client omits one — fixing the previous NOT-NULL/uniqueness failure on `op.batch.code` and unblocking the AdminBatches form flow. New frontend service helpers `generateDefaultCourseSections`, `updateCourseSection`, `deleteCourseSection`. `AdminCourses` now shows a **Sections** column with the live count + section codes + a “Manage Sections” dialog (list / add / inline edit / delete + one-click `Generate A · B · C`). `AdminBatches` exposes `Section` + `Term Key` columns plus matching selectors in both Create and Edit dialogs (the section list is fetched per-course and disabled until a course is picked). Smoke-tested live: generate-defaults created A/B/C (idempotent re-run added only the new `D`), section PATCH/DELETE worked, batch was created/updated/validated against course mismatch (`400 course_section_id does not belong to course_id`), and the Edit dialog hydrated `course_section_id` + `term_key` cleanly. Combined with the previous classroom-side cascade, the platform now fully implements the diagram: any classroom can host any sections from any courses, each producing exactly one canonical batch per `(classroom × course × section × term)` with roster + teachers auto-propagated.
|
> - **2026-04-26 (dynamic course-section UX completion):** Wired the dynamic `Course → Sections → Classroom → Batch` structure all the way through the admin UX. New idempotent backend endpoint `POST /api/courses/<id>/sections/generate-defaults` creates the canonical **A/B/C** templates in one call (custom codes also supported via `{ "codes": [...] }`). `POST /api/batches` now auto-generates a unique `code` (built from course + section + term) when the client omits one — fixing the previous NOT-NULL/uniqueness failure on `op.batch.code` and unblocking the AdminBatches form flow. New frontend service helpers `generateDefaultCourseSections`, `updateCourseSection`, `deleteCourseSection`. `AdminCourses` now shows a **Sections** column with the live count + section codes + a “Manage Sections” dialog (list / add / inline edit / delete + one-click `Generate A · B · C`). `AdminBatches` exposes `Section` + `Term Key` columns plus matching selectors in both Create and Edit dialogs (the section list is fetched per-course and disabled until a course is picked). Smoke-tested live: generate-defaults created A/B/C (idempotent re-run added only the new `D`), section PATCH/DELETE worked, batch was created/updated/validated against course mismatch (`400 course_section_id does not belong to course_id`), and the Edit dialog hydrated `course_section_id` + `term_key` cleanly. Combined with the previous classroom-side cascade, the platform now fully implements the diagram: any classroom can host any sections from any courses, each producing exactly one canonical batch per `(classroom × course × section × term)` with roster + teachers auto-propagated.
|
||||||
> - **2026-04-26 (dynamic course-section classroom structure):** Implemented a dynamic **Course -> Sections -> Classroom hosting** model aligned to the LMS diagrams. Added new backend model `encoach.course.section` (per-course section templates, unique code per course, optional branch) with API CRUD routes in `encoach_lms_api/controllers/lms_core.py`: `GET/POST /api/courses/<course_id>/sections`, `PATCH/DELETE /api/courses/<course_id>/sections/<section_id>`. Extended batches with `course_section_id` + `term_key`, and classroom cascade assignment now supports `section_id`/`term_key` to create or reuse canonical batches per `(classroom × course × section × term)`. Added explicit alias route `POST /api/classrooms/<id>/assign-section` and new `GET /api/classrooms/<id>/sections`. Frontend types/services/pages updated accordingly: course section types, section-aware classroom assignment flow, and batches UI now shows section metadata. Smoke-tested successfully: same classroom received **Section A** and **Section B** of the same course, producing two distinct batches with roster auto-propagation.
|
> - **2026-04-26 (dynamic course-section classroom structure):** Implemented a dynamic **Course -> Sections -> Classroom hosting** model aligned to the LMS diagrams. Added new backend model `encoach.course.section` (per-course section templates, unique code per course, optional branch) with API CRUD routes in `encoach_lms_api/controllers/lms_core.py`: `GET/POST /api/courses/<course_id>/sections`, `PATCH/DELETE /api/courses/<course_id>/sections/<section_id>`. Extended batches with `course_section_id` + `term_key`, and classroom cascade assignment now supports `section_id`/`term_key` to create or reuse canonical batches per `(classroom × course × section × term)`. Added explicit alias route `POST /api/classrooms/<id>/assign-section` and new `GET /api/classrooms/<id>/sections`. Frontend types/services/pages updated accordingly: course section types, section-aware classroom assignment flow, and batches UI now shows section metadata. Smoke-tested successfully: same classroom received **Section A** and **Section B** of the same course, producing two distinct batches with roster auto-propagation.
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegist
|
|||||||
|
|
||||||
// Courseware pages
|
// Courseware pages
|
||||||
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
|
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
|
||||||
|
const TeacherCourseInsights = lazy(() => import("@/pages/teacher/TeacherCourseInsights"));
|
||||||
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
|
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
|
||||||
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
|
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
|
||||||
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
|
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
|
||||||
@@ -182,6 +183,12 @@ const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
|
|||||||
const FaqPage = lazy(() => import("@/pages/FaqPage"));
|
const FaqPage = lazy(() => import("@/pages/FaqPage"));
|
||||||
const NotFound = lazy(() => import("@/pages/NotFound"));
|
const NotFound = lazy(() => import("@/pages/NotFound"));
|
||||||
|
|
||||||
|
// Phase 2/3/4 (2026-04-30) — live sessions, Turnitin, test session console.
|
||||||
|
const LiveSessionsPage = lazy(() => import("@/pages/LiveSessionsPage"));
|
||||||
|
const LiveSessionRoom = lazy(() => import("@/pages/LiveSessionRoom"));
|
||||||
|
const TurnitinSettings = lazy(() => import("@/pages/admin/TurnitinSettings"));
|
||||||
|
const TeacherTestSessionConsole = lazy(() => import("@/pages/teacher/TeacherTestSessionConsole"));
|
||||||
|
|
||||||
function StudentSubscriptionPlaceholder() {
|
function StudentSubscriptionPlaceholder() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
@@ -238,6 +245,8 @@ const App = () => (
|
|||||||
|
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route path="/onboarding" element={<OnboardingWizard />} />
|
<Route path="/onboarding" element={<OnboardingWizard />} />
|
||||||
|
<Route path="/live" element={<LiveSessionsPage />} />
|
||||||
|
<Route path="/live/:id" element={<LiveSessionRoom />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Student routes */}
|
{/* Student routes */}
|
||||||
@@ -297,6 +306,8 @@ const App = () => (
|
|||||||
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
|
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
|
||||||
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
|
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
|
||||||
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
|
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
|
||||||
|
<Route path="/teacher/courses/:courseId/insights" element={<TeacherCourseInsights />} />
|
||||||
|
<Route path="/teacher/exam/:assignmentId/console" element={<TeacherTestSessionConsole />} />
|
||||||
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
||||||
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
||||||
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
||||||
@@ -331,6 +342,7 @@ const App = () => (
|
|||||||
<Route path="/admin/timetable" element={<AdminTimetable />} />
|
<Route path="/admin/timetable" element={<AdminTimetable />} />
|
||||||
<Route path="/admin/reports" element={<AdminReports />} />
|
<Route path="/admin/reports" element={<AdminReports />} />
|
||||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||||
|
<Route path="/admin/turnitin" element={<TurnitinSettings />} />
|
||||||
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
||||||
<Route path="/admin/privacy" element={<PrivacyCenter />} />
|
<Route path="/admin/privacy" element={<PrivacyCenter />} />
|
||||||
{/* Original academic pages */}
|
{/* Original academic pages */}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ const supportItems: NavItem[] = [
|
|||||||
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
|
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
|
||||||
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
|
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
|
||||||
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
|
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
|
||||||
|
{ titleKey: "nav.turnitin", url: "/admin/turnitin", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ============= Reusable sidebar group =============
|
// ============= Reusable sidebar group =============
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { ExternalLink, Download, FileText, Video, Music, Image, Link2 } from "lucide-react";
|
import { ExternalLink, Download, FileText, Video, Music, Image, Link2 } from "lucide-react";
|
||||||
import { API_BASE_URL } from "@/lib/api-client";
|
import { API_BASE_URL } from "@/lib/api-client";
|
||||||
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
|
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
|
||||||
|
import ImageLightbox from "@/components/lesson/ImageLightbox";
|
||||||
|
|
||||||
interface MaterialViewerProps {
|
interface MaterialViewerProps {
|
||||||
material: ChapterMaterial | null;
|
material: ChapterMaterial | null;
|
||||||
@@ -126,10 +127,11 @@ function ImageViewer({ material }: { material: ChapterMaterial }) {
|
|||||||
if (!src) return <EmptyState message="No image available." />;
|
if (!src) return <EmptyState message="No image available." />;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<img
|
<ImageLightbox
|
||||||
src={src}
|
src={src}
|
||||||
alt={material.name}
|
alt={material.name}
|
||||||
className="max-w-full max-h-[70vh] rounded-lg border object-contain"
|
caption={material.description}
|
||||||
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -137,10 +139,13 @@ function ImageViewer({ material }: { material: ChapterMaterial }) {
|
|||||||
|
|
||||||
function ArticleViewer({ material }: { material: ChapterMaterial }) {
|
function ArticleViewer({ material }: { material: ChapterMaterial }) {
|
||||||
if (!material.description) return <EmptyState message="No article content available." />;
|
if (!material.description) return <EmptyState message="No article content available." />;
|
||||||
|
const paragraphs = material.description.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none p-4 rounded-lg border bg-card">
|
<article className="prose prose-zinc dark:prose-invert max-w-none p-5 sm:p-6 rounded-2xl border bg-card shadow-sm leading-7 prose-headings:scroll-mt-20 prose-img:rounded-xl prose-a:text-primary">
|
||||||
<div className="whitespace-pre-wrap">{material.description}</div>
|
{paragraphs.length > 0
|
||||||
</div>
|
? paragraphs.map((p, i) => <p key={i} className="whitespace-pre-wrap">{p}</p>)
|
||||||
|
: <p className="whitespace-pre-wrap">{material.description}</p>}
|
||||||
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ExamPopup from "./student/ExamPopup";
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||||
CalendarCheck, Calendar, User, Target, ListChecks,
|
CalendarCheck, Calendar, User, Target, ListChecks,
|
||||||
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
|
MessageSquare, Megaphone, Mail, TrendingUp, Video,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,10 +16,14 @@ const navGroups: NavGroup[] = [
|
|||||||
labelKey: "sidebarGroup.learning",
|
labelKey: "sidebarGroup.learning",
|
||||||
items: [
|
items: [
|
||||||
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||||
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
|
// Phase 1 (2026-04-29): merged "My Courses" + "My Course
|
||||||
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
|
// Plans" into a single "My Learning" entry. Detail routes
|
||||||
|
// /student/courses/:id and /student/course-plans/:planId still
|
||||||
|
// resolve from the merged page's per-card links.
|
||||||
|
{ titleKey: "nav.myLearning", url: "/student/courses", icon: BookOpen },
|
||||||
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
||||||
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
||||||
|
{ titleKey: "nav.liveSessions", url: "/live", icon: Video },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, ClipboardList,
|
LayoutDashboard, BookOpen, ClipboardList,
|
||||||
CalendarCheck, Users, Calendar, User, MessageSquare,
|
CalendarCheck, Users, Calendar, User, MessageSquare,
|
||||||
Megaphone, Library, Sparkles,
|
Megaphone, Library, Sparkles, Video,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
|
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
|
||||||
@@ -15,6 +15,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{ titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
|
{ titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
|
||||||
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
|
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
|
||||||
{ titleKey: "nav.assignments", url: "/teacher/assignments", icon: ClipboardList },
|
{ titleKey: "nav.assignments", url: "/teacher/assignments", icon: ClipboardList },
|
||||||
|
{ titleKey: "nav.liveSessions", url: "/live", icon: Video },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
114
frontend/src/components/ai/AITutorAvatar.tsx
Normal file
114
frontend/src/components/ai/AITutorAvatar.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface AITutorAvatarProps {
|
||||||
|
/** Whether the avatar is currently speaking. Drives the mouth animation. */
|
||||||
|
speaking?: boolean;
|
||||||
|
/** Optional audio element whose playback drives `speaking` automatically. */
|
||||||
|
audio?: HTMLAudioElement | null;
|
||||||
|
/** Pixel diameter. Defaults to 96px (good for sidebar). */
|
||||||
|
size?: number;
|
||||||
|
/** Display name for the tooltip. */
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight CSS-only "talking head" avatar. We avoid a 3D engine on
|
||||||
|
* purpose — most students open the platform on modest hardware and we
|
||||||
|
* already ship a TTS pipeline. The avatar mouth animates while
|
||||||
|
* `speaking` is true OR while the bound `audio` element is playing.
|
||||||
|
*
|
||||||
|
* Idle state: the head bobs gently and blinks every 4s.
|
||||||
|
* Speaking state: the mouth opens/closes at ~5Hz with random amplitude.
|
||||||
|
*/
|
||||||
|
export default function AITutorAvatar({
|
||||||
|
speaking = false,
|
||||||
|
audio,
|
||||||
|
size = 96,
|
||||||
|
name = "AI Tutor",
|
||||||
|
}: AITutorAvatarProps) {
|
||||||
|
const [audioActive, setAudioActive] = useState(false);
|
||||||
|
const blinkRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [blink, setBlink] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audio) return;
|
||||||
|
const onPlay = () => setAudioActive(true);
|
||||||
|
const onStop = () => setAudioActive(false);
|
||||||
|
audio.addEventListener("play", onPlay);
|
||||||
|
audio.addEventListener("playing", onPlay);
|
||||||
|
audio.addEventListener("pause", onStop);
|
||||||
|
audio.addEventListener("ended", onStop);
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener("play", onPlay);
|
||||||
|
audio.removeEventListener("playing", onPlay);
|
||||||
|
audio.removeEventListener("pause", onStop);
|
||||||
|
audio.removeEventListener("ended", onStop);
|
||||||
|
};
|
||||||
|
}, [audio]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = () => {
|
||||||
|
setBlink(true);
|
||||||
|
window.setTimeout(() => setBlink(false), 140);
|
||||||
|
};
|
||||||
|
blinkRef.current = setInterval(tick, 3500 + Math.random() * 1500);
|
||||||
|
return () => {
|
||||||
|
if (blinkRef.current) clearInterval(blinkRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isSpeaking = speaking || audioActive;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="ai-tutor-avatar relative inline-flex items-center justify-center"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
aria-label={name}
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
@keyframes ai-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
||||||
|
@keyframes ai-mouth {
|
||||||
|
0% { transform: scaleY(0.2); }
|
||||||
|
50% { transform: scaleY(1); }
|
||||||
|
100% { transform: scaleY(0.4); }
|
||||||
|
}
|
||||||
|
@keyframes ai-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55); }
|
||||||
|
50% { box-shadow: 0 0 20px 6px rgba(245, 158, 11, 0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div
|
||||||
|
className="rounded-full bg-gradient-to-br from-orange-200 to-amber-100 dark:from-orange-900/30 dark:to-amber-900/20 border border-amber-300/60 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: size, height: size,
|
||||||
|
animation: `ai-bob 4s ease-in-out infinite${isSpeaking ? ", ai-glow 1.8s ease-out infinite" : ""}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 100 100" width={size * 0.7} height={size * 0.7} aria-hidden="true">
|
||||||
|
<ellipse cx="35" cy="42" rx="5" ry={blink ? 0.5 : 5} fill="#1f2937" />
|
||||||
|
<ellipse cx="65" cy="42" rx="5" ry={blink ? 0.5 : 5} fill="#1f2937" />
|
||||||
|
<circle cx="33" cy="40" r="1.5" fill="#fff" />
|
||||||
|
<circle cx="63" cy="40" r="1.5" fill="#fff" />
|
||||||
|
<ellipse
|
||||||
|
cx="50" cy="64"
|
||||||
|
rx="13"
|
||||||
|
ry={isSpeaking ? 7 : 2}
|
||||||
|
fill="#dc2626"
|
||||||
|
style={{
|
||||||
|
transformOrigin: "50px 64px",
|
||||||
|
animation: isSpeaking ? "ai-mouth 220ms ease-in-out infinite" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<circle cx="22" cy="60" r="3" fill="#fbbf24" opacity="0.5" />
|
||||||
|
<circle cx="78" cy="60" r="3" fill="#fbbf24" opacity="0.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{isSpeaking && (
|
||||||
|
<span className="absolute -bottom-1 inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full bg-orange-500 text-[10px] font-semibold text-white">
|
||||||
|
speaking
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { cn } from "@/lib/utils";
|
|||||||
import type { CoursePlanMaterial, WorkbookBody } from "@/types";
|
import type { CoursePlanMaterial, WorkbookBody } from "@/types";
|
||||||
|
|
||||||
import InteractiveWorkbook, { type WorkbookMode } from "./InteractiveWorkbook";
|
import InteractiveWorkbook, { type WorkbookMode } from "./InteractiveWorkbook";
|
||||||
|
import ImageLightbox from "@/components/lesson/ImageLightbox";
|
||||||
|
import InfographicBlock, { inferTone } from "@/components/lesson/InfographicBlock";
|
||||||
|
|
||||||
export const SKILL_STYLE: Record<string, string> = {
|
export const SKILL_STYLE: Record<string, string> = {
|
||||||
reading: "bg-blue-100 text-blue-800 border-blue-200",
|
reading: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
@@ -25,15 +27,68 @@ export function SkillBadge({ skill }: { skill: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IMAGE_KEYS = new Set([
|
||||||
|
"image", "image_url", "imageurl", "img", "illustration",
|
||||||
|
"hero_image", "hero", "picture", "thumbnail",
|
||||||
|
]);
|
||||||
|
const IMAGES_KEYS = new Set([
|
||||||
|
"images", "illustrations", "pictures", "gallery",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isImageUrl(v: unknown): v is string {
|
||||||
|
if (typeof v !== "string") return false;
|
||||||
|
if (/^data:image\//.test(v)) return true;
|
||||||
|
if (/^https?:\/\//i.test(v) && /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(v)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImage(v: unknown, alt?: string): React.ReactNode {
|
||||||
|
if (typeof v === "string" && isImageUrl(v)) {
|
||||||
|
return <ImageLightbox src={v} alt={alt} />;
|
||||||
|
}
|
||||||
|
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||||
|
const obj = v as Record<string, unknown>;
|
||||||
|
const src = (obj.url || obj.src || obj.image_url || obj.image) as string | undefined;
|
||||||
|
const cap = (obj.caption || obj.description || obj.alt) as string | undefined;
|
||||||
|
if (typeof src === "string" && isImageUrl(src)) {
|
||||||
|
return <ImageLightbox src={src} alt={alt || cap} caption={cap} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyParagraph(s: string): boolean {
|
||||||
|
return s.length > 80 || /[.!?]\s/.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderString(value: string): React.ReactNode {
|
||||||
|
if (isImageUrl(value)) {
|
||||||
|
return <ImageLightbox src={value} />;
|
||||||
|
}
|
||||||
|
if (!isLikelyParagraph(value)) {
|
||||||
|
return <p className="leading-7">{value}</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 leading-7">
|
||||||
|
{value.split(/\n{2,}/).map((para, i) => (
|
||||||
|
<p key={i} className="whitespace-pre-wrap">{para.trim()}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderAny(value: unknown): React.ReactNode {
|
function renderAny(value: unknown): React.ReactNode {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
if (typeof value === "string") {
|
||||||
|
return renderString(value);
|
||||||
|
}
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return <p className="leading-7">{String(value)}</p>;
|
return <p className="leading-7">{String(value)}</p>;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
if (value.length === 0) return null;
|
if (value.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<ul className="list-disc ps-5 space-y-1">
|
<ul className="list-disc ps-5 space-y-1.5 leading-7">
|
||||||
{value.map((item, idx) => (
|
{value.map((item, idx) => (
|
||||||
<li key={idx}>{renderAny(item)}</li>
|
<li key={idx}>{renderAny(item)}</li>
|
||||||
))}
|
))}
|
||||||
@@ -43,21 +98,78 @@ function renderAny(value: unknown): React.ReactNode {
|
|||||||
if (typeof value === "object") {
|
if (typeof value === "object") {
|
||||||
const entries = Object.entries(value as Record<string, unknown>);
|
const entries = Object.entries(value as Record<string, unknown>);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{entries.map(([k, v]) => (
|
{entries.map(([k, v]) => renderEntry(k, v))}
|
||||||
<div key={k}>
|
|
||||||
<h5 className="font-medium capitalize text-sm text-muted-foreground mb-1">
|
|
||||||
{k.replace(/_/g, " ")}
|
|
||||||
</h5>
|
|
||||||
<div className="text-sm">{renderAny(v)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderEntry(key: string, value: unknown): React.ReactNode {
|
||||||
|
const norm = key.toLowerCase().replace(/[\s-]/g, "_");
|
||||||
|
const tone = inferTone(key);
|
||||||
|
const label = key.replace(/_/g, " ");
|
||||||
|
|
||||||
|
if (IMAGE_KEYS.has(norm)) {
|
||||||
|
const node = renderImage(value, label);
|
||||||
|
if (node) return <div key={key}>{node}</div>;
|
||||||
|
}
|
||||||
|
if (IMAGES_KEYS.has(norm) && Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<div key={key} className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{value.map((item, i) => {
|
||||||
|
const node = renderImage(item, `${label} ${i + 1}`);
|
||||||
|
return node
|
||||||
|
? <div key={i}>{node}</div>
|
||||||
|
: <div key={i} className="text-sm text-muted-foreground">{renderAny(item)}</div>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const items = value.map((v) =>
|
||||||
|
typeof v === "string" ? v
|
||||||
|
: (v && typeof v === "object" && "label" in (v as object) && "value" in (v as object))
|
||||||
|
? v as { label?: string; value?: string }
|
||||||
|
: { value: typeof v === "string" ? v : JSON.stringify(v) },
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<InfographicBlock
|
||||||
|
key={key}
|
||||||
|
title={titleCase(label)}
|
||||||
|
tone={tone}
|
||||||
|
items={items}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<InfographicBlock
|
||||||
|
key={key}
|
||||||
|
title={titleCase(label)}
|
||||||
|
tone={tone}
|
||||||
|
>
|
||||||
|
{renderAny(value)}
|
||||||
|
</InfographicBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<h5 className="font-semibold capitalize text-sm text-foreground/80 mb-1.5">
|
||||||
|
{label}
|
||||||
|
</h5>
|
||||||
|
<div className="text-[0.95rem]">{renderAny(value)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(s: string): string {
|
||||||
|
return s.replace(/\w\S*/g, (w) => w[0].toUpperCase() + w.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
type MaterialForBook = Pick<
|
type MaterialForBook = Pick<
|
||||||
CoursePlanMaterial,
|
CoursePlanMaterial,
|
||||||
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
|
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
|
||||||
@@ -108,18 +220,23 @@ export default function MaterialBookView({
|
|||||||
typeof material.plan_id === "number";
|
typeof material.plan_id === "number";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
|
<div className="rounded-2xl border bg-card p-5 sm:p-6 space-y-5 shadow-sm leading-relaxed">
|
||||||
{hasStructured ? (
|
{hasStructured ? (
|
||||||
renderAny(stripEmbedded(body))
|
renderAny(stripEmbedded(body))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm whitespace-pre-wrap leading-7">
|
<div className="space-y-3 text-[0.95rem] whitespace-pre-wrap leading-7">
|
||||||
{material.body_text || "No content available yet."}
|
{(material.body_text || "No content available yet.")
|
||||||
</p>
|
.split(/\n{2,}/)
|
||||||
|
.map((p, i) => <p key={i}>{p}</p>)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasEmbedded && (
|
{hasEmbedded && (
|
||||||
<div className="border-t pt-3">
|
<div className="border-t pt-4">
|
||||||
<div className="text-sm font-medium mb-2">Practice</div>
|
<div className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
|
Practice
|
||||||
|
</div>
|
||||||
<InteractiveWorkbook
|
<InteractiveWorkbook
|
||||||
material={material as MaterialForBook}
|
material={material as MaterialForBook}
|
||||||
bodyOverride={embeddedWorkbook!}
|
bodyOverride={embeddedWorkbook!}
|
||||||
|
|||||||
293
frontend/src/components/discussion/DiscussionTab.tsx
Normal file
293
frontend/src/components/discussion/DiscussionTab.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
MessageSquare,
|
||||||
|
Pin,
|
||||||
|
Send,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { communicationService } from "@/services/communication.service";
|
||||||
|
import type { DiscussionPost } from "@/types/communication";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1 (2026-04-29) — embeddable discussion thread.
|
||||||
|
*
|
||||||
|
* Used as a tab inside `StudentCourseDetail`, `StudentCoursePlanDetail`,
|
||||||
|
* and the equivalent teacher pages. The component takes either a
|
||||||
|
* `courseId` or a `planId`, calls the lookup-or-create endpoint to
|
||||||
|
* resolve a `DiscussionBoard`, then renders the existing
|
||||||
|
* board → posts → replies tree.
|
||||||
|
*
|
||||||
|
* Why a wrapper (instead of reusing `StudentDiscussionBoard`):
|
||||||
|
* - Removes the board picker (we already know the scope).
|
||||||
|
* - Lazy-creates the board the first time someone opens the tab,
|
||||||
|
* so admins don't have to seed boards for every course.
|
||||||
|
* - Lives in `components/discussion/` so both student and teacher
|
||||||
|
* pages can mount it.
|
||||||
|
*/
|
||||||
|
export interface DiscussionTabProps {
|
||||||
|
courseId?: number;
|
||||||
|
planId?: number;
|
||||||
|
/** Render the "new post" composer above the list. Default true. */
|
||||||
|
allowComposer?: boolean;
|
||||||
|
/** Hide the section header (useful when the parent already shows one). */
|
||||||
|
hideHeader?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiscussionTab({
|
||||||
|
courseId,
|
||||||
|
planId,
|
||||||
|
allowComposer = true,
|
||||||
|
hideHeader = false,
|
||||||
|
}: DiscussionTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const scopeKey = courseId ? `course-${courseId}` : `plan-${planId}`;
|
||||||
|
|
||||||
|
const boardQuery = useQuery({
|
||||||
|
queryKey: ["discussion", "board", scopeKey],
|
||||||
|
enabled: !!(courseId || planId),
|
||||||
|
queryFn: () =>
|
||||||
|
courseId
|
||||||
|
? communicationService.getBoardForCourse(courseId)
|
||||||
|
: communicationService.getBoardForPlan(planId!),
|
||||||
|
});
|
||||||
|
const board = boardQuery.data;
|
||||||
|
|
||||||
|
const postsQuery = useQuery({
|
||||||
|
queryKey: ["discussion", "posts", board?.id],
|
||||||
|
enabled: !!board?.id,
|
||||||
|
queryFn: () => communicationService.listPosts(board!.id, { page: 1, size: 50 }),
|
||||||
|
});
|
||||||
|
const posts: DiscussionPost[] = postsQuery.data?.items ?? [];
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!board || !content.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await communicationService.createPost(board.id, {
|
||||||
|
board_id: board.id,
|
||||||
|
title: title.trim() || undefined,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
qc.invalidateQueries({ queryKey: ["discussion", "posts", board.id] });
|
||||||
|
toast({
|
||||||
|
title: t("discussion.posted", "Posted"),
|
||||||
|
description: t(
|
||||||
|
"discussion.postedHint",
|
||||||
|
"Your message is visible to everyone in this course.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t("common.error", "Error"),
|
||||||
|
description: t("discussion.postFailed", "Failed to post."),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (boardQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boardQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{t("discussion.loadFailed", "Failed to load discussion.")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!hideHeader && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-4 w-4 text-primary" />
|
||||||
|
<h3 className="font-semibold text-sm">
|
||||||
|
{t("discussion.title", "Discussion")}
|
||||||
|
</h3>
|
||||||
|
{board?.post_count !== undefined && (
|
||||||
|
<Badge variant="secondary">{board.post_count}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allowComposer && board?.allow_student_posts !== false && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 space-y-2">
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={t("discussion.titlePlaceholder", "Title (optional)")}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"discussion.contentPlaceholder",
|
||||||
|
"Ask a question or share something with your classmates...",
|
||||||
|
)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={submitting || !content.trim()}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="h-4 w-4 mr-2" />
|
||||||
|
{t("discussion.post", "Post")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{postsQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : posts.length === 0 ? (
|
||||||
|
<div className="text-center py-10 border rounded-2xl bg-muted/20">
|
||||||
|
<MessageSquare className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"discussion.empty",
|
||||||
|
"No posts yet. Be the first to start the discussion!",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{posts.map((p) => (
|
||||||
|
<PostCard key={p.id} post={p} boardId={board!.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostCard({ post, boardId }: { post: DiscussionPost; boardId: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showReply, setShowReply] = useState(false);
|
||||||
|
const [reply, setReply] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const submitReply = async () => {
|
||||||
|
if (!reply.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await communicationService.createPost(boardId, {
|
||||||
|
board_id: boardId,
|
||||||
|
parent_id: post.id,
|
||||||
|
content: reply,
|
||||||
|
});
|
||||||
|
setReply("");
|
||||||
|
setShowReply(false);
|
||||||
|
qc.invalidateQueries({ queryKey: ["discussion", "posts", boardId] });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded-2xl border bg-card ${
|
||||||
|
post.is_pinned ? "border-primary/30 bg-primary/5" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{post.title && <p className="font-medium">{post.title}</p>}
|
||||||
|
<p className="text-sm mt-1 whitespace-pre-wrap">{post.content}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground flex-wrap">
|
||||||
|
<span className="font-medium">{post.author_name}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{new Date(post.created_at).toLocaleString()}</span>
|
||||||
|
{post.is_pinned && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<Pin className="h-3 w-3 mr-1" />
|
||||||
|
{t("discussion.pinned", "Pinned")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{post.is_resolved && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||||
|
{t("discussion.resolved", "Resolved")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowReply((s) => !s)}
|
||||||
|
>
|
||||||
|
{t("discussion.reply", "Reply")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{showReply && (
|
||||||
|
<div className="flex gap-2 mt-3 pt-3 border-t">
|
||||||
|
<Input
|
||||||
|
value={reply}
|
||||||
|
onChange={(e) => setReply(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"discussion.replyPlaceholder",
|
||||||
|
"Write a reply...",
|
||||||
|
)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={submitReply}
|
||||||
|
disabled={submitting || !reply.trim()}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{post.replies && post.replies.length > 0 && (
|
||||||
|
<div className="ml-6 space-y-2 border-l-2 pl-3 border-muted">
|
||||||
|
{post.replies.map((r) => (
|
||||||
|
<PostCard key={r.id} post={r} boardId={boardId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/src/components/lesson/ImageLightbox.tsx
Normal file
62
frontend/src/components/lesson/ImageLightbox.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { Maximize2 } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click-to-zoom image used inside lesson bodies.
|
||||||
|
*
|
||||||
|
* The lesson viewer historically rendered raw <img> tags from the AI body
|
||||||
|
* which made any illustration tiny and uncroppable on mobile. Wrapping
|
||||||
|
* those images in a lightbox gives students a one-click full-size view
|
||||||
|
* (matching the "Course content polish" requirement) without changing
|
||||||
|
* the underlying body shape coming from the AI pipeline.
|
||||||
|
*/
|
||||||
|
export default function ImageLightbox({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
caption,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
caption?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<figure
|
||||||
|
className={`group relative inline-block max-w-full cursor-zoom-in rounded-xl overflow-hidden border bg-muted/30 shadow-sm hover:shadow-md transition ${className ?? ""}`}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt || ""}
|
||||||
|
className="block max-h-[420px] w-auto object-contain mx-auto"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span className="absolute top-2 end-2 bg-black/60 text-white rounded-md p-1 opacity-0 group-hover:opacity-100 transition">
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
{caption && (
|
||||||
|
<figcaption className="text-xs text-muted-foreground p-2 border-t bg-background/70">
|
||||||
|
{caption}
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-5xl p-0 bg-black border-none">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt || ""}
|
||||||
|
className="w-full max-h-[85vh] object-contain bg-black"
|
||||||
|
/>
|
||||||
|
{caption && (
|
||||||
|
<div className="text-xs text-white/80 p-3 bg-black/80">{caption}</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
frontend/src/components/lesson/InfographicBlock.tsx
Normal file
105
frontend/src/components/lesson/InfographicBlock.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
Lightbulb,
|
||||||
|
Target,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
Star,
|
||||||
|
Sparkles,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
type Tone = "tip" | "info" | "warn" | "success" | "highlight" | "example";
|
||||||
|
|
||||||
|
const TONE_STYLE: Record<Tone, { wrapper: string; icon: LucideIcon; iconClass: string; title: string }> = {
|
||||||
|
tip: { wrapper: "border-amber-200 bg-amber-50", icon: Lightbulb, iconClass: "text-amber-600", title: "text-amber-900" },
|
||||||
|
info: { wrapper: "border-blue-200 bg-blue-50", icon: Info, iconClass: "text-blue-600", title: "text-blue-900" },
|
||||||
|
warn: { wrapper: "border-rose-200 bg-rose-50", icon: AlertTriangle, iconClass: "text-rose-600", title: "text-rose-900" },
|
||||||
|
success: { wrapper: "border-emerald-200 bg-emerald-50", icon: CheckCircle2, iconClass: "text-emerald-600", title: "text-emerald-900" },
|
||||||
|
highlight: { wrapper: "border-purple-200 bg-purple-50", icon: Sparkles, iconClass: "text-purple-600", title: "text-purple-900" },
|
||||||
|
example: { wrapper: "border-slate-200 bg-slate-50", icon: Star, iconClass: "text-slate-600", title: "text-slate-900" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual callout block used inside lessons. The AI course generator now
|
||||||
|
* emits keys like `did_you_know`, `key_takeaway`, `tip`, `warning`,
|
||||||
|
* `objective`, `example`, etc. Instead of dumping them as `<h5>label</h5>
|
||||||
|
* <p>text</p>` we render a coloured card with an icon — that's the
|
||||||
|
* "infographic" treatment the design refresh asked for.
|
||||||
|
*/
|
||||||
|
export default function InfographicBlock({
|
||||||
|
title,
|
||||||
|
tone = "info",
|
||||||
|
children,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
tone?: Tone;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
items?: (string | { label?: string; value?: string })[];
|
||||||
|
}) {
|
||||||
|
const style = TONE_STYLE[tone];
|
||||||
|
const Icon = style.icon;
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border ${style.wrapper} p-4 shadow-sm space-y-2`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full bg-white/80 p-1.5 shadow-sm">
|
||||||
|
<Icon className={`h-4 w-4 ${style.iconClass}`} />
|
||||||
|
</span>
|
||||||
|
<div className={`text-sm font-semibold ${style.title}`}>{title}</div>
|
||||||
|
</div>
|
||||||
|
{children && <div className="text-sm leading-7 text-foreground/90">{children}</div>}
|
||||||
|
{items && items.length > 0 && (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{items.map((it, i) => {
|
||||||
|
if (typeof it === "string") {
|
||||||
|
return (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<Target className="h-3.5 w-3.5 mt-1 text-muted-foreground shrink-0" />
|
||||||
|
<span>{it}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<Target className="h-3.5 w-3.5 mt-1 text-muted-foreground shrink-0" />
|
||||||
|
<span>
|
||||||
|
{it.label && <strong className="me-1">{it.label}:</strong>}
|
||||||
|
{it.value}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_BY_KEY: Record<string, Tone> = {
|
||||||
|
tip: "tip",
|
||||||
|
hint: "tip",
|
||||||
|
did_you_know: "tip",
|
||||||
|
fun_fact: "tip",
|
||||||
|
key_takeaway: "highlight",
|
||||||
|
takeaway: "highlight",
|
||||||
|
summary: "highlight",
|
||||||
|
highlight: "highlight",
|
||||||
|
objective: "info",
|
||||||
|
objectives: "info",
|
||||||
|
goal: "info",
|
||||||
|
goals: "info",
|
||||||
|
warning: "warn",
|
||||||
|
pitfall: "warn",
|
||||||
|
caution: "warn",
|
||||||
|
common_mistakes: "warn",
|
||||||
|
success_criteria: "success",
|
||||||
|
remember: "success",
|
||||||
|
example: "example",
|
||||||
|
examples: "example",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function inferTone(key: string): Tone | null {
|
||||||
|
const norm = key.toLowerCase().replace(/[\s-]/g, "_");
|
||||||
|
return TONE_BY_KEY[norm] ?? null;
|
||||||
|
}
|
||||||
154
frontend/src/components/student/HandwrittenSubmissionPanel.tsx
Normal file
154
frontend/src/components/student/HandwrittenSubmissionPanel.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { PenTool, Upload, FileText, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
import { handwrittenService } from "@/services/handwritten.service";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** ID of the encoach.course.plan.material the submission belongs to. */
|
||||||
|
materialId: number;
|
||||||
|
/** Optional title shown in the card header. */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel embedded in a student's material/assignment view that lets them
|
||||||
|
* upload up to 10 photographs of their handwritten solution and instantly
|
||||||
|
* see the AI grading feedback. Best suited for math/science tasks.
|
||||||
|
*/
|
||||||
|
export default function HandwrittenSubmissionPanel({ materialId, title = "Upload handwritten solution" }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const { data: latest, isLoading } = useQuery({
|
||||||
|
queryKey: ["handwritten", materialId],
|
||||||
|
queryFn: () => handwrittenService.mySubmission(materialId),
|
||||||
|
enabled: !!materialId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = useMutation({
|
||||||
|
mutationFn: () => handwrittenService.upload(materialId, files, note),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Submission uploaded", description: "AI grading is in progress." });
|
||||||
|
setFiles([]);
|
||||||
|
setNote("");
|
||||||
|
qc.invalidateQueries({ queryKey: ["handwritten", materialId] });
|
||||||
|
},
|
||||||
|
onError: (e) => toast({
|
||||||
|
title: "Upload failed",
|
||||||
|
description: e instanceof Error ? e.message : "Try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<PenTool className="h-4 w-4 text-primary" />
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{latest && (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Last submission
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="capitalize">{latest.status}</Badge>
|
||||||
|
</div>
|
||||||
|
{latest.ai_score != null && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
<span>AI score: <b>{latest.ai_score.toFixed(1)}</b> / 100</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latest.teacher_score != null && (
|
||||||
|
<div className="flex items-center gap-2 text-primary">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
<span>Teacher score: <b>{latest.teacher_score.toFixed(1)}</b> / 100</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latest.ai_feedback && (
|
||||||
|
<div className="text-xs whitespace-pre-wrap text-muted-foreground">{latest.ai_feedback}</div>
|
||||||
|
)}
|
||||||
|
{latest.image_urls.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||||
|
{latest.image_urls.map((u) => (
|
||||||
|
<a key={u} href={u} target="_blank" rel="noreferrer" className="block">
|
||||||
|
<img src={u} alt="page" className="rounded border w-full h-20 object-cover" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => setFiles(Array.from(e.target.files || []).slice(0, 10))}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => inputRef.current?.click()}>
|
||||||
|
<Upload className="h-4 w-4 me-1" />
|
||||||
|
Choose pages ({files.length})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<div key={i} className="relative">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(f)}
|
||||||
|
alt={f.name}
|
||||||
|
className="rounded border w-full h-20 object-cover"
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-x-0 bottom-0 bg-black/50 text-white text-[10px] px-1 truncate">
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Optional note for the grader (e.g. 'Question 3, side B')"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
Up to 10 images, JPEG/PNG. The AI grader uses OCR + math-aware scoring.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => upload.mutate()}
|
||||||
|
disabled={files.length === 0 || upload.isPending}
|
||||||
|
>
|
||||||
|
{upload.isPending ? "Uploading…" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
frontend/src/components/student/PersonalizedPlanCard.tsx
Normal file
296
frontend/src/components/student/PersonalizedPlanCard.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Sparkles, Wand2, ChevronRight, SlidersHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import { personalizedPlanService } from "@/services/personalizedPlan.service";
|
||||||
|
|
||||||
|
const SKILLS = ["reading", "listening", "writing", "speaking"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard widget that surfaces the student's weakest skills and lets them
|
||||||
|
* generate (or re-open) a personalized AI training plan in one click.
|
||||||
|
*
|
||||||
|
* The widget always renders the weakness summary so students see what their
|
||||||
|
* weakest skill is even before they ever generate a plan. Once generated, a
|
||||||
|
* deep link to the new plan replaces the Generate button.
|
||||||
|
*/
|
||||||
|
export default function PersonalizedPlanCard() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["personalized-plan"],
|
||||||
|
queryFn: () => personalizedPlanService.fetch(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generate = useMutation({
|
||||||
|
mutationFn: () => personalizedPlanService.generate({ total_weeks: 4 }),
|
||||||
|
onMutate: () => setPending(true),
|
||||||
|
onSettled: () => setPending(false),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast({
|
||||||
|
title: "Personalized plan ready",
|
||||||
|
description: res.plan?.name || "A tailored 4-week plan has been generated.",
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ["personalized-plan"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["student-learning"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["student-course-plans"] });
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
toast({
|
||||||
|
title: "Could not generate plan",
|
||||||
|
description: e instanceof Error ? e.message : "Try again later.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-lg">Personalized AI plan</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-9 w-32" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weakness = data?.weakness;
|
||||||
|
const plan = data?.plan;
|
||||||
|
|
||||||
|
const skillRows = SKILLS.map((s) => {
|
||||||
|
const v = weakness?.averages?.[s];
|
||||||
|
const pct = v == null ? 0 : Math.min(100, Math.round((v / 9) * 100));
|
||||||
|
return { skill: s, value: v, pct };
|
||||||
|
});
|
||||||
|
const weakest = weakness?.weakest_skill;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 via-transparent to-transparent">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
|
Personalized AI plan
|
||||||
|
</CardTitle>
|
||||||
|
{plan ? (
|
||||||
|
<Badge className="bg-primary/15 text-primary border-primary/30">Ready</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Suggested</Badge>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{weakness && weakness.attempts > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Based on <b>{weakness.attempts}</b> attempt
|
||||||
|
{weakness.attempts === 1 ? "" : "s"}, your weakest skill is{" "}
|
||||||
|
<b className="capitalize text-foreground">{weakest || "n/a"}</b>.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{skillRows.map((r) => (
|
||||||
|
<div key={r.skill} className="flex items-center gap-3">
|
||||||
|
<span className="w-20 text-xs capitalize text-muted-foreground">{r.skill}</span>
|
||||||
|
<Progress value={r.pct} className={`h-2 flex-1 ${weakest === r.skill ? "[&>div]:bg-orange-500" : ""}`} />
|
||||||
|
<span className="text-xs font-mono w-10 text-end">
|
||||||
|
{r.value == null ? "–" : r.value.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Take a graded exam first, then come back here for a tailored plan.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{plan ? (
|
||||||
|
<>
|
||||||
|
<Button asChild size="sm" className="gap-2">
|
||||||
|
<Link to={`/student/course-plans/${plan.id}`}>
|
||||||
|
Open plan <ChevronRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => generate.mutate()}
|
||||||
|
disabled={pending || generate.isPending}
|
||||||
|
>
|
||||||
|
<Wand2 className="h-4 w-4 me-1" />
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => generate.mutate()}
|
||||||
|
disabled={pending || generate.isPending || !weakness?.attempts}
|
||||||
|
>
|
||||||
|
<Wand2 className="h-4 w-4" />
|
||||||
|
{generate.isPending ? "Generating…" : "Quick generate"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<CustomizePlanDialog
|
||||||
|
disabled={pending || generate.isPending || !weakness?.attempts}
|
||||||
|
onGenerated={() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["personalized-plan"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["student-learning"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["student-course-plans"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRAMMAR_OPTIONS = [
|
||||||
|
"Tenses",
|
||||||
|
"Conditionals",
|
||||||
|
"Articles",
|
||||||
|
"Prepositions",
|
||||||
|
"Modals",
|
||||||
|
"Passive voice",
|
||||||
|
"Reported speech",
|
||||||
|
"Phrasal verbs",
|
||||||
|
"Subject-verb agreement",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Customize my AI plan" dialog. Lets the student pick the CEFR target,
|
||||||
|
* number of weeks, a free-form focus statement, and grammar checkboxes.
|
||||||
|
* Backend already accepts all of these via POST /api/student/personalized-plan.
|
||||||
|
*/
|
||||||
|
function CustomizePlanDialog({
|
||||||
|
disabled, onGenerated,
|
||||||
|
}: { disabled: boolean; onGenerated: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [cefr, setCefr] = useState("a2");
|
||||||
|
const [weeks, setWeeks] = useState(4);
|
||||||
|
const [focus, setFocus] = useState("");
|
||||||
|
const [grammar, setGrammar] = useState<string[]>([]);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const m = useMutation({
|
||||||
|
mutationFn: () => personalizedPlanService.generate({
|
||||||
|
cefr_level: cefr,
|
||||||
|
total_weeks: weeks,
|
||||||
|
focus: focus.trim() || undefined,
|
||||||
|
grammar_focus: grammar.length ? grammar : undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast({
|
||||||
|
title: "Custom plan ready",
|
||||||
|
description: res.plan?.name || "Your tailored plan was generated.",
|
||||||
|
});
|
||||||
|
onGenerated();
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: (e) => toast({
|
||||||
|
title: "Could not generate plan",
|
||||||
|
description: e instanceof Error ? e.message : "Try again later.",
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleGrammar = (g: string) => {
|
||||||
|
setGrammar((prev) =>
|
||||||
|
prev.includes(g) ? prev.filter((x) => x !== g) : [...prev, g]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="ghost" disabled={disabled}>
|
||||||
|
<SlidersHorizontal className="h-4 w-4 me-1" />Customize
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Customize my AI plan</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>CEFR level</Label>
|
||||||
|
<Select value={cefr} onValueChange={setCefr}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["a1", "a2", "b1", "b2", "c1", "c2"].map((l) => (
|
||||||
|
<SelectItem key={l} value={l}>{l.toUpperCase()}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Weeks</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={12}
|
||||||
|
value={weeks}
|
||||||
|
onChange={(e) => setWeeks(Math.max(1, Math.min(12, Number(e.target.value) || 1)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Focus (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={focus}
|
||||||
|
onChange={(e) => setFocus(e.target.value)}
|
||||||
|
placeholder="e.g. Improve my IELTS Writing Task 2 essays"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Grammar focus</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-1.5">
|
||||||
|
{GRAMMAR_OPTIONS.map((g) => (
|
||||||
|
<label
|
||||||
|
key={g}
|
||||||
|
className="flex items-center gap-2 text-sm cursor-pointer p-1.5 rounded hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={grammar.includes(g)}
|
||||||
|
onCheckedChange={() => toggleGrammar(g)}
|
||||||
|
/>
|
||||||
|
{g}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={() => m.mutate()} disabled={m.isPending}>
|
||||||
|
<Wand2 className="h-4 w-4 me-1" />
|
||||||
|
{m.isPending ? "Generating…" : "Generate"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,12 +4,22 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// "Mixed" button language for the 2026-04-29 visual-identity refresh:
|
||||||
|
// - `default` → dark charcoal CTA (`--cta`). The strongest action on
|
||||||
|
// the page. Existing `<Button>` callsites pick this up
|
||||||
|
// automatically with no source change.
|
||||||
|
// - `primary` → brand orange (`--primary`). Used to highlight a
|
||||||
|
// promotional / accent action ("Generate", "Continue").
|
||||||
|
// - `outline` → orange-tinted border + peachy hover, reads as
|
||||||
|
// "secondary CTA" without competing visually with the
|
||||||
|
// dark default.
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-cta text-cta-foreground hover:bg-cta/90",
|
||||||
|
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
@@ -18,8 +28,8 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: "h-9 px-3",
|
||||||
lg: "h-11 rounded-md px-8",
|
lg: "h-11 px-8",
|
||||||
icon: "h-10 w-10",
|
icon: "h-10 w-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,19 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// 2026-04-29 visual-identity refresh: cards are now `rounded-2xl`
|
||||||
|
// (~28px) with a soft shadow and no hard border by default — matching
|
||||||
|
// the reference's pillowy card language. Pages that *want* a border
|
||||||
|
// can still pass `border` via `className` and it will compose.
|
||||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl bg-card text-card-foreground shadow-[0_2px_8px_-2px_hsl(220_25%_12%_/_0.06),0_8px_32px_-12px_hsl(220_25%_12%_/_0.08)] dark:shadow-[0_2px_8px_-2px_hsl(0_0%_0%_/_0.4),0_8px_32px_-12px_hsl(0_0%_0%_/_0.5)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
Card.displayName = "Card";
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
|||||||
113
frontend/src/hooks/useExamSecurity.ts
Normal file
113
frontend/src/hooks/useExamSecurity.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
examSecurityService,
|
||||||
|
type SecurityEventType,
|
||||||
|
type SecuritySeverity,
|
||||||
|
} from "@/services/examSecurity.service";
|
||||||
|
|
||||||
|
interface UseExamSecurityOpts {
|
||||||
|
attemptId?: number | null;
|
||||||
|
examId?: number | null;
|
||||||
|
/** Fail-soft default true — set false to disable all listeners. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Called when the backend returns auto_locked=true so the runner can show a lock screen. */
|
||||||
|
onAutoLock?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that wires the SOFT online-test security tier into any exam runner
|
||||||
|
* page. It registers DOM event listeners for tab/window focus, paste,
|
||||||
|
* copy, cut, contextmenu, fullscreen exit, and a few keyboard shortcuts,
|
||||||
|
* then POSTs each event to /api/exam/security/event with the right
|
||||||
|
* severity. The backend tracks the log per attempt; the teacher console
|
||||||
|
* surfaces it later.
|
||||||
|
*
|
||||||
|
* Anti-cheat is intentionally non-blocking — we never break the page,
|
||||||
|
* we just record what happened so the teacher can decide.
|
||||||
|
*/
|
||||||
|
export function useExamSecurity({
|
||||||
|
attemptId,
|
||||||
|
examId,
|
||||||
|
enabled = true,
|
||||||
|
onAutoLock,
|
||||||
|
}: UseExamSecurityOpts) {
|
||||||
|
const lastSent = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !attemptId) return;
|
||||||
|
|
||||||
|
const post = async (
|
||||||
|
type: SecurityEventType,
|
||||||
|
severity: SecuritySeverity = "warning",
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const k = `${type}:${severity}`;
|
||||||
|
if (now - (lastSent.current[k] || 0) < 1500) return;
|
||||||
|
lastSent.current[k] = now;
|
||||||
|
try {
|
||||||
|
const res = await examSecurityService.postEvent({
|
||||||
|
attempt_id: attemptId,
|
||||||
|
exam_id: examId ?? null,
|
||||||
|
event_type: type,
|
||||||
|
severity,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
if (res.auto_locked) onAutoLock?.();
|
||||||
|
} catch {
|
||||||
|
// Anti-cheat must never break the exam runner.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlur = () => post("window_blur", "alert");
|
||||||
|
const onFocus = () => post("window_focus", "info");
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.hidden) post("tab_blur", "alert");
|
||||||
|
else post("tab_focus", "info");
|
||||||
|
};
|
||||||
|
const onPaste = (e: ClipboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
post("paste", "alert", { length: e.clipboardData?.getData("text").length });
|
||||||
|
};
|
||||||
|
const onCopy = () => post("copy", "warning");
|
||||||
|
const onCut = () => post("cut", "alert");
|
||||||
|
const onContext = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
post("context_menu", "warning");
|
||||||
|
};
|
||||||
|
const onFsChange = () => {
|
||||||
|
if (!document.fullscreenElement) post("fullscreen_exit", "warning");
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const meta = e.metaKey || e.ctrlKey;
|
||||||
|
if (meta && ["c", "x", "v", "p", "s"].includes(e.key.toLowerCase())) {
|
||||||
|
post("shortcut", "warning", { key: e.key });
|
||||||
|
}
|
||||||
|
if (e.key === "F12") post("shortcut", "alert", { key: "F12" });
|
||||||
|
if (meta && e.shiftKey && ["i", "j", "c"].includes(e.key.toLowerCase())) {
|
||||||
|
post("shortcut", "alert", { key: `${e.shiftKey ? "shift+" : ""}${e.key}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("blur", onBlur);
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
window.addEventListener("paste", onPaste);
|
||||||
|
window.addEventListener("copy", onCopy);
|
||||||
|
window.addEventListener("cut", onCut);
|
||||||
|
window.addEventListener("contextmenu", onContext);
|
||||||
|
document.addEventListener("fullscreenchange", onFsChange);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("blur", onBlur);
|
||||||
|
window.removeEventListener("focus", onFocus);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
window.removeEventListener("paste", onPaste);
|
||||||
|
window.removeEventListener("copy", onCopy);
|
||||||
|
window.removeEventListener("cut", onCut);
|
||||||
|
window.removeEventListener("contextmenu", onContext);
|
||||||
|
document.removeEventListener("fullscreenchange", onFsChange);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [enabled, attemptId, examId, onAutoLock]);
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ const ar: Translations = {
|
|||||||
courses: "الدورات",
|
courses: "الدورات",
|
||||||
myCourses: "دوراتي",
|
myCourses: "دوراتي",
|
||||||
myCoursePlans: "خططي الدراسية",
|
myCoursePlans: "خططي الدراسية",
|
||||||
|
myLearning: "تعلّمي",
|
||||||
|
liveSessions: "الجلسات المباشرة",
|
||||||
|
turnitin: "تيرنيتن",
|
||||||
students: "الطلاب",
|
students: "الطلاب",
|
||||||
teachers: "المعلمون",
|
teachers: "المعلمون",
|
||||||
branches: "الفروع",
|
branches: "الفروع",
|
||||||
@@ -205,6 +208,7 @@ const ar: Translations = {
|
|||||||
totalChapters: "إجمالي الفصول",
|
totalChapters: "إجمالي الفصول",
|
||||||
myCourses: "دوراتي",
|
myCourses: "دوراتي",
|
||||||
myCoursePlans: "خططي الدراسية",
|
myCoursePlans: "خططي الدراسية",
|
||||||
|
myLearning: "تعلّمي",
|
||||||
noCoursePlans: "لم يتم إسناد أي خطة دراسية لك بعد.",
|
noCoursePlans: "لم يتم إسناد أي خطة دراسية لك بعد.",
|
||||||
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
|
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
|
||||||
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
|
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ const en: Translations = {
|
|||||||
courses: "Courses",
|
courses: "Courses",
|
||||||
myCourses: "My Courses",
|
myCourses: "My Courses",
|
||||||
myCoursePlans: "My Course Plans",
|
myCoursePlans: "My Course Plans",
|
||||||
|
myLearning: "My Learning",
|
||||||
|
liveSessions: "Live Sessions",
|
||||||
|
turnitin: "Turnitin",
|
||||||
students: "Students",
|
students: "Students",
|
||||||
teachers: "Teachers",
|
teachers: "Teachers",
|
||||||
branches: "Branches",
|
branches: "Branches",
|
||||||
|
|||||||
@@ -2,30 +2,51 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
* Visual identity ("Duvex"-style refresh, 2026-04-29)
|
||||||
|
*
|
||||||
|
* Light mode is a warm cream background, pure-white cards, bright orange
|
||||||
|
* accent (`--primary`), and a near-black charcoal CTA (`--cta`) used as
|
||||||
|
* the default Button color. The sidebar is now WHITE with a dark active
|
||||||
|
* pill instead of the former dark-navy chrome.
|
||||||
|
*
|
||||||
|
* `--primary` (orange) is intentionally preserved as the platform's
|
||||||
|
* highlight color so every existing `bg-primary` / `text-primary` token
|
||||||
|
* (stat icons, badges, focus rings, charts) keeps its expected meaning.
|
||||||
|
* The new `--cta` token drives `<Button>`'s default variant — yielding
|
||||||
|
* the "mixed" button language: dark CTAs, orange highlights/accents.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 30 15% 97%;
|
--background: 28 35% 97%;
|
||||||
--foreground: 240 20% 13%;
|
--foreground: 220 25% 12%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 240 20% 13%;
|
--card-foreground: 220 25% 12%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 240 20% 13%;
|
--popover-foreground: 220 25% 12%;
|
||||||
|
|
||||||
--primary: 8 50% 54%;
|
/* Brand orange — visual identity highlight (icons, badges, charts,
|
||||||
|
focus rings, "primary" Button variant). */
|
||||||
|
--primary: 22 95% 55%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--secondary: 30 12% 94%;
|
/* Dark charcoal — default Button CTA color and active sidebar pill. */
|
||||||
--secondary-foreground: 240 20% 13%;
|
--cta: 220 25% 12%;
|
||||||
|
--cta-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--muted: 30 10% 93%;
|
--secondary: 30 20% 95%;
|
||||||
--muted-foreground: 240 10% 46%;
|
--secondary-foreground: 220 25% 12%;
|
||||||
|
|
||||||
--accent: 42 40% 92%;
|
--muted: 30 18% 94%;
|
||||||
--accent-foreground: 42 40% 28%;
|
--muted-foreground: 220 10% 45%;
|
||||||
|
|
||||||
--destructive: 0 72% 51%;
|
/* Peachy hover background used by ghost buttons & dropdown items. */
|
||||||
|
--accent: 22 95% 95%;
|
||||||
|
--accent-foreground: 22 90% 30%;
|
||||||
|
|
||||||
|
--destructive: 0 75% 55%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--success: 152 60% 42%;
|
--success: 152 60% 42%;
|
||||||
@@ -37,24 +58,27 @@
|
|||||||
--info: 220 70% 52%;
|
--info: 220 70% 52%;
|
||||||
--info-foreground: 0 0% 100%;
|
--info-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--border: 30 10% 90%;
|
--border: 30 15% 90%;
|
||||||
--input: 30 10% 90%;
|
--input: 30 15% 90%;
|
||||||
--ring: 8 50% 54%;
|
--ring: 22 95% 55%;
|
||||||
|
|
||||||
--radius: 0.625rem;
|
/* 16px base radius so cards/inputs/buttons read soft, matches the
|
||||||
|
reference's pill-and-rounded-card language. */
|
||||||
|
--radius: 1rem;
|
||||||
|
|
||||||
--sidebar-background: 240 20% 16%;
|
/* WHITE sidebar with charcoal active pill. */
|
||||||
--sidebar-foreground: 240 10% 90%;
|
--sidebar-background: 0 0% 100%;
|
||||||
--sidebar-primary: 8 50% 58%;
|
--sidebar-foreground: 220 15% 30%;
|
||||||
|
--sidebar-primary: 220 25% 12%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 240 18% 22%;
|
--sidebar-accent: 30 25% 95%;
|
||||||
--sidebar-accent-foreground: 8 40% 78%;
|
--sidebar-accent-foreground: 220 25% 12%;
|
||||||
--sidebar-border: 240 18% 20%;
|
--sidebar-border: 30 15% 92%;
|
||||||
--sidebar-ring: 8 50% 58%;
|
--sidebar-ring: 22 95% 55%;
|
||||||
|
|
||||||
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
|
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on
|
||||||
warm/cool axis so pairs read well together when stacked. */
|
the warm/cool axis so pairs read well together when stacked. */
|
||||||
--chart-1: 8 50% 54%;
|
--chart-1: 22 95% 55%;
|
||||||
--chart-2: 220 70% 52%;
|
--chart-2: 220 70% 52%;
|
||||||
--chart-3: 152 60% 42%;
|
--chart-3: 152 60% 42%;
|
||||||
--chart-4: 38 92% 50%;
|
--chart-4: 38 92% 50%;
|
||||||
@@ -62,53 +86,58 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 240 22% 7%;
|
--background: 220 22% 8%;
|
||||||
--foreground: 30 12% 95%;
|
--foreground: 30 15% 95%;
|
||||||
|
|
||||||
--card: 240 20% 11%;
|
--card: 220 18% 12%;
|
||||||
--card-foreground: 30 12% 95%;
|
--card-foreground: 30 15% 95%;
|
||||||
|
|
||||||
--popover: 240 20% 11%;
|
--popover: 220 18% 12%;
|
||||||
--popover-foreground: 30 12% 95%;
|
--popover-foreground: 30 15% 95%;
|
||||||
|
|
||||||
--primary: 8 52% 50%;
|
--primary: 22 95% 60%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 220 25% 8%;
|
||||||
|
|
||||||
--secondary: 240 18% 16%;
|
/* In dark mode the CTA inverts to light/cream so it still reads as
|
||||||
--secondary-foreground: 30 12% 95%;
|
"the strong action" against the dark canvas. */
|
||||||
|
--cta: 30 15% 95%;
|
||||||
|
--cta-foreground: 220 25% 12%;
|
||||||
|
|
||||||
--muted: 240 18% 16%;
|
--secondary: 220 15% 16%;
|
||||||
--muted-foreground: 240 8% 58%;
|
--secondary-foreground: 30 15% 95%;
|
||||||
|
|
||||||
--accent: 42 35% 20%;
|
--muted: 220 15% 16%;
|
||||||
--accent-foreground: 42 40% 72%;
|
--muted-foreground: 220 10% 60%;
|
||||||
|
|
||||||
--destructive: 0 62% 40%;
|
--accent: 22 50% 22%;
|
||||||
|
--accent-foreground: 22 90% 75%;
|
||||||
|
|
||||||
|
--destructive: 0 65% 45%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--success: 152 45% 35%;
|
--success: 152 50% 40%;
|
||||||
--success-foreground: 0 0% 100%;
|
--success-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--warning: 38 70% 45%;
|
--warning: 38 75% 50%;
|
||||||
--warning-foreground: 0 0% 100%;
|
--warning-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--info: 220 55% 42%;
|
--info: 220 60% 50%;
|
||||||
--info-foreground: 0 0% 100%;
|
--info-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--border: 240 18% 18%;
|
--border: 220 15% 18%;
|
||||||
--input: 240 18% 18%;
|
--input: 220 15% 18%;
|
||||||
--ring: 8 52% 50%;
|
--ring: 22 95% 60%;
|
||||||
|
|
||||||
--sidebar-background: 240 22% 9%;
|
--sidebar-background: 220 18% 10%;
|
||||||
--sidebar-foreground: 240 10% 88%;
|
--sidebar-foreground: 220 10% 80%;
|
||||||
--sidebar-primary: 8 50% 55%;
|
--sidebar-primary: 22 95% 60%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 220 25% 8%;
|
||||||
--sidebar-accent: 240 18% 15%;
|
--sidebar-accent: 220 15% 16%;
|
||||||
--sidebar-accent-foreground: 8 40% 75%;
|
--sidebar-accent-foreground: 30 15% 95%;
|
||||||
--sidebar-border: 240 18% 13%;
|
--sidebar-border: 220 15% 14%;
|
||||||
--sidebar-ring: 8 50% 55%;
|
--sidebar-ring: 22 95% 60%;
|
||||||
|
|
||||||
--chart-1: 8 52% 62%;
|
--chart-1: 22 95% 65%;
|
||||||
--chart-2: 220 65% 65%;
|
--chart-2: 220 65% 65%;
|
||||||
--chart-3: 152 50% 55%;
|
--chart-3: 152 50% 55%;
|
||||||
--chart-4: 38 75% 60%;
|
--chart-4: 38 75% 60%;
|
||||||
|
|||||||
347
frontend/src/pages/LiveSessionRoom.tsx
Normal file
347
frontend/src/pages/LiveSessionRoom.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ArrowLeft, Users, Square, AlertTriangle, MicOff, VideoOff, UserX,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
import { liveSessionService } from "@/services/liveSession.service";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
JitsiMeetExternalAPI?: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const JITSI_DOMAIN = "meet.jit.si";
|
||||||
|
const JITSI_SCRIPT = "https://meet.jit.si/external_api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the Jitsi external_api.js once and resolves to the global ctor.
|
||||||
|
*/
|
||||||
|
function loadJitsi(): Promise<any> {
|
||||||
|
if (window.JitsiMeetExternalAPI) return Promise.resolve(window.JitsiMeetExternalAPI);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(`script[src="${JITSI_SCRIPT}"]`);
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener("load", () => resolve(window.JitsiMeetExternalAPI));
|
||||||
|
existing.addEventListener("error", reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = JITSI_SCRIPT;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => resolve(window.JitsiMeetExternalAPI);
|
||||||
|
script.onerror = reject;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveSessionRoom() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const sessionId = Number(id);
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [api, setApi] = useState<any>(null);
|
||||||
|
const [connectErr, setConnectErr] = useState<string | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const { data: session, isLoading } = useQuery({
|
||||||
|
queryKey: ["live-session", sessionId],
|
||||||
|
queryFn: () => liveSessionService.read(sessionId),
|
||||||
|
enabled: !!sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const joinMut = useMutation({
|
||||||
|
mutationFn: () => liveSessionService.join(sessionId),
|
||||||
|
});
|
||||||
|
const leaveMut = useMutation({
|
||||||
|
mutationFn: () => liveSessionService.leave(sessionId),
|
||||||
|
});
|
||||||
|
const endMut = useMutation({
|
||||||
|
mutationFn: () => liveSessionService.end(sessionId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["live-session", sessionId] });
|
||||||
|
toast({ title: "Session ended" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || !containerRef.current) return;
|
||||||
|
let alive = true;
|
||||||
|
let local: any = null;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const Ctor = await loadJitsi();
|
||||||
|
if (!alive) return;
|
||||||
|
const join = await joinMut.mutateAsync();
|
||||||
|
if (!alive) return;
|
||||||
|
local = new Ctor(JITSI_DOMAIN, {
|
||||||
|
roomName: session.room_name,
|
||||||
|
parentNode: containerRef.current,
|
||||||
|
width: "100%",
|
||||||
|
height: 600,
|
||||||
|
userInfo: {
|
||||||
|
displayName: user?.name || user?.email || "Participant",
|
||||||
|
email: user?.email || undefined,
|
||||||
|
},
|
||||||
|
configOverwrite: {
|
||||||
|
prejoinPageEnabled: !join.is_host,
|
||||||
|
startWithAudioMuted: !join.is_host,
|
||||||
|
startWithVideoMuted: !join.is_host,
|
||||||
|
enableLobbyChat: true,
|
||||||
|
disableInviteFunctions: true,
|
||||||
|
requireDisplayName: true,
|
||||||
|
},
|
||||||
|
interfaceConfigOverwrite: {
|
||||||
|
TOOLBAR_BUTTONS: [
|
||||||
|
"microphone", "camera", "desktop", "fullscreen",
|
||||||
|
"fodeviceselection", "hangup", "chat", "raisehand",
|
||||||
|
"videoquality", "tileview", "etherpad", "shareaudio",
|
||||||
|
join.is_host ? "settings" : "",
|
||||||
|
join.is_host ? "mute-everyone" : "",
|
||||||
|
join.is_host ? "mute-video-everyone" : "",
|
||||||
|
join.is_host ? "security" : "",
|
||||||
|
join.is_host ? "recording" : "",
|
||||||
|
].filter(Boolean),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
local.addListener("readyToClose", () => {
|
||||||
|
leaveMut.mutate();
|
||||||
|
navigate("/live");
|
||||||
|
});
|
||||||
|
local.addListener("recordingStatusChanged", (ev: any) => {
|
||||||
|
if (!ev?.on && ev?.streamingURL) {
|
||||||
|
liveSessionService.storeRecording(sessionId, ev.streamingURL).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setApi(local);
|
||||||
|
} catch (e) {
|
||||||
|
setConnectErr(e instanceof Error ? e.message : "Failed to join Jitsi room");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
try { local?.dispose(); } catch { /* noop */ }
|
||||||
|
leaveMut.mutate();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [session?.id]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
}
|
||||||
|
if (!session) return <div className="p-8">Session not found.</div>;
|
||||||
|
|
||||||
|
const isHost = user?.id === session.host_id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-7xl mx-auto p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button asChild variant="ghost" size="sm"><Link to="/live"><ArrowLeft className="h-4 w-4 me-1" />Back</Link></Button>
|
||||||
|
<h1 className="text-xl font-bold">{session.title}</h1>
|
||||||
|
<Badge variant="outline">{session.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{isHost && session.status !== "ended" && (
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => endMut.mutate()} disabled={endMut.isPending}>
|
||||||
|
<Square className="h-4 w-4 me-1" />End session
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connectErr && (
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardContent className="py-4 flex items-start gap-2 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Couldn't load Jitsi.</div>
|
||||||
|
<div className="opacity-80">{connectErr}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div ref={containerRef} className="bg-zinc-900 min-h-[600px]" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4" />Participants ({session.participants.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1.5 max-h-[500px] overflow-auto">
|
||||||
|
{isHost && (
|
||||||
|
<div className="flex gap-1 pb-2 border-b mb-2">
|
||||||
|
<Button
|
||||||
|
variant="outline" size="sm" className="text-xs flex-1"
|
||||||
|
onClick={() => api?.executeCommand("muteEveryone", "audio")}
|
||||||
|
disabled={!api}
|
||||||
|
title="Mute everyone (audio)"
|
||||||
|
>
|
||||||
|
<MicOff className="h-3 w-3 me-1" />Mute all
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline" size="sm" className="text-xs flex-1"
|
||||||
|
onClick={() => api?.executeCommand("muteEveryone", "video")}
|
||||||
|
disabled={!api}
|
||||||
|
title="Disable everyone's video"
|
||||||
|
>
|
||||||
|
<VideoOff className="h-3 w-3 me-1" />Cams off
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{session.participants.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground">No one has joined yet.</div>
|
||||||
|
) : session.participants.map((p) => (
|
||||||
|
<ParticipantRow
|
||||||
|
key={p.id}
|
||||||
|
p={p}
|
||||||
|
isHost={isHost}
|
||||||
|
api={api}
|
||||||
|
sessionId={sessionId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline per-participant control strip used inside the host's
|
||||||
|
* participants sidebar. The host can:
|
||||||
|
* • Ask one user to unmute audio (executeCommand 'askToUnmute')
|
||||||
|
* • Mute one user (executeCommand 'muteEveryone' targets all
|
||||||
|
* by mediaType — for individual mute we
|
||||||
|
* rely on Jitsi's native right-click menu;
|
||||||
|
* we surface the kick action here instead.)
|
||||||
|
* • Remove the user from the session, with mandatory reason logged in the
|
||||||
|
* audit trail (POST /api/live-sessions/<id>/remove-participant).
|
||||||
|
*
|
||||||
|
* The "Remove" action also calls `kickParticipant` on Jitsi using a name match
|
||||||
|
* to ensure the user is actually disconnected from the room — backend status
|
||||||
|
* alone wouldn't drop them from the call.
|
||||||
|
*/
|
||||||
|
function ParticipantRow({
|
||||||
|
p, isHost, api, sessionId,
|
||||||
|
}: {
|
||||||
|
p: { id: number; user_id: number; user_name: string; attendance_status: string };
|
||||||
|
isHost: boolean;
|
||||||
|
api: any;
|
||||||
|
sessionId: number;
|
||||||
|
}) {
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [openKick, setOpenKick] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: () => liveSessionService.removeParticipant(sessionId, p.user_id, reason),
|
||||||
|
onSuccess: () => {
|
||||||
|
try {
|
||||||
|
if (api) {
|
||||||
|
const list = api.getParticipantsInfo() || [];
|
||||||
|
const target = list.find((j: any) => j.displayName === p.user_name);
|
||||||
|
if (target?.participantId) {
|
||||||
|
api.executeCommand("kickParticipant", target.participantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* noop */ }
|
||||||
|
toast({
|
||||||
|
title: "Participant removed",
|
||||||
|
description: "Reason logged in audit trail.",
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ["live-session", sessionId] });
|
||||||
|
setOpenKick(false);
|
||||||
|
setReason("");
|
||||||
|
},
|
||||||
|
onError: (e) => toast({
|
||||||
|
title: "Could not remove",
|
||||||
|
description: e instanceof Error ? e.message : "Try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-1 text-sm py-1">
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<span className="truncate">{p.user_name}</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||||
|
{p.attendance_status.replace("_", " ")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{isHost && p.attendance_status !== "removed" && (
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="icon" className="h-6 w-6" title="Ask to unmute"
|
||||||
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
const list = api?.getParticipantsInfo() || [];
|
||||||
|
const target = list.find((j: any) => j.displayName === p.user_name);
|
||||||
|
if (target?.participantId) {
|
||||||
|
api.executeCommand("askToUnmute", target.participantId);
|
||||||
|
toast({ title: `Asked ${p.user_name} to unmute` });
|
||||||
|
}
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MicOff className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Dialog open={openKick} onOpenChange={setOpenKick}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" title="Remove">
|
||||||
|
<UserX className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove {p.user_name}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Reason (required, logged)</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="e.g. Repeated disruption / off-topic chat"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpenKick(false)}>Cancel</Button>
|
||||||
|
<Button variant="destructive" disabled={!reason.trim() || remove.isPending} onClick={() => remove.mutate()}>
|
||||||
|
{remove.isPending ? "Removing…" : "Remove + log"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
271
frontend/src/pages/LiveSessionsPage.tsx
Normal file
271
frontend/src/pages/LiveSessionsPage.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Calendar, Plus, Video, Bell, Users, Clock, ExternalLink, CalendarPlus,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
|
import {
|
||||||
|
liveSessionService, type CreateLiveSessionPayload, type LiveSession,
|
||||||
|
} from "@/services/liveSession.service";
|
||||||
|
|
||||||
|
export default function LiveSessionsPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isStaff = ["admin", "teacher", "corporate", "mastercorporate", "developer"]
|
||||||
|
.includes(user?.user_type || "");
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["live-sessions", { mine: !isStaff }],
|
||||||
|
queryFn: () => liveSessionService.list(isStaff ? {} : { mine: true }),
|
||||||
|
});
|
||||||
|
const sessions = data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Video className="h-6 w-6 text-primary" />
|
||||||
|
Live sessions
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Online classroom rooms with attendance, recording, breakouts, and chat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isStaff && <NewSessionDialog />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<Card><CardContent className="py-16 text-center text-muted-foreground">
|
||||||
|
No live sessions yet. {isStaff ? "Schedule one to get started." : "Your teacher hasn't scheduled any."}
|
||||||
|
</CardContent></Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{sessions.map((s) => <SessionCard key={s.id} s={s} isStaff={isStaff} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionCard({ s, isStaff }: { s: LiveSession; isStaff: boolean }) {
|
||||||
|
const tone =
|
||||||
|
s.status === "live" ? "bg-green-500" :
|
||||||
|
s.status === "scheduled" ? "bg-blue-500" :
|
||||||
|
s.status === "ended" ? "bg-zinc-400" : "bg-red-500";
|
||||||
|
|
||||||
|
const when = s.scheduled_at ? new Date(s.scheduled_at).toLocaleString() : "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-base truncate">{s.title}</CardTitle>
|
||||||
|
<Badge className={`${tone} text-white capitalize`}>{s.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="text-sm text-muted-foreground line-clamp-2">{s.description || s.course_name || s.plan_name || "—"}</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5"><Calendar className="h-3.5 w-3.5" />{when}</div>
|
||||||
|
<div className="flex items-center gap-1.5"><Clock className="h-3.5 w-3.5" />{s.duration_min}m</div>
|
||||||
|
<div className="flex items-center gap-1.5"><Users className="h-3.5 w-3.5" />{s.participant_count} joined</div>
|
||||||
|
<div className="flex items-center gap-1.5"><Bell className="h-3.5 w-3.5" />{s.notification_sent ? "Notified" : "Not notified"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button asChild size="sm" className="gap-1">
|
||||||
|
<Link to={`/live/${s.id}`}>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
{s.status === "live" ? "Join now" : "Open room"}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{isStaff && (
|
||||||
|
<NotifyButton sessionId={s.id} alreadySent={s.notification_sent} />
|
||||||
|
)}
|
||||||
|
<AddToCalendarButton sessionId={s.id} title={s.title} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddToCalendarButton({ sessionId, title }: { sessionId: number; title: string }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
await liveSessionService.downloadIcs(
|
||||||
|
sessionId,
|
||||||
|
title.replace(/[^A-Za-z0-9_-]+/g, "-").toLowerCase().slice(0, 40) || "session",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Could not download .ics",
|
||||||
|
description: e instanceof Error ? e.message : "Try again later.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Add to your calendar (.ics)"
|
||||||
|
>
|
||||||
|
<CalendarPlus className="h-3.5 w-3.5 me-1" />
|
||||||
|
Calendar
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotifyButton({ sessionId, alreadySent }: { sessionId: number; alreadySent: boolean }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const m = useMutation({
|
||||||
|
mutationFn: () => liveSessionService.notify(sessionId),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
toast({ title: `Sent ${r.sent} email${r.sent === 1 ? "" : "s"}` });
|
||||||
|
qc.invalidateQueries({ queryKey: ["live-sessions"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={m.isPending}
|
||||||
|
onClick={() => m.mutate()}
|
||||||
|
>
|
||||||
|
<Bell className="h-3.5 w-3.5 me-1" />
|
||||||
|
{alreadySent ? "Resend" : "Notify"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewSessionDialog() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState<CreateLiveSessionPayload>({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
scheduled_at: new Date(Date.now() + 60 * 60_000).toISOString().slice(0, 16),
|
||||||
|
duration_min: 60,
|
||||||
|
waiting_room: true,
|
||||||
|
recording_enabled: false,
|
||||||
|
auto_attendance_minutes: 10,
|
||||||
|
late_entry_minutes: 15,
|
||||||
|
notify: true,
|
||||||
|
});
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const m = useMutation({
|
||||||
|
mutationFn: () => liveSessionService.create({
|
||||||
|
...form,
|
||||||
|
scheduled_at: new Date(form.scheduled_at).toISOString(),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Session scheduled" });
|
||||||
|
qc.invalidateQueries({ queryKey: ["live-sessions"] });
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: (e) => toast({
|
||||||
|
title: "Could not schedule",
|
||||||
|
description: e instanceof Error ? e.message : "Try again later.",
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-2"><Plus className="h-4 w-4" />New session</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader><DialogTitle>Schedule a live session</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>Title</Label>
|
||||||
|
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} rows={2} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Date & time</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.scheduled_at}
|
||||||
|
onChange={(e) => setForm({ ...form, scheduled_at: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Duration (min)</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={15} step={5}
|
||||||
|
value={form.duration_min}
|
||||||
|
onChange={(e) => setForm({ ...form, duration_min: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Auto-attendance after (min)</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={0}
|
||||||
|
value={form.auto_attendance_minutes}
|
||||||
|
onChange={(e) => setForm({ ...form, auto_attendance_minutes: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Late entry block (min)</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={0}
|
||||||
|
value={form.late_entry_minutes}
|
||||||
|
onChange={(e) => setForm({ ...form, late_entry_minutes: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Waiting room (admit students manually)</Label>
|
||||||
|
<Switch checked={form.waiting_room} onCheckedChange={(v) => setForm({ ...form, waiting_room: v })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Allow recording</Label>
|
||||||
|
<Switch checked={form.recording_enabled} onCheckedChange={(v) => setForm({ ...form, recording_enabled: v })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Email enrolled students now</Label>
|
||||||
|
<Switch checked={form.notify} onCheckedChange={(v) => setForm({ ...form, notify: v })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={() => m.mutate()} disabled={!form.title || !form.scheduled_at || m.isPending}>
|
||||||
|
{m.isPending ? "Scheduling…" : "Schedule"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ const thresholds = ["0%", "50%", "70%", "90%"];
|
|||||||
export default function StatsCorporatePage() {
|
export default function StatsCorporatePage() {
|
||||||
const [threshold, setThreshold] = useState("0%");
|
const [threshold, setThreshold] = useState("0%");
|
||||||
const [entityId, setEntityId] = useState("all");
|
const [entityId, setEntityId] = useState("all");
|
||||||
|
// Phase 1 (2026-04-29) — comparative-analysis filters.
|
||||||
|
const [branchId, setBranchId] = useState("all");
|
||||||
|
const [subjectId, setSubjectId] = useState("all");
|
||||||
|
const [gender, setGender] = useState("all");
|
||||||
|
|
||||||
const { data: filters } = useQuery({
|
const { data: filters } = useQuery({
|
||||||
queryKey: ["reports-filters"],
|
queryKey: ["reports-filters"],
|
||||||
@@ -45,12 +49,22 @@ export default function StatsCorporatePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["stats-corporate", threshold, entityId],
|
queryKey: [
|
||||||
|
"stats-corporate",
|
||||||
|
threshold,
|
||||||
|
entityId,
|
||||||
|
branchId,
|
||||||
|
subjectId,
|
||||||
|
gender,
|
||||||
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
reportsService.statsCorporate({
|
reportsService.statsCorporate({
|
||||||
entity_id: entityId === "all" ? undefined : Number(entityId),
|
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||||
threshold: Number(threshold.replace("%", "")),
|
threshold: Number(threshold.replace("%", "")),
|
||||||
months: 6,
|
months: 6,
|
||||||
|
branch_id: branchId === "all" ? undefined : Number(branchId),
|
||||||
|
subject_id: subjectId === "all" ? undefined : Number(subjectId),
|
||||||
|
gender: gender === "all" ? undefined : gender,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,6 +116,45 @@ export default function StatsCorporatePage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select value={branchId} onValueChange={setBranchId}>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue placeholder="All Branches" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Branches</SelectItem>
|
||||||
|
{(filters?.branches ?? []).map((b) => (
|
||||||
|
<SelectItem key={b.id} value={String(b.id)}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={subjectId} onValueChange={setSubjectId}>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue placeholder="All Subjects" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Subjects</SelectItem>
|
||||||
|
{(filters?.subjects ?? []).map((s) => (
|
||||||
|
<SelectItem key={s.id} value={String(s.id)}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={gender} onValueChange={setGender}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="All Genders" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Genders</SelectItem>
|
||||||
|
{(filters?.genders ?? []).map((g) => (
|
||||||
|
<SelectItem key={g.value} value={g.value}>
|
||||||
|
{g.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, Download } from "lucide-react";
|
import { Loader2, Download, FileText } from "lucide-react";
|
||||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||||
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
|
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
|
||||||
import { reportsService } from "@/services/reports.service";
|
import { reportsService } from "@/services/reports.service";
|
||||||
|
import { reportsExportService } from "@/services/reportsExport.service";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
||||||
|
|
||||||
@@ -49,6 +51,10 @@ export default function StudentPerformancePage() {
|
|||||||
const [entityId, setEntityId] = useState<string>("all");
|
const [entityId, setEntityId] = useState<string>("all");
|
||||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("all");
|
const [level, setLevel] = useState<(typeof LEVELS)[number]>("all");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
// Phase 1 (2026-04-29) — comparative-analysis filters.
|
||||||
|
const [branchId, setBranchId] = useState<string>("all");
|
||||||
|
const [subjectId, setSubjectId] = useState<string>("all");
|
||||||
|
const [gender, setGender] = useState<string>("all");
|
||||||
|
|
||||||
const { data: filters } = useQuery({
|
const { data: filters } = useQuery({
|
||||||
queryKey: ["reports-filters"],
|
queryKey: ["reports-filters"],
|
||||||
@@ -56,12 +62,23 @@ export default function StudentPerformancePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["student-performance", entityId, level, search],
|
queryKey: [
|
||||||
|
"student-performance",
|
||||||
|
entityId,
|
||||||
|
level,
|
||||||
|
search,
|
||||||
|
branchId,
|
||||||
|
subjectId,
|
||||||
|
gender,
|
||||||
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
reportsService.studentPerformance({
|
reportsService.studentPerformance({
|
||||||
entity_id: entityId === "all" ? undefined : Number(entityId),
|
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||||
level: level === "all" ? undefined : level,
|
level: level === "all" ? undefined : level,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
|
branch_id: branchId === "all" ? undefined : Number(branchId),
|
||||||
|
subject_id: subjectId === "all" ? undefined : Number(subjectId),
|
||||||
|
gender: gender === "all" ? undefined : gender,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,9 +148,18 @@ export default function StudentPerformancePage() {
|
|||||||
Track student scores across all IELTS modules.
|
Track student scores across all IELTS modules.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={exportCsv} disabled={!rows.length}>
|
<ExportButtons
|
||||||
<Download className="h-4 w-4 mr-1" /> Export CSV
|
filters={{
|
||||||
</Button>
|
entity_id: entityId === "all" ? undefined : entityId,
|
||||||
|
level: level === "all" ? undefined : level,
|
||||||
|
search: search || undefined,
|
||||||
|
branch_id: branchId === "all" ? undefined : branchId,
|
||||||
|
subject_id: subjectId === "all" ? undefined : subjectId,
|
||||||
|
gender: gender === "all" ? undefined : gender,
|
||||||
|
}}
|
||||||
|
fallbackCsv={exportCsv}
|
||||||
|
disabled={!rows.length}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
@@ -196,6 +222,45 @@ export default function StudentPerformancePage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select value={branchId} onValueChange={setBranchId}>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue placeholder="All Branches" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Branches</SelectItem>
|
||||||
|
{(filters?.branches ?? []).map((b) => (
|
||||||
|
<SelectItem key={b.id} value={String(b.id)}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={subjectId} onValueChange={setSubjectId}>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue placeholder="All Subjects" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Subjects</SelectItem>
|
||||||
|
{(filters?.subjects ?? []).map((s) => (
|
||||||
|
<SelectItem key={s.id} value={String(s.id)}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={gender} onValueChange={setGender}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="All Genders" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Genders</SelectItem>
|
||||||
|
{(filters?.genders ?? []).map((g) => (
|
||||||
|
<SelectItem key={g.value} value={g.value}>
|
||||||
|
{g.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
@@ -270,3 +335,42 @@ export default function StudentPerformancePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ExportButtons({
|
||||||
|
filters, fallbackCsv, disabled,
|
||||||
|
}: {
|
||||||
|
filters: Record<string, unknown>;
|
||||||
|
fallbackCsv: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [busy, setBusy] = useState<"csv" | "pdf" | null>(null);
|
||||||
|
const run = async (fmt: "csv" | "pdf") => {
|
||||||
|
setBusy(fmt);
|
||||||
|
try {
|
||||||
|
await reportsExportService.studentPerformance(fmt, filters);
|
||||||
|
} catch (e) {
|
||||||
|
if (fmt === "csv") {
|
||||||
|
fallbackCsv();
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "PDF download failed",
|
||||||
|
description: e instanceof Error ? e.message : "Try again later.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => run("csv")} disabled={disabled || busy !== null}>
|
||||||
|
<Download className="h-4 w-4 mr-1" /> {busy === "csv" ? "Downloading…" : "CSV"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => run("pdf")} disabled={disabled || busy !== null}>
|
||||||
|
<FileText className="h-4 w-4 mr-1" /> {busy === "pdf" ? "Downloading…" : "PDF"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,30 @@ const SKILL_ICONS: Record<string, LucideIcon> = {
|
|||||||
integrated: MessageSquare,
|
integrated: MessageSquare,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Phase 1 (2026-04-29) — colour-coded kind badge so the weekly plan
|
||||||
|
// view can be scanned at a glance: orange = mandatory work, blue =
|
||||||
|
// supplementary content. Used in MaterialCard headers and the
|
||||||
|
// student-facing PlanReader.
|
||||||
|
const KIND_TONE: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; classes: string }
|
||||||
|
> = {
|
||||||
|
content: { label: "Lesson", classes: "border-slate-300 text-slate-700 dark:text-slate-300" },
|
||||||
|
quiz: { label: "Quiz", classes: "bg-blue-500/15 border-blue-500/40 text-blue-700 dark:text-blue-300" },
|
||||||
|
test: { label: "Test", classes: "bg-primary/15 border-primary/40 text-primary" },
|
||||||
|
resource: { label: "Resource", classes: "bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300" },
|
||||||
|
assignment: { label: "Assignment", classes: "bg-amber-500/15 border-amber-500/40 text-amber-700 dark:text-amber-300" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function KindBadge({ kind }: { kind: string }) {
|
||||||
|
const tone = KIND_TONE[kind] || KIND_TONE.content;
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={`text-[10px] ${tone.classes}`}>
|
||||||
|
{tone.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminCoursePlanDetail() {
|
export default function AdminCoursePlanDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const params = useParams<{ planId: string }>();
|
const params = useParams<{ planId: string }>();
|
||||||
@@ -482,6 +506,7 @@ export default function AdminCoursePlanDetail() {
|
|||||||
}
|
}
|
||||||
studentPreview={studentPreview}
|
studentPreview={studentPreview}
|
||||||
hasIndexedSources={hasIndexedSources}
|
hasIndexedSources={hasIndexedSources}
|
||||||
|
planId={planId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
@@ -1459,6 +1484,7 @@ function WeekAccordionItem({
|
|||||||
bulkBusy,
|
bulkBusy,
|
||||||
studentPreview,
|
studentPreview,
|
||||||
hasIndexedSources,
|
hasIndexedSources,
|
||||||
|
planId,
|
||||||
}: {
|
}: {
|
||||||
week: CoursePlanWeek;
|
week: CoursePlanWeek;
|
||||||
materials: CoursePlanMaterial[];
|
materials: CoursePlanMaterial[];
|
||||||
@@ -1468,9 +1494,11 @@ function WeekAccordionItem({
|
|||||||
bulkBusy: boolean;
|
bulkBusy: boolean;
|
||||||
studentPreview: boolean;
|
studentPreview: boolean;
|
||||||
hasIndexedSources: boolean;
|
hasIndexedSources: boolean;
|
||||||
|
planId: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [skillFilter, setSkillFilter] = useState<string>("all");
|
const [skillFilter, setSkillFilter] = useState<string>("all");
|
||||||
|
const [manualOpen, setManualOpen] = useState(false);
|
||||||
const materialSkills = useMemo(
|
const materialSkills = useMemo(
|
||||||
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
|
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
|
||||||
[materials],
|
[materials],
|
||||||
@@ -1584,6 +1612,14 @@ function WeekAccordionItem({
|
|||||||
{t("coursePlan.media.bulk")}
|
{t("coursePlan.media.bulk")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setManualOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
{t("coursePlan.addMaterial", "Add quiz / test / resource")}
|
||||||
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t("coursePlan.generateHint")}
|
{t("coursePlan.generateHint")}
|
||||||
</span>
|
</span>
|
||||||
@@ -1620,11 +1656,222 @@ function WeekAccordionItem({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AddManualMaterialDialog
|
||||||
|
open={manualOpen}
|
||||||
|
onOpenChange={setManualOpen}
|
||||||
|
planId={planId}
|
||||||
|
weekNumber={week.week_number}
|
||||||
|
/>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AddManualMaterialDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
planId,
|
||||||
|
weekNumber,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
planId: number;
|
||||||
|
weekNumber: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [kind, setKind] = useState<
|
||||||
|
"content" | "quiz" | "test" | "resource" | "assignment"
|
||||||
|
>("quiz");
|
||||||
|
const [examTemplateId, setExamTemplateId] = useState("");
|
||||||
|
const [resourceId, setResourceId] = useState("");
|
||||||
|
const [dueDate, setDueDate] = useState("");
|
||||||
|
const [skill, setSkill] = useState<string>("integrated");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setTitle("");
|
||||||
|
setKind("quiz");
|
||||||
|
setExamTemplateId("");
|
||||||
|
setResourceId("");
|
||||||
|
setDueDate("");
|
||||||
|
setSkill("integrated");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
coursePlanService.createManualMaterial(planId, weekNumber, {
|
||||||
|
title: title.trim(),
|
||||||
|
kind,
|
||||||
|
skill,
|
||||||
|
material_type:
|
||||||
|
kind === "quiz" || kind === "test"
|
||||||
|
? "practice"
|
||||||
|
: kind === "resource"
|
||||||
|
? "other"
|
||||||
|
: "other",
|
||||||
|
exam_template_id: examTemplateId ? Number(examTemplateId) : null,
|
||||||
|
resource_id: resourceId ? Number(resourceId) : null,
|
||||||
|
due_date: dueDate || null,
|
||||||
|
is_graded: kind === "test",
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
|
||||||
|
toast.success(t("coursePlan.addedMaterial", "Material added"));
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(describeApiError(err, t("common.error", "Error"))),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("coursePlan.addMaterialTitle", "Add material to week {{n}}", {
|
||||||
|
n: weekNumber,
|
||||||
|
})}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
"coursePlan.addMaterialHint",
|
||||||
|
"Drop a quiz, graded test, supplementary resource, or assignment into this week of the delivery plan.",
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">
|
||||||
|
{t("common.title", "Title")} *
|
||||||
|
</Label>
|
||||||
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">
|
||||||
|
{t("coursePlan.kind", "Material kind")}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={kind}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setKind(
|
||||||
|
v as
|
||||||
|
| "content"
|
||||||
|
| "quiz"
|
||||||
|
| "test"
|
||||||
|
| "resource"
|
||||||
|
| "assignment",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="quiz">
|
||||||
|
{t("coursePlan.kindQuiz", "Quiz")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="test">
|
||||||
|
{t("coursePlan.kindTest", "Graded Test")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="resource">
|
||||||
|
{t("coursePlan.kindResource", "Supplementary Resource")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="assignment">
|
||||||
|
{t("coursePlan.kindAssignment", "Assignment")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="content">
|
||||||
|
{t("coursePlan.kindContent", "Reading / Lesson")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">
|
||||||
|
{t("coursePlan.skill", "Skill")}
|
||||||
|
</Label>
|
||||||
|
<Select value={skill} onValueChange={setSkill}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="reading">Reading</SelectItem>
|
||||||
|
<SelectItem value="writing">Writing</SelectItem>
|
||||||
|
<SelectItem value="listening">Listening</SelectItem>
|
||||||
|
<SelectItem value="speaking">Speaking</SelectItem>
|
||||||
|
<SelectItem value="grammar">Grammar</SelectItem>
|
||||||
|
<SelectItem value="vocabulary">Vocabulary</SelectItem>
|
||||||
|
<SelectItem value="integrated">Integrated</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(kind === "quiz" || kind === "test") && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">
|
||||||
|
{t("coursePlan.examTemplateId", "Exam template ID")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={examTemplateId}
|
||||||
|
onChange={(e) => setExamTemplateId(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"coursePlan.examTemplateIdHint",
|
||||||
|
"ID of an existing exam template (see /admin/exams)",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{kind === "resource" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">
|
||||||
|
{t("coursePlan.resourceId", "Resource ID")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={resourceId}
|
||||||
|
onChange={(e) => setResourceId(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"coursePlan.resourceIdHint",
|
||||||
|
"ID of an existing resource (see /admin/resources)",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">
|
||||||
|
{t("coursePlan.dueDate", "Due date")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("common.cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createMut.mutate()}
|
||||||
|
disabled={!title.trim() || createMut.isPending}
|
||||||
|
>
|
||||||
|
{createMut.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{t("common.add", "Add")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MaterialCard({
|
function MaterialCard({
|
||||||
material,
|
material,
|
||||||
studentPreview,
|
studentPreview,
|
||||||
@@ -1641,6 +1888,18 @@ function MaterialCard({
|
|||||||
const [bodyText, setBodyText] = useState(material.body_text || "");
|
const [bodyText, setBodyText] = useState(material.body_text || "");
|
||||||
const [shareDate, setShareDate] = useState(material.share_date ?? "");
|
const [shareDate, setShareDate] = useState(material.share_date ?? "");
|
||||||
const [isStatic, setIsStatic] = useState(Boolean(material.is_static));
|
const [isStatic, setIsStatic] = useState(Boolean(material.is_static));
|
||||||
|
// Phase 1 (2026-04-29) — kind / exam / resource / grading edits.
|
||||||
|
const [kind, setKind] = useState<string>(material.kind || "content");
|
||||||
|
const [examTemplateId, setExamTemplateId] = useState<string>(
|
||||||
|
material.exam_template_id ? String(material.exam_template_id) : "",
|
||||||
|
);
|
||||||
|
const [resourceId, setResourceId] = useState<string>(
|
||||||
|
material.resource_id ? String(material.resource_id) : "",
|
||||||
|
);
|
||||||
|
const [dueDate, setDueDate] = useState<string>(material.due_date ?? "");
|
||||||
|
const [isGraded, setIsGraded] = useState<boolean>(
|
||||||
|
Boolean(material.is_graded),
|
||||||
|
);
|
||||||
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
||||||
const mediaCount = material.media?.length ?? 0;
|
const mediaCount = material.media?.length ?? 0;
|
||||||
|
|
||||||
@@ -1650,6 +1909,13 @@ function MaterialCard({
|
|||||||
setBodyText(material.body_text || "");
|
setBodyText(material.body_text || "");
|
||||||
setShareDate(material.share_date ?? "");
|
setShareDate(material.share_date ?? "");
|
||||||
setIsStatic(Boolean(material.is_static));
|
setIsStatic(Boolean(material.is_static));
|
||||||
|
setKind(material.kind || "content");
|
||||||
|
setExamTemplateId(
|
||||||
|
material.exam_template_id ? String(material.exam_template_id) : "",
|
||||||
|
);
|
||||||
|
setResourceId(material.resource_id ? String(material.resource_id) : "");
|
||||||
|
setDueDate(material.due_date ?? "");
|
||||||
|
setIsGraded(Boolean(material.is_graded));
|
||||||
}, [material]);
|
}, [material]);
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
@@ -1660,6 +1926,16 @@ function MaterialCard({
|
|||||||
body_text: bodyText,
|
body_text: bodyText,
|
||||||
share_date: shareDate || null,
|
share_date: shareDate || null,
|
||||||
is_static: isStatic,
|
is_static: isStatic,
|
||||||
|
kind: kind as
|
||||||
|
| "content"
|
||||||
|
| "quiz"
|
||||||
|
| "test"
|
||||||
|
| "resource"
|
||||||
|
| "assignment",
|
||||||
|
exam_template_id: examTemplateId ? Number(examTemplateId) : null,
|
||||||
|
resource_id: resourceId ? Number(resourceId) : null,
|
||||||
|
due_date: dueDate || null,
|
||||||
|
is_graded: isGraded,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["course-plan", material.plan_id] });
|
qc.invalidateQueries({ queryKey: ["course-plan", material.plan_id] });
|
||||||
@@ -1686,6 +1962,18 @@ function MaterialCard({
|
|||||||
material.material_type,
|
material.material_type,
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<KindBadge kind={material.kind || "content"} />
|
||||||
|
{material.is_graded && (
|
||||||
|
<Badge variant="default" className="text-[10px]">
|
||||||
|
{t("coursePlan.graded", "Graded")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{material.due_date && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
<Calendar className="h-3 w-3 mr-1" />
|
||||||
|
{material.due_date}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<SkillBadge skill={material.skill} />
|
<SkillBadge skill={material.skill} />
|
||||||
<GroundingBadge material={material} />
|
<GroundingBadge material={material} />
|
||||||
<Button
|
<Button
|
||||||
@@ -1742,6 +2030,92 @@ function MaterialCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">
|
||||||
|
{t("coursePlan.kind", "Material kind")}
|
||||||
|
</Label>
|
||||||
|
<Select value={kind} onValueChange={setKind}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="content">
|
||||||
|
{t("coursePlan.kindContent", "Reading / Lesson")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="quiz">
|
||||||
|
{t("coursePlan.kindQuiz", "Quiz")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="test">
|
||||||
|
{t("coursePlan.kindTest", "Graded Test")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="resource">
|
||||||
|
{t("coursePlan.kindResource", "Supplementary Resource")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="assignment">
|
||||||
|
{t("coursePlan.kindAssignment", "Assignment")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">
|
||||||
|
{t("coursePlan.dueDate", "Due date")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dueDate || ""}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`graded-${material.id}`}
|
||||||
|
checked={isGraded}
|
||||||
|
onCheckedChange={(v) => setIsGraded(Boolean(v))}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`graded-${material.id}`}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
{t("coursePlan.graded", "Graded")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(kind === "quiz" || kind === "test") && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">
|
||||||
|
{t("coursePlan.examTemplateId", "Exam template ID")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={examTemplateId}
|
||||||
|
onChange={(e) => setExamTemplateId(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"coursePlan.examTemplateIdHint",
|
||||||
|
"ID of an existing exam template (see /admin/exams)",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{kind === "resource" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">
|
||||||
|
{t("coursePlan.resourceId", "Resource ID")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={resourceId}
|
||||||
|
onChange={(e) => setResourceId(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"coursePlan.resourceIdHint",
|
||||||
|
"ID of an existing resource (see /admin/resources)",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`static-${material.id}`}
|
id={`static-${material.id}`}
|
||||||
|
|||||||
222
frontend/src/pages/admin/TurnitinSettings.tsx
Normal file
222
frontend/src/pages/admin/TurnitinSettings.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Shield, CheckCircle2, AlertTriangle, Eye, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
import { entitiesService } from "@/services/entities.service";
|
||||||
|
import { turnitinService } from "@/services/turnitin.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-entity Turnitin integration settings page.
|
||||||
|
*
|
||||||
|
* Each institution holds its own Turnitin subscription, so the API key
|
||||||
|
* is stored on the encoach.entity record. This page lets the admin
|
||||||
|
* pick an entity, toggle Turnitin on, paste their API key + account
|
||||||
|
* ID, override the API base URL, and run a connectivity test.
|
||||||
|
*/
|
||||||
|
export default function TurnitinSettings() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [entityId, setEntityId] = useState<string>("");
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const [draft, setDraft] = useState({
|
||||||
|
enabled: false,
|
||||||
|
api_url: "https://api.turnitin.com",
|
||||||
|
account_id: "",
|
||||||
|
api_key: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: entities } = useQuery({
|
||||||
|
queryKey: ["entities"],
|
||||||
|
queryFn: () => entitiesService.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const eid = Number(entityId || 0);
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ["turnitin", eid],
|
||||||
|
queryFn: () => turnitinService.getSettings(eid),
|
||||||
|
enabled: eid > 0,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings) return;
|
||||||
|
setDraft({
|
||||||
|
enabled: settings.enabled,
|
||||||
|
api_url: settings.api_url || "https://api.turnitin.com",
|
||||||
|
account_id: settings.account_id || "",
|
||||||
|
api_key: "",
|
||||||
|
});
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => turnitinService.patchSettings(eid, {
|
||||||
|
enabled: draft.enabled,
|
||||||
|
api_url: draft.api_url,
|
||||||
|
account_id: draft.account_id,
|
||||||
|
...(draft.api_key ? { api_key: draft.api_key } : {}),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Turnitin settings saved" });
|
||||||
|
setDraft((d) => ({ ...d, api_key: "" }));
|
||||||
|
qc.invalidateQueries({ queryKey: ["turnitin", eid] });
|
||||||
|
},
|
||||||
|
onError: (e) => toast({
|
||||||
|
title: "Save failed",
|
||||||
|
description: e instanceof Error ? e.message : "Try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const test = useMutation({
|
||||||
|
mutationFn: () => turnitinService.test(eid),
|
||||||
|
onSuccess: (r) => toast({
|
||||||
|
title: r.ok ? "Turnitin connected" : "Could not reach Turnitin",
|
||||||
|
description: r.job_id,
|
||||||
|
variant: r.ok ? "default" : "destructive",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = entities?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-3xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950/40">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Turnitin integration</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Plug your institution's Turnitin subscription into the platform.
|
||||||
|
Each institution stores its own API credentials.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">Choose institution</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Select value={entityId} onValueChange={setEntityId}>
|
||||||
|
<SelectTrigger className="max-w-xs"><SelectValue placeholder="Pick an entity…" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{list.map((e: any) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{eid > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
{settings?.enabled
|
||||||
|
? <Badge tone="ok"><CheckCircle2 className="h-3.5 w-3.5" />Enabled</Badge>
|
||||||
|
: <Badge tone="warn"><AlertTriangle className="h-3.5 w-3.5" />Disabled</Badge>}
|
||||||
|
Configuration
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Turnitin enabled</Label>
|
||||||
|
<div className="text-xs text-muted-foreground">Hides/Shows the originality button across the institution.</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>API base URL</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.api_url}
|
||||||
|
onChange={(e) => setDraft({ ...draft, api_url: e.target.value })}
|
||||||
|
placeholder="https://api.turnitin.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Account ID</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.account_id}
|
||||||
|
onChange={(e) => setDraft({ ...draft, account_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>API key {settings?.has_api_key && <span className="text-xs text-muted-foreground">(leave blank to keep current)</span>}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={show ? "text" : "password"}
|
||||||
|
placeholder={settings?.has_api_key ? "•••••••••••••••" : "Paste your Turnitin token"}
|
||||||
|
value={draft.api_key}
|
||||||
|
onChange={(e) => setDraft({ ...draft, api_key: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||||
|
onClick={() => setShow(!show)}
|
||||||
|
>
|
||||||
|
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!settings?.has_api_key && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>No API key configured</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Submissions will return a synthetic stub originality score until a key is provided.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||||
|
{save.isPending ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => test.mutate()}
|
||||||
|
disabled={test.isPending || !settings?.has_api_key}
|
||||||
|
>
|
||||||
|
{test.isPending ? "Testing…" : "Test connection"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ children, tone }: { children: React.ReactNode; tone: "ok" | "warn" }) {
|
||||||
|
const cls = tone === "ok"
|
||||||
|
? "bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300"
|
||||||
|
: "bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300";
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
import { Flag, ChevronLeft, ChevronRight, Pause, Play, Mic, Square } from "lucide-react";
|
import { Flag, ChevronLeft, ChevronRight, Pause, Play, Mic, Square } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { mediaService } from "@/services/media.service";
|
import { mediaService } from "@/services/media.service";
|
||||||
|
import { useExamSecurity } from "@/hooks/useExamSecurity";
|
||||||
|
|
||||||
function normalizeType(t: string | null | undefined) {
|
function normalizeType(t: string | null | undefined) {
|
||||||
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
|
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
|
||||||
@@ -88,6 +89,15 @@ export default function ExamSession() {
|
|||||||
} as typeof rawSession;
|
} as typeof rawSession;
|
||||||
}, [rawSession]);
|
}, [rawSession]);
|
||||||
|
|
||||||
|
// Phase 2 (2026-04-30): online-test SOFT security tier. Records tab
|
||||||
|
// blur, paste/copy, fullscreen exits, etc. into the per-attempt log
|
||||||
|
// visible to the teacher. Runs as long as the session is loaded.
|
||||||
|
useExamSecurity({
|
||||||
|
attemptId: (session as any)?.attempt_id ?? null,
|
||||||
|
examId,
|
||||||
|
enabled: !!session,
|
||||||
|
});
|
||||||
|
|
||||||
const [sectionIdx, setSectionIdx] = useState(0);
|
const [sectionIdx, setSectionIdx] = useState(0);
|
||||||
const [questionIdx, setQuestionIdx] = useState(0);
|
const [questionIdx, setQuestionIdx] = useState(0);
|
||||||
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
|
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { Progress } from "@/components/ui/progress";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useCourse, useChapters, useGrades, useCourseCompletion } from "@/hooks/queries";
|
import { useCourse, useChapters, useGrades, useCourseCompletion } from "@/hooks/queries";
|
||||||
import { ArrowLeft, BookOpen, Lock, ChevronRight, Play, CheckCircle2, Trophy } from "lucide-react";
|
import { ArrowLeft, BookOpen, Lock, ChevronRight, Play, CheckCircle2, Trophy, MessageSquare } from "lucide-react";
|
||||||
|
import DiscussionTab from "@/components/discussion/DiscussionTab";
|
||||||
|
|
||||||
export default function StudentCourseDetail() {
|
export default function StudentCourseDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -69,6 +70,10 @@ export default function StudentCourseDetail() {
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="chapters">Chapters ({chapters.length})</TabsTrigger>
|
<TabsTrigger value="chapters">Chapters ({chapters.length})</TabsTrigger>
|
||||||
<TabsTrigger value="grades">Grades ({gradeRecords.length})</TabsTrigger>
|
<TabsTrigger value="grades">Grades ({gradeRecords.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="discussion">
|
||||||
|
<MessageSquare className="h-3 w-3 mr-1" />
|
||||||
|
Discussion
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="about">About</TabsTrigger>
|
<TabsTrigger value="about">About</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -129,6 +134,10 @@ export default function StudentCourseDetail() {
|
|||||||
))}
|
))}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="discussion" className="mt-4">
|
||||||
|
<DiscussionTab courseId={courseId} hideHeader />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="about" className="mt-4">
|
<TabsContent value="about" className="mt-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="text-base">About this Course</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">About this Course</CardTitle></CardHeader>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft, MessageSquare } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import PlanReader from "@/components/coursePlan/PlanReader";
|
import PlanReader from "@/components/coursePlan/PlanReader";
|
||||||
|
import DiscussionTab from "@/components/discussion/DiscussionTab";
|
||||||
import { coursePlanService } from "@/services/coursePlan.service";
|
import { coursePlanService } from "@/services/coursePlan.service";
|
||||||
import { describeApiError } from "@/lib/api-client";
|
import { describeApiError } from "@/lib/api-client";
|
||||||
import type { CoursePlan } from "@/types";
|
import type { CoursePlan } from "@/types";
|
||||||
@@ -61,7 +63,25 @@ export default function StudentCoursePlanDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{plan && <PlanReader plan={plan} mode="student" />}
|
{plan && (
|
||||||
|
<Tabs defaultValue="material">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="material">
|
||||||
|
{t("coursePlan.tabs.material", "Material")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="discussion">
|
||||||
|
<MessageSquare className="h-3 w-3 mr-1" />
|
||||||
|
{t("discussion.title", "Discussion")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="material" className="mt-4">
|
||||||
|
<PlanReader plan={plan} mode="student" />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="discussion" className="mt-4">
|
||||||
|
<DiscussionTab planId={planId} hideHeader />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,300 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
GraduationCap,
|
||||||
|
Layers,
|
||||||
|
Play,
|
||||||
|
Sparkles,
|
||||||
|
Star,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useMyEnrolledCourses } from "@/hooks/queries";
|
|
||||||
import { BookOpen, Users, Play } from "lucide-react";
|
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
import {
|
||||||
|
coursePlanService,
|
||||||
|
type StudentLearningItem,
|
||||||
|
} from "@/services/coursePlan.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1 (2026-04-29) — merged "My Learning" page.
|
||||||
|
*
|
||||||
|
* Replaces what used to be two separate sidebar entries ("My Courses"
|
||||||
|
* driven by `op.student.course` and "My Course Plans" driven by
|
||||||
|
* `encoach.course.plan.assignment`). Both lists now come from the
|
||||||
|
* unified `/api/student/learning` endpoint and are split into two
|
||||||
|
* sections by the `is_mandatory` flag:
|
||||||
|
*
|
||||||
|
* * **Accredited Core** — flagged courses/plans, surfaced with a
|
||||||
|
* bright "Mandatory" tag so students can immediately tell what
|
||||||
|
* counts toward graduation.
|
||||||
|
* * **Supplementary / Elective** — everything else, shown
|
||||||
|
* underneath in a calmer blue palette.
|
||||||
|
*
|
||||||
|
* Each card links to the same detail routes that used to power the
|
||||||
|
* separate pages (`/student/courses/:id` for op.course,
|
||||||
|
* `/student/course-plans/:planId` for AI plans), so existing deep
|
||||||
|
* links keep working.
|
||||||
|
*/
|
||||||
export default function StudentCourses() {
|
export default function StudentCourses() {
|
||||||
const { data: enrolledData, isLoading } = useMyEnrolledCourses();
|
const { t } = useTranslation();
|
||||||
const myCourses = enrolledData?.items ?? [];
|
const { data, isLoading, isError } = useQuery({
|
||||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
queryKey: ["student-learning"],
|
||||||
|
queryFn: () => coursePlanService.studentLearning(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mandatory = data?.mandatory ?? [];
|
||||||
|
const elective = data?.elective ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">My Courses</h1>
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
<p className="text-muted-foreground">Browse and continue your enrolled courses.</p>
|
<Layers className="h-6 w-6 text-primary" />
|
||||||
|
{t("learning.title", "My Learning")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"learning.subtitle",
|
||||||
|
"All your accredited courses and supplementary materials in one place.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner context="student-courses" variant="recommendation" />
|
<AiTipBanner context="student-courses" variant="recommendation" />
|
||||||
|
|
||||||
{myCourses.length === 0 ? (
|
{isError && (
|
||||||
<div className="text-center py-12">
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
<BookOpen className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
{t("learning.loadFailed", "Failed to load your courses. Please try again.")}
|
||||||
<h3 className="text-lg font-semibold">No Enrolled Courses</h3>
|
</div>
|
||||||
<p className="text-muted-foreground mt-1">You haven't been enrolled in any courses yet. Contact your administrator.</p>
|
)}
|
||||||
|
|
||||||
|
{!isError && mandatory.length === 0 && elective.length === 0 && (
|
||||||
|
<div className="text-center py-12 border rounded-2xl bg-muted/20">
|
||||||
|
<GraduationCap className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"learning.empty",
|
||||||
|
"You haven't been enrolled in any courses or plans yet. Contact your administrator.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Phase 1 (2026-04-29): always render BOTH sections — even when one
|
||||||
|
is empty — so students immediately see the structure (and the
|
||||||
|
intent that "Accredited Core" comes first). Empty sections fall
|
||||||
|
back to a calm empty-state instead of disappearing entirely.
|
||||||
|
*/}
|
||||||
|
{(mandatory.length > 0 || elective.length > 0) && (
|
||||||
|
<>
|
||||||
|
<Section
|
||||||
|
title={t("learning.accreditedCore", "Accredited Core")}
|
||||||
|
subtitle={t(
|
||||||
|
"learning.accreditedCoreSub",
|
||||||
|
"Mandatory programs that count toward your official record.",
|
||||||
|
)}
|
||||||
|
tone="mandatory"
|
||||||
|
items={mandatory}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
title={t("learning.supplementary", "Supplementary & Elective")}
|
||||||
|
subtitle={t(
|
||||||
|
"learning.supplementarySub",
|
||||||
|
"Optional content, AI-generated study plans, and self-paced material.",
|
||||||
|
)}
|
||||||
|
tone="elective"
|
||||||
|
items={elective}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
tone,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
tone: "mandatory" | "elective";
|
||||||
|
items: StudentLearningItem[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
{tone === "mandatory" ? (
|
||||||
|
<Star className="h-4 w-4 text-primary" fill="currentColor" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4 text-blue-500" />
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
<Badge variant="secondary" className="ml-1">
|
||||||
|
{items.length}
|
||||||
|
</Badge>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="text-center py-8 border rounded-2xl bg-muted/10">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{tone === "mandatory"
|
||||||
|
? "No accredited / mandatory programs assigned to you yet."
|
||||||
|
: "No supplementary or elective material yet."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{myCourses.map((c) => (
|
{items.map((item) => (
|
||||||
<Link to={`/student/courses/${c.id}`} key={c.id}>
|
<LearningCard key={`${item.kind}-${item.id}`} item={item} tone={tone} />
|
||||||
<Card className="hover:shadow-md transition-shadow h-full">
|
|
||||||
<div className="h-2 rounded-t-lg bg-primary" />
|
|
||||||
<CardContent className="pt-5 space-y-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{c.progress === 100 ? "Completed" : c.progress > 0 ? "In Progress" : "Not Started"}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-xs">{c.code}</Badge>
|
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold mt-2">{c.title || c.name}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{c.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{c.chapter_count} chapters</span>
|
|
||||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.student_count} students</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs mb-1">
|
|
||||||
<span className="text-muted-foreground">Progress</span>
|
|
||||||
<span className="font-medium">{c.progress}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={c.progress} className="h-2" />
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full" size="sm">
|
|
||||||
<Play className="mr-2 h-3 w-3" />
|
|
||||||
{c.progress > 0 ? "Continue Learning" : "Start Learning"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LearningCard({
|
||||||
|
item,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
item: StudentLearningItem;
|
||||||
|
tone: "mandatory" | "elective";
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const href =
|
||||||
|
item.kind === "plan"
|
||||||
|
? `/student/course-plans/${item.id}`
|
||||||
|
: `/student/courses/${item.id}`;
|
||||||
|
const accentBar =
|
||||||
|
tone === "mandatory"
|
||||||
|
? "bg-gradient-to-r from-primary to-orange-400"
|
||||||
|
: "bg-gradient-to-r from-blue-400 to-cyan-400";
|
||||||
|
const progress = item.progress ?? 0;
|
||||||
|
const statusLabel =
|
||||||
|
item.kind === "plan"
|
||||||
|
? item.status === "approved"
|
||||||
|
? t("learning.statusApproved", "Approved")
|
||||||
|
: item.status === "generated"
|
||||||
|
? t("learning.statusGenerated", "Generated")
|
||||||
|
: t("learning.statusDraft", "Draft")
|
||||||
|
: progress >= 100
|
||||||
|
? t("learning.statusCompleted", "Completed")
|
||||||
|
: progress > 0
|
||||||
|
? t("learning.statusInProgress", "In Progress")
|
||||||
|
: t("learning.statusNotStarted", "Not Started");
|
||||||
|
return (
|
||||||
|
<Link to={href}>
|
||||||
|
<Card className="hover:shadow-lg transition-shadow h-full overflow-hidden">
|
||||||
|
<div className={`h-2 ${accentBar}`} />
|
||||||
|
<CardContent className="pt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1 flex-wrap gap-1">
|
||||||
|
{item.is_mandatory ? (
|
||||||
|
<Badge className="bg-primary text-primary-foreground hover:bg-primary text-xs">
|
||||||
|
{t("learning.mandatory", "Mandatory")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||||
|
>
|
||||||
|
{t("learning.elective", "Elective")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{item.kind === "plan" ? (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<Sparkles className="h-3 w-3 mr-1" />
|
||||||
|
{t("learning.aiPlan", "AI Plan")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
item.code && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{item.code}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold mt-2 line-clamp-2">{item.name}</h3>
|
||||||
|
{item.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
|
||||||
|
{item.kind === "plan" ? (
|
||||||
|
<>
|
||||||
|
{item.total_weeks ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<BookOpen className="h-3 w-3" />
|
||||||
|
{t("learning.weeks", "{{n}} weeks", { n: item.total_weeks })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{item.cefr_level && (
|
||||||
|
<span className="uppercase">{item.cefr_level}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<BookOpen className="h-3 w-3" />
|
||||||
|
{t("learning.chapters", "{{n}} chapters", {
|
||||||
|
n: item.chapter_count ?? 0,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{item.batch_name && (
|
||||||
|
<span className="text-muted-foreground/80">
|
||||||
|
{item.batch_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.kind === "course" && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("learning.progress", "Progress")}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outline" className="w-full" size="sm">
|
||||||
|
<Play className="mr-2 h-3 w-3" />
|
||||||
|
{progress > 0 || item.kind === "plan"
|
||||||
|
? t("learning.continue", "Continue Learning")
|
||||||
|
: t("learning.start", "Start Learning")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
|
|||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
import AITutorAvatar from "@/components/ai/AITutorAvatar";
|
||||||
|
import PersonalizedPlanCard from "@/components/student/PersonalizedPlanCard";
|
||||||
import { coursePlanService } from "@/services/coursePlan.service";
|
import { coursePlanService } from "@/services/coursePlan.service";
|
||||||
|
|
||||||
export default function StudentDashboard() {
|
export default function StudentDashboard() {
|
||||||
@@ -42,13 +44,18 @@ export default function StudentDashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
|
<h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<AITutorAvatar size={72} name="EnCoach AI Tutor" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner context="student-dashboard" variant="tip" />
|
<AiTipBanner context="student-dashboard" variant="tip" />
|
||||||
|
|
||||||
|
<PersonalizedPlanCard />
|
||||||
|
|
||||||
<AiStudyCoach />
|
<AiStudyCoach />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
|||||||
372
frontend/src/pages/teacher/TeacherCourseInsights.tsx
Normal file
372
frontend/src/pages/teacher/TeacherCourseInsights.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Users,
|
||||||
|
CalendarCheck,
|
||||||
|
ClipboardCheck,
|
||||||
|
BookOpen,
|
||||||
|
TrendingUp,
|
||||||
|
AlertTriangle,
|
||||||
|
MessageSquare,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import DiscussionTab from "@/components/discussion/DiscussionTab";
|
||||||
|
|
||||||
|
interface CourseInsightsResponse {
|
||||||
|
course_id: number;
|
||||||
|
course_name: string;
|
||||||
|
students: {
|
||||||
|
total: number;
|
||||||
|
by_batch: { batch_id: number; batch_name: string; count: number }[];
|
||||||
|
};
|
||||||
|
attendance: {
|
||||||
|
lines_total: number;
|
||||||
|
present: number;
|
||||||
|
absent: number;
|
||||||
|
excused: number;
|
||||||
|
late: number;
|
||||||
|
rate_present: number;
|
||||||
|
rate_absent: number;
|
||||||
|
rate_late: number;
|
||||||
|
};
|
||||||
|
submissions: {
|
||||||
|
exams_assigned: number;
|
||||||
|
expected_attempts: number;
|
||||||
|
completed_attempts: number;
|
||||||
|
rate_completed: number;
|
||||||
|
};
|
||||||
|
materials: {
|
||||||
|
chapters: number;
|
||||||
|
students_started: number;
|
||||||
|
completed_progress_rows: number;
|
||||||
|
rate_chapters: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1 (2026-04-29) — Class Insights tab for teachers.
|
||||||
|
*
|
||||||
|
* Surfaces what teachers asked for: student count, attendance vs
|
||||||
|
* absence breakdown, task submission progress, and materials done.
|
||||||
|
* Pulls everything from `/api/teacher/courses/:id/insights`, which
|
||||||
|
* aggregates `op.student.course`, `op.attendance.line`,
|
||||||
|
* `encoach.exam.assignment`/`encoach.student.attempt`, and
|
||||||
|
* `encoach.chapter.progress` server-side.
|
||||||
|
*/
|
||||||
|
export default function TeacherCourseInsights() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { courseId: rawId } = useParams<{ courseId: string }>();
|
||||||
|
const courseId = Number(rawId);
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["teacher-course-insights", courseId],
|
||||||
|
enabled: Number.isFinite(courseId) && courseId > 0,
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<CourseInsightsResponse>(`/teacher/courses/${courseId}/insights`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link to="/teacher/courses">
|
||||||
|
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{data?.course_name || t("teacher.insights.title", "Class Insights")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"teacher.insights.subtitle",
|
||||||
|
"Enrollment, attendance, submission progress, and chapter completion at a glance.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-32" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="inline h-4 w-4 mr-1" />
|
||||||
|
{t("teacher.insights.loadFailed", "Failed to load class insights.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
icon={<Users className="h-4 w-4" />}
|
||||||
|
label={t("teacher.insights.students", "Students enrolled")}
|
||||||
|
value={data.students.total}
|
||||||
|
tone="primary"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<CalendarCheck className="h-4 w-4" />}
|
||||||
|
label={t("teacher.insights.attendanceRate", "Attendance rate")}
|
||||||
|
value={`${data.attendance.rate_present}%`}
|
||||||
|
hint={`${data.attendance.present} present · ${data.attendance.absent} absent`}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<ClipboardCheck className="h-4 w-4" />}
|
||||||
|
label={t("teacher.insights.submissions", "Task submission")}
|
||||||
|
value={`${data.submissions.rate_completed}%`}
|
||||||
|
hint={
|
||||||
|
data.submissions.exams_assigned
|
||||||
|
? `${data.submissions.completed_attempts} / ${data.submissions.expected_attempts} attempts`
|
||||||
|
: t(
|
||||||
|
"teacher.insights.noAssignments",
|
||||||
|
"No exams assigned yet",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tone="info"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<BookOpen className="h-4 w-4" />}
|
||||||
|
label={t("teacher.insights.chapters", "Chapter completion")}
|
||||||
|
value={`${data.materials.rate_chapters}%`}
|
||||||
|
hint={`${data.materials.completed_progress_rows} completed · ${data.materials.chapters} chapters`}
|
||||||
|
tone="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview" className="mt-2">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">
|
||||||
|
{t("teacher.insights.tabs.overview", "Overview")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="batches">
|
||||||
|
{t("teacher.insights.tabs.batches", "By Batch")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="discussion">
|
||||||
|
<MessageSquare className="h-3 w-3 mr-1" />
|
||||||
|
{t("discussion.title", "Discussion")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="mt-4 space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<CalendarCheck className="h-4 w-4 text-primary" />
|
||||||
|
{t("teacher.insights.attendanceBreakdown", "Attendance breakdown")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data.attendance.lines_total === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"teacher.insights.noAttendance",
|
||||||
|
"No attendance recorded yet.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Bar
|
||||||
|
label={t("teacher.insights.present", "Present")}
|
||||||
|
value={data.attendance.present}
|
||||||
|
total={data.attendance.lines_total}
|
||||||
|
color="bg-green-500"
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
label={t("teacher.insights.absent", "Absent")}
|
||||||
|
value={data.attendance.absent}
|
||||||
|
total={data.attendance.lines_total}
|
||||||
|
color="bg-red-500"
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
label={t("teacher.insights.excused", "Excused")}
|
||||||
|
value={data.attendance.excused}
|
||||||
|
total={data.attendance.lines_total}
|
||||||
|
color="bg-amber-500"
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
label={t("teacher.insights.late", "Late")}
|
||||||
|
value={data.attendance.late}
|
||||||
|
total={data.attendance.lines_total}
|
||||||
|
color="bg-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
|
{t("teacher.insights.tasks", "Task submission progress")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"teacher.insights.assignedExams",
|
||||||
|
"Assigned exams",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{data.submissions.exams_assigned}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"teacher.insights.expectedAttempts",
|
||||||
|
"Expected attempts",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{data.submissions.expected_attempts}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"teacher.insights.completedAttempts",
|
||||||
|
"Completed attempts",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{data.submissions.completed_attempts}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={data.submissions.rate_completed}
|
||||||
|
className="h-2 mt-1"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="batches" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
{data.students.by_batch.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"teacher.insights.noBatches",
|
||||||
|
"No students assigned to batches.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.students.by_batch.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.batch_id}
|
||||||
|
className="flex items-center justify-between py-2 border-b last:border-b-0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{b.batch_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("teacher.insights.batchId", "Batch #{{id}}", {
|
||||||
|
id: b.batch_id,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{b.count}{" "}
|
||||||
|
{t(
|
||||||
|
"teacher.insights.studentsLower",
|
||||||
|
"students",
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="discussion" className="mt-4">
|
||||||
|
<DiscussionTab courseId={courseId} hideHeader />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
hint?: string;
|
||||||
|
tone: "primary" | "success" | "info" | "warning";
|
||||||
|
}) {
|
||||||
|
const toneClasses: Record<string, string> = {
|
||||||
|
primary: "from-primary/15 to-primary/5 text-primary",
|
||||||
|
success: "from-green-500/15 to-green-500/5 text-green-700 dark:text-green-300",
|
||||||
|
info: "from-blue-500/15 to-blue-500/5 text-blue-700 dark:text-blue-300",
|
||||||
|
warning:
|
||||||
|
"from-amber-500/15 to-amber-500/5 text-amber-700 dark:text-amber-300",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardContent
|
||||||
|
className={`pt-5 pb-5 bg-gradient-to-br ${toneClasses[tone]}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-xs uppercase tracking-wider opacity-80">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold mt-2 text-foreground">{value}</div>
|
||||||
|
{hint && <div className="text-xs text-muted-foreground mt-1">{hint}</div>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bar({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
total,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
total: number;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{value} ({pct}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full ${color}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { useCourses, useChapters } from "@/hooks/queries";
|
|||||||
import { lmsService } from "@/services";
|
import { lmsService } from "@/services";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Plus, Users, BookOpen, Pencil, FolderOpen, Wand2 } from "lucide-react";
|
import { Plus, Users, BookOpen, Pencil, FolderOpen, Wand2, BarChart3 } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { Course } from "@/types";
|
import type { Course } from "@/types";
|
||||||
|
|
||||||
@@ -61,6 +61,9 @@ function CourseCard({ course }: { course: Course }) {
|
|||||||
<Button size="sm" variant="default" onClick={() => navigate(`/teacher/courses/${course.id}/chapters`)}>
|
<Button size="sm" variant="default" onClick={() => navigate(`/teacher/courses/${course.id}/chapters`)}>
|
||||||
<FolderOpen className="mr-1.5 h-3 w-3" /> Chapters & Materials
|
<FolderOpen className="mr-1.5 h-3 w-3" /> Chapters & Materials
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => navigate(`/teacher/courses/${course.id}/insights`)}>
|
||||||
|
<BarChart3 className="mr-1.5 h-3 w-3" /> Class Insights
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => navigate(`/teacher/courses/${course.id}/workbench`)}>
|
<Button size="sm" variant="ghost" onClick={() => navigate(`/teacher/courses/${course.id}/workbench`)}>
|
||||||
<Wand2 className="mr-1.5 h-3 w-3" /> AI Workbench
|
<Wand2 className="mr-1.5 h-3 w-3" /> AI Workbench
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
180
frontend/src/pages/teacher/TeacherTestSessionConsole.tsx
Normal file
180
frontend/src/pages/teacher/TeacherTestSessionConsole.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ShieldAlert, Eye, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import { examSecurityService, type SecurityEvent } from "@/services/examSecurity.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live monitoring console for a proctored online test assignment.
|
||||||
|
* Lists every in-progress attempt with the count of "alert" events.
|
||||||
|
* Click any row to inspect that attempt's full security event log.
|
||||||
|
*/
|
||||||
|
export default function TeacherTestSessionConsole() {
|
||||||
|
const { assignmentId: raw } = useParams<{ assignmentId: string }>();
|
||||||
|
const assignmentId = Number(raw);
|
||||||
|
|
||||||
|
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||||
|
queryKey: ["teacher-test-console", assignmentId],
|
||||||
|
queryFn: () => examSecurityService.liveTestConsole(assignmentId),
|
||||||
|
enabled: !!assignmentId,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<ShieldAlert className="h-6 w-6 text-orange-500" />
|
||||||
|
Test session console
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Live attempts in this assignment + suspicious-event log per student.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 me-1 ${isFetching ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
In-progress attempts
|
||||||
|
<Badge variant="secondary" className="ms-2">{data?.in_progress.length ?? 0}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
) : data?.in_progress.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center text-muted-foreground">
|
||||||
|
No active attempts.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Student</TableHead>
|
||||||
|
<TableHead>Started</TableHead>
|
||||||
|
<TableHead className="text-center">Alerts</TableHead>
|
||||||
|
<TableHead>Last event</TableHead>
|
||||||
|
<TableHead className="text-end">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.in_progress.map((row) => (
|
||||||
|
<TableRow key={row.attempt_id}>
|
||||||
|
<TableCell className="font-medium">{row.student_name || `User #${row.student_id}`}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{row.started_at ? new Date(row.started_at).toLocaleString() : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<AlertBadge n={row.alert_count} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{row.last_event ? (
|
||||||
|
<div>
|
||||||
|
<div className="capitalize">{row.last_event.event_type.replace("_", " ")}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{row.last_event.occurred_at ? new Date(row.last_event.occurred_at).toLocaleTimeString() : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : <span className="text-muted-foreground">—</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end">
|
||||||
|
<AttemptDetailsDialog attemptId={row.attempt_id} studentName={row.student_name} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertBadge({ n }: { n: number }) {
|
||||||
|
if (n === 0) return <Badge variant="secondary">0</Badge>;
|
||||||
|
if (n < 3) return <Badge className="bg-amber-500">{n}</Badge>;
|
||||||
|
return <Badge className="bg-red-500">{n}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttemptDetailsDialog({
|
||||||
|
attemptId, studentName,
|
||||||
|
}: { attemptId: number; studentName: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["attempt-events", attemptId],
|
||||||
|
queryFn: () => examSecurityService.listAttemptEvents(attemptId),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Eye className="h-3.5 w-3.5 me-1" />Details
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{studentName} — security log</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[60vh] overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Event</TableHead>
|
||||||
|
<TableHead>Severity</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.items.map((ev: SecurityEvent) => (
|
||||||
|
<TableRow key={ev.id}>
|
||||||
|
<TableCell className="text-xs">{ev.occurred_at ? new Date(ev.occurred_at).toLocaleString() : "—"}</TableCell>
|
||||||
|
<TableCell className="capitalize">{ev.event_type.replace("_", " ")}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
ev.severity === "alert" ? "bg-red-500"
|
||||||
|
: ev.severity === "warning" ? "bg-amber-500"
|
||||||
|
: "bg-zinc-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ev.severity}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{data?.items.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={3} className="text-center text-muted-foreground">No events recorded for this attempt.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
import type { ApiSuccessResponse, PaginatedResponse, PaginationParams } from "@/types";
|
import type { ApiSuccessResponse, PaginatedResponse, PaginationParams } from "@/types";
|
||||||
|
|
||||||
export const communicationService = {
|
export const communicationService = {
|
||||||
async listDiscussionBoards(params?: { course_id?: number; batch_id?: number }): Promise<DiscussionBoard[]> {
|
async listDiscussionBoards(params?: { course_id?: number; batch_id?: number; plan_id?: number }): Promise<DiscussionBoard[]> {
|
||||||
return api.get<DiscussionBoard[]>("/discussion-boards", params as Record<string, string | number | boolean | undefined>);
|
return api.get<DiscussionBoard[]>("/discussion-boards", params as Record<string, string | number | boolean | undefined>);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -19,6 +19,17 @@ export const communicationService = {
|
|||||||
return api.post<DiscussionBoard>("/discussion-boards", data);
|
return api.post<DiscussionBoard>("/discussion-boards", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Phase 1 (2026-04-29): course/plan-scoped board lookup-or-create.
|
||||||
|
// Used by the embedded Discussion tab inside course / course-plan
|
||||||
|
// detail pages so the tab "just works" without manual setup.
|
||||||
|
async getBoardForCourse(courseId: number): Promise<DiscussionBoard> {
|
||||||
|
return api.get<DiscussionBoard>(`/discussion-boards/for-course/${courseId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBoardForPlan(planId: number): Promise<DiscussionBoard> {
|
||||||
|
return api.get<DiscussionBoard>(`/discussion-boards/for-plan/${planId}`);
|
||||||
|
},
|
||||||
|
|
||||||
async updateDiscussionBoard(id: number, data: Partial<DiscussionBoard>): Promise<DiscussionBoard> {
|
async updateDiscussionBoard(id: number, data: Partial<DiscussionBoard>): Promise<DiscussionBoard> {
|
||||||
return api.patch<DiscussionBoard>(`/discussion-boards/${id}`, data);
|
return api.patch<DiscussionBoard>(`/discussion-boards/${id}`, data);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,45 @@ import type {
|
|||||||
WorkbookGroundedSource,
|
WorkbookGroundedSource,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item returned by GET /api/student/learning. The endpoint returns
|
||||||
|
* BOTH ``op.course`` enrollments and ``encoach.course.plan``
|
||||||
|
* assignments through a discriminated union on ``kind``. Each item
|
||||||
|
* carries the ``is_mandatory`` flag so the UI can group items into
|
||||||
|
* the Accredited Core / Supplementary sections.
|
||||||
|
*/
|
||||||
|
export interface StudentLearningItem {
|
||||||
|
kind: "course" | "plan";
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
is_mandatory: boolean;
|
||||||
|
cefr_level?: string;
|
||||||
|
difficulty_level?: string;
|
||||||
|
total_weeks?: number;
|
||||||
|
status?: string;
|
||||||
|
course_id?: number | null;
|
||||||
|
course_name?: string;
|
||||||
|
entity_id?: number | null;
|
||||||
|
entity_name?: string;
|
||||||
|
code?: string;
|
||||||
|
batch_id?: number | null;
|
||||||
|
batch_name?: string;
|
||||||
|
chapter_count?: number;
|
||||||
|
completed_chapters?: number;
|
||||||
|
progress?: number;
|
||||||
|
assignment_id?: number;
|
||||||
|
assignment_mode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudentLearningResponse {
|
||||||
|
mandatory: StudentLearningItem[];
|
||||||
|
elective: StudentLearningItem[];
|
||||||
|
count_mandatory: number;
|
||||||
|
count_elective: number;
|
||||||
|
count_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REST helpers for the AI course plan generator.
|
* REST helpers for the AI course plan generator.
|
||||||
*
|
*
|
||||||
@@ -115,11 +154,46 @@ export const coursePlanService = {
|
|||||||
body_text?: string;
|
body_text?: string;
|
||||||
share_date?: string | null;
|
share_date?: string | null;
|
||||||
is_static?: boolean;
|
is_static?: boolean;
|
||||||
|
// Phase 1 (2026-04-29) — kind/exam/resource/grading edits.
|
||||||
|
kind?: "content" | "quiz" | "test" | "resource" | "assignment";
|
||||||
|
exam_template_id?: number | null;
|
||||||
|
resource_id?: number | null;
|
||||||
|
due_date?: string | null;
|
||||||
|
is_graded?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<{ data: CoursePlanMaterial }> {
|
): Promise<{ data: CoursePlanMaterial }> {
|
||||||
return api.patch(`/ai/course-plan/material/${materialId}`, payload);
|
return api.patch(`/ai/course-plan/material/${materialId}`, payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Phase 1 (2026-04-29): create a manual non-AI material (quiz /
|
||||||
|
// test / resource / assignment) inside a specific week of the
|
||||||
|
// delivery plan. The shape mirrors the new `/materials/manual` route
|
||||||
|
// on the backend.
|
||||||
|
async createManualMaterial(
|
||||||
|
planId: number,
|
||||||
|
weekNumber: number,
|
||||||
|
payload: {
|
||||||
|
title: string;
|
||||||
|
kind: "content" | "quiz" | "test" | "resource" | "assignment";
|
||||||
|
skill?: string;
|
||||||
|
material_type?: string;
|
||||||
|
summary?: string;
|
||||||
|
exam_template_id?: number | null;
|
||||||
|
resource_id?: number | null;
|
||||||
|
due_date?: string | null;
|
||||||
|
is_graded?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<CoursePlanMaterial> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/${planId}/weeks/${weekNumber}/materials/manual`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteMaterial(materialId: number): Promise<{ success: boolean }> {
|
||||||
|
return api.delete(`/ai/course-plan/material/${materialId}`);
|
||||||
|
},
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Phase A — Sources
|
// Phase A — Sources
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -329,6 +403,14 @@ export const coursePlanService = {
|
|||||||
return api.get("/student/course-plans");
|
return api.get("/student/course-plans");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Phase 1 (2026-04-29): merged "My Learning" view that returns
|
||||||
|
// both classic op.course enrollments AND AI course plans, each
|
||||||
|
// tagged with `is_mandatory` so the UI can group them under
|
||||||
|
// Accredited Core vs Supplementary.
|
||||||
|
async studentLearning(): Promise<StudentLearningResponse> {
|
||||||
|
return api.get("/student/learning");
|
||||||
|
},
|
||||||
|
|
||||||
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
|
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
|
||||||
return api.get(`/student/course-plans/${planId}`);
|
return api.get(`/student/course-plans/${planId}`);
|
||||||
},
|
},
|
||||||
|
|||||||
89
frontend/src/services/examSecurity.service.ts
Normal file
89
frontend/src/services/examSecurity.service.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export type SecurityEventType =
|
||||||
|
| "tab_blur"
|
||||||
|
| "tab_focus"
|
||||||
|
| "window_blur"
|
||||||
|
| "window_focus"
|
||||||
|
| "paste"
|
||||||
|
| "copy"
|
||||||
|
| "cut"
|
||||||
|
| "context_menu"
|
||||||
|
| "fullscreen_exit"
|
||||||
|
| "shortcut"
|
||||||
|
| "multi_attempt_block"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export type SecuritySeverity = "info" | "warning" | "alert";
|
||||||
|
|
||||||
|
export interface SecurityEventPayload {
|
||||||
|
attempt_id?: number | null;
|
||||||
|
exam_id?: number | null;
|
||||||
|
event_type: SecurityEventType;
|
||||||
|
severity?: SecuritySeverity;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityEvent {
|
||||||
|
id: number;
|
||||||
|
attempt_id: number | null;
|
||||||
|
student_id: number;
|
||||||
|
student_name: string;
|
||||||
|
exam_id: number | null;
|
||||||
|
event_type: SecurityEventType;
|
||||||
|
severity: SecuritySeverity;
|
||||||
|
occurred_at: string | null;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveTestRow {
|
||||||
|
attempt_id: number;
|
||||||
|
student_id: number | null;
|
||||||
|
student_name: string;
|
||||||
|
started_at: string | null;
|
||||||
|
alert_count: number;
|
||||||
|
last_event: SecurityEvent | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const examSecurityService = {
|
||||||
|
/** Post a single suspicious-activity event to the server. */
|
||||||
|
async postEvent(p: SecurityEventPayload): Promise<{ event_id: number; auto_locked: boolean }> {
|
||||||
|
return api.post("/exam/security/event", p);
|
||||||
|
},
|
||||||
|
|
||||||
|
async singleAttemptCheck(examId: number) {
|
||||||
|
return api.get<{
|
||||||
|
allowed: boolean;
|
||||||
|
security_level: "off" | "soft" | "strict";
|
||||||
|
single_attempt: boolean;
|
||||||
|
existing_attempt_id: number | null;
|
||||||
|
}>("/exam/single-attempt-check", { exam_id: examId });
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAssignmentEvents(assignmentId: number) {
|
||||||
|
return api.get<{ items: SecurityEvent[]; total: number }>(
|
||||||
|
`/teacher/exam/${assignmentId}/security/events`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAttemptEvents(attemptId: number) {
|
||||||
|
return api.get<{
|
||||||
|
attempt_id: number;
|
||||||
|
student_id: number | null;
|
||||||
|
student_name: string;
|
||||||
|
exam_id: number | null;
|
||||||
|
status: string;
|
||||||
|
items: SecurityEvent[];
|
||||||
|
total: number;
|
||||||
|
}>(`/teacher/exam/attempt/${attemptId}/security/events`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async liveTestConsole(assignmentId: number) {
|
||||||
|
return api.get<{
|
||||||
|
assignment_id: number;
|
||||||
|
exam_id: number;
|
||||||
|
in_progress: LiveTestRow[];
|
||||||
|
total: number;
|
||||||
|
}>(`/teacher/exam/${assignmentId}/live`);
|
||||||
|
},
|
||||||
|
};
|
||||||
69
frontend/src/services/handwritten.service.ts
Normal file
69
frontend/src/services/handwritten.service.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { api, API_BASE_URL } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export type HandwrittenStatus =
|
||||||
|
| "submitted"
|
||||||
|
| "grading"
|
||||||
|
| "graded"
|
||||||
|
| "reviewed"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export interface HandwrittenSubmission {
|
||||||
|
id: number;
|
||||||
|
student_id: number | null;
|
||||||
|
student_name: string;
|
||||||
|
material_id: number | null;
|
||||||
|
plan_id: number | null;
|
||||||
|
submitted_at: string | null;
|
||||||
|
status: HandwrittenStatus;
|
||||||
|
student_note: string;
|
||||||
|
image_count: number;
|
||||||
|
image_urls: string[];
|
||||||
|
ai_score: number | null;
|
||||||
|
ai_feedback: string;
|
||||||
|
ai_extracted_text: string;
|
||||||
|
teacher_score: number | null;
|
||||||
|
teacher_feedback: string;
|
||||||
|
reviewed_by: string;
|
||||||
|
reviewed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handwrittenService = {
|
||||||
|
async upload(materialId: number, files: File[], note = ""): Promise<HandwrittenSubmission> {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const f of files) fd.append("pages", f);
|
||||||
|
if (note) fd.append("note", note);
|
||||||
|
const token = localStorage.getItem("encoach_token") || "";
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/student/handwritten/${materialId}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: fd,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async mySubmission(materialId: number): Promise<HandwrittenSubmission | null> {
|
||||||
|
const r = await api.get<{ submission: HandwrittenSubmission | null }>(
|
||||||
|
`/student/handwritten/${materialId}`,
|
||||||
|
);
|
||||||
|
return r.submission;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listForTeacher(materialId: number) {
|
||||||
|
return api.get<{ items: HandwrittenSubmission[]; total: number }>(
|
||||||
|
`/teacher/handwritten/${materialId}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async review(submissionId: number, score: number, feedback: string) {
|
||||||
|
return api.post<HandwrittenSubmission>(
|
||||||
|
`/teacher/handwritten/${submissionId}/review`,
|
||||||
|
{ score, feedback },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -35,3 +35,9 @@ export { studentProgressService } from "./student-progress.service";
|
|||||||
export { libraryService } from "./library.service";
|
export { libraryService } from "./library.service";
|
||||||
export { activityService } from "./activity.service";
|
export { activityService } from "./activity.service";
|
||||||
export { facilityService } from "./facility.service";
|
export { facilityService } from "./facility.service";
|
||||||
|
export { examSecurityService } from "./examSecurity.service";
|
||||||
|
export { liveSessionService } from "./liveSession.service";
|
||||||
|
export { personalizedPlanService } from "./personalizedPlan.service";
|
||||||
|
export { handwrittenService } from "./handwritten.service";
|
||||||
|
export { turnitinService } from "./turnitin.service";
|
||||||
|
export { reportsExportService } from "./reportsExport.service";
|
||||||
|
|||||||
181
frontend/src/services/liveSession.service.ts
Normal file
181
frontend/src/services/liveSession.service.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { api, API_BASE_URL } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export type LiveSessionStatus = "scheduled" | "live" | "ended" | "cancelled";
|
||||||
|
|
||||||
|
export interface LiveSession {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
course_id: number | null;
|
||||||
|
course_name: string;
|
||||||
|
batch_id: number | null;
|
||||||
|
batch_name: string;
|
||||||
|
plan_id: number | null;
|
||||||
|
plan_name: string;
|
||||||
|
host_id: number | null;
|
||||||
|
host_name: string;
|
||||||
|
scheduled_at: string | null;
|
||||||
|
duration_min: number;
|
||||||
|
actual_started_at: string | null;
|
||||||
|
actual_ended_at: string | null;
|
||||||
|
status: LiveSessionStatus;
|
||||||
|
room_name: string;
|
||||||
|
waiting_room: boolean;
|
||||||
|
recording_enabled: boolean;
|
||||||
|
recording_url: string;
|
||||||
|
auto_attendance_minutes: number;
|
||||||
|
late_entry_minutes: number;
|
||||||
|
notification_sent: boolean;
|
||||||
|
participant_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LiveAttendanceStatus =
|
||||||
|
| "not_joined"
|
||||||
|
| "attending"
|
||||||
|
| "present"
|
||||||
|
| "left_early"
|
||||||
|
| "removed";
|
||||||
|
|
||||||
|
export interface LiveSessionParticipant {
|
||||||
|
id: number;
|
||||||
|
session_id: number;
|
||||||
|
user_id: number;
|
||||||
|
user_name: string;
|
||||||
|
joined_at: string | null;
|
||||||
|
left_at: string | null;
|
||||||
|
duration_seconds: number;
|
||||||
|
attendance_status: LiveAttendanceStatus;
|
||||||
|
removed_reason: string;
|
||||||
|
is_host: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveSessionDetail extends LiveSession {
|
||||||
|
participants: LiveSessionParticipant[];
|
||||||
|
enrolled_user_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLiveSessionPayload {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
scheduled_at: string;
|
||||||
|
duration_min?: number;
|
||||||
|
course_id?: number;
|
||||||
|
batch_id?: number;
|
||||||
|
plan_id?: number;
|
||||||
|
entity_id?: number;
|
||||||
|
waiting_room?: boolean;
|
||||||
|
recording_enabled?: boolean;
|
||||||
|
auto_attendance_minutes?: number;
|
||||||
|
late_entry_minutes?: number;
|
||||||
|
notify?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const liveSessionService = {
|
||||||
|
async list(filters: {
|
||||||
|
course_id?: number;
|
||||||
|
batch_id?: number;
|
||||||
|
plan_id?: number;
|
||||||
|
host_id?: number;
|
||||||
|
status?: LiveSessionStatus;
|
||||||
|
mine?: boolean;
|
||||||
|
} = {}): Promise<{ items: LiveSession[]; total: number }> {
|
||||||
|
return api.get("/live-sessions", filters);
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(payload: CreateLiveSessionPayload): Promise<LiveSession> {
|
||||||
|
return api.post("/live-sessions", payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async read(id: number): Promise<LiveSessionDetail> {
|
||||||
|
return api.get(`/live-sessions/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: number, payload: Partial<CreateLiveSessionPayload> & {
|
||||||
|
status?: LiveSessionStatus;
|
||||||
|
recording_url?: string;
|
||||||
|
}): Promise<LiveSession> {
|
||||||
|
return api.patch(`/live-sessions/${id}`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancel(id: number): Promise<{ ok: boolean }> {
|
||||||
|
return api.delete(`/live-sessions/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async notify(id: number): Promise<{ sent: number }> {
|
||||||
|
return api.post(`/live-sessions/${id}/notify`, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
async join(id: number) {
|
||||||
|
return api.post<{
|
||||||
|
ok: boolean;
|
||||||
|
participant: LiveSessionParticipant;
|
||||||
|
room_name: string;
|
||||||
|
is_host: boolean;
|
||||||
|
jitsi_jwt: string;
|
||||||
|
}>(`/live-sessions/${id}/join`, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
async leave(id: number) {
|
||||||
|
return api.post<{ ok: boolean; participant?: LiveSessionParticipant; no_participant?: boolean }>(
|
||||||
|
`/live-sessions/${id}/leave`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async end(id: number) {
|
||||||
|
return api.post<{ ok: boolean }>(`/live-sessions/${id}/end`, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
async storeRecording(id: number, url: string) {
|
||||||
|
return api.post<{ ok: boolean; url: string }>(`/live-sessions/${id}/recording`, {
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeParticipant(id: number, userId: number, reason: string) {
|
||||||
|
return api.post<{ ok: boolean; participant: LiveSessionParticipant }>(
|
||||||
|
`/live-sessions/${id}/remove-participant`,
|
||||||
|
{ user_id: userId, reason },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async attendanceRoll(id: number) {
|
||||||
|
return api.get<{
|
||||||
|
session_id: number;
|
||||||
|
status: LiveSessionStatus;
|
||||||
|
roll: {
|
||||||
|
user_id: number;
|
||||||
|
user_name: string;
|
||||||
|
attendance_status: LiveAttendanceStatus;
|
||||||
|
duration_seconds: number;
|
||||||
|
removed_reason: string;
|
||||||
|
}[];
|
||||||
|
}>(`/live-sessions/${id}/attendance`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the .ics calendar file for this session (JWT-protected) and
|
||||||
|
* trigger a browser download. Calendar apps (Gmail/Outlook/Apple Mail)
|
||||||
|
* recognize the .ics MIME type and prompt the user to add the event.
|
||||||
|
*/
|
||||||
|
async downloadIcs(id: number, filenameHint = "live-session") {
|
||||||
|
const token = localStorage.getItem("encoach_token") || "";
|
||||||
|
const res = await fetch(`${API_BASE_URL}/live-sessions/${id}/ics`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `${filenameHint}-${id}.ics`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
a.remove();
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
};
|
||||||
43
frontend/src/services/personalizedPlan.service.ts
Normal file
43
frontend/src/services/personalizedPlan.service.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export interface PersonalizedWeakness {
|
||||||
|
attempts: number;
|
||||||
|
averages: {
|
||||||
|
reading: number | null;
|
||||||
|
listening: number | null;
|
||||||
|
writing: number | null;
|
||||||
|
speaking: number | null;
|
||||||
|
};
|
||||||
|
weak_skills: string[];
|
||||||
|
weakest_skill: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalizedPlanSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
cefr_level: string;
|
||||||
|
total_weeks: number;
|
||||||
|
is_personalized: boolean;
|
||||||
|
is_mandatory: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalizedPlanResponse {
|
||||||
|
plan: PersonalizedPlanSummary | null;
|
||||||
|
weakness: PersonalizedWeakness;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const personalizedPlanService = {
|
||||||
|
async fetch(): Promise<PersonalizedPlanResponse> {
|
||||||
|
return api.get("/student/personalized-plan");
|
||||||
|
},
|
||||||
|
|
||||||
|
async generate(opts: {
|
||||||
|
cefr_level?: string;
|
||||||
|
total_weeks?: number;
|
||||||
|
focus?: string;
|
||||||
|
grammar_focus?: string[];
|
||||||
|
} = {}): Promise<PersonalizedPlanResponse> {
|
||||||
|
return api.post("/student/personalized-plan", opts);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -91,43 +91,61 @@ export interface RecordResponse {
|
|||||||
export interface ReportsFiltersResponse {
|
export interface ReportsFiltersResponse {
|
||||||
entities: { id: number; name: string }[];
|
entities: { id: number; name: string }[];
|
||||||
students: { id: number; name: string; login: string }[];
|
students: { id: number; name: string; login: string }[];
|
||||||
|
branches?: { id: number; name: string; entity_id: number | null }[];
|
||||||
|
subjects?: { id: number; name: string; code: string }[];
|
||||||
|
genders?: { value: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueryParams = Record<string, string | number | boolean | undefined>;
|
type QueryParams = Record<string, string | number | boolean | undefined>;
|
||||||
|
|
||||||
|
// Phase 1 (2026-04-29): comparative-analysis filters added to all
|
||||||
|
// three report endpoints. These params are optional everywhere so
|
||||||
|
// existing callers keep working unchanged.
|
||||||
|
export interface CommonReportFilters {
|
||||||
|
branch_id?: number;
|
||||||
|
subject_id?: number;
|
||||||
|
gender?: "m" | "f" | "o" | string;
|
||||||
|
}
|
||||||
|
|
||||||
export const reportsService = {
|
export const reportsService = {
|
||||||
async studentPerformance(params?: {
|
async studentPerformance(
|
||||||
entity_id?: number;
|
params?: {
|
||||||
level?: string;
|
entity_id?: number;
|
||||||
search?: string;
|
level?: string;
|
||||||
since?: string;
|
search?: string;
|
||||||
}): Promise<StudentPerformanceResponse> {
|
since?: string;
|
||||||
|
} & CommonReportFilters,
|
||||||
|
): Promise<StudentPerformanceResponse> {
|
||||||
return api.get<StudentPerformanceResponse>(
|
return api.get<StudentPerformanceResponse>(
|
||||||
"/reports/student-performance",
|
"/reports/student-performance",
|
||||||
params as QueryParams,
|
params as QueryParams,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async statsCorporate(params?: {
|
async statsCorporate(
|
||||||
entity_id?: number;
|
params?: {
|
||||||
threshold?: number;
|
entity_id?: number;
|
||||||
months?: number;
|
threshold?: number;
|
||||||
since?: string;
|
months?: number;
|
||||||
}): Promise<StatsCorporateResponse> {
|
since?: string;
|
||||||
|
} & CommonReportFilters,
|
||||||
|
): Promise<StatsCorporateResponse> {
|
||||||
return api.get<StatsCorporateResponse>(
|
return api.get<StatsCorporateResponse>(
|
||||||
"/reports/stats-corporate",
|
"/reports/stats-corporate",
|
||||||
params as QueryParams,
|
params as QueryParams,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async record(params?: {
|
async record(
|
||||||
user_id?: number;
|
params?: {
|
||||||
entity_id?: number;
|
user_id?: number;
|
||||||
period?: "day" | "week" | "month";
|
entity_id?: number;
|
||||||
status?: string;
|
period?: "day" | "week" | "month";
|
||||||
page?: number;
|
status?: string;
|
||||||
size?: number;
|
page?: number;
|
||||||
}): Promise<RecordResponse> {
|
size?: number;
|
||||||
|
} & CommonReportFilters,
|
||||||
|
): Promise<RecordResponse> {
|
||||||
return api.get<RecordResponse>("/reports/record", params as QueryParams);
|
return api.get<RecordResponse>("/reports/record", params as QueryParams);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
53
frontend/src/services/reportsExport.service.ts
Normal file
53
frontend/src/services/reportsExport.service.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { API_BASE_URL } from "@/lib/api-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a CSV/PDF download for one of the admin reports.
|
||||||
|
* The browser handles the download via the Content-Disposition
|
||||||
|
* header from the backend; we add the JWT manually because the
|
||||||
|
* <a download> tag cannot send Authorization headers.
|
||||||
|
*/
|
||||||
|
async function download(path: string, filename: string, params: Record<string, unknown> = {}) {
|
||||||
|
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && v !== "") {
|
||||||
|
url.searchParams.set(k, String(v));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const token = localStorage.getItem("encoach_token") || "";
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
a.remove();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reportsExportService = {
|
||||||
|
async studentPerformance(format: "csv" | "pdf", filters: Record<string, unknown> = {}) {
|
||||||
|
const ext = format === "pdf" ? "pdf" : "csv";
|
||||||
|
return download(
|
||||||
|
"/reports/student-performance/export",
|
||||||
|
`student-performance.${ext}`,
|
||||||
|
{ ...filters, format },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async record(format: "csv" | "pdf", filters: Record<string, unknown> = {}) {
|
||||||
|
const ext = format === "pdf" ? "pdf" : "csv";
|
||||||
|
return download(
|
||||||
|
"/reports/record/export",
|
||||||
|
`attempt-record.${ext}`,
|
||||||
|
{ ...filters, format },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
42
frontend/src/services/turnitin.service.ts
Normal file
42
frontend/src/services/turnitin.service.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export interface TurnitinSettings {
|
||||||
|
entity_id: number;
|
||||||
|
entity_name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
api_url: string;
|
||||||
|
account_id: string;
|
||||||
|
has_api_key: boolean;
|
||||||
|
api_key: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const turnitinService = {
|
||||||
|
async getSettings(entityId: number): Promise<TurnitinSettings> {
|
||||||
|
return api.get(`/turnitin/settings/${entityId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async patchSettings(entityId: number, payload: {
|
||||||
|
enabled?: boolean;
|
||||||
|
api_key?: string;
|
||||||
|
api_url?: string;
|
||||||
|
account_id?: string;
|
||||||
|
}): Promise<TurnitinSettings> {
|
||||||
|
return api.patch(`/turnitin/settings/${entityId}`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async test(entityId: number) {
|
||||||
|
return api.post<{
|
||||||
|
ok: boolean;
|
||||||
|
job_id: string;
|
||||||
|
originality_score: number | null;
|
||||||
|
}>(`/turnitin/test/${entityId}`, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
async submit(text: string, title: string, entityId?: number) {
|
||||||
|
return api.post<{
|
||||||
|
job_id: string;
|
||||||
|
entity_id: number;
|
||||||
|
originality_score: number | null;
|
||||||
|
}>(`/turnitin/submit`, { text, title, entity_id: entityId });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -7,6 +7,8 @@ export interface DiscussionBoard {
|
|||||||
batch_name?: string;
|
batch_name?: string;
|
||||||
chapter_id?: number;
|
chapter_id?: number;
|
||||||
chapter_name?: string;
|
chapter_name?: string;
|
||||||
|
plan_id?: number | null;
|
||||||
|
plan_name?: string | null;
|
||||||
assignment_id?: number;
|
assignment_id?: number;
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
allow_student_posts: boolean;
|
allow_student_posts: boolean;
|
||||||
|
|||||||
@@ -190,6 +190,16 @@ export interface CoursePlanWeek {
|
|||||||
material_count: number;
|
material_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 1 (2026-04-29): material `kind` describes how the student
|
||||||
|
// interacts with the row. Existing materials (created before the
|
||||||
|
// migration) default to `content` so the UI can keep rendering them.
|
||||||
|
export type CoursePlanMaterialKind =
|
||||||
|
| "content"
|
||||||
|
| "quiz"
|
||||||
|
| "test"
|
||||||
|
| "resource"
|
||||||
|
| "assignment";
|
||||||
|
|
||||||
export interface CoursePlanMaterial {
|
export interface CoursePlanMaterial {
|
||||||
id: number;
|
id: number;
|
||||||
plan_id: number;
|
plan_id: number;
|
||||||
@@ -197,6 +207,18 @@ export interface CoursePlanMaterial {
|
|||||||
week_number: number;
|
week_number: number;
|
||||||
skill: string;
|
skill: string;
|
||||||
material_type: CoursePlanMaterialType | string;
|
material_type: CoursePlanMaterialType | string;
|
||||||
|
/** Phase 1: top-level interaction kind (content / quiz / test / …). */
|
||||||
|
kind?: CoursePlanMaterialKind;
|
||||||
|
/** Phase 1: optional link to an exam template (used when kind=quiz/test). */
|
||||||
|
exam_template_id?: number | null;
|
||||||
|
exam_template_name?: string | null;
|
||||||
|
/** Phase 1: optional link to a library resource (used when kind=resource). */
|
||||||
|
resource_id?: number | null;
|
||||||
|
resource_name?: string | null;
|
||||||
|
/** Phase 1: whether this row counts toward the course grade. */
|
||||||
|
is_graded?: boolean;
|
||||||
|
/** Phase 1: optional deadline displayed on the student weekly view. */
|
||||||
|
due_date?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
is_static?: boolean;
|
is_static?: boolean;
|
||||||
share_date?: string | null;
|
share_date?: string | null;
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export default {
|
|||||||
DEFAULT: "hsl(var(--primary))",
|
DEFAULT: "hsl(var(--primary))",
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
},
|
},
|
||||||
|
// Dark charcoal CTA (default Button color in the new visual
|
||||||
|
// identity). Kept distinct from `primary` so the brand orange
|
||||||
|
// remains available for accents / highlights / charts.
|
||||||
|
cta: {
|
||||||
|
DEFAULT: "hsl(var(--cta))",
|
||||||
|
foreground: "hsl(var(--cta-foreground))",
|
||||||
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
@@ -76,9 +83,15 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
|
// `--radius` is now 1rem (16px) — matching the soft-card / pill
|
||||||
|
// language of the new visual identity. The full ladder gives
|
||||||
|
// shadcn primitives sensible scaled steps without needing
|
||||||
|
// every component to opt in to a specific class.
|
||||||
|
sm: "calc(var(--radius) - 8px)",
|
||||||
|
md: "calc(var(--radius) - 4px)",
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
xl: "calc(var(--radius) + 4px)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
"2xl": "calc(var(--radius) + 12px)",
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
"accordion-down": {
|
"accordion-down": {
|
||||||
|
|||||||
Reference in New Issue
Block a user