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
614 lines
28 KiB
Python
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)
|