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:
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user