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

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

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-26 02:34:52 +04:00
parent afd1662a60
commit cd47d01f53
26 changed files with 2795 additions and 338 deletions

View File

@@ -7,12 +7,14 @@ decorator and returns JSON.
"""
import base64
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required,
validate_token,
_json_response,
_error_response,
_get_json_body,
@@ -42,7 +44,65 @@ def _request_language():
return str(raw).split(',')[0].split(';')[0].split('-')[0].strip().lower() or 'en'
def _entity_scope():
"""Return (entity_ids, is_superadmin) for current JWT user."""
user = request.env.user.sudo()
is_super = bool(user.has_group('base.group_system'))
entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
return entity_ids, is_super
def _default_entity_id_from_scope():
entity_ids, is_super = _entity_scope()
if entity_ids:
return entity_ids[0]
if is_super:
return False
raise PermissionError('User is not linked to any entity')
def _ensure_entity_access(entity_id):
if not entity_id:
raise PermissionError('entity_id is required')
entity_ids, is_super = _entity_scope()
if is_super:
return int(entity_id)
if not entity_ids:
raise PermissionError('User is not linked to any entity')
if int(entity_id) not in entity_ids:
raise PermissionError('Entity access denied')
return int(entity_id)
class CoursePlanController(http.Controller):
def _plan_domain(self, extra=None):
domain = list(extra or [])
entity_ids, is_super = _entity_scope()
if is_super:
return domain
if not entity_ids:
return domain + [('id', '=', 0)]
return domain + [('entity_id', 'in', entity_ids)]
def _assert_plan_access(self, plan):
if not plan or not plan.exists():
raise ValueError('Plan not found')
entity_ids, is_super = _entity_scope()
if is_super:
return
if not plan.entity_id or plan.entity_id.id not in entity_ids:
raise PermissionError('Entity access denied')
def _get_plan_scoped(self, plan_id):
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
self._assert_plan_access(plan)
return plan
def _assert_material_access(self, material):
if not material or not material.exists():
raise ValueError('Material not found')
self._assert_plan_access(material.plan_id)
# ------------------------------------------------------------------
# POST /api/ai/course-plan
# ------------------------------------------------------------------
@@ -54,15 +114,21 @@ class CoursePlanController(http.Controller):
body = _get_json_body()
if not (body.get('title') or '').strip():
return _error_response('title is required', 400)
if body.get('entity_id'):
entity_id = _ensure_entity_access(int(body['entity_id']))
else:
entity_id = _default_entity_id_from_scope()
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
plan = pipeline.generate_plan(body)
if entity_id:
plan.sudo().write({'entity_id': entity_id})
return _json_response({'data': plan.to_api_dict(include_weeks=True)})
except Exception as exc:
_logger.exception('course-plan.generate failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan
@@ -73,10 +139,12 @@ class CoursePlanController(http.Controller):
def list_plans(self, **kw):
try:
params = request.httprequest.args
domain = []
domain = self._plan_domain([])
search = (params.get('search') or '').strip()
if search:
domain.append(('name', 'ilike', search))
if params.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(params.get('entity_id')))))
Plan = request.env['encoach.course.plan'].sudo()
offset, limit, page = _paginate({
@@ -94,7 +162,7 @@ class CoursePlanController(http.Controller):
})
except Exception as exc:
_logger.exception('course-plan.list failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<id>
@@ -104,15 +172,15 @@ class CoursePlanController(http.Controller):
@jwt_required
def get_plan(self, plan_id, **kw):
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
plan = self._get_plan_scoped(plan_id)
return _json_response({
'data': plan.to_api_dict(include_weeks=True, include_materials=True),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.get failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# DELETE /api/ai/course-plan/<id>
@@ -122,14 +190,14 @@ class CoursePlanController(http.Controller):
@jwt_required
def delete_plan(self, plan_id, **kw):
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
plan = self._get_plan_scoped(plan_id)
plan.unlink()
return _json_response({'success': True})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.delete failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<id>/weeks/<n>/materials
@@ -139,6 +207,7 @@ class CoursePlanController(http.Controller):
@jwt_required
def generate_week_materials(self, plan_id, week_number, **kw):
try:
self._get_plan_scoped(plan_id)
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
@@ -151,7 +220,7 @@ class CoursePlanController(http.Controller):
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.generate_week_materials failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
@@ -161,6 +230,7 @@ class CoursePlanController(http.Controller):
@jwt_required
def list_week_materials(self, plan_id, week_number, **kw):
try:
self._get_plan_scoped(plan_id)
week = request.env['encoach.course.plan.week'].sudo().search([
('plan_id', '=', int(plan_id)),
('week_number', '=', int(week_number)),
@@ -173,7 +243,7 @@ class CoursePlanController(http.Controller):
})
except Exception as exc:
_logger.exception('course-plan.list_week_materials failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ==================================================================
# PHASE A — Reference sources (RAG grounding)
@@ -184,25 +254,23 @@ class CoursePlanController(http.Controller):
@jwt_required
def list_sources(self, plan_id, **kw):
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
plan = self._get_plan_scoped(plan_id)
return _json_response({
'items': [s.to_api_dict() for s in plan.source_ids],
'count': len(plan.source_ids),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.list_sources failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def create_source(self, plan_id, **kw):
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
plan = self._get_plan_scoped(plan_id)
ct = request.httprequest.content_type or ''
files = request.httprequest.files
@@ -250,37 +318,110 @@ class CoursePlanController(http.Controller):
rec = request.env['encoach.course.plan.source'].sudo().create(vals)
return _json_response({'data': rec.to_api_dict()})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.create_source failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<plan_id>/sources/from-resources
# ------------------------------------------------------------------
# Attach one or more existing library resources (``encoach.resource``,
# the items shown under /admin/resources) as RAG sources for a plan.
#
# Body shape: ``{ "resource_ids": [<int>, ...] }``. We dedupe against
# already-linked resources so re-clicking "Attach" is a no-op rather
# than producing duplicate index entries. Each new row auto-indexes
# via the ``encoach.course.plan.source`` create() hook.
@http.route('/api/ai/course-plan/<int:plan_id>/sources/from-resources',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def attach_library_resources(self, plan_id, **kw):
try:
plan = self._get_plan_scoped(plan_id)
body = _get_json_body() or {}
raw_ids = body.get('resource_ids') or body.get('ids') or []
if not isinstance(raw_ids, list):
return _error_response('resource_ids must be a list', 400)
try:
resource_ids = [int(x) for x in raw_ids if x is not None]
except (TypeError, ValueError):
return _error_response('resource_ids must contain integers', 400)
if not resource_ids:
return _error_response('No resource ids provided', 400)
Resource = request.env['encoach.resource'].sudo()
Source = request.env['encoach.course.plan.source'].sudo()
already_linked = set(
plan.source_ids.filtered('resource_id').mapped('resource_id.id')
)
attached, skipped, missing = [], [], []
for rid in resource_ids:
if rid in already_linked:
skipped.append(rid)
continue
res = Resource.browse(rid)
if not res.exists():
missing.append(rid)
continue
rec = Source.create({
'plan_id': plan.id,
'kind': 'resource',
'resource_id': res.id,
'name': res.name or f'Resource #{res.id}',
'file_name': res.name or '',
'mime_type': '',
})
attached.append(rec.to_api_dict())
return _json_response({
'attached': attached,
'skipped_existing': skipped,
'missing': missing,
'count': len(attached),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.attach_library_resources failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>/index',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def reindex_source(self, plan_id, source_id, **kw):
try:
self._get_plan_scoped(plan_id)
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
if not rec.exists() or rec.plan_id.id != int(plan_id):
return _error_response('Source not found', 404)
rec.action_index()
return _json_response({'data': rec.to_api_dict()})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.reindex_source failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>',
type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_source(self, plan_id, source_id, **kw):
try:
self._get_plan_scoped(plan_id)
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
if not rec.exists() or rec.plan_id.id != int(plan_id):
return _error_response('Source not found', 404)
rec.unlink()
return _json_response({'success': True})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.delete_source failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ==================================================================
# PHASE B — Deliverables preview / progress
@@ -291,13 +432,13 @@ class CoursePlanController(http.Controller):
@jwt_required
def get_deliverables(self, plan_id, **kw):
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
plan = self._get_plan_scoped(plan_id)
return _json_response(compute_deliverables(plan))
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.deliverables failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ==================================================================
# PHASE C — Multimedia generation per material
@@ -307,8 +448,48 @@ class CoursePlanController(http.Controller):
rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id))
if not rec.exists():
return None
self._assert_material_access(rec)
return rec
@http.route('/api/ai/course-plan/material/<int:material_id>',
type='http', auth='none', methods=['PATCH'], csrf=False)
@jwt_required
def update_material(self, material_id, **kw):
"""Edit generated material metadata/content without regenerating."""
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
body = _get_json_body() or {}
vals = {}
if 'title' in body:
new_title = (body.get('title') or '').strip()
if not new_title:
return _error_response('title cannot be empty', 400)
vals['title'] = new_title
if 'summary' in body:
vals['summary'] = (body.get('summary') or '').strip()
if 'is_static' in body:
vals['is_static'] = bool(body.get('is_static'))
if 'share_date' in body:
vals['share_date'] = body.get('share_date') or False
if 'body' in body:
vals['body_json'] = json.dumps(body.get('body') or {}, ensure_ascii=False)
if 'body_text' in body:
body_text = (body.get('body_text') or '').strip()
vals['body_text'] = body_text
if 'body' not in body and body_text:
# Keep non-technical editing simple: if only plain text is
# provided, mirror it into a minimal JSON structure.
vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
if vals:
material.sudo().write(vals)
return _json_response({'data': material.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.update_material failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/material/<int:material_id>/media/audio',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
@@ -324,12 +505,13 @@ class CoursePlanController(http.Controller):
voice=body.get('voice'),
language=body.get('language') or 'en-GB',
gender=body.get('gender') or 'female',
provider=body.get('provider') or 'polly',
provider=body.get('provider') or 'auto',
)
return _json_response({'data': media.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.gen_audio failed')
return _error_response(str(exc), 500)
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/material/<int:material_id>/media/image',
type='http', auth='none', methods=['POST'], csrf=False)
@@ -351,7 +533,8 @@ class CoursePlanController(http.Controller):
return _json_response({'data': media.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.gen_image failed')
return _error_response(str(exc), 500)
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/material/<int:material_id>/media/video',
type='http', auth='none', methods=['POST'], csrf=False)
@@ -366,7 +549,8 @@ class CoursePlanController(http.Controller):
return _json_response({'data': media.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.gen_video failed')
return _error_response(str(exc), 500)
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/material/<int:material_id>/media',
type='http', auth='none', methods=['GET'], csrf=False)
@@ -382,7 +566,63 @@ class CoursePlanController(http.Controller):
})
except Exception as exc:
_logger.exception('course-plan.list_material_media failed')
return _error_response(str(exc), 500)
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/media/<id>/raw
# ------------------------------------------------------------------
# Streams the binary backing a media row. Used as the ``src`` for
# ``<img>`` / ``<audio>`` / ``<video>`` tags AND as the target of
# download links — the only difference is the ``?download=1`` flag.
#
# Accepts the JWT via the standard ``Authorization: Bearer …`` header
# OR via ``?token=<jwt>`` / ``?access_token=<jwt>`` so plain ``<img>``
# tags can render the asset without us blob-converting every fetch.
@http.route('/api/ai/course-plan/media/<int:media_id>/raw',
type='http', auth='none', methods=['GET'], csrf=False)
def stream_media(self, media_id, **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.course.plan.media'].sudo().browse(int(media_id))
if not rec.exists() or not rec.attachment_id:
return _error_response('Media not found', 404)
self._assert_plan_access(rec.plan_id)
attachment = rec.attachment_id
payload = attachment.raw or b''
if not payload and attachment.datas:
# Older rows store the binary base64-encoded under ``datas``.
payload = base64.b64decode(attachment.datas)
if not payload:
return _error_response('Media payload missing', 410)
mimetype = attachment.mimetype or rec.mime_type or 'application/octet-stream'
filename = attachment.name or f'media-{rec.id}'
disposition = (
f'attachment; filename="{filename}"'
if request.httprequest.args.get('download')
else f'inline; filename="{filename}"'
)
headers = [
('Content-Type', mimetype),
('Content-Length', str(len(payload))),
('Content-Disposition', disposition),
# Aggressive caching is safe — the URL is keyed by the row id
# and the binary is immutable once generated. Setting a 1h
# max-age avoids re-streaming the same WAV/PNG every time the
# admin re-opens the media drawer.
('Cache-Control', 'private, max-age=3600'),
]
return request.make_response(payload, headers=headers)
except Exception as exc:
_logger.exception('course-plan.stream_media failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/media/<int:media_id>',
type='http', auth='none', methods=['DELETE'], csrf=False)
@@ -392,13 +632,15 @@ class CoursePlanController(http.Controller):
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
if not rec.exists():
return _error_response('Media not found', 404)
self._assert_plan_access(rec.plan_id)
if rec.attachment_id:
rec.attachment_id.unlink()
rec.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('course-plan.delete_media failed')
return _error_response(str(exc), 500)
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/media',
type='http', auth='none', methods=['POST'], csrf=False)
@@ -411,9 +653,7 @@ class CoursePlanController(http.Controller):
depends on ffmpeg + the audio + image steps and is slower.
"""
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
plan = self._get_plan_scoped(plan_id)
week = plan.week_ids.filtered(
lambda w: w.week_number == int(week_number),
)
@@ -443,7 +683,8 @@ class CoursePlanController(http.Controller):
return _json_response({'items': results, 'count': len(results)})
except Exception as exc:
_logger.exception('course-plan.gen_week_media failed')
return _error_response(str(exc), 500)
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
# ==================================================================
# PHASE D — Plan assignments
@@ -454,25 +695,23 @@ class CoursePlanController(http.Controller):
@jwt_required
def list_assignments(self, plan_id, **kw):
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
plan = self._get_plan_scoped(plan_id)
return _json_response({
'items': [a.to_api_dict() for a in plan.assignment_ids],
'count': len(plan.assignment_ids),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.list_assignments failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def create_assignment(self, plan_id, **kw):
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
plan = self._get_plan_scoped(plan_id)
body = _get_json_body() or {}
mode = (body.get('mode') or 'batch').strip()
vals = {
@@ -485,25 +724,40 @@ class CoursePlanController(http.Controller):
if mode == 'batch':
if not body.get('batch_id'):
return _error_response('batch_id is required', 400)
vals['batch_id'] = int(body['batch_id'])
batch_id = int(body['batch_id'])
batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists():
return _error_response('Batch not found', 404)
if plan.entity_id and batch.entity_id and plan.entity_id.id != batch.entity_id.id:
return _error_response('Batch entity does not match plan entity', 400)
vals['batch_id'] = batch_id
elif mode == 'students':
ids = body.get('student_user_ids') or []
if not isinstance(ids, list) or not ids:
return _error_response('student_user_ids is required', 400)
vals['student_user_ids'] = [(6, 0, [int(i) for i in ids])]
elif mode == 'entities':
ids = body.get('entity_ids') or []
if not isinstance(ids, list) or not ids:
return _error_response('entity_ids is required', 400)
checked = [_ensure_entity_access(int(i)) for i in ids]
vals['entity_ids'] = [(6, 0, checked)]
else:
return _error_response('Invalid mode', 400)
rec = request.env['encoach.course.plan.assignment'].sudo().create(vals)
return _json_response({'data': rec.to_api_dict()})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.create_assignment failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/ai/course-plan/<int:plan_id>/assignments/<int:assignment_id>',
type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_assignment(self, plan_id, assignment_id, **kw):
try:
self._get_plan_scoped(plan_id)
rec = request.env['encoach.course.plan.assignment'].sudo().browse(
int(assignment_id),
)
@@ -511,9 +765,11 @@ class CoursePlanController(http.Controller):
return _error_response('Assignment not found', 404)
rec.unlink()
return _json_response({'success': True})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.delete_assignment failed')
return _error_response(str(exc), 500)
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ==================================================================
# PHASE E — Student-side endpoints
@@ -535,12 +791,7 @@ class CoursePlanController(http.Controller):
try:
user = request.env.user
Assignment = request.env['encoach.course.plan.assignment'].sudo()
assignments = Assignment.search([
('state', '=', 'active'),
'|',
('student_user_ids', 'in', [user.id]),
'&', ('mode', '=', 'batch'), ('batch_id', '!=', False),
])
assignments = Assignment.search([('state', '=', 'active')])
visible = []
for a in assignments:
if a.mode == 'students' and user.id in a.student_user_ids.ids:
@@ -548,6 +799,9 @@ class CoursePlanController(http.Controller):
continue
if a.mode == 'batch' and user.id in a.expand_user_ids():
visible.append(a)
continue
if a.mode == 'entities' and user.id in a.expand_user_ids():
visible.append(a)
seen = set()
out = []
@@ -582,6 +836,9 @@ class CoursePlanController(http.Controller):
if a.mode == 'batch' and user.id in a.expand_user_ids():
allowed = True
break
if a.mode == 'entities' and user.id in a.expand_user_ids():
allowed = True
break
if not allowed:
return _error_response('Plan not assigned to you', 403)
return _json_response({