feat(course-plan): RAG sources + multi-modal media + assignments + student view

Builds the §24 product on top of the LangGraph runtime from §22:

Phase A (Sources / RAG)
  - encoach.course.plan.source model (file | url | text)
  - SourceIndexer extracts PDF (pypdf), DOCX (python-docx), HTML, plain
    text and embeds chunks via the existing pgvector pipeline scoped to
    plan_id, so resources.search only returns the plan's own corpus
  - Endpoints: list/create/upload/reindex/delete + plan-scoped retrieval

Phase B (Deliverables)
  - services.deliverables.compute_deliverables walks the plan, derives
    {planned, generated, ready} per week from material + media state
  - GET /api/ai/course-plan/<id>/deliverables drives the new wizard
    preview step and the live progress strip on the detail page

Phase C (Multi-modal media)
  - encoach.course.plan.media model + MediaService:
    audio: AWS Polly (default) or ElevenLabs
    image: OpenAI DALL-E 3, capped per plan via system parameter
    video: local ffmpeg subprocess (image + audio -> MP4 1280x720)
  - Three new agent tools (media.synthesize_audio / generate_image /
    compose_video), wired into course_week_materials and a new
    course_media_director agent
  - Endpoints per material + week-level batch generator

Phase D (Assignments)
  - encoach.course.plan.assignment supports mode='batch' (op.batch) or
    mode='students' (res.users), with due_date + message + state
  - REST endpoints to list / create / delete assignments

Phase E (Student view)
  - /api/student/course-plans + /api/student/course-plans/<id>
    enforce visibility via assignment.expand_user_ids()
  - New /student/course-plans list + read-only drilldown rendering
    audio/image/video tiles from /web/content/<attachment_id>

Cross-cutting
  - encoach.ai.tool.category: + media (so the new tools register)
  - encoach.embedding gains a plan_id filter for plan-scoped RAG
  - Wizard adds Sources + Multimedia steps; AdminCoursePlanDetail
    rewritten with DeliverablesStrip + SourcesCard + AssignmentsCard +
    per-material MediaDrawer
  - ~280 new EN + AR i18n keys (full RTL coverage)
  - smoke_course_plan.py exercises every phase via odoo-bin shell;
    last run: PASS A/B/D/E + DALL-E 3 image (753 KB), Polly audio
    fails cleanly when AWS creds aren't configured (expected)

Documentation: §24 added to docs/PROJECT_SUMMARY.md with phase-by-phase
artefact list, endpoints, smoke test, ops notes, and gotchas.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-25 17:13:01 +04:00
parent cfdf2be527
commit afd1662a60
17 changed files with 1757 additions and 1521 deletions

View File

@@ -6,10 +6,10 @@ endpoints. Every route is JWT-guarded via the shared ``@jwt_required``
decorator and returns JSON.
"""
import json
import base64
import logging
from odoo import http, fields
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required,
@@ -21,6 +21,10 @@ from odoo.addons.encoach_api.controllers.base import (
from odoo.addons.encoach_ai_course.services.course_plan_pipeline import (
CoursePlanPipeline,
)
from odoo.addons.encoach_ai_course.services.deliverables import (
compute_deliverables,
)
from odoo.addons.encoach_ai_course.services.media_service import MediaService
_logger = logging.getLogger(__name__)
@@ -172,313 +176,421 @@ class CoursePlanController(http.Controller):
return _error_response(str(exc), 500)
# ==================================================================
# NEW: Deliverable Detection & Management
# PHASE A — Reference sources (RAG grounding)
# ==================================================================
# POST /api/ai/course-plan/<id>/deliverables/detect
@http.route('/api/ai/course-plan/<int:plan_id>/deliverables/detect',
type='http', auth='none', methods=['POST'], csrf=False)
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def detect_deliverables(self, plan_id, **kw):
"""Parse course outline text and extract structured deliverables."""
def list_sources(self, plan_id, **kw):
try:
body = _get_json_body()
outline_text = body.get('outline_text', '').strip()
if not outline_text:
return _error_response('outline_text is required', 400)
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
result = pipeline.generate_deliverables_from_outline(plan_id, outline_text)
return _json_response(result)
except ValueError as exc:
return _error_response(str(exc), 404)
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
return _json_response({
'items': [s.to_api_dict() for s in plan.source_ids],
'count': len(plan.source_ids),
})
except Exception as exc:
_logger.exception('course-plan.detect_deliverables failed')
_logger.exception('course-plan.list_sources failed')
return _error_response(str(exc), 500)
# GET /api/ai/course-plan/<id>/deliverables
@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)
ct = request.httprequest.content_type or ''
files = request.httprequest.files
uploaded = files.get('file')
if 'application/json' in ct:
body = _get_json_body() or {}
else:
body = {**kw}
kind = (body.get('kind') or '').strip() or (
'file' if uploaded else
('url' if (body.get('url') or '').strip() else 'text')
)
name = (body.get('name') or '').strip()
auto_index = body.get('auto_index')
if isinstance(auto_index, str):
auto_index = auto_index.lower() not in ('0', 'false', 'no', 'off')
elif auto_index is None:
auto_index = True
vals = {
'plan_id': plan.id,
'kind': kind,
'auto_index': bool(auto_index),
}
if kind == 'file':
if not uploaded:
return _error_response('No file uploaded', 400)
payload = uploaded.read()
vals['file'] = base64.b64encode(payload)
vals['file_name'] = uploaded.filename or 'source'
vals['mime_type'] = uploaded.mimetype or ''
vals['name'] = name or uploaded.filename or 'source'
elif kind == 'url':
url = (body.get('url') or '').strip()
if not url:
return _error_response('URL is required', 400)
vals['url'] = url
vals['name'] = name or url
else:
text = (body.get('inline_text') or body.get('text') or '').strip()
if not text:
return _error_response('Inline text is required', 400)
vals['inline_text'] = text
vals['name'] = name or 'Inline text'
rec = request.env['encoach.course.plan.source'].sudo().create(vals)
return _json_response({'data': rec.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.create_source failed')
return _error_response(str(exc), 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:
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 Exception as exc:
_logger.exception('course-plan.reindex_source failed')
return _error_response(str(exc), 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:
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 Exception as exc:
_logger.exception('course-plan.delete_source failed')
return _error_response(str(exc), 500)
# ==================================================================
# PHASE B — Deliverables preview / progress
# ==================================================================
@http.route('/api/ai/course-plan/<int:plan_id>/deliverables',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_deliverables(self, plan_id, **kw):
"""List deliverables for a course plan."""
def get_deliverables(self, plan_id, **kw):
try:
params = request.httprequest.args
domain = [('plan_id', '=', int(plan_id))]
week = params.get('week')
if week:
domain.append(('week_number', '=', int(week)))
skill = params.get('skill')
if skill:
domain.append(('skill', '=', skill))
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
records = Deliverable.search(domain, order='week_number, code')
return _json_response({
'items': [d.to_api_dict() for d in records],
'count': len(records),
})
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
return _json_response(compute_deliverables(plan))
except Exception as exc:
_logger.exception('course-plan.list_deliverables failed')
_logger.exception('course-plan.deliverables failed')
return _error_response(str(exc), 500)
# PUT /api/ai/course-plan/deliverables/<id>
@http.route('/api/ai/course-plan/deliverables/<int:deliverable_id>',
type='http', auth='none', methods=['PUT'], csrf=False)
# ==================================================================
# PHASE C — Multimedia generation per material
# ==================================================================
def _resolve_material(self, material_id):
rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id))
if not rec.exists():
return None
return rec
@http.route('/api/ai/course-plan/material/<int:material_id>/media/audio',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def update_deliverable(self, deliverable_id, **kw):
"""Update deliverable status or details."""
def gen_audio(self, material_id, **kw):
try:
body = _get_json_body()
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
rec = Deliverable.browse(int(deliverable_id))
if not rec.exists():
return _error_response('Deliverable not found', 404)
updatable = {}
if 'status' in body:
updatable['status'] = body['status']
if 'target_date' in body:
updatable['target_date'] = body['target_date']
if 'description' in body:
updatable['description'] = body['description']
if updatable:
rec.write(updatable)
return _json_response({'data': rec.to_api_dict()})
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
body = _get_json_body() or {}
svc = MediaService(request.env)
media = svc.synthesize_audio(
material,
voice=body.get('voice'),
language=body.get('language') or 'en-GB',
gender=body.get('gender') or 'female',
provider=body.get('provider') or 'polly',
)
return _json_response({'data': media.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.update_deliverable failed')
_logger.exception('course-plan.gen_audio failed')
return _error_response(str(exc), 500)
# ==================================================================
# NEW: Resource Dependencies
# ==================================================================
@http.route('/api/ai/course-plan/material/<int:material_id>/media/image',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def gen_image(self, material_id, **kw):
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
body = _get_json_body() or {}
svc = MediaService(request.env)
media = svc.generate_image(
material,
custom_prompt=body.get('prompt'),
size=body.get('size') or '1024x1024',
style=body.get('style') or 'natural',
quality=body.get('quality') or 'standard',
)
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)
# GET /api/ai/course-plan/<id>/resources
@http.route('/api/ai/course-plan/<int:plan_id>/resources',
@http.route('/api/ai/course-plan/material/<int:material_id>/media/video',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def gen_video(self, material_id, **kw):
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
svc = MediaService(request.env)
media = svc.compose_video(material)
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)
@http.route('/api/ai/course-plan/material/<int:material_id>/media',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_resources(self, plan_id, **kw):
"""List resource dependencies for a course plan."""
def list_material_media(self, material_id, **kw):
try:
params = request.httprequest.args
domain = [('plan_id', '=', int(plan_id))]
resource_type = params.get('type')
if resource_type:
domain.append(('resource_type', '=', resource_type))
ResourceDep = request.env['encoach.course.plan.resource.dep'].sudo()
records = ResourceDep.search(domain)
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
return _json_response({
'items': [r.to_api_dict() for r in records],
'count': len(records),
'items': [m.to_api_dict() for m in material.media_ids],
'count': len(material.media_ids),
})
except Exception as exc:
_logger.exception('course-plan.list_resources failed')
_logger.exception('course-plan.list_material_media failed')
return _error_response(str(exc), 500)
# POST /api/ai/course-plan/<id>/resources
@http.route('/api/ai/course-plan/<int:plan_id>/resources',
type='http', auth='none', methods=['POST'], csrf=False)
@http.route('/api/ai/course-plan/media/<int:media_id>',
type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def add_resource(self, plan_id, **kw):
"""Add a resource dependency to a course plan."""
def delete_media(self, media_id, **kw):
try:
body = _get_json_body()
if not body.get('name'):
return _error_response('name is required', 400)
ResourceDep = request.env['encoach.course.plan.resource.dep'].sudo()
rec = ResourceDep.create({
'plan_id': int(plan_id),
'name': body['name'],
'resource_type': body.get('resource_type', 'textbook'),
'citation': body.get('citation', ''),
'url': body.get('url', ''),
'ai_usage_notes': body.get('ai_usage_notes', ''),
'is_required': body.get('is_required', True),
'extracted_content_json': json.dumps(body.get('extracted_content', {})),
})
return _json_response({'data': rec.to_api_dict()})
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
if not rec.exists():
return _error_response('Media not found', 404)
if rec.attachment_id:
rec.attachment_id.unlink()
rec.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('course-plan.add_resource failed')
return _json_response(str(exc), 500)
_logger.exception('course-plan.delete_media failed')
return _error_response(str(exc), 500)
# ==================================================================
# NEW: Rich Media Generation
# ==================================================================
# POST /api/ai/course-plan/materials/<id>/media/suggest
@http.route('/api/ai/course-plan/materials/<int:material_id>/media/suggest',
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/media',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def suggest_media(self, material_id, **kw):
"""Get AI suggestions for media that would enhance this material."""
def gen_week_media(self, plan_id, week_number, **kw):
"""Generate every applicable modality for every material in a week.
Body shape: ``{ kinds: ['audio', 'image', 'video'] }``.
Default is ``['audio', 'image']`` — video is opt-in because it
depends on ffmpeg + the audio + image steps and is slower.
"""
try:
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
week = plan.week_ids.filtered(
lambda w: w.week_number == int(week_number),
)
result = pipeline.suggest_media_for_material(material_id)
if 'error' in result:
return _error_response(result['error'], 400)
return _json_response(result)
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.suggest_media failed')
return _error_response(str(exc), 500)
if not week:
return _error_response('Week not found', 404)
week = week[0]
body = _get_json_body() or {}
kinds = body.get('kinds') or ['audio', 'image']
kinds = [str(k).lower() for k in kinds]
# POST /api/ai/course-plan/materials/<id>/media/generate
@http.route('/api/ai/course-plan/materials/<int:material_id>/media/generate',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def generate_media(self, material_id, **kw):
"""Generate media (image, audio) for a teaching material."""
try:
body = _get_json_body()
media_type = body.get('media_type', 'image')
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
result = pipeline.generate_media_for_material(material_id, media_type)
if 'error' in result:
return _error_response(result['error'], 400)
return _json_response(result)
except ValueError as exc:
return _error_response(str(exc), 404)
svc = MediaService(request.env)
results = []
for material in week.material_ids:
if 'audio' in kinds and material.material_type in (
'listening_script', 'speaking_prompt',
):
results.append(svc.synthesize_audio(material).to_api_dict())
if 'image' in kinds and material.material_type in (
'reading_text', 'listening_script', 'vocabulary_list',
):
try:
results.append(svc.generate_image(material).to_api_dict())
except Exception as exc:
results.append({'error': str(exc), 'material_id': material.id})
if 'video' in kinds and material.material_type == 'listening_script':
results.append(svc.compose_video(material).to_api_dict())
return _json_response({'items': results, 'count': len(results)})
except Exception as exc:
_logger.exception('course-plan.generate_media failed')
_logger.exception('course-plan.gen_week_media failed')
return _error_response(str(exc), 500)
# ==================================================================
# NEW: Course Plan Assignment to Classes/Students
# PHASE D — Plan assignments
# ==================================================================
# POST /api/ai/course-plan/<id>/assignments
@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):
"""Assign a course plan to a class or student."""
try:
body = _get_json_body()
Assignment = request.env['encoach.course.plan.assignment'].sudo()
vals = {
'plan_id': int(plan_id),
'assignment_type': body.get('assignment_type', 'class'),
'delivery_mode': body.get('delivery_mode', 'sequential'),
'status': 'scheduled',
}
if body.get('batch_id'):
vals['batch_id'] = int(body['batch_id'])
if body.get('student_id'):
vals['student_id'] = int(body['student_id'])
if body.get('start_date'):
vals['start_date'] = body['start_date']
assignment = Assignment.create(vals)
# Create deliverable tracking rows
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
deliverables = Deliverable.search([('plan_id', '=', int(plan_id))])
for d in deliverables:
AssignmentDeliverable.create({
'assignment_id': assignment.id,
'deliverable_id': d.id,
'status': 'not_started',
})
return _json_response({
'data': assignment.to_api_dict(),
'deliverables_tracked': len(deliverables),
})
except Exception as exc:
_logger.exception('course-plan.create_assignment failed')
return _error_response(str(exc), 500)
# GET /api/ai/course-plan/<id>/assignments
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_assignments(self, plan_id, **kw):
"""List assignments for a course plan."""
try:
Assignment = request.env['encoach.course.plan.assignment'].sudo()
records = Assignment.search([('plan_id', '=', int(plan_id))])
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
return _json_response({
'items': [a.to_api_dict() for a in records],
'count': len(records),
'items': [a.to_api_dict() for a in plan.assignment_ids],
'count': len(plan.assignment_ids),
})
except Exception as exc:
_logger.exception('course-plan.list_assignments failed')
return _error_response(str(exc), 500)
# GET /api/ai/course-plan/assignments/<id>
@http.route('/api/ai/course-plan/assignments/<int:assignment_id>',
type='http', auth='none', methods=['GET'], csrf=False)
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def get_assignment(self, assignment_id, **kw):
"""Get assignment details with progress."""
def create_assignment(self, plan_id, **kw):
try:
Assignment = request.env['encoach.course.plan.assignment'].sudo()
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
assignment = Assignment.browse(int(assignment_id))
if not assignment.exists():
return _error_response('Assignment not found', 404)
# Get deliverable tracking
tracking = AssignmentDeliverable.search([('assignment_id', '=', int(assignment_id))])
status_counts = {}
for t in tracking:
status_counts[t.status] = status_counts.get(t.status, 0) + 1
return _json_response({
'data': assignment.to_api_dict(),
'deliverables': {
'total': len(tracking),
'by_status': status_counts,
'items': [t.to_api_dict() for t in tracking],
},
})
except Exception as exc:
_logger.exception('course-plan.get_assignment failed')
return _error_response(str(exc), 500)
# PUT /api/ai/course-plan/assignments/<id>/deliverables/<del_id>
@http.route('/api/ai/course-plan/assignments/<int:assignment_id>/deliverables/<int:deliverable_id>',
type='http', auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_assignment_deliverable(self, assignment_id, deliverable_id, **kw):
"""Update deliverable status for an assignment."""
try:
body = _get_json_body()
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
rec = AssignmentDeliverable.search([
('assignment_id', '=', int(assignment_id)),
('deliverable_id', '=', int(deliverable_id)),
], limit=1)
if not rec:
return _error_response('Assignment deliverable not found', 404)
vals = {}
if 'status' in body:
vals['status'] = body['status']
if 'score' in body:
vals['score'] = float(body['score'])
if 'notes' in body:
vals['notes'] = body['notes']
if body.get('status') == 'completed':
vals['completion_date'] = fields.Datetime.now()
rec.write(vals)
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
body = _get_json_body() or {}
mode = (body.get('mode') or 'batch').strip()
vals = {
'plan_id': plan.id,
'mode': mode,
'message': (body.get('message') or '').strip(),
'due_date': body.get('due_date') or False,
'state': 'active',
}
if mode == 'batch':
if not body.get('batch_id'):
return _error_response('batch_id is required', 400)
vals['batch_id'] = int(body['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])]
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 Exception as exc:
_logger.exception('course-plan.update_assignment_deliverable failed')
_logger.exception('course-plan.create_assignment failed')
return _error_response(str(exc), 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:
rec = request.env['encoach.course.plan.assignment'].sudo().browse(
int(assignment_id),
)
if not rec.exists() or rec.plan_id.id != int(plan_id):
return _error_response('Assignment not found', 404)
rec.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('course-plan.delete_assignment failed')
return _error_response(str(exc), 500)
# ==================================================================
# PHASE E — Student-side endpoints
# ==================================================================
@http.route('/api/student/course-plans',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def student_list_plans(self, **kw):
"""Return the course plans assigned to the authenticated user.
Visibility rules (OR):
1. There exists an assignment with ``mode='students'`` listing
the current user.
2. There exists an assignment with ``mode='batch'`` whose
batch contains an ``op.student.course`` whose student's
``user_id`` matches the current user.
"""
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),
])
visible = []
for a in assignments:
if a.mode == 'students' and user.id in a.student_user_ids.ids:
visible.append(a)
continue
if a.mode == 'batch' and user.id in a.expand_user_ids():
visible.append(a)
seen = set()
out = []
for a in visible:
if a.plan_id.id in seen:
continue
seen.add(a.plan_id.id)
plan_data = a.plan_id.to_api_dict(
include_weeks=False, include_materials=False,
)
plan_data['assignment'] = a.to_api_dict()
out.append(plan_data)
return _json_response({'items': out, 'count': len(out)})
except Exception as exc:
_logger.exception('course-plan.student_list_plans failed')
return _error_response(str(exc), 500)
@http.route('/api/student/course-plans/<int:plan_id>',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def student_get_plan(self, plan_id, **kw):
try:
user = request.env.user
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
allowed = False
for a in plan.assignment_ids.filtered(lambda x: x.state == 'active'):
if a.mode == 'students' and user.id in a.student_user_ids.ids:
allowed = True
break
if a.mode == 'batch' 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({
'data': plan.to_api_dict(
include_weeks=True,
include_materials=True,
include_media=True,
),
})
except Exception as exc:
_logger.exception('course-plan.student_get_plan failed')
return _error_response(str(exc), 500)