diff --git a/custom_addons/encoach_ai/controllers/ai_controller.py b/custom_addons/encoach_ai/controllers/ai_controller.py index fdfeca8b..316c8fb0 100644 --- a/custom_addons/encoach_ai/controllers/ai_controller.py +++ b/custom_addons/encoach_ai/controllers/ai_controller.py @@ -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"], diff --git a/custom_addons/encoach_exam_template/controllers/approval_workflows.py b/custom_addons/encoach_exam_template/controllers/approval_workflows.py index 22237ef7..68f9aa32 100644 --- a/custom_addons/encoach_exam_template/controllers/approval_workflows.py +++ b/custom_addons/encoach_exam_template/controllers/approval_workflows.py @@ -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: diff --git a/custom_addons/encoach_exam_template/models/exam_custom.py b/custom_addons/encoach_exam_template/models/exam_custom.py index a56c74b2..575142a3 100644 --- a/custom_addons/encoach_exam_template/models/exam_custom.py +++ b/custom_addons/encoach_exam_template/models/exam_custom.py @@ -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)