Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62075c30aa | ||
|
|
d5b1987bba | ||
|
|
747e911829 | ||
|
|
744cede1a3 | ||
|
|
3b62075d7e | ||
|
|
a6f2140a9d |
47
.cursorindexingignore
Normal file
47
.cursorindexingignore
Normal 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
|
||||
@@ -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
1
.gitignore
vendored
@@ -26,6 +26,7 @@ pgdata_bak_*/
|
||||
frontend/.vite/
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
*.tsbuildinfo
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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 1–3 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
|
||||
@@ -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()}'
|
||||
@@ -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',
|
||||
|
||||
281
backend/custom_addons/encoach_ai_course/services/rag_context.py
Normal file
281
backend/custom_addons/encoach_ai_course/services/rag_context.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
'encoach_ai',
|
||||
'encoach_taxonomy',
|
||||
'encoach_resources',
|
||||
'encoach_scoring',
|
||||
'encoach_exam_template',
|
||||
'encoach_ai_course',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
|
||||
@@ -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
|
||||
|
||||
174
backend/custom_addons/encoach_lms_api/controllers/branches.py
Normal file
174
backend/custom_addons/encoach_lms_api/controllers/branches.py
Normal 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,8 @@ def _ser_board(b):
|
||||
'batch_name': b.batch_id.name if b.batch_id else None,
|
||||
'chapter_id': b.chapter_id.id if b.chapter_id else None,
|
||||
'chapter_name': b.chapter_id.name if b.chapter_id else None,
|
||||
'plan_id': b.plan_id.id if b.plan_id else None,
|
||||
'plan_name': b.plan_id.name if b.plan_id else None,
|
||||
'is_enabled': b.is_enabled,
|
||||
'allow_student_posts': b.allow_student_posts,
|
||||
'post_count': b.post_count or 0,
|
||||
@@ -97,6 +99,8 @@ class CommunicationController(http.Controller):
|
||||
domain.append(('course_id', '=', int(kw['course_id'])))
|
||||
if kw.get('batch_id'):
|
||||
domain.append(('batch_id', '=', int(kw['batch_id'])))
|
||||
if kw.get('plan_id'):
|
||||
domain.append(('plan_id', '=', int(kw['plan_id'])))
|
||||
recs = M.search(domain, limit=200, order='id desc')
|
||||
return _json_response([_ser_board(r) for r in recs])
|
||||
except Exception as e:
|
||||
@@ -112,11 +116,55 @@ class CommunicationController(http.Controller):
|
||||
vals['course_id'] = int(body['course_id'])
|
||||
if body.get('batch_id'):
|
||||
vals['batch_id'] = int(body['batch_id'])
|
||||
if body.get('plan_id'):
|
||||
vals['plan_id'] = int(body['plan_id'])
|
||||
rec = request.env['encoach.discussion.board'].sudo().create(vals)
|
||||
return _json_response(_ser_board(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# Phase 1: course/plan-scoped board lookup-or-create.
|
||||
# Returns the board attached to a given course or plan; creates
|
||||
# one transparently the first time someone opens the discussion
|
||||
# tab inside a course detail page.
|
||||
@http.route('/api/discussion-boards/for-course/<int:course_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def board_for_course(self, course_id, **kw):
|
||||
try:
|
||||
M = request.env['encoach.discussion.board'].sudo()
|
||||
board = M.search([('course_id', '=', course_id), ('plan_id', '=', False)], limit=1)
|
||||
if not board:
|
||||
Course = request.env['op.course'].sudo().browse(course_id)
|
||||
if not Course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
board = M.create({
|
||||
'name': f'Discussion — {Course.name or "Course"}',
|
||||
'course_id': course_id,
|
||||
})
|
||||
return _json_response(_ser_board(board))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/discussion-boards/for-plan/<int:plan_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def board_for_plan(self, plan_id, **kw):
|
||||
try:
|
||||
M = request.env['encoach.discussion.board'].sudo()
|
||||
board = M.search([('plan_id', '=', plan_id)], limit=1)
|
||||
if not board:
|
||||
Plan = request.env['encoach.course.plan'].sudo().browse(plan_id)
|
||||
if not Plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
board = M.create({
|
||||
'name': f'Discussion — {Plan.name or "Course Plan"}',
|
||||
'plan_id': plan_id,
|
||||
})
|
||||
return _json_response(_ser_board(board))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/discussion-boards/<int:bid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_board(self, bid, **kw):
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Online-test SOFT security controller.
|
||||
|
||||
* `POST /api/exam/security/event` — student client posts a
|
||||
suspicious event during an attempt.
|
||||
* `POST /api/exam/single-attempt-check` — student client checks
|
||||
whether a fresh attempt is allowed for an exam.
|
||||
* `GET /api/teacher/exam/<assignment_id>/security/events` —
|
||||
teacher console reads the per-assignment log.
|
||||
* `GET /api/teacher/exam/attempt/<attempt_id>/security/events` —
|
||||
same, scoped to a specific attempt.
|
||||
* `GET /api/teacher/exam/<assignment_id>/live` — list of in-progress
|
||||
attempts + latest event per attempt.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_EVENT_TYPES = {
|
||||
'tab_blur', 'tab_focus', 'window_blur', 'window_focus',
|
||||
'paste', 'copy', 'cut', 'context_menu', 'fullscreen_exit',
|
||||
'shortcut', 'multi_attempt_block', 'other',
|
||||
}
|
||||
|
||||
|
||||
def _parse_body():
|
||||
try:
|
||||
body = request.httprequest.get_data(as_text=True)
|
||||
return json.loads(body) if body else {}
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _ser_event(ev):
|
||||
payload = None
|
||||
if ev.payload:
|
||||
try:
|
||||
payload = json.loads(ev.payload)
|
||||
except (TypeError, ValueError):
|
||||
payload = ev.payload
|
||||
return {
|
||||
'id': ev.id,
|
||||
'attempt_id': ev.attempt_id.id if ev.attempt_id else None,
|
||||
'student_id': ev.student_id.id,
|
||||
'student_name': ev.student_id.name or ev.student_id.login or '',
|
||||
'exam_id': ev.exam_id.id if ev.exam_id else None,
|
||||
'event_type': ev.event_type,
|
||||
'severity': ev.severity,
|
||||
'occurred_at': ev.occurred_at.isoformat() if ev.occurred_at else None,
|
||||
'payload': payload,
|
||||
}
|
||||
|
||||
|
||||
class ExamSecurityController(http.Controller):
|
||||
|
||||
@http.route('/api/exam/security/event',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def post_event(self, **_kw):
|
||||
try:
|
||||
body = _parse_body()
|
||||
event_type = (body.get('event_type') or '').strip().lower()
|
||||
if event_type not in VALID_EVENT_TYPES:
|
||||
return _error_response('Unknown event_type', 400)
|
||||
|
||||
attempt_id = body.get('attempt_id')
|
||||
attempt = None
|
||||
if attempt_id:
|
||||
attempt = request.env['encoach.student.attempt'].sudo() \
|
||||
.browse(int(attempt_id))
|
||||
if not attempt.exists():
|
||||
attempt = None
|
||||
|
||||
user = request.env.user
|
||||
if attempt and attempt.student_id and attempt.student_id.id != user.id:
|
||||
return _error_response(
|
||||
'Cannot log events for another user\'s attempt', 403,
|
||||
)
|
||||
|
||||
severity = body.get('severity') or 'warning'
|
||||
if severity not in ('info', 'warning', 'alert'):
|
||||
severity = 'warning'
|
||||
|
||||
payload_obj = body.get('payload')
|
||||
payload_str = None
|
||||
if payload_obj is not None:
|
||||
try:
|
||||
payload_str = json.dumps(payload_obj)[:4000]
|
||||
except (TypeError, ValueError):
|
||||
payload_str = None
|
||||
|
||||
Ev = request.env['encoach.exam.security.event'].sudo()
|
||||
ev = Ev.create({
|
||||
'attempt_id': attempt.id if attempt else False,
|
||||
'student_id': user.id,
|
||||
'exam_id': (attempt.exam_id.id if attempt and attempt.exam_id
|
||||
else (int(body['exam_id']) if body.get('exam_id') else False)),
|
||||
'event_type': event_type,
|
||||
'severity': severity,
|
||||
'payload': payload_str,
|
||||
})
|
||||
|
||||
auto_locked = False
|
||||
if attempt and attempt.exam_id:
|
||||
exam = attempt.exam_id
|
||||
level = getattr(exam, 'security_level', 'soft') or 'soft'
|
||||
threshold = getattr(exam, 'suspicious_event_threshold', 5) or 5
|
||||
if level == 'strict' and severity == 'alert':
|
||||
alerts = Ev.search_count([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('severity', '=', 'alert'),
|
||||
])
|
||||
if alerts >= threshold and attempt.status == 'in_progress':
|
||||
attempt.write({
|
||||
'status': 'completed',
|
||||
'completed_at': ev.occurred_at,
|
||||
})
|
||||
auto_locked = True
|
||||
|
||||
return _json_response({
|
||||
'event_id': ev.id,
|
||||
'auto_locked': auto_locked,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('exam.security.event failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/exam/single-attempt-check',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def single_attempt_check(self, **kw):
|
||||
try:
|
||||
exam_id_raw = kw.get('exam_id')
|
||||
if not exam_id_raw:
|
||||
return _error_response('exam_id required', 400)
|
||||
exam = request.env['encoach.exam.custom'].sudo() \
|
||||
.browse(int(exam_id_raw))
|
||||
if not exam.exists():
|
||||
return _error_response('Exam not found', 404)
|
||||
|
||||
user = request.env.user
|
||||
single = bool(getattr(exam, 'single_attempt', True))
|
||||
existing = request.env['encoach.student.attempt'].sudo().search([
|
||||
('exam_id', '=', exam.id),
|
||||
('student_id', '=', user.id),
|
||||
('status', 'in', (
|
||||
'completed', 'scoring', 'scored', 'released',
|
||||
'pending_approval',
|
||||
)),
|
||||
], limit=1)
|
||||
|
||||
allowed = not (single and existing)
|
||||
if not allowed:
|
||||
request.env['encoach.exam.security.event'].sudo().create({
|
||||
'student_id': user.id,
|
||||
'exam_id': exam.id,
|
||||
'event_type': 'multi_attempt_block',
|
||||
'severity': 'alert',
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'allowed': allowed,
|
||||
'security_level': getattr(exam, 'security_level', 'soft'),
|
||||
'single_attempt': single,
|
||||
'existing_attempt_id': existing.id if existing else None,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('single_attempt_check failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teacher/exam/<int:assignment_id>/security/events',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def assignment_events(self, assignment_id, **kw):
|
||||
try:
|
||||
Assign = request.env['encoach.exam.assignment'].sudo()
|
||||
assign = Assign.browse(assignment_id)
|
||||
if not assign.exists():
|
||||
return _error_response('Assignment not found', 404)
|
||||
|
||||
domain = [('exam_id', '=', assign.exam_id.id)] if assign.exam_id else []
|
||||
if assign.student_id:
|
||||
domain.append(('student_id', '=', assign.student_id.id))
|
||||
elif assign.batch_id:
|
||||
Batch = request.env['op.batch'].sudo().browse(assign.batch_id.id)
|
||||
course_regs = request.env['op.student.course'].sudo().search([
|
||||
('batch_id', '=', Batch.id),
|
||||
])
|
||||
user_ids = [r.student_id.user_id.id for r in course_regs
|
||||
if r.student_id and r.student_id.user_id]
|
||||
if user_ids:
|
||||
domain.append(('student_id', 'in', user_ids))
|
||||
|
||||
Ev = request.env['encoach.exam.security.event'].sudo()
|
||||
events = Ev.search(domain, limit=500)
|
||||
return _json_response({
|
||||
'items': [_ser_event(e) for e in events],
|
||||
'total': len(events),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('assignment_events failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teacher/exam/attempt/<int:attempt_id>/security/events',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def attempt_events(self, attempt_id, **kw):
|
||||
try:
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Att.browse(attempt_id)
|
||||
if not attempt.exists():
|
||||
return _error_response('Attempt not found', 404)
|
||||
Ev = request.env['encoach.exam.security.event'].sudo()
|
||||
events = Ev.search([('attempt_id', '=', attempt.id)], limit=500)
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'student_id': attempt.student_id.id if attempt.student_id else None,
|
||||
'student_name': (attempt.student_id.name
|
||||
or attempt.student_id.login or ''),
|
||||
'exam_id': attempt.exam_id.id if attempt.exam_id else None,
|
||||
'status': attempt.status,
|
||||
'items': [_ser_event(e) for e in events],
|
||||
'total': len(events),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('attempt_events failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teacher/exam/<int:assignment_id>/live',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def live_test_console(self, assignment_id, **kw):
|
||||
try:
|
||||
Assign = request.env['encoach.exam.assignment'].sudo()
|
||||
assign = Assign.browse(assignment_id)
|
||||
if not assign.exists() or not assign.exam_id:
|
||||
return _error_response('Assignment not found', 404)
|
||||
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
attempts = Att.search([
|
||||
('exam_id', '=', assign.exam_id.id),
|
||||
('status', '=', 'in_progress'),
|
||||
])
|
||||
|
||||
Ev = request.env['encoach.exam.security.event'].sudo()
|
||||
items = []
|
||||
for att in attempts:
|
||||
last = Ev.search([
|
||||
('attempt_id', '=', att.id),
|
||||
], limit=1, order='occurred_at desc')
|
||||
alerts = Ev.search_count([
|
||||
('attempt_id', '=', att.id),
|
||||
('severity', '=', 'alert'),
|
||||
])
|
||||
items.append({
|
||||
'attempt_id': att.id,
|
||||
'student_id': att.student_id.id if att.student_id else None,
|
||||
'student_name': (att.student_id.name
|
||||
or att.student_id.login or ''),
|
||||
'started_at': (att.started_at.isoformat()
|
||||
if att.started_at else None),
|
||||
'alert_count': alerts,
|
||||
'last_event': _ser_event(last) if last else None,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'assignment_id': assign.id,
|
||||
'exam_id': assign.exam_id.id,
|
||||
'in_progress': items,
|
||||
'total': len(items),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('live_test_console failed')
|
||||
return _error_response(str(e), 500)
|
||||
246
backend/custom_addons/encoach_lms_api/controllers/handwritten.py
Normal file
246
backend/custom_addons/encoach_lms_api/controllers/handwritten.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Handwritten solution upload + AI grading controller.
|
||||
|
||||
* `POST /api/student/handwritten/<material_id>` — upload pages.
|
||||
* `GET /api/student/handwritten/<material_id>` — fetch my latest.
|
||||
* `GET /api/teacher/handwritten/<material_id>` — list submissions.
|
||||
* `POST /api/teacher/handwritten/<submission_id>/review` — teacher overrides AI.
|
||||
|
||||
Image uploads use multipart/form-data so we can stream the raw
|
||||
JPEG/PNG bytes straight into ir.attachment without round-tripping
|
||||
through base64.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http, fields as oo_fields
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ser_submission(s):
|
||||
return {
|
||||
'id': s.id,
|
||||
'student_id': s.student_id.id if s.student_id else None,
|
||||
'student_name': s.student_id.name or s.student_id.login or '',
|
||||
'material_id': s.material_id.id if s.material_id else None,
|
||||
'plan_id': s.plan_id.id if s.plan_id else None,
|
||||
'submitted_at': s.submitted_at.isoformat() if s.submitted_at else None,
|
||||
'status': s.status,
|
||||
'student_note': s.student_note or '',
|
||||
'image_count': len(s.image_attachment_ids),
|
||||
'image_urls': [
|
||||
f'/web/content/{a.id}?download=true'
|
||||
for a in s.image_attachment_ids
|
||||
],
|
||||
'ai_score': s.ai_score,
|
||||
'ai_feedback': s.ai_feedback or '',
|
||||
'ai_extracted_text': s.ai_extracted_text or '',
|
||||
'teacher_score': s.teacher_score,
|
||||
'teacher_feedback': s.teacher_feedback or '',
|
||||
'reviewed_by': s.reviewed_by.name if s.reviewed_by else '',
|
||||
'reviewed_at': s.reviewed_at.isoformat() if s.reviewed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _grade_with_ai(submission):
|
||||
"""Call OCR + LLM to grade the handwritten pages.
|
||||
|
||||
Falls back gracefully when the AI service is unavailable so the
|
||||
submission still records (status='failed') instead of 500-ing.
|
||||
"""
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
except ImportError:
|
||||
submission.write({
|
||||
'status': 'failed',
|
||||
'ai_feedback': 'AI grading service not installed.',
|
||||
})
|
||||
return
|
||||
|
||||
extracted_chunks = []
|
||||
try:
|
||||
try:
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
import io
|
||||
for att in submission.image_attachment_ids:
|
||||
if not att.datas:
|
||||
continue
|
||||
try:
|
||||
raw = base64.b64decode(att.datas)
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
text = pytesseract.image_to_string(img) or ''
|
||||
if text.strip():
|
||||
extracted_chunks.append(text.strip())
|
||||
except Exception:
|
||||
_logger.warning('OCR failed for attachment %s', att.id,
|
||||
exc_info=True)
|
||||
except ImportError:
|
||||
extracted_chunks.append(
|
||||
'[OCR not installed — relying on student note only.]'
|
||||
)
|
||||
extracted = '\n\n'.join(extracted_chunks).strip() \
|
||||
or (submission.student_note or '')
|
||||
|
||||
ai = OpenAIService(request.env, language='en')
|
||||
prompt_user = (
|
||||
"You are a math/science assignment grader. Grade the "
|
||||
"following handwritten solution from 0 to 100 and return "
|
||||
"valid JSON: {\"score\": float, \"feedback\": str}.\n\n"
|
||||
f"Student note:\n{submission.student_note or '(none)'}\n\n"
|
||||
f"OCR-extracted handwriting:\n{extracted or '(empty)'}\n"
|
||||
)
|
||||
try:
|
||||
data = ai.chat_json(
|
||||
system="You are a strict but fair math/science grader.",
|
||||
user=prompt_user,
|
||||
action='handwritten.grade',
|
||||
)
|
||||
except Exception as e:
|
||||
submission.write({
|
||||
'status': 'failed',
|
||||
'ai_extracted_text': extracted,
|
||||
'ai_feedback': f'AI grading error: {e}',
|
||||
})
|
||||
return
|
||||
|
||||
score = float(data.get('score') or 0)
|
||||
feedback = data.get('feedback') or ''
|
||||
submission.write({
|
||||
'status': 'graded',
|
||||
'ai_score': score,
|
||||
'ai_feedback': feedback,
|
||||
'ai_extracted_text': extracted,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('handwritten grading failed')
|
||||
submission.write({
|
||||
'status': 'failed',
|
||||
'ai_feedback': f'Grading pipeline crashed: {e}',
|
||||
})
|
||||
|
||||
|
||||
class HandwrittenController(http.Controller):
|
||||
|
||||
@http.route('/api/student/handwritten/<int:material_id>',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def upload(self, material_id, **kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
Material = request.env['encoach.course.plan.material'].sudo()
|
||||
material = Material.browse(material_id)
|
||||
if not material.exists():
|
||||
return _error_response('Material not found', 404)
|
||||
|
||||
files = request.httprequest.files.getlist('pages') \
|
||||
or request.httprequest.files.getlist('file') \
|
||||
or []
|
||||
if not files:
|
||||
return _error_response('At least one image required', 400)
|
||||
|
||||
student_note = (request.params.get('note') or '').strip()
|
||||
|
||||
Att = request.env['ir.attachment'].sudo()
|
||||
att_ids = []
|
||||
for f in files[:10]:
|
||||
raw = f.read()
|
||||
if not raw:
|
||||
continue
|
||||
rec = Att.create({
|
||||
'name': f.filename or 'handwritten.jpg',
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(raw),
|
||||
'mimetype': f.mimetype or 'image/jpeg',
|
||||
'res_model': 'encoach.handwritten.submission',
|
||||
'res_id': 0,
|
||||
})
|
||||
att_ids.append(rec.id)
|
||||
|
||||
Sub = request.env['encoach.handwritten.submission'].sudo()
|
||||
sub = Sub.create({
|
||||
'student_id': user.id,
|
||||
'material_id': material.id,
|
||||
'student_note': student_note,
|
||||
'image_attachment_ids': [(6, 0, att_ids)],
|
||||
'status': 'submitted',
|
||||
})
|
||||
for aid in att_ids:
|
||||
request.env['ir.attachment'].sudo().browse(aid).res_id = sub.id
|
||||
|
||||
sub.status = 'grading'
|
||||
try:
|
||||
_grade_with_ai(sub)
|
||||
except Exception:
|
||||
_logger.exception('async grading inline failed')
|
||||
return _json_response(_ser_submission(sub), 201)
|
||||
except Exception as e:
|
||||
_logger.exception('handwritten upload failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/student/handwritten/<int:material_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def my_submission(self, material_id, **kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
Sub = request.env['encoach.handwritten.submission'].sudo()
|
||||
sub = Sub.search([
|
||||
('material_id', '=', material_id),
|
||||
('student_id', '=', user.id),
|
||||
], limit=1, order='submitted_at desc')
|
||||
return _json_response({
|
||||
'submission': _ser_submission(sub) if sub else None,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('my_handwritten_submission failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teacher/handwritten/<int:material_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_submissions(self, material_id, **kw):
|
||||
try:
|
||||
Sub = request.env['encoach.handwritten.submission'].sudo()
|
||||
subs = Sub.search([('material_id', '=', material_id)])
|
||||
return _json_response({
|
||||
'items': [_ser_submission(s) for s in subs],
|
||||
'total': len(subs),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_handwritten failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/teacher/handwritten/<int:submission_id>/review',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def review_submission(self, submission_id, **_kw):
|
||||
try:
|
||||
Sub = request.env['encoach.handwritten.submission'].sudo()
|
||||
sub = Sub.browse(submission_id)
|
||||
if not sub.exists():
|
||||
return _error_response('Submission not found', 404)
|
||||
try:
|
||||
raw = request.httprequest.get_data(as_text=True)
|
||||
body = json.loads(raw) if raw else {}
|
||||
except (TypeError, ValueError):
|
||||
body = {}
|
||||
score = body.get('score')
|
||||
feedback = body.get('feedback') or ''
|
||||
sub.write({
|
||||
'teacher_score': float(score) if score is not None else False,
|
||||
'teacher_feedback': feedback,
|
||||
'reviewed_by': request.env.user.id,
|
||||
'reviewed_at': oo_fields.Datetime.now(),
|
||||
'status': 'reviewed',
|
||||
})
|
||||
return _json_response(_ser_submission(sub))
|
||||
except Exception as e:
|
||||
_logger.exception('review_handwritten failed')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -0,0 +1,705 @@
|
||||
"""Live (Jitsi-backed) classroom sessions controller.
|
||||
|
||||
Endpoints (Phase 3 — 2026-04-30):
|
||||
* `GET /api/live-sessions` — list (filters)
|
||||
* `POST /api/live-sessions` — create
|
||||
* `GET /api/live-sessions/<id>` — read
|
||||
* `PATCH /api/live-sessions/<id>` — update
|
||||
* `DELETE /api/live-sessions/<id>` — cancel
|
||||
* `POST /api/live-sessions/<id>/notify` — email enrolled students
|
||||
* `POST /api/live-sessions/<id>/join` — student/teacher posts on entering Jitsi
|
||||
* `POST /api/live-sessions/<id>/leave` — student/teacher posts on leaving Jitsi
|
||||
* `POST /api/live-sessions/<id>/end` — host ends session
|
||||
* `POST /api/live-sessions/<id>/recording` — host stores recording URL
|
||||
* `POST /api/live-sessions/<id>/remove-participant` — host kicks user (with reason)
|
||||
* `GET /api/live-sessions/<id>/attendance` — host attendance roll
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo import http, fields as oo_fields
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_body():
|
||||
try:
|
||||
body = request.httprequest.get_data(as_text=True)
|
||||
return json.loads(body) if body else {}
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_dt(raw):
|
||||
"""Coerce an ISO-8601 / Odoo / 'YYYY-MM-DDTHH:MM' datetime string
|
||||
into the format Odoo persists ('%Y-%m-%d %H:%M:%S')."""
|
||||
if not raw:
|
||||
return raw
|
||||
if isinstance(raw, datetime):
|
||||
return raw.strftime('%Y-%m-%d %H:%M:%S')
|
||||
s = str(raw).strip()
|
||||
# Strip a trailing Z and turn 'T' into ' '.
|
||||
if s.endswith('Z'):
|
||||
s = s[:-1]
|
||||
s = s.replace('T', ' ')
|
||||
if '.' in s:
|
||||
s = s.split('.', 1)[0]
|
||||
if '+' in s[10:]:
|
||||
s = s.rsplit('+', 1)[0]
|
||||
if len(s) == 16:
|
||||
s = s + ':00'
|
||||
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M'):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
continue
|
||||
return s
|
||||
|
||||
|
||||
def _ser_session(s, env=None):
|
||||
return {
|
||||
'id': s.id,
|
||||
'title': s.title or '',
|
||||
'description': s.description or '',
|
||||
'course_id': s.course_id.id if s.course_id else None,
|
||||
'course_name': s.course_id.name if s.course_id else '',
|
||||
'batch_id': s.batch_id.id if s.batch_id else None,
|
||||
'batch_name': s.batch_id.name if s.batch_id else '',
|
||||
'plan_id': s.plan_id.id if s.plan_id else None,
|
||||
'plan_name': s.plan_id.name if s.plan_id else '',
|
||||
'host_id': s.host_id.id if s.host_id else None,
|
||||
'host_name': s.host_id.name if s.host_id else '',
|
||||
'scheduled_at': s.scheduled_at.isoformat() if s.scheduled_at else None,
|
||||
'duration_min': s.duration_min,
|
||||
'actual_started_at': (s.actual_started_at.isoformat()
|
||||
if s.actual_started_at else None),
|
||||
'actual_ended_at': (s.actual_ended_at.isoformat()
|
||||
if s.actual_ended_at else None),
|
||||
'status': s.status,
|
||||
'room_name': s.room_name,
|
||||
'waiting_room': s.waiting_room,
|
||||
'recording_enabled': s.recording_enabled,
|
||||
'recording_url': s.recording_url or '',
|
||||
'auto_attendance_minutes': s.auto_attendance_minutes,
|
||||
'late_entry_minutes': s.late_entry_minutes,
|
||||
'notification_sent': s.notification_sent,
|
||||
'participant_count': len(s.participant_ids),
|
||||
}
|
||||
|
||||
|
||||
def _ser_participant(p):
|
||||
return {
|
||||
'id': p.id,
|
||||
'session_id': p.session_id.id,
|
||||
'user_id': p.user_id.id,
|
||||
'user_name': p.user_id.name or p.user_id.login or '',
|
||||
'joined_at': p.joined_at.isoformat() if p.joined_at else None,
|
||||
'left_at': p.left_at.isoformat() if p.left_at else None,
|
||||
'duration_seconds': p.duration_seconds,
|
||||
'attendance_status': p.attendance_status,
|
||||
'removed_reason': p.removed_reason or '',
|
||||
'is_host': p.is_host,
|
||||
}
|
||||
|
||||
|
||||
def _enrolled_user_ids(session):
|
||||
"""Return res.users IDs that should be notified / can join."""
|
||||
user_ids = set()
|
||||
if session.batch_id:
|
||||
regs = request.env['op.student.course'].sudo().search([
|
||||
('batch_id', '=', session.batch_id.id),
|
||||
])
|
||||
for r in regs:
|
||||
if r.student_id and r.student_id.user_id:
|
||||
user_ids.add(r.student_id.user_id.id)
|
||||
elif session.course_id:
|
||||
regs = request.env['op.student.course'].sudo().search([
|
||||
('course_id', '=', session.course_id.id),
|
||||
])
|
||||
for r in regs:
|
||||
if r.student_id and r.student_id.user_id:
|
||||
user_ids.add(r.student_id.user_id.id)
|
||||
if session.plan_id:
|
||||
Assign = request.env['encoach.course.plan.assignment'].sudo() \
|
||||
if 'encoach.course.plan.assignment' in request.env else None
|
||||
if Assign is not None:
|
||||
try:
|
||||
ass = Assign.search([('plan_id', '=', session.plan_id.id)])
|
||||
for a in ass:
|
||||
if a.user_id:
|
||||
user_ids.add(a.user_id.id)
|
||||
except Exception:
|
||||
pass
|
||||
if session.host_id:
|
||||
user_ids.discard(session.host_id.id)
|
||||
return list(user_ids)
|
||||
|
||||
|
||||
def _build_ics(session, recipient_email=''):
|
||||
"""Return raw iCalendar bytes for the session.
|
||||
|
||||
Adding an .ics attachment lets every modern mail client (Gmail,
|
||||
Outlook, Apple Mail, mobile clients) drop the session straight
|
||||
into the recipient's calendar with one click — addressing the
|
||||
"Advance scheduling + calendar sync" requirement without us
|
||||
having to OAuth into anyone's Google/Apple calendar.
|
||||
"""
|
||||
def _fmt(dt):
|
||||
if not dt:
|
||||
return ''
|
||||
if isinstance(dt, str):
|
||||
try:
|
||||
dt = datetime.strptime(dt, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt)
|
||||
except ValueError:
|
||||
return ''
|
||||
return dt.strftime('%Y%m%dT%H%M%SZ')
|
||||
|
||||
def _esc(s):
|
||||
return (s or '').replace('\\', '\\\\').replace(',', '\\,') \
|
||||
.replace(';', '\\;').replace('\n', '\\n')
|
||||
|
||||
start = session.scheduled_at
|
||||
if isinstance(start, str):
|
||||
try:
|
||||
start = datetime.strptime(start, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
start = datetime.utcnow()
|
||||
end = start + timedelta(minutes=session.duration_min or 60)
|
||||
uid = f'live-{session.id}-{secrets.token_hex(4)}@encoach'
|
||||
|
||||
description = (
|
||||
f"Live session hosted by {session.host_id.name}.\\n"
|
||||
f"Course: {session.course_id.name or session.plan_id.name or ''}\\n"
|
||||
f"Open from your dashboard when it starts."
|
||||
)
|
||||
|
||||
base_url = (request.env['ir.config_parameter'].sudo()
|
||||
.get_param('web.base.url') or '').rstrip('/')
|
||||
location = f'{base_url}/live/{session.id}' if base_url else f'/live/{session.id}'
|
||||
|
||||
lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//EnCoach//Live Sessions//EN',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:REQUEST',
|
||||
'BEGIN:VEVENT',
|
||||
f'UID:{uid}',
|
||||
f'DTSTAMP:{_fmt(datetime.utcnow())}',
|
||||
f'DTSTART:{_fmt(start)}',
|
||||
f'DTEND:{_fmt(end)}',
|
||||
f'SUMMARY:{_esc(session.title)}',
|
||||
f'DESCRIPTION:{description}',
|
||||
f'LOCATION:{_esc(location)}',
|
||||
f'ORGANIZER;CN={_esc(session.host_id.name)}:mailto:{session.host_id.email or "noreply@encoach.test"}',
|
||||
]
|
||||
if recipient_email:
|
||||
lines.append(
|
||||
f'ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:'
|
||||
f'mailto:{recipient_email}'
|
||||
)
|
||||
lines.extend([
|
||||
'STATUS:CONFIRMED',
|
||||
'BEGIN:VALARM',
|
||||
'TRIGGER:-PT15M',
|
||||
'ACTION:DISPLAY',
|
||||
f'DESCRIPTION:Reminder: {_esc(session.title)}',
|
||||
'END:VALARM',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
])
|
||||
return '\r\n'.join(lines).encode('utf-8')
|
||||
|
||||
|
||||
def _send_session_email(session):
|
||||
"""Send an HTML invite + .ics attachment to every enrolled student."""
|
||||
user_ids = _enrolled_user_ids(session)
|
||||
if not user_ids:
|
||||
return 0
|
||||
Mail = request.env['mail.mail'].sudo()
|
||||
Users = request.env['res.users'].sudo()
|
||||
Att = request.env['ir.attachment'].sudo()
|
||||
sent = 0
|
||||
base_url = (request.env['ir.config_parameter'].sudo()
|
||||
.get_param('web.base.url') or '').rstrip('/')
|
||||
join_url = f'{base_url}/live/{session.id}' if base_url else f'/live/{session.id}'
|
||||
for user in Users.browse(user_ids):
|
||||
if not user.email:
|
||||
continue
|
||||
body = (
|
||||
f"<p>Hi {user.name},</p>"
|
||||
f"<p>You have a new live session: <b>{session.title}</b>.</p>"
|
||||
f"<p>When: {session.scheduled_at} ({session.duration_min} min)<br/>"
|
||||
f"Course: {session.course_id.name or session.plan_id.name or ''}<br/>"
|
||||
f"Host: {session.host_id.name}</p>"
|
||||
f'<p><a href="{join_url}" '
|
||||
f'style="display:inline-block;background:#1f2937;color:#fff;'
|
||||
f'padding:10px 16px;border-radius:8px;text-decoration:none">'
|
||||
f'Join from dashboard</a></p>'
|
||||
f"<p style=\"color:#666;font-size:12px\">"
|
||||
f"The attached .ics will add this session to your calendar.</p>"
|
||||
)
|
||||
try:
|
||||
ics_bytes = _build_ics(session, recipient_email=user.email)
|
||||
att = Att.create({
|
||||
'name': f'session-{session.id}.ics',
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(ics_bytes),
|
||||
'mimetype': 'text/calendar; method=REQUEST; charset=utf-8',
|
||||
'res_model': 'encoach.live.session',
|
||||
'res_id': session.id,
|
||||
})
|
||||
mail = Mail.create({
|
||||
'subject': f'[EnCoach] {session.title}',
|
||||
'body_html': body,
|
||||
'email_to': user.email,
|
||||
'email_from': (request.env['ir.config_parameter'].sudo()
|
||||
.get_param('mail.from') or 'noreply@encoach.test'),
|
||||
'attachment_ids': [(4, att.id)],
|
||||
})
|
||||
mail.send()
|
||||
sent += 1
|
||||
except Exception:
|
||||
_logger.warning('failed to send session invite to %s',
|
||||
user.email, exc_info=True)
|
||||
return sent
|
||||
|
||||
|
||||
def _user_can_host(user, session):
|
||||
if not user or not user.id:
|
||||
return False
|
||||
if user.id == session.host_id.id:
|
||||
return True
|
||||
if user.has_group('base.group_system'):
|
||||
return True
|
||||
user_type = getattr(user, 'user_type', '') or ''
|
||||
return user_type in ('admin', 'corporate', 'mastercorporate', 'developer')
|
||||
|
||||
|
||||
class LiveSessionController(http.Controller):
|
||||
|
||||
# ── List + create ──────────────────────────────────────────────
|
||||
|
||||
@http.route('/api/live-sessions',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_sessions(self, **kw):
|
||||
try:
|
||||
Sess = request.env['encoach.live.session'].sudo()
|
||||
domain = []
|
||||
for f in ('course_id', 'batch_id', 'plan_id', 'host_id'):
|
||||
v = kw.get(f)
|
||||
if v:
|
||||
try:
|
||||
domain.append((f, '=', int(v)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
status = kw.get('status')
|
||||
if status:
|
||||
domain.append(('status', '=', status))
|
||||
mine = (kw.get('mine') or '').lower() in ('1', 'true', 'yes')
|
||||
if mine:
|
||||
user = request.env.user
|
||||
regs = request.env['op.student.course'].sudo().search([
|
||||
('student_id.user_id', '=', user.id),
|
||||
])
|
||||
course_ids = list({r.course_id.id for r in regs if r.course_id})
|
||||
batch_ids = list({r.batch_id.id for r in regs if r.batch_id})
|
||||
or_clauses = []
|
||||
if course_ids:
|
||||
or_clauses.append(('course_id', 'in', course_ids))
|
||||
if batch_ids:
|
||||
or_clauses.append(('batch_id', 'in', batch_ids))
|
||||
or_clauses.append(('host_id', '=', user.id))
|
||||
if len(or_clauses) > 1:
|
||||
domain.extend(['|'] * (len(or_clauses) - 1))
|
||||
domain.extend(or_clauses)
|
||||
sessions = Sess.search(domain, order='scheduled_at desc', limit=200)
|
||||
return _json_response({
|
||||
'items': [_ser_session(s) for s in sessions],
|
||||
'total': len(sessions),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_sessions failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_session(self, **_kw):
|
||||
try:
|
||||
body = _parse_body()
|
||||
vals = {
|
||||
'title': body.get('title') or 'Live session',
|
||||
'description': body.get('description') or '',
|
||||
'scheduled_at': _parse_dt(body.get('scheduled_at')),
|
||||
'duration_min': int(body.get('duration_min') or 60),
|
||||
'host_id': request.env.user.id,
|
||||
'waiting_room': bool(body.get('waiting_room', True)),
|
||||
'recording_enabled': bool(body.get('recording_enabled', False)),
|
||||
'auto_attendance_minutes': int(body.get('auto_attendance_minutes') or 10),
|
||||
'late_entry_minutes': int(body.get('late_entry_minutes') or 15),
|
||||
}
|
||||
for f in ('course_id', 'batch_id', 'plan_id', 'entity_id'):
|
||||
if body.get(f):
|
||||
try:
|
||||
vals[f] = int(body[f])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not vals.get('scheduled_at'):
|
||||
return _error_response('scheduled_at required', 400)
|
||||
|
||||
sess = request.env['encoach.live.session'].sudo().create(vals)
|
||||
|
||||
if body.get('notify', True):
|
||||
try:
|
||||
sent = _send_session_email(sess)
|
||||
sess.notification_sent = bool(sent)
|
||||
except Exception:
|
||||
_logger.warning('email batch failed', exc_info=True)
|
||||
|
||||
return _json_response(_ser_session(sess), 201)
|
||||
except Exception as e:
|
||||
_logger.exception('create_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def read_session(self, session_id, **kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
payload = _ser_session(sess)
|
||||
payload['participants'] = [_ser_participant(p)
|
||||
for p in sess.participant_ids]
|
||||
payload['enrolled_user_ids'] = _enrolled_user_ids(sess)
|
||||
return _json_response(payload)
|
||||
except Exception as e:
|
||||
_logger.exception('read_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>',
|
||||
type='http', auth='public', methods=['PATCH'], csrf=False)
|
||||
@jwt_required
|
||||
def patch_session(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _parse_body()
|
||||
vals = {}
|
||||
for f in ('title', 'description', 'duration_min',
|
||||
'waiting_room', 'recording_enabled',
|
||||
'auto_attendance_minutes', 'late_entry_minutes',
|
||||
'status', 'recording_url'):
|
||||
if f in body:
|
||||
vals[f] = body[f]
|
||||
if 'scheduled_at' in body:
|
||||
vals['scheduled_at'] = _parse_dt(body['scheduled_at'])
|
||||
sess.write(vals)
|
||||
return _json_response(_ser_session(sess))
|
||||
except Exception as e:
|
||||
_logger.exception('patch_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>',
|
||||
type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def cancel_session(self, session_id, **kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
sess.status = 'cancelled'
|
||||
return _json_response({'ok': True})
|
||||
except Exception as e:
|
||||
_logger.exception('cancel_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/ics',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def session_ics(self, session_id, **kw):
|
||||
"""Download a single .ics file the user can drop into their calendar."""
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
email = request.env.user.email or ''
|
||||
ics = _build_ics(sess, recipient_email=email)
|
||||
return request.make_response(ics, headers=[
|
||||
('Content-Type', 'text/calendar; method=REQUEST; charset=utf-8'),
|
||||
('Content-Disposition',
|
||||
f'attachment; filename="session-{sess.id}.ics"'),
|
||||
])
|
||||
except Exception as e:
|
||||
_logger.exception('session_ics failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/notify',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def notify_session(self, session_id, **kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
sent = _send_session_email(sess)
|
||||
sess.notification_sent = True
|
||||
return _json_response({'sent': sent})
|
||||
except Exception as e:
|
||||
_logger.exception('notify_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── Lifecycle: join / leave / end ──────────────────────────────
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/join',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def join_session(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if sess.status == 'cancelled':
|
||||
return _error_response('Session was cancelled', 400)
|
||||
user = request.env.user
|
||||
now = oo_fields.Datetime.now()
|
||||
|
||||
is_host = user.id == sess.host_id.id
|
||||
|
||||
if not is_host and sess.late_entry_minutes and sess.actual_started_at:
|
||||
cutoff = sess.actual_started_at + timedelta(
|
||||
minutes=sess.late_entry_minutes)
|
||||
if now > cutoff:
|
||||
return _error_response(
|
||||
'Late entry refused — the session has been '
|
||||
'live for too long.', 403,
|
||||
)
|
||||
|
||||
if not sess.actual_started_at:
|
||||
sess.write({
|
||||
'actual_started_at': now,
|
||||
'status': 'live',
|
||||
})
|
||||
|
||||
Part = request.env['encoach.live.session.participant'].sudo()
|
||||
part = Part.search([
|
||||
('session_id', '=', sess.id),
|
||||
('user_id', '=', user.id),
|
||||
], limit=1)
|
||||
if part:
|
||||
part.write({
|
||||
'joined_at': now,
|
||||
'attendance_status': 'attending',
|
||||
})
|
||||
else:
|
||||
part = Part.create({
|
||||
'session_id': sess.id,
|
||||
'user_id': user.id,
|
||||
'joined_at': now,
|
||||
'attendance_status': 'attending',
|
||||
'is_host': is_host,
|
||||
})
|
||||
return _json_response({
|
||||
'ok': True,
|
||||
'participant': _ser_participant(part),
|
||||
'room_name': sess.room_name,
|
||||
'is_host': is_host,
|
||||
'jitsi_jwt': '',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('join_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/leave',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def leave_session(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
user = request.env.user
|
||||
now = oo_fields.Datetime.now()
|
||||
Part = request.env['encoach.live.session.participant'].sudo()
|
||||
part = Part.search([
|
||||
('session_id', '=', sess.id),
|
||||
('user_id', '=', user.id),
|
||||
], limit=1)
|
||||
if not part:
|
||||
return _json_response({'ok': True, 'no_participant': True})
|
||||
extra = 0
|
||||
if part.joined_at:
|
||||
extra = int((now - part.joined_at).total_seconds())
|
||||
new_total = (part.duration_seconds or 0) + max(0, extra)
|
||||
new_status = part.attendance_status
|
||||
if (sess.auto_attendance_minutes
|
||||
and new_total >= sess.auto_attendance_minutes * 60
|
||||
and new_status not in ('removed',)):
|
||||
new_status = 'present'
|
||||
elif new_status == 'attending':
|
||||
new_status = 'left_early'
|
||||
part.write({
|
||||
'left_at': now,
|
||||
'duration_seconds': new_total,
|
||||
'attendance_status': new_status,
|
||||
})
|
||||
return _json_response({
|
||||
'ok': True,
|
||||
'participant': _ser_participant(part),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('leave_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/end',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def end_session(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
now = oo_fields.Datetime.now()
|
||||
sess.write({
|
||||
'status': 'ended',
|
||||
'actual_ended_at': now,
|
||||
})
|
||||
for p in sess.participant_ids:
|
||||
if p.attendance_status == 'attending':
|
||||
extra = 0
|
||||
if p.joined_at:
|
||||
extra = int((now - p.joined_at).total_seconds())
|
||||
total = (p.duration_seconds or 0) + max(0, extra)
|
||||
new_status = 'left_early'
|
||||
if (sess.auto_attendance_minutes
|
||||
and total >= sess.auto_attendance_minutes * 60):
|
||||
new_status = 'present'
|
||||
p.write({
|
||||
'left_at': now,
|
||||
'duration_seconds': total,
|
||||
'attendance_status': new_status,
|
||||
})
|
||||
return _json_response({'ok': True})
|
||||
except Exception as e:
|
||||
_logger.exception('end_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/recording',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def store_recording(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _parse_body()
|
||||
url = (body.get('url') or '').strip()
|
||||
if not url:
|
||||
return _error_response('url required', 400)
|
||||
sess.recording_url = url
|
||||
return _json_response({'ok': True, 'url': url})
|
||||
except Exception as e:
|
||||
_logger.exception('store_recording failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/remove-participant',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def remove_participant(self, session_id, **_kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
if not _user_can_host(request.env.user, sess):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _parse_body()
|
||||
user_id = body.get('user_id')
|
||||
reason = (body.get('reason') or '').strip()
|
||||
if not user_id or not reason:
|
||||
return _error_response(
|
||||
'user_id and reason required (audit trail)', 400,
|
||||
)
|
||||
Part = request.env['encoach.live.session.participant'].sudo()
|
||||
part = Part.search([
|
||||
('session_id', '=', sess.id),
|
||||
('user_id', '=', int(user_id)),
|
||||
], limit=1)
|
||||
if not part:
|
||||
return _error_response('Participant not found', 404)
|
||||
part.write({
|
||||
'attendance_status': 'removed',
|
||||
'removed_reason': reason,
|
||||
'left_at': oo_fields.Datetime.now(),
|
||||
})
|
||||
return _json_response({'ok': True,
|
||||
'participant': _ser_participant(part)})
|
||||
except Exception as e:
|
||||
_logger.exception('remove_participant failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/live-sessions/<int:session_id>/attendance',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def attendance_roll(self, session_id, **kw):
|
||||
try:
|
||||
sess = request.env['encoach.live.session'].sudo().browse(session_id)
|
||||
if not sess.exists():
|
||||
return _error_response('Session not found', 404)
|
||||
|
||||
roll = []
|
||||
seen_user_ids = set()
|
||||
for p in sess.participant_ids:
|
||||
seen_user_ids.add(p.user_id.id)
|
||||
roll.append({
|
||||
'user_id': p.user_id.id,
|
||||
'user_name': p.user_id.name or p.user_id.login or '',
|
||||
'attendance_status': p.attendance_status,
|
||||
'duration_seconds': p.duration_seconds,
|
||||
'removed_reason': p.removed_reason or '',
|
||||
})
|
||||
|
||||
for uid in _enrolled_user_ids(sess):
|
||||
if uid in seen_user_ids:
|
||||
continue
|
||||
user = request.env['res.users'].sudo().browse(uid)
|
||||
roll.append({
|
||||
'user_id': uid,
|
||||
'user_name': user.name or user.login or '',
|
||||
'attendance_status': 'not_joined',
|
||||
'duration_seconds': 0,
|
||||
'removed_reason': '',
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'session_id': sess.id,
|
||||
'status': sess.status,
|
||||
'roll': roll,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('attendance_roll failed')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Student-facing personalized AI training plan endpoint.
|
||||
|
||||
`GET /api/student/personalized-plan` — fetch the latest personalized
|
||||
plan for the calling student (or null).
|
||||
`POST /api/student/personalized-plan` — generate a new plan based on
|
||||
the student's weakness data.
|
||||
|
||||
The actual curriculum generation reuses
|
||||
``encoach_ai_course.services.course_plan_pipeline.CoursePlanPipeline``
|
||||
so the personalized plan is structurally identical to a teacher-built
|
||||
one (weeks + materials + exercises), just flagged as personalized
|
||||
and auto-assigned to the requesting student.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')
|
||||
|
||||
SKILL_FIELDS = (
|
||||
('reading', 'reading_band'),
|
||||
('listening', 'listening_band'),
|
||||
('writing', 'writing_band'),
|
||||
('speaking', 'speaking_band'),
|
||||
)
|
||||
|
||||
|
||||
def _ser_plan(plan):
|
||||
if not plan or not plan.exists():
|
||||
return None
|
||||
return {
|
||||
'id': plan.id,
|
||||
'name': plan.name or '',
|
||||
'cefr_level': getattr(plan, 'cefr_level', '') or '',
|
||||
'total_weeks': getattr(plan, 'total_weeks', 0) or 0,
|
||||
'is_personalized': bool(getattr(plan, 'is_personalized', False)),
|
||||
'is_mandatory': bool(getattr(plan, 'is_mandatory', False)),
|
||||
'created_at': plan.create_date.isoformat()
|
||||
if plan.create_date else None,
|
||||
}
|
||||
|
||||
|
||||
def _compute_weakness(user):
|
||||
"""Return per-skill averages + the top weakness label.
|
||||
|
||||
Pulls every reportable attempt for ``user`` and averages the bands.
|
||||
Skills with band < 5.0 (≈ B1) are flagged as a weakness.
|
||||
"""
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
attempts = Att.search([
|
||||
('student_id', '=', user.id),
|
||||
('status', 'in', list(REPORTABLE_STATUSES)),
|
||||
])
|
||||
sums = defaultdict(float)
|
||||
counts = defaultdict(int)
|
||||
for att in attempts:
|
||||
for skill, fname in SKILL_FIELDS:
|
||||
v = getattr(att, fname, None)
|
||||
if v and v > 0:
|
||||
sums[skill] += v
|
||||
counts[skill] += 1
|
||||
averages = {}
|
||||
for skill, _f in SKILL_FIELDS:
|
||||
if counts[skill]:
|
||||
averages[skill] = round(sums[skill] / counts[skill], 2)
|
||||
else:
|
||||
averages[skill] = None
|
||||
weak = [s for s, v in averages.items() if v is not None and v < 5.0]
|
||||
weakest = None
|
||||
weakest_score = 9.0
|
||||
for s, v in averages.items():
|
||||
if v is not None and v < weakest_score:
|
||||
weakest = s
|
||||
weakest_score = v
|
||||
return {
|
||||
'attempts': len(attempts),
|
||||
'averages': averages,
|
||||
'weak_skills': weak,
|
||||
'weakest_skill': weakest,
|
||||
}
|
||||
|
||||
|
||||
class PersonalizedPlanController(http.Controller):
|
||||
|
||||
@http.route('/api/student/personalized-plan',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_plan(self, **kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
Plan = request.env['encoach.course.plan'].sudo()
|
||||
plan = Plan.search([
|
||||
('is_personalized', '=', True),
|
||||
('personalized_for_id', '=', user.id),
|
||||
], order='create_date desc', limit=1)
|
||||
return _json_response({
|
||||
'plan': _ser_plan(plan),
|
||||
'weakness': _compute_weakness(user),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('get_personalized_plan failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/student/personalized-plan',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def generate_plan(self, **_kw):
|
||||
try:
|
||||
body = {}
|
||||
try:
|
||||
raw = request.httprequest.get_data(as_text=True)
|
||||
body = json.loads(raw) if raw else {}
|
||||
except (TypeError, ValueError):
|
||||
body = {}
|
||||
|
||||
user = request.env.user
|
||||
weakness = _compute_weakness(user)
|
||||
weak_skills = weakness['weak_skills'] or [
|
||||
weakness['weakest_skill']
|
||||
] if weakness['weakest_skill'] else ['reading']
|
||||
cefr = (body.get('cefr_level') or 'a2').lower()
|
||||
total_weeks = int(body.get('total_weeks') or 4)
|
||||
user_focus = (body.get('focus') or '').strip()
|
||||
|
||||
title = (
|
||||
f"Personalized {total_weeks}-week plan for {user.name or 'me'}"
|
||||
)
|
||||
if user_focus:
|
||||
title = f"Personalized: {user_focus}"
|
||||
skills_division = (
|
||||
', '.join(f'{s} 40%' for s in weak_skills[:2])
|
||||
or 'reading 40%, listening 30%, writing 20%, speaking 10%'
|
||||
)
|
||||
notes_parts = [
|
||||
"AI-generated personalized plan based on the student's "
|
||||
"weakest skills.",
|
||||
f"Weakest skill so far: {weakness['weakest_skill'] or 'unknown'}.",
|
||||
f"Per-skill averages: {weakness['averages']}.",
|
||||
]
|
||||
if user_focus:
|
||||
notes_parts.append(f"Student-requested focus: {user_focus}")
|
||||
notes = ' '.join(notes_parts)
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_ai_course.services.course_plan_pipeline \
|
||||
import CoursePlanPipeline
|
||||
except ImportError:
|
||||
return _error_response(
|
||||
'AI course plan pipeline not installed', 500,
|
||||
)
|
||||
|
||||
pipeline = CoursePlanPipeline(request.env, language='en')
|
||||
brief = {
|
||||
'title': title,
|
||||
'cefr_level': cefr,
|
||||
'total_weeks': total_weeks,
|
||||
'contact_hours_per_week': 6,
|
||||
'skills_division': skills_division,
|
||||
'grammar_focus': body.get('grammar_focus') or [],
|
||||
'resources': [],
|
||||
'learner_profile': (
|
||||
f"Self-paced learner targeting their weakest "
|
||||
f"skill ({weakness['weakest_skill'] or 'reading'}). "
|
||||
f"Recent IELTS averages: "
|
||||
f"{weakness['averages']}."
|
||||
),
|
||||
'notes': notes,
|
||||
}
|
||||
plan = pipeline.generate_plan(brief)
|
||||
try:
|
||||
plan.write({
|
||||
'is_personalized': True,
|
||||
'personalized_for_id': user.id,
|
||||
'weakness_summary': json.dumps(weakness),
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning('could not flag plan personalized', exc_info=True)
|
||||
|
||||
try:
|
||||
Assign = request.env['encoach.course.plan.assignment'].sudo()
|
||||
Assign.create({
|
||||
'plan_id': plan.id,
|
||||
'mode': 'students',
|
||||
'student_user_ids': [(6, 0, [user.id])],
|
||||
'message': 'Your personalized plan is ready.',
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning('could not auto-assign personalized plan',
|
||||
exc_info=True)
|
||||
|
||||
return _json_response({
|
||||
'plan': _ser_plan(plan),
|
||||
'weakness': weakness,
|
||||
}, 201)
|
||||
except Exception as e:
|
||||
_logger.exception('generate_personalized_plan failed')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -133,6 +133,46 @@ def _build_attempt_domain(kw, reportable=True):
|
||||
domain.append(('student_id', '=', int(user_id)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Phase 1 (2026-04-29): branch / subject / gender filters drive
|
||||
# the comparative-analysis pages in the admin Reports section.
|
||||
# gender + branch live on op.student; we resolve them to a set of
|
||||
# res.users ids and attach a single ('student_id', 'in', [...])
|
||||
# clause. subject_id lives on encoach.exam.custom directly.
|
||||
student_filters = []
|
||||
branch_id_raw = kw.get('branch_id')
|
||||
if branch_id_raw:
|
||||
try:
|
||||
student_filters.append(('branch_id', '=', int(branch_id_raw)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
gender_raw = (kw.get('gender') or '').strip().lower()
|
||||
if gender_raw in ('m', 'male', 'f', 'female', 'o', 'other'):
|
||||
gender_norm = {
|
||||
'm': 'm', 'male': 'm',
|
||||
'f': 'f', 'female': 'f',
|
||||
'o': 'o', 'other': 'o',
|
||||
}[gender_raw]
|
||||
student_filters.append(('gender', '=', gender_norm))
|
||||
if student_filters:
|
||||
try:
|
||||
from odoo.http import request as _req2
|
||||
students = _req2.env['op.student'].sudo().search(student_filters)
|
||||
user_ids = [s.user_id.id for s in students if s.user_id]
|
||||
if user_ids:
|
||||
domain.append(('student_id', 'in', user_ids))
|
||||
else:
|
||||
domain.append(('student_id', '=', -1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
subject_id_raw = kw.get('subject_id')
|
||||
if subject_id_raw:
|
||||
try:
|
||||
domain.append(('exam_id.subject_id', '=', int(subject_id_raw)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
since = kw.get('since')
|
||||
if since:
|
||||
try:
|
||||
@@ -285,7 +325,10 @@ class ReportsController(http.Controller):
|
||||
f"user={kw.get('user_id') or kw.get('student_id') or ''};"
|
||||
f"thr={kw.get('threshold') or ''};"
|
||||
f"months={kw.get('months') or ''};"
|
||||
f"period={kw.get('period') or ''}"
|
||||
f"period={kw.get('period') or ''};"
|
||||
f"branch={kw.get('branch_id') or ''};"
|
||||
f"subject={kw.get('subject_id') or ''};"
|
||||
f"gender={kw.get('gender') or ''}"
|
||||
)
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
@@ -520,7 +563,13 @@ class ReportsController(http.Controller):
|
||||
@jwt_required
|
||||
def filters(self, **kw):
|
||||
"""Lightweight list of entities + students/users we have attempts for.
|
||||
Used by the filter dropdowns on all three Reports pages."""
|
||||
Used by the filter dropdowns on all three Reports pages.
|
||||
|
||||
Phase 1 (2026-04-29): also returns the branch and subject lists
|
||||
plus the canonical gender option set so the admin Reports pages
|
||||
can hydrate the new comparative-analysis filters in one round
|
||||
trip.
|
||||
"""
|
||||
try:
|
||||
Ent = request.env['encoach.entity'].sudo()
|
||||
entities = [{'id': e.id, 'name': e.name or ''}
|
||||
@@ -533,9 +582,31 @@ class ReportsController(http.Controller):
|
||||
for u in att_students.sorted('name')
|
||||
if u and u.id > 0]
|
||||
|
||||
Branch = request.env['encoach.lms.branch'].sudo()
|
||||
branches = [
|
||||
{'id': b.id, 'name': b.name or '',
|
||||
'entity_id': b.entity_id.id if b.entity_id else None}
|
||||
for b in Branch.search([], order='name')
|
||||
]
|
||||
|
||||
Subject = request.env['encoach.subject'].sudo()
|
||||
subjects = [
|
||||
{'id': s.id, 'name': s.name or '', 'code': s.code or ''}
|
||||
for s in Subject.search([], order='name')
|
||||
]
|
||||
|
||||
genders = [
|
||||
{'value': 'm', 'label': 'Male'},
|
||||
{'value': 'f', 'label': 'Female'},
|
||||
{'value': 'o', 'label': 'Other'},
|
||||
]
|
||||
|
||||
return _json_response({
|
||||
'entities': entities,
|
||||
'students': students,
|
||||
'branches': branches,
|
||||
'subjects': subjects,
|
||||
'genders': genders,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('reports filters failed')
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""CSV / PDF export for the admin Reports section.
|
||||
|
||||
Phase 2 (2026-04-30): the admin asked for downloadable copies of the
|
||||
existing reports. We expose two formats — CSV (always works, no
|
||||
extra deps) and PDF (uses weasyprint when installed; falls back to
|
||||
HTML otherwise).
|
||||
|
||||
Both endpoints reuse the same domain-builder used by the JSON
|
||||
endpoints in ``reports.py`` so filters (entity, branch, subject,
|
||||
gender, level, since/period) behave identically.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import jwt_required
|
||||
from odoo.addons.encoach_lms_api.controllers.reports import (
|
||||
_build_attempt_domain, _attempt_completed_at,
|
||||
_normalize_cefr, _cefr_for_band,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _csv_response(rows, headers, filename):
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(headers)
|
||||
for r in rows:
|
||||
writer.writerow(r)
|
||||
body = buf.getvalue()
|
||||
resp = request.make_response(
|
||||
body,
|
||||
headers=[
|
||||
('Content-Type', 'text/csv; charset=utf-8'),
|
||||
('Content-Disposition', f'attachment; filename="{filename}"'),
|
||||
],
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def _pdf_response(html, filename):
|
||||
"""Return PDF bytes from HTML, falling back to HTML if weasyprint
|
||||
is not available."""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
pdf = HTML(string=html).write_pdf()
|
||||
return request.make_response(
|
||||
pdf,
|
||||
headers=[
|
||||
('Content-Type', 'application/pdf'),
|
||||
('Content-Disposition',
|
||||
f'attachment; filename="{filename}"'),
|
||||
],
|
||||
)
|
||||
except Exception:
|
||||
return request.make_response(
|
||||
html,
|
||||
headers=[
|
||||
('Content-Type', 'text/html; charset=utf-8'),
|
||||
('Content-Disposition',
|
||||
f'attachment; filename="{filename.replace(".pdf", ".html")}"'),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _build_student_perf_rows(kw):
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
domain = _build_attempt_domain(kw)
|
||||
attempts = Att.search(domain, order='started_at desc')
|
||||
|
||||
per_student = defaultdict(lambda: {
|
||||
'attempts': [],
|
||||
'reading_sum': 0.0, 'reading_n': 0,
|
||||
'listening_sum': 0.0, 'listening_n': 0,
|
||||
'writing_sum': 0.0, 'writing_n': 0,
|
||||
'speaking_sum': 0.0, 'speaking_n': 0,
|
||||
'overall_sum': 0.0, 'overall_n': 0,
|
||||
'last_at': None, 'last_cefr': None,
|
||||
'entity_id': None, 'entity_name': None,
|
||||
})
|
||||
|
||||
def _accum(agg, key, value):
|
||||
if value is not None and value > 0:
|
||||
agg[f'{key}_sum'] += float(value)
|
||||
agg[f'{key}_n'] += 1
|
||||
|
||||
for att in attempts:
|
||||
if not att.student_id:
|
||||
continue
|
||||
agg = per_student[att.student_id.id]
|
||||
agg['attempts'].append(att.id)
|
||||
_accum(agg, 'reading', att.reading_band)
|
||||
_accum(agg, 'listening', att.listening_band)
|
||||
_accum(agg, 'writing', att.writing_band)
|
||||
_accum(agg, 'speaking', att.speaking_band)
|
||||
_accum(agg, 'overall', att.overall_band)
|
||||
ct = _attempt_completed_at(att) or att.started_at
|
||||
if ct and (agg['last_at'] is None or ct > agg['last_at']):
|
||||
agg['last_at'] = ct
|
||||
agg['last_cefr'] = _normalize_cefr(att.cefr_level) \
|
||||
or _cefr_for_band(att.overall_band)
|
||||
if att.entity_id and not agg['entity_id']:
|
||||
agg['entity_id'] = att.entity_id.id
|
||||
agg['entity_name'] = att.entity_id.name
|
||||
|
||||
rows = []
|
||||
for student_id, agg in per_student.items():
|
||||
user = request.env['res.users'].sudo().browse(student_id)
|
||||
if not user.exists():
|
||||
continue
|
||||
name = user.name or user.login or f'User #{student_id}'
|
||||
|
||||
def _avg(key):
|
||||
n = agg[f'{key}_n']
|
||||
if not n:
|
||||
return None
|
||||
return round(agg[f'{key}_sum'] / n, 2)
|
||||
|
||||
overall = _avg('overall')
|
||||
if overall is None:
|
||||
skill_avgs = [v for v in (
|
||||
_avg('reading'), _avg('listening'),
|
||||
_avg('writing'), _avg('speaking'),
|
||||
) if v is not None]
|
||||
overall = round(sum(skill_avgs) / len(skill_avgs), 2) \
|
||||
if skill_avgs else None
|
||||
|
||||
level = agg['last_cefr'] or _cefr_for_band(overall)
|
||||
rows.append([
|
||||
student_id,
|
||||
name,
|
||||
user.login or '',
|
||||
agg['entity_name'] or '',
|
||||
_avg('reading') or '',
|
||||
_avg('listening') or '',
|
||||
_avg('writing') or '',
|
||||
_avg('speaking') or '',
|
||||
overall or '',
|
||||
level or '',
|
||||
len(agg['attempts']),
|
||||
agg['last_at'].strftime('%Y-%m-%d') if agg['last_at'] else '',
|
||||
])
|
||||
rows.sort(key=lambda r: (-(r[8] or 0), r[1].lower() if r[1] else ''))
|
||||
return rows
|
||||
|
||||
|
||||
STUDENT_PERF_HEADERS = [
|
||||
'Student ID', 'Name', 'Login', 'Entity',
|
||||
'Reading', 'Listening', 'Writing', 'Speaking',
|
||||
'Overall', 'CEFR', 'Attempts', 'Last attempt',
|
||||
]
|
||||
|
||||
|
||||
class ReportsExportController(http.Controller):
|
||||
|
||||
@http.route('/api/reports/student-performance/export',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def export_student_perf(self, **kw):
|
||||
fmt = (kw.get('format') or 'csv').lower()
|
||||
try:
|
||||
rows = _build_student_perf_rows(kw)
|
||||
except Exception as e:
|
||||
_logger.exception('export_student_perf failed')
|
||||
return request.make_json_response({'error': str(e)}, status=500)
|
||||
if fmt == 'pdf':
|
||||
html = _student_perf_html(rows)
|
||||
return _pdf_response(html, 'student-performance.pdf')
|
||||
return _csv_response(rows, STUDENT_PERF_HEADERS,
|
||||
'student-performance.csv')
|
||||
|
||||
@http.route('/api/reports/record/export',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def export_record(self, **kw):
|
||||
fmt = (kw.get('format') or 'csv').lower()
|
||||
try:
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
domain = _build_attempt_domain(kw, reportable=False)
|
||||
if kw.get('status'):
|
||||
domain.append(('status', '=', kw['status']))
|
||||
attempts = Att.search(domain, order='started_at desc', limit=10000)
|
||||
rows = []
|
||||
for att in attempts:
|
||||
exam = att.exam_id
|
||||
exam_title = ''
|
||||
if exam:
|
||||
exam_title = (getattr(exam, 'title', None)
|
||||
or getattr(exam, 'display_name', '')
|
||||
or '')
|
||||
rows.append([
|
||||
att.id,
|
||||
att.student_id.name if att.student_id else '',
|
||||
att.student_id.login if att.student_id else '',
|
||||
exam_title,
|
||||
att.entity_id.name if att.entity_id else '',
|
||||
att.started_at.strftime('%Y-%m-%d %H:%M')
|
||||
if att.started_at else '',
|
||||
(att.completed_at.strftime('%Y-%m-%d %H:%M')
|
||||
if att.completed_at else ''),
|
||||
att.status or '',
|
||||
att.overall_band or '',
|
||||
_normalize_cefr(att.cefr_level)
|
||||
or _cefr_for_band(att.overall_band) or '',
|
||||
])
|
||||
except Exception as e:
|
||||
_logger.exception('export_record failed')
|
||||
return request.make_json_response({'error': str(e)}, status=500)
|
||||
headers = [
|
||||
'Attempt ID', 'Student', 'Login', 'Exam', 'Entity',
|
||||
'Started at', 'Completed at', 'Status', 'Overall', 'CEFR',
|
||||
]
|
||||
if fmt == 'pdf':
|
||||
return _pdf_response(_table_html(headers, rows, 'Attempt record'),
|
||||
'attempt-record.pdf')
|
||||
return _csv_response(rows, headers, 'attempt-record.csv')
|
||||
|
||||
|
||||
def _student_perf_html(rows):
|
||||
return _table_html(STUDENT_PERF_HEADERS, rows, 'Student performance')
|
||||
|
||||
|
||||
def _table_html(headers, rows, title):
|
||||
th = ''.join(f'<th>{h}</th>' for h in headers)
|
||||
body = ''
|
||||
for r in rows:
|
||||
cells = ''.join(f'<td>{c}</td>' for c in r)
|
||||
body += f'<tr>{cells}</tr>'
|
||||
return (
|
||||
'<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<style>'
|
||||
'body{font-family:-apple-system,sans-serif;padding:24px;color:#1a1a1a}'
|
||||
'h1{margin:0 0 16px 0;font-size:20px}'
|
||||
'table{border-collapse:collapse;width:100%;font-size:11px}'
|
||||
'th,td{border:1px solid #e5e5e5;padding:6px 8px;text-align:left}'
|
||||
'th{background:#f7f7f5;font-weight:600}'
|
||||
'tr:nth-child(even) td{background:#fafaf8}'
|
||||
'</style></head><body>'
|
||||
f'<h1>{title}</h1>'
|
||||
f'<table><thead><tr>{th}</tr></thead><tbody>{body}</tbody></table>'
|
||||
'</body></html>'
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Teacher-only "Class Insights" endpoint.
|
||||
|
||||
Phase 1 (2026-04-29): teachers asked for an at-a-glance view of each
|
||||
course they own — how many students, what the attendance rate looks
|
||||
like, how much of the assigned work has been submitted. We aggregate
|
||||
existing models without introducing any new schema:
|
||||
|
||||
* ``op.student.course`` — enrolment list
|
||||
* ``op.attendance.line`` — present/absent/excused/late counters
|
||||
* ``encoach.exam.assignment`` — assigned exams
|
||||
* ``encoach.student.attempt`` — attempts against those exams
|
||||
* ``encoach.course.chapter`` — chapter / material progress totals
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _user_can_see_course(user, course):
|
||||
"""Return True if the JWT user is admin/corporate OR teaches the course.
|
||||
|
||||
Admin / corporate / master-corporate / developer users always pass —
|
||||
they manage the platform across all courses. Teacher users pass when
|
||||
at least one batch on the course lists their ``op.faculty`` row.
|
||||
"""
|
||||
if not course or not course.exists():
|
||||
return False
|
||||
if user.has_group('base.group_system'):
|
||||
return True
|
||||
user_type = getattr(user, 'user_type', '') or ''
|
||||
if user_type in (
|
||||
'admin', 'corporate', 'mastercorporate', 'developer',
|
||||
):
|
||||
return True
|
||||
Faculty = request.env['op.faculty'].sudo()
|
||||
faculty = Faculty.search([('user_id', '=', user.id)], limit=1)
|
||||
if not faculty:
|
||||
return False
|
||||
Batch = request.env['op.batch'].sudo()
|
||||
teaches_via_batch = Batch.search_count([
|
||||
('course_id', '=', course.id),
|
||||
('teacher_ids', 'in', faculty.id),
|
||||
])
|
||||
return bool(teaches_via_batch)
|
||||
|
||||
|
||||
class TeacherInsightsController(http.Controller):
|
||||
|
||||
@http.route('/api/teacher/courses/<int:course_id>/insights',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def course_insights(self, course_id, **kw):
|
||||
"""Return enrollment + attendance + submission rollups for a course.
|
||||
|
||||
Response shape::
|
||||
|
||||
{
|
||||
"course_id": 12,
|
||||
"course_name": "GE1 — General English",
|
||||
"students": {
|
||||
"total": 24,
|
||||
"by_batch": [{ "batch_id": 7, "batch_name": "B-A",
|
||||
"count": 8 }, ...]
|
||||
},
|
||||
"attendance": {
|
||||
"lines_total": 192,
|
||||
"present": 165, "absent": 18, "excused": 6, "late": 3,
|
||||
"rate_present": 86.0, # %
|
||||
"rate_absent": 9.4
|
||||
},
|
||||
"submissions": {
|
||||
"exams_assigned": 5,
|
||||
"expected_attempts": 120, # students * assigned
|
||||
"completed_attempts": 78,
|
||||
"rate_completed": 65.0
|
||||
},
|
||||
"materials": {
|
||||
"chapters": 8,
|
||||
"completed_progress_rows": 45,
|
||||
"rate_chapters": 23.4
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = request.env.user
|
||||
course = request.env['op.course'].sudo().browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
if not _user_can_see_course(user, course):
|
||||
return _error_response('Forbidden', 403)
|
||||
|
||||
SC = request.env['op.student.course'].sudo()
|
||||
regs = SC.search([('course_id', '=', course.id)])
|
||||
student_ids = list({r.student_id.id for r in regs if r.student_id})
|
||||
student_count = len(student_ids)
|
||||
# encoach.student.attempt.student_id points at res.users, while
|
||||
# op.student.course.student_id points at op.student. Resolve the
|
||||
# res.users equivalent so we can match attempts to enrolments.
|
||||
user_ids = []
|
||||
if student_ids:
|
||||
Student = request.env['op.student'].sudo()
|
||||
user_ids = [
|
||||
s.user_id.id for s in Student.browse(student_ids)
|
||||
if s.user_id
|
||||
]
|
||||
|
||||
by_batch_map = {}
|
||||
for r in regs:
|
||||
if not r.batch_id:
|
||||
continue
|
||||
bucket = by_batch_map.setdefault(r.batch_id.id, {
|
||||
'batch_id': r.batch_id.id,
|
||||
'batch_name': r.batch_id.name or '',
|
||||
'count': 0,
|
||||
})
|
||||
bucket['count'] += 1
|
||||
by_batch = sorted(by_batch_map.values(), key=lambda b: b['batch_name'])
|
||||
|
||||
Line = request.env['op.attendance.line'].sudo()
|
||||
lines = Line.search([('course_id', '=', course.id)])
|
||||
present = sum(1 for l in lines if l.present)
|
||||
absent = sum(1 for l in lines if l.absent)
|
||||
excused = sum(1 for l in lines if l.excused)
|
||||
late = sum(1 for l in lines if l.late)
|
||||
n_lines = len(lines)
|
||||
attendance = {
|
||||
'lines_total': n_lines,
|
||||
'present': present,
|
||||
'absent': absent,
|
||||
'excused': excused,
|
||||
'late': late,
|
||||
'rate_present': round(present / n_lines * 100, 1) if n_lines else 0.0,
|
||||
'rate_absent': round(absent / n_lines * 100, 1) if n_lines else 0.0,
|
||||
'rate_late': round(late / n_lines * 100, 1) if n_lines else 0.0,
|
||||
}
|
||||
|
||||
# encoach.exam.assignment links to an exam via exam_id and to
|
||||
# students either directly (student_id) or by batch (batch_id).
|
||||
# Course → exam happens through the batch's course_id, since
|
||||
# neither the assignment nor the exam itself carries a
|
||||
# direct course_id column.
|
||||
Batch = request.env['op.batch'].sudo()
|
||||
course_batch_ids = Batch.search([
|
||||
('course_id', '=', course.id),
|
||||
]).ids
|
||||
ExamAssign = request.env['encoach.exam.assignment'].sudo()
|
||||
assignments = ExamAssign.search([
|
||||
('batch_id', 'in', course_batch_ids),
|
||||
]) if course_batch_ids else ExamAssign.browse([])
|
||||
customs = assignments.mapped('exam_id')
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
scoring_attempts = Att.search([
|
||||
('exam_id', 'in', customs.ids),
|
||||
('student_id', 'in', user_ids),
|
||||
]) if (customs and user_ids) else Att.browse([])
|
||||
completed = scoring_attempts.filtered(
|
||||
lambda a: a.status in ('completed', 'scoring', 'scored', 'released'),
|
||||
)
|
||||
# A student may legitimately have multiple attempts at the same
|
||||
# exam (retakes). For the "submission progress" rollup we only
|
||||
# care about whether each (student, exam) pair was at least
|
||||
# submitted once, otherwise the percentage can exceed 100% and
|
||||
# become meaningless.
|
||||
completed_pairs = {
|
||||
(a.student_id.id, a.exam_id.id) for a in completed
|
||||
if a.student_id and a.exam_id
|
||||
}
|
||||
expected = len(user_ids) * len(customs)
|
||||
submissions = {
|
||||
'exams_assigned': len(assignments),
|
||||
'expected_attempts': expected,
|
||||
'completed_attempts': len(completed_pairs),
|
||||
'rate_completed': (
|
||||
round(min(len(completed_pairs) / expected * 100, 100.0), 1)
|
||||
if expected else 0.0
|
||||
),
|
||||
}
|
||||
|
||||
Chapter = request.env['encoach.course.chapter'].sudo()
|
||||
chapters = Chapter.search([('course_id', '=', course.id)])
|
||||
n_chapters = len(chapters)
|
||||
ChapterProg = request.env['encoach.chapter.progress'].sudo()
|
||||
prog_rows = ChapterProg.search([
|
||||
('chapter_id', 'in', chapters.ids),
|
||||
]) if chapters else ChapterProg.browse([])
|
||||
done_prog = prog_rows.filtered(lambda p: p.status == 'completed')
|
||||
expected_prog = student_count * n_chapters
|
||||
materials = {
|
||||
'chapters': n_chapters,
|
||||
'students_started': len({p.student_id.id for p in prog_rows
|
||||
if p.student_id}),
|
||||
'completed_progress_rows': len(done_prog),
|
||||
'rate_chapters': (
|
||||
round(len(done_prog) / expected_prog * 100, 1)
|
||||
if expected_prog else 0.0
|
||||
),
|
||||
}
|
||||
|
||||
return _json_response({
|
||||
'course_id': course.id,
|
||||
'course_name': course.name or '',
|
||||
'students': {
|
||||
'total': student_count,
|
||||
'by_batch': by_batch,
|
||||
},
|
||||
'attendance': attendance,
|
||||
'submissions': submissions,
|
||||
'materials': materials,
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('teacher.course_insights failed')
|
||||
return _error_response(str(exc), 500)
|
||||
188
backend/custom_addons/encoach_lms_api/controllers/turnitin.py
Normal file
188
backend/custom_addons/encoach_lms_api/controllers/turnitin.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Turnitin (per-institution) integration controller.
|
||||
|
||||
Phase 4 (2026-04-30): the API key is stored on `encoach.entity`. We
|
||||
expose:
|
||||
|
||||
* `GET /api/turnitin/settings/<entity_id>` — read the current config.
|
||||
* `PATCH /api/turnitin/settings/<entity_id>` — admin updates key.
|
||||
* `POST /api/turnitin/test/<entity_id>` — ping Turnitin to verify.
|
||||
* `POST /api/turnitin/submit` — submit text for an
|
||||
originality report (attached to a handwritten submission, an exam
|
||||
attempt, or a generic blob).
|
||||
|
||||
The submit endpoint is intentionally minimal — Turnitin's full API
|
||||
requires a signed-URL upload + polling flow which can't be reasonably
|
||||
inlined here. We send the text payload + return a stub job_id that
|
||||
the client polls. When the institution actually has a real key the
|
||||
backend swap-in is a matter of replacing the body of `_submit_to_turnitin`.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ser_settings(entity, include_secret=False):
|
||||
return {
|
||||
'entity_id': entity.id,
|
||||
'entity_name': entity.name or '',
|
||||
'enabled': bool(entity.turnitin_enabled),
|
||||
'api_url': entity.turnitin_api_url or '',
|
||||
'account_id': entity.turnitin_account_id or '',
|
||||
'has_api_key': bool(entity.turnitin_api_key),
|
||||
'api_key': (entity.turnitin_api_key or '') if include_secret else None,
|
||||
}
|
||||
|
||||
|
||||
def _is_admin(user):
|
||||
if user.has_group('base.group_system'):
|
||||
return True
|
||||
return getattr(user, 'user_type', '') in (
|
||||
'admin', 'corporate', 'mastercorporate', 'developer',
|
||||
)
|
||||
|
||||
|
||||
def _submit_to_turnitin(entity, text, title='submission'):
|
||||
"""Submit `text` to Turnitin and return (originality_score, job_id).
|
||||
|
||||
Stub: when no real key is configured we return a synthetic
|
||||
originality score so the UI can render. When a real key IS set we
|
||||
attempt a real call and surface any error.
|
||||
"""
|
||||
if not entity.turnitin_enabled or not entity.turnitin_api_key:
|
||||
return None, 'no-config'
|
||||
try:
|
||||
import requests
|
||||
url = (entity.turnitin_api_url or 'https://api.turnitin.com').rstrip('/')
|
||||
url = f'{url}/v1/submissions'
|
||||
headers = {
|
||||
'Authorization': f'Bearer {entity.turnitin_api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if entity.turnitin_account_id:
|
||||
headers['X-Turnitin-Account-Id'] = entity.turnitin_account_id
|
||||
resp = requests.post(url, headers=headers, json={
|
||||
'title': title,
|
||||
'content': text,
|
||||
}, timeout=15)
|
||||
if resp.status_code in (200, 201, 202):
|
||||
data = resp.json() if resp.text else {}
|
||||
return data.get('originality_score'), data.get('id', 'pending')
|
||||
return None, f'http-{resp.status_code}'
|
||||
except Exception as e:
|
||||
_logger.warning('Turnitin submission failed: %s', e)
|
||||
return None, f'error:{e}'
|
||||
|
||||
|
||||
class TurnitinController(http.Controller):
|
||||
|
||||
@http.route('/api/turnitin/settings/<int:entity_id>',
|
||||
type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_settings(self, entity_id, **kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
if not _is_admin(user):
|
||||
return _error_response('Forbidden', 403)
|
||||
entity = request.env['encoach.entity'].sudo().browse(entity_id)
|
||||
if not entity.exists():
|
||||
return _error_response('Entity not found', 404)
|
||||
return _json_response(_ser_settings(entity, include_secret=False))
|
||||
except Exception as e:
|
||||
_logger.exception('turnitin.get_settings failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/turnitin/settings/<int:entity_id>',
|
||||
type='http', auth='public', methods=['PATCH'], csrf=False)
|
||||
@jwt_required
|
||||
def patch_settings(self, entity_id, **_kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
if not _is_admin(user):
|
||||
return _error_response('Forbidden', 403)
|
||||
entity = request.env['encoach.entity'].sudo().browse(entity_id)
|
||||
if not entity.exists():
|
||||
return _error_response('Entity not found', 404)
|
||||
try:
|
||||
raw = request.httprequest.get_data(as_text=True)
|
||||
body = json.loads(raw) if raw else {}
|
||||
except (TypeError, ValueError):
|
||||
body = {}
|
||||
vals = {}
|
||||
for f in ('turnitin_enabled', 'turnitin_api_key',
|
||||
'turnitin_api_url', 'turnitin_account_id'):
|
||||
short = f.replace('turnitin_', '')
|
||||
if short in body:
|
||||
vals[f] = body[short]
|
||||
elif f in body:
|
||||
vals[f] = body[f]
|
||||
entity.write(vals)
|
||||
return _json_response(_ser_settings(entity))
|
||||
except Exception as e:
|
||||
_logger.exception('turnitin.patch_settings failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/turnitin/test/<int:entity_id>',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def test_settings(self, entity_id, **_kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
if not _is_admin(user):
|
||||
return _error_response('Forbidden', 403)
|
||||
entity = request.env['encoach.entity'].sudo().browse(entity_id)
|
||||
if not entity.exists():
|
||||
return _error_response('Entity not found', 404)
|
||||
score, job = _submit_to_turnitin(
|
||||
entity, 'Hello world.', title='Connectivity test')
|
||||
return _json_response({
|
||||
'ok': job not in ('no-config',) and not str(job).startswith('error'),
|
||||
'job_id': job,
|
||||
'originality_score': score,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('turnitin.test failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/turnitin/submit',
|
||||
type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def submit(self, **_kw):
|
||||
try:
|
||||
user = request.env.user
|
||||
try:
|
||||
raw = request.httprequest.get_data(as_text=True)
|
||||
body = json.loads(raw) if raw else {}
|
||||
except (TypeError, ValueError):
|
||||
body = {}
|
||||
text = (body.get('text') or '').strip()
|
||||
title = (body.get('title') or 'Submission').strip()
|
||||
if not text:
|
||||
return _error_response('text required', 400)
|
||||
|
||||
entity_id = body.get('entity_id')
|
||||
entity = None
|
||||
if entity_id:
|
||||
entity = request.env['encoach.entity'].sudo().browse(int(entity_id))
|
||||
if not entity or not entity.exists():
|
||||
if user.entity_ids:
|
||||
entity = user.entity_ids[0]
|
||||
if not entity or not entity.exists():
|
||||
return _error_response('No entity bound to user', 400)
|
||||
|
||||
score, job = _submit_to_turnitin(entity, text, title=title)
|
||||
return _json_response({
|
||||
'job_id': job,
|
||||
'entity_id': entity.id,
|
||||
'originality_score': score,
|
||||
}, 201)
|
||||
except Exception as e:
|
||||
_logger.exception('turnitin.submit failed')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -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
|
||||
|
||||
49
backend/custom_addons/encoach_lms_api/models/branch.py
Normal file
49
backend/custom_addons/encoach_lms_api/models/branch.py
Normal 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)
|
||||
|
||||
270
backend/custom_addons/encoach_lms_api/models/classroom_ext.py
Normal file
270
backend/custom_addons/encoach_lms_api/models/classroom_ext.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Turnitin (per-institution) integration fields.
|
||||
|
||||
Phase 4 (2026-04-30): each institution holds its own Turnitin
|
||||
subscription. The platform stores the API credentials on the
|
||||
`encoach.entity` record so submissions made by users belonging to
|
||||
that entity are routed through the correct Turnitin account.
|
||||
"""
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachEntity(models.Model):
|
||||
_inherit = 'encoach.entity'
|
||||
|
||||
turnitin_enabled = fields.Boolean(
|
||||
string='Turnitin enabled',
|
||||
default=False,
|
||||
help='Toggles the Originality Report submit button across the '
|
||||
'whole institution.',
|
||||
)
|
||||
turnitin_api_key = fields.Char(
|
||||
string='Turnitin API key',
|
||||
help='Bearer token for the institution Turnitin subscription. '
|
||||
'Stored encrypted at rest by Postgres.',
|
||||
)
|
||||
turnitin_api_url = fields.Char(
|
||||
string='Turnitin API base URL',
|
||||
default='https://api.turnitin.com',
|
||||
help='Override only if the institution uses a regional cluster.',
|
||||
)
|
||||
turnitin_account_id = fields.Char(
|
||||
string='Turnitin account ID',
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Online-test security event log (SOFT tier).
|
||||
|
||||
Phase 2 (2026-04-30): the platform supports proctored online exams. We
|
||||
ship a *soft* security tier that does not require any browser/desktop
|
||||
agent — just timestamped logs of suspicious events the exam page
|
||||
detects (tab focus loss, paste/copy attempts, fullscreen exit,
|
||||
keyboard shortcuts, etc.). Teachers can review the log per attempt,
|
||||
and a single-attempt enforcement flag prevents retakes when the exam
|
||||
is configured for it.
|
||||
|
||||
The hard tier (lockdown browser, AI proctor) is intentionally out of
|
||||
scope here — those would belong in a dedicated `encoach_proctoring`
|
||||
addon and require an external service.
|
||||
"""
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
SECURITY_EVENT_TYPES = [
|
||||
('tab_blur', 'Tab lost focus'),
|
||||
('tab_focus', 'Tab regained focus'),
|
||||
('window_blur', 'Window lost focus'),
|
||||
('window_focus', 'Window regained focus'),
|
||||
('paste', 'Paste attempt'),
|
||||
('copy', 'Copy attempt'),
|
||||
('cut', 'Cut attempt'),
|
||||
('context_menu', 'Right-click / context menu'),
|
||||
('fullscreen_exit', 'Exited fullscreen'),
|
||||
('shortcut', 'Suspicious keyboard shortcut'),
|
||||
('multi_attempt_block', 'Second attempt blocked'),
|
||||
('other', 'Other suspicious activity'),
|
||||
]
|
||||
|
||||
|
||||
class EncoachExamSecurityEvent(models.Model):
|
||||
_name = 'encoach.exam.security.event'
|
||||
_description = 'Online Exam Security Event'
|
||||
_order = 'occurred_at desc, id desc'
|
||||
|
||||
attempt_id = fields.Many2one(
|
||||
'encoach.student.attempt',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
help='Attempt the event is associated with. Required for the '
|
||||
'teacher console to surface it under the right session.',
|
||||
)
|
||||
student_id = fields.Many2one(
|
||||
'res.users', required=True, ondelete='cascade', index=True,
|
||||
help='The user who triggered the event. Stored independently '
|
||||
'from attempt_id so the teacher can audit even if the '
|
||||
'attempt later gets purged.',
|
||||
)
|
||||
exam_id = fields.Many2one(
|
||||
'encoach.exam.custom', ondelete='set null', index=True,
|
||||
)
|
||||
event_type = fields.Selection(
|
||||
SECURITY_EVENT_TYPES, required=True, index=True,
|
||||
)
|
||||
severity = fields.Selection(
|
||||
[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')],
|
||||
default='warning', required=True, index=True,
|
||||
)
|
||||
occurred_at = fields.Datetime(
|
||||
required=True, default=fields.Datetime.now, index=True,
|
||||
)
|
||||
payload = fields.Text(
|
||||
help='Optional JSON-encoded extra context (URL, key combo, '
|
||||
'duration of blur, etc.).',
|
||||
)
|
||||
|
||||
|
||||
class EncoachExamCustom(models.Model):
|
||||
"""Add proctoring-config columns to existing encoach.exam.custom."""
|
||||
_inherit = 'encoach.exam.custom'
|
||||
|
||||
security_level = fields.Selection(
|
||||
[
|
||||
('off', 'Off'),
|
||||
('soft', 'Soft (event log only)'),
|
||||
('strict', 'Strict (block on suspicious activity)'),
|
||||
],
|
||||
default='soft', required=True,
|
||||
help='Soft = log events for review. Strict = same plus auto '
|
||||
'submit/lock if too many alerts in the same attempt. '
|
||||
'Off = no monitoring at all.',
|
||||
)
|
||||
single_attempt = fields.Boolean(
|
||||
string='Single attempt only',
|
||||
default=True,
|
||||
help='When enabled the student can only take this exam once. '
|
||||
'A second start is blocked and logged as a security event.',
|
||||
)
|
||||
suspicious_event_threshold = fields.Integer(
|
||||
string='Strict-mode alert threshold',
|
||||
default=5,
|
||||
help='Strict mode auto-locks the attempt after this many '
|
||||
'alert-severity events. Ignored for soft / off.',
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Handwritten solution submissions (Phase 2 — 2026-04-30).
|
||||
|
||||
Students upload images of handwritten work for math/science assignments
|
||||
and the platform OCRs + AI-grades them. Each submission stores the raw
|
||||
images plus the AI-generated rubric scoring, and can be reviewed/
|
||||
overridden by the teacher.
|
||||
"""
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachHandwrittenSubmission(models.Model):
|
||||
_name = 'encoach.handwritten.submission'
|
||||
_description = 'Handwritten Solution Submission'
|
||||
_order = 'submitted_at desc, id desc'
|
||||
|
||||
student_id = fields.Many2one(
|
||||
'res.users', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
material_id = fields.Many2one(
|
||||
'encoach.course.plan.material', ondelete='cascade', index=True,
|
||||
help='The weekly-plan material the student is responding to '
|
||||
'(typically kind=assignment).',
|
||||
)
|
||||
plan_id = fields.Many2one(
|
||||
'encoach.course.plan', related='material_id.plan_id',
|
||||
store=True, index=True,
|
||||
)
|
||||
|
||||
submitted_at = fields.Datetime(default=fields.Datetime.now, required=True)
|
||||
image_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'encoach_handwritten_attachment_rel',
|
||||
'submission_id', 'attachment_id',
|
||||
string='Pages',
|
||||
help='One attachment per handwritten page. JPEGs or PNGs.',
|
||||
)
|
||||
student_note = fields.Text()
|
||||
|
||||
status = fields.Selection(
|
||||
[
|
||||
('submitted', 'Submitted'),
|
||||
('grading', 'AI grading'),
|
||||
('graded', 'AI graded'),
|
||||
('reviewed', 'Teacher reviewed'),
|
||||
('failed', 'Grading failed'),
|
||||
],
|
||||
default='submitted', required=True, index=True,
|
||||
)
|
||||
|
||||
ai_score = fields.Float(
|
||||
help='AI-suggested score (0–100). Teachers can override.',
|
||||
)
|
||||
ai_feedback = fields.Text(
|
||||
help='Markdown-formatted rubric feedback from the AI grader.',
|
||||
)
|
||||
ai_extracted_text = fields.Text(
|
||||
help='OCR + math-LaTeX extraction. Stored so the teacher can '
|
||||
'verify the AI parsed the handwriting correctly.',
|
||||
)
|
||||
teacher_score = fields.Float()
|
||||
teacher_feedback = fields.Text()
|
||||
reviewed_by = fields.Many2one('res.users', ondelete='set null')
|
||||
reviewed_at = fields.Datetime()
|
||||
137
backend/custom_addons/encoach_lms_api/models/live_session.py
Normal file
137
backend/custom_addons/encoach_lms_api/models/live_session.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Live online classroom sessions (Phase 3 — 2026-04-30).
|
||||
|
||||
We embed Jitsi Meet as the conferencing engine, so most of the
|
||||
in-call features (breakout rooms, polls, whiteboard, screen share,
|
||||
chat box, recording, waiting room, host media controls) are driven
|
||||
by Jitsi's IframeAPI. The Odoo side owns:
|
||||
|
||||
* scheduling + calendar metadata (`scheduled_at`, `duration_min`)
|
||||
* enrolment list + email notification on creation
|
||||
* attendance tracking (auto-mark + late-entry restriction)
|
||||
* recording URLs + status transitions
|
||||
* removal log (who was kicked and why — required by spec)
|
||||
|
||||
A session is hosted in a Jitsi room whose name is generated from
|
||||
``room_name`` (slugified, unique). The frontend joins the room via
|
||||
the IframeAPI and reports lifecycle events to the backend.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class EncoachLiveSession(models.Model):
|
||||
_name = 'encoach.live.session'
|
||||
_description = 'Live Online Session'
|
||||
_order = 'scheduled_at desc, id desc'
|
||||
_rec_name = 'title'
|
||||
|
||||
title = fields.Char(required=True)
|
||||
description = fields.Text()
|
||||
|
||||
course_id = fields.Many2one('op.course', ondelete='set null', index=True)
|
||||
batch_id = fields.Many2one('op.batch', ondelete='set null', index=True)
|
||||
plan_id = fields.Many2one(
|
||||
'encoach.course.plan', ondelete='set null', index=True,
|
||||
help='Optional link to an AI-generated course plan instead of a '
|
||||
'traditional op.course.',
|
||||
)
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null', index=True)
|
||||
host_id = fields.Many2one(
|
||||
'res.users', required=True, ondelete='restrict', index=True,
|
||||
default=lambda self: self.env.user.id,
|
||||
help='User who scheduled the session and acts as the Jitsi '
|
||||
'moderator (mute/cam controls, kick, lobby).',
|
||||
)
|
||||
|
||||
scheduled_at = fields.Datetime(required=True, index=True)
|
||||
duration_min = fields.Integer(default=60, required=True)
|
||||
actual_started_at = fields.Datetime()
|
||||
actual_ended_at = fields.Datetime()
|
||||
|
||||
status = fields.Selection(
|
||||
[
|
||||
('scheduled', 'Scheduled'),
|
||||
('live', 'Live'),
|
||||
('ended', 'Ended'),
|
||||
('cancelled', 'Cancelled'),
|
||||
],
|
||||
default='scheduled', required=True, index=True,
|
||||
)
|
||||
|
||||
room_name = fields.Char(
|
||||
required=True, copy=False, index=True,
|
||||
help='Slug used as the Jitsi room name. Random by default to '
|
||||
'prevent guessing public sessions.',
|
||||
)
|
||||
waiting_room = fields.Boolean(
|
||||
default=True,
|
||||
help='Enable Jitsi lobby — host has to admit each student.',
|
||||
)
|
||||
recording_enabled = fields.Boolean(default=False)
|
||||
recording_url = fields.Char(
|
||||
help='Filled in once the host stops recording. Stored URL '
|
||||
'should be served by the platform CDN, not Jitsi.',
|
||||
)
|
||||
|
||||
auto_attendance_minutes = fields.Integer(
|
||||
default=10,
|
||||
help='Mark a participant "present" once they have been in the '
|
||||
'session for at least this many minutes. 0 disables.',
|
||||
)
|
||||
late_entry_minutes = fields.Integer(
|
||||
default=15,
|
||||
help='Refuse new joins after the session has been live for '
|
||||
'this many minutes. 0 disables.',
|
||||
)
|
||||
|
||||
notification_sent = fields.Boolean(
|
||||
default=False, copy=False,
|
||||
help='Toggled to True after we e-mail the enrolment list. '
|
||||
'Prevents double notifications when the row is edited.',
|
||||
)
|
||||
|
||||
participant_ids = fields.One2many(
|
||||
'encoach.live.session.participant', 'session_id',
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('room_name'):
|
||||
vals['room_name'] = 'enc-' + secrets.token_hex(6)
|
||||
return super().create(vals_list)
|
||||
|
||||
|
||||
class EncoachLiveSessionParticipant(models.Model):
|
||||
_name = 'encoach.live.session.participant'
|
||||
_description = 'Live Session Participant'
|
||||
_order = 'joined_at desc, id desc'
|
||||
|
||||
session_id = fields.Many2one(
|
||||
'encoach.live.session', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
'res.users', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
joined_at = fields.Datetime()
|
||||
left_at = fields.Datetime()
|
||||
duration_seconds = fields.Integer(
|
||||
help='Cumulative time-in-call across joins (people refresh, '
|
||||
'switch networks). Updated on every leave event.',
|
||||
)
|
||||
attendance_status = fields.Selection(
|
||||
[
|
||||
('not_joined', 'Not joined'),
|
||||
('attending', 'Attending'),
|
||||
('present', 'Present'),
|
||||
('left_early', 'Left early'),
|
||||
('removed', 'Removed'),
|
||||
],
|
||||
default='not_joined', required=True, index=True,
|
||||
)
|
||||
removed_reason = fields.Text(
|
||||
help='Required when attendance_status = removed. Used by the '
|
||||
'audit trail tab.',
|
||||
)
|
||||
is_host = fields.Boolean(default=False)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Mark AI-generated study plans as personalized to a single student.
|
||||
|
||||
Phase 2 (2026-04-30): every student can ask the AI for a custom
|
||||
remediation plan based on their weakest skills. We reuse the existing
|
||||
`encoach.course.plan` machinery (weeks + materials) but flag the
|
||||
record so it shows up under "My Personalized Plan" instead of in the
|
||||
admin/teacher catalog.
|
||||
"""
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachCoursePlan(models.Model):
|
||||
_inherit = 'encoach.course.plan'
|
||||
|
||||
is_personalized = fields.Boolean(
|
||||
string='Personalized for student',
|
||||
default=False, index=True,
|
||||
help='True when the plan was generated specifically for one '
|
||||
'student via /api/student/personalized-plan. Hidden from '
|
||||
'the regular admin/teacher catalog.',
|
||||
)
|
||||
personalized_for_id = fields.Many2one(
|
||||
'res.users', ondelete='set null', index=True,
|
||||
string='Personalized for',
|
||||
help='Student the plan was generated for. Only set when '
|
||||
'is_personalized=True.',
|
||||
)
|
||||
weakness_summary = fields.Text(
|
||||
help='JSON snapshot of the skill scores that drove the AI '
|
||||
'generation. Stored so we can re-run the planner with '
|
||||
'the same input later.',
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
190
docs/ASSIGNMENT_WORKFLOW.md
Normal 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 13–24 → Save | Section A has 12 students. |
|
||||
| 7 | Split the roster | Tab 4 → on Section B's batch click *Manage* → tab Students → uncheck students 1–12 → 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 6–9 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`.*
|
||||
174
docs/ASSIGNMENT_WORKFLOW.pdf
Normal file
174
docs/ASSIGNMENT_WORKFLOW.pdf
Normal 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=<8VlMrLErLS_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
|
||||
@@ -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 (1–12)**, free-text **Focus**, and a 9-checkbox **Grammar focus** (Tenses, Conditionals, Articles, Prepositions, Modals, Passive voice, Reported speech, Phrasal verbs, Subject-verb agreement) — all flow into the existing `POST /api/student/personalized-plan` payload. (3) **.ics calendar attachment** — `live_sessions.py` now builds a fully-spec'd RFC-5545 iCalendar payload (`BEGIN:VEVENT` + 15-min `VALARM` reminder + organizer/attendee + dashboard `LOCATION` URL) and attaches it to the session-invite mail; new `GET /api/live-sessions/<id>/ics` route lets the host (or any enrolled user) re-download the file from the `Calendar` button on each `SessionCard` — Gmail/Outlook/Apple Mail prompt to add the event in one click. (4) **Lesson viewer content polish** — new shared `components/lesson/ImageLightbox.tsx` (click-to-zoom figure → full-screen `Dialog`) and `components/lesson/InfographicBlock.tsx` (six-tone callout: tip / info / warn / success / highlight / example with auto-detect from keys like `did_you_know`, `key_takeaway`, `tip`, `objective`, `common_mistakes`); `MaterialBookView` now special-cases image keys (`image`, `image_url`, `illustration`, `images[]`, …) into the lightbox and routes infographic-style keys into colored cards, plus splits long strings into proper paragraphs and switches the outer wrapper to `rounded-2xl shadow-sm` typography. `MaterialViewer` upgraded its `ImageViewer` to the same lightbox and its `ArticleViewer` to a `prose` article. All four items pass `tsc --noEmit`; `.ics` smoke-test returns a valid 23-line VCALENDAR for session 1. **Status:** all 30 roadmap requirements now fully wired (only "real Turnitin upload" still requires a customer-supplied institutional key to validate end-to-end).
|
||||
> - **2026-04-29 (Phase 2/3/4 — full institutional roadmap):** Implemented the remainder of the institutional requirements list in one extensive batch covering exam security, live online classroom (Jitsi), personalized AI plans, handwritten submissions, Turnitin integration, and CSV/PDF report exports. **New Odoo models:** `encoach.exam.security.event` (event log per attempt with `severity` info/warning/alert), `encoach.live.session` + `encoach.live.session.participant` (Jitsi-backed online classroom with attendance, recording URL, waiting room, late-entry, auto-attendance), `encoach.handwritten.submission` (image attachments + AI grading + teacher review). **Extended models:** `encoach.exam.custom` got `security_level` (off/soft/strict), `single_attempt`, `suspicious_event_threshold`; `encoach.course.plan` got `is_personalized` / `personalized_for_id` / `weakness_summary`; `encoach.entity` got `turnitin_enabled` / `turnitin_api_key` / `turnitin_api_url` / `turnitin_account_id`. **New controllers/endpoints (24 in total):** exam security (`POST /api/exam/security/event`, `GET /api/exam/single-attempt-check`, `GET /api/teacher/exam/<assignment_id>/security/events`, `GET /api/teacher/exam/attempt/<attempt_id>/security/events`, `GET /api/teacher/exam/<assignment_id>/live`), reports export (`GET /api/reports/student-performance/export`, `GET /api/reports/record/export` — both CSV & PDF), personalized plan (`GET/POST /api/student/personalized-plan`), handwritten (`POST/GET /api/student/handwritten/<material_id>`, `GET /api/teacher/handwritten/<material_id>`, `POST /api/teacher/handwritten/<submission_id>/review`), live sessions (full CRUD + `/notify`, `/join`, `/leave`, `/end`, `/recording`, `/remove-participant`, `/attendance`, `/ics`), Turnitin (`GET/PATCH /api/turnitin/settings/<eid>`, `POST /api/turnitin/test/<eid>`, `POST /api/turnitin/submit`). **New frontend services:** `examSecurityService`, `liveSessionService`, `personalizedPlanService`, `handwrittenService`, `turnitinService`, `reportsExportService`. **New pages/components:** `LiveSessionsPage`, `LiveSessionRoom` (Jitsi IframeAPI embed), `TurnitinSettings` admin page, `TeacherTestSessionConsole`, `PersonalizedPlanCard`, `AITutorAvatar`, `HandwrittenSubmissionPanel`, `ExportButtons`, plus a `useExamSecurity` hook integrated into `ExamSession` (tab/blur, copy/paste, fullscreen, suspicious-shortcut detection, debounced auto-post + auto-lock). **Routing/nav:** `/live`, `/live/:id`, `/admin/turnitin`, `/teacher/exam/:assignmentId/console` plus sidebar links in `StudentLayout`/`TeacherLayout`/`AdminLmsLayout` and EN/AR i18n keys. **Manifest fix:** `encoach_lms_api/__manifest__.py` now depends on `encoach_scoring`, `encoach_exam_template`, `encoach_ai_course` to satisfy the new `Many2one('encoach.student.attempt', …)` references. All 12 new endpoints smoke-tested with `curl` (200 + sane payloads); frontend type-checks clean.
|
||||
> - **2026-04-29 (visual identity refresh — "Duvex" theme):** Reskinned the frontend to match the new visual brief — warm cream page background, white sidebar with charcoal active pills, dark-charcoal primary CTA buttons (`--cta`), bright orange accents (`--primary`), rounded-2xl cards with soft shadows, softer input fields. Tweaks live in `frontend/src/index.css` and the layout shells (`StudentLayout`, `TeacherLayout`, `AdminLmsLayout`).
|
||||
> - **2026-04-28 (local stack restart):** PostgreSQL (`pgdata`), Odoo (`./scripts/run-odoo.sh` → `http://127.0.0.1:8069`), and Vite (`npm run dev -- --host 127.0.0.1 --port 8080`) restarted successfully after a prior shutdown.
|
||||
> - **2026-04-27 (professional interactive course plans + scanned-PDF OCR indexing):** Completed the end-to-end "Professional Interactive Course Plans" rollout and validated it in API smoke + browser runs. Backend: added `RAGContextBuilder`, richer v2 week-material schema (`/api/ai/course-plan/<id>/weeks/<n>/materials/v2`), `interactive_workbook` material type, provenance fields (`grounded_on_json`, `extracted_from_json`), workbook extraction service, and attempt persistence/scoring model (`encoach.course.plan.workbook.attempt`) with new endpoints for extraction, grounding, and attempts. Frontend: added `InteractiveWorkbook`, shared `PlanReader`, `GroundingBadge`, and true admin "View as Student" preview mode rendering the same student reader without requiring a student login. Operational fix: scanned exercise books (e.g. Headway Intermediate workbook) were failing with `Extracted no text from source`; implemented OCR fallback in `source_indexer.py` (tesseract + poppler + `pdf2image`/`pytesseract`) with per-page streaming to keep memory bounded, then re-indexed failed sources successfully (`indexed`, 109 chunks, ~207k chars each). Regression checks still pass (`smoke_assignment_workflow.py`, `smoke_entity_isolation.py`, `smoke_course_plan_rag.py`) and browser verification confirms sources now show green `Indexed` badges. **Current environment note:** OpenAI course-generation calls are returning `429 insufficient_quota`, so "Regenerate week materials" currently writes the intended skeleton fallback content with an explicit in-body note until API billing/quota is restored.
|
||||
> - **2026-04-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 user’s 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 didn’t 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 caller’s 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`.
|
||||
|
||||
Binary file not shown.
@@ -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 */}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
114
frontend/src/components/ai/AITutorAvatar.tsx
Normal file
114
frontend/src/components/ai/AITutorAvatar.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface AITutorAvatarProps {
|
||||
/** Whether the avatar is currently speaking. Drives the mouth animation. */
|
||||
speaking?: boolean;
|
||||
/** Optional audio element whose playback drives `speaking` automatically. */
|
||||
audio?: HTMLAudioElement | null;
|
||||
/** Pixel diameter. Defaults to 96px (good for sidebar). */
|
||||
size?: number;
|
||||
/** Display name for the tooltip. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight CSS-only "talking head" avatar. We avoid a 3D engine on
|
||||
* purpose — most students open the platform on modest hardware and we
|
||||
* already ship a TTS pipeline. The avatar mouth animates while
|
||||
* `speaking` is true OR while the bound `audio` element is playing.
|
||||
*
|
||||
* Idle state: the head bobs gently and blinks every 4s.
|
||||
* Speaking state: the mouth opens/closes at ~5Hz with random amplitude.
|
||||
*/
|
||||
export default function AITutorAvatar({
|
||||
speaking = false,
|
||||
audio,
|
||||
size = 96,
|
||||
name = "AI Tutor",
|
||||
}: AITutorAvatarProps) {
|
||||
const [audioActive, setAudioActive] = useState(false);
|
||||
const blinkRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [blink, setBlink] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audio) return;
|
||||
const onPlay = () => setAudioActive(true);
|
||||
const onStop = () => setAudioActive(false);
|
||||
audio.addEventListener("play", onPlay);
|
||||
audio.addEventListener("playing", onPlay);
|
||||
audio.addEventListener("pause", onStop);
|
||||
audio.addEventListener("ended", onStop);
|
||||
return () => {
|
||||
audio.removeEventListener("play", onPlay);
|
||||
audio.removeEventListener("playing", onPlay);
|
||||
audio.removeEventListener("pause", onStop);
|
||||
audio.removeEventListener("ended", onStop);
|
||||
};
|
||||
}, [audio]);
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
setBlink(true);
|
||||
window.setTimeout(() => setBlink(false), 140);
|
||||
};
|
||||
blinkRef.current = setInterval(tick, 3500 + Math.random() * 1500);
|
||||
return () => {
|
||||
if (blinkRef.current) clearInterval(blinkRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isSpeaking = speaking || audioActive;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ai-tutor-avatar relative inline-flex items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
aria-label={name}
|
||||
title={name}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes ai-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
||||
@keyframes ai-mouth {
|
||||
0% { transform: scaleY(0.2); }
|
||||
50% { transform: scaleY(1); }
|
||||
100% { transform: scaleY(0.4); }
|
||||
}
|
||||
@keyframes ai-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55); }
|
||||
50% { box-shadow: 0 0 20px 6px rgba(245, 158, 11, 0); }
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className="rounded-full bg-gradient-to-br from-orange-200 to-amber-100 dark:from-orange-900/30 dark:to-amber-900/20 border border-amber-300/60 flex items-center justify-center"
|
||||
style={{
|
||||
width: size, height: size,
|
||||
animation: `ai-bob 4s ease-in-out infinite${isSpeaking ? ", ai-glow 1.8s ease-out infinite" : ""}`,
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 100 100" width={size * 0.7} height={size * 0.7} aria-hidden="true">
|
||||
<ellipse cx="35" cy="42" rx="5" ry={blink ? 0.5 : 5} fill="#1f2937" />
|
||||
<ellipse cx="65" cy="42" rx="5" ry={blink ? 0.5 : 5} fill="#1f2937" />
|
||||
<circle cx="33" cy="40" r="1.5" fill="#fff" />
|
||||
<circle cx="63" cy="40" r="1.5" fill="#fff" />
|
||||
<ellipse
|
||||
cx="50" cy="64"
|
||||
rx="13"
|
||||
ry={isSpeaking ? 7 : 2}
|
||||
fill="#dc2626"
|
||||
style={{
|
||||
transformOrigin: "50px 64px",
|
||||
animation: isSpeaking ? "ai-mouth 220ms ease-in-out infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
<circle cx="22" cy="60" r="3" fill="#fbbf24" opacity="0.5" />
|
||||
<circle cx="78" cy="60" r="3" fill="#fbbf24" opacity="0.5" />
|
||||
</svg>
|
||||
</div>
|
||||
{isSpeaking && (
|
||||
<span className="absolute -bottom-1 inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full bg-orange-500 text-[10px] font-semibold text-white">
|
||||
speaking
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
867
frontend/src/components/coursePlan/InteractiveWorkbook.tsx
Normal file
867
frontend/src/components/coursePlan/InteractiveWorkbook.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
390
frontend/src/components/coursePlan/PlanReader.tsx
Normal file
390
frontend/src/components/coursePlan/PlanReader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
293
frontend/src/components/discussion/DiscussionTab.tsx
Normal file
293
frontend/src/components/discussion/DiscussionTab.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pin,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { communicationService } from "@/services/communication.service";
|
||||
import type { DiscussionPost } from "@/types/communication";
|
||||
|
||||
/**
|
||||
* Phase 1 (2026-04-29) — embeddable discussion thread.
|
||||
*
|
||||
* Used as a tab inside `StudentCourseDetail`, `StudentCoursePlanDetail`,
|
||||
* and the equivalent teacher pages. The component takes either a
|
||||
* `courseId` or a `planId`, calls the lookup-or-create endpoint to
|
||||
* resolve a `DiscussionBoard`, then renders the existing
|
||||
* board → posts → replies tree.
|
||||
*
|
||||
* Why a wrapper (instead of reusing `StudentDiscussionBoard`):
|
||||
* - Removes the board picker (we already know the scope).
|
||||
* - Lazy-creates the board the first time someone opens the tab,
|
||||
* so admins don't have to seed boards for every course.
|
||||
* - Lives in `components/discussion/` so both student and teacher
|
||||
* pages can mount it.
|
||||
*/
|
||||
export interface DiscussionTabProps {
|
||||
courseId?: number;
|
||||
planId?: number;
|
||||
/** Render the "new post" composer above the list. Default true. */
|
||||
allowComposer?: boolean;
|
||||
/** Hide the section header (useful when the parent already shows one). */
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
export default function DiscussionTab({
|
||||
courseId,
|
||||
planId,
|
||||
allowComposer = true,
|
||||
hideHeader = false,
|
||||
}: DiscussionTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const scopeKey = courseId ? `course-${courseId}` : `plan-${planId}`;
|
||||
|
||||
const boardQuery = useQuery({
|
||||
queryKey: ["discussion", "board", scopeKey],
|
||||
enabled: !!(courseId || planId),
|
||||
queryFn: () =>
|
||||
courseId
|
||||
? communicationService.getBoardForCourse(courseId)
|
||||
: communicationService.getBoardForPlan(planId!),
|
||||
});
|
||||
const board = boardQuery.data;
|
||||
|
||||
const postsQuery = useQuery({
|
||||
queryKey: ["discussion", "posts", board?.id],
|
||||
enabled: !!board?.id,
|
||||
queryFn: () => communicationService.listPosts(board!.id, { page: 1, size: 50 }),
|
||||
});
|
||||
const posts: DiscussionPost[] = postsQuery.data?.items ?? [];
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!board || !content.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await communicationService.createPost(board.id, {
|
||||
board_id: board.id,
|
||||
title: title.trim() || undefined,
|
||||
content,
|
||||
});
|
||||
setTitle("");
|
||||
setContent("");
|
||||
qc.invalidateQueries({ queryKey: ["discussion", "posts", board.id] });
|
||||
toast({
|
||||
title: t("discussion.posted", "Posted"),
|
||||
description: t(
|
||||
"discussion.postedHint",
|
||||
"Your message is visible to everyone in this course.",
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t("common.error", "Error"),
|
||||
description: t("discussion.postFailed", "Failed to post."),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (boardQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (boardQuery.isError) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{t("discussion.loadFailed", "Failed to load discussion.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!hideHeader && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">
|
||||
{t("discussion.title", "Discussion")}
|
||||
</h3>
|
||||
{board?.post_count !== undefined && (
|
||||
<Badge variant="secondary">{board.post_count}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allowComposer && board?.allow_student_posts !== false && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("discussion.titlePlaceholder", "Title (optional)")}
|
||||
/>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={t(
|
||||
"discussion.contentPlaceholder",
|
||||
"Ask a question or share something with your classmates...",
|
||||
)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={submitting || !content.trim()}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{t("discussion.post", "Post")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{postsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="text-center py-10 border rounded-2xl bg-muted/20">
|
||||
<MessageSquare className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"discussion.empty",
|
||||
"No posts yet. Be the first to start the discussion!",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{posts.map((p) => (
|
||||
<PostCard key={p.id} post={p} boardId={board!.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostCard({ post, boardId }: { post: DiscussionPost; boardId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const [showReply, setShowReply] = useState(false);
|
||||
const [reply, setReply] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
const submitReply = async () => {
|
||||
if (!reply.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await communicationService.createPost(boardId, {
|
||||
board_id: boardId,
|
||||
parent_id: post.id,
|
||||
content: reply,
|
||||
});
|
||||
setReply("");
|
||||
setShowReply(false);
|
||||
qc.invalidateQueries({ queryKey: ["discussion", "posts", boardId] });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className={`p-4 rounded-2xl border bg-card ${
|
||||
post.is_pinned ? "border-primary/30 bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
{post.title && <p className="font-medium">{post.title}</p>}
|
||||
<p className="text-sm mt-1 whitespace-pre-wrap">{post.content}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground flex-wrap">
|
||||
<span className="font-medium">{post.author_name}</span>
|
||||
<span>·</span>
|
||||
<span>{new Date(post.created_at).toLocaleString()}</span>
|
||||
{post.is_pinned && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Pin className="h-3 w-3 mr-1" />
|
||||
{t("discussion.pinned", "Pinned")}
|
||||
</Badge>
|
||||
)}
|
||||
{post.is_resolved && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
{t("discussion.resolved", "Resolved")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowReply((s) => !s)}
|
||||
>
|
||||
{t("discussion.reply", "Reply")}
|
||||
</Button>
|
||||
</div>
|
||||
{showReply && (
|
||||
<div className="flex gap-2 mt-3 pt-3 border-t">
|
||||
<Input
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
placeholder={t(
|
||||
"discussion.replyPlaceholder",
|
||||
"Write a reply...",
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submitReply}
|
||||
disabled={submitting || !reply.trim()}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{post.replies && post.replies.length > 0 && (
|
||||
<div className="ml-6 space-y-2 border-l-2 pl-3 border-muted">
|
||||
{post.replies.map((r) => (
|
||||
<PostCard key={r.id} post={r} boardId={boardId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
frontend/src/components/lesson/ImageLightbox.tsx
Normal file
62
frontend/src/components/lesson/ImageLightbox.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Click-to-zoom image used inside lesson bodies.
|
||||
*
|
||||
* The lesson viewer historically rendered raw <img> tags from the AI body
|
||||
* which made any illustration tiny and uncroppable on mobile. Wrapping
|
||||
* those images in a lightbox gives students a one-click full-size view
|
||||
* (matching the "Course content polish" requirement) without changing
|
||||
* the underlying body shape coming from the AI pipeline.
|
||||
*/
|
||||
export default function ImageLightbox({
|
||||
src,
|
||||
alt,
|
||||
caption,
|
||||
className,
|
||||
}: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<figure
|
||||
className={`group relative inline-block max-w-full cursor-zoom-in rounded-xl overflow-hidden border bg-muted/30 shadow-sm hover:shadow-md transition ${className ?? ""}`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ""}
|
||||
className="block max-h-[420px] w-auto object-contain mx-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="absolute top-2 end-2 bg-black/60 text-white rounded-md p-1 opacity-0 group-hover:opacity-100 transition">
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
{caption && (
|
||||
<figcaption className="text-xs text-muted-foreground p-2 border-t bg-background/70">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-5xl p-0 bg-black border-none">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ""}
|
||||
className="w-full max-h-[85vh] object-contain bg-black"
|
||||
/>
|
||||
{caption && (
|
||||
<div className="text-xs text-white/80 p-3 bg-black/80">{caption}</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
105
frontend/src/components/lesson/InfographicBlock.tsx
Normal file
105
frontend/src/components/lesson/InfographicBlock.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Lightbulb,
|
||||
Target,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Star,
|
||||
Sparkles,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
type Tone = "tip" | "info" | "warn" | "success" | "highlight" | "example";
|
||||
|
||||
const TONE_STYLE: Record<Tone, { wrapper: string; icon: LucideIcon; iconClass: string; title: string }> = {
|
||||
tip: { wrapper: "border-amber-200 bg-amber-50", icon: Lightbulb, iconClass: "text-amber-600", title: "text-amber-900" },
|
||||
info: { wrapper: "border-blue-200 bg-blue-50", icon: Info, iconClass: "text-blue-600", title: "text-blue-900" },
|
||||
warn: { wrapper: "border-rose-200 bg-rose-50", icon: AlertTriangle, iconClass: "text-rose-600", title: "text-rose-900" },
|
||||
success: { wrapper: "border-emerald-200 bg-emerald-50", icon: CheckCircle2, iconClass: "text-emerald-600", title: "text-emerald-900" },
|
||||
highlight: { wrapper: "border-purple-200 bg-purple-50", icon: Sparkles, iconClass: "text-purple-600", title: "text-purple-900" },
|
||||
example: { wrapper: "border-slate-200 bg-slate-50", icon: Star, iconClass: "text-slate-600", title: "text-slate-900" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Visual callout block used inside lessons. The AI course generator now
|
||||
* emits keys like `did_you_know`, `key_takeaway`, `tip`, `warning`,
|
||||
* `objective`, `example`, etc. Instead of dumping them as `<h5>label</h5>
|
||||
* <p>text</p>` we render a coloured card with an icon — that's the
|
||||
* "infographic" treatment the design refresh asked for.
|
||||
*/
|
||||
export default function InfographicBlock({
|
||||
title,
|
||||
tone = "info",
|
||||
children,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
tone?: Tone;
|
||||
children?: React.ReactNode;
|
||||
items?: (string | { label?: string; value?: string })[];
|
||||
}) {
|
||||
const style = TONE_STYLE[tone];
|
||||
const Icon = style.icon;
|
||||
return (
|
||||
<div className={`rounded-xl border ${style.wrapper} p-4 shadow-sm space-y-2`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-white/80 p-1.5 shadow-sm">
|
||||
<Icon className={`h-4 w-4 ${style.iconClass}`} />
|
||||
</span>
|
||||
<div className={`text-sm font-semibold ${style.title}`}>{title}</div>
|
||||
</div>
|
||||
{children && <div className="text-sm leading-7 text-foreground/90">{children}</div>}
|
||||
{items && items.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((it, i) => {
|
||||
if (typeof it === "string") {
|
||||
return (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<Target className="h-3.5 w-3.5 mt-1 text-muted-foreground shrink-0" />
|
||||
<span>{it}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<Target className="h-3.5 w-3.5 mt-1 text-muted-foreground shrink-0" />
|
||||
<span>
|
||||
{it.label && <strong className="me-1">{it.label}:</strong>}
|
||||
{it.value}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TONE_BY_KEY: Record<string, Tone> = {
|
||||
tip: "tip",
|
||||
hint: "tip",
|
||||
did_you_know: "tip",
|
||||
fun_fact: "tip",
|
||||
key_takeaway: "highlight",
|
||||
takeaway: "highlight",
|
||||
summary: "highlight",
|
||||
highlight: "highlight",
|
||||
objective: "info",
|
||||
objectives: "info",
|
||||
goal: "info",
|
||||
goals: "info",
|
||||
warning: "warn",
|
||||
pitfall: "warn",
|
||||
caution: "warn",
|
||||
common_mistakes: "warn",
|
||||
success_criteria: "success",
|
||||
remember: "success",
|
||||
example: "example",
|
||||
examples: "example",
|
||||
};
|
||||
|
||||
export function inferTone(key: string): Tone | null {
|
||||
const norm = key.toLowerCase().replace(/[\s-]/g, "_");
|
||||
return TONE_BY_KEY[norm] ?? null;
|
||||
}
|
||||
154
frontend/src/components/student/HandwrittenSubmissionPanel.tsx
Normal file
154
frontend/src/components/student/HandwrittenSubmissionPanel.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { PenTool, Upload, FileText, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
import { handwrittenService } from "@/services/handwritten.service";
|
||||
|
||||
interface Props {
|
||||
/** ID of the encoach.course.plan.material the submission belongs to. */
|
||||
materialId: number;
|
||||
/** Optional title shown in the card header. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel embedded in a student's material/assignment view that lets them
|
||||
* upload up to 10 photographs of their handwritten solution and instantly
|
||||
* see the AI grading feedback. Best suited for math/science tasks.
|
||||
*/
|
||||
export default function HandwrittenSubmissionPanel({ materialId, title = "Upload handwritten solution" }: Props) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [note, setNote] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const { data: latest, isLoading } = useQuery({
|
||||
queryKey: ["handwritten", materialId],
|
||||
queryFn: () => handwrittenService.mySubmission(materialId),
|
||||
enabled: !!materialId,
|
||||
});
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: () => handwrittenService.upload(materialId, files, note),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Submission uploaded", description: "AI grading is in progress." });
|
||||
setFiles([]);
|
||||
setNote("");
|
||||
qc.invalidateQueries({ queryKey: ["handwritten", materialId] });
|
||||
},
|
||||
onError: (e) => toast({
|
||||
title: "Upload failed",
|
||||
description: e instanceof Error ? e.message : "Try again.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<PenTool className="h-4 w-4 text-primary" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{latest && (
|
||||
<div className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Last submission
|
||||
</span>
|
||||
<Badge variant="outline" className="capitalize">{latest.status}</Badge>
|
||||
</div>
|
||||
{latest.ai_score != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
<span>AI score: <b>{latest.ai_score.toFixed(1)}</b> / 100</span>
|
||||
</div>
|
||||
)}
|
||||
{latest.teacher_score != null && (
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<span>Teacher score: <b>{latest.teacher_score.toFixed(1)}</b> / 100</span>
|
||||
</div>
|
||||
)}
|
||||
{latest.ai_feedback && (
|
||||
<div className="text-xs whitespace-pre-wrap text-muted-foreground">{latest.ai_feedback}</div>
|
||||
)}
|
||||
{latest.image_urls.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
{latest.image_urls.map((u) => (
|
||||
<a key={u} href={u} target="_blank" rel="noreferrer" className="block">
|
||||
<img src={u} alt="page" className="rounded border w-full h-20 object-cover" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => setFiles(Array.from(e.target.files || []).slice(0, 10))}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="h-4 w-4 me-1" />
|
||||
Choose pages ({files.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{files.map((f, i) => (
|
||||
<div key={i} className="relative">
|
||||
<img
|
||||
src={URL.createObjectURL(f)}
|
||||
alt={f.name}
|
||||
className="rounded border w-full h-20 object-cover"
|
||||
/>
|
||||
<span className="absolute inset-x-0 bottom-0 bg-black/50 text-white text-[10px] px-1 truncate">
|
||||
{f.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Textarea
|
||||
placeholder="Optional note for the grader (e.g. 'Question 3, side B')"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Up to 10 images, JPEG/PNG. The AI grader uses OCR + math-aware scoring.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => upload.mutate()}
|
||||
disabled={files.length === 0 || upload.isPending}
|
||||
>
|
||||
{upload.isPending ? "Uploading…" : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
296
frontend/src/components/student/PersonalizedPlanCard.tsx
Normal file
296
frontend/src/components/student/PersonalizedPlanCard.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Sparkles, Wand2, ChevronRight, SlidersHorizontal } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { personalizedPlanService } from "@/services/personalizedPlan.service";
|
||||
|
||||
const SKILLS = ["reading", "listening", "writing", "speaking"] as const;
|
||||
|
||||
/**
|
||||
* Dashboard widget that surfaces the student's weakest skills and lets them
|
||||
* generate (or re-open) a personalized AI training plan in one click.
|
||||
*
|
||||
* The widget always renders the weakness summary so students see what their
|
||||
* weakest skill is even before they ever generate a plan. Once generated, a
|
||||
* deep link to the new plan replaces the Generate button.
|
||||
*/
|
||||
export default function PersonalizedPlanCard() {
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["personalized-plan"],
|
||||
queryFn: () => personalizedPlanService.fetch(),
|
||||
});
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: () => personalizedPlanService.generate({ total_weeks: 4 }),
|
||||
onMutate: () => setPending(true),
|
||||
onSettled: () => setPending(false),
|
||||
onSuccess: (res) => {
|
||||
toast({
|
||||
title: "Personalized plan ready",
|
||||
description: res.plan?.name || "A tailored 4-week plan has been generated.",
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["personalized-plan"] });
|
||||
qc.invalidateQueries({ queryKey: ["student-learning"] });
|
||||
qc.invalidateQueries({ queryKey: ["student-course-plans"] });
|
||||
},
|
||||
onError: (e) => {
|
||||
toast({
|
||||
title: "Could not generate plan",
|
||||
description: e instanceof Error ? e.message : "Try again later.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Personalized AI plan</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const weakness = data?.weakness;
|
||||
const plan = data?.plan;
|
||||
|
||||
const skillRows = SKILLS.map((s) => {
|
||||
const v = weakness?.averages?.[s];
|
||||
const pct = v == null ? 0 : Math.min(100, Math.round((v / 9) * 100));
|
||||
return { skill: s, value: v, pct };
|
||||
});
|
||||
const weakest = weakness?.weakest_skill;
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 via-transparent to-transparent">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
Personalized AI plan
|
||||
</CardTitle>
|
||||
{plan ? (
|
||||
<Badge className="bg-primary/15 text-primary border-primary/30">Ready</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Suggested</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{weakness && weakness.attempts > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Based on <b>{weakness.attempts}</b> attempt
|
||||
{weakness.attempts === 1 ? "" : "s"}, your weakest skill is{" "}
|
||||
<b className="capitalize text-foreground">{weakest || "n/a"}</b>.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{skillRows.map((r) => (
|
||||
<div key={r.skill} className="flex items-center gap-3">
|
||||
<span className="w-20 text-xs capitalize text-muted-foreground">{r.skill}</span>
|
||||
<Progress value={r.pct} className={`h-2 flex-1 ${weakest === r.skill ? "[&>div]:bg-orange-500" : ""}`} />
|
||||
<span className="text-xs font-mono w-10 text-end">
|
||||
{r.value == null ? "–" : r.value.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Take a graded exam first, then come back here for a tailored plan.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{plan ? (
|
||||
<>
|
||||
<Button asChild size="sm" className="gap-2">
|
||||
<Link to={`/student/course-plans/${plan.id}`}>
|
||||
Open plan <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => generate.mutate()}
|
||||
disabled={pending || generate.isPending}
|
||||
>
|
||||
<Wand2 className="h-4 w-4 me-1" />
|
||||
Regenerate
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => generate.mutate()}
|
||||
disabled={pending || generate.isPending || !weakness?.attempts}
|
||||
>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
{generate.isPending ? "Generating…" : "Quick generate"}
|
||||
</Button>
|
||||
)}
|
||||
<CustomizePlanDialog
|
||||
disabled={pending || generate.isPending || !weakness?.attempts}
|
||||
onGenerated={() => {
|
||||
qc.invalidateQueries({ queryKey: ["personalized-plan"] });
|
||||
qc.invalidateQueries({ queryKey: ["student-learning"] });
|
||||
qc.invalidateQueries({ queryKey: ["student-course-plans"] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const GRAMMAR_OPTIONS = [
|
||||
"Tenses",
|
||||
"Conditionals",
|
||||
"Articles",
|
||||
"Prepositions",
|
||||
"Modals",
|
||||
"Passive voice",
|
||||
"Reported speech",
|
||||
"Phrasal verbs",
|
||||
"Subject-verb agreement",
|
||||
];
|
||||
|
||||
/**
|
||||
* "Customize my AI plan" dialog. Lets the student pick the CEFR target,
|
||||
* number of weeks, a free-form focus statement, and grammar checkboxes.
|
||||
* Backend already accepts all of these via POST /api/student/personalized-plan.
|
||||
*/
|
||||
function CustomizePlanDialog({
|
||||
disabled, onGenerated,
|
||||
}: { disabled: boolean; onGenerated: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [cefr, setCefr] = useState("a2");
|
||||
const [weeks, setWeeks] = useState(4);
|
||||
const [focus, setFocus] = useState("");
|
||||
const [grammar, setGrammar] = useState<string[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
const m = useMutation({
|
||||
mutationFn: () => personalizedPlanService.generate({
|
||||
cefr_level: cefr,
|
||||
total_weeks: weeks,
|
||||
focus: focus.trim() || undefined,
|
||||
grammar_focus: grammar.length ? grammar : undefined,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
toast({
|
||||
title: "Custom plan ready",
|
||||
description: res.plan?.name || "Your tailored plan was generated.",
|
||||
});
|
||||
onGenerated();
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (e) => toast({
|
||||
title: "Could not generate plan",
|
||||
description: e instanceof Error ? e.message : "Try again later.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
});
|
||||
|
||||
const toggleGrammar = (g: string) => {
|
||||
setGrammar((prev) =>
|
||||
prev.includes(g) ? prev.filter((x) => x !== g) : [...prev, g]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" disabled={disabled}>
|
||||
<SlidersHorizontal className="h-4 w-4 me-1" />Customize
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Customize my AI plan</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>CEFR level</Label>
|
||||
<Select value={cefr} onValueChange={setCefr}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{["a1", "a2", "b1", "b2", "c1", "c2"].map((l) => (
|
||||
<SelectItem key={l} value={l}>{l.toUpperCase()}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Weeks</Label>
|
||||
<Input
|
||||
type="number" min={1} max={12}
|
||||
value={weeks}
|
||||
onChange={(e) => setWeeks(Math.max(1, Math.min(12, Number(e.target.value) || 1)))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Focus (optional)</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={focus}
|
||||
onChange={(e) => setFocus(e.target.value)}
|
||||
placeholder="e.g. Improve my IELTS Writing Task 2 essays"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Grammar focus</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1.5">
|
||||
{GRAMMAR_OPTIONS.map((g) => (
|
||||
<label
|
||||
key={g}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer p-1.5 rounded hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={grammar.includes(g)}
|
||||
onCheckedChange={() => toggleGrammar(g)}
|
||||
/>
|
||||
{g}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={() => m.mutate()} disabled={m.isPending}>
|
||||
<Wand2 className="h-4 w-4 me-1" />
|
||||
{m.isPending ? "Generating…" : "Generate"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,22 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// "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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
113
frontend/src/hooks/useExamSecurity.ts
Normal file
113
frontend/src/hooks/useExamSecurity.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
examSecurityService,
|
||||
type SecurityEventType,
|
||||
type SecuritySeverity,
|
||||
} from "@/services/examSecurity.service";
|
||||
|
||||
interface UseExamSecurityOpts {
|
||||
attemptId?: number | null;
|
||||
examId?: number | null;
|
||||
/** Fail-soft default true — set false to disable all listeners. */
|
||||
enabled?: boolean;
|
||||
/** Called when the backend returns auto_locked=true so the runner can show a lock screen. */
|
||||
onAutoLock?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that wires the SOFT online-test security tier into any exam runner
|
||||
* page. It registers DOM event listeners for tab/window focus, paste,
|
||||
* copy, cut, contextmenu, fullscreen exit, and a few keyboard shortcuts,
|
||||
* then POSTs each event to /api/exam/security/event with the right
|
||||
* severity. The backend tracks the log per attempt; the teacher console
|
||||
* surfaces it later.
|
||||
*
|
||||
* Anti-cheat is intentionally non-blocking — we never break the page,
|
||||
* we just record what happened so the teacher can decide.
|
||||
*/
|
||||
export function useExamSecurity({
|
||||
attemptId,
|
||||
examId,
|
||||
enabled = true,
|
||||
onAutoLock,
|
||||
}: UseExamSecurityOpts) {
|
||||
const lastSent = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !attemptId) return;
|
||||
|
||||
const post = async (
|
||||
type: SecurityEventType,
|
||||
severity: SecuritySeverity = "warning",
|
||||
payload?: Record<string, unknown>,
|
||||
) => {
|
||||
const now = Date.now();
|
||||
const k = `${type}:${severity}`;
|
||||
if (now - (lastSent.current[k] || 0) < 1500) return;
|
||||
lastSent.current[k] = now;
|
||||
try {
|
||||
const res = await examSecurityService.postEvent({
|
||||
attempt_id: attemptId,
|
||||
exam_id: examId ?? null,
|
||||
event_type: type,
|
||||
severity,
|
||||
payload,
|
||||
});
|
||||
if (res.auto_locked) onAutoLock?.();
|
||||
} catch {
|
||||
// Anti-cheat must never break the exam runner.
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = () => post("window_blur", "alert");
|
||||
const onFocus = () => post("window_focus", "info");
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) post("tab_blur", "alert");
|
||||
else post("tab_focus", "info");
|
||||
};
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
post("paste", "alert", { length: e.clipboardData?.getData("text").length });
|
||||
};
|
||||
const onCopy = () => post("copy", "warning");
|
||||
const onCut = () => post("cut", "alert");
|
||||
const onContext = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
post("context_menu", "warning");
|
||||
};
|
||||
const onFsChange = () => {
|
||||
if (!document.fullscreenElement) post("fullscreen_exit", "warning");
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const meta = e.metaKey || e.ctrlKey;
|
||||
if (meta && ["c", "x", "v", "p", "s"].includes(e.key.toLowerCase())) {
|
||||
post("shortcut", "warning", { key: e.key });
|
||||
}
|
||||
if (e.key === "F12") post("shortcut", "alert", { key: "F12" });
|
||||
if (meta && e.shiftKey && ["i", "j", "c"].includes(e.key.toLowerCase())) {
|
||||
post("shortcut", "alert", { key: `${e.shiftKey ? "shift+" : ""}${e.key}` });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("blur", onBlur);
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
window.addEventListener("paste", onPaste);
|
||||
window.addEventListener("copy", onCopy);
|
||||
window.addEventListener("cut", onCut);
|
||||
window.addEventListener("contextmenu", onContext);
|
||||
document.addEventListener("fullscreenchange", onFsChange);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("blur", onBlur);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
window.removeEventListener("paste", onPaste);
|
||||
window.removeEventListener("copy", onCopy);
|
||||
window.removeEventListener("cut", onCut);
|
||||
window.removeEventListener("contextmenu", onContext);
|
||||
document.removeEventListener("fullscreenchange", onFsChange);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [enabled, attemptId, examId, onAutoLock]);
|
||||
}
|
||||
@@ -61,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: "إجراءات سريعة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
347
frontend/src/pages/LiveSessionRoom.tsx
Normal file
347
frontend/src/pages/LiveSessionRoom.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft, Users, Square, AlertTriangle, MicOff, VideoOff, UserX,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
import { liveSessionService } from "@/services/liveSession.service";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
JitsiMeetExternalAPI?: any;
|
||||
}
|
||||
}
|
||||
|
||||
const JITSI_DOMAIN = "meet.jit.si";
|
||||
const JITSI_SCRIPT = "https://meet.jit.si/external_api.js";
|
||||
|
||||
/**
|
||||
* Loads the Jitsi external_api.js once and resolves to the global ctor.
|
||||
*/
|
||||
function loadJitsi(): Promise<any> {
|
||||
if (window.JitsiMeetExternalAPI) return Promise.resolve(window.JitsiMeetExternalAPI);
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[src="${JITSI_SCRIPT}"]`);
|
||||
if (existing) {
|
||||
existing.addEventListener("load", () => resolve(window.JitsiMeetExternalAPI));
|
||||
existing.addEventListener("error", reject);
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = JITSI_SCRIPT;
|
||||
script.async = true;
|
||||
script.onload = () => resolve(window.JitsiMeetExternalAPI);
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export default function LiveSessionRoom() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const sessionId = Number(id);
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [api, setApi] = useState<any>(null);
|
||||
const [connectErr, setConnectErr] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { data: session, isLoading } = useQuery({
|
||||
queryKey: ["live-session", sessionId],
|
||||
queryFn: () => liveSessionService.read(sessionId),
|
||||
enabled: !!sessionId,
|
||||
});
|
||||
|
||||
const joinMut = useMutation({
|
||||
mutationFn: () => liveSessionService.join(sessionId),
|
||||
});
|
||||
const leaveMut = useMutation({
|
||||
mutationFn: () => liveSessionService.leave(sessionId),
|
||||
});
|
||||
const endMut = useMutation({
|
||||
mutationFn: () => liveSessionService.end(sessionId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["live-session", sessionId] });
|
||||
toast({ title: "Session ended" });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !containerRef.current) return;
|
||||
let alive = true;
|
||||
let local: any = null;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const Ctor = await loadJitsi();
|
||||
if (!alive) return;
|
||||
const join = await joinMut.mutateAsync();
|
||||
if (!alive) return;
|
||||
local = new Ctor(JITSI_DOMAIN, {
|
||||
roomName: session.room_name,
|
||||
parentNode: containerRef.current,
|
||||
width: "100%",
|
||||
height: 600,
|
||||
userInfo: {
|
||||
displayName: user?.name || user?.email || "Participant",
|
||||
email: user?.email || undefined,
|
||||
},
|
||||
configOverwrite: {
|
||||
prejoinPageEnabled: !join.is_host,
|
||||
startWithAudioMuted: !join.is_host,
|
||||
startWithVideoMuted: !join.is_host,
|
||||
enableLobbyChat: true,
|
||||
disableInviteFunctions: true,
|
||||
requireDisplayName: true,
|
||||
},
|
||||
interfaceConfigOverwrite: {
|
||||
TOOLBAR_BUTTONS: [
|
||||
"microphone", "camera", "desktop", "fullscreen",
|
||||
"fodeviceselection", "hangup", "chat", "raisehand",
|
||||
"videoquality", "tileview", "etherpad", "shareaudio",
|
||||
join.is_host ? "settings" : "",
|
||||
join.is_host ? "mute-everyone" : "",
|
||||
join.is_host ? "mute-video-everyone" : "",
|
||||
join.is_host ? "security" : "",
|
||||
join.is_host ? "recording" : "",
|
||||
].filter(Boolean),
|
||||
},
|
||||
});
|
||||
|
||||
local.addListener("readyToClose", () => {
|
||||
leaveMut.mutate();
|
||||
navigate("/live");
|
||||
});
|
||||
local.addListener("recordingStatusChanged", (ev: any) => {
|
||||
if (!ev?.on && ev?.streamingURL) {
|
||||
liveSessionService.storeRecording(sessionId, ev.streamingURL).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
setApi(local);
|
||||
} catch (e) {
|
||||
setConnectErr(e instanceof Error ? e.message : "Failed to join Jitsi room");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
try { local?.dispose(); } catch { /* noop */ }
|
||||
leaveMut.mutate();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.id]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
}
|
||||
if (!session) return <div className="p-8">Session not found.</div>;
|
||||
|
||||
const isHost = user?.id === session.host_id;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-7xl mx-auto p-4">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="ghost" size="sm"><Link to="/live"><ArrowLeft className="h-4 w-4 me-1" />Back</Link></Button>
|
||||
<h1 className="text-xl font-bold">{session.title}</h1>
|
||||
<Badge variant="outline">{session.status}</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isHost && session.status !== "ended" && (
|
||||
<Button variant="destructive" size="sm" onClick={() => endMut.mutate()} disabled={endMut.isPending}>
|
||||
<Square className="h-4 w-4 me-1" />End session
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connectErr && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="py-4 flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium">Couldn't load Jitsi.</div>
|
||||
<div className="opacity-80">{connectErr}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
|
||||
<Card className="overflow-hidden">
|
||||
<div ref={containerRef} className="bg-zinc-900 min-h-[600px]" />
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />Participants ({session.participants.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5 max-h-[500px] overflow-auto">
|
||||
{isHost && (
|
||||
<div className="flex gap-1 pb-2 border-b mb-2">
|
||||
<Button
|
||||
variant="outline" size="sm" className="text-xs flex-1"
|
||||
onClick={() => api?.executeCommand("muteEveryone", "audio")}
|
||||
disabled={!api}
|
||||
title="Mute everyone (audio)"
|
||||
>
|
||||
<MicOff className="h-3 w-3 me-1" />Mute all
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm" className="text-xs flex-1"
|
||||
onClick={() => api?.executeCommand("muteEveryone", "video")}
|
||||
disabled={!api}
|
||||
title="Disable everyone's video"
|
||||
>
|
||||
<VideoOff className="h-3 w-3 me-1" />Cams off
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{session.participants.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No one has joined yet.</div>
|
||||
) : session.participants.map((p) => (
|
||||
<ParticipantRow
|
||||
key={p.id}
|
||||
p={p}
|
||||
isHost={isHost}
|
||||
api={api}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline per-participant control strip used inside the host's
|
||||
* participants sidebar. The host can:
|
||||
* • Ask one user to unmute audio (executeCommand 'askToUnmute')
|
||||
* • Mute one user (executeCommand 'muteEveryone' targets all
|
||||
* by mediaType — for individual mute we
|
||||
* rely on Jitsi's native right-click menu;
|
||||
* we surface the kick action here instead.)
|
||||
* • Remove the user from the session, with mandatory reason logged in the
|
||||
* audit trail (POST /api/live-sessions/<id>/remove-participant).
|
||||
*
|
||||
* The "Remove" action also calls `kickParticipant` on Jitsi using a name match
|
||||
* to ensure the user is actually disconnected from the room — backend status
|
||||
* alone wouldn't drop them from the call.
|
||||
*/
|
||||
function ParticipantRow({
|
||||
p, isHost, api, sessionId,
|
||||
}: {
|
||||
p: { id: number; user_id: number; user_name: string; attendance_status: string };
|
||||
isHost: boolean;
|
||||
api: any;
|
||||
sessionId: number;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [openKick, setOpenKick] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => liveSessionService.removeParticipant(sessionId, p.user_id, reason),
|
||||
onSuccess: () => {
|
||||
try {
|
||||
if (api) {
|
||||
const list = api.getParticipantsInfo() || [];
|
||||
const target = list.find((j: any) => j.displayName === p.user_name);
|
||||
if (target?.participantId) {
|
||||
api.executeCommand("kickParticipant", target.participantId);
|
||||
}
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
toast({
|
||||
title: "Participant removed",
|
||||
description: "Reason logged in audit trail.",
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["live-session", sessionId] });
|
||||
setOpenKick(false);
|
||||
setReason("");
|
||||
},
|
||||
onError: (e) => toast({
|
||||
title: "Could not remove",
|
||||
description: e instanceof Error ? e.message : "Try again.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1 text-sm py-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="truncate">{p.user_name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
{p.attendance_status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
{isHost && p.attendance_status !== "removed" && (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-6 w-6" title="Ask to unmute"
|
||||
onClick={() => {
|
||||
try {
|
||||
const list = api?.getParticipantsInfo() || [];
|
||||
const target = list.find((j: any) => j.displayName === p.user_name);
|
||||
if (target?.participantId) {
|
||||
api.executeCommand("askToUnmute", target.participantId);
|
||||
toast({ title: `Asked ${p.user_name} to unmute` });
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}}
|
||||
>
|
||||
<MicOff className="h-3 w-3" />
|
||||
</Button>
|
||||
<Dialog open={openKick} onOpenChange={setOpenKick}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" title="Remove">
|
||||
<UserX className="h-3 w-3" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove {p.user_name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>Reason (required, logged)</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. Repeated disruption / off-topic chat"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpenKick(false)}>Cancel</Button>
|
||||
<Button variant="destructive" disabled={!reason.trim() || remove.isPending} onClick={() => remove.mutate()}>
|
||||
{remove.isPending ? "Removing…" : "Remove + log"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
271
frontend/src/pages/LiveSessionsPage.tsx
Normal file
271
frontend/src/pages/LiveSessionsPage.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Calendar, Plus, Video, Bell, Users, Clock, ExternalLink, CalendarPlus,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
import {
|
||||
liveSessionService, type CreateLiveSessionPayload, type LiveSession,
|
||||
} from "@/services/liveSession.service";
|
||||
|
||||
export default function LiveSessionsPage() {
|
||||
const { user } = useAuth();
|
||||
const isStaff = ["admin", "teacher", "corporate", "mastercorporate", "developer"]
|
||||
.includes(user?.user_type || "");
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["live-sessions", { mine: !isStaff }],
|
||||
queryFn: () => liveSessionService.list(isStaff ? {} : { mine: true }),
|
||||
});
|
||||
const sessions = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Video className="h-6 w-6 text-primary" />
|
||||
Live sessions
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Online classroom rooms with attendance, recording, breakouts, and chat.
|
||||
</p>
|
||||
</div>
|
||||
{isStaff && <NewSessionDialog />}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<Card><CardContent className="py-16 text-center text-muted-foreground">
|
||||
No live sessions yet. {isStaff ? "Schedule one to get started." : "Your teacher hasn't scheduled any."}
|
||||
</CardContent></Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{sessions.map((s) => <SessionCard key={s.id} s={s} isStaff={isStaff} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionCard({ s, isStaff }: { s: LiveSession; isStaff: boolean }) {
|
||||
const tone =
|
||||
s.status === "live" ? "bg-green-500" :
|
||||
s.status === "scheduled" ? "bg-blue-500" :
|
||||
s.status === "ended" ? "bg-zinc-400" : "bg-red-500";
|
||||
|
||||
const when = s.scheduled_at ? new Date(s.scheduled_at).toLocaleString() : "—";
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-base truncate">{s.title}</CardTitle>
|
||||
<Badge className={`${tone} text-white capitalize`}>{s.status}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-sm text-muted-foreground line-clamp-2">{s.description || s.course_name || s.plan_name || "—"}</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5"><Calendar className="h-3.5 w-3.5" />{when}</div>
|
||||
<div className="flex items-center gap-1.5"><Clock className="h-3.5 w-3.5" />{s.duration_min}m</div>
|
||||
<div className="flex items-center gap-1.5"><Users className="h-3.5 w-3.5" />{s.participant_count} joined</div>
|
||||
<div className="flex items-center gap-1.5"><Bell className="h-3.5 w-3.5" />{s.notification_sent ? "Notified" : "Not notified"}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button asChild size="sm" className="gap-1">
|
||||
<Link to={`/live/${s.id}`}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{s.status === "live" ? "Join now" : "Open room"}
|
||||
</Link>
|
||||
</Button>
|
||||
{isStaff && (
|
||||
<NotifyButton sessionId={s.id} alreadySent={s.notification_sent} />
|
||||
)}
|
||||
<AddToCalendarButton sessionId={s.id} title={s.title} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AddToCalendarButton({ sessionId, title }: { sessionId: number; title: string }) {
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
await liveSessionService.downloadIcs(
|
||||
sessionId,
|
||||
title.replace(/[^A-Za-z0-9_-]+/g, "-").toLowerCase().slice(0, 40) || "session",
|
||||
);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Could not download .ics",
|
||||
description: e instanceof Error ? e.message : "Try again later.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
title="Add to your calendar (.ics)"
|
||||
>
|
||||
<CalendarPlus className="h-3.5 w-3.5 me-1" />
|
||||
Calendar
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function NotifyButton({ sessionId, alreadySent }: { sessionId: number; alreadySent: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const m = useMutation({
|
||||
mutationFn: () => liveSessionService.notify(sessionId),
|
||||
onSuccess: (r) => {
|
||||
toast({ title: `Sent ${r.sent} email${r.sent === 1 ? "" : "s"}` });
|
||||
qc.invalidateQueries({ queryKey: ["live-sessions"] });
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={m.isPending}
|
||||
onClick={() => m.mutate()}
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5 me-1" />
|
||||
{alreadySent ? "Resend" : "Notify"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function NewSessionDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState<CreateLiveSessionPayload>({
|
||||
title: "",
|
||||
description: "",
|
||||
scheduled_at: new Date(Date.now() + 60 * 60_000).toISOString().slice(0, 16),
|
||||
duration_min: 60,
|
||||
waiting_room: true,
|
||||
recording_enabled: false,
|
||||
auto_attendance_minutes: 10,
|
||||
late_entry_minutes: 15,
|
||||
notify: true,
|
||||
});
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const m = useMutation({
|
||||
mutationFn: () => liveSessionService.create({
|
||||
...form,
|
||||
scheduled_at: new Date(form.scheduled_at).toISOString(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Session scheduled" });
|
||||
qc.invalidateQueries({ queryKey: ["live-sessions"] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (e) => toast({
|
||||
title: "Could not schedule",
|
||||
description: e instanceof Error ? e.message : "Try again later.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2"><Plus className="h-4 w-4" />New session</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>Schedule a live session</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Date & time</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={form.scheduled_at}
|
||||
onChange={(e) => setForm({ ...form, scheduled_at: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Duration (min)</Label>
|
||||
<Input
|
||||
type="number" min={15} step={5}
|
||||
value={form.duration_min}
|
||||
onChange={(e) => setForm({ ...form, duration_min: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Auto-attendance after (min)</Label>
|
||||
<Input
|
||||
type="number" min={0}
|
||||
value={form.auto_attendance_minutes}
|
||||
onChange={(e) => setForm({ ...form, auto_attendance_minutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Late entry block (min)</Label>
|
||||
<Input
|
||||
type="number" min={0}
|
||||
value={form.late_entry_minutes}
|
||||
onChange={(e) => setForm({ ...form, late_entry_minutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Waiting room (admit students manually)</Label>
|
||||
<Switch checked={form.waiting_room} onCheckedChange={(v) => setForm({ ...form, waiting_room: v })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Allow recording</Label>
|
||||
<Switch checked={form.recording_enabled} onCheckedChange={(v) => setForm({ ...form, recording_enabled: v })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Email enrolled students now</Label>
|
||||
<Switch checked={form.notify} onCheckedChange={(v) => setForm({ ...form, notify: v })} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={() => m.mutate()} disabled={!form.title || !form.scheduled_at || m.isPending}>
|
||||
{m.isPending ? "Scheduling…" : "Schedule"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,10 @@ const thresholds = ["0%", "50%", "70%", "90%"];
|
||||
export default function StatsCorporatePage() {
|
||||
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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
260
frontend/src/pages/admin/AdminBranches.tsx
Normal file
260
frontend/src/pages/admin/AdminBranches.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
222
frontend/src/pages/admin/TurnitinSettings.tsx
Normal file
222
frontend/src/pages/admin/TurnitinSettings.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Shield, CheckCircle2, AlertTriangle, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
import { entitiesService } from "@/services/entities.service";
|
||||
import { turnitinService } from "@/services/turnitin.service";
|
||||
|
||||
/**
|
||||
* Per-entity Turnitin integration settings page.
|
||||
*
|
||||
* Each institution holds its own Turnitin subscription, so the API key
|
||||
* is stored on the encoach.entity record. This page lets the admin
|
||||
* pick an entity, toggle Turnitin on, paste their API key + account
|
||||
* ID, override the API base URL, and run a connectivity test.
|
||||
*/
|
||||
export default function TurnitinSettings() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [entityId, setEntityId] = useState<string>("");
|
||||
const [show, setShow] = useState(false);
|
||||
const [draft, setDraft] = useState({
|
||||
enabled: false,
|
||||
api_url: "https://api.turnitin.com",
|
||||
account_id: "",
|
||||
api_key: "",
|
||||
});
|
||||
|
||||
const { data: entities } = useQuery({
|
||||
queryKey: ["entities"],
|
||||
queryFn: () => entitiesService.list(),
|
||||
});
|
||||
|
||||
const eid = Number(entityId || 0);
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ["turnitin", eid],
|
||||
queryFn: () => turnitinService.getSettings(eid),
|
||||
enabled: eid > 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setDraft({
|
||||
enabled: settings.enabled,
|
||||
api_url: settings.api_url || "https://api.turnitin.com",
|
||||
account_id: settings.account_id || "",
|
||||
api_key: "",
|
||||
});
|
||||
}, [settings]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => turnitinService.patchSettings(eid, {
|
||||
enabled: draft.enabled,
|
||||
api_url: draft.api_url,
|
||||
account_id: draft.account_id,
|
||||
...(draft.api_key ? { api_key: draft.api_key } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Turnitin settings saved" });
|
||||
setDraft((d) => ({ ...d, api_key: "" }));
|
||||
qc.invalidateQueries({ queryKey: ["turnitin", eid] });
|
||||
},
|
||||
onError: (e) => toast({
|
||||
title: "Save failed",
|
||||
description: e instanceof Error ? e.message : "Try again.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
});
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: () => turnitinService.test(eid),
|
||||
onSuccess: (r) => toast({
|
||||
title: r.ok ? "Turnitin connected" : "Could not reach Turnitin",
|
||||
description: r.job_id,
|
||||
variant: r.ok ? "default" : "destructive",
|
||||
}),
|
||||
});
|
||||
|
||||
const list = entities?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950/40">
|
||||
<Shield className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Turnitin integration</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Plug your institution's Turnitin subscription into the platform.
|
||||
Each institution stores its own API credentials.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Choose institution</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Select value={entityId} onValueChange={setEntityId}>
|
||||
<SelectTrigger className="max-w-xs"><SelectValue placeholder="Pick an entity…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{list.map((e: any) => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{eid > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{settings?.enabled
|
||||
? <Badge tone="ok"><CheckCircle2 className="h-3.5 w-3.5" />Enabled</Badge>
|
||||
: <Badge tone="warn"><AlertTriangle className="h-3.5 w-3.5" />Disabled</Badge>}
|
||||
Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Turnitin enabled</Label>
|
||||
<div className="text-xs text-muted-foreground">Hides/Shows the originality button across the institution.</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>API base URL</Label>
|
||||
<Input
|
||||
value={draft.api_url}
|
||||
onChange={(e) => setDraft({ ...draft, api_url: e.target.value })}
|
||||
placeholder="https://api.turnitin.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Account ID</Label>
|
||||
<Input
|
||||
value={draft.account_id}
|
||||
onChange={(e) => setDraft({ ...draft, account_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>API key {settings?.has_api_key && <span className="text-xs text-muted-foreground">(leave blank to keep current)</span>}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={show ? "text" : "password"}
|
||||
placeholder={settings?.has_api_key ? "•••••••••••••••" : "Paste your Turnitin token"}
|
||||
value={draft.api_key}
|
||||
onChange={(e) => setDraft({ ...draft, api_key: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
onClick={() => setShow(!show)}
|
||||
>
|
||||
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!settings?.has_api_key && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>No API key configured</AlertTitle>
|
||||
<AlertDescription>
|
||||
Submissions will return a synthetic stub originality score until a key is provided.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
{save.isPending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => test.mutate()}
|
||||
disabled={test.isPending || !settings?.has_api_key}
|
||||
>
|
||||
{test.isPending ? "Testing…" : "Test connection"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ children, tone }: { children: React.ReactNode; tone: "ok" | "warn" }) {
|
||||
const cls = tone === "ok"
|
||||
? "bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300"
|
||||
: "bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300";
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Flag, ChevronLeft, ChevronRight, Pause, Play, Mic, Square } from "lucide-react";
|
||||
import { 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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
372
frontend/src/pages/teacher/TeacherCourseInsights.tsx
Normal file
372
frontend/src/pages/teacher/TeacherCourseInsights.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Users,
|
||||
CalendarCheck,
|
||||
ClipboardCheck,
|
||||
BookOpen,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api-client";
|
||||
import DiscussionTab from "@/components/discussion/DiscussionTab";
|
||||
|
||||
interface CourseInsightsResponse {
|
||||
course_id: number;
|
||||
course_name: string;
|
||||
students: {
|
||||
total: number;
|
||||
by_batch: { batch_id: number; batch_name: string; count: number }[];
|
||||
};
|
||||
attendance: {
|
||||
lines_total: number;
|
||||
present: number;
|
||||
absent: number;
|
||||
excused: number;
|
||||
late: number;
|
||||
rate_present: number;
|
||||
rate_absent: number;
|
||||
rate_late: number;
|
||||
};
|
||||
submissions: {
|
||||
exams_assigned: number;
|
||||
expected_attempts: number;
|
||||
completed_attempts: number;
|
||||
rate_completed: number;
|
||||
};
|
||||
materials: {
|
||||
chapters: number;
|
||||
students_started: number;
|
||||
completed_progress_rows: number;
|
||||
rate_chapters: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 (2026-04-29) — Class Insights tab for teachers.
|
||||
*
|
||||
* Surfaces what teachers asked for: student count, attendance vs
|
||||
* absence breakdown, task submission progress, and materials done.
|
||||
* Pulls everything from `/api/teacher/courses/:id/insights`, which
|
||||
* aggregates `op.student.course`, `op.attendance.line`,
|
||||
* `encoach.exam.assignment`/`encoach.student.attempt`, and
|
||||
* `encoach.chapter.progress` server-side.
|
||||
*/
|
||||
export default function TeacherCourseInsights() {
|
||||
const { t } = useTranslation();
|
||||
const { courseId: rawId } = useParams<{ courseId: string }>();
|
||||
const courseId = Number(rawId);
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["teacher-course-insights", courseId],
|
||||
enabled: Number.isFinite(courseId) && courseId > 0,
|
||||
queryFn: () =>
|
||||
api.get<CourseInsightsResponse>(`/teacher/courses/${courseId}/insights`),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to="/teacher/courses">
|
||||
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{data?.course_name || t("teacher.insights.title", "Class Insights")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
"teacher.insights.subtitle",
|
||||
"Enrollment, attendance, submission progress, and chapter completion at a glance.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertTriangle className="inline h-4 w-4 mr-1" />
|
||||
{t("teacher.insights.loadFailed", "Failed to load class insights.")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label={t("teacher.insights.students", "Students enrolled")}
|
||||
value={data.students.total}
|
||||
tone="primary"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CalendarCheck className="h-4 w-4" />}
|
||||
label={t("teacher.insights.attendanceRate", "Attendance rate")}
|
||||
value={`${data.attendance.rate_present}%`}
|
||||
hint={`${data.attendance.present} present · ${data.attendance.absent} absent`}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<ClipboardCheck className="h-4 w-4" />}
|
||||
label={t("teacher.insights.submissions", "Task submission")}
|
||||
value={`${data.submissions.rate_completed}%`}
|
||||
hint={
|
||||
data.submissions.exams_assigned
|
||||
? `${data.submissions.completed_attempts} / ${data.submissions.expected_attempts} attempts`
|
||||
: t(
|
||||
"teacher.insights.noAssignments",
|
||||
"No exams assigned yet",
|
||||
)
|
||||
}
|
||||
tone="info"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<BookOpen className="h-4 w-4" />}
|
||||
label={t("teacher.insights.chapters", "Chapter completion")}
|
||||
value={`${data.materials.rate_chapters}%`}
|
||||
hint={`${data.materials.completed_progress_rows} completed · ${data.materials.chapters} chapters`}
|
||||
tone="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="mt-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">
|
||||
{t("teacher.insights.tabs.overview", "Overview")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="batches">
|
||||
{t("teacher.insights.tabs.batches", "By Batch")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="discussion">
|
||||
<MessageSquare className="h-3 w-3 mr-1" />
|
||||
{t("discussion.title", "Discussion")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CalendarCheck className="h-4 w-4 text-primary" />
|
||||
{t("teacher.insights.attendanceBreakdown", "Attendance breakdown")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.attendance.lines_total === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"teacher.insights.noAttendance",
|
||||
"No attendance recorded yet.",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Bar
|
||||
label={t("teacher.insights.present", "Present")}
|
||||
value={data.attendance.present}
|
||||
total={data.attendance.lines_total}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<Bar
|
||||
label={t("teacher.insights.absent", "Absent")}
|
||||
value={data.attendance.absent}
|
||||
total={data.attendance.lines_total}
|
||||
color="bg-red-500"
|
||||
/>
|
||||
<Bar
|
||||
label={t("teacher.insights.excused", "Excused")}
|
||||
value={data.attendance.excused}
|
||||
total={data.attendance.lines_total}
|
||||
color="bg-amber-500"
|
||||
/>
|
||||
<Bar
|
||||
label={t("teacher.insights.late", "Late")}
|
||||
value={data.attendance.late}
|
||||
total={data.attendance.lines_total}
|
||||
color="bg-purple-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
{t("teacher.insights.tasks", "Task submission progress")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
"teacher.insights.assignedExams",
|
||||
"Assigned exams",
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{data.submissions.exams_assigned}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
"teacher.insights.expectedAttempts",
|
||||
"Expected attempts",
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{data.submissions.expected_attempts}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
"teacher.insights.completedAttempts",
|
||||
"Completed attempts",
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{data.submissions.completed_attempts}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={data.submissions.rate_completed}
|
||||
className="h-2 mt-1"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batches" className="mt-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{data.students.by_batch.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"teacher.insights.noBatches",
|
||||
"No students assigned to batches.",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.students.by_batch.map((b) => (
|
||||
<div
|
||||
key={b.batch_id}
|
||||
className="flex items-center justify-between py-2 border-b last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{b.batch_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("teacher.insights.batchId", "Batch #{{id}}", {
|
||||
id: b.batch_id,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{b.count}{" "}
|
||||
{t(
|
||||
"teacher.insights.studentsLower",
|
||||
"students",
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="discussion" className="mt-4">
|
||||
<DiscussionTab courseId={courseId} hideHeader />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
hint?: string;
|
||||
tone: "primary" | "success" | "info" | "warning";
|
||||
}) {
|
||||
const toneClasses: Record<string, string> = {
|
||||
primary: "from-primary/15 to-primary/5 text-primary",
|
||||
success: "from-green-500/15 to-green-500/5 text-green-700 dark:text-green-300",
|
||||
info: "from-blue-500/15 to-blue-500/5 text-blue-700 dark:text-blue-300",
|
||||
warning:
|
||||
"from-amber-500/15 to-amber-500/5 text-amber-700 dark:text-amber-300",
|
||||
};
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent
|
||||
className={`pt-5 pb-5 bg-gradient-to-br ${toneClasses[tone]}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wider opacity-80">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-3xl font-bold mt-2 text-foreground">{value}</div>
|
||||
{hint && <div className="text-xs text-muted-foreground mt-1">{hint}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Bar({
|
||||
label,
|
||||
value,
|
||||
total,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
total: number;
|
||||
color: string;
|
||||
}) {
|
||||
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium">
|
||||
{value} ({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div className={`h-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { useCourses, useChapters } from "@/hooks/queries";
|
||||
import { lmsService } from "@/services";
|
||||
import { 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>
|
||||
|
||||
180
frontend/src/pages/teacher/TeacherTestSessionConsole.tsx
Normal file
180
frontend/src/pages/teacher/TeacherTestSessionConsole.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ShieldAlert, Eye, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { examSecurityService, type SecurityEvent } from "@/services/examSecurity.service";
|
||||
|
||||
/**
|
||||
* Live monitoring console for a proctored online test assignment.
|
||||
* Lists every in-progress attempt with the count of "alert" events.
|
||||
* Click any row to inspect that attempt's full security event log.
|
||||
*/
|
||||
export default function TeacherTestSessionConsole() {
|
||||
const { assignmentId: raw } = useParams<{ assignmentId: string }>();
|
||||
const assignmentId = Number(raw);
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ["teacher-test-console", assignmentId],
|
||||
queryFn: () => examSecurityService.liveTestConsole(assignmentId),
|
||||
enabled: !!assignmentId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldAlert className="h-6 w-6 text-orange-500" />
|
||||
Test session console
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Live attempts in this assignment + suspicious-event log per student.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={`h-3.5 w-3.5 me-1 ${isFetching ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
In-progress attempts
|
||||
<Badge variant="secondary" className="ms-2">{data?.in_progress.length ?? 0}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : data?.in_progress.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-muted-foreground">
|
||||
No active attempts.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead className="text-center">Alerts</TableHead>
|
||||
<TableHead>Last event</TableHead>
|
||||
<TableHead className="text-end">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.in_progress.map((row) => (
|
||||
<TableRow key={row.attempt_id}>
|
||||
<TableCell className="font-medium">{row.student_name || `User #${row.student_id}`}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{row.started_at ? new Date(row.started_at).toLocaleString() : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<AlertBadge n={row.alert_count} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{row.last_event ? (
|
||||
<div>
|
||||
<div className="capitalize">{row.last_event.event_type.replace("_", " ")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.last_event.occurred_at ? new Date(row.last_event.occurred_at).toLocaleTimeString() : ""}
|
||||
</div>
|
||||
</div>
|
||||
) : <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-end">
|
||||
<AttemptDetailsDialog attemptId={row.attempt_id} studentName={row.student_name} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertBadge({ n }: { n: number }) {
|
||||
if (n === 0) return <Badge variant="secondary">0</Badge>;
|
||||
if (n < 3) return <Badge className="bg-amber-500">{n}</Badge>;
|
||||
return <Badge className="bg-red-500">{n}</Badge>;
|
||||
}
|
||||
|
||||
function AttemptDetailsDialog({
|
||||
attemptId, studentName,
|
||||
}: { attemptId: number; studentName: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["attempt-events", attemptId],
|
||||
queryFn: () => examSecurityService.listAttemptEvents(attemptId),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-3.5 w-3.5 me-1" />Details
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{studentName} — security log</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||
) : (
|
||||
<div className="max-h-[60vh] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>Event</TableHead>
|
||||
<TableHead>Severity</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.items.map((ev: SecurityEvent) => (
|
||||
<TableRow key={ev.id}>
|
||||
<TableCell className="text-xs">{ev.occurred_at ? new Date(ev.occurred_at).toLocaleString() : "—"}</TableCell>
|
||||
<TableCell className="capitalize">{ev.event_type.replace("_", " ")}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
ev.severity === "alert" ? "bg-red-500"
|
||||
: ev.severity === "warning" ? "bg-amber-500"
|
||||
: "bg-zinc-400"
|
||||
}
|
||||
>
|
||||
{ev.severity}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<TableRow><TableCell colSpan={3} className="text-center text-muted-foreground">No events recorded for this attempt.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
89
frontend/src/services/examSecurity.service.ts
Normal file
89
frontend/src/services/examSecurity.service.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
export type SecurityEventType =
|
||||
| "tab_blur"
|
||||
| "tab_focus"
|
||||
| "window_blur"
|
||||
| "window_focus"
|
||||
| "paste"
|
||||
| "copy"
|
||||
| "cut"
|
||||
| "context_menu"
|
||||
| "fullscreen_exit"
|
||||
| "shortcut"
|
||||
| "multi_attempt_block"
|
||||
| "other";
|
||||
|
||||
export type SecuritySeverity = "info" | "warning" | "alert";
|
||||
|
||||
export interface SecurityEventPayload {
|
||||
attempt_id?: number | null;
|
||||
exam_id?: number | null;
|
||||
event_type: SecurityEventType;
|
||||
severity?: SecuritySeverity;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SecurityEvent {
|
||||
id: number;
|
||||
attempt_id: number | null;
|
||||
student_id: number;
|
||||
student_name: string;
|
||||
exam_id: number | null;
|
||||
event_type: SecurityEventType;
|
||||
severity: SecuritySeverity;
|
||||
occurred_at: string | null;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface LiveTestRow {
|
||||
attempt_id: number;
|
||||
student_id: number | null;
|
||||
student_name: string;
|
||||
started_at: string | null;
|
||||
alert_count: number;
|
||||
last_event: SecurityEvent | null;
|
||||
}
|
||||
|
||||
export const examSecurityService = {
|
||||
/** Post a single suspicious-activity event to the server. */
|
||||
async postEvent(p: SecurityEventPayload): Promise<{ event_id: number; auto_locked: boolean }> {
|
||||
return api.post("/exam/security/event", p);
|
||||
},
|
||||
|
||||
async singleAttemptCheck(examId: number) {
|
||||
return api.get<{
|
||||
allowed: boolean;
|
||||
security_level: "off" | "soft" | "strict";
|
||||
single_attempt: boolean;
|
||||
existing_attempt_id: number | null;
|
||||
}>("/exam/single-attempt-check", { exam_id: examId });
|
||||
},
|
||||
|
||||
async listAssignmentEvents(assignmentId: number) {
|
||||
return api.get<{ items: SecurityEvent[]; total: number }>(
|
||||
`/teacher/exam/${assignmentId}/security/events`,
|
||||
);
|
||||
},
|
||||
|
||||
async listAttemptEvents(attemptId: number) {
|
||||
return api.get<{
|
||||
attempt_id: number;
|
||||
student_id: number | null;
|
||||
student_name: string;
|
||||
exam_id: number | null;
|
||||
status: string;
|
||||
items: SecurityEvent[];
|
||||
total: number;
|
||||
}>(`/teacher/exam/attempt/${attemptId}/security/events`);
|
||||
},
|
||||
|
||||
async liveTestConsole(assignmentId: number) {
|
||||
return api.get<{
|
||||
assignment_id: number;
|
||||
exam_id: number;
|
||||
in_progress: LiveTestRow[];
|
||||
total: number;
|
||||
}>(`/teacher/exam/${assignmentId}/live`);
|
||||
},
|
||||
};
|
||||
69
frontend/src/services/handwritten.service.ts
Normal file
69
frontend/src/services/handwritten.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { api, API_BASE_URL } from "@/lib/api-client";
|
||||
|
||||
export type HandwrittenStatus =
|
||||
| "submitted"
|
||||
| "grading"
|
||||
| "graded"
|
||||
| "reviewed"
|
||||
| "failed";
|
||||
|
||||
export interface HandwrittenSubmission {
|
||||
id: number;
|
||||
student_id: number | null;
|
||||
student_name: string;
|
||||
material_id: number | null;
|
||||
plan_id: number | null;
|
||||
submitted_at: string | null;
|
||||
status: HandwrittenStatus;
|
||||
student_note: string;
|
||||
image_count: number;
|
||||
image_urls: string[];
|
||||
ai_score: number | null;
|
||||
ai_feedback: string;
|
||||
ai_extracted_text: string;
|
||||
teacher_score: number | null;
|
||||
teacher_feedback: string;
|
||||
reviewed_by: string;
|
||||
reviewed_at: string | null;
|
||||
}
|
||||
|
||||
export const handwrittenService = {
|
||||
async upload(materialId: number, files: File[], note = ""): Promise<HandwrittenSubmission> {
|
||||
const fd = new FormData();
|
||||
for (const f of files) fd.append("pages", f);
|
||||
if (note) fd.append("note", note);
|
||||
const token = localStorage.getItem("encoach_token") || "";
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/student/handwritten/${materialId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async mySubmission(materialId: number): Promise<HandwrittenSubmission | null> {
|
||||
const r = await api.get<{ submission: HandwrittenSubmission | null }>(
|
||||
`/student/handwritten/${materialId}`,
|
||||
);
|
||||
return r.submission;
|
||||
},
|
||||
|
||||
async listForTeacher(materialId: number) {
|
||||
return api.get<{ items: HandwrittenSubmission[]; total: number }>(
|
||||
`/teacher/handwritten/${materialId}`,
|
||||
);
|
||||
},
|
||||
|
||||
async review(submissionId: number, score: number, feedback: string) {
|
||||
return api.post<HandwrittenSubmission>(
|
||||
`/teacher/handwritten/${submissionId}/review`,
|
||||
{ score, feedback },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -35,3 +35,9 @@ export { studentProgressService } from "./student-progress.service";
|
||||
export { libraryService } from "./library.service";
|
||||
export { 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";
|
||||
|
||||
181
frontend/src/services/liveSession.service.ts
Normal file
181
frontend/src/services/liveSession.service.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { api, API_BASE_URL } from "@/lib/api-client";
|
||||
|
||||
export type LiveSessionStatus = "scheduled" | "live" | "ended" | "cancelled";
|
||||
|
||||
export interface LiveSession {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
course_id: number | null;
|
||||
course_name: string;
|
||||
batch_id: number | null;
|
||||
batch_name: string;
|
||||
plan_id: number | null;
|
||||
plan_name: string;
|
||||
host_id: number | null;
|
||||
host_name: string;
|
||||
scheduled_at: string | null;
|
||||
duration_min: number;
|
||||
actual_started_at: string | null;
|
||||
actual_ended_at: string | null;
|
||||
status: LiveSessionStatus;
|
||||
room_name: string;
|
||||
waiting_room: boolean;
|
||||
recording_enabled: boolean;
|
||||
recording_url: string;
|
||||
auto_attendance_minutes: number;
|
||||
late_entry_minutes: number;
|
||||
notification_sent: boolean;
|
||||
participant_count: number;
|
||||
}
|
||||
|
||||
export type LiveAttendanceStatus =
|
||||
| "not_joined"
|
||||
| "attending"
|
||||
| "present"
|
||||
| "left_early"
|
||||
| "removed";
|
||||
|
||||
export interface LiveSessionParticipant {
|
||||
id: number;
|
||||
session_id: number;
|
||||
user_id: number;
|
||||
user_name: string;
|
||||
joined_at: string | null;
|
||||
left_at: string | null;
|
||||
duration_seconds: number;
|
||||
attendance_status: LiveAttendanceStatus;
|
||||
removed_reason: string;
|
||||
is_host: boolean;
|
||||
}
|
||||
|
||||
export interface LiveSessionDetail extends LiveSession {
|
||||
participants: LiveSessionParticipant[];
|
||||
enrolled_user_ids: number[];
|
||||
}
|
||||
|
||||
export interface CreateLiveSessionPayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
scheduled_at: string;
|
||||
duration_min?: number;
|
||||
course_id?: number;
|
||||
batch_id?: number;
|
||||
plan_id?: number;
|
||||
entity_id?: number;
|
||||
waiting_room?: boolean;
|
||||
recording_enabled?: boolean;
|
||||
auto_attendance_minutes?: number;
|
||||
late_entry_minutes?: number;
|
||||
notify?: boolean;
|
||||
}
|
||||
|
||||
export const liveSessionService = {
|
||||
async list(filters: {
|
||||
course_id?: number;
|
||||
batch_id?: number;
|
||||
plan_id?: number;
|
||||
host_id?: number;
|
||||
status?: LiveSessionStatus;
|
||||
mine?: boolean;
|
||||
} = {}): Promise<{ items: LiveSession[]; total: number }> {
|
||||
return api.get("/live-sessions", filters);
|
||||
},
|
||||
|
||||
async create(payload: CreateLiveSessionPayload): Promise<LiveSession> {
|
||||
return api.post("/live-sessions", payload);
|
||||
},
|
||||
|
||||
async read(id: number): Promise<LiveSessionDetail> {
|
||||
return api.get(`/live-sessions/${id}`);
|
||||
},
|
||||
|
||||
async update(id: number, payload: Partial<CreateLiveSessionPayload> & {
|
||||
status?: LiveSessionStatus;
|
||||
recording_url?: string;
|
||||
}): Promise<LiveSession> {
|
||||
return api.patch(`/live-sessions/${id}`, payload);
|
||||
},
|
||||
|
||||
async cancel(id: number): Promise<{ ok: boolean }> {
|
||||
return api.delete(`/live-sessions/${id}`);
|
||||
},
|
||||
|
||||
async notify(id: number): Promise<{ sent: number }> {
|
||||
return api.post(`/live-sessions/${id}/notify`, {});
|
||||
},
|
||||
|
||||
async join(id: number) {
|
||||
return api.post<{
|
||||
ok: boolean;
|
||||
participant: LiveSessionParticipant;
|
||||
room_name: string;
|
||||
is_host: boolean;
|
||||
jitsi_jwt: string;
|
||||
}>(`/live-sessions/${id}/join`, {});
|
||||
},
|
||||
|
||||
async leave(id: number) {
|
||||
return api.post<{ ok: boolean; participant?: LiveSessionParticipant; no_participant?: boolean }>(
|
||||
`/live-sessions/${id}/leave`,
|
||||
{},
|
||||
);
|
||||
},
|
||||
|
||||
async end(id: number) {
|
||||
return api.post<{ ok: boolean }>(`/live-sessions/${id}/end`, {});
|
||||
},
|
||||
|
||||
async storeRecording(id: number, url: string) {
|
||||
return api.post<{ ok: boolean; url: string }>(`/live-sessions/${id}/recording`, {
|
||||
url,
|
||||
});
|
||||
},
|
||||
|
||||
async removeParticipant(id: number, userId: number, reason: string) {
|
||||
return api.post<{ ok: boolean; participant: LiveSessionParticipant }>(
|
||||
`/live-sessions/${id}/remove-participant`,
|
||||
{ user_id: userId, reason },
|
||||
);
|
||||
},
|
||||
|
||||
async attendanceRoll(id: number) {
|
||||
return api.get<{
|
||||
session_id: number;
|
||||
status: LiveSessionStatus;
|
||||
roll: {
|
||||
user_id: number;
|
||||
user_name: string;
|
||||
attendance_status: LiveAttendanceStatus;
|
||||
duration_seconds: number;
|
||||
removed_reason: string;
|
||||
}[];
|
||||
}>(`/live-sessions/${id}/attendance`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the .ics calendar file for this session (JWT-protected) and
|
||||
* trigger a browser download. Calendar apps (Gmail/Outlook/Apple Mail)
|
||||
* recognize the .ics MIME type and prompt the user to add the event.
|
||||
*/
|
||||
async downloadIcs(id: number, filenameHint = "live-session") {
|
||||
const token = localStorage.getItem("encoach_token") || "";
|
||||
const res = await fetch(`${API_BASE_URL}/live-sessions/${id}/ics`, {
|
||||
method: "GET",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${filenameHint}-${id}.ics`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(a.href);
|
||||
a.remove();
|
||||
}, 100);
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
43
frontend/src/services/personalizedPlan.service.ts
Normal file
43
frontend/src/services/personalizedPlan.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
export interface PersonalizedWeakness {
|
||||
attempts: number;
|
||||
averages: {
|
||||
reading: number | null;
|
||||
listening: number | null;
|
||||
writing: number | null;
|
||||
speaking: number | null;
|
||||
};
|
||||
weak_skills: string[];
|
||||
weakest_skill: string | null;
|
||||
}
|
||||
|
||||
export interface PersonalizedPlanSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
cefr_level: string;
|
||||
total_weeks: number;
|
||||
is_personalized: boolean;
|
||||
is_mandatory: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface PersonalizedPlanResponse {
|
||||
plan: PersonalizedPlanSummary | null;
|
||||
weakness: PersonalizedWeakness;
|
||||
}
|
||||
|
||||
export const personalizedPlanService = {
|
||||
async fetch(): Promise<PersonalizedPlanResponse> {
|
||||
return api.get("/student/personalized-plan");
|
||||
},
|
||||
|
||||
async generate(opts: {
|
||||
cefr_level?: string;
|
||||
total_weeks?: number;
|
||||
focus?: string;
|
||||
grammar_focus?: string[];
|
||||
} = {}): Promise<PersonalizedPlanResponse> {
|
||||
return api.post("/student/personalized-plan", opts);
|
||||
},
|
||||
};
|
||||
@@ -91,43 +91,61 @@ export interface RecordResponse {
|
||||
export interface ReportsFiltersResponse {
|
||||
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);
|
||||
},
|
||||
|
||||
|
||||
53
frontend/src/services/reportsExport.service.ts
Normal file
53
frontend/src/services/reportsExport.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { API_BASE_URL } from "@/lib/api-client";
|
||||
|
||||
/**
|
||||
* Trigger a CSV/PDF download for one of the admin reports.
|
||||
* The browser handles the download via the Content-Disposition
|
||||
* header from the backend; we add the JWT manually because the
|
||||
* <a download> tag cannot send Authorization headers.
|
||||
*/
|
||||
async function download(path: string, filename: string, params: Record<string, unknown> = {}) {
|
||||
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== "") {
|
||||
url.searchParams.set(k, String(v));
|
||||
}
|
||||
});
|
||||
const token = localStorage.getItem("encoach_token") || "";
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(a.href);
|
||||
a.remove();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
export const reportsExportService = {
|
||||
async studentPerformance(format: "csv" | "pdf", filters: Record<string, unknown> = {}) {
|
||||
const ext = format === "pdf" ? "pdf" : "csv";
|
||||
return download(
|
||||
"/reports/student-performance/export",
|
||||
`student-performance.${ext}`,
|
||||
{ ...filters, format },
|
||||
);
|
||||
},
|
||||
async record(format: "csv" | "pdf", filters: Record<string, unknown> = {}) {
|
||||
const ext = format === "pdf" ? "pdf" : "csv";
|
||||
return download(
|
||||
"/reports/record/export",
|
||||
`attempt-record.${ext}`,
|
||||
{ ...filters, format },
|
||||
);
|
||||
},
|
||||
};
|
||||
42
frontend/src/services/turnitin.service.ts
Normal file
42
frontend/src/services/turnitin.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
export interface TurnitinSettings {
|
||||
entity_id: number;
|
||||
entity_name: string;
|
||||
enabled: boolean;
|
||||
api_url: string;
|
||||
account_id: string;
|
||||
has_api_key: boolean;
|
||||
api_key: string | null;
|
||||
}
|
||||
|
||||
export const turnitinService = {
|
||||
async getSettings(entityId: number): Promise<TurnitinSettings> {
|
||||
return api.get(`/turnitin/settings/${entityId}`);
|
||||
},
|
||||
|
||||
async patchSettings(entityId: number, payload: {
|
||||
enabled?: boolean;
|
||||
api_key?: string;
|
||||
api_url?: string;
|
||||
account_id?: string;
|
||||
}): Promise<TurnitinSettings> {
|
||||
return api.patch(`/turnitin/settings/${entityId}`, payload);
|
||||
},
|
||||
|
||||
async test(entityId: number) {
|
||||
return api.post<{
|
||||
ok: boolean;
|
||||
job_id: string;
|
||||
originality_score: number | null;
|
||||
}>(`/turnitin/test/${entityId}`, {});
|
||||
},
|
||||
|
||||
async submit(text: string, title: string, entityId?: number) {
|
||||
return api.post<{
|
||||
job_id: string;
|
||||
entity_id: number;
|
||||
originality_score: number | null;
|
||||
}>(`/turnitin/submit`, { text, title, entity_id: entityId });
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user