Files
encoach_backend_v4/custom_addons/encoach_lms_api/controllers/courseware.py
Yamen Ahmad 6ec68160c8 feat: institutional + support + training admin sections (backend + frontend)
Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs.

Backend (new `encoach_lms_api` addon + existing addons):
- Institutional: academic years/terms, departments, admission registers & admissions,
  courses/batches, lessons, fees (terms + student fees + invoicing with income-account
  auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset),
  student leave, result templates + marksheets (incl. delete-with-cascade).
- Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived
  from `op.student.fees.details` and `account.move`; platform settings backed by
  `encoach.code` and `ir.config_parameter` (packages + grading config).
- Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models)
  with CRUD, pagination, search/level filters, and upsert-style progress endpoints.
  Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`;
  `ValidationError`/`UserError` mapped to HTTP 400.

Frontend:
- Rewire institutional admin pages (Academic Year Manager, Admissions, Courses,
  Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets,
  Taxonomy, Resources) to real APIs with React Query invalidation and dialogs.
- New typed services: `payments.service.ts`, `platformSettings.service.ts`,
  `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/
  resources/student-progress/generation` services + related types.
- Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`,
  `TicketsPage` to consume live data with search/filter/progress/CRUD flows.
- New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`.
- Favicons/branding assets and misc. UX polish across teacher/student pages.

Tooling & QA:
- Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent,
  covers institutional + support + training fixtures incl. income-account wiring).
- API write-flow test suites: `test_write_flows.py` (institutional),
  `test_support_flows.py` (support), `test_training_flows.py` (training),
  `test_ai_full.py`. All suites pass end-to-end.
- Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA.
- `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`,
  `frontend/node_modules/`.

Made-with: Cursor
2026-04-19 03:13:23 +04:00

614 lines
28 KiB
Python

import logging
import base64
from odoo import http, fields
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 _ser_chapter(c):
objectives = getattr(c, 'learning_objective_ids', c.env['encoach.learning.objective'])
return {
'id': c.id,
'name': c.name or '',
'course_id': c.course_id.id if c.course_id else 0,
'course_name': c.course_id.name if c.course_id else '',
'sequence': c.sequence,
'description': c.description or '',
'start_date': str(c.start_date) if c.start_date else None,
'end_date': str(c.end_date) if c.end_date else None,
'unlock_mode': c.unlock_mode or 'manual',
'is_unlocked': c.is_unlocked,
'topic_id': c.topic_id.id if c.topic_id else None,
'topic_name': c.topic_id.name if c.topic_id else None,
'domain_id': c.domain_id.id if c.domain_id else None,
'domain_name': c.domain_id.name if c.domain_id else None,
'learning_objective_ids': objectives.ids,
'learning_objective_names': objectives.mapped('name'),
'material_count': c.material_count or 0,
'assignment_ids': [],
'exam_ids': [],
}
def _ser_material(m):
return {
'id': m.id,
'name': m.name or '',
'chapter_id': m.chapter_id.id,
'type': m.type or 'pdf',
'file_url': f'/api/materials/{m.id}/download' if m.file_data else None,
'url': m.url or None,
'description': m.description or None,
'sequence': m.sequence,
'allow_download': m.allow_download,
'is_book': m.is_book,
'book_chapters': m.book_chapters or None,
}
class CoursewareController(http.Controller):
# ── Global Material Search ────────────────────────────────────────
@http.route('/api/materials/search', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def search_materials(self, **kw):
"""Search all materials across all courses. Used by Admin Resources page."""
try:
M = request.env['encoach.chapter.material'].sudo()
domain = []
if kw.get('search'):
domain.append(('name', 'ilike', kw['search']))
if kw.get('type') and kw['type'] != 'all':
domain.append(('type', '=', kw['type']))
if kw.get('course_id'):
chapters = request.env['encoach.course.chapter'].sudo().search([
('course_id', '=', int(kw['course_id']))
])
domain.append(('chapter_id', 'in', chapters.ids))
total = M.search_count(domain)
limit = min(int(kw.get('limit', 50)), 200)
offset = int(kw.get('offset', 0))
recs = M.search(domain, limit=limit, offset=offset, order='id desc')
items = []
for m in recs:
d = _ser_material(m)
d['course_id'] = m.chapter_id.course_id.id if m.chapter_id.course_id else 0
d['course_name'] = m.chapter_id.course_id.name if m.chapter_id.course_id else ''
d['chapter_name'] = m.chapter_id.name if m.chapter_id else ''
d['source'] = 'course_material'
items.append(d)
return _json_response({'items': items, 'total': total})
except Exception as e:
return _error_response(str(e), 500)
# ── Chapters ─────────────────────────────────────────────────────
@http.route('/api/courses/<int:course_id>/chapters', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_chapters(self, course_id, **kw):
try:
M = request.env['encoach.course.chapter'].sudo()
recs = M.search([('course_id', '=', course_id)], order='sequence, id')
return _json_response([_ser_chapter(r) for r in recs])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>/chapters', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_chapter(self, course_id, **kw):
try:
body = _get_json_body()
vals = {
'name': body.get('name', ''),
'course_id': course_id,
'sequence': body.get('sequence', 10),
'description': body.get('description', ''),
'unlock_mode': body.get('unlock_mode', 'manual'),
}
if body.get('start_date'):
vals['start_date'] = body['start_date']
if body.get('end_date'):
vals['end_date'] = body['end_date']
if body.get('topic_id'):
vals['topic_id'] = int(body['topic_id'])
if body.get('learning_objective_ids'):
vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
rec = request.env['encoach.course.chapter'].sudo().create(vals)
return _json_response(_ser_chapter(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_chapter(self, cid, **kw):
try:
rec = request.env['encoach.course.chapter'].sudo().browse(cid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_chapter(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_chapter(self, cid, **kw):
try:
rec = request.env['encoach.course.chapter'].sudo().browse(cid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'description', 'unlock_mode', 'start_date', 'end_date'):
if k in body:
vals[k] = body[k]
if 'sequence' in body:
vals['sequence'] = int(body['sequence'])
if 'topic_id' in body:
vals['topic_id'] = int(body['topic_id']) if body['topic_id'] else False
if 'is_unlocked' in body:
vals['is_unlocked'] = bool(body['is_unlocked'])
if 'learning_objective_ids' in body:
vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
if vals:
rec.write(vals)
return _json_response(_ser_chapter(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_chapter(self, cid, **kw):
try:
rec = request.env['encoach.course.chapter'].sudo().browse(cid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/unlock', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def unlock_chapter(self, cid, **kw):
try:
rec = request.env['encoach.course.chapter'].sudo().browse(cid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'is_unlocked': True})
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/lock', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def lock_chapter(self, cid, **kw):
try:
rec = request.env['encoach.course.chapter'].sudo().browse(cid)
if not rec.exists():
return _error_response('Not found', 404)
rec.write({'is_unlocked': False})
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/courses/<int:course_id>/chapters/reorder', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def reorder_chapters(self, course_id, **kw):
try:
body = _get_json_body()
ids = body.get('ids', [])
for seq, cid in enumerate(ids):
rec = request.env['encoach.course.chapter'].sudo().browse(int(cid))
if rec.exists():
rec.write({'sequence': seq * 10})
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/progress', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_chapter_progress(self, cid, **kw):
try:
user = request.env['res.users'].sudo().browse(request.env.uid)
student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1)
if student:
prog = request.env['encoach.chapter.progress'].sudo().search([
('chapter_id', '=', cid), ('student_id', '=', student.id)
], limit=1)
if prog:
return _json_response({
'id': prog.id,
'student_id': student.id,
'student_name': student.partner_id.name or '',
'chapter_id': cid,
'status': prog.status,
'started_at': str(prog.started_at) if prog.started_at else None,
'completed_at': str(prog.completed_at) if prog.completed_at else None,
'materials_completed': prog.materials_completed,
'materials_total': prog.materials_total,
})
return _json_response({
'id': 0, 'student_id': 0, 'student_name': '', 'chapter_id': cid,
'status': 'not_started', 'started_at': None, 'completed_at': None,
'materials_completed': 0, 'materials_total': 0,
})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/progress/complete', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def mark_chapter_complete(self, cid, **kw):
try:
user = request.env['res.users'].sudo().browse(request.env.uid)
student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1)
if not student:
return _error_response('Student not found for current user', 404)
chapter = request.env['encoach.course.chapter'].sudo().browse(cid)
if not chapter.exists():
return _error_response('Chapter not found', 404)
Progress = request.env['encoach.chapter.progress'].sudo()
prog = Progress.search([
('chapter_id', '=', cid),
('student_id', '=', student.id),
], limit=1)
now = fields.Datetime.now()
if prog:
prog.write({
'status': 'completed',
'completed_at': now,
'materials_completed': chapter.material_count,
'materials_total': chapter.material_count,
})
else:
prog = Progress.create({
'chapter_id': cid,
'student_id': student.id,
'status': 'completed',
'started_at': now,
'completed_at': now,
'materials_completed': chapter.material_count,
'materials_total': chapter.material_count,
})
course_id = chapter.course_id.id
self._check_course_completion(student.id, course_id)
return _json_response({
'success': True,
'chapter_id': cid,
'status': 'completed',
})
except Exception as e:
_logger.exception('mark_chapter_complete failed')
return _error_response(str(e), 500)
def _check_course_completion(self, student_id, course_id):
"""Check if all chapters are completed; if so, mark course as completed and enable post-test."""
chapters = request.env['encoach.course.chapter'].sudo().search([
('course_id', '=', course_id)
])
if not chapters:
return
completed = request.env['encoach.chapter.progress'].sudo().search_count([
('student_id', '=', student_id),
('chapter_id', 'in', chapters.ids),
('status', '=', 'completed'),
])
total = len(chapters)
progress_pct = int((completed / total * 100) if total else 0)
Completion = request.env['encoach.course.completion'].sudo()
comp = Completion.search([
('student_id', '=', student_id),
('course_id', '=', course_id),
], limit=1)
vals = {
'chapters_total': total,
'chapters_completed': completed,
'progress_percent': progress_pct,
}
if completed >= total:
vals['status'] = 'completed'
vals['completed_at'] = fields.Datetime.now()
vals['post_test_available'] = True
else:
vals['status'] = 'in_progress'
vals['post_test_available'] = False
if comp:
comp.write(vals)
else:
vals['student_id'] = student_id
vals['course_id'] = course_id
Completion.create(vals)
@http.route('/api/courses/<int:course_id>/completion', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_course_completion(self, course_id, **kw):
"""Get course completion status for the current student."""
try:
user = request.env['res.users'].sudo().browse(request.env.uid)
student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1)
if not student:
return _json_response({
'course_id': course_id,
'status': 'not_enrolled',
'chapters_total': 0,
'chapters_completed': 0,
'progress_percent': 0,
'post_test_available': False,
})
comp = request.env['encoach.course.completion'].sudo().search([
('student_id', '=', student.id),
('course_id', '=', course_id),
], limit=1)
if comp:
return _json_response({
'course_id': course_id,
'status': comp.status,
'chapters_total': comp.chapters_total,
'chapters_completed': comp.chapters_completed,
'progress_percent': comp.progress_percent,
'completed_at': str(comp.completed_at) if comp.completed_at else None,
'post_test_available': comp.post_test_available,
})
chapters = request.env['encoach.course.chapter'].sudo().search([
('course_id', '=', course_id)
])
completed = request.env['encoach.chapter.progress'].sudo().search_count([
('student_id', '=', student.id),
('chapter_id', 'in', chapters.ids),
('status', '=', 'completed'),
])
total = len(chapters)
return _json_response({
'course_id': course_id,
'status': 'in_progress' if completed > 0 else 'not_started',
'chapters_total': total,
'chapters_completed': completed,
'progress_percent': int((completed / total * 100) if total else 0),
'post_test_available': completed >= total and total > 0,
})
except Exception as e:
_logger.exception('get_course_completion failed')
return _error_response(str(e), 500)
# ── Materials ────────────────────────────────────────────────────
@http.route('/api/chapters/<int:cid>/materials', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_materials(self, cid, **kw):
try:
M = request.env['encoach.chapter.material'].sudo()
recs = M.search([('chapter_id', '=', cid)], order='sequence, id')
return _json_response([_ser_material(r) for r in recs])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/materials', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def upload_material(self, cid, **kw):
try:
files = request.httprequest.files
f = files.get('file')
body = {}
ct = request.httprequest.content_type or ''
if 'application/json' in ct:
body = _get_json_body()
params = {**body, **kw}
vals = {
'chapter_id': cid,
'name': params.get('name', f.filename if f else 'Material'),
'type': params.get('type', 'pdf'),
'sequence': int(params.get('sequence', 10)),
'allow_download': str(params.get('allow_download', 'true')).lower() == 'true',
}
if f:
vals['file_data'] = base64.b64encode(f.read())
vals['file_name'] = f.filename
if params.get('url'):
vals['url'] = params['url']
if params.get('description'):
vals['description'] = params['description']
rec = request.env['encoach.chapter.material'].sudo().create(vals)
return _json_response(_ser_material(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/materials/from-resource', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_material_from_resource(self, cid, **kw):
"""Create a chapter material by copying from an existing library resource."""
try:
body = _get_json_body()
resource_id = body.get('resource_id')
if not resource_id:
return _error_response('resource_id is required', 400)
res = request.env['encoach.resource'].sudo().browse(int(resource_id))
if not res.exists():
return _error_response('Resource not found', 404)
type_map = {
'pdf': 'pdf', 'document': 'document', 'video': 'video',
'link': 'link', 'interactive': 'document',
}
vals = {
'chapter_id': cid,
'name': body.get('name') or res.name,
'type': type_map.get(res.type, 'document'),
'sequence': int(body.get('sequence', 10)),
'allow_download': True,
'url': res.url or '',
'resource_id': res.id,
}
if res.file:
vals['file_data'] = res.file
vals['file_name'] = res.name
rec = request.env['encoach.chapter.material'].sudo().create(vals)
return _json_response(_ser_material(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/materials/<int:mid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_material(self, mid, **kw):
try:
rec = request.env['encoach.chapter.material'].sudo().browse(mid)
if not rec.exists():
return _error_response('Not found', 404)
body = _get_json_body()
vals = {}
for k in ('name', 'type', 'url', 'description'):
if k in body:
vals[k] = body[k]
if 'sequence' in body:
vals['sequence'] = int(body['sequence'])
if 'allow_download' in body:
vals['allow_download'] = bool(body['allow_download'])
if vals:
rec.write(vals)
return _json_response(_ser_material(rec))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/materials/<int:mid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_material(self, mid, **kw):
try:
rec = request.env['encoach.chapter.material'].sudo().browse(mid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/materials/<int:mid>/download', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def download_material(self, mid, **kw):
try:
rec = request.env['encoach.chapter.material'].sudo().browse(mid)
if not rec.exists() or not rec.file_data:
return _error_response('Not found', 404)
data = base64.b64decode(rec.file_data)
fname = rec.file_name or 'download'
return request.make_response(data, headers=[
('Content-Type', 'application/octet-stream'),
('Content-Disposition', f'attachment; filename="{fname}"'),
])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/materials/<int:mid>/content', type='http', auth='public', methods=['GET'], csrf=False)
def material_content(self, mid, **kw):
"""Serve material file inline (for iframe/embed) instead of as download.
Accepts JWT via Authorization header OR ?token= query param (for iframe src)."""
try:
from odoo.addons.encoach_api.controllers.base import validate_token, _get_jwt_secret
import jwt as pyjwt
user = validate_token()
if not user:
token_param = kw.get('token') or request.httprequest.args.get('token')
if token_param:
secret = _get_jwt_secret()
if secret:
try:
payload = pyjwt.decode(token_param, secret, algorithms=["HS256"])
uid = payload.get("uid")
if uid:
user = request.env['res.users'].sudo().browse(int(uid))
if user.exists():
request.update_env(user=user.id)
except Exception:
pass
if not user:
return _error_response("Authentication required", 401)
rec = request.env['encoach.chapter.material'].sudo().browse(mid)
if not rec.exists() or not rec.file_data:
return _error_response('Not found', 404)
data = base64.b64decode(rec.file_data)
fname = rec.file_name or 'file'
ext = fname.rsplit('.', 1)[-1].lower() if '.' in fname else ''
mime_map = {
'pdf': 'application/pdf',
'mp4': 'video/mp4', 'webm': 'video/webm', 'ogg': 'video/ogg',
'mp3': 'audio/mpeg', 'wav': 'audio/wav',
'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
'gif': 'image/gif', 'svg': 'image/svg+xml', 'webp': 'image/webp',
'html': 'text/html', 'htm': 'text/html',
'txt': 'text/plain',
}
content_type = mime_map.get(ext, 'application/octet-stream')
return request.make_response(data, headers=[
('Content-Type', content_type),
('Content-Disposition', f'inline; filename="{fname}"'),
('Cache-Control', 'public, max-age=3600'),
])
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/materials/<int:mid>/viewed', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def mark_viewed(self, mid, **kw):
"""Mark a material as viewed and update chapter progress."""
try:
material = request.env['encoach.chapter.material'].sudo().browse(mid)
if not material.exists():
return _error_response('Not found', 404)
user = request.env['res.users'].sudo().browse(request.env.uid)
student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1)
if not student:
return _json_response({'success': True})
chapter = material.chapter_id
Progress = request.env['encoach.chapter.progress'].sudo()
prog = Progress.search([
('chapter_id', '=', chapter.id),
('student_id', '=', student.id),
], limit=1)
now = fields.Datetime.now()
if prog:
new_count = min(prog.materials_completed + 1, chapter.material_count)
vals = {
'materials_completed': new_count,
'materials_total': chapter.material_count,
}
if prog.status == 'not_started':
vals['status'] = 'in_progress'
vals['started_at'] = now
if new_count >= chapter.material_count:
vals['status'] = 'completed'
vals['completed_at'] = now
prog.write(vals)
else:
status = 'completed' if chapter.material_count <= 1 else 'in_progress'
prog = Progress.create({
'chapter_id': chapter.id,
'student_id': student.id,
'status': status,
'started_at': now,
'completed_at': now if status == 'completed' else False,
'materials_completed': 1,
'materials_total': chapter.material_count,
})
if prog.status == 'completed':
self._check_course_completion(student.id, chapter.course_id.id)
return _json_response({'success': True, 'materials_completed': prog.materials_completed})
except Exception as e:
_logger.exception('mark_viewed failed')
return _error_response(str(e), 500)
@http.route('/api/chapters/<int:cid>/materials/reorder', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def reorder_materials(self, cid, **kw):
try:
body = _get_json_body()
ids = body.get('ids', [])
for seq, mid in enumerate(ids):
rec = request.env['encoach.chapter.material'].sudo().browse(int(mid))
if rec.exists():
rec.write({'sequence': seq * 10})
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)