feat(backend): Phase 2/3 hardening release
Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.
Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.
Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.
Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.
Made-with: Cursor
This commit is contained in:
@@ -7,4 +7,4 @@ from . import exam_schedules
|
||||
from . import rubrics
|
||||
from . import approval_workflows
|
||||
from . import entities
|
||||
from . import exam_session
|
||||
from . import review_workflow
|
||||
|
||||
@@ -256,36 +256,46 @@ class ApprovalWorkflowController(http.Controller):
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def approve_request(self, req_id, **kw):
|
||||
"""Approve the current stage or finalize the request.
|
||||
|
||||
Wrapped in a ``SAVEPOINT`` so stage + request writes commit or roll back
|
||||
atomically. The savepoint is released only after both writes succeed,
|
||||
preventing partial states (e.g. stage marked ``approved`` while request
|
||||
still ``in_progress``) that were possible with the previous
|
||||
non-transactional implementation.
|
||||
"""
|
||||
try:
|
||||
body = _get_json_body()
|
||||
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
|
||||
if not req_rec.exists():
|
||||
return _error_response('Request not found', 404)
|
||||
|
||||
stage = req_rec.current_stage_id
|
||||
if stage:
|
||||
stage.write({
|
||||
'status': 'approved',
|
||||
'comment': body.get('comment', ''),
|
||||
'acted_at': datetime.now(),
|
||||
})
|
||||
with request.env.cr.savepoint():
|
||||
stage = req_rec.current_stage_id
|
||||
if stage:
|
||||
stage.write({
|
||||
'status': 'approved',
|
||||
'comment': body.get('comment', ''),
|
||||
'acted_at': datetime.now(),
|
||||
})
|
||||
|
||||
# Move to next stage or finalize.
|
||||
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
|
||||
stage_ids = [s.id for s in stages]
|
||||
try:
|
||||
idx = stage_ids.index(stage.id) if stage else -1
|
||||
except ValueError:
|
||||
idx = -1
|
||||
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
|
||||
stage_ids = [s.id for s in stages]
|
||||
try:
|
||||
idx = stage_ids.index(stage.id) if stage else -1
|
||||
except ValueError:
|
||||
idx = -1
|
||||
|
||||
if 0 <= idx and idx + 1 < len(stage_ids):
|
||||
next_stage = request.env['encoach.approval.stage'].sudo().browse(stage_ids[idx + 1])
|
||||
req_rec.write({
|
||||
'current_stage_id': next_stage.id,
|
||||
'state': 'in_progress',
|
||||
})
|
||||
else:
|
||||
req_rec.write({'state': 'approved'})
|
||||
if 0 <= idx and idx + 1 < len(stage_ids):
|
||||
next_stage = request.env['encoach.approval.stage'].sudo().browse(
|
||||
stage_ids[idx + 1]
|
||||
)
|
||||
req_rec.write({
|
||||
'current_stage_id': next_stage.id,
|
||||
'state': 'in_progress',
|
||||
})
|
||||
else:
|
||||
req_rec.write({'state': 'approved'})
|
||||
|
||||
return _json_response({'success': True, 'id': req_id,
|
||||
'state': req_rec.state})
|
||||
@@ -297,19 +307,26 @@ class ApprovalWorkflowController(http.Controller):
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def reject_request(self, req_id, **kw):
|
||||
"""Reject a stage and mark the request rejected atomically.
|
||||
|
||||
Uses a ``SAVEPOINT`` so that if the request ``write`` fails after the
|
||||
stage has been updated, Postgres rolls both writes back and the caller
|
||||
sees a clean 500 rather than a half-updated workflow.
|
||||
"""
|
||||
try:
|
||||
body = _get_json_body()
|
||||
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
|
||||
if not req_rec.exists():
|
||||
return _error_response('Request not found', 404)
|
||||
stage = req_rec.current_stage_id
|
||||
if stage:
|
||||
stage.write({
|
||||
'status': 'rejected',
|
||||
'comment': body.get('comment', ''),
|
||||
'acted_at': datetime.now(),
|
||||
})
|
||||
req_rec.write({'state': 'rejected'})
|
||||
with request.env.cr.savepoint():
|
||||
stage = req_rec.current_stage_id
|
||||
if stage:
|
||||
stage.write({
|
||||
'status': 'rejected',
|
||||
'comment': body.get('comment', ''),
|
||||
'acted_at': datetime.now(),
|
||||
})
|
||||
req_rec.write({'state': 'rejected'})
|
||||
return _json_response({'success': True, 'id': req_id,
|
||||
'state': req_rec.state})
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
import json
|
||||
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,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _question_to_student_dict(q):
|
||||
return {
|
||||
'id': q.id,
|
||||
'skill': q.skill or '',
|
||||
'question_type': q.question_type or '',
|
||||
'stem': q.stem or '',
|
||||
'options': json.loads(q.options) if q.options else [],
|
||||
'marks': q.marks,
|
||||
'difficulty': q.difficulty or '',
|
||||
'source_type': q.source_type or '',
|
||||
'source_id': q.source_id or 0,
|
||||
}
|
||||
|
||||
|
||||
class ExamSessionController(http.Controller):
|
||||
|
||||
@http.route('/api/exam/<int:exam_id>/session', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_session(self, exam_id, **kw):
|
||||
try:
|
||||
Exam = request.env['encoach.exam.custom'].sudo()
|
||||
exam = Exam.browse(exam_id)
|
||||
if not exam.exists():
|
||||
return _error_response('Exam not found', 404)
|
||||
|
||||
uid = request.env.user.id
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.search([
|
||||
('student_id', '=', uid),
|
||||
('exam_id', '=', exam.id),
|
||||
('status', 'in', ['in_progress', 'scoring']),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not attempt:
|
||||
attempt = Attempt.create({
|
||||
'student_id': uid,
|
||||
'exam_id': exam.id,
|
||||
'status': 'in_progress',
|
||||
'entity_id': exam.entity_id.id if exam.entity_id else False,
|
||||
})
|
||||
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
saved_answers = {}
|
||||
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
|
||||
saved_answers[ans.question_id.id] = ans.answer or ''
|
||||
|
||||
sections = []
|
||||
for sec in exam.section_ids.sorted('sequence'):
|
||||
questions = []
|
||||
for q in sec.question_ids:
|
||||
q_dict = _question_to_student_dict(q)
|
||||
q_dict['saved_answer'] = saved_answers.get(q.id, '')
|
||||
questions.append(q_dict)
|
||||
|
||||
sec_dict = {
|
||||
'id': sec.id,
|
||||
'title': sec.title,
|
||||
'skill': sec.skill or '',
|
||||
'difficulty': sec.difficulty or '',
|
||||
'time_limit_min': sec.time_limit_min or 0,
|
||||
'total_marks': sec.total_marks or 0,
|
||||
'scoring_method': sec.scoring_method or 'auto',
|
||||
'sequence': sec.sequence,
|
||||
'questions': questions,
|
||||
}
|
||||
if sec.passage_text:
|
||||
sec_dict['passage_text'] = sec.passage_text
|
||||
if sec.instructions_text:
|
||||
sec_dict['instructions_text'] = sec.instructions_text
|
||||
if sec.content_json:
|
||||
try:
|
||||
sec_dict['content'] = json.loads(sec.content_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
sections.append(sec_dict)
|
||||
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'exam_id': exam.id,
|
||||
'exam_title': exam.title,
|
||||
'exam_mode': exam.exam_mode or 'official',
|
||||
'total_time_min': exam.total_time_min or 0,
|
||||
'total_marks': exam.total_marks or 0,
|
||||
'grading_system': exam.grading_system or 'ielts',
|
||||
'access_type': exam.access_type or 'private',
|
||||
'randomize_questions': exam.randomize_questions or False,
|
||||
'status': attempt.status,
|
||||
'started_at': str(attempt.started_at) if attempt.started_at else None,
|
||||
'sections': sections,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('get_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/exam/<int:exam_id>/autosave', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def autosave(self, exam_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
attempt_id = body.get('attempt_id')
|
||||
raw_answers = body.get('answers', [])
|
||||
|
||||
if not attempt_id:
|
||||
return _error_response('attempt_id is required', 400)
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.browse(int(attempt_id))
|
||||
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
|
||||
return _error_response('Invalid attempt', 403)
|
||||
|
||||
answer_pairs = self._normalize_answers(raw_answers)
|
||||
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
saved = 0
|
||||
for q_id, answer_val in answer_pairs:
|
||||
existing = Answer.search([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('question_id', '=', q_id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
existing.write({'answer': str(answer_val)})
|
||||
else:
|
||||
Answer.create({
|
||||
'attempt_id': attempt.id,
|
||||
'question_id': q_id,
|
||||
'answer': str(answer_val),
|
||||
})
|
||||
saved += 1
|
||||
|
||||
return _json_response({'saved': saved})
|
||||
except Exception as e:
|
||||
_logger.exception('autosave failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/exam/<int:exam_id>/submit', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def submit_exam(self, exam_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
attempt_id = body.get('attempt_id')
|
||||
raw_answers = body.get('answers', [])
|
||||
|
||||
if not attempt_id:
|
||||
return _error_response('attempt_id is required', 400)
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.browse(int(attempt_id))
|
||||
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
|
||||
return _error_response('Invalid attempt', 403)
|
||||
|
||||
answer_pairs = self._normalize_answers(raw_answers)
|
||||
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
Question = request.env['encoach.question'].sudo()
|
||||
|
||||
total_score = 0.0
|
||||
max_score = 0.0
|
||||
|
||||
for q_id, answer_val in answer_pairs:
|
||||
q = Question.browse(q_id)
|
||||
if not q.exists():
|
||||
continue
|
||||
|
||||
is_correct = False
|
||||
score = 0.0
|
||||
correct = (q.correct_answer or '').strip().lower()
|
||||
given = str(answer_val).strip().lower()
|
||||
|
||||
if correct and given:
|
||||
is_correct = correct == given
|
||||
score = q.marks if is_correct else 0.0
|
||||
|
||||
existing = Answer.search([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('question_id', '=', q_id),
|
||||
], limit=1)
|
||||
vals = {
|
||||
'answer': str(answer_val),
|
||||
'score': score,
|
||||
'is_correct': is_correct,
|
||||
}
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
vals.update({
|
||||
'attempt_id': attempt.id,
|
||||
'question_id': q_id,
|
||||
})
|
||||
Answer.create(vals)
|
||||
|
||||
total_score += score
|
||||
max_score += q.marks
|
||||
|
||||
from odoo.fields import Datetime
|
||||
attempt.write({
|
||||
'status': 'completed',
|
||||
'finished_at': Datetime.now(),
|
||||
'total_score': total_score,
|
||||
'max_score': max_score,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'status': 'completed',
|
||||
'total_score': total_score,
|
||||
'max_score': max_score,
|
||||
'percentage': round(total_score / max_score * 100, 1) if max_score else 0,
|
||||
'results_available': True,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('submit_exam failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/exam/<int:exam_id>/status', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_status(self, exam_id, **kw):
|
||||
try:
|
||||
uid = request.env.user.id
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.search([
|
||||
('student_id', '=', uid),
|
||||
('exam_id', '=', exam_id),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not attempt:
|
||||
return _error_response('No attempt found', 404)
|
||||
|
||||
scores_available = attempt.status == 'completed' and attempt.max_score > 0
|
||||
return _json_response({
|
||||
'status': attempt.status,
|
||||
'scores_available': scores_available,
|
||||
'total_score': attempt.total_score,
|
||||
'max_score': attempt.max_score,
|
||||
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('get_status failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/exam/<int:exam_id>/results', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_results(self, exam_id, **kw):
|
||||
try:
|
||||
uid = request.env.user.id
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.search([
|
||||
('student_id', '=', uid),
|
||||
('exam_id', '=', exam_id),
|
||||
('status', '=', 'completed'),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not attempt:
|
||||
return _error_response('No completed attempt found', 404)
|
||||
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
answers = []
|
||||
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
|
||||
answers.append({
|
||||
'question_id': ans.question_id.id,
|
||||
'answer': ans.answer or '',
|
||||
'score': ans.score,
|
||||
'is_correct': ans.is_correct,
|
||||
'feedback': ans.feedback or '',
|
||||
})
|
||||
|
||||
Exam = request.env['encoach.exam.custom'].sudo()
|
||||
exam = Exam.browse(exam_id)
|
||||
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'exam_id': exam_id,
|
||||
'exam_title': exam.title if exam.exists() else '',
|
||||
'status': attempt.status,
|
||||
'total_score': attempt.total_score,
|
||||
'max_score': attempt.max_score,
|
||||
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
|
||||
'started_at': str(attempt.started_at) if attempt.started_at else None,
|
||||
'finished_at': str(attempt.finished_at) if attempt.finished_at else None,
|
||||
'answers': answers,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('get_results failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_answers(raw):
|
||||
"""Accept answers as list of {question_id, answer} or dict {qid: answer}."""
|
||||
if isinstance(raw, list):
|
||||
pairs = []
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
qid = item.get('question_id') or item.get('qid')
|
||||
ans = item.get('answer', '')
|
||||
if qid:
|
||||
pairs.append((int(qid), ans))
|
||||
return pairs
|
||||
if isinstance(raw, dict):
|
||||
return [(int(k), v) for k, v in raw.items()]
|
||||
return []
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Human-in-the-loop review workflow for AI-generated exams.
|
||||
|
||||
When an exam is created via the AI pipeline and fails the automated quality
|
||||
gate (QualityChecker + IeltsValidator), it transitions to ``pending_review``
|
||||
instead of being published directly (see ADR 0004 and P1.1 in the hardening
|
||||
release). This controller exposes the admin-facing endpoints used by the
|
||||
review queue UI to inspect, approve, or reject those exams.
|
||||
|
||||
Endpoints:
|
||||
|
||||
* ``GET /api/exam/review/queue`` — paginated list of pending exams
|
||||
* ``GET /api/exam/review/<exam_id>`` — detail with per-question quality data
|
||||
* ``POST /api/exam/review/<exam_id>/approve`` — publish (optional notes)
|
||||
* ``POST /api/exam/review/<exam_id>/reject`` — back to draft (notes required)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
_error_response,
|
||||
_get_json_body,
|
||||
_json_response,
|
||||
_paginate,
|
||||
jwt_required,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _question_to_review_dict(q):
|
||||
"""Compact projection of a question for the review UI."""
|
||||
try:
|
||||
report = json.loads(q.quality_report) if q.quality_report else None
|
||||
except (TypeError, ValueError):
|
||||
report = {'raw': q.quality_report}
|
||||
return {
|
||||
'id': q.id,
|
||||
'skill': q.skill,
|
||||
'question_type': q.question_type,
|
||||
'difficulty': q.difficulty,
|
||||
'stem': q.stem or '',
|
||||
'marks': q.marks or 0.0,
|
||||
'ai_generated': bool(q.ai_generated),
|
||||
'ielts_certified': bool(q.ielts_certified),
|
||||
'format_validated': bool(q.format_validated),
|
||||
'ai_model_used': q.ai_model_used or '',
|
||||
'ai_prompt_hash': q.ai_prompt_hash or '',
|
||||
'quality_score': q.quality_score or 0.0,
|
||||
'quality_report': report,
|
||||
}
|
||||
|
||||
|
||||
def _review_summary(exam):
|
||||
"""Aggregate quality stats across every question in the exam."""
|
||||
questions = exam.section_ids.mapped('question_ids')
|
||||
total = len(questions)
|
||||
if total == 0:
|
||||
return {
|
||||
'question_count': 0,
|
||||
'avg_quality_score': 0.0,
|
||||
'min_quality_score': 0.0,
|
||||
'failing_count': 0,
|
||||
'ai_generated_count': 0,
|
||||
}
|
||||
scores = [q.quality_score or 0.0 for q in questions]
|
||||
failing_threshold = 0.7 # configurable later via ir.config_parameter
|
||||
return {
|
||||
'question_count': total,
|
||||
'avg_quality_score': round(sum(scores) / total, 3),
|
||||
'min_quality_score': round(min(scores), 3),
|
||||
'failing_count': sum(1 for s in scores if s < failing_threshold),
|
||||
'ai_generated_count': sum(1 for q in questions if q.ai_generated),
|
||||
}
|
||||
|
||||
|
||||
def _exam_to_review_dict(exam, *, include_questions=False):
|
||||
data = {
|
||||
'id': exam.id,
|
||||
'title': exam.title,
|
||||
'status': exam.status,
|
||||
'subject_id': exam.subject_id.id if exam.subject_id else None,
|
||||
'subject_name': exam.subject_id.name if exam.subject_id else '',
|
||||
'entity_id': exam.entity_id.id if exam.entity_id else None,
|
||||
'teacher_id': exam.teacher_id.id if exam.teacher_id else None,
|
||||
'teacher_name': exam.teacher_id.name if exam.teacher_id else '',
|
||||
'grading_system': exam.grading_system,
|
||||
'total_time_min': exam.total_time_min or 0,
|
||||
'total_marks': exam.total_marks or 0.0,
|
||||
'reviewed_by_id': exam.reviewed_by_id.id if exam.reviewed_by_id else None,
|
||||
'reviewed_by_name': exam.reviewed_by_id.name if exam.reviewed_by_id else '',
|
||||
'reviewed_at': (
|
||||
fields.Datetime.to_string(exam.reviewed_at) if exam.reviewed_at else None
|
||||
),
|
||||
'review_notes': exam.review_notes or '',
|
||||
'summary': _review_summary(exam),
|
||||
}
|
||||
if include_questions:
|
||||
data['sections'] = []
|
||||
for sec in exam.section_ids:
|
||||
data['sections'].append({
|
||||
'id': sec.id,
|
||||
'title': sec.title,
|
||||
'skill': sec.skill or '',
|
||||
'sequence': sec.sequence,
|
||||
'questions': [
|
||||
_question_to_review_dict(q) for q in sec.question_ids
|
||||
],
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
class EncoachExamReviewController(http.Controller):
|
||||
"""Admin review queue for AI-generated exams gated by the quality checker."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/exam/review/queue
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/exam/review/queue', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def queue(self, **kw):
|
||||
try:
|
||||
Exam = request.env['encoach.exam.custom'].sudo()
|
||||
# Default to pending_review but allow callers to inspect history too.
|
||||
status = (kw.get('status') or 'pending_review').strip()
|
||||
domain = [('status', '=', status)] if status else []
|
||||
|
||||
search = (kw.get('search') or '').strip()
|
||||
if search:
|
||||
domain.append(('title', 'ilike', search))
|
||||
|
||||
subject_id = kw.get('subject_id')
|
||||
if subject_id:
|
||||
try:
|
||||
domain.append(('subject_id', '=', int(subject_id)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
total = Exam.search_count(domain)
|
||||
page, per_page, offset = _paginate(kw)
|
||||
exams = Exam.search(
|
||||
domain, limit=per_page, offset=offset, order='id desc',
|
||||
)
|
||||
items = [_exam_to_review_dict(e) for e in exams]
|
||||
return _json_response({
|
||||
'items': items,
|
||||
'data': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': per_page,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('exam review queue failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/exam/review/<exam_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/exam/review/<int:exam_id>', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def detail(self, exam_id, **kw):
|
||||
try:
|
||||
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
|
||||
if not exam.exists():
|
||||
return _error_response('Exam not found', 404)
|
||||
return _json_response(
|
||||
_exam_to_review_dict(exam, include_questions=True)
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.exception('exam review detail failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/exam/review/<exam_id>/approve
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/exam/review/<int:exam_id>/approve', type='http',
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def approve(self, exam_id, **kw):
|
||||
try:
|
||||
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
|
||||
if not exam.exists():
|
||||
return _error_response('Exam not found', 404)
|
||||
if exam.status != 'pending_review':
|
||||
return _error_response(
|
||||
"Only exams in 'pending_review' can be approved "
|
||||
f"(current status: {exam.status})",
|
||||
400,
|
||||
)
|
||||
|
||||
body = _get_json_body() or {}
|
||||
notes = (body.get('notes') or '').strip() or False
|
||||
with request.env.cr.savepoint():
|
||||
exam.write({
|
||||
'status': 'published',
|
||||
'reviewed_by_id': request.env.user.id,
|
||||
'reviewed_at': fields.Datetime.now(),
|
||||
'review_notes': notes,
|
||||
})
|
||||
_logger.info(
|
||||
'exam %s approved by user %s', exam.id, request.env.user.id,
|
||||
)
|
||||
return _json_response(
|
||||
_exam_to_review_dict(exam, include_questions=False)
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.exception('exam review approve failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/exam/review/<exam_id>/reject
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/exam/review/<int:exam_id>/reject', type='http',
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def reject(self, exam_id, **kw):
|
||||
try:
|
||||
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
|
||||
if not exam.exists():
|
||||
return _error_response('Exam not found', 404)
|
||||
if exam.status != 'pending_review':
|
||||
return _error_response(
|
||||
"Only exams in 'pending_review' can be rejected "
|
||||
f"(current status: {exam.status})",
|
||||
400,
|
||||
)
|
||||
|
||||
body = _get_json_body() or {}
|
||||
notes = (body.get('notes') or '').strip()
|
||||
# Require notes on rejection — the author needs actionable feedback.
|
||||
if not notes:
|
||||
return _error_response(
|
||||
'Review notes are required when rejecting an exam.', 400,
|
||||
)
|
||||
|
||||
with request.env.cr.savepoint():
|
||||
exam.write({
|
||||
'status': 'draft',
|
||||
'reviewed_by_id': request.env.user.id,
|
||||
'reviewed_at': fields.Datetime.now(),
|
||||
'review_notes': notes,
|
||||
})
|
||||
_logger.info(
|
||||
'exam %s rejected by user %s', exam.id, request.env.user.id,
|
||||
)
|
||||
return _json_response(
|
||||
_exam_to_review_dict(exam, include_questions=False)
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.exception('exam review reject failed')
|
||||
return _error_response(str(e), 500)
|
||||
Reference in New Issue
Block a user