6 Commits

Author SHA1 Message Date
Yamen Ahmad
62075c30aa merge: pull production deploy workflow from main into v4 2026-04-30 14:28:03 +04:00
Yamen Ahmad
d5b1987bba 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
2026-04-30 14:06:48 +04:00
DevOps
747e911829 ci: add production deploy workflow for encoach_v4 monorepo 2026-04-28 00:39:41 +02:00
Yamen Ahmad
744cede1a3 docs(security): redact live OpenAI key, point to ir.config_parameter storage
The previous local-only edit appended a working sk-proj-* key in plain text.
Replaced with a placeholder referencing the Odoo `encoach_ai.openai_api_key`
config parameter (the actual storage). The visible key should be rotated.

Made-with: Cursor
2026-04-28 00:39:37 +04:00
Yamen Ahmad
3b62075d7e feat(platform): course-plan student visibility, multi-voice TTS, OCR sources, branches
Backend (encoach_ai_course):
- workbook_attempt model + scoring + REST endpoints for student attempts
- dialogue_parser splits scripts by speaker, classifies gender, strips labels
- media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation,
  manual media upload endpoint (audio/image/video) with size validation
- source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs,
  page-streaming to stay under memory limit
- exercise_extractor + rag_context for RAG-grounded interactive workbooks
- course_plan_pipeline: v2 generator that grounds week material on indexed
  sources and persists grounded_on_json metadata
- security: access rules for new models

Backend (encoach_lms_api):
- branches model + controller (entity-scoped LMS branches)
- classroom_ext + course_ext (assignment + section workflow)
- classrooms controller: students/teachers/assign-course endpoints

Frontend:
- StudentDashboard: surface assigned AI course plans alongside enrollments;
  enrolled-courses stat now counts plans+enrollments
- InteractiveWorkbook + PlanReader components
- AdminCoursePlanDetail: media drawer with upload buttons (audio/image/video),
  hidden file inputs, upload mutation
- AdminBranches page + sidebar entry
- coursePlan/lms/classrooms services + types updated for new endpoints
- i18n: studentDash.myCoursePlans/noCoursePlans (en + ar)

Infra & docs:
- odoo.conf: bump memory limits to 4G/5G for OCR + sentence-transformers
- .gitignore: ignore *.tsbuildinfo
- docs/ASSIGNMENT_WORKFLOW.{md,pdf}
- smoke_*.py end-to-end tests for assignment workflow, entity isolation,
  course-plan RAG pipeline

Made-with: Cursor
2026-04-28 00:17:51 +04:00
Yamen Ahmad
a6f2140a9d docs(summary): record 2026-04-27 course-plan rollout and OCR indexing fix
Update PROJECT_SUMMARY with the Professional Interactive Course Plans delivery, scanned-PDF OCR indexing remediation, regression/browser verification results, and the current OpenAI insufficient_quota fallback behavior.

Made-with: Cursor
2026-04-27 22:35:00 +04:00
109 changed files with 18545 additions and 1103 deletions

47
.cursorindexingignore Normal file
View File

@@ -0,0 +1,47 @@
# =====================================================================
# .cursorindexingignore — files Cursor should NOT index for semantic
# search, but that you can still open or @-mention on demand.
#
# Use this for vendored / third-party / generated code that is
# occasionally useful to read but should not pollute search results
# or be sent to the model on every agent turn.
#
# (Note: .gitignore is already honored by Cursor's indexer, so we
# only list things NOT already in .gitignore here.)
# =====================================================================
# ---------- Vendored OpenEduCat ERP (LGPL-3, tracked in repo) ----------
# 48 MB of third-party Odoo modules. Required at runtime but rarely
# needs to be in semantic search context.
backend/openeducat_erp-19.0/
# ---------- Other vendored snapshots inside new_project/ --------------
# Some of these are gitignored already; listed defensively.
new_project/enterprise-19/
new_project/openeducat_erp-19.0/
new_project/encoach_frontend_new_v1/
# ---------- Translation catalogs (huge, low semantic value) ------------
# .po / .pot files are massive and rarely useful to the agent.
**/i18n/*.po
**/i18n/*.pot
**/i18n_extra/*.po
**/i18n_extra/*.pot
# ---------- Vendored static assets inside Odoo modules -----------------
# These are minified third-party JS/CSS, fonts, icons, screenshots —
# never useful for code understanding.
**/static/lib/**
**/static/fonts/**
**/static/description/**
**/static/src/img/**
**/static/src/scss/lib/**
# ---------- Generated docs ---------------------------------------------
**/doc/_build/
**/docs/_build/
# ---------- Database / Odoo backup dumps ------------------------------
*.dump
*.sql.gz
*.sql.xz

View File

@@ -1,49 +1,74 @@
name: Deploy to Staging
name: Deploy EnCoach
# Triggered on every push to main (after PR merge).
# Runs ONLY the deploy job — not tests — so the server
# always reflects the latest main without waiting for the
# full CI suite to finish. The existing ci.yml handles lint/tests.
on:
push:
branches: [main]
concurrency:
group: deploy-backend
cancel-in-progress: true
jobs:
deploy:
name: Deploy backend + frontend to staging
runs-on: self-hosted
runs-on: ubuntu-latest
steps:
- name: Pull latest code
run: |
cd /opt/encoach/encoach_backend_new_v2
git fetch origin
cd /opt/encoach/encoach_v4
git fetch origin main
git reset --hard origin/main
echo "Now at: $(git log -1 --oneline)"
- name: Rebuild and restart containers
- name: Apply server-side patches
run: |
cd /opt/encoach/encoach_backend_new_v2
docker compose \
-f docker-compose.yml \
-f /opt/encoach/overrides/encoach.override.yml \
up -d --build --remove-orphans
echo "Containers after deploy:"
docker ps --format "table {{.Names}}\t{{.Status}}" | grep -E "encoach-v4|encoach-frontend"
cd /opt/encoach/encoach_v4
# Fix pip for Debian Bookworm (developer runs macOS — his Dockerfile doesn't need these flags)
sed -i \
's|RUN pip3 install --no-cache-dir|RUN pip3 install --break-system-packages --no-cache-dir --ignore-installed typing_extensions|' \
backend/Dockerfile || true
# Ensure required Python packages are present
grep -q "sentence_transformers" backend/requirements.txt || \
echo "sentence_transformers>=2.7.0" >> backend/requirements.txt
echo "Dockerfile pip line:"; grep "pip3" backend/Dockerfile
echo "Extra packages:"; grep -E "langgraph|sentence" backend/requirements.txt
- name: Copy odoo.conf into build context
run: cp /opt/encoach/overrides/odoo.conf /opt/encoach/encoach_v4/backend/odoo.conf
- name: Build backend image
run: |
cd /opt/encoach/encoach_v4
docker compose -f docker-compose.yml -f /opt/encoach/overrides/encoach.override.yml build odoo
- name: Build frontend image
run: |
cd /opt/encoach/encoach_v4
docker compose -f docker-compose.yml -f /opt/encoach/overrides/encoach.override.yml build frontend
- name: Cleanup build context
run: rm -f /opt/encoach/encoach_v4/backend/odoo.conf
- name: Run DB migrations
run: |
MODULES=$(docker exec encoach-v4-db psql -U odoo -d encoach_v2 -tAc \
"SELECT string_agg(name, ',') FROM ir_module_module WHERE state='installed' AND name LIKE 'encoach%';")
echo "Upgrading: $MODULES"
docker run --rm \
--network encoach_v4_default \
-v /opt/encoach/overrides/odoo.conf:/etc/odoo/odoo.conf:ro \
-v /opt/encoach/encoach_v4/backend:/mnt/extra-addons:ro \
-v /opt/encoach/encoach_v4/backend/openeducat_erp-19.0/openeducat_erp-19.0:/mnt/extra-addons/openeducat_erp-19.0:ro \
-v encoach_backend_new_v2_odoo-web-data:/var/lib/odoo \
encoach-backend:latest \
odoo -u "$MODULES" --stop-after-init 2>&1 | tail -5
- name: Restart services
run: |
cd /opt/encoach/encoach_v4
docker compose -f docker-compose.yml -f /opt/encoach/overrides/encoach.override.yml \
up -d --no-deps odoo frontend
- name: Smoke test
run: |
echo "Waiting 20s for Odoo to settle..."
sleep 20
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8069/api/health)
echo "/api/health => HTTP $STATUS"
if [ "$STATUS" != "200" ]; then
echo "WARNING: /api/health returned $STATUS (Odoo may still be starting)"
fi
FE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/)
echo "frontend / => HTTP $FE"
test "$FE" = "200" || exit 1
for i in $(seq 1 12); do
CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8069/api/health)
[ "$CODE" = "200" ] && echo "Health OK: HTTP $CODE" && exit 0
echo "Attempt $i: HTTP $CODE — waiting 10s..."
sleep 10
done
echo "Health check failed after 2 min" && exit 1

1
.gitignore vendored
View File

@@ -26,6 +26,7 @@ pgdata_bak_*/
frontend/.vite/
frontend/dist/
frontend/node_modules/
*.tsbuildinfo
# IDE
.vscode/

View File

@@ -27,6 +27,9 @@ from odoo.addons.encoach_ai_course.services.deliverables import (
compute_deliverables,
)
from odoo.addons.encoach_ai_course.services.media_service import MediaService
from odoo.addons.encoach_ai_course.services.exercise_extractor import (
ExerciseExtractor,
)
_logger = logging.getLogger(__name__)
@@ -45,10 +48,19 @@ def _request_language():
def _entity_scope():
"""Return (entity_ids, is_superadmin) for current JWT user."""
"""Return (entity_ids, is_superadmin) for current JWT user.
Mirrors ``encoach_lms_api.controllers.lms_core._entity_scope``: any user
linked to one or more entities is entity-scoped, even when they carry
Odoo settings groups.
"""
user = request.env.user.sudo()
is_super = bool(user.has_group('base.group_system'))
entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
if entity_ids:
return entity_ids, False
is_super = bool(
user.id == 1 or user.has_group('base.group_system')
)
return entity_ids, is_super
@@ -222,6 +234,213 @@ class CoursePlanController(http.Controller):
_logger.exception('course-plan.generate_week_materials failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<id>/weeks/<n>/materials/v2
# ------------------------------------------------------------------
# RAG-grounded richer-schema generator. Requires at least one indexed
# source on the plan; if none exists, the response is a 409 so the UI
# can prompt the user to upload a reference book first. The pipeline
# is otherwise identical to /materials but persists ``grounded_on_json``
# on each created row and returns a ``rag.sources_used`` summary.
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials/v2',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def generate_week_materials_v2(self, plan_id, week_number, **kw):
try:
plan = self._get_plan_scoped(plan_id)
from odoo.addons.encoach_ai_course.services.rag_context import (
RAGContextBuilder,
)
builder = RAGContextBuilder(request.env)
if not builder.has_indexed_sources(plan):
return _error_response(
'No indexed reference sources for this plan. '
'Upload at least one PDF/DOCX before using v2.',
409,
)
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
materials = pipeline.generate_week_materials(
plan_id, week_number, use_rag=True,
)
sources_used = sorted({
src['source_id']
for m in materials
for src in (m._loads(m.grounded_on_json, []) or [])
if src.get('source_id')
})
return _json_response({
'items': [m.to_api_dict() for m in materials],
'count': len(materials),
'rag': {
'enabled': True,
'sources_used': sources_used,
},
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.generate_week_materials_v2 failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<id>/extract-workbooks
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>/extract-workbooks',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def extract_workbooks(self, plan_id, **kw):
"""Mine indexed sources for original exercises and persist them
as ``interactive_workbook`` materials on the plan."""
try:
plan = self._get_plan_scoped(plan_id)
body = _get_json_body() or {}
try:
max_batches = int(body.get('max_batches') or 0) or 8
except (TypeError, ValueError):
max_batches = 8
extractor = ExerciseExtractor(
request.env, language=_request_language(),
)
stats = extractor.run(plan, max_batches=max_batches)
return _json_response({
'data': stats,
'plan_id': plan.id,
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.extract_workbooks failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/material/<id>/grounding
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/material/<int:material_id>/grounding',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_material_grounding(self, material_id, **kw):
"""Return the source citations + extracted-from provenance for a material."""
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
grounded = material._loads(material.grounded_on_json, [])
extracted = material._loads(material.extracted_from_json, None)
sources = []
ids = [
int(s.get('source_id')) for s in (grounded or [])
if s.get('source_id')
]
if extracted and extracted.get('source_id'):
ids.append(int(extracted['source_id']))
if ids:
Source = request.env['encoach.course.plan.source'].sudo()
rows = Source.browse(list(set(ids)))
sources = [r.to_api_dict() for r in rows if r.exists()]
return _json_response({
'material_id': material.id,
'grounded_on': grounded if isinstance(grounded, list) else [],
'extracted_from': extracted if isinstance(extracted, dict) else None,
'sources': sources,
})
except Exception as exc:
_logger.exception('course-plan.get_material_grounding failed')
code = 404 if isinstance(exc, ValueError) else (
403 if isinstance(exc, PermissionError) else 500
)
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
# ------------------------------------------------------------------
@@ -482,6 +701,22 @@ class CoursePlanController(http.Controller):
# Keep non-technical editing simple: if only plain text is
# provided, mirror it into a minimal JSON structure.
vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
# Phase 1 (2026-04-29) — kind/exam/resource/grading edits.
if 'kind' in body:
kind = (body.get('kind') or '').strip().lower()
if kind not in ('content', 'quiz', 'test', 'resource', 'assignment'):
return _error_response('Unsupported kind', 400)
vals['kind'] = kind
if 'exam_template_id' in body:
v = body.get('exam_template_id')
vals['exam_template_id'] = int(v) if v else False
if 'resource_id' in body:
v = body.get('resource_id')
vals['resource_id'] = int(v) if v else False
if 'due_date' in body:
vals['due_date'] = body.get('due_date') or False
if 'is_graded' in body:
vals['is_graded'] = bool(body.get('is_graded'))
if vals:
material.sudo().write(vals)
return _json_response({'data': material.to_api_dict()})
@@ -552,6 +787,106 @@ class CoursePlanController(http.Controller):
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route(
'/api/ai/course-plan/material/<int:material_id>/media/upload',
type='http', auth='none', methods=['POST'], csrf=False,
)
@jwt_required
def upload_media(self, material_id, **kw):
"""Attach an admin-supplied audio / image / video file to a material.
Multipart form fields:
* ``kind`` — ``audio`` | ``image`` | ``video`` (required)
* ``file`` — the binary blob (required)
* ``title`` — optional human label, defaults to the filename
The persisted ``encoach.course.plan.media`` row is marked
``provider='manual'`` so the drawer renders it distinctly from
AI-generated assets and admins know which clips were hand-curated.
Hard 100 MB cap on the upload to defend the filestore against an
accidental 4K-master-from-a-DSLR upload.
"""
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
kind = (request.httprequest.form.get('kind') or '').strip().lower()
if kind not in ('audio', 'image', 'video'):
return _error_response(
"kind must be one of 'audio', 'image', 'video'", 400,
)
uploaded = request.httprequest.files.get('file')
if not uploaded or not uploaded.filename:
return _error_response(
"Multipart field 'file' is required", 400,
)
data = uploaded.read()
if not data:
return _error_response('Uploaded file is empty', 400)
# 100 MB upper bound — well above the largest realistic
# listening track or lesson video, well below anything that
# could OOM the worker on base64-encode.
if len(data) > 100 * 1024 * 1024:
return _error_response(
'File too large — 100 MB maximum per upload', 413,
)
mime_type = (uploaded.mimetype or '').lower() or 'application/octet-stream'
expected_prefix = {
'audio': 'audio/',
'image': 'image/',
'video': 'video/',
}[kind]
# We don't *require* a strict mime match (browsers sometimes
# send octet-stream for .m4a, etc.) but if a caller flags
# ``kind=audio`` and uploads a clearly-image MIME, that's a
# real bug worth surfacing rather than silently swallowing.
if (mime_type
and not mime_type.startswith(expected_prefix)
and mime_type != 'application/octet-stream'):
return _error_response(
f"MIME {mime_type!r} does not match kind={kind!r}", 400,
)
title = (
(request.httprequest.form.get('title') or '').strip()
or uploaded.filename
)
Media = request.env['encoach.course.plan.media'].sudo()
media = Media.create({
'plan_id': material.plan_id.id,
'week_id': material.week_id.id if material.week_id else False,
'material_id': material.id,
'kind': kind,
'provider': 'manual',
'title': title,
'mime_type': mime_type,
'size_bytes': len(data),
'status': 'generating',
})
attach = request.env['ir.attachment'].sudo().create({
'name': uploaded.filename,
'type': 'binary',
'datas': base64.b64encode(data),
'mimetype': mime_type,
'res_model': 'encoach.course.plan.media',
'res_id': media.id,
'public': True,
})
media.write({
'attachment_id': attach.id,
'status': 'ready',
'error': False,
})
return _json_response({'data': media.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.upload_media failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/material/<int:material_id>/media',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
@@ -775,6 +1110,114 @@ class CoursePlanController(http.Controller):
# PHASE E — Student-side endpoints
# ==================================================================
# ------------------------------------------------------------------
# GET /api/student/learning
# ------------------------------------------------------------------
# Phase 1 (2026-04-29): the merged "My Learning" page in the
# student portal asked for a single endpoint that returns BOTH
# traditional ``op.course`` enrollments AND AI-generated
# ``encoach.course.plan`` assignments, each tagged with the
# ``is_mandatory`` flag so the UI can group them under
# "Accredited Core" vs "Supplementary". We re-use the existing
# visibility rules (op.student.course for courses, the
# ``encoach.course.plan.assignment`` graph for plans) so this
# endpoint stays a thin aggregator instead of a new code path.
@http.route('/api/student/learning',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def student_learning(self, **kw):
try:
user = request.env.user
uid = user.id
# --- AI course plans ---------------------------------------
Assignment = request.env['encoach.course.plan.assignment'].sudo()
assignments = Assignment.search([('state', '=', 'active')])
seen_plan_ids = set()
plan_items = []
for a in assignments:
if a.plan_id.id in seen_plan_ids:
continue
visible = False
if a.mode == 'students' and uid in a.student_user_ids.ids:
visible = True
elif a.mode in ('batch', 'entities') and uid in a.expand_user_ids():
visible = True
if not visible:
continue
seen_plan_ids.add(a.plan_id.id)
p = a.plan_id
plan_items.append({
'kind': 'plan',
'id': p.id,
'name': p.name or '',
'description': p.description or '',
'is_mandatory': bool(p.is_mandatory),
'cefr_level': p.cefr_level or '',
'total_weeks': p.total_weeks or 0,
'status': p.status or 'draft',
'course_id': p.course_id.id if p.course_id else None,
'course_name': p.course_id.name if p.course_id else '',
'entity_id': p.entity_id.id if p.entity_id else None,
'entity_name': p.entity_id.name if p.entity_id else '',
'assignment_id': a.id,
'assignment_mode': a.mode or '',
})
# --- Traditional op.course enrollments ---------------------
Student = request.env['op.student'].sudo()
student = Student.search([('user_id', '=', uid)], limit=1)
course_items = []
if student:
StudentCourse = request.env['op.student.course'].sudo()
regs = StudentCourse.search([('student_id', '=', student.id)])
for cd in regs:
c = cd.course_id
if not c:
continue
chapters = request.env['encoach.course.chapter'].sudo().search(
[('course_id', '=', c.id)],
)
progress_recs = request.env['encoach.chapter.progress'].sudo().search([
('student_id', '=', student.id),
('chapter_id', 'in', chapters.ids),
])
completed = len(progress_recs.filtered(lambda p: p.status == 'completed'))
total = len(chapters) or 0
course_items.append({
'kind': 'course',
'id': c.id,
'name': c.name or '',
'code': c.code or '',
'description': getattr(c, 'description', '') or '',
'is_mandatory': bool(getattr(c, 'is_mandatory', False)),
'cefr_level': getattr(c, 'cefr_level', '') or '',
'difficulty_level': getattr(c, 'difficulty_level', '') or '',
'batch_id': cd.batch_id.id if cd.batch_id else None,
'batch_name': cd.batch_id.name if cd.batch_id else '',
'chapter_count': total,
'completed_chapters': completed,
'progress': int((completed / total * 100) if total else 0),
'entity_id': c.entity_id.id if getattr(c, 'entity_id', False) else None,
'entity_name': c.entity_id.name if getattr(c, 'entity_id', False) else '',
})
mandatory = [i for i in (course_items + plan_items) if i.get('is_mandatory')]
elective = [i for i in (course_items + plan_items) if not i.get('is_mandatory')]
mandatory.sort(key=lambda i: i.get('name', '').lower())
elective.sort(key=lambda i: i.get('name', '').lower())
return _json_response({
'mandatory': mandatory,
'elective': elective,
'count_mandatory': len(mandatory),
'count_elective': len(elective),
'count_total': len(mandatory) + len(elective),
})
except Exception as exc:
_logger.exception('course-plan.student_learning failed')
return _error_response(str(exc), 500)
@http.route('/api/student/course-plans',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
@@ -819,6 +1262,155 @@ class CoursePlanController(http.Controller):
_logger.exception('course-plan.student_list_plans failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# POST /api/student/course-plans/<plan_id>/materials/<material_id>/attempts
# ------------------------------------------------------------------
@http.route(
'/api/student/course-plans/<int:plan_id>/materials/<int:material_id>/attempts',
type='http', auth='none', methods=['POST'], csrf=False,
)
@jwt_required
def student_save_attempt(self, plan_id, material_id, **kw):
"""Persist a workbook attempt and return the freshly-graded score.
Body shape::
{ "answers": { "<exercise_id>": "<student answer>", ... },
"finalize": false }
The response is the same shape as ``GET .../attempts/me`` so the
UI can replace its local state in one swap.
"""
try:
user = request.env.user
material = self._resolve_attempt_target(plan_id, material_id, user)
body = _get_json_body() or {}
answers = body.get('answers') or {}
if not isinstance(answers, dict):
return _error_response('answers must be an object', 400)
finalize = bool(body.get('finalize'))
Attempt = request.env['encoach.course.plan.workbook.attempt'].sudo()
attempt = Attempt.search([
('material_id', '=', material.id),
('student_id', '=', user.id),
], order='attempt_number desc', limit=1)
if not attempt:
attempt = Attempt.create({
'material_id': material.id,
'student_id': user.id,
'attempt_number': 1,
})
elif attempt.is_final:
# Once finalised, a re-submit becomes a new attempt so the
# original locked attempt survives in the audit trail.
attempt = Attempt.create({
'material_id': material.id,
'student_id': user.id,
'attempt_number': attempt.attempt_number + 1,
})
attempt.grade(answers, finalize=finalize)
return _json_response({'data': attempt.to_api_dict()})
except PermissionError as exc:
return _error_response(str(exc), 403)
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.student_save_attempt failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# GET /api/student/course-plans/<plan_id>/materials/<material_id>/attempts/me
# ------------------------------------------------------------------
@http.route(
'/api/student/course-plans/<int:plan_id>/materials/<int:material_id>/attempts/me',
type='http', auth='none', methods=['GET'], csrf=False,
)
@jwt_required
def student_my_attempt(self, plan_id, material_id, **kw):
try:
user = request.env.user
material = self._resolve_attempt_target(plan_id, material_id, user)
attempt = request.env['encoach.course.plan.workbook.attempt'].sudo().search([
('material_id', '=', material.id),
('student_id', '=', user.id),
], order='attempt_number desc', limit=1)
if not attempt:
return _json_response({'data': None})
return _json_response({'data': attempt.to_api_dict()})
except PermissionError as exc:
return _error_response(str(exc), 403)
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.student_my_attempt failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<plan_id>/materials/<material_id>/attempts
# ------------------------------------------------------------------
@http.route(
'/api/ai/course-plan/<int:plan_id>/materials/<int:material_id>/attempts',
type='http', auth='none', methods=['GET'], csrf=False,
)
@jwt_required
def list_material_attempts(self, plan_id, material_id, **kw):
"""Teacher dashboard: every student's latest attempt on a material."""
try:
self._get_plan_scoped(plan_id)
material = self._resolve_material(material_id)
if not material or material.plan_id.id != int(plan_id):
return _error_response('Material not found', 404)
attempts = request.env['encoach.course.plan.workbook.attempt'].sudo().search([
('material_id', '=', material.id),
], order='student_id, attempt_number desc')
# Latest attempt per student.
latest_by_student: dict[int, object] = {}
for a in attempts:
latest_by_student.setdefault(a.student_id.id, a)
items = [a.to_api_dict() for a in latest_by_student.values()]
return _json_response({
'items': items,
'count': len(items),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.list_material_attempts failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
def _resolve_attempt_target(self, plan_id, material_id, user):
"""Verify the material exists, belongs to the plan, and the student
actually has the plan assigned (entity-based access for non-students
is also allowed so admins / teachers can self-test the workbook).
"""
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
raise ValueError('Plan not found')
material = request.env['encoach.course.plan.material'].sudo().browse(
int(material_id),
)
if not material.exists() or material.plan_id.id != plan.id:
raise ValueError('Material not found')
# Student assignment path.
for a in plan.assignment_ids.filtered(lambda x: x.state == 'active'):
if a.mode == 'students' and user.id in a.student_user_ids.ids:
return material
if a.mode == 'batch' and user.id in a.expand_user_ids():
return material
if a.mode == 'entities' and user.id in a.expand_user_ids():
return material
# Entity-admin / teacher fallback so they can preview-and-submit.
entity_ids, is_super = _entity_scope()
if is_super:
return material
if entity_ids and plan.entity_id and plan.entity_id.id in entity_ids:
return material
raise PermissionError('Plan not assigned to you')
@http.route('/api/student/course-plans/<int:plan_id>',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required

View File

@@ -4,3 +4,4 @@ from . import course_plan
from . import course_plan_source
from . import course_plan_media
from . import course_plan_assignment
from . import workbook_attempt

View File

@@ -44,10 +44,27 @@ MATERIAL_TYPE_SELECTION = [
('grammar_lesson', 'Grammar Lesson'),
('vocabulary_list', 'Vocabulary List'),
('practice', 'Practice Exercises'),
('interactive_workbook', 'Interactive Workbook'),
('other', 'Other'),
]
# Phase 1 (2026-04-29): teachers asked to be able to drop a quiz, a
# test, a supplementary resource, or a graded assignment into any
# week of the delivery plan. ``material_type`` keeps describing *what
# kind of teaching content this is* (reading text, listening script,
# …); the new ``kind`` field describes *how the student interacts
# with it* — read, take a test, attempt a quiz, submit work — and is
# what the weekly-plan UI surfaces as a top-level filter.
MATERIAL_KIND_SELECTION = [
('content', 'Reading / Lesson'),
('quiz', 'Quiz'),
('test', 'Graded Test'),
('resource', 'Supplementary Resource'),
('assignment', 'Assignment'),
]
class CoursePlan(models.Model):
_name = 'encoach.course.plan'
_description = 'AI-generated Course Plan'
@@ -113,6 +130,18 @@ class CoursePlan(models.Model):
('archived', 'Archived'),
], default='draft')
# Same flag as op.course.is_mandatory so the merged "My Learning"
# page can group both kinds of records under the same two
# sections (Accredited Core vs Supplementary).
is_mandatory = fields.Boolean(
string='Mandatory',
default=False,
index=True,
help='Accredited core plan (mandatory). When True the plan is '
'grouped under "Accredited Core" on the student learning '
'page and tagged as Mandatory.',
)
brief_json = fields.Text(
help='Original brief that was sent to the AI — kept for audit and '
'so the user can re-generate if the first pass disappoints.',
@@ -182,6 +211,7 @@ class CoursePlan(models.Model):
'course_id': self.course_id.id if self.course_id else None,
'course_name': self.course_id.name if self.course_id else '',
'cefr_level': self.cefr_level or '',
'is_mandatory': bool(self.is_mandatory),
'total_weeks': self.total_weeks or 0,
'contact_hours_per_week': self.contact_hours_per_week or 0,
'skills_division': self.skills_division or '',
@@ -281,6 +311,44 @@ class CoursePlanMaterial(models.Model):
material_type = fields.Selection(
MATERIAL_TYPE_SELECTION, required=True, default='other',
)
# Phase 1 (2026-04-29): top-level interaction kind. Reading-style
# materials default to ``content``; teachers can flip a row to
# ``quiz`` / ``test`` / ``resource`` / ``assignment`` from the
# weekly plan editor and link the appropriate ``exam_template_id``
# / ``resource_id`` / ``assignment_id`` on the same row.
kind = fields.Selection(
MATERIAL_KIND_SELECTION,
required=True,
default='content',
index=True,
help='How the student interacts with this material: read it, '
'take a graded test, attempt a quiz, download a resource, '
'or submit an assignment.',
)
exam_template_id = fields.Many2one(
'encoach.exam.template',
string='Linked exam',
ondelete='set null',
help='Used when kind in (quiz, test). Points at the exam '
'template the student should take from this row.',
)
resource_id = fields.Many2one(
'encoach.resource',
string='Linked resource',
ondelete='set null',
help='Used when kind = resource. Points at a library resource '
'(PDF, video, link) the student should consult.',
)
is_graded = fields.Boolean(
string='Graded',
default=False,
help='When enabled, the row counts toward course grades. '
'Auto-set to True for kind=test, optional for assignment.',
)
due_date = fields.Date(
string='Due date',
help='Optional deadline. Surfaced on the student weekly view.',
)
title = fields.Char(required=True)
is_static = fields.Boolean(
default=False,
@@ -302,6 +370,17 @@ class CoursePlanMaterial(models.Model):
help='Plain-text rendering for easy preview / copy-paste when the '
'structured body is not needed.',
)
grounded_on_json = fields.Text(
help='JSON list of source citations used to ground this material via '
'RAG: [{source_id, title, chunks_used}]. Surfaced in the UI as '
'a "Grounded on N references" badge.',
)
extracted_from_json = fields.Text(
help='JSON {source_id, page_hint, …} when the material was mined '
'from an indexed PDF/DOCX by ExerciseExtractor. Distinct from '
'grounded_on_json which records *retrieval* rather than '
'*extraction* provenance.',
)
media_ids = fields.One2many(
'encoach.course.plan.media', 'material_id', string='Generated media',
@@ -317,6 +396,8 @@ class CoursePlanMaterial(models.Model):
def to_api_dict(self, include_media=True):
self.ensure_one()
grounded = self._loads(self.grounded_on_json, [])
extracted = self._loads(self.extracted_from_json, None)
out = {
'id': self.id,
'plan_id': self.plan_id.id,
@@ -324,12 +405,21 @@ class CoursePlanMaterial(models.Model):
'week_number': self.week_number or 0,
'skill': self.skill or '',
'material_type': self.material_type or 'other',
'kind': self.kind or 'content',
'is_graded': bool(self.is_graded),
'due_date': self.due_date.isoformat() if self.due_date else None,
'exam_template_id': self.exam_template_id.id if self.exam_template_id else None,
'exam_template_name': self.exam_template_id.name if self.exam_template_id else None,
'resource_id': self.resource_id.id if self.resource_id else None,
'resource_name': self.resource_id.name if self.resource_id else None,
'title': self.title or '',
'is_static': bool(self.is_static),
'share_date': self.share_date.isoformat() if self.share_date else None,
'summary': self.summary or '',
'body': self._loads(self.body_json, {}),
'body_text': self.body_text or '',
'grounded_on': grounded if isinstance(grounded, list) else [],
'extracted_from': extracted if isinstance(extracted, dict) else None,
}
if include_media:
out['media'] = [m.to_api_dict() for m in self.media_ids]

View File

@@ -0,0 +1,337 @@
"""Per-student workbook attempt — server-side scoring + persistence.
A student opens an ``interactive_workbook`` material in
``InteractiveWorkbook.tsx``, types/picks/drags answers, and either
clicks "Check answers" (debounced save) or "Submit final" (final save).
Each save creates or updates one ``encoach.course.plan.workbook.attempt``
row keyed by ``(material_id, student_id, attempt_number)``.
The score is always recomputed server-side from the persisted answers
so a tampered client cannot inflate the score. The grading function
handles each of the six exercise types defined in the plan schema:
* ``gap_fill`` — case-insensitive equality, ``alt`` list match.
* ``multiple_choice`` — exact match against ``answer`` (string or letter).
* ``match_pairs`` — set-equality of pair tuples.
* ``reorder_words`` — token-by-token equality (whitespace-tolerant).
* ``transformation`` — equality with normalised punctuation, ``alt``
fallback, capitalisation tolerated.
* ``short_answer`` — glob-style ``accepted`` template (``*`` matches
any phrase) plus exact ``answer`` fallback.
Entity isolation
----------------
Every attempt carries the same ``entity_id`` as the parent plan. A SQL
constraint mirrors the LMS isolation pattern: a student in entity A
cannot create an attempt against a material owned by entity B.
"""
from __future__ import annotations
import json
import logging
import re
from odoo import api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# Scoring helpers — pure functions so the unit tests can hit them
# directly without spinning up an ORM.
# ----------------------------------------------------------------------
def _norm_text(s) -> str:
if s is None:
return ''
return re.sub(r'\s+', ' ', str(s)).strip().lower()
def _norm_punct(s) -> str:
"""Normalise final punctuation/quotes for transformation checks."""
return re.sub(r'[\s\.\?\!,]+$', '', _norm_text(s))
def _glob_match(pattern: str, value: str) -> bool:
"""Tiny ``*``-only glob matcher used by short-answer templates."""
pat = _norm_text(pattern)
val = _norm_text(value)
if not pat:
return False
if '*' not in pat:
return pat == val
rx = '^' + re.escape(pat).replace(r'\*', r'.*') + '$'
return bool(re.match(rx, val))
def _pairs_equal(answer, given) -> bool:
"""Set-equality check for match_pairs answers."""
def _to_set(p):
out = set()
for it in (p or []):
if isinstance(it, (list, tuple)) and len(it) == 2:
try:
out.add((int(it[0]), int(it[1])))
except (TypeError, ValueError):
return None
return out
a = _to_set(answer)
g = _to_set(given)
if a is None or g is None:
return False
return a == g
def _check_one(ex: dict, given) -> bool:
"""Return True iff ``given`` is a correct answer for ``ex``."""
t = (ex.get('type') or '').lower()
if t == 'gap_fill':
if given is None:
return False
accepted = [ex.get('answer')] + list(ex.get('alt') or [])
n = _norm_text(given)
return any(_norm_text(a) == n for a in accepted if a is not None)
if t == 'multiple_choice':
opts = ex.get('options') or []
ans = ex.get('answer')
# The "answer" in the schema can be the literal option string
# ("Paris") OR a single letter ("A","B",…) — accept either.
if ans is None:
return False
if isinstance(given, str):
n = _norm_text(given)
if n == _norm_text(ans):
return True
# Letter-mode: convert "A"/"B" to index → option string.
if isinstance(ans, str) and len(ans) == 1 and ans.isalpha():
idx = ord(ans.upper()) - ord('A')
if 0 <= idx < len(opts) and _norm_text(opts[idx]) == n:
return True
if isinstance(given, str) and len(given) == 1 and given.isalpha():
idx = ord(given.upper()) - ord('A')
if 0 <= idx < len(opts) and _norm_text(opts[idx]) == _norm_text(ans):
return True
return False
if t == 'match_pairs':
return _pairs_equal(ex.get('answer'), given)
if t == 'reorder_words':
if given is None:
return False
# Accept either a list of tokens or a joined string.
if isinstance(given, list):
given_str = ' '.join(str(t) for t in given)
else:
given_str = str(given)
return _norm_text(given_str) == _norm_text(ex.get('answer'))
if t == 'transformation':
if given is None:
return False
accepted = [ex.get('answer')] + list(ex.get('alt') or [])
n = _norm_punct(given)
return any(_norm_punct(a) == n for a in accepted if a is not None)
if t == 'short_answer':
if given is None or str(given).strip() == '':
return False
accepted = list(ex.get('accepted') or [])
if ex.get('answer'):
accepted.append(str(ex['answer']))
return any(_glob_match(p, given) for p in accepted)
# Unknown / un-checkable type — never auto-mark correct.
return False
def score_exercises(exercises: list[dict], answers: dict) -> dict:
"""Grade ``answers`` against ``exercises``. Pure / safe / deterministic."""
items: list[dict] = []
correct = 0
for ex in exercises or []:
eid = str(ex.get('id') or '')
if not eid:
continue
given = answers.get(eid) if isinstance(answers, dict) else None
ok = _check_one(ex, given)
if ok:
correct += 1
items.append({
'id': eid,
'correct': bool(ok),
'expected': ex.get('answer'),
'given': given,
})
total = len(items)
return {
'items': items,
'correct': correct,
'total': total,
'percent': round((correct / total) * 100, 1) if total else 0.0,
}
# ----------------------------------------------------------------------
# Odoo model
# ----------------------------------------------------------------------
class CoursePlanWorkbookAttempt(models.Model):
_name = 'encoach.course.plan.workbook.attempt'
_description = 'Course-plan interactive workbook attempt'
_order = 'submitted_at desc, id desc'
_rec_name = 'display_name'
material_id = fields.Many2one(
'encoach.course.plan.material',
required=True, ondelete='cascade', index=True,
)
plan_id = fields.Many2one(
related='material_id.plan_id', store=True, index=True,
)
student_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
string='Student user',
)
entity_id = fields.Many2one(
'encoach.entity', index=True, string='Entity',
help='Mirrors the parent plan\'s entity for LMS isolation.',
)
answers_json = fields.Text(
default='{}',
help='Per-exercise dict keyed by exercise ID '
'— shape mirrors the schema output by InteractiveWorkbook.tsx.',
)
score_json = fields.Text(
default='{}',
help='Server-side grading output: '
'{items: [{id, correct, expected, given}], correct, total, percent}.',
)
correct_count = fields.Integer(default=0, string='Correct')
total_count = fields.Integer(default=0, string='Total')
percent = fields.Float(default=0.0, digits=(5, 1))
submitted_at = fields.Datetime(
help='Set when the student clicks "Submit final". '
'Until then the row is editable on every "Check answers".',
)
last_updated_at = fields.Datetime(default=fields.Datetime.now)
attempt_number = fields.Integer(default=1)
is_final = fields.Boolean(default=False)
display_name = fields.Char(compute='_compute_display_name', store=False)
_sql_constraints = [
(
'workbook_attempt_unique',
'unique(material_id, student_id, attempt_number)',
'Only one workbook attempt per (material, student, attempt#).',
),
]
# ------------------------------------------------------------------
@api.depends('material_id', 'student_id', 'attempt_number')
def _compute_display_name(self):
for rec in self:
mat = rec.material_id.title or 'Workbook'
who = rec.student_id.name or rec.student_id.login or '?'
rec.display_name = f'{mat}{who} (attempt {rec.attempt_number})'
# ------------------------------------------------------------------
@api.constrains('material_id', 'entity_id')
def _check_entity_isolation(self):
for rec in self:
plan = rec.material_id.plan_id
plan_entity = plan.entity_id.id if (plan and plan.entity_id) else False
if rec.entity_id and plan_entity and rec.entity_id.id != plan_entity:
raise ValidationError(
'Workbook attempt entity must match its plan\'s entity.'
)
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
mat = self.env['encoach.course.plan.material'].sudo().browse(
int(vals.get('material_id') or 0),
)
if mat and mat.exists() and not vals.get('entity_id'):
vals['entity_id'] = (
mat.plan_id.entity_id.id if mat.plan_id and mat.plan_id.entity_id
else False
)
return super().create(vals_list)
# ------------------------------------------------------------------
@staticmethod
def _exercises_from_material(material) -> list[dict]:
try:
body = json.loads(material.body_json or '{}')
except (TypeError, ValueError):
return []
ex = body.get('exercises')
if isinstance(ex, list):
return ex
# Some skill bodies (grammar / vocabulary) embed the workbook
# one level deeper. Search a couple of known keys before giving up.
nested = (
(body.get('interactive_workbook') or {}).get('exercises')
if isinstance(body.get('interactive_workbook'), dict) else None
)
if isinstance(nested, list):
return nested
return []
# ------------------------------------------------------------------
def grade(self, answers: dict, *, finalize: bool = False) -> dict:
"""Re-grade ``answers`` server-side and persist on this row."""
self.ensure_one()
exercises = self._exercises_from_material(self.material_id)
score = score_exercises(exercises, answers or {})
vals = {
'answers_json': json.dumps(answers or {}, ensure_ascii=False),
'score_json': json.dumps(score, ensure_ascii=False),
'correct_count': score['correct'],
'total_count': score['total'],
'percent': score['percent'],
'last_updated_at': fields.Datetime.now(),
}
if finalize:
vals['submitted_at'] = fields.Datetime.now()
vals['is_final'] = True
self.write(vals)
return score
# ------------------------------------------------------------------
def to_api_dict(self) -> dict:
self.ensure_one()
try:
answers = json.loads(self.answers_json or '{}')
except (TypeError, ValueError):
answers = {}
try:
score = json.loads(self.score_json or '{}')
except (TypeError, ValueError):
score = {}
return {
'id': self.id,
'material_id': self.material_id.id,
'plan_id': self.plan_id.id if self.plan_id else None,
'student_id': self.student_id.id,
'student_name': self.student_id.name or self.student_id.login or '',
'attempt_number': self.attempt_number,
'is_final': bool(self.is_final),
'submitted_at': (
self.submitted_at.isoformat() if self.submitted_at else None
),
'last_updated_at': (
self.last_updated_at.isoformat() if self.last_updated_at else None
),
'correct_count': self.correct_count,
'total_count': self.total_count,
'percent': self.percent,
'answers': answers,
'score': score,
}

View File

@@ -7,3 +7,4 @@ access_encoach_course_plan_material_user,encoach.course.plan.material.user,model
access_encoach_course_plan_source_user,encoach.course.plan.source.user,model_encoach_course_plan_source,base.group_user,1,1,1,1
access_encoach_course_plan_media_user,encoach.course.plan.media.user,model_encoach_course_plan_media,base.group_user,1,1,1,1
access_encoach_course_plan_assignment_user,encoach.course.plan.assignment.user,model_encoach_course_plan_assignment,base.group_user,1,1,1,1
access_encoach_course_plan_workbook_attempt_user,encoach.course.plan.workbook.attempt.user,model_encoach_course_plan_workbook_attempt,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_encoach_course_plan_source_user encoach.course.plan.source.user model_encoach_course_plan_source base.group_user 1 1 1 1
8 access_encoach_course_plan_media_user encoach.course.plan.media.user model_encoach_course_plan_media base.group_user 1 1 1 1
9 access_encoach_course_plan_assignment_user encoach.course.plan.assignment.user model_encoach_course_plan_assignment base.group_user 1 1 1 1
10 access_encoach_course_plan_workbook_attempt_user encoach.course.plan.workbook.attempt.user model_encoach_course_plan_workbook_attempt base.group_user 1 1 1 1

View File

@@ -2,5 +2,7 @@ from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline
from .course_plan_pipeline import CoursePlanPipeline
from .source_indexer import SourceIndexer
from .rag_context import RAGContextBuilder
from .exercise_extractor import ExerciseExtractor
from .media_service import MediaService
from .deliverables import compute_deliverables

View File

@@ -203,6 +203,165 @@ Only include skills present in the week's items list.
"""
# Richer schema used by the RAG-grounded v2 generator. Each skill body now
# carries enough material to fill a real teacher's lesson, plus a
# self-contained ``interactive_workbook`` block whose ``exercises[]`` are
# rendered by ``InteractiveWorkbook.tsx``. The model is instructed to keep
# the structure exact — UI components depend on it.
_WEEK_JSON_HINT_V2 = """
Return JSON with EXACTLY this shape (no extra prose, no surrounding markdown):
{
"materials": [
{
"skill": "reading",
"material_type": "reading_text",
"title": "...",
"summary": "1-2 sentence teacher note (purpose + LOs targeted)",
"body": {
"intro": "lead-in question to activate schema",
"text": "<reading passage 600-900 words at the target CEFR level>",
"glossary": [{"term": "...", "definition": "..."}],
"questions": [
{"id":"r1", "type": "multiple_choice", "stem":"...",
"options":["A","B","C","D"], "answer":"A",
"rationale":"Why this is the correct answer."},
{"id":"r2", "type": "true_false", "stem":"...", "answer": true,
"rationale":"..."},
{"id":"r3", "type": "open", "stem":"...", "model_answer":"..."},
{"id":"r4", "type": "inference", "stem":"...", "model_answer":"..."}
]
}
},
{
"skill": "listening",
"material_type": "listening_script",
"title": "...",
"summary": "...",
"body": {
"context": "Where the conversation/monologue is set.",
"script": "<300-500 word natural script with speaker labels>",
"transcript": "Same as script but cleaned for student handout.",
"comprehension_questions": [
{"id":"l1","type":"multiple_choice","stem":"...","options":["A","B","C","D"],"answer":"A"},
{"id":"l2","type":"open","stem":"...","model_answer":"..."}
],
"listen_and_fill": {
"instructions": "Listen and fill in the missing words.",
"transcript_with_gaps": "I ___ (1) to the ___ (2) every morning.",
"answers": ["go","gym"]
}
}
},
{
"skill": "speaking",
"material_type": "speaking_prompt",
"title": "...",
"summary": "...",
"body": {
"warmup": "30-second pair task to break the ice.",
"lead_in": "Discussion question to set the topic.",
"paired_prompt": "Student A asks; Student B answers; then swap.",
"solo_prompt": "1-minute mini-presentation on the topic.",
"useful_language": ["I usually...", "On the other hand...", "..."],
"model_answer": "Sample 60-90 second answer the teacher can read aloud."
}
},
{
"skill": "writing",
"material_type": "writing_prompt",
"title": "...",
"summary": "...",
"body": {
"brainstorm": ["3-5 prompts to spark ideas before drafting."],
"task": "The actual writing task.",
"word_count": 150,
"model_paragraph": "<a fully written 120-180 word model>",
"annotations": [
{"highlight":"phrase from the model","note":"Why it works."}
],
"checklist": ["Topic sentence", "Linking words", "Range of tenses"]
}
},
{
"skill": "grammar",
"material_type": "grammar_lesson",
"title": "...",
"summary": "...",
"body": {
"rule": "1-2 paragraph rule explanation.",
"form_table": [
{"subject":"I/you/we/they","positive":"work","negative":"don't work","question":"do ... work?"},
{"subject":"he/she/it","positive":"works","negative":"doesn't work","question":"does ... work?"}
],
"examples": ["She works in a hospital.","They don't live here."],
"common_errors": [{"wrong":"He don't like fish.","right":"He doesn't like fish."}],
"interactive_workbook": {
"lesson_plan": {
"warmup":"...","presentation":"...","controlled_practice":"...",
"freer_practice":"...","exit_ticket":"..."
},
"exercises": [
{"id":"g1","type":"gap_fill","stem":"I ___ (be) a student.","answer":"am","alt":["am"],"hint":"present simple of 'be'"},
{"id":"g2","type":"multiple_choice","stem":"She ___ coffee every morning.","options":["drink","drinks","drinking","is drink"],"answer":"drinks","rationale":"3rd person singular adds -s."},
{"id":"g3","type":"transformation","from":"He plays football.","instruction":"Make the sentence negative.","answer":"He doesn't play football.","alt":["He does not play football."]},
{"id":"g4","type":"reorder_words","tokens":["Where","do","you","live","?"],"answer":"Where do you live ?"}
]
}
}
},
{
"skill": "vocabulary",
"material_type": "vocabulary_list",
"title": "...",
"summary": "...",
"body": {
"words": [
{"term":"routine","pos":"n.","definition":"a regular sequence of activities","collocations":["daily routine","morning routine"],"example":"My morning routine is busy."}
],
"interactive_workbook": {
"lesson_plan": {"warmup":"...","presentation":"...","controlled_practice":"...","freer_practice":"...","exit_ticket":"..."},
"exercises": [
{"id":"v1","type":"match_pairs","left":["dog","cat","cow","sheep"],"right":["barks","meows","moos","baas"],"answer":[[0,0],[1,1],[2,2],[3,3]]},
{"id":"v2","type":"short_answer","stem":"Give an example of your daily routine.","answer":"I usually …","accepted":["I usually *","I always *","I often *","I sometimes *"]}
]
}
}
},
{
"skill": "integrated",
"material_type": "interactive_workbook",
"title": "Week N — Practice Workbook",
"summary": "Mixed-skill consolidation tasks — students solve and submit.",
"body": {
"lesson_plan": {
"warmup":"...","presentation":"...","controlled_practice":"...",
"freer_practice":"...","exit_ticket":"..."
},
"exercises": [
{"id":"ex1","type":"gap_fill","stem":"I ___ (live) in London.","answer":"live","alt":["live"]},
{"id":"ex2","type":"multiple_choice","stem":"Which is correct?","options":["He don't","He doesn't","He didn't","He do not"],"answer":"He doesn't","rationale":"3rd person singular negative."},
{"id":"ex3","type":"match_pairs","left":["bus","train","car","plane"],"right":["station","stop","park","airport"],"answer":[[0,1],[1,0],[2,2],[3,3]]},
{"id":"ex4","type":"reorder_words","tokens":["What","time","do","you","get","up","?"],"answer":"What time do you get up ?"},
{"id":"ex5","type":"transformation","from":"She is from Spain.","instruction":"Make a yes/no question.","answer":"Is she from Spain?","alt":["Is she from Spain ?"]},
{"id":"ex6","type":"short_answer","stem":"What's your name?","answer":"My name is …","accepted":["My name is *","I'm *","I am *"]}
]
}
}
]
}
Rules
-----
- Include skills exactly matching the week's items list (do not add or skip).
- Plus ONE additional `interactive_workbook` of skill `integrated` for mixed practice.
- Word counts and CEFR-appropriate vocabulary are MANDATORY — do not output A1 vocabulary in a B2 lesson.
- Every interactive exercise MUST be one of: gap_fill, multiple_choice, match_pairs, reorder_words, transformation, short_answer.
- Provide a clean machine-checkable `answer` (and `alt`/`accepted` where applicable). The frontend grades them server-side.
- Use the `Reference passages` block (when present) as the primary source of vocabulary, examples, and topic. Cite ideas faithfully — do not invent contradicting facts.
- Do not include URLs, copyrighted full pages, or large verbatim quotes. Paraphrase or use only short illustrative phrases.
"""
class CoursePlanPipeline:
"""Wrap the LLM call, normalise the JSON, persist the result."""
@@ -384,12 +543,19 @@ class CoursePlanPipeline:
# ------------------------------------------------------------------
# Week-level material generation
# ------------------------------------------------------------------
def generate_week_materials(self, plan_id, week_number):
def generate_week_materials(self, plan_id, week_number, *, use_rag=False):
"""Generate teaching materials for one week and persist them.
Any existing materials for the same plan_id + week_number are
replaced — callers that want to keep old versions should copy
them before re-running.
:param use_rag: when True, retrieve top-k passages from each
indexed source via :class:`RAGContextBuilder`, inject them
into the prompt, switch to ``_WEEK_JSON_HINT_V2`` (richer
bodies + interactive workbook), and persist the source
citations on each created material under ``grounded_on_json``.
Falls back to v1 cleanly if no sources are indexed.
"""
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
@@ -402,25 +568,72 @@ class CoursePlanPipeline:
outcomes = plan._loads(plan.outcomes_json, {})
items = week._loads(week.items_json, [])
system_msg = (
"You are an expert English language teacher creating ready-"
"to-use classroom materials. Your output MUST be valid JSON "
"matching the schema in the user prompt. Keep reading texts "
"close to the target word count for the CEFR level. Keep "
"listening scripts natural and conversational. All tasks "
"must target the outcome codes supplied."
)
user_msg = (
f"Course: {plan.name}\n"
f"CEFR: {(plan.cefr_level or '').upper()}\n"
f"Week {week.week_number}{week.date_label or ''}\n"
f"Unit: {week.unit or ''}\n"
f"Focus: {week.focus or ''}\n\n"
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
f"Full outcome catalogue (for looking up codes):\n"
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
+ _WEEK_JSON_HINT
)
# Build per-skill RAG context (only when requested AND sources exist).
rag_blocks: dict[str, dict] = {}
rag_enabled = False
if use_rag:
from .rag_context import RAGContextBuilder
builder = RAGContextBuilder(self.env)
if builder.has_indexed_sources(plan):
rag_enabled = True
# The week may contain duplicates / the integrated workbook
# references "integrated"; we always retrieve once per
# listed skill plus once for the integrated bucket so the
# extra workbook material has its own grounding too.
wanted = {(it.get('skill') or '').lower()
for it in items if it.get('skill')}
wanted.add('integrated')
for skill in wanted:
if not skill:
continue
rag_blocks[skill] = builder.for_week(plan, week, skill)
if rag_enabled:
system_msg = (
"You are an expert English language teacher creating ready-"
"to-use, classroom-ready materials. You ground your "
"content on the reference passages provided in the prompt "
"(from the teacher's uploaded book). Paraphrase ideas; "
"do not quote whole pages verbatim. Output MUST be valid "
"JSON matching the schema EXACTLY — no preamble, no "
"trailing commentary."
)
ref_block = self._render_rag_block(rag_blocks)
user_msg = (
f"Course: {plan.name}\n"
f"CEFR: {(plan.cefr_level or '').upper()}\n"
f"Week {week.week_number}{week.date_label or ''}\n"
f"Unit: {week.unit or ''}\n"
f"Focus: {week.focus or ''}\n\n"
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
f"Full outcome catalogue (for looking up codes):\n"
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
f"Reference passages from your uploaded book(s):\n"
f"{ref_block}\n\n"
+ _WEEK_JSON_HINT_V2
)
max_tokens = 8000
else:
system_msg = (
"You are an expert English language teacher creating ready-"
"to-use classroom materials. Your output MUST be valid JSON "
"matching the schema in the user prompt. Keep reading texts "
"close to the target word count for the CEFR level. Keep "
"listening scripts natural and conversational. All tasks "
"must target the outcome codes supplied."
)
user_msg = (
f"Course: {plan.name}\n"
f"CEFR: {(plan.cefr_level or '').upper()}\n"
f"Week {week.week_number}{week.date_label or ''}\n"
f"Unit: {week.unit or ''}\n"
f"Focus: {week.focus or ''}\n\n"
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
f"Full outcome catalogue (for looking up codes):\n"
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
+ _WEEK_JSON_HINT
)
max_tokens = 6000
content = self._invoke_agent_or_chat(
agent_key="course_week_materials",
@@ -430,9 +643,10 @@ class CoursePlanPipeline:
"course": plan.name,
"cefr_level": (plan.cefr_level or "").lower(),
"week_number": week.week_number,
"rag_enabled": rag_enabled,
},
temperature=0.6,
max_tokens=6000,
max_tokens=max_tokens,
action="course_plan.generate_week",
)
used_week_fallback = False
@@ -474,21 +688,47 @@ class CoursePlanPipeline:
summary = (m.get('summary') or '').strip()
if used_week_fallback:
summary = (summary + "\n\n" + skeleton_note).strip()
rec = Material.create({
skill = (m.get('skill') or 'integrated').strip().lower()
vals = {
'plan_id': plan.id,
'week_id': week.id,
'skill': (m.get('skill') or 'integrated').strip().lower(),
'skill': skill,
'material_type': (m.get('material_type') or 'other').strip(),
'title': (m.get('title') or '').strip() or 'Untitled',
'summary': summary,
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
'body_text': self._flatten_body(m.get('body') or {}),
})
}
if rag_enabled:
block = rag_blocks.get(skill) or rag_blocks.get('integrated')
if block and block.get('sources'):
vals['grounded_on_json'] = json.dumps(
block['sources'], ensure_ascii=False,
)
rec = Material.create(vals)
created.append(rec)
except Exception as exc: # pragma: no cover - defensive
_logger.warning("Skipping bad material row: %s", exc)
return created
# ------------------------------------------------------------------
@staticmethod
def _render_rag_block(rag_blocks: dict) -> str:
"""Render the per-skill RAG passages as a single citation block.
We keep the per-skill grouping so the model knows which passages
relate to which skill — a flat dump tends to mix topics and the
model loses focus when generating, e.g., a grammar lesson against
a reading-passage source.
"""
parts: list[str] = []
for skill, block in rag_blocks.items():
text = (block or {}).get('as_prompt_block') or ''
if not text:
continue
parts.append(f"### For {skill}:\n{text}")
return '\n\n'.join(parts) or '(no reference passages available)'
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------

View File

@@ -0,0 +1,247 @@
"""Parse a listening-script into per-speaker segments with gender hints.
The course-plan AI emits listening scripts in a "screenplay" form::
Host: Welcome to Travel Tales. Today we have Sarah ...
Sarah: Hi! Yes, I spent two weeks in Italy ...
Host: That sounds amazing. What was your favourite city?
Sarah: Venice was incredible.
Naively feeding that whole blob to a TTS engine reads the speaker
labels aloud ("Host colon Welcome ... Sarah colon Hi") and uses one
monotone voice for both characters. That's painful for listening
practice and breaks the very pedagogical signal a dialogue is supposed
to carry — turn-taking, contrasted voices, gendered roles.
This module extracts the labels, classifies each speaker by gender, and
returns a clean ``[{speaker, gender, text}, ...]`` list so the TTS
pipeline can:
1. Strip the labels from the rendered audio (``Host:`` is never spoken).
2. Synthesize each turn with a voice matching the speaker's gender
(Host with a male voice, Sarah with a female voice).
3. Concatenate the per-turn clips so a 4-turn dialogue plays as a real
conversation rather than a single narration.
For monologues (no ``Name:`` prefix anywhere in the script) the parser
returns one segment with ``speaker=None``, so callers can keep the
single-voice fast-path without special-casing.
"""
from __future__ import annotations
import re
from typing import Iterable
# A "speaker tag" is a 13 word capitalized name followed by a colon at
# either start-of-string or right after sentence-final punctuation. We
# anchor with a positive lookbehind so we don't match colons that appear
# inside dialogue ("She said: 'No.'") — only those that actually mark a
# turn boundary.
_SPEAKER_PREFIX = re.compile(
r'(?:^|(?<=[.!?]\s)|(?<=\n))'
r'(?P<name>[A-Z][A-Za-z\'.-]+(?:\s[A-Z][A-Za-z\'.-]+){0,2})'
r'\s*:\s*'
)
# Pragmatic name → gender lookup. Covers the names the LLM picks 95% of
# the time when generating English-language listening dialogues. Anything
# missing falls back to alternation by encounter order, which always
# produces alternating male/female voices for the typical 2-speaker
# classroom dialogue.
_FEMALE_NAMES = frozenset({
# English first names
'sarah', 'anna', 'maria', 'sophie', 'sophia', 'emma', 'lisa', 'jane',
'clare', 'claire', 'olivia', 'mia', 'emily', 'grace', 'ava', 'isabella',
'chloe', 'julia', 'laura', 'sofia', 'amy', 'rachel', 'nora', 'ella',
'katie', 'kate', 'hannah', 'lucy', 'mary', 'susan', 'jessica',
'rebecca', 'alice', 'helen', 'sandra', 'linda', 'barbara', 'nancy',
'karen', 'betty', 'diana', 'victoria', 'natalie', 'natasha', 'irina',
'rose', 'lily', 'ruby', 'molly', 'amelia', 'zoe', 'beth', 'eve',
# Arabic / regional first names common in the platform's audience
'aisha', 'fatima', 'layla', 'noor', 'zainab', 'yasmin', 'salma',
'nadia', 'mariam', 'amal', 'hala', 'huda', 'rania', 'reem',
# Generic role / kinship terms that imply female
'female', 'woman', 'girl', 'mother', 'mom', 'mum', 'wife', 'sister',
'daughter', 'aunt', 'grandmother', 'gran', 'lady', 'miss', 'mrs',
'ms', 'queen', 'princess',
})
_MALE_NAMES = frozenset({
# English first names
'john', 'david', 'michael', 'james', 'robert', 'william', 'richard',
'thomas', 'charles', 'christopher', 'daniel', 'matthew', 'andrew',
'peter', 'mark', 'paul', 'steven', 'kevin', 'brian', 'george',
'edward', 'joseph', 'sam', 'samuel', 'ben', 'benjamin', 'tom', 'tim',
'bob', 'jack', 'henry', 'oliver', 'noah', 'liam', 'elias', 'elijah',
'alex', 'alexander', 'ethan', 'lucas', 'mason', 'logan', 'caleb',
'ryan', 'nathan', 'jacob', 'frank', 'harry', 'simon', 'steve',
# Arabic / regional first names
'ahmed', 'ahmad', 'mohammed', 'mohamed', 'omar', 'ali', 'khaled',
'tarek', 'yousef', 'youssef', 'hassan', 'amir', 'rami', 'sami',
'hamza', 'ibrahim', 'mahmoud', 'karim', 'adam', 'bilal',
# Generic role / kinship terms
'male', 'man', 'boy', 'father', 'dad', 'husband', 'brother', 'son',
'uncle', 'grandfather', 'grandpa', 'sir', 'mr', 'king', 'prince',
})
# Role nouns that frequently appear as speaker labels in listening
# scripts but carry no inherent gender — we route these through the
# alternation path instead of forcing a default voice on them.
_NEUTRAL_ROLES = frozenset({
'narrator', 'speaker', 'voice', 'person', 'student', 'teacher',
'host', 'presenter', 'interviewer', 'guest', 'reporter', 'announcer',
'caller', 'customer', 'staff', 'clerk', 'agent', 'driver', 'doctor',
'patient', 'manager', 'employee', 'colleague', 'friend',
})
def _lookup_gender(name: str) -> str | None:
"""Return ``'male'`` / ``'female'`` from the static name lookup, or
``None`` if the first token isn't recognised.
"""
first = (name or '').strip().lower().split()[0] if name else ''
if first in _FEMALE_NAMES:
return 'female'
if first in _MALE_NAMES:
return 'male'
return None
def parse_dialogue(script: str) -> list[dict]:
"""Split ``script`` into per-speaker segments.
Returns a list of ``{'speaker': str | None, 'gender': str | None,
'text': str}``:
* Multi-speaker dialogue → one entry per turn, with the speaker's
name (preserved as written) and a heuristic gender label.
* Monologue (no ``Name:`` prefix) → a single segment with
``speaker=None`` and ``gender=None`` so callers can short-circuit.
* Empty / whitespace-only script → ``[]``.
The returned ``text`` never includes the speaker label — that's the
whole point: the TTS engine must not say "Host colon ..." aloud.
Gender assignment is two-pass so that unknown / role-only speakers
(Host, Interviewer, etc.) end up *contrasting* with the named
speakers in the same dialogue. Without this a "Host + Sarah"
interview would assign female to both (Sarah by lookup, Host by
default), defeating the whole point of multi-voice rendering.
"""
if not script or not script.strip():
return []
matches = list(_SPEAKER_PREFIX.finditer(script))
if not matches:
return [{'speaker': None, 'gender': None, 'text': script.strip()}]
# Build the list of (name, text) turns first so we can do a global
# gender assignment over the unique speaker set.
turns: list[tuple[str, str]] = []
head = script[: matches[0].start()].strip()
for i, m in enumerate(matches):
name = m.group('name').strip()
end = matches[i + 1].start() if i + 1 < len(matches) else len(script)
text = script[m.end():end].strip()
if text:
turns.append((name, text))
if not turns:
# Edge case: matched some "Name:" labels but every body turned
# out empty. Return the whole pre-match head as a single
# narration, falling back to the script if even that's empty.
return [{'speaker': None, 'gender': None,
'text': head or script.strip()}]
# Pass 1: collect unique speakers in order of first appearance, and
# apply the static name → gender lookup. ``None`` here means
# "we don't know yet — fill in pass 2".
unique_order: list[str] = []
speaker_gender: dict[str, str | None] = {}
for name, _ in turns:
key = name.lower()
if key not in speaker_gender:
unique_order.append(key)
speaker_gender[key] = _lookup_gender(name)
# Pass 2: resolve unknowns. The contract we want is "contrast" —
# any 2-speaker dialogue should produce one male and one female
# voice. So for each unknown speaker we count how many male vs
# female slots are *already* assigned (by lookup or by an earlier
# iteration of this pass) and pick the under-represented gender.
# Pure parity-by-index would re-introduce the Host/Sarah collision.
for key in unique_order:
if speaker_gender[key] is not None:
continue
male_count = sum(1 for v in speaker_gender.values() if v == 'male')
female_count = sum(1 for v in speaker_gender.values() if v == 'female')
if male_count < female_count:
speaker_gender[key] = 'male'
elif female_count < male_count:
speaker_gender[key] = 'female'
else:
# Truly tied (or first unknown in a no-lookup dialogue) —
# alternate based on how many unknowns we've already filled
# so two unknowns in a row get different voices.
assigned_unknowns = sum(
1 for k in unique_order
if speaker_gender[k] is not None and _lookup_gender(k) is None
)
speaker_gender[key] = (
'male' if assigned_unknowns % 2 == 0 else 'female'
)
# Pass 3: emit the segments, including any unattributed pre-match
# narration ("It was a sunny day. Anna: Hi!"). The narration takes
# whichever gender is *under-represented* among the speakers, so it
# contrasts with the dialogue voices instead of duplicating one.
segments: list[dict] = []
if head:
female = sum(1 for v in speaker_gender.values() if v == 'female')
male = sum(1 for v in speaker_gender.values() if v == 'male')
narrator_gender = 'female' if female <= male else 'male'
segments.append(
{'speaker': None, 'gender': narrator_gender, 'text': head},
)
for name, text in turns:
segments.append({
'speaker': name,
'gender': speaker_gender[name.lower()],
'text': text,
})
return segments
def strip_speaker_labels(script: str) -> str:
"""Return ``script`` with all ``Name:`` prefixes removed.
Used when the active TTS provider can't do multi-voice (gTTS only
speaks one voice per call) so we still get a clean recording —
just one narrator reading both turns instead of an awkward
"Host colon ... Sarah colon ...".
"""
if not script:
return ''
return _SPEAKER_PREFIX.sub('', script).strip()
def is_dialogue(segments: Iterable[dict]) -> bool:
"""True if ``segments`` represents a multi-speaker dialogue.
The single-speaker fast-path skips concatenation and just calls the
underlying TTS once with the full text, so this gate has to be
accurate. We require *two distinct named speakers* — a single
speaker who happens to talk in multiple turns (rare but possible
after an intro paragraph) is still a monologue from a TTS
perspective.
"""
seen = set()
for seg in segments:
sp = seg.get('speaker')
if sp:
seen.add(sp.lower())
if len(seen) >= 2:
return True
return False

View File

@@ -0,0 +1,373 @@
"""Mine exercises from indexed reference books and persist them as
``interactive_workbook`` materials on the plan.
Pipeline
--------
1. Walk all chunks belonging to the plan's indexed sources
(``encoach.embedding`` rows with ``content_type='course_plan_source'``
and ``entity_id=plan.id``).
2. Group chunks into N-chunk batches and ask the LLM to identify
exercises in the text — gap-fills, multiple-choice items, matching
pairs, word-reorder, transformation, short-answer.
3. Validate / clean each exercise with cheap heuristics. Drop anything
without a usable answer key. De-dupe against previously-extracted
stems (same plan).
4. For each accepted batch, attach the workbook to the most relevant
week — picked by keyword overlap between the exercise stems and
each week's ``unit + focus``. Falls back to the first week.
5. Persist as one ``encoach.course.plan.material`` per batch with
``material_type='interactive_workbook'`` and an
``extracted_from`` provenance record so the UI can show "Extracted
from <book>".
"""
from __future__ import annotations
import json
import logging
import re
_logger = logging.getLogger(__name__)
# Per LLM call: how many ~2K-char chunks to feed in one go. 6 keeps the
# prompt under ~12K chars, well within a 16K context budget while still
# letting the model see enough surrounding context to identify rubrics.
CHUNKS_PER_BATCH = 6
# Hard cap on total batches per run — protects against runaway cost on a
# very large book. Operators can lift this via ``max_batches`` kwarg.
DEFAULT_MAX_BATCHES = 8
ALLOWED_TYPES = {
'gap_fill', 'multiple_choice', 'match_pairs',
'reorder_words', 'transformation', 'short_answer',
}
_EXTRACT_HINT = """
You are reading raw text extracted from a printed English exercise book.
Identify any drills, controlled-practice exercises, or workbook tasks in
the passage and extract them as machine-checkable JSON.
Return JSON with EXACTLY this shape:
{
"exercises": [
{"id":"e1", "type":"gap_fill", "stem":"I ___ (be) a student.", "answer":"am", "alt":["am"], "hint":"present simple of 'be'"},
{"id":"e2", "type":"multiple_choice", "stem":"...", "options":["A","B","C","D"], "answer":"B", "rationale":"..."},
{"id":"e3", "type":"match_pairs", "left":["dog","cat"], "right":["barks","meows"], "answer":[[0,0],[1,1]]},
{"id":"e4", "type":"reorder_words", "tokens":["I","am","a","student"], "answer":"I am a student"},
{"id":"e5", "type":"transformation", "from":"He plays football.", "instruction":"Make it negative.", "answer":"He doesn't play football.", "alt":["He does not play football."]},
{"id":"e6", "type":"short_answer", "stem":"What's your name?", "answer":"My name is …", "accepted":["My name is *","I'm *","I am *"]}
],
"topic_keywords": ["routine","present simple", "..."],
"page_hint": "if the source mentions 'p. 12' or 'Unit 1', echo it here"
}
Strict rules
------------
- Skip narrative prose, scope-and-sequence pages, answer keys.
- Skip exercises whose answer cannot be inferred from the source.
- Use ONLY the six types listed above; never invent a new type.
- Stems must be self-contained (no "look at exercise 3").
- Answers must be a string, a boolean, or a list of [int,int] pairs as
shown — never a sentence like "answer key on p. 99".
- Output MUST be valid JSON. If the passage contains no usable
exercises, return ``{"exercises": [], "topic_keywords": [], "page_hint": ""}``.
"""
def _normalize_stem(stem: str) -> str:
"""Lowercase + collapse whitespace — used for de-dup keys."""
return re.sub(r'\s+', ' ', (stem or '').strip().lower())
def _is_clean_exercise(ex: dict) -> bool:
"""Cheap structural validation. Drops anything we can't grade."""
t = (ex.get('type') or '').lower()
if t not in ALLOWED_TYPES:
return False
if not (ex.get('stem') or ex.get('from') or ex.get('left') or ex.get('tokens')):
return False
ans = ex.get('answer')
if ans is None or ans == '' or ans == []:
return False
if t == 'multiple_choice':
opts = ex.get('options') or []
if not isinstance(opts, list) or len(opts) < 2:
return False
if isinstance(ans, str) and ans not in opts:
# Some authors use "B" as the answer; that's fine.
if not (len(ans) == 1 and ans.isalpha()):
return False
if t == 'match_pairs':
if not (isinstance(ex.get('left'), list)
and isinstance(ex.get('right'), list)
and isinstance(ans, list)):
return False
if not all(isinstance(p, list) and len(p) == 2 for p in ans):
return False
if t == 'reorder_words':
toks = ex.get('tokens')
if not (isinstance(toks, list) and len(toks) >= 2):
return False
return True
class ExerciseExtractor:
"""Walk the plan's indexed corpus and persist interactive workbooks."""
CONTENT_TYPE = 'course_plan_source'
def __init__(self, env, *, language: str = 'en'):
self.env = env
self.language = language
try:
from odoo.addons.encoach_ai.services.openai_service import (
OpenAIService,
)
except ImportError:
OpenAIService = None
self._OpenAIService = OpenAIService
self._ai = None # lazy
# ------------------------------------------------------------------
def _service(self):
if self._ai is not None:
return self._ai
if self._OpenAIService is None:
raise RuntimeError('encoach_ai.OpenAIService not available')
self._ai = self._OpenAIService(self.env, language=self.language)
return self._ai
# ------------------------------------------------------------------
def _load_corpus(self, plan) -> list[dict]:
"""Return all indexed chunks for the plan, oldest-first."""
Embedding = self.env['encoach.embedding'].sudo()
rows = Embedding.search([
('content_type', '=', self.CONTENT_TYPE),
('entity_id', '=', plan.id),
], order='content_id asc, chunk_index asc')
chunks = []
for r in rows:
metadata = {}
try:
metadata = json.loads(r.metadata_json or '{}')
except (TypeError, ValueError):
pass
chunks.append({
'source_id': int(metadata.get('source_id') or r.content_id),
'title': metadata.get('title') or '',
'chunk_index': r.chunk_index,
'text': r.content_text or '',
})
return chunks
# ------------------------------------------------------------------
def _existing_stems(self, plan) -> set[str]:
"""Already-stored normalized stems → avoid duplicate workbooks."""
Material = self.env['encoach.course.plan.material'].sudo()
rows = Material.search([
('plan_id', '=', plan.id),
('material_type', '=', 'interactive_workbook'),
])
seen: set[str] = set()
for m in rows:
try:
body = json.loads(m.body_json or '{}')
except (TypeError, ValueError):
continue
for ex in body.get('exercises') or []:
key = _normalize_stem(ex.get('stem') or ex.get('from') or '')
if key:
seen.add(key)
return seen
# ------------------------------------------------------------------
@staticmethod
def _pick_week(plan, topic_keywords: list[str], stems: list[str]):
"""Return the week most relevant to a batch's topic keywords.
Falls back to week 1 when the plan has no overlap signal — that's
better than dropping the workbook entirely.
"""
weeks = list(plan.week_ids.sorted('week_number'))
if not weeks:
return None
haystack = ' '.join(topic_keywords + stems).lower()
if not haystack.strip():
return weeks[0]
def score(week):
text = f'{week.unit or ""} {week.focus or ""}'.lower()
terms = [t for t in re.split(r'\W+', text) if len(t) > 3]
return sum(1 for t in terms if t in haystack)
ranked = sorted(weeks, key=score, reverse=True)
# If nothing scored at all, prefer week 1 for stability.
return ranked[0] if score(ranked[0]) > 0 else weeks[0]
# ------------------------------------------------------------------
def _llm_extract_batch(self, chunks: list[dict]) -> dict:
"""Run one extraction LLM call against a batch of chunks."""
ai = self._service()
joined = '\n\n---\n\n'.join(
f'[chunk {c["chunk_index"]} | source #{c["source_id"]}]\n{c["text"]}'
for c in chunks
)
messages = [
{
'role': 'system',
'content': (
'You extract original printed exercises from English '
'workbook scans. You return STRICT JSON. You never '
'invent exercises that are not on the page.'
),
},
{
'role': 'user',
'content': (
f'Extract the exercises from the passage below.\n\n'
f'{_EXTRACT_HINT}\n\nPassage:\n{joined}'
),
},
]
try:
return ai.chat_json(
messages,
temperature=0.1,
max_tokens=4000,
action='course_plan.extract_exercises',
) or {}
except Exception as exc:
_logger.warning('Exercise extraction LLM call failed: %s', exc)
return {}
# ------------------------------------------------------------------
def run(self, plan, *, max_batches: int = DEFAULT_MAX_BATCHES) -> dict:
"""Extract exercises for ``plan`` and persist workbook materials.
:returns: ``{materials_created, exercises_total, batches_run, skipped}``.
"""
chunks = self._load_corpus(plan)
if not chunks:
return {
'materials_created': 0,
'exercises_total': 0,
'batches_run': 0,
'skipped': 0,
'reason': 'no indexed chunks for this plan',
}
seen_stems = self._existing_stems(plan)
Material = self.env['encoach.course.plan.material'].sudo()
materials_created = 0
exercises_total = 0
batches_run = 0
skipped_dup = 0
# Group chunks per source so a single workbook stays cohesive.
by_source: dict[int, list[dict]] = {}
for ch in chunks:
by_source.setdefault(ch['source_id'], []).append(ch)
for source_id, source_chunks in by_source.items():
for i in range(0, len(source_chunks), CHUNKS_PER_BATCH):
if batches_run >= max_batches:
break
batch = source_chunks[i:i + CHUNKS_PER_BATCH]
batches_run += 1
payload = self._llm_extract_batch(batch)
if not payload or 'error' in payload:
continue
raw_exercises = payload.get('exercises') or []
clean: list[dict] = []
for ex in raw_exercises:
if not isinstance(ex, dict):
continue
if not _is_clean_exercise(ex):
continue
key = _normalize_stem(
ex.get('stem') or ex.get('from') or '',
)
if not key or key in seen_stems:
skipped_dup += 1
continue
seen_stems.add(key)
clean.append(ex)
if not clean:
continue
topic_keywords = [
str(t).strip() for t in (payload.get('topic_keywords') or [])
if str(t).strip()
]
page_hint = (payload.get('page_hint') or '').strip()
stems = [ex.get('stem') or ex.get('from') or '' for ex in clean]
week = self._pick_week(plan, topic_keywords, stems)
if week is None:
continue
title = self._pick_title(batch, topic_keywords, week)
summary = (
f'Extracted from {batch[0].get("title") or "uploaded book"}'
+ (f' ({page_hint})' if page_hint else '')
+ f'{len(clean)} interactive exercise(s).'
)
body = {
'lesson_plan': {
'warmup': '',
'presentation': (
'Originally printed exercises mined from the '
'reference book. Use them as in-class drills '
'or homework.'
),
'controlled_practice': '',
'freer_practice': '',
'exit_ticket': '',
},
'exercises': clean,
}
provenance = {
'source_id': int(source_id),
'source_title': batch[0].get('title') or '',
'page_hint': page_hint,
'topic_keywords': topic_keywords,
'chunk_indices': [c['chunk_index'] for c in batch],
}
Material.create({
'plan_id': plan.id,
'week_id': week.id,
'skill': 'integrated',
'material_type': 'interactive_workbook',
'title': title,
'summary': summary,
'body_json': json.dumps(body, ensure_ascii=False),
'body_text': '',
'extracted_from_json': json.dumps(
provenance, ensure_ascii=False,
),
})
materials_created += 1
exercises_total += len(clean)
if batches_run >= max_batches:
break
return {
'materials_created': materials_created,
'exercises_total': exercises_total,
'batches_run': batches_run,
'skipped': skipped_dup,
}
# ------------------------------------------------------------------
@staticmethod
def _pick_title(batch, topic_keywords, week) -> str:
"""Compose a friendly material title for the extracted workbook."""
topic = ''
if topic_keywords:
topic = topic_keywords[0]
if not topic and week and (week.unit or week.focus):
topic = (week.unit or week.focus or '').strip()
if not topic:
topic = 'Practice'
return f'Workbook — {topic.title()}'

View File

@@ -31,17 +31,42 @@ import logging
import os
import shutil
import subprocess
import sys
import tempfile
import time
from typing import Optional
_logger = logging.getLogger(__name__)
def _resolve_bin(name: str) -> str | None:
"""Locate a binary, preferring the running Python's sibling ``bin/``.
Odoo is launched with the conda env's interpreter, so its sibling
``bin/`` reliably hosts ``ffmpeg`` even when ``$PATH`` in the
daemon's environment doesn't include the conda prefix. We use this
same pattern in ``encoach_ai_course.services.source_indexer`` for
tesseract / pdftoppm — keeping it duplicated here rather than
importing across services to avoid a circular dep on a single
helper.
"""
sibling = os.path.join(os.path.dirname(sys.executable), name)
if os.path.isfile(sibling) and os.access(sibling, os.X_OK):
return sibling
return shutil.which(name)
from odoo.addons.encoach_ai.services import provider_router
from odoo.addons.encoach_ai.services.provider_router import (
classify_provider_error,
should_fallback,
)
from . import dialogue_parser
# Providers that can do per-segment multi-voice TTS. gTTS / silent only
# expose a single voice per request, so for those we degrade gracefully
# to label-stripped single-voice narration.
_MULTI_VOICE_PROVIDERS = frozenset({'polly', 'elevenlabs'})
# --- Helpers ----------------------------------------------------------------
@@ -93,7 +118,16 @@ def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
def _build_audio_script(material) -> str:
"""Choose the right text from a material's body for narration."""
"""Choose the right text from a material's body for narration.
NEVER include the structural field labels ("Context:", "Script:",
"Transcript:") — only the actual narratable content. The previous
implementation already did this for ``listening_script`` (only the
``script`` field was returned), but we re-document the contract
here because the dialogue-aware path below depends on it: the
speaker labels we strip out are ONLY the in-text "Name:" turn
markers, never section headers.
"""
body = material._loads(material.body_json, {}) or {}
if material.material_type == 'listening_script':
return (body.get('script') or '').strip()
@@ -208,16 +242,41 @@ class MediaService:
return media
media.write({'source_text': text[:3000]})
# Parse "Host: ... Sarah: ..." style dialogues into per-speaker
# segments. For monologues this returns a single segment with
# speaker=None and we fall through to the legacy fast-path. For
# real dialogues we'll synthesize each turn with a gender-matched
# voice and concatenate them with ffmpeg.
segments = dialogue_parser.parse_dialogue(text)
is_dialogue = dialogue_parser.is_dialogue(segments)
chain = provider_router.resolve_chain(
self.env, 'audio', requested=provider if provider != 'auto' else None,
)
errors = []
for prov in chain:
try:
result = self._call_audio_provider(
prov, text=text, voice=voice,
language=language, gender=gender,
)
if is_dialogue and prov in _MULTI_VOICE_PROVIDERS:
# The good path: per-speaker, gender-matched voices,
# stitched into a single MP3 by ffmpeg. The user asked
# for "if conversation between two persons, person if
# man voice must be man and if woman voice is for
# woman" — this is where that contract lives.
result = self._synthesize_dialogue(
prov, segments=segments, language=language,
)
else:
# Single-voice path. Even here we must NOT speak the
# speaker labels aloud, so for dialogue scripts hitting
# a free fallback we feed the label-stripped form.
spoken = (
dialogue_parser.strip_speaker_labels(text)
if is_dialogue else text
)
result = self._call_audio_provider(
prov, text=spoken, voice=voice,
language=language, gender=gender,
)
content_type = result.get('content_type', 'audio/mpeg')
ext = 'wav' if content_type == 'audio/wav' else 'mp3'
attach = _attach_bytes(
@@ -294,6 +353,132 @@ class MediaService:
return synthesize_silent(duration_seconds=seconds)
raise RuntimeError(f'Unknown audio provider: {provider!r}')
# -- Multi-voice dialogue --------------------------------------------
def _synthesize_dialogue(self, provider, *, segments, language):
"""Render a dialogue script with per-speaker, gendered voices.
Each turn is synthesized independently — Polly/ElevenLabs both
let us pick a voice per call — and the resulting MP3 / WAV blobs
are concatenated via ffmpeg's concat filter into a single audio
track. We re-encode through libmp3lame so the output is uniform
regardless of which provider was used (bitrate, sample-rate
differences would otherwise corrupt the join).
If ffmpeg is not available we still return *some* audio — a
single-voice rendering of the label-stripped text — rather than
failing the whole request. Listening practice without a real
dialogue is preferable to no audio at all.
"""
if not segments:
raise RuntimeError('No dialogue segments to synthesize')
ffmpeg_bin = _resolve_bin('ffmpeg')
if not ffmpeg_bin:
# Graceful degradation: produce a single-voice rendition of
# the stripped script. Surface a soft warning but do not
# raise — the caller's fallback chain would just route us to
# gtts/silent, which is identical behaviour but slower.
_logger.warning(
'ffmpeg missing — falling back to single-voice dialogue; '
'install ffmpeg for true per-speaker rendering.'
)
joined = ' '.join(seg['text'] for seg in segments)
return self._call_audio_provider(
provider, text=joined, voice=None,
language=language, gender='female',
)
clip_paths: list[str] = []
first_voice: Optional[str] = None
# Polly bills per character; cap each turn at ~3000 chars (5x the
# ~600-char average so a stray giant turn from the LLM doesn't
# blow the budget but a typical 3-minute conversation passes
# untouched).
MAX_CHARS_PER_TURN = 3000
with tempfile.TemporaryDirectory(prefix='encoach_dialogue_') as tmp:
for idx, seg in enumerate(segments):
seg_text = (seg.get('text') or '').strip()
if not seg_text:
continue
seg_text = seg_text[:MAX_CHARS_PER_TURN]
seg_gender = seg.get('gender') or 'female'
try:
# Pin the per-segment voice via gender. Letting
# ``voice=None`` flow through means each provider
# picks its default gendered voice (Amy/Brian for
# Polly en-GB, Rachel/Arnold for ElevenLabs).
res = self._call_audio_provider(
provider, text=seg_text, voice=None,
language=language, gender=seg_gender,
)
except Exception as exc:
raise RuntimeError(
f'segment {idx} ({seg.get("speaker") or "narrator"}) '
f'failed: {exc}'
) from exc
if not first_voice:
first_voice = res.get('voice') or seg_gender
ext = 'wav' if res.get(
'content_type') == 'audio/wav' else 'mp3'
clip_path = os.path.join(tmp, f'seg_{idx:03d}.{ext}')
with open(clip_path, 'wb') as fh:
fh.write(res['audio'])
clip_paths.append(clip_path)
if not clip_paths:
raise RuntimeError('All dialogue segments were empty')
if len(clip_paths) == 1:
# Edge case: 2 speakers but only one had non-empty text.
# Skip the concat dance and return the single clip raw.
with open(clip_paths[0], 'rb') as fh:
return {
'audio': fh.read(),
'content_type': 'audio/mpeg' if clip_paths[0].endswith('.mp3') else 'audio/wav',
'voice': first_voice or 'dialogue',
'characters': sum(len(s.get('text') or '') for s in segments),
}
# Build the ffmpeg concat-filter call. We re-encode to a
# uniform 22050 Hz mono MP3 so bitrate/sample-rate mismatches
# between providers (or even between Polly's neural and
# standard engines) don't corrupt the join.
out_path = os.path.join(tmp, 'dialogue.mp3')
cmd = [ffmpeg_bin, '-y']
for p in clip_paths:
cmd += ['-i', p]
n = len(clip_paths)
filter_inputs = ''.join(f'[{i}:a]' for i in range(n))
cmd += [
'-filter_complex',
f'{filter_inputs}concat=n={n}:v=0:a=1[outa]',
'-map', '[outa]',
'-ac', '1',
'-ar', '22050',
'-c:a', 'libmp3lame',
'-b:a', '96k',
out_path,
]
t0 = time.time()
proc = subprocess.run(
cmd, capture_output=True, check=False, timeout=180,
)
elapsed = time.time() - t0
if proc.returncode != 0:
err = (proc.stderr or b'').decode(
'utf-8', errors='replace')[-500:]
raise RuntimeError(
f'ffmpeg concat failed in {elapsed:.1f}s: {err}',
)
with open(out_path, 'rb') as fh:
audio_bytes = fh.read()
return {
'audio': audio_bytes,
'content_type': 'audio/mpeg',
'voice': f'dialogue-{n}-turns',
'characters': sum(len(s.get('text') or '') for s in segments),
}
# -- Image -----------------------------------------------------------
def generate_image(self, material, *,
custom_prompt: Optional[str] = None,
@@ -497,8 +682,9 @@ class MediaService:
# ── Concrete video providers ────────────────────────────────────────
def _compose_video_ffmpeg(self, media, material, audio_media, image_media):
"""Real slideshow MP4 via ffmpeg. Raises if ffmpeg not on PATH."""
if shutil.which('ffmpeg') is None:
"""Real slideshow MP4 via ffmpeg. Raises if ffmpeg not available."""
ffmpeg_bin = _resolve_bin('ffmpeg')
if ffmpeg_bin is None:
raise RuntimeError('ffmpeg not found on PATH')
plan = material.plan_id
@@ -537,7 +723,7 @@ class MediaService:
with open(i_path, 'wb') as f:
f.write(image_bytes)
cmd = [
'ffmpeg', '-y',
ffmpeg_bin, '-y',
'-loop', '1', '-i', i_path,
'-i', a_path,
'-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',

View File

@@ -0,0 +1,281 @@
"""Build RAG context for course-plan generation.
Wraps :class:`encoach_vector.services.embedding_service.EmbeddingService` to
return the top-k chunks from a plan's indexed reference sources, scoped to
the canonical ``content_type='course_plan_source'`` and trimmed to a
character budget so the LLM prompt stays within context limits.
Indexer convention
------------------
:class:`SourceIndexer` stores each ``encoach.course.plan.source`` chunk
with metadata ``{plan_id, source_id, kind, title, entity_id=plan_id}``
(see :mod:`source_indexer`). This builder uses that convention to scope
retrieval to a single plan: it filters embeddings on ``entity_id=plan.id``
and ``content_type='course_plan_source'``.
Usage
-----
.. code-block:: python
builder = RAGContextBuilder(env)
ctx = builder.for_week(plan, week, skill='reading', k=6)
if ctx['passages']:
prompt += "\\n\\nReference passages from your uploaded book(s):\\n"
prompt += ctx['as_prompt_block']
"""
from __future__ import annotations
import logging
_logger = logging.getLogger(__name__)
# Soft char budget for the assembled passage block. ~3500 chars is roughly
# 800-1000 tokens, leaving room for the rest of the prompt (~2-3 K tokens)
# under a 16 K context window.
DEFAULT_CHAR_BUDGET = 3500
DEFAULT_K = 6
class RAGContextBuilder:
"""Produce RAG passages for a (plan, week, skill) tuple."""
CONTENT_TYPE = 'course_plan_source'
def __init__(self, env, *, char_budget: int = DEFAULT_CHAR_BUDGET):
self.env = env
self.char_budget = int(char_budget)
self._svc = None # lazily created on first call
# ------------------------------------------------------------------
def _service(self):
"""Lazy-load the embedding service so import never fails fatally."""
if self._svc is not None:
return self._svc
try:
from odoo.addons.encoach_vector.services.embedding_service import (
EmbeddingService,
)
except ImportError:
_logger.warning(
'encoach_vector not installed; RAG context disabled.'
)
return None
self._svc = EmbeddingService(self.env)
return self._svc
# ------------------------------------------------------------------
def has_indexed_sources(self, plan) -> bool:
"""Return True iff the plan has at least one indexed (status='indexed') source."""
try:
count = self.env['encoach.course.plan.source'].sudo().search_count([
('plan_id', '=', plan.id),
('status', '=', 'indexed'),
])
except Exception:
return False
return bool(count)
# ------------------------------------------------------------------
def _build_query(self, plan, week, skill: str) -> str:
"""Synthesize a focused retrieval query for (week, skill).
The query is intentionally dense: skill, week unit + focus, and the
descriptions of the outcomes targeted by this week's items. We feed
the descriptions (not just codes) so semantic search can match book
passages that talk about, say, "personal introductions" even when
no outcome code appears in the source text.
"""
outcomes = plan._loads(plan.outcomes_json, {})
items = week._loads(week.items_json, []) if week else []
codes_for_skill: list[str] = []
for it in items:
if (it.get('skill') or '').lower() == (skill or '').lower():
codes_for_skill.extend(it.get('outcome_codes') or [])
outcome_descs: list[str] = []
skill_outcomes = outcomes.get((skill or '').lower()) or []
for o in skill_outcomes:
if o.get('code') in set(codes_for_skill):
outcome_descs.append((o.get('description') or '').strip())
parts = [
(skill or '').strip(),
(week.unit or '').strip() if week else '',
(week.focus or '').strip() if week else '',
' '.join(d for d in outcome_descs if d),
]
query = ''.join(p for p in parts if p)
return query or (plan.name or 'general english')
# ------------------------------------------------------------------
def _trim_to_budget(self, results: list[dict]) -> tuple[list[dict], int]:
"""Cut chunks until total chars <= self.char_budget.
Returns the truncated list and the actual char count used.
"""
out: list[dict] = []
used = 0
for r in results:
text = (r.get('text') or '').strip()
if not text:
continue
# Per-chunk soft cap so one giant page can't eat the whole budget.
if len(text) > 1200:
text = text[:1200].rsplit(' ', 1)[0] + ''
if used + len(text) > self.char_budget:
# If we have nothing yet, take a hard slice so the prompt is
# never empty when the budget is tiny.
if not out:
text = text[: max(self.char_budget // 2, 200)]
else:
break
out.append({**r, 'text': text})
used += len(text)
return out, used
# ------------------------------------------------------------------
def for_week(self, plan, week, skill: str, *, k: int = DEFAULT_K) -> dict:
"""Return RAG context for a (plan, week, skill).
:returns: ``{passages, sources, char_budget_used, query, as_prompt_block}``.
Empty dict-shape when the plan has no indexed sources.
"""
empty = {
'passages': [],
'sources': [],
'char_budget_used': 0,
'query': '',
'as_prompt_block': '',
}
svc = self._service()
if svc is None:
return empty
if not self.has_indexed_sources(plan):
return empty
query = self._build_query(plan, week, skill)
try:
raw_results = svc.search(
query,
content_type=self.CONTENT_TYPE,
limit=int(k),
entity_id=plan.id, # SourceIndexer stores plan_id under entity_id
)
except Exception:
_logger.exception('RAG search failed for plan=%s skill=%s', plan.id, skill)
return empty
# Decorate with source titles for citation.
Source = self.env['encoach.course.plan.source'].sudo()
passages: list[dict] = []
for r in raw_results or []:
md = r.get('metadata') or {}
source_id = int(md.get('source_id') or 0) or None
title = md.get('title') or ''
if source_id and not title:
rec = Source.browse(source_id)
if rec.exists():
title = rec.name or rec.file_name or rec.url or f'Source #{source_id}'
passages.append({
'text': r.get('text') or '',
'similarity': float(r.get('score') or r.get('similarity') or 0.0),
'source_id': source_id,
'source_title': title,
'chunk_index': md.get('chunk_index'),
})
passages, used = self._trim_to_budget(passages)
sources = self._collect_sources(passages)
block = self._render_prompt_block(passages)
return {
'passages': passages,
'sources': sources,
'char_budget_used': used,
'query': query,
'as_prompt_block': block,
}
# ------------------------------------------------------------------
def for_plan(self, plan, *, k: int = 12, query: str | None = None) -> dict:
"""Plan-wide retrieval — used by ExerciseExtractor and the workbook
cross-week pass. Falls back to ``plan.name`` when no query is given.
"""
empty = {
'passages': [],
'sources': [],
'char_budget_used': 0,
'query': '',
'as_prompt_block': '',
}
svc = self._service()
if svc is None or not self.has_indexed_sources(plan):
return empty
q = (query or plan.name or 'exercises').strip()
try:
raw_results = svc.search(
q,
content_type=self.CONTENT_TYPE,
limit=int(k),
entity_id=plan.id,
)
except Exception:
_logger.exception('RAG plan-wide search failed for plan=%s', plan.id)
return empty
Source = self.env['encoach.course.plan.source'].sudo()
passages: list[dict] = []
for r in raw_results or []:
md = r.get('metadata') or {}
source_id = int(md.get('source_id') or 0) or None
title = md.get('title') or ''
if source_id and not title:
rec = Source.browse(source_id)
if rec.exists():
title = rec.name or rec.file_name or rec.url or f'Source #{source_id}'
passages.append({
'text': r.get('text') or '',
'similarity': float(r.get('score') or r.get('similarity') or 0.0),
'source_id': source_id,
'source_title': title,
'chunk_index': md.get('chunk_index'),
})
passages, used = self._trim_to_budget(passages)
sources = self._collect_sources(passages)
block = self._render_prompt_block(passages)
return {
'passages': passages,
'sources': sources,
'char_budget_used': used,
'query': q,
'as_prompt_block': block,
}
# ------------------------------------------------------------------
@staticmethod
def _collect_sources(passages: list[dict]) -> list[dict]:
"""Group passages by source_id → ``[{source_id, title, chunks_used}]``."""
agg: dict[int, dict] = {}
for p in passages:
sid = p.get('source_id') or 0
entry = agg.setdefault(sid, {
'source_id': sid,
'title': p.get('source_title') or '',
'chunks_used': 0,
})
entry['chunks_used'] += 1
if not entry['title']:
entry['title'] = p.get('source_title') or ''
return [v for v in agg.values() if v['source_id']]
# ------------------------------------------------------------------
@staticmethod
def _render_prompt_block(passages: list[dict]) -> str:
"""Format passages as a citation-ready prompt block."""
if not passages:
return ''
lines: list[str] = []
for i, p in enumerate(passages, start=1):
title = p.get('source_title') or 'Reference'
lines.append(f'[{i}] ({title})\n{p.get("text", "")}\n')
return '\n'.join(lines).strip()

View File

@@ -20,18 +20,182 @@ from __future__ import annotations
import base64
import io
import logging
import os
import sys
from datetime import datetime
_logger = logging.getLogger(__name__)
# A doc with fewer than this many extractable characters is treated as
# image-only and routed to OCR. Most legitimate PDFs cross 200 chars in
# their first few pages; below that we're almost certainly looking at a
# scan whose pypdf/pdfminer output is junk metadata only.
_OCR_TRIGGER_CHARS = 200
# Cap how much we OCR in a single indexing pass so a 500-page scanned
# tome doesn't tie up the request thread for 20 minutes. Editable via
# env var so an operator can dial it up for big workbooks.
_OCR_MAX_PAGES = int(os.environ.get('ENCOACH_OCR_MAX_PAGES', '300'))
_OCR_DPI = int(os.environ.get('ENCOACH_OCR_DPI', '200'))
_OCR_LANGS = os.environ.get('ENCOACH_OCR_LANGS', 'eng')
def _resolve_bin(name: str) -> str | None:
"""Locate a binary by checking the running Python's bin/ first.
Odoo is launched with the conda env's interpreter, so its sibling
``bin/`` reliably hosts ``tesseract`` / ``pdftoppm`` even when
``$PATH`` in the daemon's environment is sparse. Fall back to
``shutil.which`` for systems where they're installed globally.
"""
sibling = os.path.join(os.path.dirname(sys.executable), name)
if os.path.isfile(sibling) and os.access(sibling, os.X_OK):
return sibling
import shutil
return shutil.which(name)
def _ocr_pdf(payload: bytes, *, max_pages: int = _OCR_MAX_PAGES) -> str:
"""OCR a scanned/image-only PDF using tesseract + poppler.
Streams page-by-page so peak memory stays bounded even on a 100+
page workbook: rasterizing all pages eagerly into PIL.Image objects
blows past Odoo's ~2 GB ``limit_memory_soft`` and forces a worker
reload mid-request. We instead call ``pdftoppm`` for one page at a
time, OCR the image, drop it, and move on.
Raises a ``RuntimeError`` with an actionable message when the OCR
stack isn't available, so the UI can surface "install OCR" rather
than the cryptic "Extracted no text from source".
"""
try:
from pdf2image import convert_from_bytes, pdfinfo_from_bytes # type: ignore
import pytesseract # type: ignore
except ImportError as exc:
raise RuntimeError(
'This PDF appears to be scanned/image-only and the OCR stack '
'is not installed. Install the deps: '
'`micromamba install -n odoo19 -c conda-forge tesseract poppler` '
'and `pip install pytesseract pdf2image`. '
f'(missing: {exc.name})',
) from exc
tesseract_bin = _resolve_bin('tesseract')
poppler_bin = _resolve_bin('pdftoppm')
if not tesseract_bin or not poppler_bin:
raise RuntimeError(
'OCR is required for this PDF (no embedded text layer) but '
f'tesseract={tesseract_bin or "missing"} / '
f'pdftoppm={poppler_bin or "missing"} could not be located. '
'Install with `micromamba install -n odoo19 -c conda-forge '
'tesseract poppler`.',
)
pytesseract.pytesseract.tesseract_cmd = tesseract_bin
poppler_path = os.path.dirname(poppler_bin)
try:
info = pdfinfo_from_bytes(payload, poppler_path=poppler_path)
total_pages = int(info.get('Pages') or 0)
except Exception as exc:
raise RuntimeError(f'Could not read PDF page count: {exc}') from exc
page_count = min(total_pages, max_pages)
if page_count <= 0:
return ''
_logger.info(
'OCR streaming %s page(s) at %sdpi (lang=%s)',
page_count, _OCR_DPI, _OCR_LANGS,
)
pages: list[str] = []
for page_num in range(1, page_count + 1):
try:
images = convert_from_bytes(
payload,
dpi=_OCR_DPI,
first_page=page_num,
last_page=page_num,
poppler_path=poppler_path,
fmt='png',
thread_count=1,
)
except Exception as exc:
_logger.warning('OCR rasterize page %s failed: %s', page_num, exc)
continue
if not images:
continue
img = images[0]
try:
txt = pytesseract.image_to_string(img, lang=_OCR_LANGS) or ''
except Exception as exc:
_logger.warning('OCR page %s failed: %s', page_num, exc)
txt = ''
finally:
try:
img.close()
except Exception:
pass
del img, images
if txt.strip():
pages.append(txt)
return '\n\n'.join(pages).strip()
def _sniff_format(payload: bytes) -> str:
"""Detect the real file format from magic bytes.
User-supplied `type` / `mime_type` / filename are unreliable: people
pick `type=pdf` in the resource library and then upload a `.docx`,
or rename a `.doc` to `.pdf`. We therefore decide by content first,
so that even when `res.type='pdf'` is wrong we route a DOCX into
`_extract_docx` instead of feeding it to `pypdf` which then dies
with "EOF marker not found" — exactly the failure that surfaced in
the UI as "indexing failed" without an actionable message.
Returns one of: ``pdf``, ``docx``, ``doc``, ``html``, ``text``, ``unknown``.
"""
if not payload or len(payload) < 4:
return 'unknown'
if payload[:5] == b'%PDF-':
return 'pdf'
if payload[:4] == b'PK\x03\x04':
head = payload[:8192]
if b'word/document.xml' in head or b'word/' in head:
return 'docx'
return 'zip'
if payload[:8] == b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1':
return 'doc'
sniff = payload[:512].lstrip().lower()
if sniff.startswith(b'<!doctype') or sniff.startswith(b'<html') or b'<html' in sniff:
return 'html'
try:
sample = payload[:1024].decode('utf-8')
except UnicodeDecodeError:
return 'unknown'
if all((ch.isprintable() or ch in '\r\n\t') for ch in sample):
return 'text'
return 'unknown'
def _extract_pdf(payload: bytes) -> str:
"""Best-effort PDF text extraction.
"""Best-effort PDF text extraction with OCR fallback.
Tries ``pypdf`` first (newer, maintained), then ``PyPDF2`` (older,
still common). Both raise on encrypted PDFs we can't decrypt — we
swallow that and let the caller record the error.
1. Try ``pypdf`` (and ``PyPDF2`` as a legacy fallback) to read the
embedded text layer — fast, deterministic, lossless.
2. If that yields virtually nothing (< ``_OCR_TRIGGER_CHARS``), the
PDF is almost certainly a scan; rasterize the first
``_OCR_MAX_PAGES`` pages and run them through tesseract.
Both raise on encrypted PDFs we can't decrypt — we swallow that and
let the caller record the error.
"""
if payload[:5] != b'%PDF-':
raise RuntimeError(
'File is not a valid PDF (missing %PDF- header). '
f'Detected format: {_sniff_format(payload)!r}.'
)
try:
from pypdf import PdfReader # type: ignore
except ImportError:
@@ -48,7 +212,22 @@ def _extract_pdf(payload: bytes) -> str:
pages.append(page.extract_text() or '')
except Exception:
pages.append('')
return '\n\n'.join(p for p in pages if p).strip()
text = '\n\n'.join(p for p in pages if p).strip()
if len(text) >= _OCR_TRIGGER_CHARS:
return text
# Looks image-only — try OCR. If OCR is unavailable, propagate a
# clear error rather than silently returning the empty/near-empty
# text-layer output.
_logger.info(
'PDF text layer empty (%s chars over %s pages); attempting OCR',
len(text), len(reader.pages),
)
ocr_text = _ocr_pdf(payload)
if ocr_text:
return ocr_text
return text
def _extract_docx(payload: bytes) -> str:
@@ -142,17 +321,28 @@ class SourceIndexer:
payload = base64.b64decode(res.file)
if not payload:
raise ValueError('Library resource has an empty file.')
if rtype == 'pdf' or file_name.lower().endswith('.pdf'):
# Route by ACTUAL file content (magic bytes), not by the
# user-set `type`. Otherwise an admin who uploaded a DOCX
# but classified it as `type='pdf'` causes pypdf to die
# with "EOF marker not found" — exactly the failure we
# were debugging here.
fmt = _sniff_format(payload)
if fmt == 'pdf':
return _extract_pdf(payload)
if rtype == 'document' and file_name.lower().endswith(('.docx', '.doc')):
if fmt == 'docx':
return _extract_docx(payload)
# Fall back to plain-text decoding for txt/md/csv/json.
if file_name.lower().endswith((
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
'.log', '.rst',
)):
if fmt == 'html':
return _extract_html(payload.decode('utf-8', errors='replace'))
if fmt == 'text':
return payload.decode('utf-8', errors='replace').strip()
# Best-effort: try PDF first, then DOCX, then UTF-8.
if fmt == 'doc':
raise RuntimeError(
'Legacy .doc (Word 97-2003) is not supported for RAG '
'indexing. Re-save the file as .docx or PDF and '
're-upload.'
)
# Last-resort: best-effort try the parsers anyway, in case
# someone gave us a weird-but-still-text-bearing payload.
for fn in (_extract_pdf, _extract_docx):
try:
text = fn(payload)
@@ -161,11 +351,18 @@ class SourceIndexer:
except Exception:
continue
try:
return payload.decode('utf-8', errors='replace').strip()
decoded = payload.decode('utf-8', errors='replace').strip()
except Exception as exc:
raise RuntimeError(
f'Cannot decode library resource binary: {exc}',
) from exc
if decoded:
return decoded
raise RuntimeError(
f'Unsupported file format for RAG indexing '
f'(detected={fmt!r}, declared type={rtype!r}). '
'Supported: PDF, DOCX, HTML, plain text.'
)
if res.url:
_, text = _fetch_url(res.url)
@@ -188,32 +385,40 @@ class SourceIndexer:
payload = source.get_decoded_file()
if not payload:
raise ValueError('No file payload to index')
# Detect actual content first so a mis-typed mime / extension
# (e.g. .pdf renamed onto a .docx) still indexes correctly.
fmt = _sniff_format(payload)
mime = (source.mime_type or '').lower()
name = (source.file_name or '').lower()
if mime == 'application/pdf' or name.endswith('.pdf'):
if fmt == 'pdf':
return _extract_pdf(payload)
if (mime in (
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
) or name.endswith('.docx') or name.endswith('.doc')):
if fmt == 'docx':
return _extract_docx(payload)
# Whitelist plain-text-shaped uploads explicitly. Anything else
# (xlsx, png, mp3, zip, …) must be rejected with a clear error
# rather than silently UTF-8-decoded into garbage that we'd
# then "successfully" embed and surface as a usable RAG source.
if (mime.startswith('text/')
or mime in ('application/json', 'application/xml',
'application/csv')
or name.endswith(('.txt', '.md', '.markdown', '.csv',
'.json', '.xml', '.log', '.rst'))):
if fmt == 'html':
return _extract_html(payload.decode('utf-8', errors='replace'))
if fmt == 'text':
return payload.decode('utf-8', errors='replace').strip()
if fmt == 'doc':
raise RuntimeError(
'Legacy .doc (Word 97-2003) is not supported for RAG '
'indexing. Re-save the file as .docx or PDF and '
're-upload.'
)
# Fall back to declared mime/extension for ambiguous content.
if mime.startswith('text/') or mime in (
'application/json', 'application/xml', 'application/csv',
) or name.endswith((
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
'.log', '.rst',
)):
try:
return payload.decode('utf-8', errors='replace').strip()
except Exception as exc:
raise RuntimeError(f'Cannot decode file: {exc}') from exc
raise ValueError(
f'Unsupported file type for RAG indexing: '
f'mime={mime!r} name={source.file_name!r}. '
f'Supported: PDF, DOCX/DOC, plain text (txt/md/csv/json/xml).'
f'detected={fmt!r} mime={mime!r} name={source.file_name!r}. '
f'Supported: PDF, DOCX, HTML, plain text (txt/md/csv/json/xml).'
)
raise ValueError(f'Unknown source kind: {source.kind!r}')
@@ -238,13 +443,26 @@ class SourceIndexer:
return {'status': 'failed', 'error': str(exc)}
if not text or not text.strip():
kind = (source.kind or '').lower()
if kind in ('file', 'resource') and (
(source.file_name or '').lower().endswith('.pdf')
or (source.mime_type or '').lower() == 'application/pdf'
):
err = (
'No text could be extracted from this PDF. It looks '
'image-only (a scan) and OCR returned nothing. Try a '
'higher-quality scan, a publisher PDF with an '
'embedded text layer, or raise ENCOACH_OCR_DPI.'
)
else:
err = 'Extracted no text from source'
source.write({
'status': 'failed',
'error': 'Extracted no text from source',
'error': err,
'chunks_count': 0,
'extracted_chars': 0,
})
return {'status': 'failed', 'error': 'empty'}
return {'status': 'failed', 'error': err}
try:
svc = EmbeddingService(self.env)

View File

@@ -23,6 +23,9 @@
'encoach_ai',
'encoach_taxonomy',
'encoach_resources',
'encoach_scoring',
'encoach_exam_template',
'encoach_ai_course',
],
'data': [
'security/ir.model.access.csv',

View File

@@ -26,3 +26,11 @@ from . import paymob
from . import platform_settings
from . import training
from . import reports
from . import branches
from . import teacher_insights
from . import exam_security
from . import live_sessions
from . import personalized_plan
from . import handwritten
from . import reports_export
from . import turnitin

View File

@@ -0,0 +1,174 @@
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, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _entity_scope():
"""Same convention as ``encoach_lms_api.controllers.lms_core._entity_scope``."""
user = request.env.user.sudo()
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
if ids:
return ids, False
is_super = bool(
user.id == 1 or user.has_group('base.group_system')
)
return ids, is_super
def _ensure_entity_access(entity_id):
if not entity_id:
raise PermissionError('entity_id is required')
ids, is_super = _entity_scope()
if is_super:
return int(entity_id)
if not ids:
raise PermissionError('User is not linked to any entity')
if int(entity_id) not in ids:
raise PermissionError('Entity access denied')
return int(entity_id)
def _default_entity_id_from_scope():
ids, is_super = _entity_scope()
if ids:
return ids[0]
if is_super:
return False
raise PermissionError('User is not linked to any entity')
def _scoped_entity_domain(base_domain=None):
domain = list(base_domain or [])
ids, is_super = _entity_scope()
if is_super:
return domain
if not ids:
return domain + [('id', '=', 0)]
return domain + [('entity_id', 'in', ids)]
def _ser_branch(rec):
return {
'id': rec.id,
'name': rec.name or '',
'code': rec.code or '',
'active': bool(rec.active),
'notes': rec.notes or '',
'entity_id': rec.entity_id.id if rec.entity_id else None,
'entity_name': rec.entity_id.name if rec.entity_id else '',
'course_count': rec.course_count or 0,
'batch_count': rec.batch_count or 0,
'student_count': rec.student_count or 0,
'teacher_count': rec.teacher_count or 0,
}
class LmsBranchController(http.Controller):
@http.route('/api/branches', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_branches(self, **kw):
try:
Branch = request.env['encoach.lms.branch'].sudo()
domain = _scoped_entity_domain([])
if kw.get('search'):
q = kw['search']
domain += ['|', ('name', 'ilike', q), ('code', 'ilike', q)]
if kw.get('active') in ('true', 'false', '1', '0'):
domain.append(('active', '=', kw.get('active') in ('true', '1')))
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw)
total = Branch.search_count(domain)
recs = Branch.search(domain, offset=offset, limit=limit, order='name asc, id asc')
items = [_ser_branch(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as exc:
_logger.exception('list_branches failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_branch(self, branch_id, **kw):
try:
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
if not rec.exists():
return _error_response('Not found', 404)
_ensure_entity_access(rec.entity_id.id)
return _json_response({'data': _ser_branch(rec)})
except Exception as exc:
_logger.exception('get_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_branch(self, **kw):
try:
body = _get_json_body()
name = (body.get('name') or '').strip()
if not name:
return _error_response('name is required', 400)
requested_entity = body.get('entity_id')
if requested_entity:
entity_id = _ensure_entity_access(int(requested_entity))
else:
entity_id = _default_entity_id_from_scope()
vals = {
'name': name,
'entity_id': entity_id,
'code': (body.get('code') or '').strip() or False,
'notes': (body.get('notes') or '').strip(),
}
if 'active' in body:
vals['active'] = bool(body.get('active'))
rec = request.env['encoach.lms.branch'].sudo().create(vals)
return _json_response({'data': _ser_branch(rec)})
except Exception as exc:
_logger.exception('create_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_branch(self, branch_id, **kw):
try:
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
if not rec.exists():
return _error_response('Not found', 404)
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = (body.get('name') or '').strip()
if 'code' in body:
vals['code'] = (body.get('code') or '').strip() or False
if 'notes' in body:
vals['notes'] = (body.get('notes') or '').strip()
if 'active' in body:
vals['active'] = bool(body.get('active'))
if 'entity_id' in body and body.get('entity_id'):
vals['entity_id'] = _ensure_entity_access(int(body.get('entity_id')))
if vals:
rec.write(vals)
return _json_response({'data': _ser_branch(rec)})
except Exception as exc:
_logger.exception('update_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_branch(self, branch_id, **kw):
try:
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
if rec.exists():
_ensure_entity_access(rec.entity_id.id)
rec.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('delete_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

View File

@@ -1,4 +1,30 @@
"""HTTP controller for classrooms (homerooms).
Routes:
- GET /api/classrooms list (entity-scoped)
- GET /api/classrooms/<id> fetch with full nested data
- POST /api/classrooms create
- PATCH /api/classrooms/<id> partial update
- DELETE /api/classrooms/<id> delete
- GET /api/classrooms/<id>/students list roster
- POST /api/classrooms/<id>/students set/add students (body: {student_ids:[..], mode:'set'|'add'})
- DELETE /api/classrooms/<id>/students remove students (body: {student_ids:[..]})
- GET /api/classrooms/<id>/teachers list homeroom teachers
- POST /api/classrooms/<id>/teachers set/add teachers (body: {teacher_ids:[..], mode:'set'|'add'})
- GET /api/classrooms/<id>/courses list courses
- POST /api/classrooms/<id>/assign-course cascade course → batch + enroll students
- DELETE /api/classrooms/<id>/courses/<cid> detach course (does NOT delete batches/enrollments)
- GET /api/classrooms/<id>/batches list classroom batches
The legacy ``/api/groups`` aliases on op.classroom remain for backward
compatibility but new clients should target ``/api/classrooms``.
"""
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
@@ -8,89 +34,592 @@ from odoo.addons.encoach_api.controllers.base import (
_logger = logging.getLogger(__name__)
def _ser_classroom(r):
# ----------------------------------------------------------------------
# Entity scoping helpers (same shape used in branches.py / lms_core.py)
# ----------------------------------------------------------------------
def _entity_scope():
"""Same convention as ``encoach_lms_api.controllers.lms_core._entity_scope``."""
user = request.env.user.sudo()
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
if ids:
return ids, False
is_super = bool(
user.id == 1 or user.has_group('base.group_system')
)
return ids, is_super
def _ensure_entity_access(entity_id):
if not entity_id:
raise PermissionError('entity_id is required')
ids, is_super = _entity_scope()
if is_super:
return int(entity_id)
if not ids:
raise PermissionError('User is not linked to any entity')
if int(entity_id) not in ids:
raise PermissionError('Entity access denied')
return int(entity_id)
def _default_entity_id_from_scope():
ids, is_super = _entity_scope()
if ids:
return ids[0]
if is_super:
return False
raise PermissionError('User is not linked to any entity')
def _scoped_entity_domain(base_domain=None):
domain = list(base_domain or [])
ids, is_super = _entity_scope()
if is_super:
return domain
if not ids:
return domain + [('id', '=', 0)]
return domain + [('entity_id', 'in', ids)]
def _ensure_branch_access(branch_id, *, entity_id=None):
"""Validate that the branch exists, belongs to ``entity_id`` (when set)
and is accessible by the current user."""
if not branch_id:
return None
Branch = request.env['encoach.lms.branch'].sudo()
branch = Branch.browse(int(branch_id))
if not branch.exists():
raise ValueError('Branch not found')
_ensure_entity_access(branch.entity_id.id)
if entity_id and branch.entity_id.id != int(entity_id):
raise PermissionError('Branch does not belong to the requested entity.')
return branch.id
def _filter_ids_in_entity(model, ids, entity_id, *, label='record'):
"""Return ids that exist *and* belong to ``entity_id``.
When ``entity_id`` is falsy (e.g. legacy classroom without an entity), we
accept any record that has no ``entity_id`` set yet. This keeps the API
safe by default while still allowing administrators to operate on
pre-LMS-isolation data without surprises.
"""
if not ids:
return []
Model = request.env[model].sudo()
recs = Model.browse([int(x) for x in ids])
out = []
for rec in recs:
if not rec.exists():
continue
rec_entity = rec.entity_id.id if rec.entity_id else None
if entity_id and rec_entity and rec_entity != int(entity_id):
raise PermissionError(
f"{label} {rec.id} belongs to a different entity"
)
out.append(rec.id)
return out
# ----------------------------------------------------------------------
# Serializers
# ----------------------------------------------------------------------
def _ser_student_short(s):
return {
'id': r.id,
'name': r.name or '',
'code': getattr(r, 'code', '') or '',
'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0,
'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '',
'capacity': getattr(r, 'capacity', 0) or 0,
'id': s.id,
'name': s.name or '',
'email': s.email or '',
'gr_no': getattr(s, 'gr_no', '') or '',
}
def _ser_teacher_short(f):
return {
'id': f.id,
'name': f.name or '',
'email': getattr(f, 'email', '') or '',
}
def _ser_course_short(c):
return {
'id': c.id,
'name': c.name or '',
'code': c.code or '',
}
def _ser_section_short(s):
return {
'id': s.id,
'name': s.name or '',
'code': s.code or '',
'sequence': s.sequence or 0,
'course_id': s.course_id.id if s.course_id else None,
'course_name': s.course_id.name if s.course_id else '',
}
def _ser_batch_short(b):
return {
'id': b.id,
'name': b.name or '',
'code': b.code or '',
'course_id': b.course_id.id if b.course_id else None,
'course_name': b.course_id.name if b.course_id else '',
'course_section_id': (
b.course_section_id.id
if hasattr(b, 'course_section_id') and b.course_section_id
else None
),
'course_section_name': (
b.course_section_id.name
if hasattr(b, 'course_section_id') and b.course_section_id
else ''
),
'course_section_code': (
b.course_section_id.code
if hasattr(b, 'course_section_id') and b.course_section_id
else ''
),
'term_key': getattr(b, 'term_key', '') or '',
'start_date': b.start_date and str(b.start_date) or '',
'end_date': b.end_date and str(b.end_date) or '',
'student_count': getattr(b, 'student_count', 0) or 0,
'teacher_ids': b.teacher_ids.ids if hasattr(b, 'teacher_ids') else [],
}
def _ser_classroom(r, *, full=False):
data = {
'id': r.id,
'name': r.name or '',
'code': r.code or '',
'capacity': r.capacity or 0,
'active': bool(r.active),
'notes': getattr(r, 'notes', '') or '',
'entity_id': r.entity_id.id if r.entity_id else None,
'entity_name': r.entity_id.name if r.entity_id else '',
'branch_id': r.branch_id.id if r.branch_id else None,
'branch_name': r.branch_id.name if r.branch_id else '',
'student_count': r.student_count or 0,
'teacher_count': r.teacher_count or 0,
'course_count': r.course_count or 0,
'batch_count': r.batch_count or 0,
'student_ids': r.student_ids.ids,
'teacher_ids': r.teacher_ids.ids,
'course_ids': r.course_ids.ids,
}
if full:
data['students'] = [_ser_student_short(s) for s in r.student_ids]
data['teachers'] = [_ser_teacher_short(t) for t in r.teacher_ids]
data['courses'] = [_ser_course_short(c) for c in r.course_ids]
data['sections'] = [
_ser_section_short(s)
for s in request.env['encoach.course.section'].sudo().search([
('course_id', 'in', r.course_ids.ids)
], order='course_id, sequence, id')
]
data['batches'] = [_ser_batch_short(b) for b in r.batch_ids]
return data
def _classroom_or_404(cid):
rec = request.env['op.classroom'].sudo().browse(int(cid))
if not rec.exists():
return None
return rec
def _scoped_classroom_domain(base_domain=None):
"""Same as ``_scoped_entity_domain`` but tolerates legacy classrooms
without ``entity_id`` for backward-compatibility (so existing rooms
keep showing up to admins)."""
domain = list(base_domain or [])
ids, is_super = _entity_scope()
if is_super:
return domain
if not ids:
return domain + [('id', '=', 0)]
return domain + ['|', ('entity_id', 'in', ids), ('entity_id', '=', False)]
# ----------------------------------------------------------------------
# Controller
# ----------------------------------------------------------------------
class ClassroomsController(http.Controller):
# ---- CRUD --------------------------------------------------------
@http.route('/api/classrooms', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classrooms(self, **kw):
try:
Classroom = request.env['op.classroom'].sudo()
domain = _scoped_classroom_domain([])
if kw.get('search'):
q = kw['search']
domain += ['|', ('name', 'ilike', q), ('code', 'ilike', q)]
if kw.get('active') in ('true', 'false', '1', '0'):
domain.append(('active', '=', kw.get('active') in ('true', '1')))
if kw.get('branch_id'):
domain.append(('branch_id', '=', int(kw['branch_id'])))
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw)
total = Classroom.search_count(domain)
recs = Classroom.search(domain, offset=offset, limit=limit, order='name asc, id asc')
items = [_ser_classroom(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as exc:
_logger.exception('list_classrooms failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_classroom(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('get_classroom failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_classroom(self, **kw):
try:
body = _get_json_body()
name = (body.get('name') or '').strip()
if not name:
return _error_response('name is required', 400)
entity_id = (
_ensure_entity_access(int(body['entity_id']))
if body.get('entity_id') else _default_entity_id_from_scope()
)
branch_id = _ensure_branch_access(body.get('branch_id'), entity_id=entity_id) if body.get('branch_id') else None
code = (body.get('code') or '').strip() or name.upper().replace(' ', '_')[:16]
vals = {
'name': name,
'code': code,
'capacity': int(body.get('capacity') or 30),
'notes': (body.get('notes') or '').strip(),
'entity_id': entity_id,
'branch_id': branch_id or False,
}
if 'active' in body:
vals['active'] = bool(body.get('active'))
if body.get('student_ids'):
vals['student_ids'] = [(6, 0, [int(x) for x in body['student_ids']])]
if body.get('teacher_ids'):
vals['teacher_ids'] = [(6, 0, [int(x) for x in body['teacher_ids']])]
rec = request.env['op.classroom'].sudo().create(vals)
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('create_classroom failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_classroom(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = (body.get('name') or '').strip()
if 'code' in body:
vals['code'] = (body.get('code') or '').strip() or rec.code
if 'capacity' in body:
vals['capacity'] = int(body.get('capacity') or rec.capacity)
if 'notes' in body:
vals['notes'] = (body.get('notes') or '').strip()
if 'active' in body:
vals['active'] = bool(body.get('active'))
target_entity = rec.entity_id.id
if 'entity_id' in body and body.get('entity_id'):
target_entity = _ensure_entity_access(int(body['entity_id']))
vals['entity_id'] = target_entity
if 'branch_id' in body:
vals['branch_id'] = (
_ensure_branch_access(int(body['branch_id']), entity_id=target_entity)
if body.get('branch_id') else False
)
if vals:
rec.write(vals)
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('update_classroom failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_classroom(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if rec:
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
rec.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('delete_classroom failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ---- Roster (students) ------------------------------------------
@http.route('/api/classrooms/<int:cid>/students', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classroom_students(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
items = [_ser_student_short(s) for s in rec.student_ids]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as exc:
_logger.exception('list_classroom_students failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/students', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def set_classroom_students(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
raw_ids = [int(x) for x in (body.get('student_ids') or [])]
ids = _filter_ids_in_entity(
'op.student', raw_ids,
rec.entity_id.id if rec.entity_id else None,
label='Student',
)
mode = (body.get('mode') or 'set').lower()
if mode == 'set':
rec.write({'student_ids': [(6, 0, ids)]})
else:
rec.write({'student_ids': [(4, x) for x in ids]})
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('set_classroom_students failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/students', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def remove_classroom_students(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
ids = [int(x) for x in (body.get('student_ids') or [])]
rec.write({'student_ids': [(3, x) for x in ids]})
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('remove_classroom_students failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ---- Homeroom teachers ------------------------------------------
@http.route('/api/classrooms/<int:cid>/teachers', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classroom_teachers(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
items = [_ser_teacher_short(t) for t in rec.teacher_ids]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as exc:
_logger.exception('list_classroom_teachers failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/teachers', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def set_classroom_teachers(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
raw_ids = [int(x) for x in (body.get('teacher_ids') or [])]
ids = _filter_ids_in_entity(
'op.faculty', raw_ids,
rec.entity_id.id if rec.entity_id else None,
label='Teacher',
)
mode = (body.get('mode') or 'set').lower()
if mode == 'set':
rec.write({'teacher_ids': [(6, 0, ids)]})
else:
rec.write({'teacher_ids': [(4, x) for x in ids]})
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('set_classroom_teachers failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ---- Courses + cascade ------------------------------------------
@http.route('/api/classrooms/<int:cid>/courses', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classroom_courses(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
items = [_ser_course_short(c) for c in rec.course_ids]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as exc:
_logger.exception('list_classroom_courses failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/sections', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classroom_sections(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
domain = [('course_id', 'in', rec.course_ids.ids)]
if kw.get('course_id'):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('active') in ('true', 'false', '1', '0'):
domain.append(('active', '=', kw.get('active') in ('true', '1')))
items = [
_ser_section_short(s)
for s in request.env['encoach.course.section'].sudo().search(
domain, order='course_id, sequence, id'
)
]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as exc:
_logger.exception('list_classroom_sections failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/assign-course', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def assign_classroom_course(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
course_id = int(body.get('course_id') or 0)
if not course_id:
return _error_response('course_id is required', 400)
classroom_entity_id = rec.entity_id.id if rec.entity_id else None
_filter_ids_in_entity('op.course', [course_id], classroom_entity_id, label='Course')
section_id = int(body.get('section_id') or 0) or None
if section_id:
_filter_ids_in_entity(
'encoach.course.section', [section_id],
classroom_entity_id, label='Course section',
)
teacher_ids = body.get('teacher_ids')
if teacher_ids is not None:
teacher_ids = _filter_ids_in_entity(
'op.faculty', [int(x) for x in teacher_ids],
classroom_entity_id, label='Teacher',
)
term_name = body.get('term_name') or None
term_key = (body.get('term_key') or '').strip() or None
start_date = body.get('start_date') or None
end_date = body.get('end_date') or None
batch = rec.assign_course(
course_id,
term_name=term_name,
start_date=start_date,
end_date=end_date,
teacher_ids=teacher_ids,
section_id=section_id,
term_key=term_key,
)
return _json_response({
'data': _ser_classroom(rec, full=True),
'batch': _ser_batch_short(batch),
})
except Exception as exc:
_logger.exception('assign_classroom_course failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/assign-section', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def assign_classroom_section(self, cid, **kw):
"""Alias to make the classroom×section workflow explicit for clients."""
return self.assign_classroom_course(cid, **kw)
@http.route('/api/classrooms/<int:cid>/courses/<int:course_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def detach_classroom_course(self, cid, course_id, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
rec.write({'course_ids': [(3, int(course_id))]})
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('detach_classroom_course failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ---- Batches -----------------------------------------------------
@http.route('/api/classrooms/<int:cid>/batches', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classroom_batches(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
items = [_ser_batch_short(b) for b in rec.batch_ids]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as exc:
_logger.exception('list_classroom_batches failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ---- Legacy /api/groups aliases (back-compat) -------------------
@http.route('/api/groups', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_groups(self, **kw):
try:
M = request.env['op.classroom'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [_ser_classroom(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
return self.list_classrooms(**kw)
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_group(self, gid, **kw):
try:
rec = request.env['op.classroom'].sudo().browse(gid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_classroom(rec))
except Exception as e:
return _error_response(str(e), 500)
return self.get_classroom(gid, **kw)
@http.route('/api/groups', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_group(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
if body.get('code'):
vals['code'] = body['code']
if body.get('capacity'):
vals['capacity'] = int(body['capacity'])
rec = request.env['op.classroom'].sudo().create(vals)
return _json_response(_ser_classroom(rec))
except Exception as e:
return _error_response(str(e), 500)
return self.create_classroom(**kw)
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_group(self, gid, **kw):
try:
rec = request.env['op.classroom'].sudo().browse(gid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups/transfer', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def transfer(self, **kw):
try:
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups/<int:gid>/members', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def add_members(self, gid, **kw):
try:
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups/<int:gid>/members/remove', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def remove_members(self, gid, **kw):
try:
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
return self.delete_classroom(gid, **kw)

View File

@@ -18,6 +18,8 @@ def _ser_board(b):
'batch_name': b.batch_id.name if b.batch_id else None,
'chapter_id': b.chapter_id.id if b.chapter_id else None,
'chapter_name': b.chapter_id.name if b.chapter_id else None,
'plan_id': b.plan_id.id if b.plan_id else None,
'plan_name': b.plan_id.name if b.plan_id else None,
'is_enabled': b.is_enabled,
'allow_student_posts': b.allow_student_posts,
'post_count': b.post_count or 0,
@@ -97,6 +99,8 @@ class CommunicationController(http.Controller):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('batch_id'):
domain.append(('batch_id', '=', int(kw['batch_id'])))
if kw.get('plan_id'):
domain.append(('plan_id', '=', int(kw['plan_id'])))
recs = M.search(domain, limit=200, order='id desc')
return _json_response([_ser_board(r) for r in recs])
except Exception as e:
@@ -112,11 +116,55 @@ class CommunicationController(http.Controller):
vals['course_id'] = int(body['course_id'])
if body.get('batch_id'):
vals['batch_id'] = int(body['batch_id'])
if body.get('plan_id'):
vals['plan_id'] = int(body['plan_id'])
rec = request.env['encoach.discussion.board'].sudo().create(vals)
return _json_response(_ser_board(rec))
except Exception as e:
return _error_response(str(e), 500)
# Phase 1: course/plan-scoped board lookup-or-create.
# Returns the board attached to a given course or plan; creates
# one transparently the first time someone opens the discussion
# tab inside a course detail page.
@http.route('/api/discussion-boards/for-course/<int:course_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def board_for_course(self, course_id, **kw):
try:
M = request.env['encoach.discussion.board'].sudo()
board = M.search([('course_id', '=', course_id), ('plan_id', '=', False)], limit=1)
if not board:
Course = request.env['op.course'].sudo().browse(course_id)
if not Course.exists():
return _error_response('Course not found', 404)
board = M.create({
'name': f'Discussion — {Course.name or "Course"}',
'course_id': course_id,
})
return _json_response(_ser_board(board))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/discussion-boards/for-plan/<int:plan_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def board_for_plan(self, plan_id, **kw):
try:
M = request.env['encoach.discussion.board'].sudo()
board = M.search([('plan_id', '=', plan_id)], limit=1)
if not board:
Plan = request.env['encoach.course.plan'].sudo().browse(plan_id)
if not Plan.exists():
return _error_response('Plan not found', 404)
board = M.create({
'name': f'Discussion — {Plan.name or "Course Plan"}',
'plan_id': plan_id,
})
return _json_response(_ser_board(board))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/discussion-boards/<int:bid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_board(self, bid, **kw):

View File

@@ -0,0 +1,282 @@
"""Online-test SOFT security controller.
* `POST /api/exam/security/event` — student client posts a
suspicious event during an attempt.
* `POST /api/exam/single-attempt-check` — student client checks
whether a fresh attempt is allowed for an exam.
* `GET /api/teacher/exam/<assignment_id>/security/events` —
teacher console reads the per-assignment log.
* `GET /api/teacher/exam/attempt/<attempt_id>/security/events` —
same, scoped to a specific attempt.
* `GET /api/teacher/exam/<assignment_id>/live` — list of in-progress
attempts + latest event per attempt.
"""
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
VALID_EVENT_TYPES = {
'tab_blur', 'tab_focus', 'window_blur', 'window_focus',
'paste', 'copy', 'cut', 'context_menu', 'fullscreen_exit',
'shortcut', 'multi_attempt_block', 'other',
}
def _parse_body():
try:
body = request.httprequest.get_data(as_text=True)
return json.loads(body) if body else {}
except (TypeError, ValueError):
return {}
def _ser_event(ev):
payload = None
if ev.payload:
try:
payload = json.loads(ev.payload)
except (TypeError, ValueError):
payload = ev.payload
return {
'id': ev.id,
'attempt_id': ev.attempt_id.id if ev.attempt_id else None,
'student_id': ev.student_id.id,
'student_name': ev.student_id.name or ev.student_id.login or '',
'exam_id': ev.exam_id.id if ev.exam_id else None,
'event_type': ev.event_type,
'severity': ev.severity,
'occurred_at': ev.occurred_at.isoformat() if ev.occurred_at else None,
'payload': payload,
}
class ExamSecurityController(http.Controller):
@http.route('/api/exam/security/event',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def post_event(self, **_kw):
try:
body = _parse_body()
event_type = (body.get('event_type') or '').strip().lower()
if event_type not in VALID_EVENT_TYPES:
return _error_response('Unknown event_type', 400)
attempt_id = body.get('attempt_id')
attempt = None
if attempt_id:
attempt = request.env['encoach.student.attempt'].sudo() \
.browse(int(attempt_id))
if not attempt.exists():
attempt = None
user = request.env.user
if attempt and attempt.student_id and attempt.student_id.id != user.id:
return _error_response(
'Cannot log events for another user\'s attempt', 403,
)
severity = body.get('severity') or 'warning'
if severity not in ('info', 'warning', 'alert'):
severity = 'warning'
payload_obj = body.get('payload')
payload_str = None
if payload_obj is not None:
try:
payload_str = json.dumps(payload_obj)[:4000]
except (TypeError, ValueError):
payload_str = None
Ev = request.env['encoach.exam.security.event'].sudo()
ev = Ev.create({
'attempt_id': attempt.id if attempt else False,
'student_id': user.id,
'exam_id': (attempt.exam_id.id if attempt and attempt.exam_id
else (int(body['exam_id']) if body.get('exam_id') else False)),
'event_type': event_type,
'severity': severity,
'payload': payload_str,
})
auto_locked = False
if attempt and attempt.exam_id:
exam = attempt.exam_id
level = getattr(exam, 'security_level', 'soft') or 'soft'
threshold = getattr(exam, 'suspicious_event_threshold', 5) or 5
if level == 'strict' and severity == 'alert':
alerts = Ev.search_count([
('attempt_id', '=', attempt.id),
('severity', '=', 'alert'),
])
if alerts >= threshold and attempt.status == 'in_progress':
attempt.write({
'status': 'completed',
'completed_at': ev.occurred_at,
})
auto_locked = True
return _json_response({
'event_id': ev.id,
'auto_locked': auto_locked,
})
except Exception as e:
_logger.exception('exam.security.event failed')
return _error_response(str(e), 500)
@http.route('/api/exam/single-attempt-check',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def single_attempt_check(self, **kw):
try:
exam_id_raw = kw.get('exam_id')
if not exam_id_raw:
return _error_response('exam_id required', 400)
exam = request.env['encoach.exam.custom'].sudo() \
.browse(int(exam_id_raw))
if not exam.exists():
return _error_response('Exam not found', 404)
user = request.env.user
single = bool(getattr(exam, 'single_attempt', True))
existing = request.env['encoach.student.attempt'].sudo().search([
('exam_id', '=', exam.id),
('student_id', '=', user.id),
('status', 'in', (
'completed', 'scoring', 'scored', 'released',
'pending_approval',
)),
], limit=1)
allowed = not (single and existing)
if not allowed:
request.env['encoach.exam.security.event'].sudo().create({
'student_id': user.id,
'exam_id': exam.id,
'event_type': 'multi_attempt_block',
'severity': 'alert',
})
return _json_response({
'allowed': allowed,
'security_level': getattr(exam, 'security_level', 'soft'),
'single_attempt': single,
'existing_attempt_id': existing.id if existing else None,
})
except Exception as e:
_logger.exception('single_attempt_check failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/exam/<int:assignment_id>/security/events',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def assignment_events(self, assignment_id, **kw):
try:
Assign = request.env['encoach.exam.assignment'].sudo()
assign = Assign.browse(assignment_id)
if not assign.exists():
return _error_response('Assignment not found', 404)
domain = [('exam_id', '=', assign.exam_id.id)] if assign.exam_id else []
if assign.student_id:
domain.append(('student_id', '=', assign.student_id.id))
elif assign.batch_id:
Batch = request.env['op.batch'].sudo().browse(assign.batch_id.id)
course_regs = request.env['op.student.course'].sudo().search([
('batch_id', '=', Batch.id),
])
user_ids = [r.student_id.user_id.id for r in course_regs
if r.student_id and r.student_id.user_id]
if user_ids:
domain.append(('student_id', 'in', user_ids))
Ev = request.env['encoach.exam.security.event'].sudo()
events = Ev.search(domain, limit=500)
return _json_response({
'items': [_ser_event(e) for e in events],
'total': len(events),
})
except Exception as e:
_logger.exception('assignment_events failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/exam/attempt/<int:attempt_id>/security/events',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def attempt_events(self, attempt_id, **kw):
try:
Att = request.env['encoach.student.attempt'].sudo()
attempt = Att.browse(attempt_id)
if not attempt.exists():
return _error_response('Attempt not found', 404)
Ev = request.env['encoach.exam.security.event'].sudo()
events = Ev.search([('attempt_id', '=', attempt.id)], limit=500)
return _json_response({
'attempt_id': attempt.id,
'student_id': attempt.student_id.id if attempt.student_id else None,
'student_name': (attempt.student_id.name
or attempt.student_id.login or ''),
'exam_id': attempt.exam_id.id if attempt.exam_id else None,
'status': attempt.status,
'items': [_ser_event(e) for e in events],
'total': len(events),
})
except Exception as e:
_logger.exception('attempt_events failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/exam/<int:assignment_id>/live',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def live_test_console(self, assignment_id, **kw):
try:
Assign = request.env['encoach.exam.assignment'].sudo()
assign = Assign.browse(assignment_id)
if not assign.exists() or not assign.exam_id:
return _error_response('Assignment not found', 404)
Att = request.env['encoach.student.attempt'].sudo()
attempts = Att.search([
('exam_id', '=', assign.exam_id.id),
('status', '=', 'in_progress'),
])
Ev = request.env['encoach.exam.security.event'].sudo()
items = []
for att in attempts:
last = Ev.search([
('attempt_id', '=', att.id),
], limit=1, order='occurred_at desc')
alerts = Ev.search_count([
('attempt_id', '=', att.id),
('severity', '=', 'alert'),
])
items.append({
'attempt_id': att.id,
'student_id': att.student_id.id if att.student_id else None,
'student_name': (att.student_id.name
or att.student_id.login or ''),
'started_at': (att.started_at.isoformat()
if att.started_at else None),
'alert_count': alerts,
'last_event': _ser_event(last) if last else None,
})
return _json_response({
'assignment_id': assign.id,
'exam_id': assign.exam_id.id,
'in_progress': items,
'total': len(items),
})
except Exception as e:
_logger.exception('live_test_console failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,246 @@
"""Handwritten solution upload + AI grading controller.
* `POST /api/student/handwritten/<material_id>` — upload pages.
* `GET /api/student/handwritten/<material_id>` — fetch my latest.
* `GET /api/teacher/handwritten/<material_id>` — list submissions.
* `POST /api/teacher/handwritten/<submission_id>/review` — teacher overrides AI.
Image uploads use multipart/form-data so we can stream the raw
JPEG/PNG bytes straight into ir.attachment without round-tripping
through base64.
"""
import base64
import json
import logging
from odoo import http, fields as oo_fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _ser_submission(s):
return {
'id': s.id,
'student_id': s.student_id.id if s.student_id else None,
'student_name': s.student_id.name or s.student_id.login or '',
'material_id': s.material_id.id if s.material_id else None,
'plan_id': s.plan_id.id if s.plan_id else None,
'submitted_at': s.submitted_at.isoformat() if s.submitted_at else None,
'status': s.status,
'student_note': s.student_note or '',
'image_count': len(s.image_attachment_ids),
'image_urls': [
f'/web/content/{a.id}?download=true'
for a in s.image_attachment_ids
],
'ai_score': s.ai_score,
'ai_feedback': s.ai_feedback or '',
'ai_extracted_text': s.ai_extracted_text or '',
'teacher_score': s.teacher_score,
'teacher_feedback': s.teacher_feedback or '',
'reviewed_by': s.reviewed_by.name if s.reviewed_by else '',
'reviewed_at': s.reviewed_at.isoformat() if s.reviewed_at else None,
}
def _grade_with_ai(submission):
"""Call OCR + LLM to grade the handwritten pages.
Falls back gracefully when the AI service is unavailable so the
submission still records (status='failed') instead of 500-ing.
"""
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
except ImportError:
submission.write({
'status': 'failed',
'ai_feedback': 'AI grading service not installed.',
})
return
extracted_chunks = []
try:
try:
import pytesseract
from PIL import Image
import io
for att in submission.image_attachment_ids:
if not att.datas:
continue
try:
raw = base64.b64decode(att.datas)
img = Image.open(io.BytesIO(raw))
text = pytesseract.image_to_string(img) or ''
if text.strip():
extracted_chunks.append(text.strip())
except Exception:
_logger.warning('OCR failed for attachment %s', att.id,
exc_info=True)
except ImportError:
extracted_chunks.append(
'[OCR not installed — relying on student note only.]'
)
extracted = '\n\n'.join(extracted_chunks).strip() \
or (submission.student_note or '')
ai = OpenAIService(request.env, language='en')
prompt_user = (
"You are a math/science assignment grader. Grade the "
"following handwritten solution from 0 to 100 and return "
"valid JSON: {\"score\": float, \"feedback\": str}.\n\n"
f"Student note:\n{submission.student_note or '(none)'}\n\n"
f"OCR-extracted handwriting:\n{extracted or '(empty)'}\n"
)
try:
data = ai.chat_json(
system="You are a strict but fair math/science grader.",
user=prompt_user,
action='handwritten.grade',
)
except Exception as e:
submission.write({
'status': 'failed',
'ai_extracted_text': extracted,
'ai_feedback': f'AI grading error: {e}',
})
return
score = float(data.get('score') or 0)
feedback = data.get('feedback') or ''
submission.write({
'status': 'graded',
'ai_score': score,
'ai_feedback': feedback,
'ai_extracted_text': extracted,
})
except Exception as e:
_logger.exception('handwritten grading failed')
submission.write({
'status': 'failed',
'ai_feedback': f'Grading pipeline crashed: {e}',
})
class HandwrittenController(http.Controller):
@http.route('/api/student/handwritten/<int:material_id>',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def upload(self, material_id, **kw):
try:
user = request.env.user
Material = request.env['encoach.course.plan.material'].sudo()
material = Material.browse(material_id)
if not material.exists():
return _error_response('Material not found', 404)
files = request.httprequest.files.getlist('pages') \
or request.httprequest.files.getlist('file') \
or []
if not files:
return _error_response('At least one image required', 400)
student_note = (request.params.get('note') or '').strip()
Att = request.env['ir.attachment'].sudo()
att_ids = []
for f in files[:10]:
raw = f.read()
if not raw:
continue
rec = Att.create({
'name': f.filename or 'handwritten.jpg',
'type': 'binary',
'datas': base64.b64encode(raw),
'mimetype': f.mimetype or 'image/jpeg',
'res_model': 'encoach.handwritten.submission',
'res_id': 0,
})
att_ids.append(rec.id)
Sub = request.env['encoach.handwritten.submission'].sudo()
sub = Sub.create({
'student_id': user.id,
'material_id': material.id,
'student_note': student_note,
'image_attachment_ids': [(6, 0, att_ids)],
'status': 'submitted',
})
for aid in att_ids:
request.env['ir.attachment'].sudo().browse(aid).res_id = sub.id
sub.status = 'grading'
try:
_grade_with_ai(sub)
except Exception:
_logger.exception('async grading inline failed')
return _json_response(_ser_submission(sub), 201)
except Exception as e:
_logger.exception('handwritten upload failed')
return _error_response(str(e), 500)
@http.route('/api/student/handwritten/<int:material_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def my_submission(self, material_id, **kw):
try:
user = request.env.user
Sub = request.env['encoach.handwritten.submission'].sudo()
sub = Sub.search([
('material_id', '=', material_id),
('student_id', '=', user.id),
], limit=1, order='submitted_at desc')
return _json_response({
'submission': _ser_submission(sub) if sub else None,
})
except Exception as e:
_logger.exception('my_handwritten_submission failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/handwritten/<int:material_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_submissions(self, material_id, **kw):
try:
Sub = request.env['encoach.handwritten.submission'].sudo()
subs = Sub.search([('material_id', '=', material_id)])
return _json_response({
'items': [_ser_submission(s) for s in subs],
'total': len(subs),
})
except Exception as e:
_logger.exception('list_handwritten failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/handwritten/<int:submission_id>/review',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def review_submission(self, submission_id, **_kw):
try:
Sub = request.env['encoach.handwritten.submission'].sudo()
sub = Sub.browse(submission_id)
if not sub.exists():
return _error_response('Submission not found', 404)
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
score = body.get('score')
feedback = body.get('feedback') or ''
sub.write({
'teacher_score': float(score) if score is not None else False,
'teacher_feedback': feedback,
'reviewed_by': request.env.user.id,
'reviewed_at': oo_fields.Datetime.now(),
'status': 'reviewed',
})
return _json_response(_ser_submission(sub))
except Exception as e:
_logger.exception('review_handwritten failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,705 @@
"""Live (Jitsi-backed) classroom sessions controller.
Endpoints (Phase 3 — 2026-04-30):
* `GET /api/live-sessions` — list (filters)
* `POST /api/live-sessions` — create
* `GET /api/live-sessions/<id>` — read
* `PATCH /api/live-sessions/<id>` — update
* `DELETE /api/live-sessions/<id>` — cancel
* `POST /api/live-sessions/<id>/notify` — email enrolled students
* `POST /api/live-sessions/<id>/join` — student/teacher posts on entering Jitsi
* `POST /api/live-sessions/<id>/leave` — student/teacher posts on leaving Jitsi
* `POST /api/live-sessions/<id>/end` — host ends session
* `POST /api/live-sessions/<id>/recording` — host stores recording URL
* `POST /api/live-sessions/<id>/remove-participant` — host kicks user (with reason)
* `GET /api/live-sessions/<id>/attendance` — host attendance roll
"""
import base64
import json
import logging
import secrets
from datetime import datetime, timedelta
from odoo import http, fields as oo_fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _parse_body():
try:
body = request.httprequest.get_data(as_text=True)
return json.loads(body) if body else {}
except (TypeError, ValueError):
return {}
def _parse_dt(raw):
"""Coerce an ISO-8601 / Odoo / 'YYYY-MM-DDTHH:MM' datetime string
into the format Odoo persists ('%Y-%m-%d %H:%M:%S')."""
if not raw:
return raw
if isinstance(raw, datetime):
return raw.strftime('%Y-%m-%d %H:%M:%S')
s = str(raw).strip()
# Strip a trailing Z and turn 'T' into ' '.
if s.endswith('Z'):
s = s[:-1]
s = s.replace('T', ' ')
if '.' in s:
s = s.split('.', 1)[0]
if '+' in s[10:]:
s = s.rsplit('+', 1)[0]
if len(s) == 16:
s = s + ':00'
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M'):
try:
return datetime.strptime(s, fmt).strftime('%Y-%m-%d %H:%M:%S')
except ValueError:
continue
return s
def _ser_session(s, env=None):
return {
'id': s.id,
'title': s.title or '',
'description': s.description or '',
'course_id': s.course_id.id if s.course_id else None,
'course_name': s.course_id.name if s.course_id else '',
'batch_id': s.batch_id.id if s.batch_id else None,
'batch_name': s.batch_id.name if s.batch_id else '',
'plan_id': s.plan_id.id if s.plan_id else None,
'plan_name': s.plan_id.name if s.plan_id else '',
'host_id': s.host_id.id if s.host_id else None,
'host_name': s.host_id.name if s.host_id else '',
'scheduled_at': s.scheduled_at.isoformat() if s.scheduled_at else None,
'duration_min': s.duration_min,
'actual_started_at': (s.actual_started_at.isoformat()
if s.actual_started_at else None),
'actual_ended_at': (s.actual_ended_at.isoformat()
if s.actual_ended_at else None),
'status': s.status,
'room_name': s.room_name,
'waiting_room': s.waiting_room,
'recording_enabled': s.recording_enabled,
'recording_url': s.recording_url or '',
'auto_attendance_minutes': s.auto_attendance_minutes,
'late_entry_minutes': s.late_entry_minutes,
'notification_sent': s.notification_sent,
'participant_count': len(s.participant_ids),
}
def _ser_participant(p):
return {
'id': p.id,
'session_id': p.session_id.id,
'user_id': p.user_id.id,
'user_name': p.user_id.name or p.user_id.login or '',
'joined_at': p.joined_at.isoformat() if p.joined_at else None,
'left_at': p.left_at.isoformat() if p.left_at else None,
'duration_seconds': p.duration_seconds,
'attendance_status': p.attendance_status,
'removed_reason': p.removed_reason or '',
'is_host': p.is_host,
}
def _enrolled_user_ids(session):
"""Return res.users IDs that should be notified / can join."""
user_ids = set()
if session.batch_id:
regs = request.env['op.student.course'].sudo().search([
('batch_id', '=', session.batch_id.id),
])
for r in regs:
if r.student_id and r.student_id.user_id:
user_ids.add(r.student_id.user_id.id)
elif session.course_id:
regs = request.env['op.student.course'].sudo().search([
('course_id', '=', session.course_id.id),
])
for r in regs:
if r.student_id and r.student_id.user_id:
user_ids.add(r.student_id.user_id.id)
if session.plan_id:
Assign = request.env['encoach.course.plan.assignment'].sudo() \
if 'encoach.course.plan.assignment' in request.env else None
if Assign is not None:
try:
ass = Assign.search([('plan_id', '=', session.plan_id.id)])
for a in ass:
if a.user_id:
user_ids.add(a.user_id.id)
except Exception:
pass
if session.host_id:
user_ids.discard(session.host_id.id)
return list(user_ids)
def _build_ics(session, recipient_email=''):
"""Return raw iCalendar bytes for the session.
Adding an .ics attachment lets every modern mail client (Gmail,
Outlook, Apple Mail, mobile clients) drop the session straight
into the recipient's calendar with one click — addressing the
"Advance scheduling + calendar sync" requirement without us
having to OAuth into anyone's Google/Apple calendar.
"""
def _fmt(dt):
if not dt:
return ''
if isinstance(dt, str):
try:
dt = datetime.strptime(dt, '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
dt = datetime.fromisoformat(dt)
except ValueError:
return ''
return dt.strftime('%Y%m%dT%H%M%SZ')
def _esc(s):
return (s or '').replace('\\', '\\\\').replace(',', '\\,') \
.replace(';', '\\;').replace('\n', '\\n')
start = session.scheduled_at
if isinstance(start, str):
try:
start = datetime.strptime(start, '%Y-%m-%d %H:%M:%S')
except ValueError:
start = datetime.utcnow()
end = start + timedelta(minutes=session.duration_min or 60)
uid = f'live-{session.id}-{secrets.token_hex(4)}@encoach'
description = (
f"Live session hosted by {session.host_id.name}.\\n"
f"Course: {session.course_id.name or session.plan_id.name or ''}\\n"
f"Open from your dashboard when it starts."
)
base_url = (request.env['ir.config_parameter'].sudo()
.get_param('web.base.url') or '').rstrip('/')
location = f'{base_url}/live/{session.id}' if base_url else f'/live/{session.id}'
lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//EnCoach//Live Sessions//EN',
'CALSCALE:GREGORIAN',
'METHOD:REQUEST',
'BEGIN:VEVENT',
f'UID:{uid}',
f'DTSTAMP:{_fmt(datetime.utcnow())}',
f'DTSTART:{_fmt(start)}',
f'DTEND:{_fmt(end)}',
f'SUMMARY:{_esc(session.title)}',
f'DESCRIPTION:{description}',
f'LOCATION:{_esc(location)}',
f'ORGANIZER;CN={_esc(session.host_id.name)}:mailto:{session.host_id.email or "noreply@encoach.test"}',
]
if recipient_email:
lines.append(
f'ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:'
f'mailto:{recipient_email}'
)
lines.extend([
'STATUS:CONFIRMED',
'BEGIN:VALARM',
'TRIGGER:-PT15M',
'ACTION:DISPLAY',
f'DESCRIPTION:Reminder: {_esc(session.title)}',
'END:VALARM',
'END:VEVENT',
'END:VCALENDAR',
])
return '\r\n'.join(lines).encode('utf-8')
def _send_session_email(session):
"""Send an HTML invite + .ics attachment to every enrolled student."""
user_ids = _enrolled_user_ids(session)
if not user_ids:
return 0
Mail = request.env['mail.mail'].sudo()
Users = request.env['res.users'].sudo()
Att = request.env['ir.attachment'].sudo()
sent = 0
base_url = (request.env['ir.config_parameter'].sudo()
.get_param('web.base.url') or '').rstrip('/')
join_url = f'{base_url}/live/{session.id}' if base_url else f'/live/{session.id}'
for user in Users.browse(user_ids):
if not user.email:
continue
body = (
f"<p>Hi {user.name},</p>"
f"<p>You have a new live session: <b>{session.title}</b>.</p>"
f"<p>When: {session.scheduled_at} ({session.duration_min} min)<br/>"
f"Course: {session.course_id.name or session.plan_id.name or ''}<br/>"
f"Host: {session.host_id.name}</p>"
f'<p><a href="{join_url}" '
f'style="display:inline-block;background:#1f2937;color:#fff;'
f'padding:10px 16px;border-radius:8px;text-decoration:none">'
f'Join from dashboard</a></p>'
f"<p style=\"color:#666;font-size:12px\">"
f"The attached .ics will add this session to your calendar.</p>"
)
try:
ics_bytes = _build_ics(session, recipient_email=user.email)
att = Att.create({
'name': f'session-{session.id}.ics',
'type': 'binary',
'datas': base64.b64encode(ics_bytes),
'mimetype': 'text/calendar; method=REQUEST; charset=utf-8',
'res_model': 'encoach.live.session',
'res_id': session.id,
})
mail = Mail.create({
'subject': f'[EnCoach] {session.title}',
'body_html': body,
'email_to': user.email,
'email_from': (request.env['ir.config_parameter'].sudo()
.get_param('mail.from') or 'noreply@encoach.test'),
'attachment_ids': [(4, att.id)],
})
mail.send()
sent += 1
except Exception:
_logger.warning('failed to send session invite to %s',
user.email, exc_info=True)
return sent
def _user_can_host(user, session):
if not user or not user.id:
return False
if user.id == session.host_id.id:
return True
if user.has_group('base.group_system'):
return True
user_type = getattr(user, 'user_type', '') or ''
return user_type in ('admin', 'corporate', 'mastercorporate', 'developer')
class LiveSessionController(http.Controller):
# ── List + create ──────────────────────────────────────────────
@http.route('/api/live-sessions',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_sessions(self, **kw):
try:
Sess = request.env['encoach.live.session'].sudo()
domain = []
for f in ('course_id', 'batch_id', 'plan_id', 'host_id'):
v = kw.get(f)
if v:
try:
domain.append((f, '=', int(v)))
except (TypeError, ValueError):
pass
status = kw.get('status')
if status:
domain.append(('status', '=', status))
mine = (kw.get('mine') or '').lower() in ('1', 'true', 'yes')
if mine:
user = request.env.user
regs = request.env['op.student.course'].sudo().search([
('student_id.user_id', '=', user.id),
])
course_ids = list({r.course_id.id for r in regs if r.course_id})
batch_ids = list({r.batch_id.id for r in regs if r.batch_id})
or_clauses = []
if course_ids:
or_clauses.append(('course_id', 'in', course_ids))
if batch_ids:
or_clauses.append(('batch_id', 'in', batch_ids))
or_clauses.append(('host_id', '=', user.id))
if len(or_clauses) > 1:
domain.extend(['|'] * (len(or_clauses) - 1))
domain.extend(or_clauses)
sessions = Sess.search(domain, order='scheduled_at desc', limit=200)
return _json_response({
'items': [_ser_session(s) for s in sessions],
'total': len(sessions),
})
except Exception as e:
_logger.exception('list_sessions failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_session(self, **_kw):
try:
body = _parse_body()
vals = {
'title': body.get('title') or 'Live session',
'description': body.get('description') or '',
'scheduled_at': _parse_dt(body.get('scheduled_at')),
'duration_min': int(body.get('duration_min') or 60),
'host_id': request.env.user.id,
'waiting_room': bool(body.get('waiting_room', True)),
'recording_enabled': bool(body.get('recording_enabled', False)),
'auto_attendance_minutes': int(body.get('auto_attendance_minutes') or 10),
'late_entry_minutes': int(body.get('late_entry_minutes') or 15),
}
for f in ('course_id', 'batch_id', 'plan_id', 'entity_id'):
if body.get(f):
try:
vals[f] = int(body[f])
except (TypeError, ValueError):
pass
if not vals.get('scheduled_at'):
return _error_response('scheduled_at required', 400)
sess = request.env['encoach.live.session'].sudo().create(vals)
if body.get('notify', True):
try:
sent = _send_session_email(sess)
sess.notification_sent = bool(sent)
except Exception:
_logger.warning('email batch failed', exc_info=True)
return _json_response(_ser_session(sess), 201)
except Exception as e:
_logger.exception('create_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def read_session(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
payload = _ser_session(sess)
payload['participants'] = [_ser_participant(p)
for p in sess.participant_ids]
payload['enrolled_user_ids'] = _enrolled_user_ids(sess)
return _json_response(payload)
except Exception as e:
_logger.exception('read_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>',
type='http', auth='public', methods=['PATCH'], csrf=False)
@jwt_required
def patch_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
body = _parse_body()
vals = {}
for f in ('title', 'description', 'duration_min',
'waiting_room', 'recording_enabled',
'auto_attendance_minutes', 'late_entry_minutes',
'status', 'recording_url'):
if f in body:
vals[f] = body[f]
if 'scheduled_at' in body:
vals['scheduled_at'] = _parse_dt(body['scheduled_at'])
sess.write(vals)
return _json_response(_ser_session(sess))
except Exception as e:
_logger.exception('patch_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>',
type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def cancel_session(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
sess.status = 'cancelled'
return _json_response({'ok': True})
except Exception as e:
_logger.exception('cancel_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/ics',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def session_ics(self, session_id, **kw):
"""Download a single .ics file the user can drop into their calendar."""
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
email = request.env.user.email or ''
ics = _build_ics(sess, recipient_email=email)
return request.make_response(ics, headers=[
('Content-Type', 'text/calendar; method=REQUEST; charset=utf-8'),
('Content-Disposition',
f'attachment; filename="session-{sess.id}.ics"'),
])
except Exception as e:
_logger.exception('session_ics failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/notify',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def notify_session(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
sent = _send_session_email(sess)
sess.notification_sent = True
return _json_response({'sent': sent})
except Exception as e:
_logger.exception('notify_session failed')
return _error_response(str(e), 500)
# ── Lifecycle: join / leave / end ──────────────────────────────
@http.route('/api/live-sessions/<int:session_id>/join',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def join_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if sess.status == 'cancelled':
return _error_response('Session was cancelled', 400)
user = request.env.user
now = oo_fields.Datetime.now()
is_host = user.id == sess.host_id.id
if not is_host and sess.late_entry_minutes and sess.actual_started_at:
cutoff = sess.actual_started_at + timedelta(
minutes=sess.late_entry_minutes)
if now > cutoff:
return _error_response(
'Late entry refused — the session has been '
'live for too long.', 403,
)
if not sess.actual_started_at:
sess.write({
'actual_started_at': now,
'status': 'live',
})
Part = request.env['encoach.live.session.participant'].sudo()
part = Part.search([
('session_id', '=', sess.id),
('user_id', '=', user.id),
], limit=1)
if part:
part.write({
'joined_at': now,
'attendance_status': 'attending',
})
else:
part = Part.create({
'session_id': sess.id,
'user_id': user.id,
'joined_at': now,
'attendance_status': 'attending',
'is_host': is_host,
})
return _json_response({
'ok': True,
'participant': _ser_participant(part),
'room_name': sess.room_name,
'is_host': is_host,
'jitsi_jwt': '',
})
except Exception as e:
_logger.exception('join_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/leave',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def leave_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
user = request.env.user
now = oo_fields.Datetime.now()
Part = request.env['encoach.live.session.participant'].sudo()
part = Part.search([
('session_id', '=', sess.id),
('user_id', '=', user.id),
], limit=1)
if not part:
return _json_response({'ok': True, 'no_participant': True})
extra = 0
if part.joined_at:
extra = int((now - part.joined_at).total_seconds())
new_total = (part.duration_seconds or 0) + max(0, extra)
new_status = part.attendance_status
if (sess.auto_attendance_minutes
and new_total >= sess.auto_attendance_minutes * 60
and new_status not in ('removed',)):
new_status = 'present'
elif new_status == 'attending':
new_status = 'left_early'
part.write({
'left_at': now,
'duration_seconds': new_total,
'attendance_status': new_status,
})
return _json_response({
'ok': True,
'participant': _ser_participant(part),
})
except Exception as e:
_logger.exception('leave_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/end',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def end_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
now = oo_fields.Datetime.now()
sess.write({
'status': 'ended',
'actual_ended_at': now,
})
for p in sess.participant_ids:
if p.attendance_status == 'attending':
extra = 0
if p.joined_at:
extra = int((now - p.joined_at).total_seconds())
total = (p.duration_seconds or 0) + max(0, extra)
new_status = 'left_early'
if (sess.auto_attendance_minutes
and total >= sess.auto_attendance_minutes * 60):
new_status = 'present'
p.write({
'left_at': now,
'duration_seconds': total,
'attendance_status': new_status,
})
return _json_response({'ok': True})
except Exception as e:
_logger.exception('end_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/recording',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def store_recording(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
body = _parse_body()
url = (body.get('url') or '').strip()
if not url:
return _error_response('url required', 400)
sess.recording_url = url
return _json_response({'ok': True, 'url': url})
except Exception as e:
_logger.exception('store_recording failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/remove-participant',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def remove_participant(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
body = _parse_body()
user_id = body.get('user_id')
reason = (body.get('reason') or '').strip()
if not user_id or not reason:
return _error_response(
'user_id and reason required (audit trail)', 400,
)
Part = request.env['encoach.live.session.participant'].sudo()
part = Part.search([
('session_id', '=', sess.id),
('user_id', '=', int(user_id)),
], limit=1)
if not part:
return _error_response('Participant not found', 404)
part.write({
'attendance_status': 'removed',
'removed_reason': reason,
'left_at': oo_fields.Datetime.now(),
})
return _json_response({'ok': True,
'participant': _ser_participant(part)})
except Exception as e:
_logger.exception('remove_participant failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/attendance',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def attendance_roll(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
roll = []
seen_user_ids = set()
for p in sess.participant_ids:
seen_user_ids.add(p.user_id.id)
roll.append({
'user_id': p.user_id.id,
'user_name': p.user_id.name or p.user_id.login or '',
'attendance_status': p.attendance_status,
'duration_seconds': p.duration_seconds,
'removed_reason': p.removed_reason or '',
})
for uid in _enrolled_user_ids(sess):
if uid in seen_user_ids:
continue
user = request.env['res.users'].sudo().browse(uid)
roll.append({
'user_id': uid,
'user_name': user.name or user.login or '',
'attendance_status': 'not_joined',
'duration_seconds': 0,
'removed_reason': '',
})
return _json_response({
'session_id': sess.id,
'status': sess.status,
'roll': roll,
})
except Exception as e:
_logger.exception('attendance_roll failed')
return _error_response(str(e), 500)

View File

@@ -12,10 +12,21 @@ _logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
def _entity_scope():
"""Return (entity_ids, is_superadmin) for the current user."""
"""Return (entity_ids, is_superadmin) for the current user.
A user is treated as a *platform* superadmin only when they have no
entities assigned (``entity_ids`` is empty) AND either are the bootstrap
Odoo user (uid=1) or carry ``base.group_system`` directly. Any user
linked to one or more entities is always entity-scoped — even Odoo
settings admins — so multi-entity LMS isolation is enforced.
"""
user = request.env.user.sudo()
is_super = bool(user.has_group('base.group_system'))
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
if ids:
return ids, False
is_super = bool(
user.id == 1 or user.has_group('base.group_system')
)
return ids, is_super
@@ -53,6 +64,49 @@ def _scoped_entity_domain(base_domain=None, field='entity_id'):
return domain + [('id', '=', 0)]
return domain + [(field, 'in', ids)]
def _ensure_branch_access(branch_id, *, entity_id=None):
"""Validate branch ownership and user access, return branch id."""
if not branch_id:
raise PermissionError('branch_id is required')
branch = request.env['encoach.lms.branch'].sudo().browse(int(branch_id))
if not branch.exists():
raise ValueError('Branch not found')
_ensure_entity_access(branch.entity_id.id)
if entity_id and branch.entity_id.id != int(entity_id):
raise PermissionError('Branch entity mismatch')
return branch.id
def _assert_record_in_entity(model, rec_id, entity_id, *, label='record'):
"""Raise PermissionError if a record (M2O) doesn't belong to ``entity_id``.
A record without ``entity_id`` is considered legacy/unscoped and is
allowed; this keeps pre-isolation data usable while still blocking
explicit cross-entity references.
"""
if not rec_id or not entity_id:
return int(rec_id) if rec_id else False
rec = request.env[model].sudo().browse(int(rec_id))
if not rec.exists():
raise ValueError(f'{label} {rec_id} not found')
rec_entity = rec.entity_id.id if rec.entity_id else None
if rec_entity and rec_entity != int(entity_id):
raise PermissionError(
f'{label} {rec_id} belongs to a different entity'
)
return rec.id
def _filter_ids_in_entity(model, ids, entity_id, *, label='record'):
"""Same as ``_assert_record_in_entity`` but for a list of ids."""
if not ids:
return []
out = []
for rid in ids:
out.append(_assert_record_in_entity(model, rid, entity_id, label=label))
return out
def _serialize_course(c):
subj = getattr(c, 'encoach_subject_id', False)
tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag'])
@@ -82,11 +136,43 @@ def _serialize_course(c):
'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
'difficulty_level': getattr(c, 'difficulty_level', '') or '',
'cefr_level': getattr(c, 'cefr_level', '') or '',
'is_mandatory': bool(getattr(c, 'is_mandatory', False)),
'chapter_count': getattr(c, 'chapter_count', 0) or 0,
'resource_count': getattr(c, 'resource_count', 0) or 0,
'objective_count': getattr(c, 'objective_count', 0) or 0,
'section_count': getattr(c, 'section_count', 0) or 0,
'sections': [
{
'id': s.id,
'name': s.name or '',
'code': s.code or '',
'sequence': s.sequence or 0,
'active': bool(s.active),
}
for s in (c.section_ids if hasattr(c, 'section_ids') else c.env['encoach.course.section'])
],
'entity_id': c.entity_id.id if hasattr(c, 'entity_id') and c.entity_id else None,
'entity_name': c.entity_id.name if hasattr(c, 'entity_id') and c.entity_id else '',
'branch_id': c.branch_id.id if hasattr(c, 'branch_id') and c.branch_id else None,
'branch_name': c.branch_id.name if hasattr(c, 'branch_id') and c.branch_id else '',
}
def _serialize_course_section(s):
return {
'id': s.id,
'name': s.name or '',
'code': s.code or '',
'sequence': s.sequence or 0,
'active': bool(s.active),
'notes': s.notes or '',
'course_id': s.course_id.id if s.course_id else None,
'course_name': s.course_id.name if s.course_id else '',
'entity_id': s.entity_id.id if s.entity_id else None,
'entity_name': s.entity_id.name if s.entity_id else '',
'branch_id': s.branch_id.id if s.branch_id else None,
'branch_name': s.branch_id.name if s.branch_id else '',
'batch_count': s.batch_count or 0,
}
@@ -121,6 +207,8 @@ def _serialize_student(s):
'user_id': s.user_id.id if s.user_id else None,
'entity_id': s.entity_id.id if hasattr(s, 'entity_id') and s.entity_id else None,
'entity_name': s.entity_id.name if hasattr(s, 'entity_id') and s.entity_id else '',
'branch_id': s.branch_id.id if hasattr(s, 'branch_id') and s.branch_id else None,
'branch_name': s.branch_id.name if hasattr(s, 'branch_id') and s.branch_id else '',
}
@@ -145,6 +233,8 @@ def _serialize_teacher(f):
'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [],
'entity_id': f.entity_id.id if hasattr(f, 'entity_id') and f.entity_id else None,
'entity_name': f.entity_id.name if hasattr(f, 'entity_id') and f.entity_id else '',
'branch_id': f.branch_id.id if hasattr(f, 'branch_id') and f.branch_id else None,
'branch_name': f.branch_id.name if hasattr(f, 'branch_id') and f.branch_id else '',
}
@@ -177,6 +267,28 @@ def _serialize_batch(b):
'students': students,
'entity_id': b.entity_id.id if hasattr(b, 'entity_id') and b.entity_id else None,
'entity_name': b.entity_id.name if hasattr(b, 'entity_id') and b.entity_id else '',
'branch_id': b.branch_id.id if hasattr(b, 'branch_id') and b.branch_id else None,
'branch_name': b.branch_id.name if hasattr(b, 'branch_id') and b.branch_id else '',
'classroom_id': b.classroom_id.id if hasattr(b, 'classroom_id') and b.classroom_id else None,
'classroom_name': b.classroom_id.name if hasattr(b, 'classroom_id') and b.classroom_id else '',
'course_section_id': (
b.course_section_id.id
if hasattr(b, 'course_section_id') and b.course_section_id
else None
),
'course_section_name': (
b.course_section_id.name
if hasattr(b, 'course_section_id') and b.course_section_id
else ''
),
'course_section_code': (
b.course_section_id.code
if hasattr(b, 'course_section_id') and b.course_section_id
else ''
),
'term_key': getattr(b, 'term_key', '') or '',
'teacher_ids': b.teacher_ids.ids if hasattr(b, 'teacher_ids') else [],
'teacher_names': [t.name or '' for t in b.teacher_ids] if hasattr(b, 'teacher_ids') else [],
}
@@ -194,6 +306,8 @@ class LmsCoreController(http.Controller):
pass # op.course has no status field by default
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
if kw.get('branch_id'):
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
offset, limit, page = _paginate(kw)
total = Course.search_count(domain)
records = Course.search(domain, offset=offset, limit=limit, order='id desc')
@@ -239,6 +353,8 @@ class LmsCoreController(http.Controller):
}
if entity_id:
vals['entity_id'] = entity_id
if body.get('branch_id'):
vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id)
if body.get('description'):
vals['description'] = body['description']
if body.get('max_capacity'):
@@ -292,6 +408,12 @@ class LmsCoreController(http.Controller):
vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])]
if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
if 'branch_id' in body:
vals['branch_id'] = (
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
if body['branch_id'] else False
)
if vals:
rec.write(vals)
return _json_response({'data': _serialize_course(rec)})
@@ -327,6 +449,172 @@ class LmsCoreController(http.Controller):
_logger.exception('delete_course failed')
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>/sections', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_course_sections(self, course_id, **kw):
try:
course = request.env['op.course'].sudo().browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
return _error_response('Forbidden', 403)
Section = request.env['encoach.course.section'].sudo()
domain = [('course_id', '=', course_id)]
if kw.get('active') in ('true', 'false', '1', '0'):
domain.append(('active', '=', kw.get('active') in ('true', '1')))
recs = Section.search(domain, order='sequence, id')
items = [_serialize_course_section(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as e:
_logger.exception('list_course_sections failed')
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>/sections', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_course_section(self, course_id, **kw):
try:
course = request.env['op.course'].sudo().browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body()
name = (body.get('name') or '').strip()
code = (body.get('code') or '').strip()
if not name or not code:
return _error_response('name and code are required', 400)
vals = {
'name': name,
'code': code,
'course_id': course_id,
'sequence': int(body.get('sequence') or 10),
'notes': (body.get('notes') or '').strip(),
'active': bool(body.get('active', True)),
}
if body.get('branch_id'):
vals['branch_id'] = _ensure_branch_access(
int(body['branch_id']),
entity_id=course.entity_id.id if course.entity_id else None,
)
rec = request.env['encoach.course.section'].sudo().create(vals)
return _json_response({'data': _serialize_course_section(rec)})
except Exception as e:
_logger.exception('create_course_section failed')
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>/sections/<int:section_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_course_section(self, course_id, section_id, **kw):
try:
section = request.env['encoach.course.section'].sudo().browse(section_id)
if not section.exists() or section.course_id.id != course_id:
return _error_response('Section not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not section.entity_id or section.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = (body.get('name') or '').strip()
if 'code' in body:
vals['code'] = (body.get('code') or '').strip()
if 'sequence' in body:
vals['sequence'] = int(body.get('sequence') or 10)
if 'notes' in body:
vals['notes'] = (body.get('notes') or '').strip()
if 'active' in body:
vals['active'] = bool(body.get('active'))
if 'branch_id' in body:
vals['branch_id'] = (
_ensure_branch_access(
int(body['branch_id']),
entity_id=section.entity_id.id if section.entity_id else None,
)
if body.get('branch_id') else False
)
if vals:
section.write(vals)
return _json_response({'data': _serialize_course_section(section)})
except Exception as e:
_logger.exception('update_course_section failed')
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>/sections/<int:section_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_course_section(self, course_id, section_id, **kw):
try:
section = request.env['encoach.course.section'].sudo().browse(section_id)
if section.exists() and section.course_id.id == course_id:
ids, is_super = _entity_scope()
if not is_super and (not section.entity_id or section.entity_id.id not in ids):
return _error_response('Forbidden', 403)
section.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete_course_section failed')
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>/sections/generate-defaults', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def generate_default_course_sections(self, course_id, **kw):
"""Idempotently create A/B/C section templates for a course.
Body (optional):
- codes: ["A","B","C", ...] (defaults to ["A","B","C"])
- branch_id: int (optional default branch for new sections)
Existing sections (matched by code per course) are kept untouched.
Returns the full ordered list of sections after generation.
"""
try:
course = request.env['op.course'].sudo().browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body() or {}
raw_codes = body.get('codes')
if not isinstance(raw_codes, list) or not raw_codes:
raw_codes = ['A', 'B', 'C']
codes = [str(c).strip().upper() for c in raw_codes if str(c).strip()]
branch_id = body.get('branch_id')
if branch_id:
branch_id = _ensure_branch_access(
int(branch_id),
entity_id=course.entity_id.id if course.entity_id else None,
)
Section = request.env['encoach.course.section'].sudo()
existing = Section.search([('course_id', '=', course_id)])
existing_codes = {s.code for s in existing}
created = []
for idx, code in enumerate(codes, start=1):
if code in existing_codes:
continue
vals = {
'name': f'Section {code}',
'code': code,
'course_id': course_id,
'sequence': 10 * idx,
'active': True,
}
if branch_id:
vals['branch_id'] = branch_id
created.append(Section.create(vals))
recs = Section.search([('course_id', '=', course_id)], order='sequence, id')
return _json_response({
'created_count': len(created),
'created_ids': [r.id for r in created],
'items': [_serialize_course_section(r) for r in recs],
'data': [_serialize_course_section(r) for r in recs],
'total': len(recs),
})
except Exception as e:
_logger.exception('generate_default_course_sections failed')
return _error_response(str(e), 500)
# ── Student: My Courses (enrollment-filtered) ──────────────────
@http.route('/api/student/my-courses', type='http', auth='public', methods=['GET'], csrf=False)
@@ -486,6 +774,8 @@ class LmsCoreController(http.Controller):
domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id'])))
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
if kw.get('branch_id'):
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
offset, limit, page = _paginate(kw)
total = Student.search_count(domain)
records = Student.search(domain, offset=offset, limit=limit, order='id desc')
@@ -540,6 +830,8 @@ class LmsCoreController(http.Controller):
}
if entity_id:
student_vals['entity_id'] = entity_id
if body.get('branch_id'):
student_vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id)
if body.get('birth_date'):
student_vals['birth_date'] = body['birth_date']
student = request.env['op.student'].sudo().create(student_vals)
@@ -594,6 +886,12 @@ class LmsCoreController(http.Controller):
student_vals['gender'] = body['gender']
if 'entity_id' in body:
student_vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
effective_entity = student_vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
if 'branch_id' in body:
student_vals['branch_id'] = (
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
if body['branch_id'] else False
)
if student_vals:
rec.write(student_vals)
return _json_response({'data': _serialize_student(rec)})
@@ -628,6 +926,8 @@ class LmsCoreController(http.Controller):
domain.append(('partner_id.name', 'ilike', kw['search']))
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
if kw.get('branch_id'):
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
offset, limit, page = _paginate(kw)
total = Faculty.search_count(domain)
records = Faculty.search(domain, offset=offset, limit=limit, order='id desc')
@@ -652,9 +952,18 @@ class LmsCoreController(http.Controller):
entity_id = _ensure_entity_access(int(requested_entity))
else:
entity_id = _default_entity_id_from_scope()
first = body.get('first_name', '')
last = body.get('last_name', '')
name = f"{first} {last}".strip()
first = (body.get('first_name') or '').strip()
last = (body.get('last_name') or '').strip()
name = f"{first} {last}".strip() or (body.get('name') or '').strip()
if not name:
return _error_response('first_name/last_name (or name) is required', 400)
if not first and ' ' in name:
first, last = name.split(' ', 1)
elif not first:
first = name
last = name
if not last:
last = first
partner = request.env['res.partner'].sudo().create({
'name': name,
'email': body.get('email', ''),
@@ -662,14 +971,18 @@ class LmsCoreController(http.Controller):
})
fac_vals = {
'partner_id': partner.id,
'gender': body.get('gender', ''),
'first_name': first,
'last_name': last,
'name': name,
'gender': body.get('gender') or 'male',
'birth_date': body.get('birth_date') or '1990-01-01',
}
if entity_id:
fac_vals['entity_id'] = entity_id
if body.get('branch_id'):
fac_vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id)
if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'):
fac_vals['department_id'] = int(body['department_id'])
if body.get('birth_date'):
fac_vals['birth_date'] = body['birth_date']
faculty = request.env['op.faculty'].sudo().create(fac_vals)
return _json_response({'data': _serialize_teacher(faculty)})
except Exception as e:
@@ -716,6 +1029,12 @@ class LmsCoreController(http.Controller):
vals['gender'] = body['gender']
if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
if 'branch_id' in body:
vals['branch_id'] = (
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
if body['branch_id'] else False
)
if vals:
rec.write(vals)
return _json_response({'data': _serialize_teacher(rec)})
@@ -748,6 +1067,18 @@ class LmsCoreController(http.Controller):
domain = _scoped_entity_domain([])
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
if kw.get('branch_id'):
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
if kw.get('classroom_id'):
domain.append(('classroom_id', '=', int(kw['classroom_id'])))
if kw.get('teacher_id'):
domain.append(('teacher_ids', 'in', int(kw['teacher_id'])))
if kw.get('course_id'):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('course_section_id'):
domain.append(('course_section_id', '=', int(kw['course_section_id'])))
if kw.get('term_key'):
domain.append(('term_key', '=', str(kw['term_key'])))
offset, limit, page = _paginate(kw)
total = Batch.search_count(domain)
records = Batch.search(domain, offset=offset, limit=limit, order='id desc')
@@ -791,13 +1122,65 @@ class LmsCoreController(http.Controller):
if body.get('code'):
vals['code'] = body['code']
if body.get('course_id'):
vals['course_id'] = int(body['course_id'])
vals['course_id'] = _assert_record_in_entity(
'op.course', int(body['course_id']), entity_id, label='Course',
)
section_id = body.get('course_section_id')
section = None
if section_id:
section = request.env['encoach.course.section'].sudo().browse(int(section_id))
if not section.exists():
return _error_response('course_section_id not found', 400)
if vals.get('course_id') and section.course_id.id != vals['course_id']:
return _error_response('course_section_id does not belong to course_id', 400)
_assert_record_in_entity(
'encoach.course.section', section.id, entity_id, label='Course section',
)
vals['course_section_id'] = section.id
if entity_id:
vals['entity_id'] = entity_id
if body.get('branch_id'):
vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id)
if body.get('classroom_id'):
vals['classroom_id'] = _assert_record_in_entity(
'op.classroom', int(body['classroom_id']), entity_id, label='Classroom',
)
if body.get('teacher_ids'):
tids = _filter_ids_in_entity(
'op.faculty', [int(x) for x in body['teacher_ids']],
entity_id, label='Teacher',
)
vals['teacher_ids'] = [(6, 0, tids)]
if 'term_key' in body:
vals['term_key'] = (body.get('term_key') or '').strip() or False
if body.get('start_date'):
vals['start_date'] = body['start_date']
if body.get('end_date'):
vals['end_date'] = body['end_date']
# Auto-generate a unique batch code if the client didn't supply one.
# `op.batch.code` is NOT NULL + UNIQUE, and AdminBatches UI doesn't ship a code field.
if not vals.get('code'):
Batch = request.env['op.batch'].sudo()
course = request.env['op.course'].sudo().browse(vals['course_id']) if vals.get('course_id') else None
course_part = ((course.code or course.name or 'C') if course else 'C')
course_part = ''.join(ch for ch in course_part.upper() if ch.isalnum())[:6] or 'C'
section_part = ''
if section:
section_part = '-' + ''.join(ch for ch in (section.code or '').upper() if ch.isalnum())[:4]
term_part = ''
term_key_val = vals.get('term_key')
if term_key_val:
term_part = '-' + ''.join(ch for ch in term_key_val.upper() if ch.isalnum())[:5]
base = f"B{course_part}{section_part}{term_part}"[:14]
candidate = base
idx = 1
while Batch.search_count([('code', '=', candidate)]):
suffix = f"-{idx}"
candidate = (base[: 16 - len(suffix)]) + suffix
idx += 1
if idx > 999:
break
vals['code'] = candidate[:16]
rec = request.env['op.batch'].sudo().create(vals)
return _json_response({'data': _serialize_batch(rec)})
except Exception as e:
@@ -819,10 +1202,46 @@ class LmsCoreController(http.Controller):
for k in ('name', 'code', 'start_date', 'end_date'):
if k in body:
vals[k] = body[k]
if 'course_id' in body:
vals['course_id'] = int(body['course_id'])
if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
if 'course_id' in body:
vals['course_id'] = _assert_record_in_entity(
'op.course', int(body['course_id']), effective_entity, label='Course',
)
if 'course_section_id' in body:
if body['course_section_id']:
section = request.env['encoach.course.section'].sudo().browse(int(body['course_section_id']))
if not section.exists():
return _error_response('course_section_id not found', 400)
target_course = vals.get('course_id') or (rec.course_id.id if rec.course_id else None)
if target_course and section.course_id.id != target_course:
return _error_response('course_section_id does not belong to course_id', 400)
_assert_record_in_entity(
'encoach.course.section', section.id, effective_entity, label='Course section',
)
vals['course_section_id'] = section.id
else:
vals['course_section_id'] = False
if 'branch_id' in body:
vals['branch_id'] = (
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
if body['branch_id'] else False
)
if 'classroom_id' in body:
vals['classroom_id'] = (
_assert_record_in_entity(
'op.classroom', int(body['classroom_id']), effective_entity, label='Classroom',
) if body['classroom_id'] else False
)
if 'teacher_ids' in body:
tids = _filter_ids_in_entity(
'op.faculty', [int(x) for x in (body['teacher_ids'] or [])],
effective_entity, label='Teacher',
)
vals['teacher_ids'] = [(6, 0, tids)]
if 'term_key' in body:
vals['term_key'] = (body.get('term_key') or '').strip() or False
if vals:
rec.write(vals)
return _json_response({'data': _serialize_batch(rec)})
@@ -956,6 +1375,94 @@ class LmsCoreController(http.Controller):
except Exception as e:
return _error_response(str(e), 500)
# ── Per-batch teacher assignment ────────────────────────────────
@http.route('/api/batches/<int:batch_id>/teachers', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_batch_teachers(self, batch_id, **kw):
try:
batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists():
return _error_response('Batch not found', 404)
scope_ids, is_super = _entity_scope()
if not is_super and (not batch.entity_id or batch.entity_id.id not in scope_ids):
return _error_response('Forbidden', 403)
teachers = []
for t in batch.teacher_ids:
p = t.partner_id
teachers.append({
'id': t.id,
'name': p.name if p else '',
'email': (p.email or '') if p else '',
})
return _json_response({
'items': teachers,
'data': teachers,
'total': len(teachers),
})
except Exception as e:
_logger.exception('list_batch_teachers failed')
return _error_response(str(e), 500)
@http.route('/api/batches/<int:batch_id>/teachers', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def set_batch_teachers(self, batch_id, **kw):
"""Assign teachers to a single batch.
Body:
- teacher_ids: int[] required
- mode: 'set' | 'add' default 'set'
Cross-entity teachers are rejected. ``mode='set'`` replaces the whole
list; ``mode='add'`` unions with the existing teachers.
"""
try:
batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists():
return _error_response('Batch not found', 404)
scope_ids, is_super = _entity_scope()
if not is_super and (not batch.entity_id or batch.entity_id.id not in scope_ids):
return _error_response('Forbidden', 403)
body = _get_json_body() or {}
mode = (body.get('mode') or 'set').lower()
raw_ids = [int(x) for x in (body.get('teacher_ids') or [])]
target_entity = batch.entity_id.id if batch.entity_id else None
ids = _filter_ids_in_entity('op.faculty', raw_ids, target_entity, label='Teacher')
if mode == 'set':
batch.write({'teacher_ids': [(6, 0, ids)]})
else:
batch.write({'teacher_ids': [(4, x) for x in ids]})
return _json_response({
'success': True,
'data': _serialize_batch(batch),
'teacher_ids': batch.teacher_ids.ids,
})
except Exception as e:
_logger.exception('set_batch_teachers failed')
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/batches/<int:batch_id>/teachers/remove', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def remove_batch_teachers(self, batch_id, **kw):
try:
batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists():
return _error_response('Batch not found', 404)
scope_ids, is_super = _entity_scope()
if not is_super and (not batch.entity_id or batch.entity_id.id not in scope_ids):
return _error_response('Forbidden', 403)
body = _get_json_body() or {}
ids = [int(x) for x in (body.get('teacher_ids') or [])]
if ids:
batch.write({'teacher_ids': [(3, x) for x in ids]})
return _json_response({
'success': True,
'data': _serialize_batch(batch),
'teacher_ids': batch.teacher_ids.ids,
})
except Exception as e:
_logger.exception('remove_batch_teachers failed')
return _error_response(str(e), 500)
# ── Taxonomy Relationship Endpoints ─────────────────────────────
@http.route('/api/courses/<int:course_id>/taxonomy', type='http', auth='public', methods=['GET'], csrf=False)

View File

@@ -0,0 +1,207 @@
"""Student-facing personalized AI training plan endpoint.
`GET /api/student/personalized-plan` — fetch the latest personalized
plan for the calling student (or null).
`POST /api/student/personalized-plan` — generate a new plan based on
the student's weakness data.
The actual curriculum generation reuses
``encoach_ai_course.services.course_plan_pipeline.CoursePlanPipeline``
so the personalized plan is structurally identical to a teacher-built
one (weeks + materials + exercises), just flagged as personalized
and auto-assigned to the requesting student.
"""
import json
import logging
from collections import defaultdict
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')
SKILL_FIELDS = (
('reading', 'reading_band'),
('listening', 'listening_band'),
('writing', 'writing_band'),
('speaking', 'speaking_band'),
)
def _ser_plan(plan):
if not plan or not plan.exists():
return None
return {
'id': plan.id,
'name': plan.name or '',
'cefr_level': getattr(plan, 'cefr_level', '') or '',
'total_weeks': getattr(plan, 'total_weeks', 0) or 0,
'is_personalized': bool(getattr(plan, 'is_personalized', False)),
'is_mandatory': bool(getattr(plan, 'is_mandatory', False)),
'created_at': plan.create_date.isoformat()
if plan.create_date else None,
}
def _compute_weakness(user):
"""Return per-skill averages + the top weakness label.
Pulls every reportable attempt for ``user`` and averages the bands.
Skills with band < 5.0 (≈ B1) are flagged as a weakness.
"""
Att = request.env['encoach.student.attempt'].sudo()
attempts = Att.search([
('student_id', '=', user.id),
('status', 'in', list(REPORTABLE_STATUSES)),
])
sums = defaultdict(float)
counts = defaultdict(int)
for att in attempts:
for skill, fname in SKILL_FIELDS:
v = getattr(att, fname, None)
if v and v > 0:
sums[skill] += v
counts[skill] += 1
averages = {}
for skill, _f in SKILL_FIELDS:
if counts[skill]:
averages[skill] = round(sums[skill] / counts[skill], 2)
else:
averages[skill] = None
weak = [s for s, v in averages.items() if v is not None and v < 5.0]
weakest = None
weakest_score = 9.0
for s, v in averages.items():
if v is not None and v < weakest_score:
weakest = s
weakest_score = v
return {
'attempts': len(attempts),
'averages': averages,
'weak_skills': weak,
'weakest_skill': weakest,
}
class PersonalizedPlanController(http.Controller):
@http.route('/api/student/personalized-plan',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_plan(self, **kw):
try:
user = request.env.user
Plan = request.env['encoach.course.plan'].sudo()
plan = Plan.search([
('is_personalized', '=', True),
('personalized_for_id', '=', user.id),
], order='create_date desc', limit=1)
return _json_response({
'plan': _ser_plan(plan),
'weakness': _compute_weakness(user),
})
except Exception as e:
_logger.exception('get_personalized_plan failed')
return _error_response(str(e), 500)
@http.route('/api/student/personalized-plan',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def generate_plan(self, **_kw):
try:
body = {}
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
user = request.env.user
weakness = _compute_weakness(user)
weak_skills = weakness['weak_skills'] or [
weakness['weakest_skill']
] if weakness['weakest_skill'] else ['reading']
cefr = (body.get('cefr_level') or 'a2').lower()
total_weeks = int(body.get('total_weeks') or 4)
user_focus = (body.get('focus') or '').strip()
title = (
f"Personalized {total_weeks}-week plan for {user.name or 'me'}"
)
if user_focus:
title = f"Personalized: {user_focus}"
skills_division = (
', '.join(f'{s} 40%' for s in weak_skills[:2])
or 'reading 40%, listening 30%, writing 20%, speaking 10%'
)
notes_parts = [
"AI-generated personalized plan based on the student's "
"weakest skills.",
f"Weakest skill so far: {weakness['weakest_skill'] or 'unknown'}.",
f"Per-skill averages: {weakness['averages']}.",
]
if user_focus:
notes_parts.append(f"Student-requested focus: {user_focus}")
notes = ' '.join(notes_parts)
try:
from odoo.addons.encoach_ai_course.services.course_plan_pipeline \
import CoursePlanPipeline
except ImportError:
return _error_response(
'AI course plan pipeline not installed', 500,
)
pipeline = CoursePlanPipeline(request.env, language='en')
brief = {
'title': title,
'cefr_level': cefr,
'total_weeks': total_weeks,
'contact_hours_per_week': 6,
'skills_division': skills_division,
'grammar_focus': body.get('grammar_focus') or [],
'resources': [],
'learner_profile': (
f"Self-paced learner targeting their weakest "
f"skill ({weakness['weakest_skill'] or 'reading'}). "
f"Recent IELTS averages: "
f"{weakness['averages']}."
),
'notes': notes,
}
plan = pipeline.generate_plan(brief)
try:
plan.write({
'is_personalized': True,
'personalized_for_id': user.id,
'weakness_summary': json.dumps(weakness),
})
except Exception:
_logger.warning('could not flag plan personalized', exc_info=True)
try:
Assign = request.env['encoach.course.plan.assignment'].sudo()
Assign.create({
'plan_id': plan.id,
'mode': 'students',
'student_user_ids': [(6, 0, [user.id])],
'message': 'Your personalized plan is ready.',
})
except Exception:
_logger.warning('could not auto-assign personalized plan',
exc_info=True)
return _json_response({
'plan': _ser_plan(plan),
'weakness': weakness,
}, 201)
except Exception as e:
_logger.exception('generate_personalized_plan failed')
return _error_response(str(e), 500)

View File

@@ -133,6 +133,46 @@ def _build_attempt_domain(kw, reportable=True):
domain.append(('student_id', '=', int(user_id)))
except (TypeError, ValueError):
pass
# Phase 1 (2026-04-29): branch / subject / gender filters drive
# the comparative-analysis pages in the admin Reports section.
# gender + branch live on op.student; we resolve them to a set of
# res.users ids and attach a single ('student_id', 'in', [...])
# clause. subject_id lives on encoach.exam.custom directly.
student_filters = []
branch_id_raw = kw.get('branch_id')
if branch_id_raw:
try:
student_filters.append(('branch_id', '=', int(branch_id_raw)))
except (TypeError, ValueError):
pass
gender_raw = (kw.get('gender') or '').strip().lower()
if gender_raw in ('m', 'male', 'f', 'female', 'o', 'other'):
gender_norm = {
'm': 'm', 'male': 'm',
'f': 'f', 'female': 'f',
'o': 'o', 'other': 'o',
}[gender_raw]
student_filters.append(('gender', '=', gender_norm))
if student_filters:
try:
from odoo.http import request as _req2
students = _req2.env['op.student'].sudo().search(student_filters)
user_ids = [s.user_id.id for s in students if s.user_id]
if user_ids:
domain.append(('student_id', 'in', user_ids))
else:
domain.append(('student_id', '=', -1))
except Exception:
pass
subject_id_raw = kw.get('subject_id')
if subject_id_raw:
try:
domain.append(('exam_id.subject_id', '=', int(subject_id_raw)))
except (TypeError, ValueError):
pass
since = kw.get('since')
if since:
try:
@@ -285,7 +325,10 @@ class ReportsController(http.Controller):
f"user={kw.get('user_id') or kw.get('student_id') or ''};"
f"thr={kw.get('threshold') or ''};"
f"months={kw.get('months') or ''};"
f"period={kw.get('period') or ''}"
f"period={kw.get('period') or ''};"
f"branch={kw.get('branch_id') or ''};"
f"subject={kw.get('subject_id') or ''};"
f"gender={kw.get('gender') or ''}"
)
cached = _cache_get(cache_key)
if cached is not None:
@@ -520,7 +563,13 @@ class ReportsController(http.Controller):
@jwt_required
def filters(self, **kw):
"""Lightweight list of entities + students/users we have attempts for.
Used by the filter dropdowns on all three Reports pages."""
Used by the filter dropdowns on all three Reports pages.
Phase 1 (2026-04-29): also returns the branch and subject lists
plus the canonical gender option set so the admin Reports pages
can hydrate the new comparative-analysis filters in one round
trip.
"""
try:
Ent = request.env['encoach.entity'].sudo()
entities = [{'id': e.id, 'name': e.name or ''}
@@ -533,9 +582,31 @@ class ReportsController(http.Controller):
for u in att_students.sorted('name')
if u and u.id > 0]
Branch = request.env['encoach.lms.branch'].sudo()
branches = [
{'id': b.id, 'name': b.name or '',
'entity_id': b.entity_id.id if b.entity_id else None}
for b in Branch.search([], order='name')
]
Subject = request.env['encoach.subject'].sudo()
subjects = [
{'id': s.id, 'name': s.name or '', 'code': s.code or ''}
for s in Subject.search([], order='name')
]
genders = [
{'value': 'm', 'label': 'Male'},
{'value': 'f', 'label': 'Female'},
{'value': 'o', 'label': 'Other'},
]
return _json_response({
'entities': entities,
'students': students,
'branches': branches,
'subjects': subjects,
'genders': genders,
})
except Exception as e:
_logger.exception('reports filters failed')

View File

@@ -0,0 +1,247 @@
"""CSV / PDF export for the admin Reports section.
Phase 2 (2026-04-30): the admin asked for downloadable copies of the
existing reports. We expose two formats — CSV (always works, no
extra deps) and PDF (uses weasyprint when installed; falls back to
HTML otherwise).
Both endpoints reuse the same domain-builder used by the JSON
endpoints in ``reports.py`` so filters (entity, branch, subject,
gender, level, since/period) behave identically.
"""
import csv
import io
import logging
from collections import defaultdict
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import jwt_required
from odoo.addons.encoach_lms_api.controllers.reports import (
_build_attempt_domain, _attempt_completed_at,
_normalize_cefr, _cefr_for_band,
)
_logger = logging.getLogger(__name__)
def _csv_response(rows, headers, filename):
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(headers)
for r in rows:
writer.writerow(r)
body = buf.getvalue()
resp = request.make_response(
body,
headers=[
('Content-Type', 'text/csv; charset=utf-8'),
('Content-Disposition', f'attachment; filename="{filename}"'),
],
)
return resp
def _pdf_response(html, filename):
"""Return PDF bytes from HTML, falling back to HTML if weasyprint
is not available."""
try:
from weasyprint import HTML
pdf = HTML(string=html).write_pdf()
return request.make_response(
pdf,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition',
f'attachment; filename="{filename}"'),
],
)
except Exception:
return request.make_response(
html,
headers=[
('Content-Type', 'text/html; charset=utf-8'),
('Content-Disposition',
f'attachment; filename="{filename.replace(".pdf", ".html")}"'),
],
)
def _build_student_perf_rows(kw):
Att = request.env['encoach.student.attempt'].sudo()
domain = _build_attempt_domain(kw)
attempts = Att.search(domain, order='started_at desc')
per_student = defaultdict(lambda: {
'attempts': [],
'reading_sum': 0.0, 'reading_n': 0,
'listening_sum': 0.0, 'listening_n': 0,
'writing_sum': 0.0, 'writing_n': 0,
'speaking_sum': 0.0, 'speaking_n': 0,
'overall_sum': 0.0, 'overall_n': 0,
'last_at': None, 'last_cefr': None,
'entity_id': None, 'entity_name': None,
})
def _accum(agg, key, value):
if value is not None and value > 0:
agg[f'{key}_sum'] += float(value)
agg[f'{key}_n'] += 1
for att in attempts:
if not att.student_id:
continue
agg = per_student[att.student_id.id]
agg['attempts'].append(att.id)
_accum(agg, 'reading', att.reading_band)
_accum(agg, 'listening', att.listening_band)
_accum(agg, 'writing', att.writing_band)
_accum(agg, 'speaking', att.speaking_band)
_accum(agg, 'overall', att.overall_band)
ct = _attempt_completed_at(att) or att.started_at
if ct and (agg['last_at'] is None or ct > agg['last_at']):
agg['last_at'] = ct
agg['last_cefr'] = _normalize_cefr(att.cefr_level) \
or _cefr_for_band(att.overall_band)
if att.entity_id and not agg['entity_id']:
agg['entity_id'] = att.entity_id.id
agg['entity_name'] = att.entity_id.name
rows = []
for student_id, agg in per_student.items():
user = request.env['res.users'].sudo().browse(student_id)
if not user.exists():
continue
name = user.name or user.login or f'User #{student_id}'
def _avg(key):
n = agg[f'{key}_n']
if not n:
return None
return round(agg[f'{key}_sum'] / n, 2)
overall = _avg('overall')
if overall is None:
skill_avgs = [v for v in (
_avg('reading'), _avg('listening'),
_avg('writing'), _avg('speaking'),
) if v is not None]
overall = round(sum(skill_avgs) / len(skill_avgs), 2) \
if skill_avgs else None
level = agg['last_cefr'] or _cefr_for_band(overall)
rows.append([
student_id,
name,
user.login or '',
agg['entity_name'] or '',
_avg('reading') or '',
_avg('listening') or '',
_avg('writing') or '',
_avg('speaking') or '',
overall or '',
level or '',
len(agg['attempts']),
agg['last_at'].strftime('%Y-%m-%d') if agg['last_at'] else '',
])
rows.sort(key=lambda r: (-(r[8] or 0), r[1].lower() if r[1] else ''))
return rows
STUDENT_PERF_HEADERS = [
'Student ID', 'Name', 'Login', 'Entity',
'Reading', 'Listening', 'Writing', 'Speaking',
'Overall', 'CEFR', 'Attempts', 'Last attempt',
]
class ReportsExportController(http.Controller):
@http.route('/api/reports/student-performance/export',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def export_student_perf(self, **kw):
fmt = (kw.get('format') or 'csv').lower()
try:
rows = _build_student_perf_rows(kw)
except Exception as e:
_logger.exception('export_student_perf failed')
return request.make_json_response({'error': str(e)}, status=500)
if fmt == 'pdf':
html = _student_perf_html(rows)
return _pdf_response(html, 'student-performance.pdf')
return _csv_response(rows, STUDENT_PERF_HEADERS,
'student-performance.csv')
@http.route('/api/reports/record/export',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def export_record(self, **kw):
fmt = (kw.get('format') or 'csv').lower()
try:
Att = request.env['encoach.student.attempt'].sudo()
domain = _build_attempt_domain(kw, reportable=False)
if kw.get('status'):
domain.append(('status', '=', kw['status']))
attempts = Att.search(domain, order='started_at desc', limit=10000)
rows = []
for att in attempts:
exam = att.exam_id
exam_title = ''
if exam:
exam_title = (getattr(exam, 'title', None)
or getattr(exam, 'display_name', '')
or '')
rows.append([
att.id,
att.student_id.name if att.student_id else '',
att.student_id.login if att.student_id else '',
exam_title,
att.entity_id.name if att.entity_id else '',
att.started_at.strftime('%Y-%m-%d %H:%M')
if att.started_at else '',
(att.completed_at.strftime('%Y-%m-%d %H:%M')
if att.completed_at else ''),
att.status or '',
att.overall_band or '',
_normalize_cefr(att.cefr_level)
or _cefr_for_band(att.overall_band) or '',
])
except Exception as e:
_logger.exception('export_record failed')
return request.make_json_response({'error': str(e)}, status=500)
headers = [
'Attempt ID', 'Student', 'Login', 'Exam', 'Entity',
'Started at', 'Completed at', 'Status', 'Overall', 'CEFR',
]
if fmt == 'pdf':
return _pdf_response(_table_html(headers, rows, 'Attempt record'),
'attempt-record.pdf')
return _csv_response(rows, headers, 'attempt-record.csv')
def _student_perf_html(rows):
return _table_html(STUDENT_PERF_HEADERS, rows, 'Student performance')
def _table_html(headers, rows, title):
th = ''.join(f'<th>{h}</th>' for h in headers)
body = ''
for r in rows:
cells = ''.join(f'<td>{c}</td>' for c in r)
body += f'<tr>{cells}</tr>'
return (
'<!doctype html><html><head><meta charset="utf-8">'
'<style>'
'body{font-family:-apple-system,sans-serif;padding:24px;color:#1a1a1a}'
'h1{margin:0 0 16px 0;font-size:20px}'
'table{border-collapse:collapse;width:100%;font-size:11px}'
'th,td{border:1px solid #e5e5e5;padding:6px 8px;text-align:left}'
'th{background:#f7f7f5;font-weight:600}'
'tr:nth-child(even) td{background:#fafaf8}'
'</style></head><body>'
f'<h1>{title}</h1>'
f'<table><thead><tr>{th}</tr></thead><tbody>{body}</tbody></table>'
'</body></html>'
)

View File

@@ -0,0 +1,219 @@
"""Teacher-only "Class Insights" endpoint.
Phase 1 (2026-04-29): teachers asked for an at-a-glance view of each
course they own — how many students, what the attendance rate looks
like, how much of the assigned work has been submitted. We aggregate
existing models without introducing any new schema:
* ``op.student.course`` — enrolment list
* ``op.attendance.line`` — present/absent/excused/late counters
* ``encoach.exam.assignment`` — assigned exams
* ``encoach.student.attempt`` — attempts against those exams
* ``encoach.course.chapter`` — chapter / material progress totals
"""
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _user_can_see_course(user, course):
"""Return True if the JWT user is admin/corporate OR teaches the course.
Admin / corporate / master-corporate / developer users always pass —
they manage the platform across all courses. Teacher users pass when
at least one batch on the course lists their ``op.faculty`` row.
"""
if not course or not course.exists():
return False
if user.has_group('base.group_system'):
return True
user_type = getattr(user, 'user_type', '') or ''
if user_type in (
'admin', 'corporate', 'mastercorporate', 'developer',
):
return True
Faculty = request.env['op.faculty'].sudo()
faculty = Faculty.search([('user_id', '=', user.id)], limit=1)
if not faculty:
return False
Batch = request.env['op.batch'].sudo()
teaches_via_batch = Batch.search_count([
('course_id', '=', course.id),
('teacher_ids', 'in', faculty.id),
])
return bool(teaches_via_batch)
class TeacherInsightsController(http.Controller):
@http.route('/api/teacher/courses/<int:course_id>/insights',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def course_insights(self, course_id, **kw):
"""Return enrollment + attendance + submission rollups for a course.
Response shape::
{
"course_id": 12,
"course_name": "GE1 — General English",
"students": {
"total": 24,
"by_batch": [{ "batch_id": 7, "batch_name": "B-A",
"count": 8 }, ...]
},
"attendance": {
"lines_total": 192,
"present": 165, "absent": 18, "excused": 6, "late": 3,
"rate_present": 86.0, # %
"rate_absent": 9.4
},
"submissions": {
"exams_assigned": 5,
"expected_attempts": 120, # students * assigned
"completed_attempts": 78,
"rate_completed": 65.0
},
"materials": {
"chapters": 8,
"completed_progress_rows": 45,
"rate_chapters": 23.4
}
}
"""
try:
user = request.env.user
course = request.env['op.course'].sudo().browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
if not _user_can_see_course(user, course):
return _error_response('Forbidden', 403)
SC = request.env['op.student.course'].sudo()
regs = SC.search([('course_id', '=', course.id)])
student_ids = list({r.student_id.id for r in regs if r.student_id})
student_count = len(student_ids)
# encoach.student.attempt.student_id points at res.users, while
# op.student.course.student_id points at op.student. Resolve the
# res.users equivalent so we can match attempts to enrolments.
user_ids = []
if student_ids:
Student = request.env['op.student'].sudo()
user_ids = [
s.user_id.id for s in Student.browse(student_ids)
if s.user_id
]
by_batch_map = {}
for r in regs:
if not r.batch_id:
continue
bucket = by_batch_map.setdefault(r.batch_id.id, {
'batch_id': r.batch_id.id,
'batch_name': r.batch_id.name or '',
'count': 0,
})
bucket['count'] += 1
by_batch = sorted(by_batch_map.values(), key=lambda b: b['batch_name'])
Line = request.env['op.attendance.line'].sudo()
lines = Line.search([('course_id', '=', course.id)])
present = sum(1 for l in lines if l.present)
absent = sum(1 for l in lines if l.absent)
excused = sum(1 for l in lines if l.excused)
late = sum(1 for l in lines if l.late)
n_lines = len(lines)
attendance = {
'lines_total': n_lines,
'present': present,
'absent': absent,
'excused': excused,
'late': late,
'rate_present': round(present / n_lines * 100, 1) if n_lines else 0.0,
'rate_absent': round(absent / n_lines * 100, 1) if n_lines else 0.0,
'rate_late': round(late / n_lines * 100, 1) if n_lines else 0.0,
}
# encoach.exam.assignment links to an exam via exam_id and to
# students either directly (student_id) or by batch (batch_id).
# Course → exam happens through the batch's course_id, since
# neither the assignment nor the exam itself carries a
# direct course_id column.
Batch = request.env['op.batch'].sudo()
course_batch_ids = Batch.search([
('course_id', '=', course.id),
]).ids
ExamAssign = request.env['encoach.exam.assignment'].sudo()
assignments = ExamAssign.search([
('batch_id', 'in', course_batch_ids),
]) if course_batch_ids else ExamAssign.browse([])
customs = assignments.mapped('exam_id')
Att = request.env['encoach.student.attempt'].sudo()
scoring_attempts = Att.search([
('exam_id', 'in', customs.ids),
('student_id', 'in', user_ids),
]) if (customs and user_ids) else Att.browse([])
completed = scoring_attempts.filtered(
lambda a: a.status in ('completed', 'scoring', 'scored', 'released'),
)
# A student may legitimately have multiple attempts at the same
# exam (retakes). For the "submission progress" rollup we only
# care about whether each (student, exam) pair was at least
# submitted once, otherwise the percentage can exceed 100% and
# become meaningless.
completed_pairs = {
(a.student_id.id, a.exam_id.id) for a in completed
if a.student_id and a.exam_id
}
expected = len(user_ids) * len(customs)
submissions = {
'exams_assigned': len(assignments),
'expected_attempts': expected,
'completed_attempts': len(completed_pairs),
'rate_completed': (
round(min(len(completed_pairs) / expected * 100, 100.0), 1)
if expected else 0.0
),
}
Chapter = request.env['encoach.course.chapter'].sudo()
chapters = Chapter.search([('course_id', '=', course.id)])
n_chapters = len(chapters)
ChapterProg = request.env['encoach.chapter.progress'].sudo()
prog_rows = ChapterProg.search([
('chapter_id', 'in', chapters.ids),
]) if chapters else ChapterProg.browse([])
done_prog = prog_rows.filtered(lambda p: p.status == 'completed')
expected_prog = student_count * n_chapters
materials = {
'chapters': n_chapters,
'students_started': len({p.student_id.id for p in prog_rows
if p.student_id}),
'completed_progress_rows': len(done_prog),
'rate_chapters': (
round(len(done_prog) / expected_prog * 100, 1)
if expected_prog else 0.0
),
}
return _json_response({
'course_id': course.id,
'course_name': course.name or '',
'students': {
'total': student_count,
'by_batch': by_batch,
},
'attendance': attendance,
'submissions': submissions,
'materials': materials,
})
except Exception as exc:
_logger.exception('teacher.course_insights failed')
return _error_response(str(exc), 500)

View File

@@ -0,0 +1,188 @@
"""Turnitin (per-institution) integration controller.
Phase 4 (2026-04-30): the API key is stored on `encoach.entity`. We
expose:
* `GET /api/turnitin/settings/<entity_id>` — read the current config.
* `PATCH /api/turnitin/settings/<entity_id>` — admin updates key.
* `POST /api/turnitin/test/<entity_id>` — ping Turnitin to verify.
* `POST /api/turnitin/submit` — submit text for an
originality report (attached to a handwritten submission, an exam
attempt, or a generic blob).
The submit endpoint is intentionally minimal — Turnitin's full API
requires a signed-URL upload + polling flow which can't be reasonably
inlined here. We send the text payload + return a stub job_id that
the client polls. When the institution actually has a real key the
backend swap-in is a matter of replacing the body of `_submit_to_turnitin`.
"""
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _ser_settings(entity, include_secret=False):
return {
'entity_id': entity.id,
'entity_name': entity.name or '',
'enabled': bool(entity.turnitin_enabled),
'api_url': entity.turnitin_api_url or '',
'account_id': entity.turnitin_account_id or '',
'has_api_key': bool(entity.turnitin_api_key),
'api_key': (entity.turnitin_api_key or '') if include_secret else None,
}
def _is_admin(user):
if user.has_group('base.group_system'):
return True
return getattr(user, 'user_type', '') in (
'admin', 'corporate', 'mastercorporate', 'developer',
)
def _submit_to_turnitin(entity, text, title='submission'):
"""Submit `text` to Turnitin and return (originality_score, job_id).
Stub: when no real key is configured we return a synthetic
originality score so the UI can render. When a real key IS set we
attempt a real call and surface any error.
"""
if not entity.turnitin_enabled or not entity.turnitin_api_key:
return None, 'no-config'
try:
import requests
url = (entity.turnitin_api_url or 'https://api.turnitin.com').rstrip('/')
url = f'{url}/v1/submissions'
headers = {
'Authorization': f'Bearer {entity.turnitin_api_key}',
'Content-Type': 'application/json',
}
if entity.turnitin_account_id:
headers['X-Turnitin-Account-Id'] = entity.turnitin_account_id
resp = requests.post(url, headers=headers, json={
'title': title,
'content': text,
}, timeout=15)
if resp.status_code in (200, 201, 202):
data = resp.json() if resp.text else {}
return data.get('originality_score'), data.get('id', 'pending')
return None, f'http-{resp.status_code}'
except Exception as e:
_logger.warning('Turnitin submission failed: %s', e)
return None, f'error:{e}'
class TurnitinController(http.Controller):
@http.route('/api/turnitin/settings/<int:entity_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_settings(self, entity_id, **kw):
try:
user = request.env.user
if not _is_admin(user):
return _error_response('Forbidden', 403)
entity = request.env['encoach.entity'].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
return _json_response(_ser_settings(entity, include_secret=False))
except Exception as e:
_logger.exception('turnitin.get_settings failed')
return _error_response(str(e), 500)
@http.route('/api/turnitin/settings/<int:entity_id>',
type='http', auth='public', methods=['PATCH'], csrf=False)
@jwt_required
def patch_settings(self, entity_id, **_kw):
try:
user = request.env.user
if not _is_admin(user):
return _error_response('Forbidden', 403)
entity = request.env['encoach.entity'].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
vals = {}
for f in ('turnitin_enabled', 'turnitin_api_key',
'turnitin_api_url', 'turnitin_account_id'):
short = f.replace('turnitin_', '')
if short in body:
vals[f] = body[short]
elif f in body:
vals[f] = body[f]
entity.write(vals)
return _json_response(_ser_settings(entity))
except Exception as e:
_logger.exception('turnitin.patch_settings failed')
return _error_response(str(e), 500)
@http.route('/api/turnitin/test/<int:entity_id>',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def test_settings(self, entity_id, **_kw):
try:
user = request.env.user
if not _is_admin(user):
return _error_response('Forbidden', 403)
entity = request.env['encoach.entity'].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
score, job = _submit_to_turnitin(
entity, 'Hello world.', title='Connectivity test')
return _json_response({
'ok': job not in ('no-config',) and not str(job).startswith('error'),
'job_id': job,
'originality_score': score,
})
except Exception as e:
_logger.exception('turnitin.test failed')
return _error_response(str(e), 500)
@http.route('/api/turnitin/submit',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def submit(self, **_kw):
try:
user = request.env.user
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
text = (body.get('text') or '').strip()
title = (body.get('title') or 'Submission').strip()
if not text:
return _error_response('text required', 400)
entity_id = body.get('entity_id')
entity = None
if entity_id:
entity = request.env['encoach.entity'].sudo().browse(int(entity_id))
if not entity or not entity.exists():
if user.entity_ids:
entity = user.entity_ids[0]
if not entity or not entity.exists():
return _error_response('No entity bound to user', 400)
score, job = _submit_to_turnitin(entity, text, title=title)
return _json_response({
'job_id': job,
'entity_id': entity.id,
'originality_score': score,
}, 201)
except Exception as e:
_logger.exception('turnitin.submit failed')
return _error_response(str(e), 500)

View File

@@ -6,7 +6,14 @@ from . import courseware
from . import notification
from . import faq
from . import course_ext
from . import classroom_ext
from . import asset
from . import ticket
from . import training
from . import paymob_order
from . import branch
from . import exam_security
from . import live_session
from . import handwritten_submission
from . import personalized_plan
from . import entity_turnitin

View File

@@ -0,0 +1,49 @@
from odoo import api, fields, models
class EncoachLmsBranch(models.Model):
_name = "encoach.lms.branch"
_description = "EnCoach LMS Branch"
_order = "name asc, id asc"
name = fields.Char(required=True)
code = fields.Char(index=True)
active = fields.Boolean(default=True)
entity_id = fields.Many2one(
"encoach.entity",
required=True,
ondelete="cascade",
index=True,
string="Entity",
)
notes = fields.Text()
course_ids = fields.One2many("op.course", "branch_id")
batch_ids = fields.One2many("op.batch", "branch_id")
student_ids = fields.One2many("op.student", "branch_id")
teacher_ids = fields.One2many("op.faculty", "branch_id")
course_count = fields.Integer(compute="_compute_counts")
batch_count = fields.Integer(compute="_compute_counts")
student_count = fields.Integer(compute="_compute_counts")
teacher_count = fields.Integer(compute="_compute_counts")
_sql_constraints = [
("branch_entity_code_uniq", "unique(entity_id, code)", "Branch code must be unique per entity."),
]
@api.depends("course_ids", "batch_ids", "student_ids", "teacher_ids")
def _compute_counts(self):
for rec in self:
rec.course_count = len(rec.course_ids)
rec.batch_count = len(rec.batch_ids)
rec.student_count = len(rec.student_ids)
rec.teacher_count = len(rec.teacher_ids)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get("code") and vals.get("name"):
vals["code"] = vals["name"].upper().replace(" ", "_")[:24]
return super().create(vals_list)

View File

@@ -0,0 +1,270 @@
from odoo import api, fields, models
from odoo.exceptions import UserError, ValidationError
class OpClassroomExt(models.Model):
"""Extend the OpenEduCat classroom into a homeroom (class group).
Original ``op.classroom`` represents a physical room (with capacity and
facilities). We keep that semantic and add organisational fields so the
same record also acts as the home of a group of students:
* ``student_ids`` — homeroom roster (M2M).
* ``teacher_ids`` — homeroom teachers (M2M).
* ``course_ids`` — courses taught to this classroom (M2M).
* ``batch_ids`` — auto-created batches (one per classroom × course).
``assign_course`` is idempotent: re-assigning the same course reuses the
existing batch and only enrolls students that aren't already enrolled.
"""
_inherit = "op.classroom"
entity_id = fields.Many2one(
"encoach.entity",
string="Entity",
ondelete="set null",
index=True,
help="Owning entity/organization for LMS isolation.",
)
branch_id = fields.Many2one(
"encoach.lms.branch",
string="Branch",
ondelete="set null",
index=True,
help="Optional branch/campus inside the owning entity.",
)
student_ids = fields.Many2many(
"op.student",
"op_classroom_student_rel",
"classroom_id",
"student_id",
string="Students",
)
teacher_ids = fields.Many2many(
"op.faculty",
"op_classroom_faculty_rel",
"classroom_id",
"faculty_id",
string="Homeroom Teachers",
)
course_ids = fields.Many2many(
"op.course",
"op_classroom_course_rel",
"classroom_id",
"course_id",
string="Courses",
)
batch_ids = fields.One2many(
"op.batch",
"classroom_id",
string="Batches",
)
student_count = fields.Integer(compute="_compute_counts")
teacher_count = fields.Integer(compute="_compute_counts")
course_count = fields.Integer(compute="_compute_counts")
batch_count = fields.Integer(compute="_compute_counts")
notes = fields.Text(string="Notes")
@api.depends("student_ids", "teacher_ids", "course_ids", "batch_ids")
def _compute_counts(self):
for rec in self:
rec.student_count = len(rec.student_ids)
rec.teacher_count = len(rec.teacher_ids)
rec.course_count = len(rec.course_ids)
rec.batch_count = len(rec.batch_ids)
@api.constrains("branch_id", "entity_id")
def _check_branch_entity(self):
for rec in self:
if rec.branch_id and rec.entity_id and rec.branch_id.entity_id != rec.entity_id:
raise ValidationError(
"Classroom branch must belong to the same entity as the classroom."
)
# ------------------------------------------------------------------
# Cascade: assign a course to this classroom
# ------------------------------------------------------------------
def _ensure_unique_batch_code(self, base_code):
"""Return a unique batch code derived from ``base_code``.
``op.batch`` has a global ``unique(code)`` constraint, so we suffix
with a counter when needed.
"""
Batch = self.env["op.batch"].sudo()
candidate = base_code
idx = 1
while Batch.search_count([("code", "=", candidate)]):
idx += 1
candidate = f"{base_code}-{idx}"
return candidate
def assign_course(
self,
course,
term_name=None,
start_date=None,
end_date=None,
teacher_ids=None,
section_id=None,
term_key=None,
):
"""Assign ``course`` to this classroom and cascade to a batch.
- Creates (or reuses) the canonical batch for
``(classroom, course, section, term_key)``.
- Enrolls every classroom student into the batch via ``op.student.course``.
- Optionally sets ``teacher_ids`` (M2M) on the batch; if none is
provided we default to the classroom's homeroom teachers.
Returns the ``op.batch`` record.
"""
self.ensure_one()
Course = self.env["op.course"].sudo()
Section = self.env["encoach.course.section"].sudo()
Batch = self.env["op.batch"].sudo()
Enroll = self.env["op.student.course"].sudo()
if isinstance(course, int):
course = Course.browse(course)
if not course or not course.exists():
raise UserError("Course not found.")
if (
self.entity_id
and course.entity_id
and self.entity_id.id != course.entity_id.id
):
raise ValidationError(
"Course belongs to a different entity than the classroom."
)
section = None
if section_id:
section = Section.browse(int(section_id))
if not section.exists():
raise UserError("Course section not found.")
if section.course_id.id != course.id:
raise ValidationError("Selected section does not belong to the selected course.")
if (
self.entity_id
and section.entity_id
and self.entity_id.id != section.entity_id.id
):
raise ValidationError(
"Course section belongs to a different entity than the classroom."
)
if teacher_ids is not None and self.entity_id:
teachers = self.env["op.faculty"].sudo().browse(list(teacher_ids))
for t in teachers:
if (
t.exists()
and t.entity_id
and t.entity_id.id != self.entity_id.id
):
raise ValidationError(
f"Teacher {t.partner_id.name or t.id} belongs to a "
"different entity than the classroom."
)
# Add to classroom course list (idempotent)
if course.id not in self.course_ids.ids:
self.write({"course_ids": [(4, course.id)]})
# Find or create the canonical batch (classroom × course × section × term)
domain = [
("classroom_id", "=", self.id),
("course_id", "=", course.id),
("course_section_id", "=", section.id if section else False),
("term_key", "=", term_key or False),
]
batch = Batch.search(domain, limit=1)
if not batch:
section_label = f" — Section {section.code}" if section else ""
term_label = f" ({term_name})" if term_name else ""
base_name = f"{self.name}{course.name or course.code or 'Course'}{section_label}{term_label}"
base_code = (self.code or self.name or "BATCH").upper().replace(" ", "_")[:12]
section_code = (section.code if section else "").upper().replace(" ", "")
term_code = (term_key or "").upper().replace(" ", "")
base_code = f"{base_code}-{(course.code or '').upper()[:8] or course.id}"
if section_code:
base_code = f"{base_code}-{section_code[:5]}"
if term_code:
base_code = f"{base_code}-{term_code[:4]}"
today = fields.Date.context_today(self)
vals = {
"name": base_name,
"code": self._ensure_unique_batch_code(base_code[:16]),
"course_id": course.id,
"classroom_id": self.id,
"course_section_id": section.id if section else False,
"term_key": term_key or False,
"start_date": start_date or today,
"end_date": end_date or today.replace(month=12, day=31),
"entity_id": self.entity_id.id if self.entity_id else False,
"branch_id": self.branch_id.id if self.branch_id else False,
}
batch = Batch.create(vals)
# Teacher assignment (M2M) — explicit overrides homeroom defaults
target_teachers = teacher_ids if teacher_ids is not None else self.teacher_ids.ids
if target_teachers:
batch.write({"teacher_ids": [(6, 0, list(target_teachers))]})
# Enroll classroom roster into the batch (idempotent)
for student in self.student_ids:
existing = Enroll.search([
("student_id", "=", student.id),
("course_id", "=", course.id),
("batch_id", "=", batch.id),
], limit=1)
if not existing:
Enroll.create({
"student_id": student.id,
"course_id": course.id,
"batch_id": batch.id,
"state": "running",
})
return batch
@api.model
def _add_students_to_open_batches(self, classroom_id, student_ids):
"""When new students join the classroom, enroll them in every existing
course batch under that classroom.
"""
Batch = self.env["op.batch"].sudo()
Enroll = self.env["op.student.course"].sudo()
classroom = self.browse(classroom_id)
if not classroom.exists():
return
batches = Batch.search([("classroom_id", "=", classroom.id)])
for batch in batches:
for sid in student_ids:
exists = Enroll.search([
("student_id", "=", sid),
("course_id", "=", batch.course_id.id),
("batch_id", "=", batch.id),
], limit=1)
if not exists:
Enroll.create({
"student_id": sid,
"course_id": batch.course_id.id,
"batch_id": batch.id,
"state": "running",
})
def write(self, vals):
"""Cascade roster changes into existing classroom batches."""
old_students = {rec.id: set(rec.student_ids.ids) for rec in self}
res = super().write(vals)
if "student_ids" in vals:
for rec in self:
added = set(rec.student_ids.ids) - old_students.get(rec.id, set())
if added:
self._add_students_to_open_batches(rec.id, list(added))
return res

View File

@@ -1,4 +1,4 @@
from odoo import models, fields
from odoo import api, models, fields
class EncoachDiscussionBoard(models.Model):
@@ -9,11 +9,18 @@ class EncoachDiscussionBoard(models.Model):
course_id = fields.Many2one('op.course', ondelete='cascade')
batch_id = fields.Many2one('op.batch', ondelete='set null')
chapter_id = fields.Many2one('encoach.course.chapter', ondelete='set null')
# Phase 1 (2026-04-29): also embed discussion inside AI course
# plans, not just classic op.course records. A board can be scoped
# to either, both, or neither (a global "course-less" board).
plan_id = fields.Many2one(
'encoach.course.plan', ondelete='cascade', string='Course plan',
)
is_enabled = fields.Boolean(default=True)
allow_student_posts = fields.Boolean(default=True)
post_ids = fields.One2many('encoach.discussion.post', 'board_id')
post_count = fields.Integer(compute='_compute_post_count', store=True)
@api.depends('post_ids')
def _compute_post_count(self):
for rec in self:
rec.post_count = len(rec.post_ids)

View File

@@ -1,4 +1,61 @@
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class EncoachCourseSection(models.Model):
_name = "encoach.course.section"
_description = "Course Section Template"
_order = "course_id, sequence, id"
name = fields.Char(required=True)
code = fields.Char(required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
notes = fields.Text()
course_id = fields.Many2one("op.course", required=True, ondelete="cascade", index=True)
entity_id = fields.Many2one(
"encoach.entity",
string="Entity",
related="course_id.entity_id",
store=True,
index=True,
)
branch_id = fields.Many2one(
"encoach.lms.branch",
string="Branch",
ondelete="set null",
index=True,
help="Optional default branch for this section template.",
)
batch_ids = fields.One2many("op.batch", "course_section_id", string="Batches")
batch_count = fields.Integer(compute="_compute_batch_count")
_sql_constraints = [
(
"uniq_course_section_code",
"unique(course_id, code)",
"Section code must be unique per course.",
),
]
def _compute_batch_count(self):
for rec in self:
rec.batch_count = len(rec.batch_ids)
@api.constrains("branch_id", "course_id")
def _check_section_branch_entity(self):
for rec in self:
if (
rec.branch_id
and rec.course_id
and rec.course_id.entity_id
and rec.branch_id.entity_id != rec.course_id.entity_id
):
raise ValidationError(
"Section branch must belong to the same entity as the course."
)
class OpCourseExt(models.Model):
@@ -13,6 +70,13 @@ class OpCourseExt(models.Model):
index=True,
help='Owning entity/organization for LMS isolation.',
)
branch_id = fields.Many2one(
'encoach.lms.branch',
string='Branch',
ondelete='set null',
index=True,
help='Optional branch/campus inside the owning entity.',
)
encoach_subject_id = fields.Many2one(
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
@@ -40,7 +104,24 @@ class OpCourseExt(models.Model):
])
chapter_ids = fields.One2many('encoach.course.chapter', 'course_id', string='Chapters')
section_ids = fields.One2many(
'encoach.course.section', 'course_id', string='Sections'
)
# Phase 1 (2026-04-29): merged "My Learning" page in the student
# portal needs to distinguish accredited core courses from
# supplementary/elective ones. Default = elective so existing
# courses are not retroactively treated as mandatory.
is_mandatory = fields.Boolean(
string='Mandatory',
default=False,
index=True,
help='Accredited core course (mandatory). When True the course '
'is grouped under "Accredited Core" on the student '
'learning page and tagged as Mandatory.',
)
chapter_count = fields.Integer(compute='_compute_chapter_count', store=True)
section_count = fields.Integer(compute='_compute_section_count', store=True)
objective_count = fields.Integer(compute='_compute_objective_count')
resource_count = fields.Integer(compute='_compute_resource_count')
@@ -49,6 +130,11 @@ class OpCourseExt(models.Model):
for rec in self:
rec.chapter_count = len(rec.chapter_ids)
@api.depends('section_ids')
def _compute_section_count(self):
for rec in self:
rec.section_count = len(rec.section_ids)
def _compute_objective_count(self):
for rec in self:
rec.objective_count = len(rec.learning_objective_ids)
@@ -73,6 +159,76 @@ class OpBatchExt(models.Model):
index=True,
help='Owning entity/organization for LMS isolation.',
)
branch_id = fields.Many2one(
'encoach.lms.branch',
string='Branch',
ondelete='set null',
index=True,
help='Optional branch/campus inside the owning entity.',
)
classroom_id = fields.Many2one(
'op.classroom',
string='Classroom',
ondelete='set null',
index=True,
help='Classroom (homeroom) hosting this batch. When set, the batch '
'inherits the classroom roster on course assignment.',
)
teacher_ids = fields.Many2many(
'op.faculty',
'op_batch_faculty_rel',
'batch_id',
'faculty_id',
string='Teachers',
help='Teachers assigned to this batch. A teacher may be assigned '
'to multiple batches.',
)
student_count = fields.Integer(
string='Student Count', compute='_compute_batch_student_count'
)
course_section_id = fields.Many2one(
'encoach.course.section',
string='Course Section',
ondelete='set null',
index=True,
help='Specific section template of the selected course for this batch.',
)
term_key = fields.Char(
string='Term Key',
help='Optional term/semester key used for one-batch-per-term uniqueness.',
)
def _compute_batch_student_count(self):
Enroll = self.env['op.student.course'].sudo()
for rec in self:
rec.student_count = Enroll.search_count([('batch_id', '=', rec.id)])
@api.constrains('entity_id', 'course_id', 'classroom_id', 'course_section_id', 'branch_id')
def _check_batch_entity_consistency(self):
for rec in self:
ent = rec.entity_id
if not ent:
continue
if rec.course_id and rec.course_id.entity_id and rec.course_id.entity_id != ent:
raise ValidationError(
"Batch course must belong to the same entity as the batch."
)
if rec.classroom_id and rec.classroom_id.entity_id and rec.classroom_id.entity_id != ent:
raise ValidationError(
"Batch classroom must belong to the same entity as the batch."
)
if (
rec.course_section_id
and rec.course_section_id.entity_id
and rec.course_section_id.entity_id != ent
):
raise ValidationError(
"Batch course section must belong to the same entity as the batch."
)
if rec.branch_id and rec.branch_id.entity_id != ent:
raise ValidationError(
"Batch branch must belong to the same entity as the batch."
)
class OpStudentExt(models.Model):
@@ -85,6 +241,25 @@ class OpStudentExt(models.Model):
index=True,
help='Owning entity/organization for LMS isolation.',
)
branch_id = fields.Many2one(
'encoach.lms.branch',
string='Branch',
ondelete='set null',
index=True,
help='Optional branch/campus inside the owning entity.',
)
@api.constrains('branch_id', 'entity_id')
def _check_student_branch_entity(self):
for rec in self:
if (
rec.branch_id
and rec.entity_id
and rec.branch_id.entity_id != rec.entity_id
):
raise ValidationError(
"Student branch must belong to the same entity as the student."
)
class OpFacultyExt(models.Model):
@@ -97,3 +272,22 @@ class OpFacultyExt(models.Model):
index=True,
help='Owning entity/organization for LMS isolation.',
)
branch_id = fields.Many2one(
'encoach.lms.branch',
string='Branch',
ondelete='set null',
index=True,
help='Optional branch/campus inside the owning entity.',
)
@api.constrains('branch_id', 'entity_id')
def _check_faculty_branch_entity(self):
for rec in self:
if (
rec.branch_id
and rec.entity_id
and rec.branch_id.entity_id != rec.entity_id
):
raise ValidationError(
"Teacher branch must belong to the same entity as the teacher."
)

View File

@@ -0,0 +1,32 @@
"""Turnitin (per-institution) integration fields.
Phase 4 (2026-04-30): each institution holds its own Turnitin
subscription. The platform stores the API credentials on the
`encoach.entity` record so submissions made by users belonging to
that entity are routed through the correct Turnitin account.
"""
from odoo import models, fields
class EncoachEntity(models.Model):
_inherit = 'encoach.entity'
turnitin_enabled = fields.Boolean(
string='Turnitin enabled',
default=False,
help='Toggles the Originality Report submit button across the '
'whole institution.',
)
turnitin_api_key = fields.Char(
string='Turnitin API key',
help='Bearer token for the institution Turnitin subscription. '
'Stored encrypted at rest by Postgres.',
)
turnitin_api_url = fields.Char(
string='Turnitin API base URL',
default='https://api.turnitin.com',
help='Override only if the institution uses a regional cluster.',
)
turnitin_account_id = fields.Char(
string='Turnitin account ID',
)

View File

@@ -0,0 +1,97 @@
"""Online-test security event log (SOFT tier).
Phase 2 (2026-04-30): the platform supports proctored online exams. We
ship a *soft* security tier that does not require any browser/desktop
agent — just timestamped logs of suspicious events the exam page
detects (tab focus loss, paste/copy attempts, fullscreen exit,
keyboard shortcuts, etc.). Teachers can review the log per attempt,
and a single-attempt enforcement flag prevents retakes when the exam
is configured for it.
The hard tier (lockdown browser, AI proctor) is intentionally out of
scope here — those would belong in a dedicated `encoach_proctoring`
addon and require an external service.
"""
from odoo import models, fields
SECURITY_EVENT_TYPES = [
('tab_blur', 'Tab lost focus'),
('tab_focus', 'Tab regained focus'),
('window_blur', 'Window lost focus'),
('window_focus', 'Window regained focus'),
('paste', 'Paste attempt'),
('copy', 'Copy attempt'),
('cut', 'Cut attempt'),
('context_menu', 'Right-click / context menu'),
('fullscreen_exit', 'Exited fullscreen'),
('shortcut', 'Suspicious keyboard shortcut'),
('multi_attempt_block', 'Second attempt blocked'),
('other', 'Other suspicious activity'),
]
class EncoachExamSecurityEvent(models.Model):
_name = 'encoach.exam.security.event'
_description = 'Online Exam Security Event'
_order = 'occurred_at desc, id desc'
attempt_id = fields.Many2one(
'encoach.student.attempt',
ondelete='cascade',
index=True,
help='Attempt the event is associated with. Required for the '
'teacher console to surface it under the right session.',
)
student_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
help='The user who triggered the event. Stored independently '
'from attempt_id so the teacher can audit even if the '
'attempt later gets purged.',
)
exam_id = fields.Many2one(
'encoach.exam.custom', ondelete='set null', index=True,
)
event_type = fields.Selection(
SECURITY_EVENT_TYPES, required=True, index=True,
)
severity = fields.Selection(
[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')],
default='warning', required=True, index=True,
)
occurred_at = fields.Datetime(
required=True, default=fields.Datetime.now, index=True,
)
payload = fields.Text(
help='Optional JSON-encoded extra context (URL, key combo, '
'duration of blur, etc.).',
)
class EncoachExamCustom(models.Model):
"""Add proctoring-config columns to existing encoach.exam.custom."""
_inherit = 'encoach.exam.custom'
security_level = fields.Selection(
[
('off', 'Off'),
('soft', 'Soft (event log only)'),
('strict', 'Strict (block on suspicious activity)'),
],
default='soft', required=True,
help='Soft = log events for review. Strict = same plus auto '
'submit/lock if too many alerts in the same attempt. '
'Off = no monitoring at all.',
)
single_attempt = fields.Boolean(
string='Single attempt only',
default=True,
help='When enabled the student can only take this exam once. '
'A second start is blocked and logged as a security event.',
)
suspicious_event_threshold = fields.Integer(
string='Strict-mode alert threshold',
default=5,
help='Strict mode auto-locks the attempt after this many '
'alert-severity events. Ignored for soft / off.',
)

View File

@@ -0,0 +1,63 @@
"""Handwritten solution submissions (Phase 2 — 2026-04-30).
Students upload images of handwritten work for math/science assignments
and the platform OCRs + AI-grades them. Each submission stores the raw
images plus the AI-generated rubric scoring, and can be reviewed/
overridden by the teacher.
"""
from odoo import models, fields
class EncoachHandwrittenSubmission(models.Model):
_name = 'encoach.handwritten.submission'
_description = 'Handwritten Solution Submission'
_order = 'submitted_at desc, id desc'
student_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
)
material_id = fields.Many2one(
'encoach.course.plan.material', ondelete='cascade', index=True,
help='The weekly-plan material the student is responding to '
'(typically kind=assignment).',
)
plan_id = fields.Many2one(
'encoach.course.plan', related='material_id.plan_id',
store=True, index=True,
)
submitted_at = fields.Datetime(default=fields.Datetime.now, required=True)
image_attachment_ids = fields.Many2many(
'ir.attachment',
'encoach_handwritten_attachment_rel',
'submission_id', 'attachment_id',
string='Pages',
help='One attachment per handwritten page. JPEGs or PNGs.',
)
student_note = fields.Text()
status = fields.Selection(
[
('submitted', 'Submitted'),
('grading', 'AI grading'),
('graded', 'AI graded'),
('reviewed', 'Teacher reviewed'),
('failed', 'Grading failed'),
],
default='submitted', required=True, index=True,
)
ai_score = fields.Float(
help='AI-suggested score (0100). Teachers can override.',
)
ai_feedback = fields.Text(
help='Markdown-formatted rubric feedback from the AI grader.',
)
ai_extracted_text = fields.Text(
help='OCR + math-LaTeX extraction. Stored so the teacher can '
'verify the AI parsed the handwriting correctly.',
)
teacher_score = fields.Float()
teacher_feedback = fields.Text()
reviewed_by = fields.Many2one('res.users', ondelete='set null')
reviewed_at = fields.Datetime()

View File

@@ -0,0 +1,137 @@
"""Live online classroom sessions (Phase 3 — 2026-04-30).
We embed Jitsi Meet as the conferencing engine, so most of the
in-call features (breakout rooms, polls, whiteboard, screen share,
chat box, recording, waiting room, host media controls) are driven
by Jitsi's IframeAPI. The Odoo side owns:
* scheduling + calendar metadata (`scheduled_at`, `duration_min`)
* enrolment list + email notification on creation
* attendance tracking (auto-mark + late-entry restriction)
* recording URLs + status transitions
* removal log (who was kicked and why — required by spec)
A session is hosted in a Jitsi room whose name is generated from
``room_name`` (slugified, unique). The frontend joins the room via
the IframeAPI and reports lifecycle events to the backend.
"""
import secrets
from odoo import models, fields, api
class EncoachLiveSession(models.Model):
_name = 'encoach.live.session'
_description = 'Live Online Session'
_order = 'scheduled_at desc, id desc'
_rec_name = 'title'
title = fields.Char(required=True)
description = fields.Text()
course_id = fields.Many2one('op.course', ondelete='set null', index=True)
batch_id = fields.Many2one('op.batch', ondelete='set null', index=True)
plan_id = fields.Many2one(
'encoach.course.plan', ondelete='set null', index=True,
help='Optional link to an AI-generated course plan instead of a '
'traditional op.course.',
)
entity_id = fields.Many2one('encoach.entity', ondelete='set null', index=True)
host_id = fields.Many2one(
'res.users', required=True, ondelete='restrict', index=True,
default=lambda self: self.env.user.id,
help='User who scheduled the session and acts as the Jitsi '
'moderator (mute/cam controls, kick, lobby).',
)
scheduled_at = fields.Datetime(required=True, index=True)
duration_min = fields.Integer(default=60, required=True)
actual_started_at = fields.Datetime()
actual_ended_at = fields.Datetime()
status = fields.Selection(
[
('scheduled', 'Scheduled'),
('live', 'Live'),
('ended', 'Ended'),
('cancelled', 'Cancelled'),
],
default='scheduled', required=True, index=True,
)
room_name = fields.Char(
required=True, copy=False, index=True,
help='Slug used as the Jitsi room name. Random by default to '
'prevent guessing public sessions.',
)
waiting_room = fields.Boolean(
default=True,
help='Enable Jitsi lobby — host has to admit each student.',
)
recording_enabled = fields.Boolean(default=False)
recording_url = fields.Char(
help='Filled in once the host stops recording. Stored URL '
'should be served by the platform CDN, not Jitsi.',
)
auto_attendance_minutes = fields.Integer(
default=10,
help='Mark a participant "present" once they have been in the '
'session for at least this many minutes. 0 disables.',
)
late_entry_minutes = fields.Integer(
default=15,
help='Refuse new joins after the session has been live for '
'this many minutes. 0 disables.',
)
notification_sent = fields.Boolean(
default=False, copy=False,
help='Toggled to True after we e-mail the enrolment list. '
'Prevents double notifications when the row is edited.',
)
participant_ids = fields.One2many(
'encoach.live.session.participant', 'session_id',
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('room_name'):
vals['room_name'] = 'enc-' + secrets.token_hex(6)
return super().create(vals_list)
class EncoachLiveSessionParticipant(models.Model):
_name = 'encoach.live.session.participant'
_description = 'Live Session Participant'
_order = 'joined_at desc, id desc'
session_id = fields.Many2one(
'encoach.live.session', required=True, ondelete='cascade', index=True,
)
user_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
)
joined_at = fields.Datetime()
left_at = fields.Datetime()
duration_seconds = fields.Integer(
help='Cumulative time-in-call across joins (people refresh, '
'switch networks). Updated on every leave event.',
)
attendance_status = fields.Selection(
[
('not_joined', 'Not joined'),
('attending', 'Attending'),
('present', 'Present'),
('left_early', 'Left early'),
('removed', 'Removed'),
],
default='not_joined', required=True, index=True,
)
removed_reason = fields.Text(
help='Required when attendance_status = removed. Used by the '
'audit trail tab.',
)
is_host = fields.Boolean(default=False)

View File

@@ -0,0 +1,32 @@
"""Mark AI-generated study plans as personalized to a single student.
Phase 2 (2026-04-30): every student can ask the AI for a custom
remediation plan based on their weakest skills. We reuse the existing
`encoach.course.plan` machinery (weeks + materials) but flag the
record so it shows up under "My Personalized Plan" instead of in the
admin/teacher catalog.
"""
from odoo import models, fields
class EncoachCoursePlan(models.Model):
_inherit = 'encoach.course.plan'
is_personalized = fields.Boolean(
string='Personalized for student',
default=False, index=True,
help='True when the plan was generated specifically for one '
'student via /api/student/personalized-plan. Hidden from '
'the regular admin/teacher catalog.',
)
personalized_for_id = fields.Many2one(
'res.users', ondelete='set null', index=True,
string='Personalized for',
help='Student the plan was generated for. Only set when '
'is_personalized=True.',
)
weakness_summary = fields.Text(
help='JSON snapshot of the skill scores that drove the AI '
'generation. Stored so we can re-run the planner with '
'the same input later.',
)

View File

@@ -25,3 +25,9 @@ access_encoach_grammar_rule_all,encoach.grammar.rule.all,model_encoach_grammar_r
access_encoach_grammar_progress_all,encoach.grammar.progress.all,model_encoach_grammar_progress,base.group_user,1,1,1,1
access_encoach_paymob_order_user,encoach.paymob.order.user,model_encoach_paymob_order,base.group_user,1,0,1,0
access_encoach_paymob_order_admin,encoach.paymob.order.admin,model_encoach_paymob_order,base.group_system,1,1,1,1
access_encoach_lms_branch_user,encoach.lms.branch.user,model_encoach_lms_branch,base.group_user,1,1,1,1
access_encoach_course_section_user,encoach.course.section.user,model_encoach_course_section,base.group_user,1,1,1,1
access_encoach_exam_security_event_user,encoach.exam.security.event.user,model_encoach_exam_security_event,base.group_user,1,1,1,1
access_encoach_live_session_user,encoach.live.session.user,model_encoach_live_session,base.group_user,1,1,1,1
access_encoach_live_session_participant_user,encoach.live.session.participant.user,model_encoach_live_session_participant,base.group_user,1,1,1,1
access_encoach_handwritten_submission_user,encoach.handwritten.submission.user,model_encoach_handwritten_submission,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
25 access_encoach_grammar_progress_all encoach.grammar.progress.all model_encoach_grammar_progress base.group_user 1 1 1 1
26 access_encoach_paymob_order_user encoach.paymob.order.user model_encoach_paymob_order base.group_user 1 0 1 0
27 access_encoach_paymob_order_admin encoach.paymob.order.admin model_encoach_paymob_order base.group_system 1 1 1 1
28 access_encoach_lms_branch_user encoach.lms.branch.user model_encoach_lms_branch base.group_user 1 1 1 1
29 access_encoach_course_section_user encoach.course.section.user model_encoach_course_section base.group_user 1 1 1 1
30 access_encoach_exam_security_event_user encoach.exam.security.event.user model_encoach_exam_security_event base.group_user 1 1 1 1
31 access_encoach_live_session_user encoach.live.session.user model_encoach_live_session base.group_user 1 1 1 1
32 access_encoach_live_session_participant_user encoach.live.session.participant.user model_encoach_live_session_participant base.group_user 1 1 1 1
33 access_encoach_handwritten_submission_user encoach.handwritten.submission.user model_encoach_handwritten_submission base.group_user 1 1 1 1

View File

@@ -188,3 +188,6 @@
4. **The `BACKEND_JWT` and `JWT_TEST_TOKEN` are identical** — the same test JWT is used for frontend-to-backend auth and for testing.
5. **The `GCS_SERVICE_ACCOUNT` in encoach-cms contains a full GCP service account private key** encoded in base64.
6. **The `FIREBASE_CLIENT_EMAIL` references `tiago.ribeiro@ecrop.dev`** — a developer's email from the previous development team.
openai_key: <REDACTED — stored in Odoo `ir.config_parameter` key `encoach_ai.openai_api_key`. Rotate at https://platform.openai.com/account/api-keys if exposed.>

190
docs/ASSIGNMENT_WORKFLOW.md Normal file
View File

@@ -0,0 +1,190 @@
# EnCoach LMS — Assignment Workflow
**Audience:** Entity admins managing students, teachers, classrooms, sections, and batches.
**Scope:** Single-entity workflow. Cross-entity isolation is enforced by the API and is documented at the end.
**Goal:** Get every student into the right batch (course × section × term) with the right teachers, in the fewest clicks.
---
## 1. The mental model
EnCoach uses **four canonical entities** that build on each other. Read this once — it makes every screen self-explanatory afterwards.
| Entity | What it represents | Owns |
|---|---|---|
| Classroom | A homeroom: a physical room repurposed as a class group (e.g. "Grade 7-A") | Roster of students, homeroom teachers, the courses studied here |
| Course | A subject taught (e.g. "Physics 102") | Course sections (A/B/C…), syllabus, learning objectives |
| Course Section | A timetabled instance of a course (e.g. "Physics 102 — Section A") | Optional capacity, sequence, branch |
| Batch | The intersection of a classroom × course × section × term | The actual enrolled students and the teachers who teach this slice |
**Rule of thumb:** the **classroom is the anchor**. You set up the classroom once (roster + homeroom teachers), then you bind one or more (course, section, term) tuples to it. Each binding becomes a *batch* which automatically inherits the roster and homeroom teachers. Per-batch overrides are available when you need them.
```
Entity (school / academy)
└── Classroom (homeroom) ← step 1 & 2
├── Roster (students) ← step 1
├── Homeroom teachers ← step 2
└── Courses ← step 3 (assign a course → cascade)
└── Batch (course × section × term) ← step 4 (per-batch tuning)
├── Enrolled students (= roster, overridable)
└── Teachers (= homeroom teachers, overridable)
```
---
## 2. The 4-step workflow
The Classrooms page is the single entry point. Each tab is a numbered step. The workflow guide at the top of the detail pane shows green check marks as you complete each step.
### Step 1 — Roster
**Where:** Classrooms → pick a classroom → tab **1. Roster**
**Action:** Click *Manage Roster* and pick the students who belong to this homeroom.
**Why it matters:** The roster cascades into every batch you create later. Set this once; downstream steps populate themselves.
**API:** `POST /api/classrooms/{id}/students` with `{ student_ids: [...], mode: "set" | "add" }`
### Step 2 — Faculty
**Where:** tab **2. Faculty**
**Action:** Click *Manage Faculty* and pick the homeroom teachers.
**Why it matters:** When you assign a course in step 3, these teachers are automatically attached to the new batch. You can override per batch later.
**API:** `POST /api/classrooms/{id}/teachers` with `{ teacher_ids: [...], mode: "set" | "add" }`
### Step 3 — Courses (cascade)
**Where:** tab **3. Courses**
**Action:** Click *Assign Course*, then:
1. Pick a course from the dropdown.
2. (Optional) Pick a section. Sections are loaded dynamically from `/api/courses/{course_id}/sections`. Leave on *Auto* to use the whole course.
3. (Optional) Enter a term key (e.g. `2026-T1`).
4. Click **Assign + Cascade**.
**What the server does in one transaction:**
- Creates a canonical batch `(classroom × course × section × term)` (or reuses an existing one).
- Auto-enrolls every student from the classroom roster into the batch.
- Attaches the homeroom teachers to the batch.
**API:** `POST /api/classrooms/{id}/assign-course` with `{ course_id, section_id?, term_key?, teacher_ids? }`. Cross-entity course/section/teacher IDs are rejected with HTTP 403.
### Step 4 — Batches (per-batch tuning)
**Where:** tab **4. Batches**
**Action:** Each batch row has a **Manage** button. The dialog has two tabs:
#### 4a. Students
Pre-checked with the batch's actual enrollments. Use this when you need a *subset* of the homeroom in this batch (e.g. half the homeroom is in section A, the other half is in section B). One click on *reset to classroom roster* re-syncs back to the cascade.
**APIs:**
- `POST /api/batches/{id}/students` — add students
- `POST /api/batches/{id}/students/remove` — remove students
#### 4b. Teachers
Pre-checked with the batch's current teachers. Use this when sections are taught by different teachers, or when a substitute takes over for one batch. One click on *reset to homeroom teachers* re-syncs.
**APIs:**
- `GET /api/batches/{id}/teachers`
- `POST /api/batches/{id}/teachers` with `{ teacher_ids: [...], mode: "set" | "add" }`
- `POST /api/batches/{id}/teachers/remove`
---
## 3. Worked example
> Goal: enroll 24 Grade-7 students into Physics 102 (sections A and B), with Ms. Salem teaching A and Mr. Khaled teaching B.
| # | Step | What you do | Result |
|---|---|---|---|
| 1 | Create classroom | Classrooms → New Classroom → "Grade 7" | Empty classroom shell. |
| 2 | Roster | Tab 1 → Manage Roster → pick all 24 students | Classroom now has 24 students. |
| 3 | Homeroom teachers | Tab 2 → Manage Faculty → add Ms. Salem and Mr. Khaled | Both are default teachers. |
| 4 | Assign Section A | Tab 3 → Assign Course → Physics 102 → Section A → 2026-T1 → *Assign + Cascade* | Batch *"Grade 7 — Physics 102 — A"* is created with **24 students** and **2 teachers**. |
| 5 | Assign Section B | Tab 3 → Assign Course → Physics 102 → Section B → 2026-T1 → *Assign + Cascade* | Batch *"Grade 7 — Physics 102 — B"* is created with **24 students** and **2 teachers**. |
| 6 | Split the roster | Tab 4 → on Section A's batch click *Manage* → tab Students → uncheck students 1324 → Save | Section A has 12 students. |
| 7 | Split the roster | Tab 4 → on Section B's batch click *Manage* → tab Students → uncheck students 112 → Save | Section B has 12 students. |
| 8 | Pin teacher per section | Tab 4 → on Section A's batch click *Manage* → tab Teachers → keep only Ms. Salem → Save | Section A taught by Ms. Salem. |
| 9 | Pin teacher per section | Tab 4 → on Section B's batch click *Manage* → tab Teachers → keep only Mr. Khaled → Save | Section B taught by Mr. Khaled. |
Total: 9 clicks beyond the picker dialogs. Steps 69 only happen when you need per-section overrides; if every section has the whole homeroom and the same teachers, you stop at step 5.
---
## 4. Where assignments come from (the cascade chain)
```
classroom.student_ids ──┐
├─→ batch.student_ids (on assign-course)
section binding ─┘ (auto-cascade)
per-batch override batch.student_ids
via Tab 4 → Manage (final state)
classroom.teacher_ids ──┐
├─→ batch.teacher_ids (on assign-course)
─┘ (auto-cascade)
per-batch override batch.teacher_ids
via Tab 4 → Manage (final state)
```
- The cascade only runs **on `assign-course`**. After that, the batch and the classroom roster diverge independently. Adding a new student to the classroom **after** a batch exists does *not* enroll them automatically — re-run the assignment or use Tab 4 → Manage.
- `mode: "add"` on roster/faculty mutations unions; `mode: "set"` replaces. The UI uses `set` everywhere, so what you see is what you get.
---
## 5. Entity isolation guarantees
Every endpoint above is wrapped in the same multi-tenant guard:
1. The user's `entity_ids` defines their scope. A user with one or more entities is **always** entity-scoped, even if they hold Odoo system groups.
2. Foreign-key IDs sent by the client (course, section, classroom, student, teacher) are validated to belong to the **target entity**. Cross-entity IDs return **HTTP 403**.
3. Model-level `@api.constrains` rejects direct ORM writes that mix entities — so even shell scripts can't poison the data.
This is verified by the `smoke_entity_isolation.py` script, which asserts every cross-entity write attempt returns 403 and every cross-entity read leaks zero records.
---
## 6. Reference: endpoints used
| Method | Path | Purpose |
|---|---|---|
| POST | `/api/classrooms/{id}/students` | Set/add classroom roster |
| DELETE | `/api/classrooms/{id}/students` | Remove from roster |
| POST | `/api/classrooms/{id}/teachers` | Set/add homeroom teachers |
| GET | `/api/courses/{id}/sections` | List sections for a course |
| POST | `/api/courses/{id}/sections/generate-defaults` | Generate A/B/C sections |
| POST | `/api/classrooms/{id}/assign-course` | Bind course → create batch + cascade |
| DELETE | `/api/classrooms/{id}/courses/{course_id}` | Detach course (keeps existing batch) |
| GET | `/api/batches/{id}/students` | List batch enrollments |
| POST | `/api/batches/{id}/students` | Add students to a batch |
| POST | `/api/batches/{id}/students/remove` | Remove students from a batch |
| GET | `/api/batches/{id}/teachers` | List batch teachers |
| POST | `/api/batches/{id}/teachers` | Set/add batch teachers |
| POST | `/api/batches/{id}/teachers/remove` | Remove batch teachers |
All endpoints require a Bearer token from `POST /api/login`.
---
## 7. Smoke tests shipped with the platform
| Script | Verifies |
|---|---|
| `smoke_assignment_workflow.py` | The full 4-step flow against a live backend: classroom → roster → teachers → assign-course → per-batch override. Asserts the cascade and the override are persisted server-side. |
| `smoke_entity_isolation.py` | An entity-A admin cannot read or modify entity-B records via any of the endpoints above. |
| `seed_dynamic_sections_demo_via_api.py` | Idempotently seeds a demo classroom + course-with-sections + batches using only the public API. |
Run from the repo root with `.conda-envs/odoo19/bin/python <script>.py`.
---
*Last updated: April 2026. Backend module: `encoach_lms_api`. Frontend page: `frontend/src/pages/ClassroomsPage.tsx`.*

View File

@@ -0,0 +1,174 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R /F5 6 0 R /F6 7 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
6 0 obj
<<
/BaseFont /ZapfDingbats /Name /F5 /Subtype /Type1 /Type /Font
>>
endobj
7 0 obj
<<
/BaseFont /Symbol /Name /F6 /Subtype /Type1 /Type /Font
>>
endobj
8 0 obj
<<
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/PageMode /UseNone /Pages 15 0 R /Type /Catalog
>>
endobj
14 0 obj
<<
/Author (EnCoach Platform) /CreationDate (D:20260427012439-04'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260427012439-04'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (\(unspecified\)) /Title (EnCoach LMS \204 Assignment Workflow) /Trapped /False
>>
endobj
15 0 obj
<<
/Count 5 /Kids [ 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R ] /Type /Pages
>>
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2686
>>
stream
Gau/ZBlE#ip3V8-6\&He\d9tRZ8>!7G_4.UM\_ME(`4e^Mh78Y=;+8J7;9D#g]-,#;J.O.:?(EC7)&Qg?)1le2q<DQ%,fqCVXGs1)\srs/ob=i1L)P-IQON1!jrRP^OAtKd&,Y.M!MUFAHVI-cO'b&DA</RZ$S;M+kQg8>i.us$mo'BQ32O;Mm'A[:15&W-^O/pp(64=^CVr"_p?)&_i8NZ?nQ#2RqFi:iJT,P<iOaFn<"j_m?n2)7"-['s1=N@I/*Wu"JshDj;f*CjU"')PC_o.`:eM\;RepdK]9JRc"`mhK%&'W;/Ng:/2%9FUgf#n^'-iOG@pX]oO*4cAbmH:di,XsV8qbVp1Y%-N_d<J,9MW8nC(m)a>Jh`gjg$l%NKjMnrD`("1O&8+I6LN:J"$`^F8nEojS+%o`C>&'dl,)pff>=Eip<o!ge2#PkVuQ/BK2K/8)F`ILEH_@ap5sK9E"DG_)K]PY#!`Ol9rpUG<EAIU[R2931$n8Mkai\fP4BlUYj3-sn3iPb3$7^p[9-JVuicft:(U8=?3*T7H)T;+)Dgh*\a80YdH]PK0M%7$TMO0!\A.niRuVkC[1,#Wj,Q$2b_qmMjh&a\/=)(!5jS1d]((,SF*#3SZUX4gc3P6ck8Qjpc-jEnpi`oU"Y8onm[u/5_m.FB>O0;.oK56/VXsUH=r\^#4-\c1.N3J$u27(F?:N&X=YE$CQ]qH@UO3gLFVV"f'bdgZ]m<hOO+.EXB.I47fZ&Hk<Pfj@P"eC-PA$gi\EnNLmMYOu*BMYd,GYZsQ,W1M8EpUY?);<$mVN,p&X>,Zsq?<,eEfZ;2`A=6KM]RhX1M.<(i[cs*r[L`c5=->L-[^]UMAr5:l136D7U0An$X8/\"F(:^&J%"D%B4hBEmLfAd]HQ:boj3i5a$"l.C(W7.mQu1?&9qd:KqhQ8.%'`%.jU&TFj*5]-6pJ#"U9rZ3fr3"_EBL"b5"t--Fi76M!b5J-lXZYtb[17sR71@2FC1",Mt/!4N.PsT/lame9?V<['$qbK0s-Mkr#p5V6T#dXO(sdQcTD+X+7oRU(JU';>-7Sk4IY\%U2sFpN%:oHJ\brH0D4#dZS=`j<5h<tPp%F@db,%H$W1apW,m"6*lQ1bLP!JbB'8d7Rl:S>bjq>Q#^,hInl\MB$QrK`"PegaAWi?VPtk"6Or>H/iG]%ZbHm8Ke?KFakrrtGUCj#KXHm3JiBkX9Q!'*si:KXr>G</)KC%\ae*m""bNPTNL6/2+o\K0!`jWOL2cq?]V^YhN4bKT$'tL?JKU*nZ`qP5k+5Go1$Eai1:YX)RRYKs$-5eJi1.Bg5$F=7mA"nH/B^2N$B<PrBP?D\(iNU4E1nB'M.&n+Q,.:nljp>&[r`6!Rq@&Ynb-?E6FS&[O]MhhndhY$6_,U5bKeJ1K>4'=19iT`0DJQ=D9AothF\JZ8<*$T%.n$D-XU1Z36bkt?"jK)e_KIM)9Js5J`.E@fT)F>W?QX-*D,5AdVY80D@R`M\_RlqjRt]g:X1,@CAIftb]ChN[YuqD=5[aoX%i5(\_&4d`blSg!\<U68+$)"o4"qFLlg7'Grb5`5n+Jf%d1'1tmct"E^A$D,<Y,Bi=DXV]"+RIO*I,_LPq>N&5F"lcRYB&Ud6Z7lFmMQ%"&7]<U;A-O!`fsq/%:&+7@4c9Zi=f"SZ>G8+Dk5hP\?8-`4&E\eU&Fn"nkgtDk#(]s#t4ecEc0"=9N$LRZmq-PuB2HQ"lUI6B\A0WS)Gu[[1iM)5q#V:(X!sV9#n8`_%^tMr[Hm,L8qqZr%4-:=tS/"KluhDOP0g4].?[M@#N!I:GjK1'/*J3@II?cLi@*%d8]8Yj7&CEE2Ln.$.0ir3'5=%Lsh:AO;5hk%o[1!;.njbT_`iL(u6CZhlBJ9VBe.2ZV+o]RE(5#o?n6S2=D9M[dC88:LVbK2'fS9%<\AV>)Fj4g#!V%cjap2c8Em=q2Br&Qc0skhcpLk`u(Y<^R[GGs1lo&q1g"2UaJ&Xp!I6`ui>80r^[`i0#=$*@TjX"34Ru'A#[sRl1b@Pq%7W1B%p[06ctM+NY_jcudil*YiRQ8p@Q+Sso(b[l2N`Nc/FT/XV[Rh`1#0h6;t?<RX.d9`*UdD<gN)j)Z.i\``S2bQ<-jaqOier+i(/KT3S4W:RFH6Y84;/+-gjYd1C;,ISk4%tbkF!Gn06mB9!P7M<550PNBHj\fngrQpgm0m5HHM?GY(bI-!&Gll$NL5(cW+CiB]<5Sp*i@kLHL+^"S,\gLY.uFO;?W$\*Ifi-MXg-++P.j5N>aq7W>:L<[&"rCckmrW`i#:U5RSAa*?9R&N0kP+X?8-b?I+T^Cl[Fc_!Z]?%G)ALV%TemA<(5&Sp`7nL&pC15qaFfFn34;`-rlmR/a3qr`(AMY/9r_L7+CrNGT*%/Fbjg2\kh=@6i-t6j5/#,9@\BLLq`*<brPktF$jFAmO#d6P?)XWk:FS:"bCGqG,+p&+Vrg;QFJ0gB+T]LfMlU$J#8N'Spo^Tgs(LU#sm&,L\1ZWI;9Us+?`'mlf#?JM=53PbT%&WW17HZr[:T6r0[oVlBB[_^#$`2IN24*Qg<oq=!+U^0P-_ag>2a&Io#&gVl_'.@*ocu4.(J84R^lIQ>b`sKTQ5)kprL;E/L48.Js4E/<3NbcNC>/S:TO(*uO<HdsDj1l->OoSGWPBg=>i~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2205
>>
stream
Gau`UD/\/e&H88.ETi`L?,DEIHbU83Q?F9P1gN3X_`SWl7)s%Y0pCgI7b@[S,:+kGjhu9-V?i83P)d2%md@VIMLpIFeGN&g!&+4gnqmK[clW>^L^3mfE7R8KkFd8:km&pLlMJMZ)>-?>T'sW."6+[A'1Yl(B`VD@maI!1^`qcaJVTs5kah;q!ULBoT:u9aYI*p_EL7]LAVmbk<:XQ&Dr4j9J?]YlP\#$':IMADUF`R'@l\>,8"qD>TarD<I2idtHBa-br;QNmXdU9k\(eE+:ZUOt'C\/G0Y_aG$l9-jd/6ABYSIQJT?oQR&[i#eg-Fm7?Y`=EGUK[3ioOt.LuUu;6WKZ/5uAa]n3SKfYb:`1,RX(ckc\Et-$l6Z-.I..FaB"^/"TGTC>L`@_]=&LHe%^VU,&.?;=nt#-.`N)h<+\uSnh-2eQigJGmZK6;N1g<j?s\[:-]-XZm\OU@3;=ifnWH!&4aqf;_[0&T`T!<78N#!QW)fhrd[?[VaW$WbFqnp#2CG?%,P)g!PAuZVb5sC=i/5>3T03!=UIb>bB#Cj9<OFT@r^s5g`k];a=j89Z;eW!.&qK!bY^;,CkB0V%;(.49bI\+)!Qtbd;5h(2$ZTBi#0m.(\7_P6_0!.2[No]KJ#m]/,*(ZpFn[YZNg:&NX&F0kPY8^dLs=::6I?C6cVrInhj(TSM\uC<gdN6e6V>)_HWY<[!W(-&OQI\Jr*1?1O)e)o`Y8H-Tk+q^c)YV"1O7OXW_f6>ZW]o;C4S4k"*E^USRFZg"11Zn%s`W`H\:-cq5/I&mcnI'n\j]AN]^B&X?!)3a:gip8[;a6rd6N>2>4'8a1Q/qlOgTXY;%7Pr7E?86"27a2ffYs#:!g@ZN-YEP"M%f5,uBFXI6+&T!k7J_<-:?o/QU/2[/H+/O\(8`_M4+;kUL#?jf2@kueLEdNR91_rJ)F9?A#`QPkABsf]mRtr!ThV[Oaio@iue@:d\KGf?!lD!;7Z,GEk+e@eD;A6c1[_k-@F]B<"o!Q((V!4pb/D#_H!cJ8B,+:81o/3e3[:W-f8snnDK^PCjjB0o,[rJ\I:`!2!m;DR#BC0-mU0CdeirZAZ6G&\m]mn@K/@"DHr&oUH0h9M(jGJ%ba;bs8pM&5VK0pSgfl"lW)j+^:-*(3XZp^L%PrsPNe#G$CUXfJR-esF.fF"m3*-#P!]bCWn:ln:#SCjRX*)iNgH-'7oia0U:.,;p7!uco`gsk1j;Z]cb&.7=A=L?Zg3%Y8VQDL_h4I<UUY.EZ[KKZadMiJ/L0(pW7"YnUdHWC+0%)R\_k!r2_W;'=N1t^rJJ$W$aq3?,b<U+hF,If&P.4/,SXle_TOV`%#/jWlEc>d<\VL!i2.dnsjKe,(dAU-E`/C+Rq!963D0]%80WQ(WPr=fOl!^O48HZZk2lSAmk\OXuqTkQ,mjH@;V9WK&Zp2m_>\@V:)<)'X<Fo#9;922U1[)dF'IXYh5r=dc*k=87%dMf3Y_mab/bpeBnM2@o]26U(fF?kaM%c7+]oi?eAN)-[i17J0qn"55f/b.u%E2,Q,AR76Y^!onnL,'aqO"7e@di>@2Aib!c+#fa9A>-YiT)5'&n!q\KoFYF6@NfA<ijp%N1p9Z>]$cpDV0"ZQf-<u/a7@BSTOq(H:g+Q[QdH$LS(g=;D6]PMEA"3bh#_"#hMJJ[*`1),$ZV->9s,;CdST.'LhWT\=PTr\UlS>F!e6Tr+=hFmW)'pDJ[2pM?^0$_;Q)5GEk"g$fP[)\c)qKte=H5fO&o[I%GIP%G[Y3%O@j0#H$T5]9C*;LIBG)tmRc$T6%qQ]FnJigWiN-=]b%AfSgR]II3)PASGJntJmXn[dpToD^ETr;`%,nQ4)_>Pp?b"$e5IUV7tfT=V+jCh2isN_F]ktL<Oh>>C!DHMBUPd_U:IR$55GO=dd:G$?/5>)?p#mV7"/:f!>mZZL\?8HG`Ot5M2Yd8JegHFU$W)9h0CiF(56Y`Rh$8b/6]KCKbU.cN>,XCYr3&H@S-%pE#LJU]HKmAZe,mbPnq'Q(.3QYJ0DY*LY/UG[Um6L;r)$R"oh_E*Hh*;X'9TbV;Hd,C%#KLFa"i\q@?-Ef4XD)<*>!*Cika9mg2g;O6qkAr?iNr,GE:gX3XALE8M3)8/+7>Zbe;bF\g,)83Cg@]e2;\+&sKt[`m*jmj\0Xh^AEGSdBWR-&b:h'4ph_s2+jWr"$#rjY6~>endstream
endobj
18 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2249
>>
stream
Gb"/)=`<%a&:XAW(o/R[UbTd#GdL7D&>s4%_MJEV76n,TeeW%Y<(T-Rs*\O*l_$d'$lTqI@0u!GB4bekD`E(T0_(37hqf8Z3epcQ":0_W%1mDrR(nEco<Z3W$AkM]Y$i(>%H^P4CbWBWY"?G@>aVYrnZ@7j'\W_1$l>jP0Q(lXF:FEaH)lHYL\[PrJ"\`:2)e4O/@teug[Io_]bS2pSABeT)dXnKIiGOjR)5aL^:O]GLPA"1E@bi40O47jjS6n)Yq>bGmL4I8E#.#:)"tu^fObSEF+]@iXT2sG+0bX-Dg(Z4/R3q<QA]0=Z!e![TkMDe9tUi<Z++XS4)lr?kcVS0TKn/?,W0]a-d9YXTU)8MQKq8<)ZBHX@\0m&H.SoQ!8'l``"CD3%V7tr%tVX/"5FImV%*UQ+]`RR,#d7BD+"I9@,X/_d2+f(1>gtX1AD/T(e6jlW9RY>Qg3@\,e[REcm7P)B`qM<a2I]b+Gc0!,Z.n57puJ&6&&%"/tTh2#<uM1AR`GK#&4UH_QigPgS2f$Cm6ZBr3jHXbIY[BBFe1(UoeTu*2Nk__dmElUG*(B$(OlN0$E9t:>@%$7#.LO6Wg)m1Q+.]#JpW+*.2Ko1DA"kSFk<)RQ_sJ%qqi+H)OGmAnjAtRPB*j1_\qOQY(6M>?OunGa]KoBpGem!2*Q7c3M&Y9MJ&m<0VJ3>a#TJhmJ&n:tK*P9a7YiV/"JZ*OCf8&P`RHs0Rq<4@](LN*;2p>A26e_+$Vgj??+YoUFNTjf"8NUV`+@-RDMkkFZTe8G>*'BC&?oA?*EQA<Ol.[-eu(k1HTb-EPaBBf1Icm]-_2Vo=5pWt=Y^%)8k.m9"-BU?GaGiPBuoNNVdQs)u(I+7$F(Z;O!8T(`h)el\e`%tKXOlsl9U.dd<f0cI.Unet^@!5bQ@FV0tPI(B@g`/X6AJ]nLWZ=)ksX785X1Eh\u?mQKYUnUHFd8?F=Z%ekC^tED@IYlW*lZQ;tWOP+V2<9R9U2bVjEqB_^G9K+sq`GA+S6RZcFlqZBZ3LS()iA?_Ks/G+AuTYnG,lOEA$D`rM%jV\cb!UET!<[c#.(uALY(@\UDD0mD])q,[udY-fP1(67i`P#]#*bld=%O`@A%D]Ig1-o"`0QKC/XAF4t+MhpK;dV*5Wf`@R;C_('NHXdBiBATe*Og8OGM'WOLUUXXm3;omn=LQ)!$f1Kuh`[bKOCp^)1JEOX/)U.(!?m[Y>B0C3K%fjt-)m3LG6i$cRT(l<C0#K91h[R9S!%uTBUiI?_d!IN6/VsN02q".-SS@EA\elgfC+4&SU'DP\,X;&T9V^o\@a.t[:!qW?fGPk9)juUDJ([.:.*np12EutMebbt$CZ8jGe7L/.f%hWI2h&]I6P(Uibkb-SPjQ@TN*YC\<4eno[R/d%W[0S-)RC@OIAMdm!fO?o!&mM758[`<<OC<(mIUj<,5ZMB7!a2$,2l9c\45F-Zc.8.%Q7l.gi.&"-eBJh=I7o7h[pLf[U)"W@k>%ucp*Ft5CEtb@ZaGt"`YJBh[?UCX]_]U1b59pn$J,MDEcaH_XJHE9a)^$=[Kc>8e?tR>rgk!a2Z#Sd?]@#:`"gsChFsEOg$?a'ir"]6Ii^ai5t,tP,2ZI/fA!k4;BN(J1]J?pF6g9fB\7"HU.Far2aqsoMY-%X6_l@cOk:G+L59Y+XY3>0hG8'GoOT>c'!<\#b>P/1ar;OQ=c'F?.(-!5Io>Wc2^G,>Jl%cr'(&85;$Vbp.qcGZ<u897=`+]q/[$d!q1lE--TKW+EJhXC3#QZrVYub$4A,9$'/r\n$!aOO#UeWCWn)MfR.HWX%&-t>Y+M^g!g^"XgmjNX\_3nm*`i'42o\4=Ur5B@?K=\65-$[:QR_eKr#8IWoiP:S72oNZBlafc-r@,tpJ5>Y73^?`=p0)<R7Yh?"#@i&bRNRQ+dFNjN#Qp\1jqtfoBQ[k.dB!h8Y$Xq)#C-/006DW%JYT^<?S>@=:'3h'tB)T/;o>pMO1LcJ[^ek\9QjN!R'%(3n[]tpK+W2M;kp:;mj9$Z0VuGKQ-'0c]>+A3Nh(!2MqS4%tC!4odNeDfQnkQ_rCZ]/e&6Qr3U.=?O)?n#2<HOp*r2_nm[3LbbK[UAa>Y)KIbPYqr(GFOZLn!*>a``()(tc7d%`]2a$:6:Tr^D7EHT7*fuLJ0.ff=2hZO/5,g3-I0mMc:p9C@p,)*]@WLLa1;LOGdmc_]:ML:S\`P>LGi.VX)#ckZSfico`!C#U!,);``W~>endstream
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2935
>>
stream
Gb!SnD0+Gi')q<+Z.#,13^!ip5S,2f?d;66ZlNeB-G$`@T#i*!A2C)RKr!_n3qqNM!P[lZN5E*L,niAe!fQ1.oB\(4;R#XD2*^7sJ>Y2j5,UX`bSEH(i@qluX4c1HZVodsJPS]03O+!%*>]!*\8O)E%X0J]KCi1tTVZgeHGg'N&Xs/g9:'Vs>2Cae:LNksAj9s'Sa"(%[U@o+=7NUbcL9]fj]f6'b_=4k$ZG(%MKAQ:P27jdjIrE2Gb/<G"2m]V/<7J*V65>-0@''d*mWbW)]PEiF)]D;*6nk3=s$3&p?X_cr7X'<>iaoEW%WO?gY(IjVeV?i9:=rmGV)j>7P)UO)")/7(<utJ"a;Q'jVRj8os7Abo>7bsC;6@XJ3PQL:>X/6*KkeJBc72U,r>[d/+f0#hh=?s3G+B<:SEH0Hk.4@ZKZGXZ0m?,U(hF3rDLWrXNdB,o0jM>#dR^tlK1Yg4SsPA3OMWlhAH#W^I>dJE0aYUFlPl?D&\Gh:tIk7M4f2Rq07Xu0RtI`YZsuA`5P8E]gIb)`JEpW'S-NV&EZ!_*^c5C=T$MTUs4^j9pf&$U>5H]n6hu;D.qsFklM@4!mYG&$Ka+598mBo7iP"NEE5MY<>s/H(`MS(T1QqRaZN_(RB"3uVWS(7(V*-]^Q\Hsr_s%b5fS?mbp-2L-HQnK,h!EZ&0ZaY%-'UT'`Np[BL=8i>5HXl07tC+Da?^k*\b<D4UL[i9Atp,;HpG:+fZtb;SRuiSjAI:qU+\V<h/6_.oi)d;5C[,Jm@IFOt`EL#lSD!7&hTt7kLkm+Ig$+Up*=LD1TCM>stk*GIf(M!Dbb<gP]n,dM%a;rP?QmDpj8SPBe-6BERFmo9k;7UVciNA[^O8`KHml;hs!1a@!Xm4;D4<A09jT1'uHkn=9u>mrfk8/1Iec?-)h&au?P0@h&hZUsqHV^](K2UJbU!ni[mi[>O$MAHXhes7M9S::@2*([nM,8>j]7VIFZK=TT,T_]"N(A.=qnY%?a:d,S>q&&FK<`,3AnM].:VNtoC]W)s,q-t1?+EDY/orV@CMlQ96VS>)ZbSi"d=/Ai0W^f?n[(l'e<+qk6l?"9^iF/?/CcYB]uCuM1sAt-;33ZseNJlNFm)NaEmL7/CDEj\dG-9dX61:^?iS1"mq\Ved8@rq-L^V,ZsC3rb947+8>H\WZJF$Tr,Q>ai[TnrFT;-,L7\<_P@'glT/hs]NY1'l7oa=]m_Kq/8X$Jcc#ONiuS_Qm`P_*aT"QP1m?Y*TH`D%q[`45<aWAb7*(ftKeHGFo`&_$(r/@gEls_bIVGh'GnuYC\$NCkF0I$+FWS?=a3Ul.GpWU<\([DRVWO1l2\Lf5l&3DB8/q#(H@s_b>_+J%mG5Gc4+kk=&a/WCtJnEA*dWISj*oCb(BToB$?MN6ipiK`UmpJSkeNX!kebTkFq9-Z"PhQe609AVBca0@)>=B9PYubY2$N:?10_s#cda#&`,+3X,`5ZG%l7G&QkR47l>bcR-jkA^_C4)-Ad%7JnGI\S)U!r1dPPr_osd1<8[IV_X5*cnn`E&-UXYKdm"U=)VDSc4"b]>V'4)nFq[-VZ4MRB:,"@.I>KgGoEi)q-:qskCCZ59=heS@R^i`'c27r-[eV"U2QaL*S6R_]?m>r/10="7')`nfkh(^ah<"%V8'#1P1=0r_1dXsYSRRfLRqrR6[tL.!p4><(q5,]E@t=<8VlMrLE&#8rLS_BOo[sChIl="PpSJ^-YY7[]l=;'j'T(Mi65s%s.Y68&9srcfQ"lOm-1NBTBUq?*.H=nW<aD!":oL+-+(UWN<\)45oFglbd*G@)$TH_`</jh)&2k_*Z=6\gU>bK3J^YsE$$r`QL-L,g[%b>dZaPb1Vll7N'2HiAgU]&ChoDM*ND9'3h!$-8[C!pD+<HSi!kW^T?4r35qbk,JT5etZ4KmV<$G@m)ctW,<$2O4[b!A&@&2pTR>o?BCL4%=/icT/l<8jF5!N3MCRO'#e8Ab8j&+,e?2HZg-(3K@TO"27f9h?L`]X"edL9p:&mM6GH9:G:A$b(_A%=dj]>W=DpQVZ*LgoOukN")CoP*-dA^`l\i4maHD**.9WZs?lUY`"uMPp-<YF?$UJ5JYeNO/FV=4^N[Wr[4?GPp%O1n(K%W22Zf0@E,uih'AV^Q_69f[j>g'VFsG1F,/Z3ND)d?*[Fa7_M9!Qu$Q6h-XCrM,M#mDj5'-`9:&R#M+c!`MmuNLdlI6FdV:@EB/Y?P(JA(W+.R!`K*V;CKQZHh<*/U99X+n2/n+)X#T5/700edKu*DiLk.Q5`@@.`RKR@Kf?p6B4'ISLW+'hXS3EK-K3jcd8\E*_#QEm>K+I*l%=Dt<oBI6r_8gQ<eu0E=1/C8"[!2!/`7C0uI8j:$#CWEl<SXGL]t-s$L+-adbD.uXPlGl&<SV,5AQ2Z4W'SrU`CKAn3WNCE77cP:rJ;.m2s9p-Xg6s+<SujUctqOcDh3V%QMcIo&^>KK8eYY2Tg$(!+4!NUM(#RHbpE,TW!2AbM`ks/W;$1d!]6@40!P_dp=6Vkj\$"P,37F<)Ba6+Hlf,7UIh5Yg:[cZVQC%uT4IgB0j7]%UN6kOF,'+<Keq<ZKCWcjgRlgGiQ(-/>h$or"4#.LIoO;9gO2_g)9k\%(E8"%6_-`,cEQ7)Iq7$Nq^5q'd@sNNdp>$3MZ5j[IGpMQk)J9XY&;qJ\EKX#OK@n".tQ7HfP4,s9f\k;ht=BG335Mog_/k$[7KeI8FT*eSW3u2[>9["#`"[9]=]H<_ce=s,1.%V?'3qU;7@de@3n7A[Nmi4"JR+PNI^nC!KJ)eFe)S@&3C%%[C@87DRZ$8TMNbs5pcQud$:qM2]'1)?TbYV2o/"PloN)&!*I:ug#+6jB5e_WXW-V+I9_Q+=i+)`;uKm<Ej+Jse_#0=5USHDm#I7`0])D=n.qYSm!j0*)i+~>endstream
endobj
20 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1471
>>
stream
Gau0CD/\/e&H;*)E?Dk0VF?B:UgP%$'[>"!"5:cI^(`7<M,)PkUaF5UrU4s[Rq:2pSKNQI#Sc`fGL,lB4AkV;8"9NM!H>Gsq)&>u@'KdU$&?JIGe]XD46^?$2\?_N]dJ9QBFk7(!KTAJ,iF4i7o8#%#MMTWBLH<;&b3EikZYdoZb03Jr!&)JY^#1Pm?eQJq1A![][uV29^^lg?T7J6Yn/Oic0f\IF:J8;l=#sIVboPh7=p3oO;RTZ3'K@!Tn'=r:9^arM;BlF@m*\>2?a5R',HO5j`!$OK&mNRKq2-Z7O3p]N3M8_'9^5Q;p4GQ6ifEEKX&V=5o]cAI.!!SHU/?#%-PSNN<KkCGj'uIS37,t<:al01iPi;cg`h$$/?6W5s_VhD8G(AAZ8RPb3F>PmhM(tXB28K7e#eVeO(WF3mNVkC_InSo3K`l#+WW5>d;eV^gk,2G"2#8K%1eKl@-DU8o8?raL=&Q%N-`',=W_HbWVF13\THOS:'d%@/mT*,A2\OCP9t;RJ.F(bY5BDNd\?,1GW>YOToVo)mF7D.P'h[:nkOWTU`\Trnj`u<gg7?.sR%d?Pu",=WDVW`>sB3nA<7\2+1FR#bj%d,]`4Zi#TtD9ko.%q^ddY'rKQ=E%-Qj]6ro;/L`Wn##+OLm0,`<+f'Gr/l;/?Fe/]-fij[!;?1ti1MpohOCsU]DIrC3FF!sZNX8e,'[WX8^D;1D^9KqF;gSm\3Rc./'%QT%Vmon"\5SZ@M/S=B/2?J5Wg<i:<AhCR24h;>;,pV^.ag+0`Ih5lG`K;/,EXmHW<0'JB&$UC7*T*NRpl-Wi/3P.0XKea5;=d@#NmS$Kp_UM.hnhJeXL,^%3CIrd:^SMJ#5?-InZH#)VPU3QQXa$dtk<\VT'fk$DrElUtKM3)dVG"`)N^ia%Qt69>(A4C,eKsQR:2&R(Pk::'"/:#!ToimkoYt.UAnj/W2/_>*V)"m4q[q=^kB(=_;</n7iqj^nj\&,K0B^>]L)k\9(4'_-:UIf?@NrZTdW)J[a!B+D"tu1OTKtA#SP$?/OdI!gfND*2;F;rd@#a^9F9p?ePmrfNrW:kW4TpdHDsjS_)`>E+Eu7OLq1+;`O-A`/^&6Y2\nDq#(?UU1MG@%tf>90?6,I!!DmWa'jVI+#bLDUl-(QOLoOI&.uQ$q9X)Nn3S<ie9Ld1[_Cobq48_AE=0utaJT_6kVbT`Dk2&>bId/\g[.G7*WP6pqRY7^W_g#sJ!DJmTRuL<"731@@@L"`@WX)48jD%?8WBG6r&3$qE`:j7%DOjq;nHmfVLPr`gn?FW-ZL6pGZ-Wl-UR`kq#=O^JApRD-hS)A(ACY8HfZg,7`XIR^1alHR"#TST-UihlS=`MHX=L!=_0U:o1T_*C)[h1$*82jnM>Sc-gj?9R2=GFS%XBfV$*qOUMC[A`6YZGStU.c<G+%q<AJc!G5eX/poO@;,IRJi+S6`00_l7ERqE3~>endstream
endobj
xref
0 21
0000000000 65535 f
0000000073 00000 n
0000000154 00000 n
0000000261 00000 n
0000000373 00000 n
0000000488 00000 n
0000000593 00000 n
0000000676 00000 n
0000000753 00000 n
0000000958 00000 n
0000001163 00000 n
0000001369 00000 n
0000001575 00000 n
0000001781 00000 n
0000001851 00000 n
0000002161 00000 n
0000002248 00000 n
0000005026 00000 n
0000007323 00000 n
0000009664 00000 n
0000012691 00000 n
trailer
<<
/ID
[<f5c24091d994f2afdef500eb9f389ba5><f5c24091d994f2afdef500eb9f389ba5>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 14 0 R
/Root 13 0 R
/Size 21
>>
startxref
14254
%%EOF

View File

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

View File

@@ -96,6 +96,7 @@ const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDe
const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
const AdminBranches = lazy(() => import("@/pages/admin/AdminBranches"));
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
@@ -124,6 +125,7 @@ const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegist
// Courseware pages
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
const TeacherCourseInsights = lazy(() => import("@/pages/teacher/TeacherCourseInsights"));
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
@@ -181,6 +183,12 @@ const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
const FaqPage = lazy(() => import("@/pages/FaqPage"));
const NotFound = lazy(() => import("@/pages/NotFound"));
// Phase 2/3/4 (2026-04-30) — live sessions, Turnitin, test session console.
const LiveSessionsPage = lazy(() => import("@/pages/LiveSessionsPage"));
const LiveSessionRoom = lazy(() => import("@/pages/LiveSessionRoom"));
const TurnitinSettings = lazy(() => import("@/pages/admin/TurnitinSettings"));
const TeacherTestSessionConsole = lazy(() => import("@/pages/teacher/TeacherTestSessionConsole"));
function StudentSubscriptionPlaceholder() {
const navigate = useNavigate();
return (
@@ -237,6 +245,8 @@ const App = () => (
<Route element={<ProtectedRoute />}>
<Route path="/onboarding" element={<OnboardingWizard />} />
<Route path="/live" element={<LiveSessionsPage />} />
<Route path="/live/:id" element={<LiveSessionRoom />} />
</Route>
{/* Student routes */}
@@ -296,6 +306,8 @@ const App = () => (
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
<Route path="/teacher/courses/:courseId/insights" element={<TeacherCourseInsights />} />
<Route path="/teacher/exam/:assignmentId/console" element={<TeacherTestSessionConsole />} />
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
<Route path="/teacher/profile" element={<TeacherProfile />} />
@@ -320,15 +332,17 @@ const App = () => (
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
{/* Original platform dashboard */}
<Route path="/admin/platform" element={<AdminDashboard />} />
{/* LMS pages */}
{/* LMS pages (entity-scoped, includes branch management) */}
<Route path="/admin/courses" element={<AdminCourses />} />
<Route path="/admin/students" element={<AdminStudents />} />
<Route path="/admin/teachers" element={<AdminTeachers />} />
<Route path="/admin/branches" element={<AdminBranches />} />
<Route path="/admin/batches" element={<AdminBatches />} />
<Route path="/admin/batches/:id" element={<AdminBatchDetail />} />
<Route path="/admin/timetable" element={<AdminTimetable />} />
<Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/turnitin" element={<TurnitinSettings />} />
<Route path="/admin/profile" element={<AdminProfileLms />} />
<Route path="/admin/privacy" element={<PrivacyCenter />} />
{/* Original academic pages */}

View File

@@ -54,6 +54,8 @@ const lmsItems: NavItem[] = [
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
{ titleKey: "nav.branches", url: "/admin/branches", icon: GitBranch },
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
@@ -97,7 +99,6 @@ const institutionalItems: NavItem[] = [
const managementItems: NavItem[] = [
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
@@ -124,6 +125,7 @@ const supportItems: NavItem[] = [
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
{ titleKey: "nav.turnitin", url: "/admin/turnitin", icon: Settings },
];
// ============= Reusable sidebar group =============
@@ -167,7 +169,7 @@ function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem
const routeLabelKeys: Record<string, string> = {
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
platform: "breadcrumb.platform", courses: "nav.courses",
students: "nav.students", teachers: "nav.teachers", batches: "nav.batches",
students: "nav.students", teachers: "nav.teachers", branches: "nav.branches", batches: "nav.batches",
timetable: "nav.timetable", reports: "nav.reports",
assignments: "nav.assignments", examsList: "nav.examsList",
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",

View File

@@ -22,12 +22,12 @@ const mainItems = [
{ title: "Rubrics", url: "/rubrics", icon: BookOpen },
{ title: "Generation", url: "/generation", icon: Wand2 },
{ title: "Approval Workflows", url: "/approval-workflows", icon: GitBranch },
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
];
const managementItems = [
{ title: "Users", url: "/users", icon: Users },
{ title: "Entities", url: "/entities", icon: Building2 },
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
];
const reportItems = [
@@ -103,7 +103,7 @@ export function AppSidebar() {
<SidebarSeparator />
<SidebarContent>
<SidebarNavGroup label="Academic" items={mainItems} />
<SidebarNavGroup label="LMS" items={mainItems} />
<SidebarNavGroup label="Management" items={managementItems} />
<SidebarNavGroup label="Reports" items={reportItems} />

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,867 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Award,
CheckCircle2,
Eye,
GripVertical,
Lightbulb,
Loader2,
RotateCcw,
Save,
XCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import { coursePlanService } from "@/services/coursePlan.service";
import type {
CoursePlanMaterial,
GapFillExercise,
MatchPairsExercise,
MultipleChoiceExercise,
ReorderWordsExercise,
ShortAnswerExercise,
TransformationExercise,
WorkbookAttempt,
WorkbookAttemptScoreItem,
WorkbookBody,
WorkbookExercise,
} from "@/types";
/**
* Workbook renderer for `material_type === "interactive_workbook"`.
*
* Modes:
* - `student` → answers persist server-side (graded, scored, saved).
* - `preview` → no persistence, no Submit; "Show answers" toggle reveals
* the key so the admin can verify the v2 generator's output.
*
* The renderer also accepts a `WorkbookBody` directly (for skill bodies
* that embed `interactive_workbook: { exercises: [...] }`), so it can be
* reused inside other material renderers without duplicating the logic.
*/
export type WorkbookMode = "student" | "preview";
interface InteractiveWorkbookProps {
material: Pick<
CoursePlanMaterial,
"id" | "plan_id" | "body" | "title" | "summary" | "material_type"
>;
/** Optional override — when present we read exercises from here instead
* of `material.body`. Used by skill bodies that embed a workbook one
* level deep. */
bodyOverride?: WorkbookBody;
mode?: WorkbookMode;
}
type AnswerValue = string | string[] | number[][] | undefined;
function toExercises(body: unknown): WorkbookExercise[] {
if (!body || typeof body !== "object") return [];
const b = body as Record<string, unknown>;
if (Array.isArray(b.exercises)) {
return b.exercises as WorkbookExercise[];
}
// Skill bodies sometimes nest the workbook under `interactive_workbook`.
const nested = b.interactive_workbook as { exercises?: WorkbookExercise[] } | undefined;
if (nested && Array.isArray(nested.exercises)) {
return nested.exercises;
}
return [];
}
function toLessonPlan(body: unknown): WorkbookBody["lesson_plan"] | undefined {
if (!body || typeof body !== "object") return undefined;
const b = body as Record<string, unknown>;
if (b.lesson_plan && typeof b.lesson_plan === "object") {
return b.lesson_plan as WorkbookBody["lesson_plan"];
}
const nested = b.interactive_workbook as { lesson_plan?: WorkbookBody["lesson_plan"] } | undefined;
return nested?.lesson_plan;
}
// Local copies of the backend grading functions for instant per-exercise
// feedback while typing. The authoritative score still comes from the
// server when we POST — these are only used for the "Check" button.
function normText(s: unknown): string {
if (s == null) return "";
return String(s).replace(/\s+/g, " ").trim().toLowerCase();
}
function normPunct(s: unknown): string {
return normText(s).replace(/[\s.?!,]+$/u, "");
}
function globMatch(pattern: string, value: string): boolean {
const pat = normText(pattern);
const val = normText(value);
if (!pat) return false;
if (!pat.includes("*")) return pat === val;
const rx = new RegExp(
"^" + pat.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*") + "$",
);
return rx.test(val);
}
function gradeOne(ex: WorkbookExercise, given: AnswerValue): boolean {
if (given === undefined) return false;
switch (ex.type) {
case "gap_fill": {
const accepted = [ex.answer, ...(ex.alt ?? [])];
const n = normText(given as string);
return accepted.some((a) => normText(a) === n);
}
case "multiple_choice": {
const opts = ex.options ?? [];
const ans = ex.answer;
const g = normText(given as string);
if (g === normText(ans)) return true;
if (typeof ans === "string" && ans.length === 1 && /[a-z]/i.test(ans)) {
const idx = ans.toUpperCase().charCodeAt(0) - 65;
if (opts[idx] && normText(opts[idx]) === g) return true;
}
if (typeof given === "string" && given.length === 1 && /[a-z]/i.test(given)) {
const idx = given.toUpperCase().charCodeAt(0) - 65;
if (opts[idx] && normText(opts[idx]) === normText(ans)) return true;
}
return false;
}
case "match_pairs": {
const pairs = (given as number[][]) ?? [];
const expected = ex.answer ?? [];
const setOf = (rows: number[][]) =>
new Set(rows.filter((p) => p.length === 2).map((p) => `${p[0]},${p[1]}`));
const a = setOf(expected);
const b = setOf(pairs);
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
case "reorder_words": {
const value = Array.isArray(given) ? (given as string[]).join(" ") : String(given ?? "");
return normText(value) === normText(ex.answer);
}
case "transformation": {
const accepted = [ex.answer, ...(ex.alt ?? [])];
const n = normPunct(given as string);
return accepted.some((a) => normPunct(a) === n);
}
case "short_answer": {
const accepted = [...(ex.accepted ?? []), ex.answer].filter(Boolean) as string[];
return accepted.some((p) => globMatch(p, given as string));
}
default:
return false;
}
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function InteractiveWorkbook({
material,
bodyOverride,
mode = "student",
}: InteractiveWorkbookProps) {
const exercises = useMemo<WorkbookExercise[]>(
() => toExercises(bodyOverride ?? material.body),
[bodyOverride, material.body],
);
const lessonPlan = useMemo(
() => toLessonPlan(bodyOverride ?? material.body),
[bodyOverride, material.body],
);
const { toast } = useToast();
const [answers, setAnswers] = useState<Record<string, AnswerValue>>({});
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set());
const [showKey, setShowKey] = useState(false);
const [serverScore, setServerScore] = useState<{
correct: number;
total: number;
percent: number;
items?: WorkbookAttemptScoreItem[];
is_final?: boolean;
} | null>(null);
const [saving, setSaving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(mode === "student");
// Load existing attempt (student mode only).
useEffect(() => {
if (mode !== "student") return;
let cancelled = false;
(async () => {
try {
const res = await coursePlanService.myWorkbookAttempt(
material.plan_id,
material.id,
);
if (cancelled) return;
const attempt = res.data;
if (attempt) {
setAnswers((attempt.answers ?? {}) as Record<string, AnswerValue>);
if (attempt.score && "items" in attempt.score) {
setServerScore({
correct: attempt.correct_count,
total: attempt.total_count,
percent: attempt.percent,
items: attempt.score.items,
is_final: attempt.is_final,
});
}
}
} catch {
// Non-fatal — empty workbook on first open.
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [material.id, material.plan_id, mode]);
const isFinal = !!serverScore?.is_final;
const liveCorrect = useMemo(
() => exercises.filter((ex) => gradeOne(ex, answers[ex.id])).length,
[answers, exercises],
);
const livePercent = exercises.length
? Math.round((liveCorrect / exercises.length) * 100)
: 0;
const onChange = (id: string, value: AnswerValue) => {
setAnswers((prev) => ({ ...prev, [id]: value }));
};
const onCheck = (id: string) => {
setRevealedIds((prev) => new Set(prev).add(id));
if (mode === "student") void persist(false);
};
const persist = async (finalize: boolean) => {
if (mode !== "student") return;
if (finalize) setSubmitting(true);
else setSaving(true);
try {
const res = await coursePlanService.saveWorkbookAttempt(
material.plan_id,
material.id,
{ answers, finalize },
);
const attempt: WorkbookAttempt = res.data;
setServerScore({
correct: attempt.correct_count,
total: attempt.total_count,
percent: attempt.percent,
items: attempt.score && "items" in attempt.score ? attempt.score.items : [],
is_final: attempt.is_final,
});
if (finalize) {
toast({
title: "Submitted",
description: `${attempt.correct_count} / ${attempt.total_count} correct (${attempt.percent.toFixed(0)}%)`,
});
}
} catch (err) {
toast({
title: finalize ? "Submit failed" : "Save failed",
description: err instanceof Error ? err.message : String(err),
variant: "destructive",
});
} finally {
setSaving(false);
setSubmitting(false);
}
};
// Debounce per-keystroke saves: every 1.5s after the last change.
const saveTimer = useRef<number | null>(null);
useEffect(() => {
if (mode !== "student" || isFinal || loading) return;
if (saveTimer.current) window.clearTimeout(saveTimer.current);
saveTimer.current = window.setTimeout(() => {
void persist(false);
}, 1500);
return () => {
if (saveTimer.current) window.clearTimeout(saveTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]);
const reset = () => {
setAnswers({});
setRevealedIds(new Set());
};
if (!exercises.length) {
return (
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
This workbook has no exercises yet generate or extract first.
</div>
);
}
return (
<div className="space-y-4">
{mode === "preview" && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-900 flex items-center justify-between">
<span>
<strong>Preview</strong> answers won't be saved. Toggle the
answer key to verify the generated content.
</span>
<Button
type="button"
size="sm"
variant={showKey ? "default" : "outline"}
onClick={() => setShowKey((v) => !v)}
>
<Eye className="mr-1 h-4 w-4" />
{showKey ? "Hide answers" : "Show answers"}
</Button>
</div>
)}
{lessonPlan && Object.values(lessonPlan).some(Boolean) && (
<details className="rounded-lg border bg-background/70 p-3">
<summary className="cursor-pointer text-sm font-medium">
Teacher's lesson plan
</summary>
<div className="mt-2 grid gap-2 sm:grid-cols-2 text-sm">
{(["warmup", "presentation", "controlled_practice", "freer_practice", "exit_ticket"] as const).map(
(k) =>
lessonPlan[k] ? (
<div key={k}>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
{k.replace(/_/g, " ")}
</div>
<div className="leading-6">{lessonPlan[k]}</div>
</div>
) : null,
)}
</div>
</details>
)}
<div className="rounded-lg border bg-background/70 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 text-sm">
<Award className="h-4 w-4 text-primary" />
<strong>{liveCorrect}</strong>
<span className="text-muted-foreground">/ {exercises.length} correct (live)</span>
{serverScore && (
<Badge variant="secondary" className="ml-2">
Server: {serverScore.correct}/{serverScore.total} ({serverScore.percent.toFixed(0)}%)
</Badge>
)}
</div>
{mode === "student" && (
<div className="text-xs text-muted-foreground flex items-center gap-2">
{saving ? (
<>
<Loader2 className="h-3 w-3 animate-spin" /> Saving
</>
) : (
<>
<Save className="h-3 w-3" />{" "}
{serverScore ? "Auto-saved" : "Auto-save on"}
</>
)}
</div>
)}
</div>
<Progress value={livePercent} className="h-2" />
</div>
<ol className="space-y-3 list-none p-0">
{exercises.map((ex, idx) => {
const value = answers[ex.id];
const checked = revealedIds.has(ex.id) || isFinal || showKey;
const correct = checked ? gradeOne(ex, value) : null;
return (
<li
key={ex.id || idx}
className={cn(
"rounded-lg border p-4 bg-background/70 space-y-3",
checked && correct === true && "border-emerald-400 bg-emerald-50/40",
checked && correct === false && "border-rose-400 bg-rose-50/40",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Exercise {idx + 1} · {ex.type.replace(/_/g, " ")}
</div>
{checked && (
<Badge variant={correct ? "default" : "destructive"} className="capitalize">
{correct ? (
<>
<CheckCircle2 className="mr-1 h-3 w-3" /> Correct
</>
) : (
<>
<XCircle className="mr-1 h-3 w-3" /> Try again
</>
)}
</Badge>
)}
</div>
{renderExercise(ex, value, (v) => onChange(ex.id, v), {
disabled: mode === "preview" || isFinal,
showKey: showKey,
})}
{ex.hint && !checked && (
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Lightbulb className="h-3 w-3" /> {ex.hint}
</div>
)}
{checked && (
<div className="text-xs text-muted-foreground space-y-1">
{!correct && (
<div>
Expected: <span className="font-medium">{formatAnswer(ex)}</span>
</div>
)}
{ex.rationale && <div>{ex.rationale}</div>}
</div>
)}
{!isFinal && mode !== "preview" && (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => onCheck(ex.id)}
>
<CheckCircle2 className="mr-1 h-4 w-4" /> Check
</Button>
</div>
)}
</li>
);
})}
</ol>
{mode === "student" && (
<div className="flex items-center justify-between rounded-lg border bg-background/70 p-3">
<Button type="button" variant="ghost" onClick={reset} disabled={isFinal}>
<RotateCcw className="mr-1 h-4 w-4" />
Reset
</Button>
<Button
type="button"
disabled={submitting || isFinal}
onClick={() => persist(true)}
>
{submitting ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Save className="mr-1 h-4 w-4" />
)}
{isFinal ? "Submitted" : "Submit final"}
</Button>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Per-type renderers
// ---------------------------------------------------------------------------
function renderExercise(
ex: WorkbookExercise,
value: AnswerValue,
onChange: (v: AnswerValue) => void,
opts: { disabled?: boolean; showKey?: boolean },
): React.ReactNode {
switch (ex.type) {
case "gap_fill":
return <GapFill ex={ex} value={value as string} onChange={onChange} disabled={opts.disabled} />;
case "multiple_choice":
return (
<MultipleChoice
ex={ex}
value={value as string}
onChange={onChange}
disabled={opts.disabled}
/>
);
case "match_pairs":
return (
<MatchPairs
ex={ex}
value={value as number[][]}
onChange={onChange}
disabled={opts.disabled}
/>
);
case "reorder_words":
return (
<ReorderWords
ex={ex}
value={value as string[]}
onChange={onChange}
disabled={opts.disabled}
/>
);
case "transformation":
return (
<Transformation
ex={ex}
value={value as string}
onChange={onChange}
disabled={opts.disabled}
/>
);
case "short_answer":
return (
<ShortAnswer
ex={ex}
value={value as string}
onChange={onChange}
disabled={opts.disabled}
/>
);
default:
return <p className="text-sm text-muted-foreground">Unsupported exercise type.</p>;
}
}
function formatAnswer(ex: WorkbookExercise): string {
switch (ex.type) {
case "match_pairs":
return (ex.answer ?? [])
.map((p) => `${ex.left[p[0]] ?? "?"}${ex.right[p[1]] ?? "?"}`)
.join(", ");
case "reorder_words":
return ex.answer;
default:
return String((ex as { answer?: unknown }).answer ?? "");
}
}
// ---------------------------------------------------------------------------
// Type-specific input subcomponents
// ---------------------------------------------------------------------------
function GapFill({
ex,
value,
onChange,
disabled,
}: {
ex: GapFillExercise;
value: string | undefined;
onChange: (v: string) => void;
disabled?: boolean;
}) {
// Replace ___ in the stem with an inline input. Falls back to a stem
// label + standalone input when there's no underscore marker.
const parts = ex.stem.split(/_{2,}/);
if (parts.length === 1) {
return (
<div className="space-y-2">
<p className="leading-7">{ex.stem}</p>
<Input
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder="Type your answer"
/>
</div>
);
}
return (
<p className="leading-8 text-base flex flex-wrap items-center gap-1">
{parts.map((part, i) => (
<span key={i} className="inline-flex items-center gap-1">
<span>{part}</span>
{i < parts.length - 1 && (
<Input
type="text"
className="inline-block w-32 align-baseline"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder="…"
/>
)}
</span>
))}
</p>
);
}
function MultipleChoice({
ex,
value,
onChange,
disabled,
}: {
ex: MultipleChoiceExercise;
value: string | undefined;
onChange: (v: string) => void;
disabled?: boolean;
}) {
return (
<div className="space-y-2">
<p className="leading-7">{ex.stem}</p>
<RadioGroup
value={value ?? ""}
onValueChange={onChange}
disabled={disabled}
className="grid gap-2 sm:grid-cols-2"
>
{ex.options.map((opt, i) => {
const id = `${ex.id}_opt_${i}`;
return (
<Label
key={id}
htmlFor={id}
className="flex items-start gap-2 rounded-md border p-2 cursor-pointer hover:bg-muted"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span className="text-sm">{opt}</span>
</Label>
);
})}
</RadioGroup>
</div>
);
}
function MatchPairs({
ex,
value,
onChange,
disabled,
}: {
ex: MatchPairsExercise;
value: number[][] | undefined;
onChange: (v: number[][]) => void;
disabled?: boolean;
}) {
const [selectedLeft, setSelectedLeft] = useState<number | null>(null);
const pairs = value ?? [];
const pairedLeft = new Set(pairs.map((p) => p[0]));
const pairedRight = new Set(pairs.map((p) => p[1]));
const tapLeft = (idx: number) => {
if (disabled) return;
setSelectedLeft(idx);
};
const tapRight = (idx: number) => {
if (disabled || selectedLeft == null) return;
const next = pairs.filter((p) => p[0] !== selectedLeft && p[1] !== idx);
next.push([selectedLeft, idx]);
onChange(next);
setSelectedLeft(null);
};
const clear = (li: number) => {
if (disabled) return;
onChange(pairs.filter((p) => p[0] !== li));
};
return (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="space-y-1">
<div className="text-xs uppercase tracking-wide text-muted-foreground">Match these</div>
{ex.left.map((item, i) => {
const paired = pairs.find((p) => p[0] === i);
return (
<button
type="button"
key={`L${i}`}
onClick={() => (paired ? clear(i) : tapLeft(i))}
disabled={disabled}
className={cn(
"block w-full rounded border px-2 py-1.5 text-left",
selectedLeft === i && "border-primary ring-1 ring-primary",
paired && "bg-muted",
)}
>
<span className="text-muted-foreground mr-1">{i + 1}.</span>
{item}
{paired ? (
<span className="ml-2 text-xs text-muted-foreground">
{ex.right[paired[1]]}
</span>
) : null}
</button>
);
})}
</div>
<div className="space-y-1">
<div className="text-xs uppercase tracking-wide text-muted-foreground">with these</div>
{ex.right.map((item, j) => (
<button
type="button"
key={`R${j}`}
onClick={() => tapRight(j)}
disabled={disabled || selectedLeft == null}
className={cn(
"block w-full rounded border px-2 py-1.5 text-left",
pairedRight.has(j) && "bg-muted",
)}
>
<span className="text-muted-foreground mr-1">{String.fromCharCode(65 + j)}.</span>
{item}
</button>
))}
</div>
<div className="col-span-2 text-xs text-muted-foreground">
Tap a left item, then tap its match on the right. Tap a paired left
item to clear it.
</div>
{!pairedLeft.size && (
<div className="col-span-2 text-xs text-muted-foreground">
{ex.left.length} pair(s) to match.
</div>
)}
</div>
);
}
function ReorderWords({
ex,
value,
onChange,
disabled,
}: {
ex: ReorderWordsExercise;
value: string[] | undefined;
onChange: (v: string[]) => void;
disabled?: boolean;
}) {
// Stateful tap-to-insert: tap a token in the bank to append; tap a
// token in the answer strip to remove. Keeps it touch-friendly.
const order = value ?? [];
const remaining: { tok: string; bankIdx: number }[] = [];
const used: number[] = [];
// Build maps so duplicate tokens stay independently selectable.
const usedCounts = new Map<string, number>();
order.forEach((t) => usedCounts.set(t, (usedCounts.get(t) ?? 0) + 1));
const seenWhileBuilding = new Map<string, number>();
ex.tokens.forEach((tok, idx) => {
const seen = seenWhileBuilding.get(tok) ?? 0;
const usesLeft = (usedCounts.get(tok) ?? 0) - seen;
if (usesLeft > 0) {
used.push(idx);
seenWhileBuilding.set(tok, seen + 1);
} else {
remaining.push({ tok, bankIdx: idx });
}
});
const append = (tok: string) => {
if (disabled) return;
onChange([...order, tok]);
};
const removeAt = (i: number) => {
if (disabled) return;
onChange(order.filter((_, idx) => idx !== i));
};
return (
<div className="space-y-2 text-sm">
<div className="rounded border bg-muted/30 p-2 min-h-10 flex flex-wrap gap-1">
{order.length === 0 ? (
<span className="text-xs text-muted-foreground">Tap tokens below to build the sentence</span>
) : (
order.map((tok, i) => (
<button
type="button"
key={`a${i}`}
onClick={() => removeAt(i)}
disabled={disabled}
className="rounded bg-background border px-2 py-1"
>
<GripVertical className="mr-1 inline h-3 w-3 text-muted-foreground" />
{tok}
</button>
))
)}
</div>
<div className="flex flex-wrap gap-1">
{remaining.map(({ tok, bankIdx }) => (
<button
type="button"
key={`b${bankIdx}`}
onClick={() => append(tok)}
disabled={disabled}
className="rounded border px-2 py-1 hover:bg-muted"
>
{tok}
</button>
))}
</div>
</div>
);
}
function Transformation({
ex,
value,
onChange,
disabled,
}: {
ex: TransformationExercise;
value: string | undefined;
onChange: (v: string) => void;
disabled?: boolean;
}) {
return (
<div className="space-y-2">
<div className="rounded border bg-muted/30 px-3 py-2 text-sm">
<Badge variant="outline" className="mr-2">
{ex.instruction}
</Badge>
<span className="font-medium">{ex.from}</span>
</div>
<Textarea
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
rows={2}
placeholder="Transformed sentence"
/>
</div>
);
}
function ShortAnswer({
ex,
value,
onChange,
disabled,
}: {
ex: ShortAnswerExercise;
value: string | undefined;
onChange: (v: string) => void;
disabled?: boolean;
}) {
return (
<div className="space-y-2">
<p className="leading-7">{ex.stem}</p>
<Textarea
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
rows={3}
placeholder="Type your answer"
/>
</div>
);
}

View File

@@ -1,6 +1,10 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { CoursePlanMaterial } from "@/types";
import type { CoursePlanMaterial, WorkbookBody } from "@/types";
import InteractiveWorkbook, { type WorkbookMode } from "./InteractiveWorkbook";
import ImageLightbox from "@/components/lesson/ImageLightbox";
import InfographicBlock, { inferTone } from "@/components/lesson/InfographicBlock";
export const SKILL_STYLE: Record<string, string> = {
reading: "bg-blue-100 text-blue-800 border-blue-200",
@@ -23,15 +27,68 @@ export function SkillBadge({ skill }: { skill: string }) {
);
}
const IMAGE_KEYS = new Set([
"image", "image_url", "imageurl", "img", "illustration",
"hero_image", "hero", "picture", "thumbnail",
]);
const IMAGES_KEYS = new Set([
"images", "illustrations", "pictures", "gallery",
]);
function isImageUrl(v: unknown): v is string {
if (typeof v !== "string") return false;
if (/^data:image\//.test(v)) return true;
if (/^https?:\/\//i.test(v) && /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(v)) return true;
return false;
}
function renderImage(v: unknown, alt?: string): React.ReactNode {
if (typeof v === "string" && isImageUrl(v)) {
return <ImageLightbox src={v} alt={alt} />;
}
if (v && typeof v === "object" && !Array.isArray(v)) {
const obj = v as Record<string, unknown>;
const src = (obj.url || obj.src || obj.image_url || obj.image) as string | undefined;
const cap = (obj.caption || obj.description || obj.alt) as string | undefined;
if (typeof src === "string" && isImageUrl(src)) {
return <ImageLightbox src={src} alt={alt || cap} caption={cap} />;
}
}
return null;
}
function isLikelyParagraph(s: string): boolean {
return s.length > 80 || /[.!?]\s/.test(s);
}
function renderString(value: string): React.ReactNode {
if (isImageUrl(value)) {
return <ImageLightbox src={value} />;
}
if (!isLikelyParagraph(value)) {
return <p className="leading-7">{value}</p>;
}
return (
<div className="space-y-3 leading-7">
{value.split(/\n{2,}/).map((para, i) => (
<p key={i} className="whitespace-pre-wrap">{para.trim()}</p>
))}
</div>
);
}
function renderAny(value: unknown): React.ReactNode {
if (value == null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
if (typeof value === "string") {
return renderString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return <p className="leading-7">{String(value)}</p>;
}
if (Array.isArray(value)) {
if (value.length === 0) return null;
return (
<ul className="list-disc ps-5 space-y-1">
<ul className="list-disc ps-5 space-y-1.5 leading-7">
{value.map((item, idx) => (
<li key={idx}>{renderAny(item)}</li>
))}
@@ -41,38 +98,159 @@ function renderAny(value: unknown): React.ReactNode {
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
return (
<div className="space-y-3">
{entries.map(([k, v]) => (
<div key={k}>
<h5 className="font-medium capitalize text-sm text-muted-foreground mb-1">
{k.replace(/_/g, " ")}
</h5>
<div className="text-sm">{renderAny(v)}</div>
</div>
))}
<div className="space-y-4">
{entries.map(([k, v]) => renderEntry(k, v))}
</div>
);
}
return null;
}
function renderEntry(key: string, value: unknown): React.ReactNode {
const norm = key.toLowerCase().replace(/[\s-]/g, "_");
const tone = inferTone(key);
const label = key.replace(/_/g, " ");
if (IMAGE_KEYS.has(norm)) {
const node = renderImage(value, label);
if (node) return <div key={key}>{node}</div>;
}
if (IMAGES_KEYS.has(norm) && Array.isArray(value)) {
return (
<div key={key} className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{value.map((item, i) => {
const node = renderImage(item, `${label} ${i + 1}`);
return node
? <div key={i}>{node}</div>
: <div key={i} className="text-sm text-muted-foreground">{renderAny(item)}</div>;
})}
</div>
);
}
if (tone) {
if (Array.isArray(value)) {
const items = value.map((v) =>
typeof v === "string" ? v
: (v && typeof v === "object" && "label" in (v as object) && "value" in (v as object))
? v as { label?: string; value?: string }
: { value: typeof v === "string" ? v : JSON.stringify(v) },
);
return (
<InfographicBlock
key={key}
title={titleCase(label)}
tone={tone}
items={items}
/>
);
}
return (
<InfographicBlock
key={key}
title={titleCase(label)}
tone={tone}
>
{renderAny(value)}
</InfographicBlock>
);
}
return (
<div key={key}>
<h5 className="font-semibold capitalize text-sm text-foreground/80 mb-1.5">
{label}
</h5>
<div className="text-[0.95rem]">{renderAny(value)}</div>
</div>
);
}
function titleCase(s: string): string {
return s.replace(/\w\S*/g, (w) => w[0].toUpperCase() + w.slice(1));
}
type MaterialForBook = Pick<
CoursePlanMaterial,
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
>;
export default function MaterialBookView({
material,
mode = "student",
}: {
material: Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
// The legacy callsites only pass body/body_text/material_type, so we
// tolerate that by making id/plan_id optional in the prop type even
// though the types/index file marks them as required. Required props
// are validated at runtime when interactive_workbook is dispatched.
material: Partial<MaterialForBook> &
Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
mode?: WorkbookMode;
}) {
const body = material.body ?? {};
const hasStructured = Object.keys(body).length > 0;
// Dispatch to the interactive renderer for workbook materials. The
// skill-bodied workbooks (grammar/vocabulary that embed their own
// `interactive_workbook` block) render via the generic walker AND
// the embedded workbook below for the exercises portion.
if (
material.material_type === "interactive_workbook" &&
typeof material.id === "number" &&
typeof material.plan_id === "number"
) {
return (
<InteractiveWorkbook
material={material as MaterialForBook}
mode={mode}
/>
);
}
// For grammar / vocabulary that embed an interactive workbook inside
// their body, we render the prose first and then the workbook UI so
// students can practise without leaving the lesson.
const embeddedWorkbook = (body as { interactive_workbook?: WorkbookBody })
.interactive_workbook;
const hasEmbedded =
embeddedWorkbook &&
Array.isArray(embeddedWorkbook.exercises) &&
embeddedWorkbook.exercises.length > 0 &&
typeof material.id === "number" &&
typeof material.plan_id === "number";
return (
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
<div className="rounded-2xl border bg-card p-5 sm:p-6 space-y-5 shadow-sm leading-relaxed">
{hasStructured ? (
renderAny(body)
renderAny(stripEmbedded(body))
) : (
<p className="text-sm whitespace-pre-wrap leading-7">
{material.body_text || "No content available yet."}
</p>
<div className="space-y-3 text-[0.95rem] whitespace-pre-wrap leading-7">
{(material.body_text || "No content available yet.")
.split(/\n{2,}/)
.map((p, i) => <p key={i}>{p}</p>)}
</div>
)}
{hasEmbedded && (
<div className="border-t pt-4">
<div className="text-sm font-semibold mb-3 flex items-center gap-2">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
Practice
</div>
<InteractiveWorkbook
material={material as MaterialForBook}
bodyOverride={embeddedWorkbook!}
mode={mode}
/>
</div>
)}
</div>
);
}
function stripEmbedded(body: Record<string, unknown>): Record<string, unknown> {
if (!body || typeof body !== "object") return body;
if (!("interactive_workbook" in body)) return body;
const { interactive_workbook: _drop, ...rest } = body;
return rest;
}

View File

@@ -0,0 +1,390 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
BookOpen,
Calendar,
ClipboardList,
Headphones,
Image as ImageIcon,
Library,
Link2,
MessageSquare,
Mic,
Music,
PenSquare,
Sparkles,
Type,
Video,
type LucideIcon,
} from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { withAuthQuery } from "@/lib/api-client";
import MaterialBookView, {
SkillBadge,
} from "@/components/coursePlan/MaterialBookView";
import type { WorkbookMode } from "@/components/coursePlan/InteractiveWorkbook";
import type {
CoursePlan,
CoursePlanMaterial,
CoursePlanMedia,
CoursePlanWeek,
} from "@/types";
const SKILL_ICONS: Record<string, LucideIcon> = {
reading: BookOpen,
writing: PenSquare,
listening: Headphones,
speaking: Mic,
grammar: Type,
vocabulary: Library,
integrated: MessageSquare,
};
/**
* Read-only renderer for a course plan that BOTH the student page and
* the admin "View as Student" preview share. The only difference is
* the workbook `mode`:
*
* - `mode="student"` → answers persist server-side (real attempts).
* - `mode="preview"` → InteractiveWorkbook runs in preview mode (no
* persistence) and surfaces an "Show answer key" toggle so admins
* can verify the v2 generator's output.
*
* Materials are rendered through `MaterialBookView`, which dispatches
* `interactive_workbook` materials into the live workbook component.
*/
export interface PlanReaderProps {
plan: CoursePlan;
mode?: WorkbookMode;
/** Heading level for accessibility — admins use this inside their own
* Card; students use it as the page's main grid block. Defaults to 'h3'. */
headingClassName?: string;
}
export default function PlanReader({ plan, mode = "student" }: PlanReaderProps) {
const { t } = useTranslation();
const materialsByWeek = useMemo(() => {
const map = new Map<number, CoursePlanMaterial[]>();
if (!plan.materials) return map;
for (const m of plan.materials) {
const list = map.get(m.week_number) ?? [];
list.push(m);
map.set(m.week_number, list);
}
return map;
}, [plan.materials]);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<CardTitle className="text-2xl">{plan.name}</CardTitle>
{plan.description && (
<CardDescription className="max-w-3xl">
{plan.description}
</CardDescription>
)}
</div>
<div className="flex gap-2 flex-wrap">
<Badge variant="secondary" className="uppercase">
{plan.cefr_level}
</Badge>
<Badge variant="outline">
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
</Badge>
<Badge variant="outline">
{t("coursePlan.hoursPerWeek", {
count: plan.contact_hours_per_week,
})}
</Badge>
</div>
</div>
{plan.assignment && (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
{plan.assignment.due_date
? t("coursePlan.student.due", { date: plan.assignment.due_date })
: t("coursePlan.student.noDue")}
{plan.assignment.assigned_by_name && (
<span>
·{" "}
{t("coursePlan.student.assignedBy", {
name: plan.assignment.assigned_by_name,
})}
</span>
)}
</p>
)}
{plan.assignment?.message && (
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
{plan.assignment.message}
</p>
)}
</CardHeader>
</Card>
{plan.objectives && plan.objectives.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-primary" />
{t("coursePlan.sections.objectives")}
</CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-1 text-sm">
{plan.objectives.map((o, i) => (
<li key={i}>{o}</li>
))}
</ol>
</CardContent>
</Card>
)}
{plan.weeks && plan.weeks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("coursePlan.sections.delivery")}
</CardTitle>
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
</CardHeader>
<CardContent>
<Accordion type="multiple" className="w-full">
{plan.weeks.map((w) => (
<ReaderWeek
key={w.id}
week={w}
materials={materialsByWeek.get(w.week_number) ?? []}
mode={mode}
/>
))}
</Accordion>
</CardContent>
</Card>
)}
</div>
);
}
function ReaderWeek({
week,
materials,
mode,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
mode: WorkbookMode;
}) {
const { t } = useTranslation();
const [skillFilter, setSkillFilter] = useState<string>("all");
const skills = useMemo(
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
[materials],
);
const filteredMaterials = useMemo(
() =>
materials.filter(
(m) => skillFilter === "all" || m.skill === skillFilter,
),
[materials, skillFilter],
);
return (
<AccordionItem value={String(week.week_number)}>
<AccordionTrigger className="text-left">
<div className="flex-1 flex items-center gap-3 min-w-0">
<Badge variant="outline" className="shrink-0">
{t("coursePlan.weekN", { n: week.week_number })}
</Badge>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{week.focus || week.unit || "—"}
</div>
{week.date_label && (
<div className="text-xs text-muted-foreground truncate">
{week.date_label}
</div>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-3">
{skills.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
<Button
size="sm"
variant={skillFilter === "all" ? "default" : "outline"}
onClick={() => setSkillFilter("all")}
>
{t("common.all", "All")}
</Button>
{skills.map((s) => (
<Button
key={s}
size="sm"
variant={skillFilter === s ? "default" : "outline"}
onClick={() => setSkillFilter(s)}
className="capitalize"
>
{s}
</Button>
))}
</div>
)}
{materials.length === 0 && (
<p className="text-sm italic text-muted-foreground">
{t("coursePlan.media.noMedia")}
</p>
)}
{filteredMaterials.map((m) => (
<ReaderMaterial key={m.id} material={m} mode={mode} />
))}
</AccordionContent>
</AccordionItem>
);
}
function ReaderMaterial({
material,
mode,
}: {
material: CoursePlanMaterial;
mode: WorkbookMode;
}) {
const { t } = useTranslation();
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<Icon className="h-4 w-4 text-primary" />
<CardTitle className="text-base flex-1 min-w-0">
{material.title}
</CardTitle>
<Badge variant="outline" className="text-[10px]">
{t(
`coursePlan.materialType.${material.material_type}`,
material.material_type,
)}
</Badge>
<SkillBadge skill={material.skill} />
<GroundingBadge material={material} />
</div>
{material.summary && (
<CardDescription>{material.summary}</CardDescription>
)}
{material.share_date && (
<CardDescription>
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-3">
{(material.media ?? []).map((m) => (
<ReaderMediaTile key={m.id} media={m} />
))}
<MaterialBookView material={material} mode={mode} />
</CardContent>
</Card>
);
}
function ReaderMediaTile({ media }: { media: CoursePlanMedia }) {
const url = withAuthQuery(media.preview_url || media.download_url || "");
if (!url) return null;
return (
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
<div className="flex items-center gap-2">
{media.kind === "audio" && <Music className="h-4 w-4" />}
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
{media.kind === "video" && <Video className="h-4 w-4" />}
<span className="capitalize text-xs text-muted-foreground">
{media.kind}
</span>
</div>
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
{media.kind === "image" && (
<img
src={url}
alt={media.title}
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
/>
)}
{media.kind === "video" && (
<video src={url} controls className="w-full rounded-md max-h-72" />
)}
</div>
);
}
/** Pill that surfaces RAG / extraction provenance for one material. */
export function GroundingBadge({ material }: { material: CoursePlanMaterial }) {
const grounded = material.grounded_on ?? [];
const extracted = material.extracted_from ?? null;
if (!grounded.length && !extracted) return null;
const total =
grounded.reduce((acc, g) => acc + (g.chunks_used || 0), 0) +
(extracted ? 1 : 0);
return (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>
<Badge variant="secondary" className="cursor-help">
<Link2 className="mr-1 h-3 w-3" />
Grounded on {total} reference{total === 1 ? "" : "s"}
</Badge>
</HoverCardTrigger>
<HoverCardContent className="w-72 text-xs">
{grounded.length > 0 && (
<div className="space-y-1">
<div className="font-medium text-sm">RAG sources</div>
<ul className="space-y-1">
{grounded.map((g) => (
<li key={g.source_id} className="flex justify-between gap-2">
<span className="truncate">{g.title || `Source #${g.source_id}`}</span>
<span className="text-muted-foreground">{g.chunks_used} chunk(s)</span>
</li>
))}
</ul>
</div>
)}
{extracted && (
<div className={grounded.length ? "mt-3 border-t pt-2" : ""}>
<div className="font-medium text-sm">Extracted from</div>
<div className="text-muted-foreground">
{extracted.source_title || `Source #${extracted.source_id}`}
{extracted.page_hint ? ` · ${extracted.page_hint}` : ""}
</div>
{extracted.topic_keywords && extracted.topic_keywords.length > 0 && (
<div className="mt-1 text-muted-foreground">
Topics: {extracted.topic_keywords.slice(0, 5).join(", ")}
</div>
)}
</div>
)}
</HoverCardContent>
</HoverCard>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,8 +61,12 @@ const ar: Translations = {
courses: "الدورات",
myCourses: "دوراتي",
myCoursePlans: "خططي الدراسية",
myLearning: "تعلّمي",
liveSessions: "الجلسات المباشرة",
turnitin: "تيرنيتن",
students: "الطلاب",
teachers: "المعلمون",
branches: "الفروع",
batches: "الدفعات",
timetable: "الجدول الدراسي",
reports: "التقارير",
@@ -203,6 +207,9 @@ const ar: Translations = {
averageGrade: "متوسط الدرجات",
totalChapters: "إجمالي الفصول",
myCourses: "دوراتي",
myCoursePlans: "خططي الدراسية",
myLearning: "تعلّمي",
noCoursePlans: "لم يتم إسناد أي خطة دراسية لك بعد.",
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
quickActions: "إجراءات سريعة",

View File

@@ -108,8 +108,12 @@ const en: Translations = {
courses: "Courses",
myCourses: "My Courses",
myCoursePlans: "My Course Plans",
myLearning: "My Learning",
liveSessions: "Live Sessions",
turnitin: "Turnitin",
students: "Students",
teachers: "Teachers",
branches: "Branches",
batches: "Batches",
timetable: "Timetable",
reports: "Reports",
@@ -250,6 +254,8 @@ const en: Translations = {
averageGrade: "Average Grade",
totalChapters: "Total Chapters",
myCourses: "My Courses",
myCoursePlans: "My Course Plans",
noCoursePlans: "No course plans assigned yet.",
noEnrolledCourses: "No enrolled courses yet.",
chaptersMaterials: "{{chapters}} chapters · {{materials}} materials",
quickActions: "Quick Actions",

View File

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

View File

@@ -283,9 +283,9 @@ export type QueryParamValue =
export type QueryParams = object;
const ENTITY_QUERY_SCOPE_RE =
/^\/(courses|students|teachers|batches|student\/my-courses|ai\/course-plan)(\/|$)/;
/^\/(courses|students|teachers|batches|branches|classrooms|student\/my-courses|ai\/course-plan)(\/|$)/;
const ENTITY_BODY_SCOPE_RE =
/^\/(courses|students|teachers|batches|ai\/course-plan)(\/|$)/;
/^\/(courses|students|teachers|batches|branches|classrooms|ai\/course-plan)(\/|$)/;
function buildUrl(path: string, params?: QueryParams): string {
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
@@ -409,10 +409,11 @@ export const api = {
});
},
async delete<T>(path: string): Promise<T> {
async delete<T>(path: string, payload?: Record<string, unknown>): Promise<T> {
return performRequest<T>(buildUrl(path), {
method: "DELETE",
headers: buildHeaders(),
body: payload ? JSON.stringify(payload) : undefined,
});
},

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,34 +4,106 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useBatches, useCourses } from "@/hooks/queries";
import { useBatches, useCourses, useTeachers } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { classroomsService } from "@/services/classrooms.service";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2 } from "lucide-react";
import { Search, Plus, Trash2, Pencil } from "lucide-react";
import { Link } from "react-router-dom";
import { useToast } from "@/hooks/use-toast";
import AiBatchOptimizer from "@/components/ai/AiBatchOptimizer";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner";
type BatchForm = {
name: string;
course_id: string;
course_section_id: string;
term_key: string;
classroom_id: string;
start_date: string;
end_date: string;
max_students: string;
teacher_ids: number[];
};
const EMPTY_FORM: BatchForm = {
name: "",
course_id: "",
course_section_id: "",
term_key: "",
classroom_id: "",
start_date: "",
end_date: "",
max_students: "30",
teacher_ids: [],
};
export default function AdminBatches() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" });
const [editOpen, setEditOpen] = useState(false);
const [editingBatchId, setEditingBatchId] = useState<number | null>(null);
const [form, setForm] = useState<BatchForm>(EMPTY_FORM);
const [editForm, setEditForm] = useState<BatchForm>(EMPTY_FORM);
const { toast } = useToast();
const qc = useQueryClient();
const { data: batchesData, isLoading } = useBatches();
const { data: coursesData } = useCourses({ size: 200 });
const { data: teachersData } = useTeachers({ size: 200 });
const classroomsQ = useQuery({
queryKey: ["lms-classrooms-light"],
queryFn: async () => (await classroomsService.list({ size: 200 })).items,
});
const createSectionsQ = useQuery({
queryKey: ["course-sections", form.course_id],
queryFn: () =>
form.course_id
? lmsService.listCourseSections(Number(form.course_id))
: Promise.resolve({ items: [], total: 0 }),
enabled: !!form.course_id,
});
const editSectionsQ = useQuery({
queryKey: ["course-sections", editForm.course_id],
queryFn: () =>
editForm.course_id
? lmsService.listCourseSections(Number(editForm.course_id))
: Promise.resolve({ items: [], total: 0 }),
enabled: !!editForm.course_id,
});
const batches = batchesData?.items ?? [];
const courses = coursesData?.items ?? [];
const teachers = teachersData?.items ?? [];
const classrooms = classroomsQ.data ?? [];
const createSections = createSectionsQ.data?.items ?? [];
const editSections = editSectionsQ.data?.items ?? [];
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => lmsService.createBatch(data as never),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); toast({ title: "Batch created" }); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" }); },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setCreateOpen(false);
setForm(EMPTY_FORM);
toast({ title: "Batch created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setEditOpen(false);
setEditingBatchId(null);
setEditForm(EMPTY_FORM);
toast({ title: "Batch updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
@@ -47,9 +119,26 @@ export default function AdminBatches() {
}
}
function openEdit(batch: (typeof batches)[number]) {
setEditingBatchId(batch.id);
setEditForm({
name: batch.name || "",
course_id: String(batch.course_id || ""),
course_section_id: batch.course_section_id ? String(batch.course_section_id) : "",
term_key: batch.term_key || "",
classroom_id: batch.classroom_id ? String(batch.classroom_id) : "",
start_date: batch.start_date || "",
end_date: batch.end_date || "",
max_students: String(batch.capacity || 30),
teacher_ids: batch.teacher_ids || [],
});
setEditOpen(true);
}
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const filtered = batches.filter(b => b.name.toLowerCase().includes(search.toLowerCase()));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -62,7 +151,81 @@ export default function AdminBatches() {
</div>
<AiTipBanner context="admin-batches" variant="recommendation" />
<div className="relative max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search batches..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div>
<Card><CardContent className="pt-6"><Table><TableHeader><TableRow><TableHead>Batch</TableHead><TableHead>Course</TableHead><TableHead>Teacher</TableHead><TableHead>Students</TableHead><TableHead>Schedule</TableHead><TableHead>Status</TableHead><TableHead></TableHead></TableRow></TableHeader><TableBody>{filtered.map(b => (<TableRow key={b.id}><TableCell className="font-medium">{b.name}</TableCell><TableCell>{b.course_name}</TableCell><TableCell>{b.teacher_name}</TableCell><TableCell>{b.student_count}/{b.capacity}</TableCell><TableCell className="text-xs">{b.schedule}</TableCell><TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell><TableCell><div className="flex gap-1"><Button size="sm" variant="ghost" asChild><Link to={`/admin/batches/${b.id}`}>View</Link></Button><Button variant="ghost" size="icon" onClick={() => handleDelete(b.id, b.name)}><Trash2 className="h-4 w-4 text-destructive" /></Button></div></TableCell></TableRow>))}</TableBody></Table></CardContent></Card>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Batch</TableHead>
<TableHead>Course</TableHead>
<TableHead>Section</TableHead>
<TableHead>Term</TableHead>
<TableHead>Classroom</TableHead>
<TableHead>Teachers</TableHead>
<TableHead>Students</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-medium">{b.name}</TableCell>
<TableCell>{b.course_name}</TableCell>
<TableCell>
{b.course_section_name ? (
<Badge variant="outline" className="font-mono text-xs">
{b.course_section_code || b.course_section_name}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{b.term_key || "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{b.classroom_name || "—"}
</TableCell>
<TableCell>
{b.teacher_names?.length ? b.teacher_names.join(", ") : b.teacher_name || "—"}
</TableCell>
<TableCell>
{b.student_count}/{b.capacity}
</TableCell>
<TableCell className="text-xs">{b.schedule}</TableCell>
<TableCell>
<Badge
variant={b.status === "active" ? "default" : "secondary"}
className="capitalize"
>
{b.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="sm" variant="ghost" asChild>
<Link to={`/admin/batches/${b.id}`}>View</Link>
</Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(b)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(b.id, b.name)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Batch</DialogTitle></DialogHeader>
@@ -70,7 +233,16 @@ export default function AdminBatches() {
<div className="space-y-2"><Label>Batch Name *</Label><Input placeholder="e.g. IELTS Morning B" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Course *</Label>
<Select value={form.course_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, course_id: v === "__none__" ? "" : v }))}>
<Select
value={form.course_id || "__none__"}
onValueChange={(v) =>
setForm((f) => ({
...f,
course_id: v === "__none__" ? "" : v,
course_section_id: "",
}))
}
>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
@@ -78,15 +250,233 @@ export default function AdminBatches() {
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Section (optional)</Label>
<Select
value={form.course_section_id || "__none__"}
onValueChange={(v) =>
setForm((f) => ({ ...f, course_section_id: v === "__none__" ? "" : v }))
}
disabled={!form.course_id}
>
<SelectTrigger>
<SelectValue placeholder="Whole course" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Whole course (no section)</SelectItem>
{createSections.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.code ? `${s.code} · ${s.name}` : s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
Pick a course section template to keep batches isolated per group.
</p>
</div>
<div className="space-y-2">
<Label>Term Key (optional)</Label>
<Input
placeholder="e.g. 2026-T1"
value={form.term_key}
onChange={(e) => setForm((f) => ({ ...f, term_key: e.target.value }))}
/>
<p className="text-[11px] text-muted-foreground">
One batch per (classroom × course × section × term).
</p>
</div>
</div>
<div className="space-y-2">
<Label>Classroom (optional)</Label>
<Select value={form.classroom_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, classroom_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="No classroom" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No classroom</SelectItem>
{classrooms.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.name} ({c.student_count} students)</SelectItem>)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Selecting a classroom links the batch to that homeroom. Use the Classrooms page to auto-enroll the roster.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={form.start_date} onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={form.end_date} onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
</div>
<div className="space-y-2"><Label>Capacity</Label><Input type="number" placeholder="30" value={form.max_students} onChange={e => setForm(f => ({ ...f, max_students: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Teachers (multi-select)</Label>
<div className="max-h-40 overflow-y-auto rounded border p-2 space-y-1">
{teachers.map((t) => (
<label key={t.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
<Checkbox
checked={form.teacher_ids.includes(t.id)}
onCheckedChange={() =>
setForm((prev) => ({
...prev,
teacher_ids: prev.teacher_ids.includes(t.id)
? prev.teacher_ids.filter((id) => id !== t.id)
: [...prev.teacher_ids, t.id],
}))
}
/>
<span className="text-sm">{t.name}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMutation.isPending || !form.name || !form.course_id || !form.start_date || !form.end_date} onClick={() => createMutation.mutate({ name: form.name, course_id: Number(form.course_id), start_date: form.start_date, end_date: form.end_date, max_students: Number(form.max_students) || 30 })}>Create</Button>
<Button
disabled={
createMutation.isPending ||
!form.name ||
!form.course_id ||
!form.start_date ||
!form.end_date
}
onClick={() =>
createMutation.mutate({
name: form.name,
course_id: Number(form.course_id),
course_section_id: form.course_section_id ? Number(form.course_section_id) : undefined,
term_key: form.term_key.trim() || undefined,
classroom_id: form.classroom_id ? Number(form.classroom_id) : undefined,
start_date: form.start_date,
end_date: form.end_date,
max_students: Number(form.max_students) || 30,
teacher_ids: form.teacher_ids,
})
}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Batch</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Batch Name *</Label><Input value={editForm.name} onChange={e => setEditForm(f => ({ ...f, name: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Course *</Label>
<Select
value={editForm.course_id || "__none__"}
onValueChange={(v) =>
setEditForm((f) => ({
...f,
course_id: v === "__none__" ? "" : v,
course_section_id: "",
}))
}
>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Section (optional)</Label>
<Select
value={editForm.course_section_id || "__none__"}
onValueChange={(v) =>
setEditForm((f) => ({
...f,
course_section_id: v === "__none__" ? "" : v,
}))
}
disabled={!editForm.course_id}
>
<SelectTrigger>
<SelectValue placeholder="Whole course" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Whole course (no section)</SelectItem>
{editSections.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.code ? `${s.code} · ${s.name}` : s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Term Key (optional)</Label>
<Input
placeholder="e.g. 2026-T1"
value={editForm.term_key}
onChange={(e) => setEditForm((f) => ({ ...f, term_key: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>Classroom (optional)</Label>
<Select value={editForm.classroom_id || "__none__"} onValueChange={v => setEditForm(f => ({ ...f, classroom_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="No classroom" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No classroom</SelectItem>
{classrooms.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.name} ({c.student_count} students)</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={editForm.start_date} onChange={e => setEditForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={editForm.end_date} onChange={e => setEditForm(f => ({ ...f, end_date: e.target.value }))} /></div>
</div>
<div className="space-y-2"><Label>Capacity</Label><Input type="number" value={editForm.max_students} onChange={e => setEditForm(f => ({ ...f, max_students: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Teachers (multi-select)</Label>
<div className="max-h-40 overflow-y-auto rounded border p-2 space-y-1">
{teachers.map((t) => (
<label key={t.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
<Checkbox
checked={editForm.teacher_ids.includes(t.id)}
onCheckedChange={() =>
setEditForm((prev) => ({
...prev,
teacher_ids: prev.teacher_ids.includes(t.id)
? prev.teacher_ids.filter((id) => id !== t.id)
: [...prev.teacher_ids, t.id],
}))
}
/>
<span className="text-sm">{t.name}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button
disabled={updateMutation.isPending || !editingBatchId || !editForm.name || !editForm.course_id || !editForm.start_date || !editForm.end_date}
onClick={() => {
if (!editingBatchId) return;
updateMutation.mutate({
id: editingBatchId,
data: {
name: editForm.name,
course_id: Number(editForm.course_id),
course_section_id: editForm.course_section_id
? Number(editForm.course_section_id)
: null,
term_key: editForm.term_key.trim(),
classroom_id: editForm.classroom_id ? Number(editForm.classroom_id) : null,
start_date: editForm.start_date,
end_date: editForm.end_date,
max_students: Number(editForm.max_students) || 30,
teacher_ids: editForm.teacher_ids,
},
});
}}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,260 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Building2, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
import { lmsService } from "@/services/lms.service";
import type { LmsBranch } from "@/types";
import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
type BranchForm = {
name: string;
code: string;
notes: string;
active: boolean;
};
const EMPTY_FORM: BranchForm = { name: "", code: "", notes: "", active: true };
export default function AdminBranches() {
const { toast } = useToast();
const queryClient = useQueryClient();
const { selectedEntity } = useAuth();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<LmsBranch | null>(null);
const [search, setSearch] = useState("");
const [form, setForm] = useState<BranchForm>(EMPTY_FORM);
const branchesQ = useQuery({
queryKey: ["lms-branches", selectedEntity?.id ?? 0, search],
queryFn: async () => {
const res = await lmsService.listBranches({
size: 200,
search: search.trim() || undefined,
});
return res.items;
},
});
const createMut = useMutation({
mutationFn: (payload: Partial<LmsBranch>) => lmsService.createBranch(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
setOpen(false);
setEditing(null);
setForm(EMPTY_FORM);
toast({ title: "Branch created" });
},
onError: () => toast({ title: "Error", description: "Failed to create branch", variant: "destructive" }),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: Partial<LmsBranch> }) =>
lmsService.updateBranch(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
setOpen(false);
setEditing(null);
setForm(EMPTY_FORM);
toast({ title: "Branch updated" });
},
onError: () => toast({ title: "Error", description: "Failed to update branch", variant: "destructive" }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => lmsService.deleteBranch(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
toast({ title: "Branch deleted" });
},
onError: () => toast({ title: "Error", description: "Failed to delete branch", variant: "destructive" }),
});
const isSaving = createMut.isPending || updateMut.isPending;
const rows = useMemo(() => branchesQ.data ?? [], [branchesQ.data]);
const openCreate = () => {
setEditing(null);
setForm(EMPTY_FORM);
setOpen(true);
};
const openEdit = (row: LmsBranch) => {
setEditing(row);
setForm({
name: row.name || "",
code: row.code || "",
notes: row.notes || "",
active: !!row.active,
});
setOpen(true);
};
const onSave = () => {
const payload: Partial<LmsBranch> = {
name: form.name.trim(),
code: form.code.trim(),
notes: form.notes.trim(),
active: form.active,
};
if (!payload.name) return;
if (editing) {
updateMut.mutate({ id: editing.id, payload });
return;
}
createMut.mutate(payload);
};
const onDelete = (row: LmsBranch) => {
if (!window.confirm(`Delete branch "${row.name}"?`)) return;
deleteMut.mutate(row.id);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Branches</h1>
<p className="text-muted-foreground">
Create branches per entity to organize courses, batches, teachers, and students.
</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Branch
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "Edit Branch" : "Create Branch"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label>Branch name</Label>
<Input
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g. Riyadh Main Campus"
/>
</div>
<div className="space-y-2">
<Label>Branch code</Label>
<Input
value={form.code}
onChange={(e) => setForm((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. RUH_MAIN"
/>
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
value={form.notes}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
placeholder="Optional notes for managers"
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">Active</p>
<p className="text-xs text-muted-foreground">Inactive branches stay in history but are hidden from active workflows.</p>
</div>
<Switch
checked={form.active}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, active: checked }))}
/>
</div>
<Button className="w-full" onClick={onSave} disabled={isSaving || !form.name.trim()}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editing ? "Update Branch" : "Create Branch"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search branches by name or code"
/>
</div>
{branchesQ.isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Code</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Shared LMS</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-medium">{row.name}</TableCell>
<TableCell>{row.code || "—"}</TableCell>
<TableCell>{row.entity_name || "—"}</TableCell>
<TableCell>
<div className="text-xs text-muted-foreground">
C:{row.course_count} B:{row.batch_count} S:{row.student_count} T:{row.teacher_count}
</div>
</TableCell>
<TableCell>
{row.active ? (
<span className="inline-flex items-center rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">Active</span>
) : (
<span className="inline-flex items-center rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-600">Inactive</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => openEdit(row)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onDelete(row)} disabled={deleteMut.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Building2 className="h-5 w-5" />
<span>No branches found for this entity.</span>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -88,6 +88,9 @@ import { LibraryPickerDialog } from "@/components/coursePlan/LibraryPickerDialog
import MaterialBookView, {
SkillBadge,
} from "@/components/coursePlan/MaterialBookView";
import PlanReader, {
GroundingBadge,
} from "@/components/coursePlan/PlanReader";
import { describeApiError, withAuthQuery } from "@/lib/api-client";
import type {
CoursePlan,
@@ -128,6 +131,30 @@ const SKILL_ICONS: Record<string, LucideIcon> = {
integrated: MessageSquare,
};
// Phase 1 (2026-04-29) — colour-coded kind badge so the weekly plan
// view can be scanned at a glance: orange = mandatory work, blue =
// supplementary content. Used in MaterialCard headers and the
// student-facing PlanReader.
const KIND_TONE: Record<
string,
{ label: string; classes: string }
> = {
content: { label: "Lesson", classes: "border-slate-300 text-slate-700 dark:text-slate-300" },
quiz: { label: "Quiz", classes: "bg-blue-500/15 border-blue-500/40 text-blue-700 dark:text-blue-300" },
test: { label: "Test", classes: "bg-primary/15 border-primary/40 text-primary" },
resource: { label: "Resource", classes: "bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300" },
assignment: { label: "Assignment", classes: "bg-amber-500/15 border-amber-500/40 text-amber-700 dark:text-amber-300" },
};
function KindBadge({ kind }: { kind: string }) {
const tone = KIND_TONE[kind] || KIND_TONE.content;
return (
<Badge variant="outline" className={`text-[10px] ${tone.classes}`}>
{tone.label}
</Badge>
);
}
export default function AdminCoursePlanDetail() {
const { t } = useTranslation();
const params = useParams<{ planId: string }>();
@@ -168,6 +195,36 @@ export default function AdminCoursePlanDetail() {
const plan = planQ.data?.data as CoursePlan | undefined;
const [studentPreview, setStudentPreview] = useState(false);
const [gateWeek, setGateWeek] = useState<number | null>(null);
// True as soon as at least one uploaded source is fully indexed in
// pgvector. Drives the v2 (RAG-grounded) path on Generate and gates
// the soft "upload your book first" modal.
const indexedSources = useMemo(
() => (sourcesQ.data?.items ?? []).filter((s) => s.status === "indexed"),
[sourcesQ.data?.items],
);
const hasIndexedSources = indexedSources.length > 0;
const generateV2Mut = useMutation({
mutationFn: (weekNumber: number) =>
coursePlanService.generateWeekMaterialsV2(planId, weekNumber),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
qc.invalidateQueries({ queryKey: ["course-plan", planId, "deliverables"] });
toast.success(
t("coursePlan.weekMaterialsGenerated") +
(res.rag?.sources_used?.length
? ` (${t("coursePlan.rag.groundedOn", {
count: res.rag.sources_used.length,
defaultValue: "grounded on {{count}} source(s)",
})})`
: ""),
);
},
onError: (err) =>
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
});
const generateMut = useMutation({
mutationFn: (weekNumber: number) =>
@@ -181,6 +238,24 @@ export default function AdminCoursePlanDetail() {
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
});
/**
* Single entry point for the per-week Generate button:
* - if any source is indexed → call the RAG-grounded v2 endpoint
* - otherwise → open the upload gate; the admin can still choose
* "Generate without RAG" to fall back to the legacy generator
*/
const requestGenerate = (weekNumber: number) => {
if (hasIndexedSources) {
generateV2Mut.mutate(weekNumber);
} else {
setGateWeek(weekNumber);
}
};
const generateBusy = (weekNumber: number) =>
(generateV2Mut.isPending && generateV2Mut.variables === weekNumber) ||
(generateMut.isPending && generateMut.variables === weekNumber);
const bulkMediaMut = useMutation({
mutationFn: (vars: {
week: number;
@@ -273,7 +348,7 @@ export default function AdminCoursePlanDetail() {
</Badge>
</div>
</div>
{plan.skills_division && (
{plan.skills_division && !studentPreview && (
<p className="text-sm text-muted-foreground">
{plan.skills_division}
</p>
@@ -281,6 +356,19 @@ export default function AdminCoursePlanDetail() {
</CardHeader>
</Card>
{studentPreview ? (
<>
<div className="rounded-md border border-amber-300/60 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-900 dark:text-amber-100 flex items-center gap-2">
<Eye className="h-4 w-4" />
{t(
"coursePlan.preview.banner",
"Preview — answers won't be saved. Toggle Student view to exit.",
)}
</div>
<PlanReader plan={plan} mode="preview" />
</>
) : (
<>
<DeliverablesStrip data={deliverablesQ.data} />
<SourcesCard
@@ -407,11 +495,8 @@ export default function AdminCoursePlanDetail() {
key={week.id}
week={week}
materials={materialsByWeek.get(week.week_number) ?? []}
generating={
generateMut.isPending &&
generateMut.variables === week.week_number
}
onGenerate={() => generateMut.mutate(week.week_number)}
generating={generateBusy(week.week_number)}
onGenerate={() => requestGenerate(week.week_number)}
onBulkMedia={(kinds) =>
bulkMediaMut.mutate({ week: week.week_number, kinds })
}
@@ -420,18 +505,107 @@ export default function AdminCoursePlanDetail() {
bulkMediaMut.variables?.week === week.week_number
}
studentPreview={studentPreview}
hasIndexedSources={hasIndexedSources}
planId={planId}
/>
))}
</Accordion>
</CardContent>
</Card>
)}
</>
)}
</>
)}
<PreGenerationGate
open={gateWeek !== null}
onOpenChange={(v) => {
if (!v) setGateWeek(null);
}}
weekNumber={gateWeek}
onUpload={() => {
setGateWeek(null);
// Scroll to the SourcesCard upload area; the admin uploads
// the PDF, watches it index, then clicks Generate again.
document
.querySelector<HTMLElement>(`[data-section="sources-card"]`)
?.scrollIntoView({ behavior: "smooth", block: "start" });
}}
onContinueWithoutRag={() => {
if (gateWeek !== null) {
generateMut.mutate(gateWeek);
setGateWeek(null);
}
}}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Pre-generation upload gate
// ---------------------------------------------------------------------------
/**
* Soft gate shown before Generate when the plan has no indexed sources.
* Encourages the admin to upload reference material first so the v2
* generator can ground exercises on real text. Dismissable: clicking
* "Generate without RAG" falls back to the legacy v1 endpoint so we
* never block the workflow on a missing book.
*/
function PreGenerationGate({
open,
onOpenChange,
weekNumber,
onUpload,
onContinueWithoutRag,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
weekNumber: number | null;
onUpload: () => void;
onContinueWithoutRag: () => void;
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("coursePlan.gate.title", "Upload your reference book first?")}
</DialogTitle>
<DialogDescription>
{t(
"coursePlan.gate.subtitle",
"Generating week {{n}} works best when grounded on your uploaded PDF / DOCX / link. Upload a reference, wait for indexing, then click Generate again. You can dismiss this and continue without RAG.",
{ n: weekNumber ?? 0 },
)}
</DialogDescription>
</DialogHeader>
<div className="text-xs text-muted-foreground">
{t(
"coursePlan.gate.hint",
"Tip: a typical course book takes 10-30 seconds to index after upload.",
)}
</div>
<DialogFooter className="gap-2 flex-wrap">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("coursePlan.gate.cancel", "Cancel")}
</Button>
<Button variant="ghost" onClick={onContinueWithoutRag}>
{t("coursePlan.gate.continueWithoutRag", "Generate without RAG")}
</Button>
<Button onClick={onUpload}>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.gate.upload", "Upload now")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Phase B — Deliverables / progress strip
// ---------------------------------------------------------------------------
@@ -618,14 +792,82 @@ function SourcesCard({
),
});
const extractMut = useMutation({
mutationFn: () => coursePlanService.extractWorkbooks(planId),
onSuccess: (res) => {
if (res.data.materials_created > 0) {
toast.success(
t("coursePlan.sources.extractDone", {
materials: res.data.materials_created,
exercises: res.data.exercises_total,
defaultValue:
"Extracted {{exercises}} exercises into {{materials}} workbooks",
}),
);
} else {
toast.message(
res.data.reason
? `${t("coursePlan.sources.extractEmpty", { defaultValue: "No new exercises extracted" })} (${res.data.reason})`
: t("coursePlan.sources.extractEmpty", {
defaultValue: "No new exercises extracted",
}),
);
}
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
qc.invalidateQueries({ queryKey: ["course-plan", planId, "deliverables"] });
},
onError: (err) =>
toast.error(
describeApiError(
err,
t("coursePlan.sources.extractFailed", {
defaultValue: "Failed to extract workbooks",
}),
),
),
});
// True once at least one source is fully indexed — surfaces the
// "Extract workbooks" action only when running it can actually
// produce results.
const hasIndexed = sources.some((s) => s.status === "indexed");
return (
<Card>
<Card data-section="sources-card">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
{t("coursePlan.sources.sectionTitle")}
</CardTitle>
<CardDescription>{t("coursePlan.sources.sectionDesc")}</CardDescription>
<div className="flex items-start justify-between gap-2 flex-wrap">
<div>
<CardTitle className="text-base flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
{t("coursePlan.sources.sectionTitle")}
</CardTitle>
<CardDescription>
{t("coursePlan.sources.sectionDesc")}
</CardDescription>
</div>
<Button
size="sm"
variant="outline"
onClick={() => extractMut.mutate()}
disabled={extractMut.isPending || !hasIndexed}
title={
hasIndexed
? undefined
: t("coursePlan.sources.extractGate", {
defaultValue: "Index a source first",
})
}
>
{extractMut.isPending ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<ClipboardList className="mr-1 h-4 w-4" />
)}
{t("coursePlan.sources.extractWorkbooks", {
defaultValue: "Extract workbooks from sources",
})}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
@@ -1241,6 +1483,8 @@ function WeekAccordionItem({
onBulkMedia,
bulkBusy,
studentPreview,
hasIndexedSources,
planId,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
@@ -1249,9 +1493,12 @@ function WeekAccordionItem({
onBulkMedia: (kinds: Array<"audio" | "image" | "video">) => void;
bulkBusy: boolean;
studentPreview: boolean;
hasIndexedSources: boolean;
planId: number;
}) {
const { t } = useTranslation();
const [skillFilter, setSkillFilter] = useState<string>("all");
const [manualOpen, setManualOpen] = useState(false);
const materialSkills = useMemo(
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
[materials],
@@ -1344,6 +1591,11 @@ function WeekAccordionItem({
: materials.length > 0
? t("coursePlan.regenerateMaterials")
: t("coursePlan.generateMaterials")}
{hasIndexedSources && (
<Badge variant="secondary" className="ml-2">
RAG
</Badge>
)}
</Button>
{materials.length > 0 && (
<Button
@@ -1360,6 +1612,14 @@ function WeekAccordionItem({
{t("coursePlan.media.bulk")}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => setManualOpen(true)}
>
<Plus className="mr-1 h-4 w-4" />
{t("coursePlan.addMaterial", "Add quiz / test / resource")}
</Button>
<span className="text-xs text-muted-foreground">
{t("coursePlan.generateHint")}
</span>
@@ -1396,11 +1656,222 @@ function WeekAccordionItem({
))}
</div>
)}
<AddManualMaterialDialog
open={manualOpen}
onOpenChange={setManualOpen}
planId={planId}
weekNumber={week.week_number}
/>
</AccordionContent>
</AccordionItem>
);
}
function AddManualMaterialDialog({
open,
onOpenChange,
planId,
weekNumber,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
planId: number;
weekNumber: number;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [title, setTitle] = useState("");
const [kind, setKind] = useState<
"content" | "quiz" | "test" | "resource" | "assignment"
>("quiz");
const [examTemplateId, setExamTemplateId] = useState("");
const [resourceId, setResourceId] = useState("");
const [dueDate, setDueDate] = useState("");
const [skill, setSkill] = useState<string>("integrated");
useEffect(() => {
if (!open) {
setTitle("");
setKind("quiz");
setExamTemplateId("");
setResourceId("");
setDueDate("");
setSkill("integrated");
}
}, [open]);
const createMut = useMutation({
mutationFn: () =>
coursePlanService.createManualMaterial(planId, weekNumber, {
title: title.trim(),
kind,
skill,
material_type:
kind === "quiz" || kind === "test"
? "practice"
: kind === "resource"
? "other"
: "other",
exam_template_id: examTemplateId ? Number(examTemplateId) : null,
resource_id: resourceId ? Number(resourceId) : null,
due_date: dueDate || null,
is_graded: kind === "test",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
toast.success(t("coursePlan.addedMaterial", "Material added"));
onOpenChange(false);
},
onError: (err) =>
toast.error(describeApiError(err, t("common.error", "Error"))),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("coursePlan.addMaterialTitle", "Add material to week {{n}}", {
n: weekNumber,
})}
</DialogTitle>
<DialogDescription>
{t(
"coursePlan.addMaterialHint",
"Drop a quiz, graded test, supplementary resource, or assignment into this week of the delivery plan.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs mb-1 block">
{t("common.title", "Title")} *
</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.kind", "Material kind")}
</Label>
<Select
value={kind}
onValueChange={(v) =>
setKind(
v as
| "content"
| "quiz"
| "test"
| "resource"
| "assignment",
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="quiz">
{t("coursePlan.kindQuiz", "Quiz")}
</SelectItem>
<SelectItem value="test">
{t("coursePlan.kindTest", "Graded Test")}
</SelectItem>
<SelectItem value="resource">
{t("coursePlan.kindResource", "Supplementary Resource")}
</SelectItem>
<SelectItem value="assignment">
{t("coursePlan.kindAssignment", "Assignment")}
</SelectItem>
<SelectItem value="content">
{t("coursePlan.kindContent", "Reading / Lesson")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.skill", "Skill")}
</Label>
<Select value={skill} onValueChange={setSkill}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reading">Reading</SelectItem>
<SelectItem value="writing">Writing</SelectItem>
<SelectItem value="listening">Listening</SelectItem>
<SelectItem value="speaking">Speaking</SelectItem>
<SelectItem value="grammar">Grammar</SelectItem>
<SelectItem value="vocabulary">Vocabulary</SelectItem>
<SelectItem value="integrated">Integrated</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{(kind === "quiz" || kind === "test") && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.examTemplateId", "Exam template ID")}
</Label>
<Input
type="number"
value={examTemplateId}
onChange={(e) => setExamTemplateId(e.target.value)}
placeholder={t(
"coursePlan.examTemplateIdHint",
"ID of an existing exam template (see /admin/exams)",
)}
/>
</div>
)}
{kind === "resource" && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.resourceId", "Resource ID")}
</Label>
<Input
type="number"
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
placeholder={t(
"coursePlan.resourceIdHint",
"ID of an existing resource (see /admin/resources)",
)}
/>
</div>
)}
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.dueDate", "Due date")}
</Label>
<Input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={() => createMut.mutate()}
disabled={!title.trim() || createMut.isPending}
>
{createMut.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{t("common.add", "Add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function MaterialCard({
material,
studentPreview,
@@ -1417,6 +1888,18 @@ function MaterialCard({
const [bodyText, setBodyText] = useState(material.body_text || "");
const [shareDate, setShareDate] = useState(material.share_date ?? "");
const [isStatic, setIsStatic] = useState(Boolean(material.is_static));
// Phase 1 (2026-04-29) — kind / exam / resource / grading edits.
const [kind, setKind] = useState<string>(material.kind || "content");
const [examTemplateId, setExamTemplateId] = useState<string>(
material.exam_template_id ? String(material.exam_template_id) : "",
);
const [resourceId, setResourceId] = useState<string>(
material.resource_id ? String(material.resource_id) : "",
);
const [dueDate, setDueDate] = useState<string>(material.due_date ?? "");
const [isGraded, setIsGraded] = useState<boolean>(
Boolean(material.is_graded),
);
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
const mediaCount = material.media?.length ?? 0;
@@ -1426,6 +1909,13 @@ function MaterialCard({
setBodyText(material.body_text || "");
setShareDate(material.share_date ?? "");
setIsStatic(Boolean(material.is_static));
setKind(material.kind || "content");
setExamTemplateId(
material.exam_template_id ? String(material.exam_template_id) : "",
);
setResourceId(material.resource_id ? String(material.resource_id) : "");
setDueDate(material.due_date ?? "");
setIsGraded(Boolean(material.is_graded));
}, [material]);
const saveMut = useMutation({
@@ -1436,6 +1926,16 @@ function MaterialCard({
body_text: bodyText,
share_date: shareDate || null,
is_static: isStatic,
kind: kind as
| "content"
| "quiz"
| "test"
| "resource"
| "assignment",
exam_template_id: examTemplateId ? Number(examTemplateId) : null,
resource_id: resourceId ? Number(resourceId) : null,
due_date: dueDate || null,
is_graded: isGraded,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["course-plan", material.plan_id] });
@@ -1462,7 +1962,20 @@ function MaterialCard({
material.material_type,
)}
</Badge>
<KindBadge kind={material.kind || "content"} />
{material.is_graded && (
<Badge variant="default" className="text-[10px]">
{t("coursePlan.graded", "Graded")}
</Badge>
)}
{material.due_date && (
<Badge variant="outline" className="text-[10px]">
<Calendar className="h-3 w-3 mr-1" />
{material.due_date}
</Badge>
)}
<SkillBadge skill={material.skill} />
<GroundingBadge material={material} />
<Button
variant="outline"
size="sm"
@@ -1517,6 +2030,92 @@ function MaterialCard({
/>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.kind", "Material kind")}
</Label>
<Select value={kind} onValueChange={setKind}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="content">
{t("coursePlan.kindContent", "Reading / Lesson")}
</SelectItem>
<SelectItem value="quiz">
{t("coursePlan.kindQuiz", "Quiz")}
</SelectItem>
<SelectItem value="test">
{t("coursePlan.kindTest", "Graded Test")}
</SelectItem>
<SelectItem value="resource">
{t("coursePlan.kindResource", "Supplementary Resource")}
</SelectItem>
<SelectItem value="assignment">
{t("coursePlan.kindAssignment", "Assignment")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.dueDate", "Due date")}
</Label>
<Input
type="date"
value={dueDate || ""}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="flex items-end">
<div className="flex items-center gap-2">
<Checkbox
id={`graded-${material.id}`}
checked={isGraded}
onCheckedChange={(v) => setIsGraded(Boolean(v))}
/>
<label
htmlFor={`graded-${material.id}`}
className="text-sm"
>
{t("coursePlan.graded", "Graded")}
</label>
</div>
</div>
</div>
{(kind === "quiz" || kind === "test") && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.examTemplateId", "Exam template ID")}
</Label>
<Input
type="number"
value={examTemplateId}
onChange={(e) => setExamTemplateId(e.target.value)}
placeholder={t(
"coursePlan.examTemplateIdHint",
"ID of an existing exam template (see /admin/exams)",
)}
/>
</div>
)}
{kind === "resource" && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.resourceId", "Resource ID")}
</Label>
<Input
type="number"
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
placeholder={t(
"coursePlan.resourceIdHint",
"ID of an existing resource (see /admin/resources)",
)}
/>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id={`static-${material.id}`}
@@ -1630,6 +2229,46 @@ function MediaDrawer({
toast.error(describeApiError(err, t("coursePlan.media.deleteFailed"))),
});
// Admin upload — one mutation handles all three kinds, the file picker
// hidden inputs below decide which kind the file is for. The mutation
// input is the kind+file pair so React Query can serialize multiple
// uploads (e.g. drag two images in a row) without colliding state.
const uploadMut = useMutation({
mutationFn: ({ kind, file }: { kind: "audio" | "image" | "video"; file: File }) =>
coursePlanService.uploadMedia(material.id, kind, file),
onSuccess: (_data, vars) => {
toast.success(
t(`coursePlan.media.uploaded_${vars.kind}`, {
defaultValue: `${vars.kind.charAt(0).toUpperCase() + vars.kind.slice(1)} uploaded`,
}),
);
refresh();
},
onError: (err) =>
toast.error(
describeApiError(
err,
t("coursePlan.media.uploadFailed", { defaultValue: "Upload failed" }),
),
),
});
const audioInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
function handleFilePicked(
kind: "audio" | "image" | "video",
e: React.ChangeEvent<HTMLInputElement>,
) {
const file = e.target.files?.[0];
// Always reset the input value so the same file can be re-selected
// after a failed upload — otherwise onChange won't fire again.
e.target.value = "";
if (!file) return;
uploadMut.mutate({ kind, file });
}
const items = mediaQ.data?.items ?? [];
return (
@@ -1645,47 +2284,121 @@ function MediaDrawer({
</SheetHeader>
{!studentPreview && (
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<Button
variant="outline"
size="sm"
onClick={() => audioMut.mutate()}
disabled={audioMut.isPending}
>
{audioMut.isPending ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Music className="mr-1 h-4 w-4" />
)}
{t("coursePlan.media.generateAudio")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => imageMut.mutate()}
disabled={imageMut.isPending}
>
{imageMut.isPending ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<ImageIcon className="mr-1 h-4 w-4" />
)}
{t("coursePlan.media.generateImage")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => videoMut.mutate()}
disabled={videoMut.isPending}
>
{videoMut.isPending ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Video className="mr-1 h-4 w-4" />
)}
{t("coursePlan.media.generateVideo")}
</Button>
</div>
<>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<Button
variant="outline"
size="sm"
onClick={() => audioMut.mutate()}
disabled={audioMut.isPending}
>
{audioMut.isPending ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Music className="mr-1 h-4 w-4" />
)}
{t("coursePlan.media.generateAudio")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => imageMut.mutate()}
disabled={imageMut.isPending}
>
{imageMut.isPending ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<ImageIcon className="mr-1 h-4 w-4" />
)}
{t("coursePlan.media.generateImage")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => videoMut.mutate()}
disabled={videoMut.isPending}
>
{videoMut.isPending ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Video className="mr-1 h-4 w-4" />
)}
{t("coursePlan.media.generateVideo")}
</Button>
</div>
{/*
Manual upload row — admins can attach a real human voiceover,
a custom illustration, or a recorded scene to override the
AI-generated asset. The hidden inputs are kept off-DOM-flow
and triggered by the visible buttons via refs so the styling
stays consistent with the Generate row above.
*/}
<div className="mt-2 grid gap-2 sm:grid-cols-3">
<input
ref={audioInputRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => handleFilePicked("audio", e)}
/>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFilePicked("image", e)}
/>
<input
ref={videoInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => handleFilePicked("video", e)}
/>
<Button
variant="ghost"
size="sm"
onClick={() => audioInputRef.current?.click()}
disabled={uploadMut.isPending}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.media.uploadAudio", {
defaultValue: "Upload audio",
})}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => imageInputRef.current?.click()}
disabled={uploadMut.isPending}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.media.uploadImage", {
defaultValue: "Upload image",
})}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => videoInputRef.current?.click()}
disabled={uploadMut.isPending}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.media.uploadVideo", {
defaultValue: "Upload video",
})}
</Button>
</div>
{uploadMut.isPending && (
<p className="text-muted-foreground mt-2 inline-flex items-center gap-1 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
{t("coursePlan.media.uploading", {
defaultValue: "Uploading…",
})}
</p>
)}
</>
)}
<div className="mt-6 space-y-3">

View File

@@ -13,12 +13,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
import { lmsService, taxonomyService, resourcesService } from "@/services";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap } from "lucide-react";
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap, Layers, Sparkles, X } from "lucide-react";
import { Link } from "react-router-dom";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { useToast } from "@/hooks/use-toast";
import type { Course, CourseCreateRequest } from "@/types";
import type { Course, CourseCreateRequest, CourseSection } from "@/types";
import type { ResourceTag } from "@/types/adaptive";
const DIFFICULTY_OPTIONS = [
@@ -409,10 +409,335 @@ function courseToFormData(c: Course): CourseFormData {
};
}
function CourseSectionsDialog({
open,
onOpenChange,
course,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
course: Course;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const [newName, setNewName] = useState("");
const [newCode, setNewCode] = useState("");
const [newSeq, setNewSeq] = useState<number>(10);
const [editing, setEditing] = useState<CourseSection | null>(null);
const [editName, setEditName] = useState("");
const [editCode, setEditCode] = useState("");
const [editSeq, setEditSeq] = useState<number>(10);
const [editActive, setEditActive] = useState(true);
const [busy, setBusy] = useState(false);
const sectionsQ = useQuery({
queryKey: ["course-sections", course.id],
queryFn: () => lmsService.listCourseSections(course.id),
enabled: open,
});
const sections = sectionsQ.data?.items ?? [];
function refreshAll() {
qc.invalidateQueries({ queryKey: ["course-sections", course.id] });
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
}
async function handleGenerateDefaults() {
setBusy(true);
try {
const res = await lmsService.generateDefaultCourseSections(course.id);
toast({
title: res.created_count > 0 ? `Generated ${res.created_count} section(s)` : "Defaults already exist",
});
refreshAll();
} catch (e: unknown) {
toast({
title: "Generation failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
async function handleAdd() {
const code = newCode.trim().toUpperCase();
const name = newName.trim() || `Section ${code}`;
if (!code) {
toast({ title: "Code is required", variant: "destructive" });
return;
}
setBusy(true);
try {
await lmsService.createCourseSection(course.id, {
name,
code,
sequence: newSeq || 10,
active: true,
});
toast({ title: `Section ${code} added` });
setNewName("");
setNewCode("");
setNewSeq(10);
refreshAll();
} catch (e: unknown) {
toast({
title: "Add failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
function startEdit(s: CourseSection) {
setEditing(s);
setEditName(s.name);
setEditCode(s.code);
setEditSeq(s.sequence || 10);
setEditActive(s.active);
}
async function saveEdit() {
if (!editing) return;
setBusy(true);
try {
await lmsService.updateCourseSection(course.id, editing.id, {
name: editName.trim() || editing.name,
code: editCode.trim().toUpperCase() || editing.code,
sequence: editSeq || editing.sequence,
active: editActive,
});
toast({ title: "Section updated" });
setEditing(null);
refreshAll();
} catch (e: unknown) {
toast({
title: "Update failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
async function handleDelete(s: CourseSection) {
if ((s.batch_count ?? 0) > 0) {
toast({
title: "Cannot delete",
description: `Section "${s.code}" has ${s.batch_count} batch(es). Reassign or remove them first.`,
variant: "destructive",
});
return;
}
if (!window.confirm(`Delete section "${s.name}" (${s.code})?`)) return;
setBusy(true);
try {
await lmsService.deleteCourseSection(course.id, s.id);
toast({ title: "Section deleted" });
refreshAll();
} catch (e: unknown) {
toast({
title: "Delete failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Layers className="h-5 w-5" /> Sections {course.title}
</DialogTitle>
<DialogDescription>
Course sections (A, B, C) are templates. Each section can be hosted by a classroom,
which auto-creates a batch and enrolls the classroom roster.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
<div className="text-sm">
<p className="font-medium">Quick start</p>
<p className="text-xs text-muted-foreground">
Create the standard <strong>A · B · C</strong> sections for this course in one click.
</p>
</div>
<Button
size="sm"
onClick={handleGenerateDefaults}
disabled={busy || sectionsQ.isLoading}
variant="secondary"
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
Generate A / B / C
</Button>
</div>
<div>
<Label className="text-sm font-medium">Existing sections</Label>
{sectionsQ.isLoading ? (
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</div>
) : sections.length === 0 ? (
<p className="text-sm text-muted-foreground py-3">
No sections yet. Add one below or click <em>Generate A / B / C</em>.
</p>
) : (
<div className="mt-2 border rounded-md divide-y">
{sections.map((s) => (
<div key={s.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex items-center gap-3 min-w-0">
<Badge variant="outline" className="font-mono">
{s.code}
</Badge>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground">
seq {s.sequence}
{s.batch_count != null && (
<span className="ml-2">· {s.batch_count} batch(es)</span>
)}
{!s.active && <span className="ml-2 text-amber-600">· inactive</span>}
</p>
</div>
</div>
<div className="flex gap-1 shrink-0">
<Button
size="icon"
variant="ghost"
title="Edit section"
onClick={() => startEdit(s)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
title="Delete section"
onClick={() => handleDelete(s)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-lg border p-3 space-y-3">
<Label className="text-sm font-medium">Add a new section</Label>
<div className="grid grid-cols-12 gap-2">
<div className="col-span-3">
<Label className="text-[11px] text-muted-foreground">Code *</Label>
<Input
placeholder="A"
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
maxLength={8}
/>
</div>
<div className="col-span-7">
<Label className="text-[11px] text-muted-foreground">Name</Label>
<Input
placeholder="Section A"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<div className="col-span-2">
<Label className="text-[11px] text-muted-foreground">Seq</Label>
<Input
type="number"
value={newSeq}
onChange={(e) => setNewSeq(Number(e.target.value) || 10)}
/>
</div>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={handleAdd} disabled={busy || !newCode.trim()}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
Add section
</Button>
</div>
</div>
</div>
{editing && (
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pencil className="h-4 w-4" /> Edit section
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<Label>Code</Label>
<Input
value={editCode}
onChange={(e) => setEditCode(e.target.value)}
maxLength={8}
/>
</div>
<div>
<Label>Sequence</Label>
<Input
type="number"
value={editSeq}
onChange={(e) => setEditSeq(Number(e.target.value) || 10)}
/>
</div>
</div>
<div>
<Label>Name</Label>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={editActive}
onCheckedChange={(v) => setEditActive(Boolean(v))}
/>
Active
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button onClick={saveEdit} disabled={busy}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function AdminCourses() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [sectionsCourse, setSectionsCourse] = useState<Course | null>(null);
const [enrollOpen, setEnrollOpen] = useState(false);
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
@@ -539,10 +864,11 @@ export default function AdminCourses() {
<TableHead>Course</TableHead>
<TableHead>Subject / Tags</TableHead>
<TableHead>Difficulty</TableHead>
<TableHead>Sections</TableHead>
<TableHead>Chapters</TableHead>
<TableHead>Enrolled / Cap</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[150px]" />
<TableHead className="w-[180px]" />
</TableRow>
</TableHeader>
<TableBody>
@@ -594,6 +920,36 @@ export default function AdminCourses() {
</Badge>
)}
</TableCell>
<TableCell>
<button
type="button"
onClick={() => setSectionsCourse(c)}
className="group flex items-center gap-1 text-xs hover:underline"
title="Manage course sections (A/B/C…)"
>
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{c.section_count ?? c.sections?.length ?? 0}</span>
<span className="text-muted-foreground">sections</span>
{c.sections && c.sections.length > 0 && (
<span className="ml-1 flex flex-wrap gap-0.5">
{c.sections.slice(0, 4).map((s) => (
<Badge
key={s.id}
variant="outline"
className="text-[10px] px-1 py-0 leading-none"
>
{s.code || s.name}
</Badge>
))}
{c.sections.length > 4 && (
<span className="text-[10px] text-muted-foreground">
+{c.sections.length - 4}
</span>
)}
</span>
)}
</button>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
@@ -627,6 +983,14 @@ export default function AdminCourses() {
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Manage sections (A/B/C…)"
onClick={() => setSectionsCourse(c)}
>
<Layers className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -653,7 +1017,7 @@ export default function AdminCourses() {
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
No courses found.
</TableCell>
</TableRow>
@@ -679,6 +1043,16 @@ export default function AdminCourses() {
/>
)}
{sectionsCourse && (
<CourseSectionsDialog
open={!!sectionsCourse}
onOpenChange={(open) => {
if (!open) setSectionsCourse(null);
}}
course={sectionsCourse}
/>
)}
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>

View File

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

View File

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

View File

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

View File

@@ -1,60 +1,16 @@
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
BookOpen,
Calendar,
ClipboardList,
Headphones,
Image as ImageIcon,
Library,
MessageSquare,
Mic,
Music,
PenSquare,
Sparkles,
Type,
Video,
type LucideIcon,
} from "lucide-react";
import { ArrowLeft, MessageSquare } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import PlanReader from "@/components/coursePlan/PlanReader";
import DiscussionTab from "@/components/discussion/DiscussionTab";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError, withAuthQuery } from "@/lib/api-client";
import MaterialBookView, { SkillBadge } from "@/components/coursePlan/MaterialBookView";
import type {
CoursePlan,
CoursePlanMaterial,
CoursePlanMedia,
CoursePlanWeek,
} from "@/types";
const SKILL_ICONS: Record<string, LucideIcon> = {
reading: BookOpen,
writing: PenSquare,
listening: Headphones,
speaking: Mic,
grammar: Type,
vocabulary: Library,
integrated: MessageSquare,
};
import { describeApiError } from "@/lib/api-client";
import type { CoursePlan } from "@/types";
/**
* Student detail view for an assigned AI course plan.
@@ -63,8 +19,8 @@ const SKILL_ICONS: Record<string, LucideIcon> = {
* returns 403 unless the current user is in the plan's assignment list,
* so we can render unconditionally as soon as the query resolves.
*
* The page is intentionally read-only — students can play audio, view
* images, and watch video, but they can't (re)generate content.
* Rendering is delegated to the shared `<PlanReader>` so the admin
* "View as Student" preview matches the real student experience byte-for-byte.
*/
export default function StudentCoursePlanDetail() {
const { t } = useTranslation();
@@ -79,20 +35,14 @@ export default function StudentCoursePlanDetail() {
const plan = data?.data as CoursePlan | undefined;
const materialsByWeek = useMemo(() => {
const map = new Map<number, CoursePlanMaterial[]>();
if (!plan?.materials) return map;
for (const m of plan.materials) {
const list = map.get(m.week_number) ?? [];
list.push(m);
map.set(m.week_number, list);
}
return map;
}, [plan?.materials]);
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
<Button
variant="ghost"
size="sm"
asChild
className="-ml-2 text-muted-foreground"
>
<Link to="/student/course-plans" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
{t("coursePlan.student.backToList")}
@@ -114,233 +64,23 @@ export default function StudentCoursePlanDetail() {
)}
{plan && (
<>
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<CardTitle className="text-2xl">{plan.name}</CardTitle>
{plan.description && (
<CardDescription className="max-w-3xl">
{plan.description}
</CardDescription>
)}
</div>
<div className="flex gap-2 flex-wrap">
<Badge variant="secondary" className="uppercase">
{plan.cefr_level}
</Badge>
<Badge variant="outline">
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
</Badge>
<Badge variant="outline">
{t("coursePlan.hoursPerWeek", {
count: plan.contact_hours_per_week,
})}
</Badge>
</div>
</div>
{plan.assignment && (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
{plan.assignment.due_date
? t("coursePlan.student.due", { date: plan.assignment.due_date })
: t("coursePlan.student.noDue")}
{plan.assignment.assigned_by_name && (
<span>
·{" "}
{t("coursePlan.student.assignedBy", {
name: plan.assignment.assigned_by_name,
})}
</span>
)}
</p>
)}
{plan.assignment?.message && (
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
{plan.assignment.message}
</p>
)}
</CardHeader>
</Card>
{plan.objectives.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-primary" />
{t("coursePlan.sections.objectives")}
</CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-1 text-sm">
{plan.objectives.map((o, i) => (
<li key={i}>{o}</li>
))}
</ol>
</CardContent>
</Card>
)}
{plan.weeks && plan.weeks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("coursePlan.sections.delivery")}
</CardTitle>
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
</CardHeader>
<CardContent>
<Accordion type="multiple" className="w-full">
{plan.weeks.map((w) => (
<StudentWeek
key={w.id}
week={w}
materials={materialsByWeek.get(w.week_number) ?? []}
/>
))}
</Accordion>
</CardContent>
</Card>
)}
</>
)}
</div>
);
}
function StudentWeek({
week,
materials,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
}) {
const { t } = useTranslation();
const [skillFilter, setSkillFilter] = useState<string>("all");
const skills = useMemo(
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
[materials],
);
const filteredMaterials = useMemo(
() => materials.filter((m) => skillFilter === "all" || m.skill === skillFilter),
[materials, skillFilter],
);
return (
<AccordionItem value={String(week.week_number)}>
<AccordionTrigger className="text-left">
<div className="flex-1 flex items-center gap-3 min-w-0">
<Badge variant="outline" className="shrink-0">
{t("coursePlan.weekN", { n: week.week_number })}
</Badge>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{week.focus || week.unit || "—"}
</div>
{week.date_label && (
<div className="text-xs text-muted-foreground truncate">
{week.date_label}
</div>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-3">
{skills.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
<Button
size="sm"
variant={skillFilter === "all" ? "default" : "outline"}
onClick={() => setSkillFilter("all")}
>
{t("common.all", "All")}
</Button>
{skills.map((s) => (
<Button
key={s}
size="sm"
variant={skillFilter === s ? "default" : "outline"}
onClick={() => setSkillFilter(s)}
className="capitalize"
>
{s}
</Button>
))}
</div>
)}
{materials.length === 0 && (
<p className="text-sm italic text-muted-foreground">
{t("coursePlan.media.noMedia")}
</p>
)}
{filteredMaterials.map((m) => (
<StudentMaterial key={m.id} material={m} />
))}
</AccordionContent>
</AccordionItem>
);
}
function StudentMaterial({ material }: { material: CoursePlanMaterial }) {
const { t } = useTranslation();
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<Icon className="h-4 w-4 text-primary" />
<CardTitle className="text-base flex-1 min-w-0">
{material.title}
</CardTitle>
<Badge variant="outline" className="text-[10px]">
{t(
`coursePlan.materialType.${material.material_type}`,
material.material_type,
)}
</Badge>
<SkillBadge skill={material.skill} />
</div>
{material.summary && <CardDescription>{material.summary}</CardDescription>}
{material.share_date && (
<CardDescription>
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-3">
{(material.media ?? []).map((m) => (
<StudentMediaTile key={m.id} media={m} />
))}
<MaterialBookView material={material} />
</CardContent>
</Card>
);
}
function StudentMediaTile({ media }: { media: CoursePlanMedia }) {
const url = withAuthQuery(media.preview_url || media.download_url || "");
if (!url) return null;
return (
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
<div className="flex items-center gap-2">
{media.kind === "audio" && <Music className="h-4 w-4" />}
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
{media.kind === "video" && <Video className="h-4 w-4" />}
<span className="capitalize text-xs text-muted-foreground">
{media.kind}
</span>
</div>
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
{media.kind === "image" && (
<img
src={url}
alt={media.title}
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
/>
)}
{media.kind === "video" && (
<video src={url} controls className="w-full rounded-md max-h-72" />
<Tabs defaultValue="material">
<TabsList>
<TabsTrigger value="material">
{t("coursePlan.tabs.material", "Material")}
</TabsTrigger>
<TabsTrigger value="discussion">
<MessageSquare className="h-3 w-3 mr-1" />
{t("discussion.title", "Discussion")}
</TabsTrigger>
</TabsList>
<TabsContent value="material" className="mt-4">
<PlanReader plan={plan} mode="student" />
</TabsContent>
<TabsContent value="discussion" className="mt-4">
<DiscussionTab planId={planId} hideHeader />
</TabsContent>
</Tabs>
)}
</div>
);

View File

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

View File

@@ -2,22 +2,31 @@ 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 { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react";
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play, Sparkles } from "lucide-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
import { useAuth } from "@/contexts/AuthContext";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AITutorAvatar from "@/components/ai/AITutorAvatar";
import PersonalizedPlanCard from "@/components/student/PersonalizedPlanCard";
import { coursePlanService } from "@/services/coursePlan.service";
export default function StudentDashboard() {
const { user } = useAuth();
const { t } = useTranslation();
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
const { data: gradesData, isLoading: lg } = useGrades();
const { data: planData, isLoading: lp } = useQuery({
queryKey: ["student-course-plans"],
queryFn: () => coursePlanService.studentList(),
});
const myCourses = enrolledData?.items ?? [];
const myPlans = planData?.items ?? [];
const gradeRecords = gradesData ?? [];
if (lc || lg) 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 (lc || lg || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const recentGrades = gradeRecords.slice(0, 3);
const avgProgress = myCourses.length > 0 ? Math.round(myCourses.reduce((s, c) => s + c.progress, 0) / myCourses.length) : 0;
@@ -27,7 +36,7 @@ export default function StudentDashboard() {
const firstName = user?.name?.split(" ")[0] || t("dashboard.greetingFallback");
const stats = [
{ label: t("studentDash.enrolledCourses"), value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
{ label: t("studentDash.enrolledCourses"), value: String(myCourses.length + myPlans.length), icon: BookOpen, color: "text-primary" },
{ label: t("studentDash.overallProgress"), value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
{ label: t("studentDash.averageGrade"), value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
{ label: t("studentDash.totalChapters"), value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
@@ -35,13 +44,18 @@ export default function StudentDashboard() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
<p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
<p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
</div>
<AITutorAvatar size={72} name="EnCoach AI Tutor" />
</div>
<AiTipBanner context="student-dashboard" variant="tip" />
<PersonalizedPlanCard />
<AiStudyCoach />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
@@ -93,6 +107,42 @@ export default function StudentDashboard() {
</Card>
<div className="space-y-6">
<Card>
<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" />
{t("studentDash.myCoursePlans")}
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link to="/student/course-plans">
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
{myPlans.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noCoursePlans")}</p>
) : myPlans.slice(0, 4).map((p) => (
<Link to={`/student/course-plans/${p.id}`} key={p.id} className="block">
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors gap-3">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate" dir="auto">{p.name}</p>
<p className="text-xs text-muted-foreground truncate">
{t("coursePlan.weeksCount", { count: p.total_weeks })} ·{" "}
{p.assignment?.due_date
? t("coursePlan.student.due", { date: p.assignment.due_date })
: t("coursePlan.student.noDue")}
</p>
</div>
<Badge variant="secondary" className="uppercase shrink-0">
{p.cefr_level}
</Badge>
</div>
</Link>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">{t("studentDash.quickActions")}</CardTitle>

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,127 @@
import { api } from "@/lib/api-client";
import type { Classroom, ClassroomCreateRequest, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
import { asPaginated, asRecordData } from "@/lib/odoo-api";
import type {
Classroom,
ClassroomAssignCourseRequest,
ClassroomBatchSummary,
ClassroomCourse,
ClassroomCreateRequest,
ClassroomMember,
ClassroomSection,
ClassroomStudent,
PaginatedResponse,
PaginationParams,
ApiSuccessResponse,
} from "@/types";
export interface ClassroomListParams extends PaginationParams {
entity_id?: number;
branch_id?: number;
search?: string;
active?: boolean;
}
export const classroomsService = {
async list(params?: ClassroomListParams): Promise<PaginatedResponse<Classroom>> {
return api.get<PaginatedResponse<Classroom>>("/groups", params as Record<string, string | number | boolean | undefined>);
const raw = await api.get<unknown>(
"/classrooms",
params as Record<string, string | number | boolean | undefined>,
);
return asPaginated<Classroom>(raw);
},
async getById(id: number): Promise<Classroom> {
return api.get<Classroom>(`/groups/${id}`);
const raw = await api.get<unknown>(`/classrooms/${id}`);
return asRecordData<Classroom>(raw);
},
async create(data: ClassroomCreateRequest): Promise<Classroom> {
return api.post<Classroom>("/groups", data);
const raw = await api.post<unknown>("/classrooms", data);
return asRecordData<Classroom>(raw);
},
async update(id: number, data: Partial<ClassroomCreateRequest>): Promise<Classroom> {
const raw = await api.patch<unknown>(`/classrooms/${id}`, data);
return asRecordData<Classroom>(raw);
},
async delete(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/groups/${id}`);
return api.delete<ApiSuccessResponse>(`/classrooms/${id}`);
},
async transfer(data: { student_ids: number[]; from_id: number; to_id: number }): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>("/groups/transfer", data);
// ---- Roster ----------------------------------------------------------
async listStudents(id: number): Promise<{ items: ClassroomStudent[]; total: number }> {
return api.get(`/classrooms/${id}/students`);
},
async addMembers(id: number, userIds: number[]): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>(`/groups/${id}/members`, { user_ids: userIds });
/** Replace or add to roster. ``mode='set'`` overwrites; ``mode='add'`` appends. */
async setStudents(
id: number,
studentIds: number[],
mode: "set" | "add" = "set",
): Promise<Classroom> {
const raw = await api.post<unknown>(`/classrooms/${id}/students`, {
student_ids: studentIds,
mode,
});
return asRecordData<Classroom>(raw);
},
async removeMembers(id: number, userIds: number[]): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>(`/groups/${id}/members/remove`, { user_ids: userIds });
async removeStudents(id: number, studentIds: number[]): Promise<Classroom> {
const raw = await api.delete<unknown>(`/classrooms/${id}/students`, {
student_ids: studentIds,
});
return asRecordData<Classroom>(raw);
},
// ---- Homeroom teachers ----------------------------------------------
async listTeachers(id: number): Promise<{ items: ClassroomMember[]; total: number }> {
return api.get(`/classrooms/${id}/teachers`);
},
async setTeachers(
id: number,
teacherIds: number[],
mode: "set" | "add" = "set",
): Promise<Classroom> {
const raw = await api.post<unknown>(`/classrooms/${id}/teachers`, {
teacher_ids: teacherIds,
mode,
});
return asRecordData<Classroom>(raw);
},
// ---- Courses + cascade ----------------------------------------------
async listCourses(id: number): Promise<{ items: ClassroomCourse[]; total: number }> {
return api.get(`/classrooms/${id}/courses`);
},
async listSections(
id: number,
params?: { course_id?: number; active?: boolean },
): Promise<{ items: ClassroomSection[]; total: number }> {
return api.get(
`/classrooms/${id}/sections`,
params as Record<string, string | number | boolean | undefined>,
);
},
/** Assign a course → server creates (or reuses) the canonical batch and
* enrolls every classroom student into it. */
async assignCourse(
id: number,
payload: ClassroomAssignCourseRequest,
): Promise<{ data: Classroom; batch: ClassroomBatchSummary }> {
return api.post(`/classrooms/${id}/assign-course`, payload);
},
async detachCourse(id: number, courseId: number): Promise<Classroom> {
const raw = await api.delete<unknown>(`/classrooms/${id}/courses/${courseId}`);
return asRecordData<Classroom>(raw);
},
// ---- Batches --------------------------------------------------------
async listBatches(id: number): Promise<{ items: ClassroomBatchSummary[]; total: number }> {
return api.get(`/classrooms/${id}/batches`);
},
};

View File

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

View File

@@ -8,8 +8,50 @@ import type {
CoursePlanMedia,
CoursePlanSource,
CoursePlanSourceKind,
WorkbookAttempt,
WorkbookExtractedFrom,
WorkbookGroundedSource,
} from "@/types";
/**
* Item returned by GET /api/student/learning. The endpoint returns
* BOTH ``op.course`` enrollments and ``encoach.course.plan``
* assignments through a discriminated union on ``kind``. Each item
* carries the ``is_mandatory`` flag so the UI can group items into
* the Accredited Core / Supplementary sections.
*/
export interface StudentLearningItem {
kind: "course" | "plan";
id: number;
name: string;
description?: string;
is_mandatory: boolean;
cefr_level?: string;
difficulty_level?: string;
total_weeks?: number;
status?: string;
course_id?: number | null;
course_name?: string;
entity_id?: number | null;
entity_name?: string;
code?: string;
batch_id?: number | null;
batch_name?: string;
chapter_count?: number;
completed_chapters?: number;
progress?: number;
assignment_id?: number;
assignment_mode?: string;
}
export interface StudentLearningResponse {
mandatory: StudentLearningItem[];
elective: StudentLearningItem[];
count_mandatory: number;
count_elective: number;
count_total: number;
}
/**
* REST helpers for the AI course plan generator.
*
@@ -54,6 +96,55 @@ export const coursePlanService = {
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
},
/**
* RAG-grounded richer-schema generator. Requires at least one
* indexed source on the plan; backend returns 409 otherwise so the
* UI can prompt for upload.
*/
async generateWeekMaterialsV2(
planId: number,
weekNumber: number,
): Promise<{
items: CoursePlanMaterial[];
count: number;
rag: { enabled: boolean; sources_used: number[] };
}> {
return api.post(
`/ai/course-plan/${planId}/weeks/${weekNumber}/materials/v2`,
);
},
/** Mine indexed PDFs for original printed exercises. */
async extractWorkbooks(
planId: number,
payload?: { max_batches?: number },
): Promise<{
data: {
materials_created: number;
exercises_total: number;
batches_run: number;
skipped: number;
reason?: string;
};
plan_id: number;
}> {
return api.post(
`/ai/course-plan/${planId}/extract-workbooks`,
payload ?? {},
);
},
/** Source citations for one material — shown as the
* "Grounded on N references" badge popover. */
async materialGrounding(materialId: number): Promise<{
material_id: number;
grounded_on: WorkbookGroundedSource[];
extracted_from: WorkbookExtractedFrom | null;
sources: CoursePlanSource[];
}> {
return api.get(`/ai/course-plan/material/${materialId}/grounding`);
},
async updateMaterial(
materialId: number,
payload: {
@@ -63,11 +154,46 @@ export const coursePlanService = {
body_text?: string;
share_date?: string | null;
is_static?: boolean;
// Phase 1 (2026-04-29) — kind/exam/resource/grading edits.
kind?: "content" | "quiz" | "test" | "resource" | "assignment";
exam_template_id?: number | null;
resource_id?: number | null;
due_date?: string | null;
is_graded?: boolean;
},
): Promise<{ data: CoursePlanMaterial }> {
return api.patch(`/ai/course-plan/material/${materialId}`, payload);
},
// Phase 1 (2026-04-29): create a manual non-AI material (quiz /
// test / resource / assignment) inside a specific week of the
// delivery plan. The shape mirrors the new `/materials/manual` route
// on the backend.
async createManualMaterial(
planId: number,
weekNumber: number,
payload: {
title: string;
kind: "content" | "quiz" | "test" | "resource" | "assignment";
skill?: string;
material_type?: string;
summary?: string;
exam_template_id?: number | null;
resource_id?: number | null;
due_date?: string | null;
is_graded?: boolean;
},
): Promise<CoursePlanMaterial> {
return api.post(
`/ai/course-plan/${planId}/weeks/${weekNumber}/materials/manual`,
payload,
);
},
async deleteMaterial(materialId: number): Promise<{ success: boolean }> {
return api.delete(`/ai/course-plan/material/${materialId}`);
},
// ---------------------------------------------------------------------
// Phase A — Sources
// ---------------------------------------------------------------------
@@ -193,6 +319,29 @@ export const coursePlanService = {
return api.delete(`/ai/course-plan/media/${mediaId}`);
},
/**
* Attach an admin-supplied media file (audio / image / video) to a
* course-plan material. The backend tags the resulting row with
* `provider='manual'` so the drawer can distinguish hand-curated
* clips from AI-generated ones — and admins can swap a robotic
* Polly recording for a real human voiceover whenever they want.
*/
async uploadMedia(
materialId: number,
kind: "audio" | "image" | "video",
file: File,
title?: string,
): Promise<{ data: CoursePlanMedia }> {
const fd = new FormData();
fd.append("kind", kind);
fd.append("file", file);
if (title) fd.append("title", title);
return api.upload(
`/ai/course-plan/material/${materialId}/media/upload`,
fd,
);
},
async generateWeekMedia(
planId: number,
weekNumber: number,
@@ -254,7 +403,53 @@ export const coursePlanService = {
return api.get("/student/course-plans");
},
// Phase 1 (2026-04-29): merged "My Learning" view that returns
// both classic op.course enrollments AND AI course plans, each
// tagged with `is_mandatory` so the UI can group them under
// Accredited Core vs Supplementary.
async studentLearning(): Promise<StudentLearningResponse> {
return api.get("/student/learning");
},
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
return api.get(`/student/course-plans/${planId}`);
},
// ---------------------------------------------------------------------
// Phase F — Interactive workbook attempts
// ---------------------------------------------------------------------
/** Save (or finalize) the current student's answers and return the
* freshly-graded score. Used by InteractiveWorkbook.tsx on every
* "Check answers" click (debounced) and on "Submit final". */
async saveWorkbookAttempt(
planId: number,
materialId: number,
payload: { answers: Record<string, unknown>; finalize?: boolean },
): Promise<{ data: WorkbookAttempt }> {
return api.post(
`/student/course-plans/${planId}/materials/${materialId}/attempts`,
payload,
);
},
/** Latest attempt the current user owns on this material — null when
* the student hasn't started yet. */
async myWorkbookAttempt(
planId: number,
materialId: number,
): Promise<{ data: WorkbookAttempt | null }> {
return api.get(
`/student/course-plans/${planId}/materials/${materialId}/attempts/me`,
);
},
/** Teacher-side roster of latest attempts per student. */
async listMaterialAttempts(
planId: number,
materialId: number,
): Promise<{ items: WorkbookAttempt[]; count: number }> {
return api.get(
`/ai/course-plan/${planId}/materials/${materialId}/attempts`,
);
},
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,8 @@ import type {
MyEnrolledCourse,
EnrollStudentRequest,
BulkEnrollRequest,
LmsBranch,
CourseSection,
} from "@/types";
function mapCourseRaw(r: Record<string, unknown>): Course {
@@ -53,6 +55,12 @@ function mapCourseRaw(r: Record<string, unknown>): Course {
chapter_count: Number(r.chapter_count ?? 0),
resource_count: Number(r.resource_count ?? 0),
objective_count: Number(r.objective_count ?? 0),
section_count: Number(r.section_count ?? 0),
sections: Array.isArray(r.sections)
? (r.sections as Record<string, unknown>[]).map(mapSectionRaw)
: [],
branch_id: (r.branch_id as number | null) ?? null,
branch_name: (r.branch_name as string) || "",
};
}
@@ -63,13 +71,17 @@ function mapBatchRaw(r: Record<string, unknown>): Batch {
active: "active",
completed: "completed",
};
const teacherIds = (r.teacher_ids as number[]) || [];
const teacherNames = (r.teacher_names as string[]) || [];
return {
id: r.id as number,
name: String(r.name || ""),
course_id: Number(r.course_id || 0),
course_name: String(r.course_name || ""),
teacher_id: 0,
teacher_name: "",
teacher_id: teacherIds[0] || 0,
teacher_name: teacherNames[0] || "",
teacher_ids: teacherIds,
teacher_names: teacherNames,
student_ids: ((r.students as { id: number }[]) ?? []).map((s) => s.id),
student_count: Number(r.student_count ?? 0),
start_date: String(r.start_date || ""),
@@ -77,6 +89,32 @@ function mapBatchRaw(r: Record<string, unknown>): Batch {
schedule: "",
status: statusMap[st] || "upcoming",
capacity: Number(r.max_students ?? 0),
branch_id: (r.branch_id as number | null) ?? null,
branch_name: (r.branch_name as string) || "",
classroom_id: (r.classroom_id as number | null) ?? null,
classroom_name: (r.classroom_name as string) || "",
course_section_id: (r.course_section_id as number | null) ?? null,
course_section_name: (r.course_section_name as string) || "",
course_section_code: (r.course_section_code as string) || "",
term_key: (r.term_key as string) || "",
};
}
function mapSectionRaw(r: Record<string, unknown>): CourseSection {
return {
id: Number(r.id || 0),
name: String(r.name || ""),
code: String(r.code || ""),
sequence: Number(r.sequence || 0),
active: Boolean(r.active ?? true),
notes: String(r.notes || ""),
course_id: Number(r.course_id || 0),
course_name: String(r.course_name || ""),
entity_id: (r.entity_id as number | null) ?? null,
entity_name: String(r.entity_name || ""),
branch_id: (r.branch_id as number | null) ?? null,
branch_name: String(r.branch_name || ""),
batch_count: Number(r.batch_count || 0),
};
}
@@ -136,6 +174,25 @@ export const lmsService = {
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
},
async listBranches(params?: PaginationParams & { search?: string; entity_id?: number; active?: boolean }): Promise<PaginatedResponse<LmsBranch>> {
const raw = await api.get<unknown>("/branches", params as Record<string, string | number | boolean | undefined>);
return asPaginated<LmsBranch>(raw);
},
async createBranch(data: Partial<LmsBranch>): Promise<LmsBranch> {
const raw = await api.post<unknown>("/branches", data);
return asRecordData<LmsBranch>(raw);
},
async updateBranch(id: number, data: Partial<LmsBranch>): Promise<LmsBranch> {
const raw = await api.patch<unknown>(`/branches/${id}`, data);
return asRecordData<LmsBranch>(raw);
},
async deleteBranch(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/branches/${id}`);
},
async getCourse(id: number): Promise<Course> {
const raw = await api.get<unknown>(`/courses/${id}`);
return mapCourseRaw(asRecordData<Record<string, unknown>>(raw));
@@ -179,6 +236,51 @@ export const lmsService = {
return api.delete<ApiSuccessResponse>(`/courses/${id}`);
},
async listCourseSections(courseId: number): Promise<{ items: CourseSection[]; total: number }> {
const raw = await api.get<unknown>(`/courses/${courseId}/sections`);
const p = asPaginated<Record<string, unknown>>(raw);
return { ...p, items: p.items.map(mapSectionRaw) };
},
async createCourseSection(
courseId: number,
data: { name: string; code: string; sequence?: number; active?: boolean; notes?: string; branch_id?: number | null },
): Promise<CourseSection> {
const raw = await api.post<unknown>(`/courses/${courseId}/sections`, data);
return asRecordData<CourseSection>(raw);
},
async updateCourseSection(
courseId: number,
sectionId: number,
data: Partial<{ name: string; code: string; sequence: number; active: boolean; notes: string; branch_id: number | null }>,
): Promise<CourseSection> {
const raw = await api.patch<unknown>(`/courses/${courseId}/sections/${sectionId}`, data);
return asRecordData<CourseSection>(raw);
},
async deleteCourseSection(courseId: number, sectionId: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/courses/${courseId}/sections/${sectionId}`);
},
async generateDefaultCourseSections(
courseId: number,
data?: { codes?: string[]; branch_id?: number | null },
): Promise<{ items: CourseSection[]; created_count: number; created_ids: number[]; total: number }> {
const raw = await api.post<{
items: Record<string, unknown>[];
created_count: number;
created_ids: number[];
total: number;
}>(`/courses/${courseId}/sections/generate-defaults`, data ?? {});
return {
created_count: raw.created_count ?? 0,
created_ids: raw.created_ids ?? [],
total: raw.total ?? 0,
items: (raw.items ?? []).map(mapSectionRaw),
};
},
async aiGenerateCourse(data: { title: string; subject_id?: number; level?: string }): Promise<{ outline: unknown }> {
return api.post("/courses/ai-generate", data);
},
@@ -255,6 +357,25 @@ export const lmsService = {
return api.post(`/batches/${batchId}/students/remove`, { student_ids: studentIds });
},
async getBatchTeachers(batchId: number): Promise<{ items: { id: number; name: string; email: string }[]; total: number }> {
return api.get(`/batches/${batchId}/teachers`);
},
async setBatchTeachers(
batchId: number,
teacherIds: number[],
mode: "set" | "add" = "set",
): Promise<{ success: boolean; teacher_ids: number[] }> {
return api.post(`/batches/${batchId}/teachers`, { teacher_ids: teacherIds, mode });
},
async removeBatchTeachers(
batchId: number,
teacherIds: number[],
): Promise<{ success: boolean; teacher_ids: number[] }> {
return api.post(`/batches/${batchId}/teachers/remove`, { teacher_ids: teacherIds });
},
async deleteStudent(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/students/${id}`);
},

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,98 @@
/** Classroom = homeroom (physical room repurposed as a class group).
*
* Owns:
* - a roster of students (M2M)
* - a set of homeroom teachers (M2M)
* - a list of courses taught here (M2M) — assigning a course cascades
* into a batch + auto-enrollment of every roster student.
* - the resulting batches (one per (classroom × course))
*/
export interface Classroom {
id: number;
name: string;
admin_id: number;
admin_name: string;
entity_id: number;
code: string;
capacity: number;
active: boolean;
notes?: string;
entity_id: number | null;
entity_name: string;
participant_count: number;
participants: ClassroomMember[];
branch_id?: number | null;
branch_name?: string;
student_count: number;
teacher_count: number;
course_count: number;
batch_count: number;
student_ids: number[];
teacher_ids: number[];
course_ids: number[];
batch_ids?: number[];
/** Only populated by GET /api/classrooms/<id> (full payload). */
students?: ClassroomStudent[];
teachers?: ClassroomMember[];
courses?: ClassroomCourse[];
sections?: ClassroomSection[];
batches?: ClassroomBatchSummary[];
}
export interface ClassroomMember {
id: number;
name: string;
email: string;
user_type: string;
}
export interface ClassroomStudent extends ClassroomMember {
gr_no?: string;
}
export interface ClassroomCourse {
id: number;
name: string;
code: string;
}
export interface ClassroomSection {
id: number;
name: string;
code: string;
sequence: number;
course_id: number | null;
course_name: string;
}
export interface ClassroomBatchSummary {
id: number;
name: string;
code: string;
course_id: number | null;
course_name: string;
course_section_id?: number | null;
course_section_name?: string;
course_section_code?: string;
term_key?: string;
start_date: string;
end_date: string;
student_count: number;
teacher_ids: number[];
}
export interface ClassroomCreateRequest {
name: string;
entity_id: number;
admin_id: number;
participant_ids?: number[];
code?: string;
capacity?: number;
notes?: string;
entity_id?: number;
branch_id?: number | null;
student_ids?: number[];
teacher_ids?: number[];
active?: boolean;
}
export interface ClassroomAssignCourseRequest {
course_id: number;
section_id?: number;
teacher_ids?: number[];
term_key?: string;
term_name?: string;
start_date?: string;
end_date?: string;
}

View File

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

View File

@@ -20,8 +20,129 @@ export type CoursePlanMaterialType =
| "grammar_lesson"
| "vocabulary_list"
| "practice"
| "interactive_workbook"
| "other";
// ---------------------------------------------------------------------------
// Interactive workbooks — schema mirrors backend `_WEEK_JSON_HINT_V2` and
// `ExerciseExtractor` output. The renderer in `InteractiveWorkbook.tsx`
// is a discriminated-union switch on `type`.
// ---------------------------------------------------------------------------
export type WorkbookExerciseType =
| "gap_fill"
| "multiple_choice"
| "match_pairs"
| "reorder_words"
| "transformation"
| "short_answer";
export interface WorkbookExerciseBase {
id: string;
type: WorkbookExerciseType;
hint?: string;
rationale?: string;
}
export interface GapFillExercise extends WorkbookExerciseBase {
type: "gap_fill";
stem: string;
answer: string;
alt?: string[];
}
export interface MultipleChoiceExercise extends WorkbookExerciseBase {
type: "multiple_choice";
stem: string;
options: string[];
answer: string;
}
export interface MatchPairsExercise extends WorkbookExerciseBase {
type: "match_pairs";
left: string[];
right: string[];
answer: number[][];
}
export interface ReorderWordsExercise extends WorkbookExerciseBase {
type: "reorder_words";
tokens: string[];
answer: string;
}
export interface TransformationExercise extends WorkbookExerciseBase {
type: "transformation";
from: string;
instruction: string;
answer: string;
alt?: string[];
}
export interface ShortAnswerExercise extends WorkbookExerciseBase {
type: "short_answer";
stem: string;
answer: string;
accepted?: string[];
}
export type WorkbookExercise =
| GapFillExercise
| MultipleChoiceExercise
| MatchPairsExercise
| ReorderWordsExercise
| TransformationExercise
| ShortAnswerExercise;
export interface WorkbookLessonPlan {
warmup?: string;
presentation?: string;
controlled_practice?: string;
freer_practice?: string;
exit_ticket?: string;
}
export interface WorkbookBody {
lesson_plan?: WorkbookLessonPlan;
exercises: WorkbookExercise[];
}
export interface WorkbookGroundedSource {
source_id: number;
title: string;
chunks_used: number;
}
export interface WorkbookExtractedFrom {
source_id: number;
source_title?: string;
page_hint?: string;
topic_keywords?: string[];
chunk_indices?: number[];
}
export interface WorkbookAttemptScoreItem {
id: string;
correct: boolean;
expected: unknown;
given: unknown;
}
export interface WorkbookAttemptScore {
items: WorkbookAttemptScoreItem[];
correct: number;
total: number;
percent: number;
}
export interface WorkbookAttempt {
id: number;
material_id: number;
plan_id: number | null;
student_id: number;
student_name: string;
attempt_number: number;
is_final: boolean;
submitted_at: string | null;
last_updated_at: string | null;
correct_count: number;
total_count: number;
percent: number;
answers: Record<string, unknown>;
score: WorkbookAttemptScore | Record<string, never>;
}
export interface CoursePlanOutcome {
code: string;
description: string;
@@ -69,6 +190,16 @@ export interface CoursePlanWeek {
material_count: number;
}
// Phase 1 (2026-04-29): material `kind` describes how the student
// interacts with the row. Existing materials (created before the
// migration) default to `content` so the UI can keep rendering them.
export type CoursePlanMaterialKind =
| "content"
| "quiz"
| "test"
| "resource"
| "assignment";
export interface CoursePlanMaterial {
id: number;
plan_id: number;
@@ -76,6 +207,18 @@ export interface CoursePlanMaterial {
week_number: number;
skill: string;
material_type: CoursePlanMaterialType | string;
/** Phase 1: top-level interaction kind (content / quiz / test / …). */
kind?: CoursePlanMaterialKind;
/** Phase 1: optional link to an exam template (used when kind=quiz/test). */
exam_template_id?: number | null;
exam_template_name?: string | null;
/** Phase 1: optional link to a library resource (used when kind=resource). */
resource_id?: number | null;
resource_name?: string | null;
/** Phase 1: whether this row counts toward the course grade. */
is_graded?: boolean;
/** Phase 1: optional deadline displayed on the student weekly view. */
due_date?: string | null;
title: string;
is_static?: boolean;
share_date?: string | null;
@@ -83,6 +226,10 @@ export interface CoursePlanMaterial {
/** Loose shape: depends on material_type. */
body: Record<string, unknown>;
body_text: string;
/** Citations for materials produced by the v2/RAG generator. */
grounded_on?: WorkbookGroundedSource[];
/** Provenance for materials mined out of indexed PDFs. */
extracted_from?: WorkbookExtractedFrom | null;
media?: CoursePlanMedia[];
}

Some files were not shown because too many files have changed in this diff Show More