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