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
This commit is contained in:
613
custom_addons/encoach_lms_api/controllers/courseware.py
Normal file
613
custom_addons/encoach_lms_api/controllers/courseware.py
Normal file
@@ -0,0 +1,613 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user