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//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//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/', 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/', 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/', 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//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//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//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//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//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//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//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//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//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/', 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/', 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//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//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//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//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)