feat(platform): ship AI fallback stack and entity-scoped course planning

Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-26 02:34:52 +04:00
parent 096b042daf
commit 35ccc0dfb2
51 changed files with 5487 additions and 538 deletions

View File

@@ -224,7 +224,15 @@ class AcademicController(http.Controller):
'term_end_date': str(e),
'academic_year_id': year.id,
})
if 'parent_id' in Term._fields:
# Wire the quarter -> parent semester via the actual
# field name on op.academic.term, which is
# ``parent_term`` (not ``parent_id``). The previous
# check matched ``parent_id`` and silently no-op'd
# for everyone — the relationship was never written.
if 'parent_term' in Term._fields:
q1.write({'parent_term': parent.id})
q2.write({'parent_term': parent.id})
elif 'parent_id' in Term._fields:
q1.write({'parent_id': parent.id})
q2.write({'parent_id': parent.id})
quarters += [q1, q2]

View File

@@ -11,6 +11,48 @@ _logger = logging.getLogger(__name__)
# Helpers
# ---------------------------------------------------------------------------
def _entity_scope():
"""Return (entity_ids, is_superadmin) for the current user."""
user = request.env.user.sudo()
is_super = bool(user.has_group('base.group_system'))
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
return ids, is_super
def _ensure_entity_access(entity_id):
"""Raise PermissionError if current user cannot access entity_id."""
if not entity_id:
raise PermissionError('entity_id is required')
ids, is_super = _entity_scope()
if is_super:
return int(entity_id)
if not ids:
raise PermissionError('User is not linked to any entity')
if int(entity_id) not in ids:
raise PermissionError('Entity access denied')
return int(entity_id)
def _default_entity_id_from_scope():
"""Pick the default entity for creation from the current user scope."""
ids, is_super = _entity_scope()
if ids:
return ids[0]
if is_super:
return False
raise PermissionError('User is not linked to any entity')
def _scoped_entity_domain(base_domain=None, field='entity_id'):
"""Apply entity isolation domain to a model query."""
domain = list(base_domain or [])
ids, is_super = _entity_scope()
if is_super:
return domain
if not ids:
return domain + [('id', '=', 0)]
return domain + [(field, 'in', ids)]
def _serialize_course(c):
subj = getattr(c, 'encoach_subject_id', False)
tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag'])
@@ -43,6 +85,8 @@ def _serialize_course(c):
'chapter_count': getattr(c, 'chapter_count', 0) or 0,
'resource_count': getattr(c, 'resource_count', 0) or 0,
'objective_count': getattr(c, 'objective_count', 0) or 0,
'entity_id': c.entity_id.id if hasattr(c, 'entity_id') and c.entity_id else None,
'entity_name': c.entity_id.name if hasattr(c, 'entity_id') and c.entity_id else '',
}
@@ -75,6 +119,8 @@ def _serialize_student(s):
'batch_name': batch_name,
'partner_id': partner.id,
'user_id': s.user_id.id if s.user_id else None,
'entity_id': s.entity_id.id if hasattr(s, 'entity_id') and s.entity_id else None,
'entity_name': s.entity_id.name if hasattr(s, 'entity_id') and s.entity_id else '',
}
@@ -97,6 +143,8 @@ def _serialize_teacher(f):
'department_name': dept.name if dept else '',
'specialization': getattr(f, 'specialization', '') or '',
'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [],
'entity_id': f.entity_id.id if hasattr(f, 'entity_id') and f.entity_id else None,
'entity_name': f.entity_id.name if hasattr(f, 'entity_id') and f.entity_id else '',
}
@@ -127,6 +175,8 @@ def _serialize_batch(b):
'max_students': getattr(b, 'max_students', 0) or 0,
'student_count': len(students),
'students': students,
'entity_id': b.entity_id.id if hasattr(b, 'entity_id') and b.entity_id else None,
'entity_name': b.entity_id.name if hasattr(b, 'entity_id') and b.entity_id else '',
}
@@ -139,9 +189,11 @@ class LmsCoreController(http.Controller):
def list_courses(self, **kw):
try:
Course = request.env['op.course'].sudo()
domain = []
domain = _scoped_entity_domain([])
if kw.get('status'):
pass # op.course has no status field by default
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw)
total = Course.search_count(domain)
records = Course.search(domain, offset=offset, limit=limit, order='id desc')
@@ -154,7 +206,7 @@ class LmsCoreController(http.Controller):
})
except Exception as e:
_logger.exception('list_courses failed')
return _error_response(str(e), 500)
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
@@ -163,6 +215,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists():
return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
return _json_response({'data': _serialize_course(rec)})
except Exception as e:
_logger.exception('get_course failed')
@@ -173,10 +228,17 @@ class LmsCoreController(http.Controller):
def create_course(self, **kw):
try:
body = _get_json_body()
requested_entity = body.get('entity_id')
if requested_entity:
entity_id = _ensure_entity_access(int(requested_entity))
else:
entity_id = _default_entity_id_from_scope()
vals = {
'name': body.get('name', ''),
'code': body.get('code', ''),
}
if entity_id:
vals['entity_id'] = entity_id
if body.get('description'):
vals['description'] = body['description']
if body.get('max_capacity'):
@@ -197,7 +259,7 @@ class LmsCoreController(http.Controller):
return _json_response({'data': _serialize_course(rec)})
except Exception as e:
_logger.exception('create_course failed')
return _error_response(str(e), 500)
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
@@ -206,6 +268,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists():
return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body()
vals = {}
for k in ('name', 'code', 'description'):
@@ -225,12 +290,14 @@ class LmsCoreController(http.Controller):
vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
if 'tag_ids' in body:
vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])]
if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
if vals:
rec.write(vals)
return _json_response({'data': _serialize_course(rec)})
except Exception as e:
_logger.exception('update_course failed')
return _error_response(str(e), 500)
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
@@ -239,6 +306,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists():
return _json_response({'success': True})
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
force = str(kw.get('force', '')).lower() in ('1', 'true', 'yes')
enrollments = request.env['op.student.course'].sudo().search([('course_id', '=', course_id)])
if enrollments and not force:
@@ -272,8 +342,12 @@ class LmsCoreController(http.Controller):
('student_id', '=', student.id)
])
course_ids = course_details.mapped('course_id')
ids, is_super = _entity_scope()
items = []
for c in course_ids:
if not is_super:
if not c.entity_id or c.entity_id.id not in ids:
continue
data = _serialize_course(c)
cd = course_details.filtered(lambda d: d.course_id.id == c.id)
data['batch_id'] = cd[0].batch_id.id if cd and cd[0].batch_id else None
@@ -307,6 +381,9 @@ class LmsCoreController(http.Controller):
student = request.env['op.student'].sudo().browse(student_id)
if not student.exists():
return _error_response('Student not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not student.entity_id or student.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body()
course_id = body.get('course_id')
course_ids = body.get('course_ids', [])
@@ -317,6 +394,13 @@ class LmsCoreController(http.Controller):
SC = request.env['op.student.course'].sudo()
for cid in course_ids:
cid = int(cid)
course = request.env['op.course'].sudo().browse(cid)
if not course.exists():
continue
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
continue
if student.entity_id and course.entity_id and student.entity_id.id != course.entity_id.id:
continue
existing = SC.search([
('student_id', '=', student.id),
('course_id', '=', cid),
@@ -347,6 +431,9 @@ class LmsCoreController(http.Controller):
course = request.env['op.course'].sudo().browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body()
student_ids = [int(sid) for sid in body.get('student_ids', [])]
batch_id = body.get('batch_id')
@@ -358,6 +445,13 @@ class LmsCoreController(http.Controller):
SC = request.env['op.student.course'].sudo()
enrolled = []
for sid in student_ids:
stu = request.env['op.student'].sudo().browse(sid)
if not stu.exists():
continue
if not is_super and (not stu.entity_id or stu.entity_id.id not in ids):
continue
if stu.entity_id and course.entity_id and stu.entity_id.id != course.entity_id.id:
continue
existing = SC.search([
('student_id', '=', sid),
('course_id', '=', course_id),
@@ -385,11 +479,13 @@ class LmsCoreController(http.Controller):
def list_students(self, **kw):
try:
Student = request.env['op.student'].sudo()
domain = []
domain = _scoped_entity_domain([])
if kw.get('search'):
domain = [('partner_id.name', 'ilike', kw['search'])]
domain.append(('partner_id.name', 'ilike', kw['search']))
if kw.get('batch_id'):
domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id'])))
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw)
total = Student.search_count(domain)
records = Student.search(domain, offset=offset, limit=limit, order='id desc')
@@ -402,7 +498,7 @@ class LmsCoreController(http.Controller):
})
except Exception as e:
_logger.exception('list_students failed')
return _error_response(str(e), 500)
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
@@ -411,6 +507,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.student'].sudo().browse(student_id)
if not rec.exists():
return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
return _json_response({'data': _serialize_student(rec)})
except Exception as e:
_logger.exception('get_student failed')
@@ -421,6 +520,11 @@ class LmsCoreController(http.Controller):
def create_student(self, **kw):
try:
body = _get_json_body()
requested_entity = body.get('entity_id')
if requested_entity:
entity_id = _ensure_entity_access(int(requested_entity))
else:
entity_id = _default_entity_id_from_scope()
first = body.get('first_name', '')
last = body.get('last_name', '')
name = f"{first} {last}".strip()
@@ -434,6 +538,8 @@ class LmsCoreController(http.Controller):
'partner_id': partner.id,
'gender': body.get('gender', ''),
}
if entity_id:
student_vals['entity_id'] = entity_id
if body.get('birth_date'):
student_vals['birth_date'] = body['birth_date']
student = request.env['op.student'].sudo().create(student_vals)
@@ -453,11 +559,13 @@ class LmsCoreController(http.Controller):
'password': body.get('password', 'student123'),
'partner_id': partner.id,
})
if entity_id and hasattr(user, 'entity_ids'):
user.write({'entity_ids': [(4, entity_id)]})
student.sudo().write({'user_id': user.id})
return _json_response({'data': _serialize_student(student)})
except Exception as e:
_logger.exception('create_student failed')
return _error_response(str(e), 500)
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
@@ -466,6 +574,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.student'].sudo().browse(student_id)
if not rec.exists():
return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body()
partner_vals = {}
if 'first_name' in body or 'last_name' in body:
@@ -481,12 +592,14 @@ class LmsCoreController(http.Controller):
student_vals = {}
if 'gender' in body:
student_vals['gender'] = body['gender']
if 'entity_id' in body:
student_vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
if student_vals:
rec.write(student_vals)
return _json_response({'data': _serialize_student(rec)})
except Exception as e:
_logger.exception('update_student failed')
return _error_response(str(e), 500)
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
@@ -494,6 +607,9 @@ class LmsCoreController(http.Controller):
try:
rec = request.env['op.student'].sudo().browse(student_id)
if rec.exists():
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
rec.unlink()
return _json_response({'success': True})
except Exception as e:
@@ -507,9 +623,11 @@ class LmsCoreController(http.Controller):
def list_teachers(self, **kw):
try:
Faculty = request.env['op.faculty'].sudo()
domain = []
domain = _scoped_entity_domain([])
if kw.get('search'):
domain = [('partner_id.name', 'ilike', kw['search'])]
domain.append(('partner_id.name', 'ilike', kw['search']))
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw)
total = Faculty.search_count(domain)
records = Faculty.search(domain, offset=offset, limit=limit, order='id desc')
@@ -522,13 +640,18 @@ class LmsCoreController(http.Controller):
})
except Exception as e:
_logger.exception('list_teachers failed')
return _error_response(str(e), 500)
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/teachers', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_teacher(self, **kw):
try:
body = _get_json_body()
requested_entity = body.get('entity_id')
if requested_entity:
entity_id = _ensure_entity_access(int(requested_entity))
else:
entity_id = _default_entity_id_from_scope()
first = body.get('first_name', '')
last = body.get('last_name', '')
name = f"{first} {last}".strip()
@@ -541,6 +664,8 @@ class LmsCoreController(http.Controller):
'partner_id': partner.id,
'gender': body.get('gender', ''),
}
if entity_id:
fac_vals['entity_id'] = entity_id
if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'):
fac_vals['department_id'] = int(body['department_id'])
if body.get('birth_date'):
@@ -549,14 +674,64 @@ class LmsCoreController(http.Controller):
return _json_response({'data': _serialize_teacher(faculty)})
except Exception as e:
_logger.exception('create_teacher failed')
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_teacher(self, teacher_id, **kw):
try:
rec = request.env['op.faculty'].sudo().browse(teacher_id)
if not rec.exists():
return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
return _json_response({'data': _serialize_teacher(rec)})
except Exception as e:
_logger.exception('get_teacher failed')
return _error_response(str(e), 500)
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_teacher(self, teacher_id, **kw):
try:
rec = request.env['op.faculty'].sudo().browse(teacher_id)
if not rec.exists():
return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body()
pvals = {}
if 'name' in body:
pvals['name'] = body['name']
if 'email' in body:
pvals['email'] = body['email']
if 'phone' in body:
pvals['phone'] = body['phone']
if pvals:
rec.partner_id.sudo().write(pvals)
vals = {}
if 'gender' in body:
vals['gender'] = body['gender']
if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
if vals:
rec.write(vals)
return _json_response({'data': _serialize_teacher(rec)})
except Exception as e:
_logger.exception('update_teacher failed')
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_teacher(self, teacher_id, **kw):
try:
rec = request.env['op.faculty'].sudo().browse(teacher_id)
if rec.exists():
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
rec.unlink()
return _json_response({'success': True})
except Exception as e:
@@ -570,7 +745,9 @@ class LmsCoreController(http.Controller):
def list_batches(self, **kw):
try:
Batch = request.env['op.batch'].sudo()
domain = []
domain = _scoped_entity_domain([])
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw)
total = Batch.search_count(domain)
records = Batch.search(domain, offset=offset, limit=limit, order='id desc')
@@ -583,7 +760,7 @@ class LmsCoreController(http.Controller):
})
except Exception as e:
_logger.exception('list_batches failed')
return _error_response(str(e), 500)
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
@@ -592,6 +769,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.batch'].sudo().browse(batch_id)
if not rec.exists():
return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
return _json_response({'data': _serialize_batch(rec)})
except Exception as e:
_logger.exception('get_batch failed')
@@ -602,11 +782,18 @@ class LmsCoreController(http.Controller):
def create_batch(self, **kw):
try:
body = _get_json_body()
requested_entity = body.get('entity_id')
if requested_entity:
entity_id = _ensure_entity_access(int(requested_entity))
else:
entity_id = _default_entity_id_from_scope()
vals = {'name': body.get('name', '')}
if body.get('code'):
vals['code'] = body['code']
if body.get('course_id'):
vals['course_id'] = int(body['course_id'])
if entity_id:
vals['entity_id'] = entity_id
if body.get('start_date'):
vals['start_date'] = body['start_date']
if body.get('end_date'):
@@ -615,7 +802,7 @@ class LmsCoreController(http.Controller):
return _json_response({'data': _serialize_batch(rec)})
except Exception as e:
_logger.exception('create_batch failed')
return _error_response(str(e), 500)
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
@@ -624,6 +811,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.batch'].sudo().browse(batch_id)
if not rec.exists():
return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body()
vals = {}
for k in ('name', 'code', 'start_date', 'end_date'):
@@ -631,12 +821,14 @@ class LmsCoreController(http.Controller):
vals[k] = body[k]
if 'course_id' in body:
vals['course_id'] = int(body['course_id'])
if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
if vals:
rec.write(vals)
return _json_response({'data': _serialize_batch(rec)})
except Exception as e:
_logger.exception('update_batch failed')
return _error_response(str(e), 500)
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
@@ -644,6 +836,9 @@ class LmsCoreController(http.Controller):
try:
rec = request.env['op.batch'].sudo().browse(batch_id)
if rec.exists():
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
rec.unlink()
return _json_response({'success': True})
except Exception as e:
@@ -655,6 +850,12 @@ class LmsCoreController(http.Controller):
def list_batch_students(self, batch_id, **kw):
try:
SC = request.env['op.student.course'].sudo()
batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists():
return _error_response('Batch not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
return _error_response('Forbidden', 403)
recs = SC.search([('batch_id', '=', batch_id)])
students = []
for sc in recs:
@@ -680,11 +881,19 @@ class LmsCoreController(http.Controller):
batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists():
return _error_response('Batch not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body()
student_ids = [int(s) for s in body.get('student_ids', [])]
SC = request.env['op.student.course'].sudo()
added = []
for sid in student_ids:
stu = request.env['op.student'].sudo().browse(sid)
if not stu.exists():
continue
if batch.entity_id and stu.entity_id and batch.entity_id.id != stu.entity_id.id:
continue
existing = SC.search([
('student_id', '=', sid),
('batch_id', '=', batch_id),
@@ -719,6 +928,12 @@ class LmsCoreController(http.Controller):
def remove_students_from_batch(self, batch_id, **kw):
"""Remove students from a batch by clearing their batch_id."""
try:
batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists():
return _error_response('Batch not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body()
student_ids = [int(s) for s in body.get('student_ids', [])]
SC = request.env['op.student.course'].sudo()
@@ -751,6 +966,9 @@ class LmsCoreController(http.Controller):
course = request.env['op.course'].sudo().browse(course_id)
if not course.exists():
return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
return _error_response('Forbidden', 403)
subj = course.encoach_subject_id if hasattr(course, 'encoach_subject_id') else False
topics = course.encoach_topic_ids if hasattr(course, 'encoach_topic_ids') else course.env['encoach.topic']
objectives = course.learning_objective_ids if hasattr(course, 'learning_objective_ids') else course.env['encoach.learning.objective']
@@ -771,9 +989,9 @@ class LmsCoreController(http.Controller):
def subject_courses(self, subject_id, **kw):
"""Return all courses linked to a given taxonomy subject."""
try:
courses = request.env['op.course'].sudo().search([
courses = request.env['op.course'].sudo().search(_scoped_entity_domain([
('encoach_subject_id', '=', subject_id)
])
]))
return _json_response({
'items': [_serialize_course(c) for c in courses],
'total': len(courses),

View File

@@ -63,17 +63,70 @@ def _attempt_completed_at(att):
return None
def _allowed_entity_ids(env):
"""Return the set of entity ids the calling user is allowed to see.
* Admins / system users: ``None`` (unrestricted — they may pass any
``entity_id`` query param to scope manually).
* Corporate / master-corporate / teacher / student: the entity ids
linked to ``res.users.entity_ids`` on their record. Empty set means
"no entities, see nothing" (defensive — better than leaking).
"""
user = env.user
if not user or not user.id:
return set()
user_type = getattr(user, 'user_type', None)
if user_type == 'admin' or user.has_group('base.group_system'):
return None # unrestricted
try:
return set((user.entity_ids or env['encoach.entity']).ids)
except Exception:
return set()
def _build_attempt_domain(kw, reportable=True):
"""Common filter reader used by all three endpoints."""
"""Common filter reader used by all three endpoints.
Always enforces the caller's allowed entity scope as a default
domain so corporate users can never see another company's data —
even if they omit ``entity_id`` or pass one outside their allow-list.
Admins are unrestricted and may pass any ``entity_id``.
"""
from odoo.http import request as _req
domain = []
if reportable:
domain.append(('status', 'in', list(REPORTABLE_STATUSES)))
entity_id = kw.get('entity_id')
if entity_id:
allowed = _allowed_entity_ids(_req.env)
requested_entity = kw.get('entity_id')
requested_int = None
if requested_entity:
try:
domain.append(('entity_id', '=', int(entity_id)))
requested_int = int(requested_entity)
except (TypeError, ValueError):
pass
requested_int = None
if allowed is None:
# Admin: honour the requested entity_id verbatim, no scoping.
if requested_int is not None:
domain.append(('entity_id', '=', requested_int))
else:
# Non-admin: clamp the requested entity to the allow-list. If
# they didn't request one, scope to all of theirs. If their
# request is outside the allow-list, return an empty result set
# by appending an impossible domain — never a 200 with leaked
# data.
if requested_int is not None:
if requested_int in allowed:
domain.append(('entity_id', '=', requested_int))
else:
domain.append(('entity_id', '=', -1)) # forces empty
else:
if allowed:
domain.append(('entity_id', 'in', list(allowed)))
else:
domain.append(('entity_id', '=', -1)) # no entities, no data
user_id = kw.get('user_id') or kw.get('student_id')
if user_id:
try:

View File

@@ -1,17 +1,90 @@
import base64
import logging
import mimetypes
import os
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
validate_token,
)
_logger = logging.getLogger(__name__)
# Keep this in sync with the Selection on ``encoach.resource`` so we
# never silently drop a category. Order matters: more specific MIMEs
# (``application/pdf``) must come before catch-all groups (``image/*``)
# because the matcher walks the list top-to-bottom.
_MIME_TO_TYPE = (
('application/pdf', 'pdf'),
('image/', 'image'),
('audio/', 'audio'),
('video/', 'video'),
('text/html', 'article'),
('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'document'),
('application/msword', 'document'),
('application/vnd.openxmlformats-officedocument.presentationml.presentation', 'document'),
('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'document'),
('text/', 'document'),
)
def _detect_type_from_mime(mime):
"""Map a MIME string to one of the ``encoach.resource.type`` values."""
if not mime:
return ''
mime = mime.lower().split(';')[0].strip()
for prefix, rtype in _MIME_TO_TYPE:
if mime.startswith(prefix):
return rtype
return ''
def _resolve_attachment_mime(rec):
"""Find the MIME of the binary even when ``rec.mimetype`` was never
persisted (older rows uploaded before the schema migration). We
walk the ir.attachment row Odoo creates for ``Binary(attachment=True)``
and fall back to extension sniffing on the human name.
"""
if rec.mimetype:
return rec.mimetype.split(';')[0].strip().lower()
att = rec.env['ir.attachment'].sudo().search([
('res_model', '=', 'encoach.resource'),
('res_id', '=', rec.id),
('res_field', '=', 'file'),
], limit=1)
if att and att.mimetype:
return att.mimetype.split(';')[0].strip().lower()
if rec.name:
return (mimetypes.guess_type(rec.name)[0] or '').lower()
return ''
def _build_filename(rec):
"""Best-effort filename with extension for the download header.
Priority: 1) the persisted original_filename (always has the
extension as uploaded), 2) the human name + an extension guessed
from the cached mimetype (or sniffed from the linked ir.attachment),
3) the human name as-is.
"""
if rec.original_filename:
return rec.original_filename
base = (rec.name or f'resource-{rec.id}').strip()
if '.' in os.path.basename(base):
return base
mime = _resolve_attachment_mime(rec)
ext = mimetypes.guess_extension(mime) if mime else ''
return f'{base}{ext}' if ext else base
def _ser_resource(r):
tags = r.tag_ids if r.tag_ids else r.env['encoach.resource.tag']
objectives = r.learning_objective_ids if r.learning_objective_ids else r.env['encoach.learning.objective']
download_url = f'/api/resources/{r.id}/download' if r.file else ''
preview_url = f'/api/resources/{r.id}/download?inline=1' if r.file else ''
return {
'id': r.id,
'name': r.name or '',
@@ -30,6 +103,11 @@ def _ser_resource(r):
'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
'url': r.url or '',
'has_file': bool(r.file),
'mimetype': r.mimetype or '',
'original_filename': r.original_filename or '',
'download_url': download_url,
'preview_url': preview_url,
'file_name': r.original_filename or r.name or '',
'difficulty': r.difficulty or '',
'duration_minutes': r.duration_minutes or 0,
'author_id': r.creator_id.id if r.creator_id else None,
@@ -125,7 +203,36 @@ class ResourcesController(http.Controller):
if params.get('cefr_level'):
vals['cefr_level'] = params['cefr_level']
if f:
vals['file'] = base64.b64encode(f.read())
payload = f.read()
vals['file'] = base64.b64encode(payload)
# Persist the *real* upload filename — the human-
# readable ``name`` field often loses the extension
# ("test" instead of "test.pdf"), which broke
# downloads and inline previews.
if f.filename:
vals['original_filename'] = f.filename
# Detect MIME — prefer the one Werkzeug parsed from
# the multipart upload; fall back to extension sniffing
# for clients that don't send it.
mime = (f.mimetype or '').split(';')[0].strip().lower()
if not mime and f.filename:
mime = (mimetypes.guess_type(f.filename)[0] or '').lower()
if mime:
vals['mimetype'] = mime
# Auto-correct the type when the user picked the wrong
# one in the dropdown (e.g. PDF default but actually an
# image), or when no type was supplied at all. We only
# *override* an explicit user choice when the picked
# type clearly contradicts the mime — otherwise keep
# what the admin selected.
detected = _detect_type_from_mime(mime)
if detected:
if not vals.get('type'):
vals['type'] = detected
elif vals['type'] in ('pdf', 'image', 'audio', 'video') \
and vals['type'] != detected \
and detected in ('pdf', 'image', 'audio', 'video'):
vals['type'] = detected
rec = request.env['encoach.resource'].sudo().create(vals)
return _json_response({'data': _ser_resource(rec)})
except Exception as e:
@@ -195,21 +302,49 @@ class ResourcesController(http.Controller):
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/resources/<int:rid>/download', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
# ``auth='none'`` + manual JWT validation lets us accept the token
# from either the ``Authorization: Bearer`` header (download anchor
# via fetch) **or** the ``?token=`` query param (preview iframe /
# <img> / <audio>). HTML media tags can't send custom headers, so
# the query-param fallback is what makes inline preview work.
@http.route('/api/resources/<int:rid>/download', type='http',
auth='none', methods=['GET'], csrf=False)
def download_resource(self, rid, **kw):
try:
user = validate_token(allow_query_param=True)
if not user:
return _error_response('Authentication required', 401)
request.update_env(user=user.id)
rec = request.env['encoach.resource'].sudo().browse(rid)
if not rec.exists() or not rec.file:
return _error_response('No file', 404)
import mimetypes
ext = (rec.name or '').rsplit('.', 1)[-1].lower() if '.' in (rec.name or '') else ''
mime = mimetypes.types_map.get(f'.{ext}', 'application/octet-stream')
data = base64.b64decode(rec.file)
filename = _build_filename(rec)
mime = (
_resolve_attachment_mime(rec)
or mimetypes.guess_type(filename)[0]
or 'application/octet-stream'
)
# ``inline=1`` (or ``preview=1``) lets the browser render
# PDFs in an iframe / images in <img> instead of forcing a
# download. Default stays ``attachment`` for safety so
# existing /download links keep their old behaviour.
inline_flag = (
request.httprequest.args.get('inline')
or request.httprequest.args.get('preview')
or ''
).lower() in ('1', 'true', 'yes')
disposition_kind = 'inline' if inline_flag else 'attachment'
return request.make_response(data, [
('Content-Type', mime),
('Content-Disposition', f'attachment; filename="{rec.name}"'),
('Content-Disposition', f'{disposition_kind}; filename="{filename}"'),
('Content-Length', str(len(data))),
('Cache-Control', 'private, max-age=3600'),
])
except Exception as e:
_logger.exception('download_resource')

View File

@@ -6,6 +6,13 @@ class OpCourseExt(models.Model):
description = fields.Text('Description')
max_capacity = fields.Integer('Max Capacity', default=30)
entity_id = fields.Many2one(
'encoach.entity',
string='Entity',
ondelete='set null',
index=True,
help='Owning entity/organization for LMS isolation.',
)
encoach_subject_id = fields.Many2one(
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
@@ -54,3 +61,39 @@ class OpCourseExt(models.Model):
('resource_id', '!=', False),
])
rec.resource_count = len(mats.mapped('resource_id'))
class OpBatchExt(models.Model):
_inherit = 'op.batch'
entity_id = fields.Many2one(
'encoach.entity',
string='Entity',
ondelete='set null',
index=True,
help='Owning entity/organization for LMS isolation.',
)
class OpStudentExt(models.Model):
_inherit = 'op.student'
entity_id = fields.Many2one(
'encoach.entity',
string='Entity',
ondelete='set null',
index=True,
help='Owning entity/organization for LMS isolation.',
)
class OpFacultyExt(models.Model):
_inherit = 'op.faculty'
entity_id = fields.Many2one(
'encoach.entity',
string='Entity',
ondelete='set null',
index=True,
help='Owning entity/organization for LMS isolation.',
)