feat(backend): course-plan student visibility, multi-voice TTS, OCR sources, branches
Ported from monorepo v4 commit 3b62075d (backend/* portion). encoach_ai_course: - workbook_attempt model + scoring + REST endpoints for student attempts - dialogue_parser splits scripts by speaker, classifies gender, strips labels - media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation, manual media upload endpoint (audio/image/video) with size validation - source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs, page-streaming to stay under memory limit - exercise_extractor + rag_context for RAG-grounded interactive workbooks - course_plan_pipeline: v2 generator that grounds week material on indexed sources and persists grounded_on_json metadata - security: access rules for new models encoach_lms_api: - branches model + controller (entity-scoped LMS branches) - classroom_ext + course_ext (assignment + section workflow) - classrooms controller: students/teachers/assign-course endpoints Made-with: Cursor
This commit is contained in:
@@ -26,3 +26,4 @@ from . import paymob
|
||||
from . import platform_settings
|
||||
from . import training
|
||||
from . import reports
|
||||
from . import branches
|
||||
|
||||
174
custom_addons/encoach_lms_api/controllers/branches.py
Normal file
174
custom_addons/encoach_lms_api/controllers/branches.py
Normal file
@@ -0,0 +1,174 @@
|
||||
import logging
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _entity_scope():
|
||||
"""Same convention as ``encoach_lms_api.controllers.lms_core._entity_scope``."""
|
||||
user = request.env.user.sudo()
|
||||
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
|
||||
if ids:
|
||||
return ids, False
|
||||
is_super = bool(
|
||||
user.id == 1 or user.has_group('base.group_system')
|
||||
)
|
||||
return ids, is_super
|
||||
|
||||
|
||||
def _ensure_entity_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():
|
||||
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):
|
||||
domain = list(base_domain or [])
|
||||
ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return domain
|
||||
if not ids:
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + [('entity_id', 'in', ids)]
|
||||
|
||||
|
||||
def _ser_branch(rec):
|
||||
return {
|
||||
'id': rec.id,
|
||||
'name': rec.name or '',
|
||||
'code': rec.code or '',
|
||||
'active': bool(rec.active),
|
||||
'notes': rec.notes or '',
|
||||
'entity_id': rec.entity_id.id if rec.entity_id else None,
|
||||
'entity_name': rec.entity_id.name if rec.entity_id else '',
|
||||
'course_count': rec.course_count or 0,
|
||||
'batch_count': rec.batch_count or 0,
|
||||
'student_count': rec.student_count or 0,
|
||||
'teacher_count': rec.teacher_count or 0,
|
||||
}
|
||||
|
||||
|
||||
class LmsBranchController(http.Controller):
|
||||
@http.route('/api/branches', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_branches(self, **kw):
|
||||
try:
|
||||
Branch = request.env['encoach.lms.branch'].sudo()
|
||||
domain = _scoped_entity_domain([])
|
||||
if kw.get('search'):
|
||||
q = kw['search']
|
||||
domain += ['|', ('name', 'ilike', q), ('code', 'ilike', q)]
|
||||
if kw.get('active') in ('true', 'false', '1', '0'):
|
||||
domain.append(('active', '=', kw.get('active') in ('true', '1')))
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Branch.search_count(domain)
|
||||
recs = Branch.search(domain, offset=offset, limit=limit, order='name asc, id asc')
|
||||
items = [_ser_branch(r) for r in recs]
|
||||
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
|
||||
except Exception as exc:
|
||||
_logger.exception('list_branches failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_branch(self, branch_id, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
return _json_response({'data': _ser_branch(rec)})
|
||||
except Exception as exc:
|
||||
_logger.exception('get_branch failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/branches', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_branch(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
name = (body.get('name') or '').strip()
|
||||
if not name:
|
||||
return _error_response('name is required', 400)
|
||||
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': name,
|
||||
'entity_id': entity_id,
|
||||
'code': (body.get('code') or '').strip() or False,
|
||||
'notes': (body.get('notes') or '').strip(),
|
||||
}
|
||||
if 'active' in body:
|
||||
vals['active'] = bool(body.get('active'))
|
||||
rec = request.env['encoach.lms.branch'].sudo().create(vals)
|
||||
return _json_response({'data': _ser_branch(rec)})
|
||||
except Exception as exc:
|
||||
_logger.exception('create_branch failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_branch(self, branch_id, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
if 'name' in body:
|
||||
vals['name'] = (body.get('name') or '').strip()
|
||||
if 'code' in body:
|
||||
vals['code'] = (body.get('code') or '').strip() or False
|
||||
if 'notes' in body:
|
||||
vals['notes'] = (body.get('notes') or '').strip()
|
||||
if 'active' in body:
|
||||
vals['active'] = bool(body.get('active'))
|
||||
if 'entity_id' in body and body.get('entity_id'):
|
||||
vals['entity_id'] = _ensure_entity_access(int(body.get('entity_id')))
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _ser_branch(rec)})
|
||||
except Exception as exc:
|
||||
_logger.exception('update_branch failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_branch(self, branch_id, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
|
||||
if rec.exists():
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as exc:
|
||||
_logger.exception('delete_branch failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
"""HTTP controller for classrooms (homerooms).
|
||||
|
||||
Routes:
|
||||
- GET /api/classrooms list (entity-scoped)
|
||||
- GET /api/classrooms/<id> fetch with full nested data
|
||||
- POST /api/classrooms create
|
||||
- PATCH /api/classrooms/<id> partial update
|
||||
- DELETE /api/classrooms/<id> delete
|
||||
|
||||
- GET /api/classrooms/<id>/students list roster
|
||||
- POST /api/classrooms/<id>/students set/add students (body: {student_ids:[..], mode:'set'|'add'})
|
||||
- DELETE /api/classrooms/<id>/students remove students (body: {student_ids:[..]})
|
||||
|
||||
- GET /api/classrooms/<id>/teachers list homeroom teachers
|
||||
- POST /api/classrooms/<id>/teachers set/add teachers (body: {teacher_ids:[..], mode:'set'|'add'})
|
||||
|
||||
- GET /api/classrooms/<id>/courses list courses
|
||||
- POST /api/classrooms/<id>/assign-course cascade course → batch + enroll students
|
||||
- DELETE /api/classrooms/<id>/courses/<cid> detach course (does NOT delete batches/enrollments)
|
||||
|
||||
- GET /api/classrooms/<id>/batches list classroom batches
|
||||
|
||||
The legacy ``/api/groups`` aliases on op.classroom remain for backward
|
||||
compatibility but new clients should target ``/api/classrooms``.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
@@ -8,89 +34,592 @@ from odoo.addons.encoach_api.controllers.base import (
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ser_classroom(r):
|
||||
# ----------------------------------------------------------------------
|
||||
# Entity scoping helpers (same shape used in branches.py / lms_core.py)
|
||||
# ----------------------------------------------------------------------
|
||||
def _entity_scope():
|
||||
"""Same convention as ``encoach_lms_api.controllers.lms_core._entity_scope``."""
|
||||
user = request.env.user.sudo()
|
||||
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
|
||||
if ids:
|
||||
return ids, False
|
||||
is_super = bool(
|
||||
user.id == 1 or user.has_group('base.group_system')
|
||||
)
|
||||
return ids, is_super
|
||||
|
||||
|
||||
def _ensure_entity_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():
|
||||
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):
|
||||
domain = list(base_domain or [])
|
||||
ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return domain
|
||||
if not ids:
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + [('entity_id', 'in', ids)]
|
||||
|
||||
|
||||
def _ensure_branch_access(branch_id, *, entity_id=None):
|
||||
"""Validate that the branch exists, belongs to ``entity_id`` (when set)
|
||||
and is accessible by the current user."""
|
||||
if not branch_id:
|
||||
return None
|
||||
Branch = request.env['encoach.lms.branch'].sudo()
|
||||
branch = Branch.browse(int(branch_id))
|
||||
if not branch.exists():
|
||||
raise ValueError('Branch not found')
|
||||
_ensure_entity_access(branch.entity_id.id)
|
||||
if entity_id and branch.entity_id.id != int(entity_id):
|
||||
raise PermissionError('Branch does not belong to the requested entity.')
|
||||
return branch.id
|
||||
|
||||
|
||||
def _filter_ids_in_entity(model, ids, entity_id, *, label='record'):
|
||||
"""Return ids that exist *and* belong to ``entity_id``.
|
||||
|
||||
When ``entity_id`` is falsy (e.g. legacy classroom without an entity), we
|
||||
accept any record that has no ``entity_id`` set yet. This keeps the API
|
||||
safe by default while still allowing administrators to operate on
|
||||
pre-LMS-isolation data without surprises.
|
||||
"""
|
||||
if not ids:
|
||||
return []
|
||||
Model = request.env[model].sudo()
|
||||
recs = Model.browse([int(x) for x in ids])
|
||||
out = []
|
||||
for rec in recs:
|
||||
if not rec.exists():
|
||||
continue
|
||||
rec_entity = rec.entity_id.id if rec.entity_id else None
|
||||
if entity_id and rec_entity and rec_entity != int(entity_id):
|
||||
raise PermissionError(
|
||||
f"{label} {rec.id} belongs to a different entity"
|
||||
)
|
||||
out.append(rec.id)
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Serializers
|
||||
# ----------------------------------------------------------------------
|
||||
def _ser_student_short(s):
|
||||
return {
|
||||
'id': r.id,
|
||||
'name': r.name or '',
|
||||
'code': getattr(r, 'code', '') or '',
|
||||
'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0,
|
||||
'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '',
|
||||
'capacity': getattr(r, 'capacity', 0) or 0,
|
||||
'id': s.id,
|
||||
'name': s.name or '',
|
||||
'email': s.email or '',
|
||||
'gr_no': getattr(s, 'gr_no', '') or '',
|
||||
}
|
||||
|
||||
|
||||
def _ser_teacher_short(f):
|
||||
return {
|
||||
'id': f.id,
|
||||
'name': f.name or '',
|
||||
'email': getattr(f, 'email', '') or '',
|
||||
}
|
||||
|
||||
|
||||
def _ser_course_short(c):
|
||||
return {
|
||||
'id': c.id,
|
||||
'name': c.name or '',
|
||||
'code': c.code or '',
|
||||
}
|
||||
|
||||
|
||||
def _ser_section_short(s):
|
||||
return {
|
||||
'id': s.id,
|
||||
'name': s.name or '',
|
||||
'code': s.code or '',
|
||||
'sequence': s.sequence or 0,
|
||||
'course_id': s.course_id.id if s.course_id else None,
|
||||
'course_name': s.course_id.name if s.course_id else '',
|
||||
}
|
||||
|
||||
|
||||
def _ser_batch_short(b):
|
||||
return {
|
||||
'id': b.id,
|
||||
'name': b.name or '',
|
||||
'code': b.code or '',
|
||||
'course_id': b.course_id.id if b.course_id else None,
|
||||
'course_name': b.course_id.name if b.course_id else '',
|
||||
'course_section_id': (
|
||||
b.course_section_id.id
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else None
|
||||
),
|
||||
'course_section_name': (
|
||||
b.course_section_id.name
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else ''
|
||||
),
|
||||
'course_section_code': (
|
||||
b.course_section_id.code
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else ''
|
||||
),
|
||||
'term_key': getattr(b, 'term_key', '') or '',
|
||||
'start_date': b.start_date and str(b.start_date) or '',
|
||||
'end_date': b.end_date and str(b.end_date) or '',
|
||||
'student_count': getattr(b, 'student_count', 0) or 0,
|
||||
'teacher_ids': b.teacher_ids.ids if hasattr(b, 'teacher_ids') else [],
|
||||
}
|
||||
|
||||
|
||||
def _ser_classroom(r, *, full=False):
|
||||
data = {
|
||||
'id': r.id,
|
||||
'name': r.name or '',
|
||||
'code': r.code or '',
|
||||
'capacity': r.capacity or 0,
|
||||
'active': bool(r.active),
|
||||
'notes': getattr(r, 'notes', '') or '',
|
||||
'entity_id': r.entity_id.id if r.entity_id else None,
|
||||
'entity_name': r.entity_id.name if r.entity_id else '',
|
||||
'branch_id': r.branch_id.id if r.branch_id else None,
|
||||
'branch_name': r.branch_id.name if r.branch_id else '',
|
||||
'student_count': r.student_count or 0,
|
||||
'teacher_count': r.teacher_count or 0,
|
||||
'course_count': r.course_count or 0,
|
||||
'batch_count': r.batch_count or 0,
|
||||
'student_ids': r.student_ids.ids,
|
||||
'teacher_ids': r.teacher_ids.ids,
|
||||
'course_ids': r.course_ids.ids,
|
||||
}
|
||||
if full:
|
||||
data['students'] = [_ser_student_short(s) for s in r.student_ids]
|
||||
data['teachers'] = [_ser_teacher_short(t) for t in r.teacher_ids]
|
||||
data['courses'] = [_ser_course_short(c) for c in r.course_ids]
|
||||
data['sections'] = [
|
||||
_ser_section_short(s)
|
||||
for s in request.env['encoach.course.section'].sudo().search([
|
||||
('course_id', 'in', r.course_ids.ids)
|
||||
], order='course_id, sequence, id')
|
||||
]
|
||||
data['batches'] = [_ser_batch_short(b) for b in r.batch_ids]
|
||||
return data
|
||||
|
||||
|
||||
def _classroom_or_404(cid):
|
||||
rec = request.env['op.classroom'].sudo().browse(int(cid))
|
||||
if not rec.exists():
|
||||
return None
|
||||
return rec
|
||||
|
||||
|
||||
def _scoped_classroom_domain(base_domain=None):
|
||||
"""Same as ``_scoped_entity_domain`` but tolerates legacy classrooms
|
||||
without ``entity_id`` for backward-compatibility (so existing rooms
|
||||
keep showing up to admins)."""
|
||||
domain = list(base_domain or [])
|
||||
ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return domain
|
||||
if not ids:
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + ['|', ('entity_id', 'in', ids), ('entity_id', '=', False)]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Controller
|
||||
# ----------------------------------------------------------------------
|
||||
class ClassroomsController(http.Controller):
|
||||
|
||||
# ---- CRUD --------------------------------------------------------
|
||||
@http.route('/api/classrooms', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_classrooms(self, **kw):
|
||||
try:
|
||||
Classroom = request.env['op.classroom'].sudo()
|
||||
domain = _scoped_classroom_domain([])
|
||||
if kw.get('search'):
|
||||
q = kw['search']
|
||||
domain += ['|', ('name', 'ilike', q), ('code', 'ilike', q)]
|
||||
if kw.get('active') in ('true', 'false', '1', '0'):
|
||||
domain.append(('active', '=', kw.get('active') in ('true', '1')))
|
||||
if kw.get('branch_id'):
|
||||
domain.append(('branch_id', '=', int(kw['branch_id'])))
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Classroom.search_count(domain)
|
||||
recs = Classroom.search(domain, offset=offset, limit=limit, order='name asc, id asc')
|
||||
items = [_ser_classroom(r) for r in recs]
|
||||
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
|
||||
except Exception as exc:
|
||||
_logger.exception('list_classrooms failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/classrooms/<int:cid>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_classroom(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
return _json_response({'data': _ser_classroom(rec, full=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('get_classroom failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/classrooms', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_classroom(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
name = (body.get('name') or '').strip()
|
||||
if not name:
|
||||
return _error_response('name is required', 400)
|
||||
entity_id = (
|
||||
_ensure_entity_access(int(body['entity_id']))
|
||||
if body.get('entity_id') else _default_entity_id_from_scope()
|
||||
)
|
||||
branch_id = _ensure_branch_access(body.get('branch_id'), entity_id=entity_id) if body.get('branch_id') else None
|
||||
|
||||
code = (body.get('code') or '').strip() or name.upper().replace(' ', '_')[:16]
|
||||
vals = {
|
||||
'name': name,
|
||||
'code': code,
|
||||
'capacity': int(body.get('capacity') or 30),
|
||||
'notes': (body.get('notes') or '').strip(),
|
||||
'entity_id': entity_id,
|
||||
'branch_id': branch_id or False,
|
||||
}
|
||||
if 'active' in body:
|
||||
vals['active'] = bool(body.get('active'))
|
||||
if body.get('student_ids'):
|
||||
vals['student_ids'] = [(6, 0, [int(x) for x in body['student_ids']])]
|
||||
if body.get('teacher_ids'):
|
||||
vals['teacher_ids'] = [(6, 0, [int(x) for x in body['teacher_ids']])]
|
||||
rec = request.env['op.classroom'].sudo().create(vals)
|
||||
return _json_response({'data': _ser_classroom(rec, full=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('create_classroom failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/classrooms/<int:cid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_classroom(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
if 'name' in body:
|
||||
vals['name'] = (body.get('name') or '').strip()
|
||||
if 'code' in body:
|
||||
vals['code'] = (body.get('code') or '').strip() or rec.code
|
||||
if 'capacity' in body:
|
||||
vals['capacity'] = int(body.get('capacity') or rec.capacity)
|
||||
if 'notes' in body:
|
||||
vals['notes'] = (body.get('notes') or '').strip()
|
||||
if 'active' in body:
|
||||
vals['active'] = bool(body.get('active'))
|
||||
target_entity = rec.entity_id.id
|
||||
if 'entity_id' in body and body.get('entity_id'):
|
||||
target_entity = _ensure_entity_access(int(body['entity_id']))
|
||||
vals['entity_id'] = target_entity
|
||||
if 'branch_id' in body:
|
||||
vals['branch_id'] = (
|
||||
_ensure_branch_access(int(body['branch_id']), entity_id=target_entity)
|
||||
if body.get('branch_id') else False
|
||||
)
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _ser_classroom(rec, full=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('update_classroom failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/classrooms/<int:cid>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_classroom(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if rec:
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as exc:
|
||||
_logger.exception('delete_classroom failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ---- Roster (students) ------------------------------------------
|
||||
@http.route('/api/classrooms/<int:cid>/students', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_classroom_students(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
items = [_ser_student_short(s) for s in rec.student_ids]
|
||||
return _json_response({'items': items, 'data': items, 'total': len(items)})
|
||||
except Exception as exc:
|
||||
_logger.exception('list_classroom_students failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/classrooms/<int:cid>/students', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def set_classroom_students(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
body = _get_json_body()
|
||||
raw_ids = [int(x) for x in (body.get('student_ids') or [])]
|
||||
ids = _filter_ids_in_entity(
|
||||
'op.student', raw_ids,
|
||||
rec.entity_id.id if rec.entity_id else None,
|
||||
label='Student',
|
||||
)
|
||||
mode = (body.get('mode') or 'set').lower()
|
||||
if mode == 'set':
|
||||
rec.write({'student_ids': [(6, 0, ids)]})
|
||||
else:
|
||||
rec.write({'student_ids': [(4, x) for x in ids]})
|
||||
return _json_response({'data': _ser_classroom(rec, full=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('set_classroom_students failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/classrooms/<int:cid>/students', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def remove_classroom_students(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
body = _get_json_body()
|
||||
ids = [int(x) for x in (body.get('student_ids') or [])]
|
||||
rec.write({'student_ids': [(3, x) for x in ids]})
|
||||
return _json_response({'data': _ser_classroom(rec, full=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('remove_classroom_students failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ---- Homeroom teachers ------------------------------------------
|
||||
@http.route('/api/classrooms/<int:cid>/teachers', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_classroom_teachers(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
items = [_ser_teacher_short(t) for t in rec.teacher_ids]
|
||||
return _json_response({'items': items, 'data': items, 'total': len(items)})
|
||||
except Exception as exc:
|
||||
_logger.exception('list_classroom_teachers failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/classrooms/<int:cid>/teachers', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def set_classroom_teachers(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
body = _get_json_body()
|
||||
raw_ids = [int(x) for x in (body.get('teacher_ids') or [])]
|
||||
ids = _filter_ids_in_entity(
|
||||
'op.faculty', raw_ids,
|
||||
rec.entity_id.id if rec.entity_id else None,
|
||||
label='Teacher',
|
||||
)
|
||||
mode = (body.get('mode') or 'set').lower()
|
||||
if mode == 'set':
|
||||
rec.write({'teacher_ids': [(6, 0, ids)]})
|
||||
else:
|
||||
rec.write({'teacher_ids': [(4, x) for x in ids]})
|
||||
return _json_response({'data': _ser_classroom(rec, full=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('set_classroom_teachers failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ---- Courses + cascade ------------------------------------------
|
||||
@http.route('/api/classrooms/<int:cid>/courses', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_classroom_courses(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
items = [_ser_course_short(c) for c in rec.course_ids]
|
||||
return _json_response({'items': items, 'data': items, 'total': len(items)})
|
||||
except Exception as exc:
|
||||
_logger.exception('list_classroom_courses failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/classrooms/<int:cid>/sections', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_classroom_sections(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
domain = [('course_id', 'in', rec.course_ids.ids)]
|
||||
if kw.get('course_id'):
|
||||
domain.append(('course_id', '=', int(kw['course_id'])))
|
||||
if kw.get('active') in ('true', 'false', '1', '0'):
|
||||
domain.append(('active', '=', kw.get('active') in ('true', '1')))
|
||||
items = [
|
||||
_ser_section_short(s)
|
||||
for s in request.env['encoach.course.section'].sudo().search(
|
||||
domain, order='course_id, sequence, id'
|
||||
)
|
||||
]
|
||||
return _json_response({'items': items, 'data': items, 'total': len(items)})
|
||||
except Exception as exc:
|
||||
_logger.exception('list_classroom_sections failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/classrooms/<int:cid>/assign-course', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def assign_classroom_course(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
body = _get_json_body()
|
||||
course_id = int(body.get('course_id') or 0)
|
||||
if not course_id:
|
||||
return _error_response('course_id is required', 400)
|
||||
classroom_entity_id = rec.entity_id.id if rec.entity_id else None
|
||||
_filter_ids_in_entity('op.course', [course_id], classroom_entity_id, label='Course')
|
||||
|
||||
section_id = int(body.get('section_id') or 0) or None
|
||||
if section_id:
|
||||
_filter_ids_in_entity(
|
||||
'encoach.course.section', [section_id],
|
||||
classroom_entity_id, label='Course section',
|
||||
)
|
||||
|
||||
teacher_ids = body.get('teacher_ids')
|
||||
if teacher_ids is not None:
|
||||
teacher_ids = _filter_ids_in_entity(
|
||||
'op.faculty', [int(x) for x in teacher_ids],
|
||||
classroom_entity_id, label='Teacher',
|
||||
)
|
||||
term_name = body.get('term_name') or None
|
||||
term_key = (body.get('term_key') or '').strip() or None
|
||||
start_date = body.get('start_date') or None
|
||||
end_date = body.get('end_date') or None
|
||||
batch = rec.assign_course(
|
||||
course_id,
|
||||
term_name=term_name,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
teacher_ids=teacher_ids,
|
||||
section_id=section_id,
|
||||
term_key=term_key,
|
||||
)
|
||||
return _json_response({
|
||||
'data': _ser_classroom(rec, full=True),
|
||||
'batch': _ser_batch_short(batch),
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('assign_classroom_course failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/classrooms/<int:cid>/assign-section', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def assign_classroom_section(self, cid, **kw):
|
||||
"""Alias to make the classroom×section workflow explicit for clients."""
|
||||
return self.assign_classroom_course(cid, **kw)
|
||||
|
||||
@http.route('/api/classrooms/<int:cid>/courses/<int:course_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def detach_classroom_course(self, cid, course_id, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
rec.write({'course_ids': [(3, int(course_id))]})
|
||||
return _json_response({'data': _ser_classroom(rec, full=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('detach_classroom_course failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ---- Batches -----------------------------------------------------
|
||||
@http.route('/api/classrooms/<int:cid>/batches', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_classroom_batches(self, cid, **kw):
|
||||
try:
|
||||
rec = _classroom_or_404(cid)
|
||||
if not rec:
|
||||
return _error_response('Not found', 404)
|
||||
if rec.entity_id:
|
||||
_ensure_entity_access(rec.entity_id.id)
|
||||
items = [_ser_batch_short(b) for b in rec.batch_ids]
|
||||
return _json_response({'items': items, 'data': items, 'total': len(items)})
|
||||
except Exception as exc:
|
||||
_logger.exception('list_classroom_batches failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ---- Legacy /api/groups aliases (back-compat) -------------------
|
||||
@http.route('/api/groups', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_groups(self, **kw):
|
||||
try:
|
||||
M = request.env['op.classroom'].sudo()
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = M.search_count([])
|
||||
recs = M.search([], offset=offset, limit=limit, order='id desc')
|
||||
items = [_ser_classroom(r) for r in recs]
|
||||
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
return self.list_classrooms(**kw)
|
||||
|
||||
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_group(self, gid, **kw):
|
||||
try:
|
||||
rec = request.env['op.classroom'].sudo().browse(gid)
|
||||
if not rec.exists():
|
||||
return _error_response('Not found', 404)
|
||||
return _json_response(_ser_classroom(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
return self.get_classroom(gid, **kw)
|
||||
|
||||
@http.route('/api/groups', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_group(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
vals = {'name': body.get('name', '')}
|
||||
if body.get('code'):
|
||||
vals['code'] = body['code']
|
||||
if body.get('capacity'):
|
||||
vals['capacity'] = int(body['capacity'])
|
||||
rec = request.env['op.classroom'].sudo().create(vals)
|
||||
return _json_response(_ser_classroom(rec))
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
return self.create_classroom(**kw)
|
||||
|
||||
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_group(self, gid, **kw):
|
||||
try:
|
||||
rec = request.env['op.classroom'].sudo().browse(gid)
|
||||
if rec.exists():
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/groups/transfer', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def transfer(self, **kw):
|
||||
try:
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/groups/<int:gid>/members', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def add_members(self, gid, **kw):
|
||||
try:
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/groups/<int:gid>/members/remove', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def remove_members(self, gid, **kw):
|
||||
try:
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
return self.delete_classroom(gid, **kw)
|
||||
|
||||
@@ -12,10 +12,21 @@ _logger = logging.getLogger(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _entity_scope():
|
||||
"""Return (entity_ids, is_superadmin) for the current user."""
|
||||
"""Return (entity_ids, is_superadmin) for the current user.
|
||||
|
||||
A user is treated as a *platform* superadmin only when they have no
|
||||
entities assigned (``entity_ids`` is empty) AND either are the bootstrap
|
||||
Odoo user (uid=1) or carry ``base.group_system`` directly. Any user
|
||||
linked to one or more entities is always entity-scoped — even Odoo
|
||||
settings admins — so multi-entity LMS isolation is enforced.
|
||||
"""
|
||||
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 []
|
||||
if ids:
|
||||
return ids, False
|
||||
is_super = bool(
|
||||
user.id == 1 or user.has_group('base.group_system')
|
||||
)
|
||||
return ids, is_super
|
||||
|
||||
|
||||
@@ -53,6 +64,49 @@ def _scoped_entity_domain(base_domain=None, field='entity_id'):
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + [(field, 'in', ids)]
|
||||
|
||||
|
||||
def _ensure_branch_access(branch_id, *, entity_id=None):
|
||||
"""Validate branch ownership and user access, return branch id."""
|
||||
if not branch_id:
|
||||
raise PermissionError('branch_id is required')
|
||||
branch = request.env['encoach.lms.branch'].sudo().browse(int(branch_id))
|
||||
if not branch.exists():
|
||||
raise ValueError('Branch not found')
|
||||
_ensure_entity_access(branch.entity_id.id)
|
||||
if entity_id and branch.entity_id.id != int(entity_id):
|
||||
raise PermissionError('Branch entity mismatch')
|
||||
return branch.id
|
||||
|
||||
|
||||
def _assert_record_in_entity(model, rec_id, entity_id, *, label='record'):
|
||||
"""Raise PermissionError if a record (M2O) doesn't belong to ``entity_id``.
|
||||
|
||||
A record without ``entity_id`` is considered legacy/unscoped and is
|
||||
allowed; this keeps pre-isolation data usable while still blocking
|
||||
explicit cross-entity references.
|
||||
"""
|
||||
if not rec_id or not entity_id:
|
||||
return int(rec_id) if rec_id else False
|
||||
rec = request.env[model].sudo().browse(int(rec_id))
|
||||
if not rec.exists():
|
||||
raise ValueError(f'{label} {rec_id} not found')
|
||||
rec_entity = rec.entity_id.id if rec.entity_id else None
|
||||
if rec_entity and rec_entity != int(entity_id):
|
||||
raise PermissionError(
|
||||
f'{label} {rec_id} belongs to a different entity'
|
||||
)
|
||||
return rec.id
|
||||
|
||||
|
||||
def _filter_ids_in_entity(model, ids, entity_id, *, label='record'):
|
||||
"""Same as ``_assert_record_in_entity`` but for a list of ids."""
|
||||
if not ids:
|
||||
return []
|
||||
out = []
|
||||
for rid in ids:
|
||||
out.append(_assert_record_in_entity(model, rid, entity_id, label=label))
|
||||
return out
|
||||
|
||||
def _serialize_course(c):
|
||||
subj = getattr(c, 'encoach_subject_id', False)
|
||||
tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag'])
|
||||
@@ -85,8 +139,39 @@ 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,
|
||||
'section_count': getattr(c, 'section_count', 0) or 0,
|
||||
'sections': [
|
||||
{
|
||||
'id': s.id,
|
||||
'name': s.name or '',
|
||||
'code': s.code or '',
|
||||
'sequence': s.sequence or 0,
|
||||
'active': bool(s.active),
|
||||
}
|
||||
for s in (c.section_ids if hasattr(c, 'section_ids') else c.env['encoach.course.section'])
|
||||
],
|
||||
'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 '',
|
||||
'branch_id': c.branch_id.id if hasattr(c, 'branch_id') and c.branch_id else None,
|
||||
'branch_name': c.branch_id.name if hasattr(c, 'branch_id') and c.branch_id else '',
|
||||
}
|
||||
|
||||
|
||||
def _serialize_course_section(s):
|
||||
return {
|
||||
'id': s.id,
|
||||
'name': s.name or '',
|
||||
'code': s.code or '',
|
||||
'sequence': s.sequence or 0,
|
||||
'active': bool(s.active),
|
||||
'notes': s.notes or '',
|
||||
'course_id': s.course_id.id if s.course_id else None,
|
||||
'course_name': s.course_id.name if s.course_id else '',
|
||||
'entity_id': s.entity_id.id if s.entity_id else None,
|
||||
'entity_name': s.entity_id.name if s.entity_id else '',
|
||||
'branch_id': s.branch_id.id if s.branch_id else None,
|
||||
'branch_name': s.branch_id.name if s.branch_id else '',
|
||||
'batch_count': s.batch_count or 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +206,8 @@ def _serialize_student(s):
|
||||
'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 '',
|
||||
'branch_id': s.branch_id.id if hasattr(s, 'branch_id') and s.branch_id else None,
|
||||
'branch_name': s.branch_id.name if hasattr(s, 'branch_id') and s.branch_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +232,8 @@ def _serialize_teacher(f):
|
||||
'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 '',
|
||||
'branch_id': f.branch_id.id if hasattr(f, 'branch_id') and f.branch_id else None,
|
||||
'branch_name': f.branch_id.name if hasattr(f, 'branch_id') and f.branch_id else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +266,28 @@ def _serialize_batch(b):
|
||||
'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 '',
|
||||
'branch_id': b.branch_id.id if hasattr(b, 'branch_id') and b.branch_id else None,
|
||||
'branch_name': b.branch_id.name if hasattr(b, 'branch_id') and b.branch_id else '',
|
||||
'classroom_id': b.classroom_id.id if hasattr(b, 'classroom_id') and b.classroom_id else None,
|
||||
'classroom_name': b.classroom_id.name if hasattr(b, 'classroom_id') and b.classroom_id else '',
|
||||
'course_section_id': (
|
||||
b.course_section_id.id
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else None
|
||||
),
|
||||
'course_section_name': (
|
||||
b.course_section_id.name
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else ''
|
||||
),
|
||||
'course_section_code': (
|
||||
b.course_section_id.code
|
||||
if hasattr(b, 'course_section_id') and b.course_section_id
|
||||
else ''
|
||||
),
|
||||
'term_key': getattr(b, 'term_key', '') or '',
|
||||
'teacher_ids': b.teacher_ids.ids if hasattr(b, 'teacher_ids') else [],
|
||||
'teacher_names': [t.name or '' for t in b.teacher_ids] if hasattr(b, 'teacher_ids') else [],
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +305,8 @@ class LmsCoreController(http.Controller):
|
||||
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']))))
|
||||
if kw.get('branch_id'):
|
||||
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Course.search_count(domain)
|
||||
records = Course.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -239,6 +352,8 @@ class LmsCoreController(http.Controller):
|
||||
}
|
||||
if entity_id:
|
||||
vals['entity_id'] = entity_id
|
||||
if body.get('branch_id'):
|
||||
vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id)
|
||||
if body.get('description'):
|
||||
vals['description'] = body['description']
|
||||
if body.get('max_capacity'):
|
||||
@@ -292,6 +407,12 @@ class LmsCoreController(http.Controller):
|
||||
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
|
||||
effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
|
||||
if 'branch_id' in body:
|
||||
vals['branch_id'] = (
|
||||
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
|
||||
if body['branch_id'] else False
|
||||
)
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_course(rec)})
|
||||
@@ -327,6 +448,172 @@ class LmsCoreController(http.Controller):
|
||||
_logger.exception('delete_course failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/courses/<int:course_id>/sections', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_course_sections(self, course_id, **kw):
|
||||
try:
|
||||
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)
|
||||
Section = request.env['encoach.course.section'].sudo()
|
||||
domain = [('course_id', '=', course_id)]
|
||||
if kw.get('active') in ('true', 'false', '1', '0'):
|
||||
domain.append(('active', '=', kw.get('active') in ('true', '1')))
|
||||
recs = Section.search(domain, order='sequence, id')
|
||||
items = [_serialize_course_section(r) for r in recs]
|
||||
return _json_response({'items': items, 'data': items, 'total': len(items)})
|
||||
except Exception as e:
|
||||
_logger.exception('list_course_sections failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/courses/<int:course_id>/sections', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_course_section(self, course_id, **kw):
|
||||
try:
|
||||
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()
|
||||
name = (body.get('name') or '').strip()
|
||||
code = (body.get('code') or '').strip()
|
||||
if not name or not code:
|
||||
return _error_response('name and code are required', 400)
|
||||
vals = {
|
||||
'name': name,
|
||||
'code': code,
|
||||
'course_id': course_id,
|
||||
'sequence': int(body.get('sequence') or 10),
|
||||
'notes': (body.get('notes') or '').strip(),
|
||||
'active': bool(body.get('active', True)),
|
||||
}
|
||||
if body.get('branch_id'):
|
||||
vals['branch_id'] = _ensure_branch_access(
|
||||
int(body['branch_id']),
|
||||
entity_id=course.entity_id.id if course.entity_id else None,
|
||||
)
|
||||
rec = request.env['encoach.course.section'].sudo().create(vals)
|
||||
return _json_response({'data': _serialize_course_section(rec)})
|
||||
except Exception as e:
|
||||
_logger.exception('create_course_section failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/courses/<int:course_id>/sections/<int:section_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_course_section(self, course_id, section_id, **kw):
|
||||
try:
|
||||
section = request.env['encoach.course.section'].sudo().browse(section_id)
|
||||
if not section.exists() or section.course_id.id != course_id:
|
||||
return _error_response('Section not found', 404)
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not section.entity_id or section.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
if 'name' in body:
|
||||
vals['name'] = (body.get('name') or '').strip()
|
||||
if 'code' in body:
|
||||
vals['code'] = (body.get('code') or '').strip()
|
||||
if 'sequence' in body:
|
||||
vals['sequence'] = int(body.get('sequence') or 10)
|
||||
if 'notes' in body:
|
||||
vals['notes'] = (body.get('notes') or '').strip()
|
||||
if 'active' in body:
|
||||
vals['active'] = bool(body.get('active'))
|
||||
if 'branch_id' in body:
|
||||
vals['branch_id'] = (
|
||||
_ensure_branch_access(
|
||||
int(body['branch_id']),
|
||||
entity_id=section.entity_id.id if section.entity_id else None,
|
||||
)
|
||||
if body.get('branch_id') else False
|
||||
)
|
||||
if vals:
|
||||
section.write(vals)
|
||||
return _json_response({'data': _serialize_course_section(section)})
|
||||
except Exception as e:
|
||||
_logger.exception('update_course_section failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/courses/<int:course_id>/sections/<int:section_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_course_section(self, course_id, section_id, **kw):
|
||||
try:
|
||||
section = request.env['encoach.course.section'].sudo().browse(section_id)
|
||||
if section.exists() and section.course_id.id == course_id:
|
||||
ids, is_super = _entity_scope()
|
||||
if not is_super and (not section.entity_id or section.entity_id.id not in ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
section.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
_logger.exception('delete_course_section failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/courses/<int:course_id>/sections/generate-defaults', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def generate_default_course_sections(self, course_id, **kw):
|
||||
"""Idempotently create A/B/C section templates for a course.
|
||||
|
||||
Body (optional):
|
||||
- codes: ["A","B","C", ...] (defaults to ["A","B","C"])
|
||||
- branch_id: int (optional default branch for new sections)
|
||||
|
||||
Existing sections (matched by code per course) are kept untouched.
|
||||
Returns the full ordered list of sections after generation.
|
||||
"""
|
||||
try:
|
||||
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() or {}
|
||||
raw_codes = body.get('codes')
|
||||
if not isinstance(raw_codes, list) or not raw_codes:
|
||||
raw_codes = ['A', 'B', 'C']
|
||||
codes = [str(c).strip().upper() for c in raw_codes if str(c).strip()]
|
||||
branch_id = body.get('branch_id')
|
||||
if branch_id:
|
||||
branch_id = _ensure_branch_access(
|
||||
int(branch_id),
|
||||
entity_id=course.entity_id.id if course.entity_id else None,
|
||||
)
|
||||
Section = request.env['encoach.course.section'].sudo()
|
||||
existing = Section.search([('course_id', '=', course_id)])
|
||||
existing_codes = {s.code for s in existing}
|
||||
created = []
|
||||
for idx, code in enumerate(codes, start=1):
|
||||
if code in existing_codes:
|
||||
continue
|
||||
vals = {
|
||||
'name': f'Section {code}',
|
||||
'code': code,
|
||||
'course_id': course_id,
|
||||
'sequence': 10 * idx,
|
||||
'active': True,
|
||||
}
|
||||
if branch_id:
|
||||
vals['branch_id'] = branch_id
|
||||
created.append(Section.create(vals))
|
||||
recs = Section.search([('course_id', '=', course_id)], order='sequence, id')
|
||||
return _json_response({
|
||||
'created_count': len(created),
|
||||
'created_ids': [r.id for r in created],
|
||||
'items': [_serialize_course_section(r) for r in recs],
|
||||
'data': [_serialize_course_section(r) for r in recs],
|
||||
'total': len(recs),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('generate_default_course_sections failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── Student: My Courses (enrollment-filtered) ──────────────────
|
||||
|
||||
@http.route('/api/student/my-courses', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@@ -486,6 +773,8 @@ class LmsCoreController(http.Controller):
|
||||
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']))))
|
||||
if kw.get('branch_id'):
|
||||
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Student.search_count(domain)
|
||||
records = Student.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -540,6 +829,8 @@ class LmsCoreController(http.Controller):
|
||||
}
|
||||
if entity_id:
|
||||
student_vals['entity_id'] = entity_id
|
||||
if body.get('branch_id'):
|
||||
student_vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), 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)
|
||||
@@ -594,6 +885,12 @@ class LmsCoreController(http.Controller):
|
||||
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
|
||||
effective_entity = student_vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
|
||||
if 'branch_id' in body:
|
||||
student_vals['branch_id'] = (
|
||||
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
|
||||
if body['branch_id'] else False
|
||||
)
|
||||
if student_vals:
|
||||
rec.write(student_vals)
|
||||
return _json_response({'data': _serialize_student(rec)})
|
||||
@@ -628,6 +925,8 @@ class LmsCoreController(http.Controller):
|
||||
domain.append(('partner_id.name', 'ilike', kw['search']))
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
if kw.get('branch_id'):
|
||||
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Faculty.search_count(domain)
|
||||
records = Faculty.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -652,9 +951,18 @@ class LmsCoreController(http.Controller):
|
||||
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()
|
||||
first = (body.get('first_name') or '').strip()
|
||||
last = (body.get('last_name') or '').strip()
|
||||
name = f"{first} {last}".strip() or (body.get('name') or '').strip()
|
||||
if not name:
|
||||
return _error_response('first_name/last_name (or name) is required', 400)
|
||||
if not first and ' ' in name:
|
||||
first, last = name.split(' ', 1)
|
||||
elif not first:
|
||||
first = name
|
||||
last = name
|
||||
if not last:
|
||||
last = first
|
||||
partner = request.env['res.partner'].sudo().create({
|
||||
'name': name,
|
||||
'email': body.get('email', ''),
|
||||
@@ -662,14 +970,18 @@ class LmsCoreController(http.Controller):
|
||||
})
|
||||
fac_vals = {
|
||||
'partner_id': partner.id,
|
||||
'gender': body.get('gender', ''),
|
||||
'first_name': first,
|
||||
'last_name': last,
|
||||
'name': name,
|
||||
'gender': body.get('gender') or 'male',
|
||||
'birth_date': body.get('birth_date') or '1990-01-01',
|
||||
}
|
||||
if entity_id:
|
||||
fac_vals['entity_id'] = entity_id
|
||||
if body.get('branch_id'):
|
||||
fac_vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), 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'):
|
||||
fac_vals['birth_date'] = body['birth_date']
|
||||
faculty = request.env['op.faculty'].sudo().create(fac_vals)
|
||||
return _json_response({'data': _serialize_teacher(faculty)})
|
||||
except Exception as e:
|
||||
@@ -716,6 +1028,12 @@ class LmsCoreController(http.Controller):
|
||||
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
|
||||
effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
|
||||
if 'branch_id' in body:
|
||||
vals['branch_id'] = (
|
||||
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
|
||||
if body['branch_id'] else False
|
||||
)
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_teacher(rec)})
|
||||
@@ -748,6 +1066,18 @@ class LmsCoreController(http.Controller):
|
||||
domain = _scoped_entity_domain([])
|
||||
if kw.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
|
||||
if kw.get('branch_id'):
|
||||
domain.append(('branch_id', '=', _ensure_branch_access(int(kw['branch_id']))))
|
||||
if kw.get('classroom_id'):
|
||||
domain.append(('classroom_id', '=', int(kw['classroom_id'])))
|
||||
if kw.get('teacher_id'):
|
||||
domain.append(('teacher_ids', 'in', int(kw['teacher_id'])))
|
||||
if kw.get('course_id'):
|
||||
domain.append(('course_id', '=', int(kw['course_id'])))
|
||||
if kw.get('course_section_id'):
|
||||
domain.append(('course_section_id', '=', int(kw['course_section_id'])))
|
||||
if kw.get('term_key'):
|
||||
domain.append(('term_key', '=', str(kw['term_key'])))
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Batch.search_count(domain)
|
||||
records = Batch.search(domain, offset=offset, limit=limit, order='id desc')
|
||||
@@ -791,13 +1121,65 @@ class LmsCoreController(http.Controller):
|
||||
if body.get('code'):
|
||||
vals['code'] = body['code']
|
||||
if body.get('course_id'):
|
||||
vals['course_id'] = int(body['course_id'])
|
||||
vals['course_id'] = _assert_record_in_entity(
|
||||
'op.course', int(body['course_id']), entity_id, label='Course',
|
||||
)
|
||||
section_id = body.get('course_section_id')
|
||||
section = None
|
||||
if section_id:
|
||||
section = request.env['encoach.course.section'].sudo().browse(int(section_id))
|
||||
if not section.exists():
|
||||
return _error_response('course_section_id not found', 400)
|
||||
if vals.get('course_id') and section.course_id.id != vals['course_id']:
|
||||
return _error_response('course_section_id does not belong to course_id', 400)
|
||||
_assert_record_in_entity(
|
||||
'encoach.course.section', section.id, entity_id, label='Course section',
|
||||
)
|
||||
vals['course_section_id'] = section.id
|
||||
if entity_id:
|
||||
vals['entity_id'] = entity_id
|
||||
if body.get('branch_id'):
|
||||
vals['branch_id'] = _ensure_branch_access(int(body['branch_id']), entity_id=entity_id)
|
||||
if body.get('classroom_id'):
|
||||
vals['classroom_id'] = _assert_record_in_entity(
|
||||
'op.classroom', int(body['classroom_id']), entity_id, label='Classroom',
|
||||
)
|
||||
if body.get('teacher_ids'):
|
||||
tids = _filter_ids_in_entity(
|
||||
'op.faculty', [int(x) for x in body['teacher_ids']],
|
||||
entity_id, label='Teacher',
|
||||
)
|
||||
vals['teacher_ids'] = [(6, 0, tids)]
|
||||
if 'term_key' in body:
|
||||
vals['term_key'] = (body.get('term_key') or '').strip() or False
|
||||
if body.get('start_date'):
|
||||
vals['start_date'] = body['start_date']
|
||||
if body.get('end_date'):
|
||||
vals['end_date'] = body['end_date']
|
||||
# Auto-generate a unique batch code if the client didn't supply one.
|
||||
# `op.batch.code` is NOT NULL + UNIQUE, and AdminBatches UI doesn't ship a code field.
|
||||
if not vals.get('code'):
|
||||
Batch = request.env['op.batch'].sudo()
|
||||
course = request.env['op.course'].sudo().browse(vals['course_id']) if vals.get('course_id') else None
|
||||
course_part = ((course.code or course.name or 'C') if course else 'C')
|
||||
course_part = ''.join(ch for ch in course_part.upper() if ch.isalnum())[:6] or 'C'
|
||||
section_part = ''
|
||||
if section:
|
||||
section_part = '-' + ''.join(ch for ch in (section.code or '').upper() if ch.isalnum())[:4]
|
||||
term_part = ''
|
||||
term_key_val = vals.get('term_key')
|
||||
if term_key_val:
|
||||
term_part = '-' + ''.join(ch for ch in term_key_val.upper() if ch.isalnum())[:5]
|
||||
base = f"B{course_part}{section_part}{term_part}"[:14]
|
||||
candidate = base
|
||||
idx = 1
|
||||
while Batch.search_count([('code', '=', candidate)]):
|
||||
suffix = f"-{idx}"
|
||||
candidate = (base[: 16 - len(suffix)]) + suffix
|
||||
idx += 1
|
||||
if idx > 999:
|
||||
break
|
||||
vals['code'] = candidate[:16]
|
||||
rec = request.env['op.batch'].sudo().create(vals)
|
||||
return _json_response({'data': _serialize_batch(rec)})
|
||||
except Exception as e:
|
||||
@@ -819,10 +1201,46 @@ class LmsCoreController(http.Controller):
|
||||
for k in ('name', 'code', 'start_date', 'end_date'):
|
||||
if k in body:
|
||||
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
|
||||
effective_entity = vals.get('entity_id') or (rec.entity_id.id if rec.entity_id else False)
|
||||
if 'course_id' in body:
|
||||
vals['course_id'] = _assert_record_in_entity(
|
||||
'op.course', int(body['course_id']), effective_entity, label='Course',
|
||||
)
|
||||
if 'course_section_id' in body:
|
||||
if body['course_section_id']:
|
||||
section = request.env['encoach.course.section'].sudo().browse(int(body['course_section_id']))
|
||||
if not section.exists():
|
||||
return _error_response('course_section_id not found', 400)
|
||||
target_course = vals.get('course_id') or (rec.course_id.id if rec.course_id else None)
|
||||
if target_course and section.course_id.id != target_course:
|
||||
return _error_response('course_section_id does not belong to course_id', 400)
|
||||
_assert_record_in_entity(
|
||||
'encoach.course.section', section.id, effective_entity, label='Course section',
|
||||
)
|
||||
vals['course_section_id'] = section.id
|
||||
else:
|
||||
vals['course_section_id'] = False
|
||||
if 'branch_id' in body:
|
||||
vals['branch_id'] = (
|
||||
_ensure_branch_access(int(body['branch_id']), entity_id=effective_entity)
|
||||
if body['branch_id'] else False
|
||||
)
|
||||
if 'classroom_id' in body:
|
||||
vals['classroom_id'] = (
|
||||
_assert_record_in_entity(
|
||||
'op.classroom', int(body['classroom_id']), effective_entity, label='Classroom',
|
||||
) if body['classroom_id'] else False
|
||||
)
|
||||
if 'teacher_ids' in body:
|
||||
tids = _filter_ids_in_entity(
|
||||
'op.faculty', [int(x) for x in (body['teacher_ids'] or [])],
|
||||
effective_entity, label='Teacher',
|
||||
)
|
||||
vals['teacher_ids'] = [(6, 0, tids)]
|
||||
if 'term_key' in body:
|
||||
vals['term_key'] = (body.get('term_key') or '').strip() or False
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response({'data': _serialize_batch(rec)})
|
||||
@@ -956,6 +1374,94 @@ class LmsCoreController(http.Controller):
|
||||
except Exception as e:
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── Per-batch teacher assignment ────────────────────────────────
|
||||
@http.route('/api/batches/<int:batch_id>/teachers', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_batch_teachers(self, batch_id, **kw):
|
||||
try:
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
scope_ids, is_super = _entity_scope()
|
||||
if not is_super and (not batch.entity_id or batch.entity_id.id not in scope_ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
teachers = []
|
||||
for t in batch.teacher_ids:
|
||||
p = t.partner_id
|
||||
teachers.append({
|
||||
'id': t.id,
|
||||
'name': p.name if p else '',
|
||||
'email': (p.email or '') if p else '',
|
||||
})
|
||||
return _json_response({
|
||||
'items': teachers,
|
||||
'data': teachers,
|
||||
'total': len(teachers),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('list_batch_teachers failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/batches/<int:batch_id>/teachers', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def set_batch_teachers(self, batch_id, **kw):
|
||||
"""Assign teachers to a single batch.
|
||||
|
||||
Body:
|
||||
- teacher_ids: int[] required
|
||||
- mode: 'set' | 'add' default 'set'
|
||||
|
||||
Cross-entity teachers are rejected. ``mode='set'`` replaces the whole
|
||||
list; ``mode='add'`` unions with the existing teachers.
|
||||
"""
|
||||
try:
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
scope_ids, is_super = _entity_scope()
|
||||
if not is_super and (not batch.entity_id or batch.entity_id.id not in scope_ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body() or {}
|
||||
mode = (body.get('mode') or 'set').lower()
|
||||
raw_ids = [int(x) for x in (body.get('teacher_ids') or [])]
|
||||
target_entity = batch.entity_id.id if batch.entity_id else None
|
||||
ids = _filter_ids_in_entity('op.faculty', raw_ids, target_entity, label='Teacher')
|
||||
if mode == 'set':
|
||||
batch.write({'teacher_ids': [(6, 0, ids)]})
|
||||
else:
|
||||
batch.write({'teacher_ids': [(4, x) for x in ids]})
|
||||
return _json_response({
|
||||
'success': True,
|
||||
'data': _serialize_batch(batch),
|
||||
'teacher_ids': batch.teacher_ids.ids,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('set_batch_teachers failed')
|
||||
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
|
||||
|
||||
@http.route('/api/batches/<int:batch_id>/teachers/remove', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def remove_batch_teachers(self, batch_id, **kw):
|
||||
try:
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
scope_ids, is_super = _entity_scope()
|
||||
if not is_super and (not batch.entity_id or batch.entity_id.id not in scope_ids):
|
||||
return _error_response('Forbidden', 403)
|
||||
body = _get_json_body() or {}
|
||||
ids = [int(x) for x in (body.get('teacher_ids') or [])]
|
||||
if ids:
|
||||
batch.write({'teacher_ids': [(3, x) for x in ids]})
|
||||
return _json_response({
|
||||
'success': True,
|
||||
'data': _serialize_batch(batch),
|
||||
'teacher_ids': batch.teacher_ids.ids,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('remove_batch_teachers failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── Taxonomy Relationship Endpoints ─────────────────────────────
|
||||
|
||||
@http.route('/api/courses/<int:course_id>/taxonomy', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
|
||||
@@ -6,7 +6,9 @@ from . import courseware
|
||||
from . import notification
|
||||
from . import faq
|
||||
from . import course_ext
|
||||
from . import classroom_ext
|
||||
from . import asset
|
||||
from . import ticket
|
||||
from . import training
|
||||
from . import paymob_order
|
||||
from . import branch
|
||||
|
||||
49
custom_addons/encoach_lms_api/models/branch.py
Normal file
49
custom_addons/encoach_lms_api/models/branch.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class EncoachLmsBranch(models.Model):
|
||||
_name = "encoach.lms.branch"
|
||||
_description = "EnCoach LMS Branch"
|
||||
_order = "name asc, id asc"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
code = fields.Char(index=True)
|
||||
active = fields.Boolean(default=True)
|
||||
entity_id = fields.Many2one(
|
||||
"encoach.entity",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
index=True,
|
||||
string="Entity",
|
||||
)
|
||||
notes = fields.Text()
|
||||
|
||||
course_ids = fields.One2many("op.course", "branch_id")
|
||||
batch_ids = fields.One2many("op.batch", "branch_id")
|
||||
student_ids = fields.One2many("op.student", "branch_id")
|
||||
teacher_ids = fields.One2many("op.faculty", "branch_id")
|
||||
|
||||
course_count = fields.Integer(compute="_compute_counts")
|
||||
batch_count = fields.Integer(compute="_compute_counts")
|
||||
student_count = fields.Integer(compute="_compute_counts")
|
||||
teacher_count = fields.Integer(compute="_compute_counts")
|
||||
|
||||
_sql_constraints = [
|
||||
("branch_entity_code_uniq", "unique(entity_id, code)", "Branch code must be unique per entity."),
|
||||
]
|
||||
|
||||
@api.depends("course_ids", "batch_ids", "student_ids", "teacher_ids")
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
rec.course_count = len(rec.course_ids)
|
||||
rec.batch_count = len(rec.batch_ids)
|
||||
rec.student_count = len(rec.student_ids)
|
||||
rec.teacher_count = len(rec.teacher_ids)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get("code") and vals.get("name"):
|
||||
vals["code"] = vals["name"].upper().replace(" ", "_")[:24]
|
||||
return super().create(vals_list)
|
||||
|
||||
270
custom_addons/encoach_lms_api/models/classroom_ext.py
Normal file
270
custom_addons/encoach_lms_api/models/classroom_ext.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class OpClassroomExt(models.Model):
|
||||
"""Extend the OpenEduCat classroom into a homeroom (class group).
|
||||
|
||||
Original ``op.classroom`` represents a physical room (with capacity and
|
||||
facilities). We keep that semantic and add organisational fields so the
|
||||
same record also acts as the home of a group of students:
|
||||
* ``student_ids`` — homeroom roster (M2M).
|
||||
* ``teacher_ids`` — homeroom teachers (M2M).
|
||||
* ``course_ids`` — courses taught to this classroom (M2M).
|
||||
* ``batch_ids`` — auto-created batches (one per classroom × course).
|
||||
|
||||
``assign_course`` is idempotent: re-assigning the same course reuses the
|
||||
existing batch and only enrolls students that aren't already enrolled.
|
||||
"""
|
||||
|
||||
_inherit = "op.classroom"
|
||||
|
||||
entity_id = fields.Many2one(
|
||||
"encoach.entity",
|
||||
string="Entity",
|
||||
ondelete="set null",
|
||||
index=True,
|
||||
help="Owning entity/organization for LMS isolation.",
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
"encoach.lms.branch",
|
||||
string="Branch",
|
||||
ondelete="set null",
|
||||
index=True,
|
||||
help="Optional branch/campus inside the owning entity.",
|
||||
)
|
||||
|
||||
student_ids = fields.Many2many(
|
||||
"op.student",
|
||||
"op_classroom_student_rel",
|
||||
"classroom_id",
|
||||
"student_id",
|
||||
string="Students",
|
||||
)
|
||||
teacher_ids = fields.Many2many(
|
||||
"op.faculty",
|
||||
"op_classroom_faculty_rel",
|
||||
"classroom_id",
|
||||
"faculty_id",
|
||||
string="Homeroom Teachers",
|
||||
)
|
||||
course_ids = fields.Many2many(
|
||||
"op.course",
|
||||
"op_classroom_course_rel",
|
||||
"classroom_id",
|
||||
"course_id",
|
||||
string="Courses",
|
||||
)
|
||||
|
||||
batch_ids = fields.One2many(
|
||||
"op.batch",
|
||||
"classroom_id",
|
||||
string="Batches",
|
||||
)
|
||||
|
||||
student_count = fields.Integer(compute="_compute_counts")
|
||||
teacher_count = fields.Integer(compute="_compute_counts")
|
||||
course_count = fields.Integer(compute="_compute_counts")
|
||||
batch_count = fields.Integer(compute="_compute_counts")
|
||||
|
||||
notes = fields.Text(string="Notes")
|
||||
|
||||
@api.depends("student_ids", "teacher_ids", "course_ids", "batch_ids")
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
rec.student_count = len(rec.student_ids)
|
||||
rec.teacher_count = len(rec.teacher_ids)
|
||||
rec.course_count = len(rec.course_ids)
|
||||
rec.batch_count = len(rec.batch_ids)
|
||||
|
||||
@api.constrains("branch_id", "entity_id")
|
||||
def _check_branch_entity(self):
|
||||
for rec in self:
|
||||
if rec.branch_id and rec.entity_id and rec.branch_id.entity_id != rec.entity_id:
|
||||
raise ValidationError(
|
||||
"Classroom branch must belong to the same entity as the classroom."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cascade: assign a course to this classroom
|
||||
# ------------------------------------------------------------------
|
||||
def _ensure_unique_batch_code(self, base_code):
|
||||
"""Return a unique batch code derived from ``base_code``.
|
||||
|
||||
``op.batch`` has a global ``unique(code)`` constraint, so we suffix
|
||||
with a counter when needed.
|
||||
"""
|
||||
Batch = self.env["op.batch"].sudo()
|
||||
candidate = base_code
|
||||
idx = 1
|
||||
while Batch.search_count([("code", "=", candidate)]):
|
||||
idx += 1
|
||||
candidate = f"{base_code}-{idx}"
|
||||
return candidate
|
||||
|
||||
def assign_course(
|
||||
self,
|
||||
course,
|
||||
term_name=None,
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
teacher_ids=None,
|
||||
section_id=None,
|
||||
term_key=None,
|
||||
):
|
||||
"""Assign ``course`` to this classroom and cascade to a batch.
|
||||
|
||||
- Creates (or reuses) the canonical batch for
|
||||
``(classroom, course, section, term_key)``.
|
||||
- Enrolls every classroom student into the batch via ``op.student.course``.
|
||||
- Optionally sets ``teacher_ids`` (M2M) on the batch; if none is
|
||||
provided we default to the classroom's homeroom teachers.
|
||||
|
||||
Returns the ``op.batch`` record.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Course = self.env["op.course"].sudo()
|
||||
Section = self.env["encoach.course.section"].sudo()
|
||||
Batch = self.env["op.batch"].sudo()
|
||||
Enroll = self.env["op.student.course"].sudo()
|
||||
|
||||
if isinstance(course, int):
|
||||
course = Course.browse(course)
|
||||
if not course or not course.exists():
|
||||
raise UserError("Course not found.")
|
||||
|
||||
if (
|
||||
self.entity_id
|
||||
and course.entity_id
|
||||
and self.entity_id.id != course.entity_id.id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Course belongs to a different entity than the classroom."
|
||||
)
|
||||
|
||||
section = None
|
||||
if section_id:
|
||||
section = Section.browse(int(section_id))
|
||||
if not section.exists():
|
||||
raise UserError("Course section not found.")
|
||||
if section.course_id.id != course.id:
|
||||
raise ValidationError("Selected section does not belong to the selected course.")
|
||||
if (
|
||||
self.entity_id
|
||||
and section.entity_id
|
||||
and self.entity_id.id != section.entity_id.id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Course section belongs to a different entity than the classroom."
|
||||
)
|
||||
|
||||
if teacher_ids is not None and self.entity_id:
|
||||
teachers = self.env["op.faculty"].sudo().browse(list(teacher_ids))
|
||||
for t in teachers:
|
||||
if (
|
||||
t.exists()
|
||||
and t.entity_id
|
||||
and t.entity_id.id != self.entity_id.id
|
||||
):
|
||||
raise ValidationError(
|
||||
f"Teacher {t.partner_id.name or t.id} belongs to a "
|
||||
"different entity than the classroom."
|
||||
)
|
||||
|
||||
# Add to classroom course list (idempotent)
|
||||
if course.id not in self.course_ids.ids:
|
||||
self.write({"course_ids": [(4, course.id)]})
|
||||
|
||||
# Find or create the canonical batch (classroom × course × section × term)
|
||||
domain = [
|
||||
("classroom_id", "=", self.id),
|
||||
("course_id", "=", course.id),
|
||||
("course_section_id", "=", section.id if section else False),
|
||||
("term_key", "=", term_key or False),
|
||||
]
|
||||
batch = Batch.search(domain, limit=1)
|
||||
if not batch:
|
||||
section_label = f" — Section {section.code}" if section else ""
|
||||
term_label = f" ({term_name})" if term_name else ""
|
||||
base_name = f"{self.name} — {course.name or course.code or 'Course'}{section_label}{term_label}"
|
||||
base_code = (self.code or self.name or "BATCH").upper().replace(" ", "_")[:12]
|
||||
section_code = (section.code if section else "").upper().replace(" ", "")
|
||||
term_code = (term_key or "").upper().replace(" ", "")
|
||||
base_code = f"{base_code}-{(course.code or '').upper()[:8] or course.id}"
|
||||
if section_code:
|
||||
base_code = f"{base_code}-{section_code[:5]}"
|
||||
if term_code:
|
||||
base_code = f"{base_code}-{term_code[:4]}"
|
||||
today = fields.Date.context_today(self)
|
||||
vals = {
|
||||
"name": base_name,
|
||||
"code": self._ensure_unique_batch_code(base_code[:16]),
|
||||
"course_id": course.id,
|
||||
"classroom_id": self.id,
|
||||
"course_section_id": section.id if section else False,
|
||||
"term_key": term_key or False,
|
||||
"start_date": start_date or today,
|
||||
"end_date": end_date or today.replace(month=12, day=31),
|
||||
"entity_id": self.entity_id.id if self.entity_id else False,
|
||||
"branch_id": self.branch_id.id if self.branch_id else False,
|
||||
}
|
||||
batch = Batch.create(vals)
|
||||
|
||||
# Teacher assignment (M2M) — explicit overrides homeroom defaults
|
||||
target_teachers = teacher_ids if teacher_ids is not None else self.teacher_ids.ids
|
||||
if target_teachers:
|
||||
batch.write({"teacher_ids": [(6, 0, list(target_teachers))]})
|
||||
|
||||
# Enroll classroom roster into the batch (idempotent)
|
||||
for student in self.student_ids:
|
||||
existing = Enroll.search([
|
||||
("student_id", "=", student.id),
|
||||
("course_id", "=", course.id),
|
||||
("batch_id", "=", batch.id),
|
||||
], limit=1)
|
||||
if not existing:
|
||||
Enroll.create({
|
||||
"student_id": student.id,
|
||||
"course_id": course.id,
|
||||
"batch_id": batch.id,
|
||||
"state": "running",
|
||||
})
|
||||
|
||||
return batch
|
||||
|
||||
@api.model
|
||||
def _add_students_to_open_batches(self, classroom_id, student_ids):
|
||||
"""When new students join the classroom, enroll them in every existing
|
||||
course batch under that classroom.
|
||||
"""
|
||||
Batch = self.env["op.batch"].sudo()
|
||||
Enroll = self.env["op.student.course"].sudo()
|
||||
classroom = self.browse(classroom_id)
|
||||
if not classroom.exists():
|
||||
return
|
||||
batches = Batch.search([("classroom_id", "=", classroom.id)])
|
||||
for batch in batches:
|
||||
for sid in student_ids:
|
||||
exists = Enroll.search([
|
||||
("student_id", "=", sid),
|
||||
("course_id", "=", batch.course_id.id),
|
||||
("batch_id", "=", batch.id),
|
||||
], limit=1)
|
||||
if not exists:
|
||||
Enroll.create({
|
||||
"student_id": sid,
|
||||
"course_id": batch.course_id.id,
|
||||
"batch_id": batch.id,
|
||||
"state": "running",
|
||||
})
|
||||
|
||||
def write(self, vals):
|
||||
"""Cascade roster changes into existing classroom batches."""
|
||||
old_students = {rec.id: set(rec.student_ids.ids) for rec in self}
|
||||
res = super().write(vals)
|
||||
if "student_ids" in vals:
|
||||
for rec in self:
|
||||
added = set(rec.student_ids.ids) - old_students.get(rec.id, set())
|
||||
if added:
|
||||
self._add_students_to_open_batches(rec.id, list(added))
|
||||
return res
|
||||
@@ -1,4 +1,61 @@
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EncoachCourseSection(models.Model):
|
||||
_name = "encoach.course.section"
|
||||
_description = "Course Section Template"
|
||||
_order = "course_id, sequence, id"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
code = fields.Char(required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
notes = fields.Text()
|
||||
|
||||
course_id = fields.Many2one("op.course", required=True, ondelete="cascade", index=True)
|
||||
entity_id = fields.Many2one(
|
||||
"encoach.entity",
|
||||
string="Entity",
|
||||
related="course_id.entity_id",
|
||||
store=True,
|
||||
index=True,
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
"encoach.lms.branch",
|
||||
string="Branch",
|
||||
ondelete="set null",
|
||||
index=True,
|
||||
help="Optional default branch for this section template.",
|
||||
)
|
||||
|
||||
batch_ids = fields.One2many("op.batch", "course_section_id", string="Batches")
|
||||
batch_count = fields.Integer(compute="_compute_batch_count")
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"uniq_course_section_code",
|
||||
"unique(course_id, code)",
|
||||
"Section code must be unique per course.",
|
||||
),
|
||||
]
|
||||
|
||||
def _compute_batch_count(self):
|
||||
for rec in self:
|
||||
rec.batch_count = len(rec.batch_ids)
|
||||
|
||||
@api.constrains("branch_id", "course_id")
|
||||
def _check_section_branch_entity(self):
|
||||
for rec in self:
|
||||
if (
|
||||
rec.branch_id
|
||||
and rec.course_id
|
||||
and rec.course_id.entity_id
|
||||
and rec.branch_id.entity_id != rec.course_id.entity_id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Section branch must belong to the same entity as the course."
|
||||
)
|
||||
|
||||
|
||||
class OpCourseExt(models.Model):
|
||||
@@ -13,6 +70,13 @@ class OpCourseExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
|
||||
encoach_subject_id = fields.Many2one(
|
||||
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
|
||||
@@ -40,7 +104,11 @@ class OpCourseExt(models.Model):
|
||||
])
|
||||
|
||||
chapter_ids = fields.One2many('encoach.course.chapter', 'course_id', string='Chapters')
|
||||
section_ids = fields.One2many(
|
||||
'encoach.course.section', 'course_id', string='Sections'
|
||||
)
|
||||
chapter_count = fields.Integer(compute='_compute_chapter_count', store=True)
|
||||
section_count = fields.Integer(compute='_compute_section_count', store=True)
|
||||
objective_count = fields.Integer(compute='_compute_objective_count')
|
||||
resource_count = fields.Integer(compute='_compute_resource_count')
|
||||
|
||||
@@ -49,6 +117,11 @@ class OpCourseExt(models.Model):
|
||||
for rec in self:
|
||||
rec.chapter_count = len(rec.chapter_ids)
|
||||
|
||||
@api.depends('section_ids')
|
||||
def _compute_section_count(self):
|
||||
for rec in self:
|
||||
rec.section_count = len(rec.section_ids)
|
||||
|
||||
def _compute_objective_count(self):
|
||||
for rec in self:
|
||||
rec.objective_count = len(rec.learning_objective_ids)
|
||||
@@ -73,6 +146,76 @@ class OpBatchExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
classroom_id = fields.Many2one(
|
||||
'op.classroom',
|
||||
string='Classroom',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Classroom (homeroom) hosting this batch. When set, the batch '
|
||||
'inherits the classroom roster on course assignment.',
|
||||
)
|
||||
teacher_ids = fields.Many2many(
|
||||
'op.faculty',
|
||||
'op_batch_faculty_rel',
|
||||
'batch_id',
|
||||
'faculty_id',
|
||||
string='Teachers',
|
||||
help='Teachers assigned to this batch. A teacher may be assigned '
|
||||
'to multiple batches.',
|
||||
)
|
||||
student_count = fields.Integer(
|
||||
string='Student Count', compute='_compute_batch_student_count'
|
||||
)
|
||||
course_section_id = fields.Many2one(
|
||||
'encoach.course.section',
|
||||
string='Course Section',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Specific section template of the selected course for this batch.',
|
||||
)
|
||||
term_key = fields.Char(
|
||||
string='Term Key',
|
||||
help='Optional term/semester key used for one-batch-per-term uniqueness.',
|
||||
)
|
||||
|
||||
def _compute_batch_student_count(self):
|
||||
Enroll = self.env['op.student.course'].sudo()
|
||||
for rec in self:
|
||||
rec.student_count = Enroll.search_count([('batch_id', '=', rec.id)])
|
||||
|
||||
@api.constrains('entity_id', 'course_id', 'classroom_id', 'course_section_id', 'branch_id')
|
||||
def _check_batch_entity_consistency(self):
|
||||
for rec in self:
|
||||
ent = rec.entity_id
|
||||
if not ent:
|
||||
continue
|
||||
if rec.course_id and rec.course_id.entity_id and rec.course_id.entity_id != ent:
|
||||
raise ValidationError(
|
||||
"Batch course must belong to the same entity as the batch."
|
||||
)
|
||||
if rec.classroom_id and rec.classroom_id.entity_id and rec.classroom_id.entity_id != ent:
|
||||
raise ValidationError(
|
||||
"Batch classroom must belong to the same entity as the batch."
|
||||
)
|
||||
if (
|
||||
rec.course_section_id
|
||||
and rec.course_section_id.entity_id
|
||||
and rec.course_section_id.entity_id != ent
|
||||
):
|
||||
raise ValidationError(
|
||||
"Batch course section must belong to the same entity as the batch."
|
||||
)
|
||||
if rec.branch_id and rec.branch_id.entity_id != ent:
|
||||
raise ValidationError(
|
||||
"Batch branch must belong to the same entity as the batch."
|
||||
)
|
||||
|
||||
|
||||
class OpStudentExt(models.Model):
|
||||
@@ -85,6 +228,25 @@ class OpStudentExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
|
||||
@api.constrains('branch_id', 'entity_id')
|
||||
def _check_student_branch_entity(self):
|
||||
for rec in self:
|
||||
if (
|
||||
rec.branch_id
|
||||
and rec.entity_id
|
||||
and rec.branch_id.entity_id != rec.entity_id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Student branch must belong to the same entity as the student."
|
||||
)
|
||||
|
||||
|
||||
class OpFacultyExt(models.Model):
|
||||
@@ -97,3 +259,22 @@ class OpFacultyExt(models.Model):
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
branch_id = fields.Many2one(
|
||||
'encoach.lms.branch',
|
||||
string='Branch',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Optional branch/campus inside the owning entity.',
|
||||
)
|
||||
|
||||
@api.constrains('branch_id', 'entity_id')
|
||||
def _check_faculty_branch_entity(self):
|
||||
for rec in self:
|
||||
if (
|
||||
rec.branch_id
|
||||
and rec.entity_id
|
||||
and rec.branch_id.entity_id != rec.entity_id
|
||||
):
|
||||
raise ValidationError(
|
||||
"Teacher branch must belong to the same entity as the teacher."
|
||||
)
|
||||
|
||||
@@ -25,3 +25,5 @@ access_encoach_grammar_rule_all,encoach.grammar.rule.all,model_encoach_grammar_r
|
||||
access_encoach_grammar_progress_all,encoach.grammar.progress.all,model_encoach_grammar_progress,base.group_user,1,1,1,1
|
||||
access_encoach_paymob_order_user,encoach.paymob.order.user,model_encoach_paymob_order,base.group_user,1,0,1,0
|
||||
access_encoach_paymob_order_admin,encoach.paymob.order.admin,model_encoach_paymob_order,base.group_system,1,1,1,1
|
||||
access_encoach_lms_branch_user,encoach.lms.branch.user,model_encoach_lms_branch,base.group_user,1,1,1,1
|
||||
access_encoach_course_section_user,encoach.course.section.user,model_encoach_course_section,base.group_user,1,1,1,1
|
||||
|
||||
|
Reference in New Issue
Block a user