fix(qa): approval queue, rubric UX, structure enforcement, upload/publish limits
Addresses the QA notes on the end-to-end approval flow. Highlights:
Backend
- encoach_ai/generation_submit: create encoach.approval.request on submit so
submitted exams actually reach the approver queue (previously only the
workflow id was stored on the exam, leaving approvers with an empty inbox).
Initial exam status flips to pending_approval instead of draft.
- encoach_exam_template/approval_workflows:
* /api/approval-users filters out students (user_type='student',
op_student_id set, share=True) so the approver dropdown only lists staff.
* _ser_request enriches requests with target_name / target_status and the
current stage approver for UI badges.
* list_requests supports mine=1 / requester=1 so approvers only see queue
items awaiting their action.
* approve_request / reject_request now transition the underlying
encoach.exam.custom to published / rejected on final approval.
- encoach.exam.custom.status: add pending_approval and rejected states to
match the approval workflow transitions.
Frontend UX
- GenerationPage: rubric field shows "Auto-graded — no rubric" for Listening
and Reading (rubrics only apply to Writing / Speaking). Divider dropdowns
now carry explicit help text explaining None / Line / Space / Page Break
and where to write prompts. Selecting an official exam structure
auto-populates the required tasks/sections/parts, the delete button is
hidden for essential tasks, and submit is blocked if the user dropped
below the structure's required count.
- RubricsPage: "Add Criterion" is now a dropdown with IELTS-specific
presets (Task Achievement, Coherence & Cohesion, Fluency & Coherence, …)
keyed off the selected skill, plus a "Custom (blank row)" fallback.
- AdminCourses: added tooltips and helper copy clarifying Difficulty vs
CEFR Level in the Create Course dialog.
- CustomExamCreate: validate the whole form before Save/Publish, surface
backend error messages (413/504/401) instead of a generic toast, read the
exam id from the correct response field, and avoid marking Publish as
failed when only the optional Save-as-Template step errors.
- ResourceManager / TeacherLibrary: upload toast now shows a concrete
reason (HTTP 413 / 504 / 401) instead of a generic "Upload failed".
Config (504 / 413 / resource upload fixes)
- odoo.conf + odoo-docker.conf: raise limit_time_cpu to 600s,
limit_time_real to 900s, limit_request to 128 MB, and memory caps so
/api/exam/generation/submit and multipart resource uploads stop hitting
504 / 413 during QA.
- frontend/Dockerfile (nginx): add client_max_body_size 128m, bump
proxy_read_timeout / proxy_send_timeout to 900s, and disable request
buffering so large AI/resource payloads stream through the proxy.
Made-with: Cursor
This commit is contained in:
@@ -1372,7 +1372,7 @@ class AIController(http.Controller):
|
||||
except KeyError:
|
||||
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
||||
|
||||
initial_status = "published" if skip_approval else "draft"
|
||||
initial_status = "published" if skip_approval else "pending_approval"
|
||||
exam_vals = {
|
||||
"title": title,
|
||||
"label": label,
|
||||
@@ -1558,11 +1558,46 @@ class AIController(http.Controller):
|
||||
quality_summary["failed"], quality_summary["warned"],
|
||||
)
|
||||
|
||||
# ── Route through the approval workflow ────────────────────────
|
||||
# If the admin selected an approval workflow AND did not click
|
||||
# "skip approval", create a real ``encoach.approval.request`` that
|
||||
# lands in the first stage approver's queue. QA flagged that
|
||||
# submitted modules were invisible to the assigned approver —
|
||||
# before this block the exam was just left in ``draft`` with
|
||||
# ``approval_workflow_id`` set but no request record routing it.
|
||||
approval_request_id = False
|
||||
if not skip_approval and workflow_id:
|
||||
try:
|
||||
Workflow = request.env["encoach.approval.workflow"].sudo()
|
||||
workflow = Workflow.browse(workflow_id)
|
||||
if workflow.exists() and workflow.stage_ids:
|
||||
first_stage = workflow.stage_ids.sorted("sequence")[:1]
|
||||
approval_req = request.env["encoach.approval.request"].sudo().create({
|
||||
"workflow_id": workflow.id,
|
||||
"res_model": "encoach.exam.custom",
|
||||
"res_id": exam.id,
|
||||
"state": "in_progress" if first_stage else "draft",
|
||||
"requester_id": request.env.user.id,
|
||||
"current_stage_id": first_stage.id if first_stage else False,
|
||||
})
|
||||
approval_request_id = approval_req.id
|
||||
_logger.info(
|
||||
"created approval.request %s for exam %s (workflow %s, stage %s)",
|
||||
approval_req.id, exam.id, workflow.id,
|
||||
first_stage.id if first_stage else None,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"failed to create approval request for exam %s workflow %s",
|
||||
exam.id, workflow_id,
|
||||
)
|
||||
|
||||
return _json_response({
|
||||
"exam_id": exam.id,
|
||||
"status": exam.status,
|
||||
"template_id": template_id,
|
||||
"total_questions": total_questions,
|
||||
"approval_request_id": approval_request_id,
|
||||
"quality": quality_summary,
|
||||
"schema_validation": {
|
||||
"verdict": schema_report["verdict"],
|
||||
|
||||
@@ -57,17 +57,33 @@ def _ser_workflow(wf):
|
||||
|
||||
def _ser_request(req):
|
||||
stage = req.current_stage_id
|
||||
# Resolve target record so the UI can show "what am I approving?"
|
||||
# rather than an opaque id.
|
||||
target_name = ''
|
||||
target_status = ''
|
||||
if req.res_model and req.res_id:
|
||||
try:
|
||||
target = request.env[req.res_model].sudo().browse(req.res_id)
|
||||
if target.exists():
|
||||
target_name = getattr(target, 'title', '') or getattr(target, 'name', '') or ''
|
||||
target_status = getattr(target, 'status', '') or ''
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
return {
|
||||
'id': req.id,
|
||||
'workflow_id': req.workflow_id.id or None,
|
||||
'workflow_name': req.workflow_id.name if req.workflow_id else '',
|
||||
'res_model': req.res_model or '',
|
||||
'res_id': req.res_id or 0,
|
||||
'target_name': target_name,
|
||||
'target_status': target_status,
|
||||
'state': req.state or 'draft',
|
||||
'requester_id': req.requester_id.id or None,
|
||||
'requester_name': req.requester_id.name if req.requester_id else '',
|
||||
'current_stage_id': stage.id or None,
|
||||
'current_stage_sequence': stage.sequence if stage else None,
|
||||
'current_stage_approver_id': stage.approver_id.id if stage and stage.approver_id else None,
|
||||
'current_stage_approver_name': stage.approver_id.name if stage and stage.approver_id else '',
|
||||
'bypass_reason': req.bypass_reason or '',
|
||||
'created_at': req.created_at.isoformat() if req.created_at else None,
|
||||
}
|
||||
@@ -234,6 +250,16 @@ class ApprovalWorkflowController(http.Controller):
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_requests(self, **kw):
|
||||
"""List approval requests.
|
||||
|
||||
Query params:
|
||||
* ``state`` — filter by request state
|
||||
* ``workflow_id`` — filter by workflow
|
||||
* ``mine=1`` — only requests currently awaiting the calling user
|
||||
(current_stage.approver_id == me). Used by the "My approvals"
|
||||
queue so approvers see exactly what's on their plate.
|
||||
* ``requester=1`` — only requests the calling user submitted
|
||||
"""
|
||||
try:
|
||||
M = request.env['encoach.approval.request'].sudo()
|
||||
domain = []
|
||||
@@ -241,6 +267,14 @@ class ApprovalWorkflowController(http.Controller):
|
||||
domain.append(('state', '=', kw['state']))
|
||||
if kw.get('workflow_id'):
|
||||
domain.append(('workflow_id', '=', int(kw['workflow_id'])))
|
||||
if kw.get('mine') in ('1', 'true', True):
|
||||
uid = request.env.user.id
|
||||
domain += [
|
||||
('current_stage_id.approver_id', '=', uid),
|
||||
('state', 'in', ('draft', 'in_progress')),
|
||||
]
|
||||
if kw.get('requester') in ('1', 'true', True):
|
||||
domain.append(('requester_id', '=', request.env.user.id))
|
||||
recs = M.search(domain, order='created_at desc, id desc')
|
||||
items = [_ser_request(r) for r in recs]
|
||||
return _json_response({
|
||||
@@ -296,6 +330,16 @@ class ApprovalWorkflowController(http.Controller):
|
||||
})
|
||||
else:
|
||||
req_rec.write({'state': 'approved'})
|
||||
# Last stage passed — publish the underlying record.
|
||||
# Today this is always an exam; guard defensively so
|
||||
# other res_models don't raise.
|
||||
if req_rec.res_model == 'encoach.exam.custom' and req_rec.res_id:
|
||||
try:
|
||||
target = request.env['encoach.exam.custom'].sudo().browse(req_rec.res_id)
|
||||
if target.exists() and target.status in ('draft', 'pending_review', 'pending_approval'):
|
||||
target.write({'status': 'published'})
|
||||
except Exception:
|
||||
_logger.exception('failed to publish exam %s on final approval', req_rec.res_id)
|
||||
|
||||
return _json_response({'success': True, 'id': req_id,
|
||||
'state': req_rec.state})
|
||||
@@ -327,6 +371,14 @@ class ApprovalWorkflowController(http.Controller):
|
||||
'acted_at': datetime.now(),
|
||||
})
|
||||
req_rec.write({'state': 'rejected'})
|
||||
# Mark the underlying exam rejected so the author can see it.
|
||||
if req_rec.res_model == 'encoach.exam.custom' and req_rec.res_id:
|
||||
try:
|
||||
target = request.env['encoach.exam.custom'].sudo().browse(req_rec.res_id)
|
||||
if target.exists():
|
||||
target.write({'status': 'rejected'})
|
||||
except Exception:
|
||||
_logger.exception('failed to mark exam %s rejected', req_rec.res_id)
|
||||
return _json_response({'success': True, 'id': req_id,
|
||||
'state': req_rec.state})
|
||||
except Exception as e:
|
||||
@@ -339,18 +391,33 @@ class ApprovalWorkflowController(http.Controller):
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_users(self, **kw):
|
||||
"""Return users eligible to act as approvers.
|
||||
|
||||
Explicitly excludes:
|
||||
* inactive users, ``__system__`` (id=1), the portal template user, and
|
||||
any ``op.student`` mirrored account (``op_student_id`` set)
|
||||
* users with ``user_type='student'`` — students must never be
|
||||
approvers; QA flagged student logins appearing in the picker.
|
||||
"""
|
||||
try:
|
||||
Users = request.env['res.users'].sudo()
|
||||
domain = [('active', '=', True), ('id', '>', 1)]
|
||||
domain = [
|
||||
('active', '=', True),
|
||||
('id', '>', 1),
|
||||
('share', '=', False),
|
||||
'|', ('user_type', '=', False), ('user_type', '!=', 'student'),
|
||||
('op_student_id', '=', False),
|
||||
]
|
||||
search = (kw.get('search') or '').strip()
|
||||
if search:
|
||||
domain += ['|', ('name', 'ilike', search), ('login', 'ilike', search)]
|
||||
recs = Users.search(domain, order='name', limit=100)
|
||||
recs = Users.search(domain, order='name', limit=200)
|
||||
items = [{
|
||||
'id': u.id,
|
||||
'name': u.name or u.login,
|
||||
'login': u.login or '',
|
||||
'email': u.email or '',
|
||||
'user_type': u.user_type or '',
|
||||
} for u in recs]
|
||||
return _json_response({'items': items, 'results': items, 'total': len(items)})
|
||||
except Exception as e:
|
||||
|
||||
@@ -40,6 +40,8 @@ class EncoachExamCustom(models.Model):
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('pending_review', 'Pending Review'),
|
||||
('pending_approval', 'Pending Approval'),
|
||||
('rejected', 'Rejected'),
|
||||
('published', 'Published'),
|
||||
('archived', 'Archived'),
|
||||
], default='draft', required=True)
|
||||
|
||||
Reference in New Issue
Block a user