From 3972023a30ce193f1b97f22a68204a4e02f17aa3 Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Sun, 19 Apr 2026 14:16:09 +0400 Subject: [PATCH] feat(backend): Phase 2/3 hardening release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- custom_addons/encoach_ai/__manifest__.py | 2 +- .../encoach_ai/controllers/__init__.py | 2 + .../encoach_ai/controllers/ai_controller.py | 161 ++++++-- .../controllers/coach_controller.py | 22 +- .../controllers/feedback_controller.py | 217 +++++++++++ .../controllers/media_controller.py | 22 +- .../controllers/prompt_controller.py | 219 +++++++++++ custom_addons/encoach_ai/models/__init__.py | 2 + .../encoach_ai/models/ai_feedback.py | 134 +++++++ custom_addons/encoach_ai/models/ai_prompt.py | 213 +++++++++++ .../encoach_ai/security/ir.model.access.csv | 4 + custom_addons/encoach_ai/services/__init__.py | 2 + .../encoach_ai/services/cefr_mapper.py | 149 ++++++++ .../encoach_ai/services/openai_service.py | 10 +- .../encoach_ai/services/question_validator.py | 318 ++++++++++++++++ custom_addons/encoach_api/__init__.py | 2 + custom_addons/encoach_api/__manifest__.py | 5 +- .../encoach_api/controllers/__init__.py | 3 + custom_addons/encoach_api/controllers/auth.py | 197 +++++++++- custom_addons/encoach_api/controllers/base.py | 174 ++++++++- custom_addons/encoach_api/controllers/gdpr.py | 316 ++++++++++++++++ .../encoach_api/controllers/health.py | 65 ++++ .../encoach_api/controllers/openapi.py | 265 +++++++++++++ custom_addons/encoach_api/data/cron.xml | 12 + custom_addons/encoach_api/models/__init__.py | 2 + .../encoach_api/models/gdpr_erasure.py | 35 ++ custom_addons/encoach_api/models/jwt_token.py | 105 ++++++ .../encoach_api/security/ir.model.access.csv | 4 + custom_addons/encoach_api/tests/__init__.py | 1 + .../encoach_api/tests/test_health.py | 27 ++ custom_addons/encoach_api/utils/__init__.py | 1 + custom_addons/encoach_api/utils/cache.py | 106 ++++++ .../controllers/__init__.py | 2 +- .../controllers/approval_workflows.py | 77 ++-- .../controllers/exam_session.py | 316 ---------------- .../controllers/review_workflow.py | 256 +++++++++++++ .../encoach_exam_template/models/__init__.py | 1 - .../models/exam_custom.py | 15 + .../encoach_exam_template/models/question.py | 58 ++- .../models/student_attempt.py | 52 --- .../security/ir.model.access.csv | 3 - custom_addons/encoach_lms_api/__manifest__.py | 1 + .../encoach_lms_api/controllers/__init__.py | 1 + .../encoach_lms_api/controllers/attendance.py | 2 +- .../encoach_lms_api/controllers/grades.py | 2 +- .../encoach_lms_api/controllers/payments.py | 40 +- .../encoach_lms_api/controllers/paymob.py | 351 ++++++++++++++++++ .../encoach_lms_api/controllers/reports.py | 183 +++++---- .../encoach_lms_api/controllers/timetable.py | 2 +- .../controllers/users_roles.py | 6 +- .../encoach_lms_api/models/__init__.py | 1 + .../encoach_lms_api/models/paymob_order.py | 92 +++++ .../encoach_lms_api/models/ticket.py | 132 ++++++- .../security/ir.model.access.csv | 2 + .../encoach_placement/__manifest__.py | 2 +- .../controllers/placement.py | 13 +- .../encoach_placement/services/cefr_mapper.py | 99 +---- .../controllers/exam_session.py | 6 +- .../encoach_scoring/controllers/grading.py | 6 +- .../encoach_scoring/models/student_attempt.py | 34 +- .../encoach_taxonomy/controllers/taxonomy.py | 11 +- .../encoach_vector/models/embedding.py | 57 ++- .../services/embedding_service.py | 157 ++++++-- .../encoach_vector/services/indexer.py | 45 ++- 64 files changed, 4121 insertions(+), 701 deletions(-) create mode 100644 custom_addons/encoach_ai/controllers/feedback_controller.py create mode 100644 custom_addons/encoach_ai/controllers/prompt_controller.py create mode 100644 custom_addons/encoach_ai/models/ai_feedback.py create mode 100644 custom_addons/encoach_ai/models/ai_prompt.py create mode 100644 custom_addons/encoach_ai/services/cefr_mapper.py create mode 100644 custom_addons/encoach_ai/services/question_validator.py create mode 100644 custom_addons/encoach_api/controllers/gdpr.py create mode 100644 custom_addons/encoach_api/controllers/health.py create mode 100644 custom_addons/encoach_api/controllers/openapi.py create mode 100644 custom_addons/encoach_api/data/cron.xml create mode 100644 custom_addons/encoach_api/models/__init__.py create mode 100644 custom_addons/encoach_api/models/gdpr_erasure.py create mode 100644 custom_addons/encoach_api/models/jwt_token.py create mode 100644 custom_addons/encoach_api/security/ir.model.access.csv create mode 100644 custom_addons/encoach_api/tests/__init__.py create mode 100644 custom_addons/encoach_api/tests/test_health.py create mode 100644 custom_addons/encoach_api/utils/__init__.py create mode 100644 custom_addons/encoach_api/utils/cache.py delete mode 100644 custom_addons/encoach_exam_template/controllers/exam_session.py create mode 100644 custom_addons/encoach_exam_template/controllers/review_workflow.py delete mode 100644 custom_addons/encoach_exam_template/models/student_attempt.py create mode 100644 custom_addons/encoach_lms_api/controllers/paymob.py create mode 100644 custom_addons/encoach_lms_api/models/paymob_order.py diff --git a/custom_addons/encoach_ai/__manifest__.py b/custom_addons/encoach_ai/__manifest__.py index 1535e00d..c4929652 100644 --- a/custom_addons/encoach_ai/__manifest__.py +++ b/custom_addons/encoach_ai/__manifest__.py @@ -15,7 +15,7 @@ - AI Search, Insights, Report Narrative """, "author": "EnCoach", - "depends": ["base", "encoach_core"], + "depends": ["base", "encoach_core", "encoach_api"], "external_dependencies": { "python": ["openai", "boto3"], }, diff --git a/custom_addons/encoach_ai/controllers/__init__.py b/custom_addons/encoach_ai/controllers/__init__.py index fd28fabd..4520844b 100644 --- a/custom_addons/encoach_ai/controllers/__init__.py +++ b/custom_addons/encoach_ai/controllers/__init__.py @@ -1,3 +1,5 @@ from . import ai_controller from . import coach_controller from . import media_controller +from . import prompt_controller +from . import feedback_controller diff --git a/custom_addons/encoach_ai/controllers/ai_controller.py b/custom_addons/encoach_ai/controllers/ai_controller.py index c7ae3026..18655c9f 100644 --- a/custom_addons/encoach_ai/controllers/ai_controller.py +++ b/custom_addons/encoach_ai/controllers/ai_controller.py @@ -2,8 +2,9 @@ import json import logging -from odoo import http +from odoo import fields, http from odoo.http import request, Response +from odoo.addons.encoach_api.controllers.base import jwt_required _logger = logging.getLogger(__name__) @@ -27,7 +28,8 @@ class AIController(http.Controller): """Handles /api/ai/* endpoints consumed by frontend AI components.""" # ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ── - @http.route("/api/ai/search", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/ai/search", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_search(self, **kw): body = _get_json() query = body.get("query", "") @@ -43,7 +45,8 @@ class AIController(http.Controller): return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []}) # ── GET /api/ai/vector-search — pure semantic search without GPT ── - @http.route("/api/ai/vector-search", type="http", auth="public", methods=["GET"], csrf=False) + @http.route("/api/ai/vector-search", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required def ai_vector_search(self, **kw): query = request.params.get("q", "") content_type = request.params.get("content_type") @@ -60,7 +63,8 @@ class AIController(http.Controller): return _json_response({"results": [], "query": query, "error": str(e)}) # ── POST /api/ai/insights — AiInsightsPanel.tsx ── - @http.route("/api/ai/insights", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/ai/insights", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_insights(self, **kw): body = _get_json() try: @@ -76,7 +80,8 @@ class AIController(http.Controller): return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]}) # ── GET /api/ai/alerts — AiAlertBanner.tsx ── - @http.route("/api/ai/alerts", type="http", auth="public", methods=["GET"], csrf=False) + @http.route("/api/ai/alerts", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required def ai_alerts(self, **kw): try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService @@ -92,7 +97,8 @@ class AIController(http.Controller): return _json_response({"alerts": []}) # ── POST /api/ai/report-narrative — AiReportNarrative.tsx ── - @http.route("/api/ai/report-narrative", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/ai/report-narrative", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_report_narrative(self, **kw): body = _get_json() try: @@ -107,7 +113,8 @@ class AIController(http.Controller): return _json_response({"narrative": f"Report generation unavailable: {e}"}) # ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ── - @http.route("/api/ai/batch-optimize", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/ai/batch-optimize", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_batch_optimize(self, **kw): body = _get_json() try: @@ -122,7 +129,8 @@ class AIController(http.Controller): return _json_response({"optimized": [], "summary": str(e), "impact": "none"}) # ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ── - @http.route("/api/ai/grade-suggest", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/ai/grade-suggest", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_grade_suggest(self, **kw): body = _get_json() try: @@ -146,7 +154,8 @@ class AIController(http.Controller): return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []}) # ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ── - @http.route("/api/ai/generate-resource", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/ai/generate-resource", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_generate_resource(self, **kw): body = _get_json() try: @@ -162,7 +171,8 @@ class AIController(http.Controller): return _json_response({"resource": None, "status": "error", "error": str(e)}) # ── POST /api/ai/detect — GPTZero AI detection ── - @http.route("/api/ai/detect", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/ai/detect", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_detect(self, **kw): body = _get_json() try: @@ -174,7 +184,8 @@ class AIController(http.Controller): return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)}) # ── POST /api/plagiarism/check — plagiarism.service.ts ── - @http.route("/api/plagiarism/check", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/plagiarism/check", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def plagiarism_check(self, **kw): body = _get_json() try: @@ -187,7 +198,8 @@ class AIController(http.Controller): return _json_response({"report_id": None, "error": str(e)}) # ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ── - @http.route("/api/domains//ai-suggest", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/domains//ai-suggest", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_suggest_topics(self, domain_id, **kw): body = _get_json() try: @@ -206,7 +218,8 @@ class AIController(http.Controller): return _json_response({"topics": [], "error": str(e)}) # ── POST /api/learning-plan/generate — LearningPlan.tsx ── - @http.route("/api/learning-plan/generate", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/learning-plan/generate", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def learning_plan_generate(self, **kw): body = _get_json() try: @@ -227,7 +240,8 @@ class AIController(http.Controller): return _json_response({"plan": None, "error": str(e)}) # ── Workbench endpoints — AiWorkbench.tsx ── - @http.route("/api/workbench/generate-outline", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/workbench/generate-outline", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_outline(self, **kw): body = _get_json() try: @@ -244,7 +258,8 @@ class AIController(http.Controller): except Exception as e: return _json_response({"chapters": [], "error": str(e)}) - @http.route("/api/workbench/generate-chapter", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/workbench/generate-chapter", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_chapter(self, **kw): body = _get_json() try: @@ -262,7 +277,8 @@ class AIController(http.Controller): except Exception as e: return _json_response({"content": "", "error": str(e)}) - @http.route("/api/workbench/generate-rubric", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/workbench/generate-rubric", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_rubric(self, **kw): body = _get_json() try: @@ -280,11 +296,13 @@ class AIController(http.Controller): except Exception as e: return _json_response({"rubric": None, "error": str(e)}) - @http.route("/api/workbench/regenerate", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/workbench/regenerate", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_regenerate(self, **kw): return self.workbench_chapter(**kw) - @http.route("/api/workbench/publish", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/workbench/publish", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_publish(self, **kw): body = _get_json() try: @@ -315,7 +333,8 @@ class AIController(http.Controller): return _json_response({"status": "error", "error": str(e)}, 500) # ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ── - @http.route("/api/ai/suggest-rubric-criteria", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/ai/suggest-rubric-criteria", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_suggest_rubric_criteria(self, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() @@ -450,7 +469,8 @@ class AIController(http.Controller): ] # ── Exam generation — GenerationPage.tsx ── - @http.route("/api/exam//generate", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/exam//generate", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def exam_generate(self, module, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() @@ -1252,7 +1272,8 @@ class AIController(http.Controller): return {"questions": questions} # ── POST /api/exam/generation/submit — create exam from generation page ── - @http.route("/api/exam/generation/submit", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/exam/generation/submit", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def generation_submit(self, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() @@ -1265,12 +1286,40 @@ class AIController(http.Controller): if not title: return _json_response({"error": "title is required"}, 400) + from odoo.addons.encoach_ai.services.question_validator import ( + validate_payload, score_question, summarize_question_reports, + dumps_report, prompt_hash as _prompt_hash, + VERDICT_FAIL, VERDICT_WARN, + ) + + schema_report = validate_payload(body) + if schema_report["verdict"] == VERDICT_FAIL: + _logger.warning( + "generation_submit rejected %d schema errors: %s", + len(schema_report["errors"]), schema_report["errors"][:3], + ) + return _json_response({ + "error": "AI payload failed schema validation", + "details": schema_report, + }, 422) + label = body.get("label", "") modules = body.get("modules", {}) skip_approval = body.get("skip_approval", False) exam_mode = body.get("exam_mode", "official") structure_id = body.get("structure_id") + ai_model_used = ( + body.get("ai_model") + or request.env["ir.config_parameter"].sudo().get_param( + "encoach_ai.openai_fast_model", "gpt-4o-mini" + ) + ) + payload_hash = _prompt_hash(json.dumps( + {"title": title, "modules": modules}, sort_keys=True, default=str, + )) + question_reports = [] # collected for aggregate gating below + first_mod = next(iter(modules.values()), {}) if modules else {} entity_val = first_mod.get("entity", "none") entity_id = int(entity_val) if entity_val and entity_val != "none" else False @@ -1304,13 +1353,14 @@ 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" exam_vals = { "title": title, "label": label, "exam_mode": exam_mode, "teacher_id": request.env.user.id, "template_id": template_id, - "status": "published" if skip_approval else "draft", + "status": initial_status, "total_time_min": sum(m.get("timer", 0) for m in modules.values()), "total_marks": sum(float(m.get("totalMarks", 0)) for m in modules.values()), "randomize_questions": any(m.get("shuffling", False) for m in modules.values()), @@ -1344,6 +1394,27 @@ class AIController(http.Controller): "form_completion": "form_completion", "map_labelling": "map_labelling", } + now = fields.Datetime.now() if hasattr(fields, 'Datetime') else None + + def _create_question(vals, *, cefr_level=None): + stem = vals.get("stem") or "" + q_report = score_question( + stem, skill=vals.get("skill"), cefr_level=cefr_level + ) + question_reports.append(q_report) + status = vals.pop("status", "draft") + if q_report["verdict"] == VERDICT_FAIL: + status = "flagged" + vals.update({ + "status": status, + "ai_model_used": ai_model_used, + "ai_prompt_hash": payload_hash, + "ai_generated_at": now, + "quality_score": q_report.get("score"), + "quality_report": dumps_report(q_report), + }) + return Question.create(vals) + seq = 10 total_questions = 0 for mod_key, mod_data in modules.items(): @@ -1381,7 +1452,7 @@ class AIController(http.Controller): for ex in (passage.get("exercises") or []): q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq") opts = ex.get("options", []) - q = Question.create({ + q = _create_question({ "skill": mod_key if mod_key in ("reading", "listening", "writing", "speaking", "grammar", "vocabulary", "math", "it") else "reading", "source_type": "passage", "question_type": q_type, @@ -1392,7 +1463,7 @@ class AIController(http.Controller): "difficulty": _q_difficulty_for(ex), "status": "active", "ai_generated": True, - }) + }, cefr_level=(ex.get("cefr_level") or cefr_level)) question_ids.append(q.id) sections_data = mod_data.get("sections") or [] @@ -1402,7 +1473,7 @@ class AIController(http.Controller): for ex in (s_data.get("exercises") or []): q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq") opts = ex.get("options", []) - q = Question.create({ + q = _create_question({ "skill": "listening", "source_type": "audio", "question_type": q_type, @@ -1413,12 +1484,12 @@ class AIController(http.Controller): "difficulty": _q_difficulty_for(ex), "status": "active", "ai_generated": True, - }) + }, cefr_level=(ex.get("cefr_level") or cefr_level)) question_ids.append(q.id) tasks = mod_data.get("tasks") or [] for t_idx, task in enumerate(tasks): - q = Question.create({ + q = _create_question({ "skill": "writing", "source_type": "writing_prompt", "question_type": "short_answer", @@ -1429,12 +1500,12 @@ class AIController(http.Controller): "difficulty": q_difficulty, "status": "active", "ai_generated": True, - }) + }, cefr_level=cefr_level) question_ids.append(q.id) parts = mod_data.get("parts") or [] for p_idx, part in enumerate(parts): - q = Question.create({ + q = _create_question({ "skill": "speaking", "source_type": "speaking_card", "question_type": "short_answer", @@ -1445,7 +1516,7 @@ class AIController(http.Controller): "difficulty": q_difficulty, "status": "active", "ai_generated": True, - }) + }, cefr_level=cefr_level) question_ids.append(q.id) if question_ids: @@ -1455,18 +1526,37 @@ class AIController(http.Controller): }) total_questions += len(question_ids) + quality_summary = summarize_question_reports(question_reports) + if ( + exam.status != "draft" + and quality_summary["verdict"] in (VERDICT_FAIL, VERDICT_WARN) + and quality_summary["total"] > 0 + ): + exam.sudo().write({"status": "pending_review"}) + _logger.info( + "exam %s forced pending_review: avg_score=%.2f failed=%d warned=%d", + exam.id, quality_summary["avg_score"], + quality_summary["failed"], quality_summary["warned"], + ) + return _json_response({ "exam_id": exam.id, "status": exam.status, "template_id": template_id, "total_questions": total_questions, + "quality": quality_summary, + "schema_validation": { + "verdict": schema_report["verdict"], + "warnings": schema_report["warnings"], + }, }, 201) except Exception as e: _logger.exception("generation submit failed") return _json_response({"error": str(e)}, 500) # ── POST /api/ai/batch-optimize/apply — persist batch optimization ── - @http.route("/api/ai/batch-optimize/apply", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/ai/batch-optimize/apply", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_batch_optimize_apply(self, **kw): body = _get_json() optimized = body.get("optimized", []) @@ -1481,7 +1571,8 @@ class AIController(http.Controller): return _json_response({"applied": 0, "error": str(e)}, 500) # ── POST /api/exam//generate/save — save generated exam items ── - @http.route("/api/exam//generate/save", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/exam//generate/save", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def exam_generate_save(self, module, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() @@ -1521,7 +1612,8 @@ class AIController(http.Controller): return _json_response({"saved": 0, "error": str(e)}, 500) # ── POST /api/workbench/suggest-materials — AI material suggestions ── - @http.route("/api/workbench/suggest-materials", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/workbench/suggest-materials", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def workbench_suggest_materials(self, **kw): body = _get_json() try: @@ -1541,7 +1633,8 @@ class AIController(http.Controller): return _json_response({"materials": [], "error": str(e)}) # ── Topic content generation — adaptive ── - @http.route("/api/topics//generate-content", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/topics//generate-content", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def topic_generate_content(self, topic_id, **kw): body = _get_json() try: diff --git a/custom_addons/encoach_ai/controllers/coach_controller.py b/custom_addons/encoach_ai/controllers/coach_controller.py index 4f0a988b..340d001f 100644 --- a/custom_addons/encoach_ai/controllers/coach_controller.py +++ b/custom_addons/encoach_ai/controllers/coach_controller.py @@ -4,6 +4,10 @@ import json import logging from odoo import http from odoo.http import request, Response +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response as _base_json_response, _get_json_body, +) + _logger = logging.getLogger(__name__) @@ -27,7 +31,8 @@ class CoachController(http.Controller): return CoachService(request.env) # ── POST /api/coach/chat — AiAssistantDrawer.tsx ── - @http.route("/api/coach/chat", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def coach_chat(self, **kw): body = _get_json() try: @@ -43,7 +48,8 @@ class CoachController(http.Controller): return _json_response({"reply": f"I'm having trouble right now. Error: {e}"}) # ── GET /api/coach/tip — AiTipBanner.tsx ── - @http.route("/api/coach/tip", type="http", auth="public", methods=["GET"], csrf=False) + @http.route("/api/coach/tip", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required def coach_tip(self, **kw): context = request.params.get("context", "general") try: @@ -53,7 +59,8 @@ class CoachController(http.Controller): return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"}) # ── POST /api/coach/explain — AiGradeExplainer.tsx ── - @http.route("/api/coach/explain", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/coach/explain", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def coach_explain(self, **kw): body = _get_json() try: @@ -67,7 +74,8 @@ class CoachController(http.Controller): return _json_response({"explanation": f"Could not generate explanation: {e}"}) # ── POST /api/coach/suggest — AiStudyCoach.tsx ── - @http.route("/api/coach/suggest", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/coach/suggest", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def coach_suggest(self, **kw): body = _get_json() try: @@ -82,7 +90,8 @@ class CoachController(http.Controller): }) # ── POST /api/coach/writing-help — AiWritingHelper.tsx ── - @http.route("/api/coach/writing-help", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/coach/writing-help", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def coach_writing_help(self, **kw): body = _get_json() try: @@ -97,7 +106,8 @@ class CoachController(http.Controller): return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]}) # ── POST /api/coach/hint — (unused component, wired for completeness) ── - @http.route("/api/coach/hint", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/coach/hint", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def coach_hint(self, **kw): body = _get_json() try: diff --git a/custom_addons/encoach_ai/controllers/feedback_controller.py b/custom_addons/encoach_ai/controllers/feedback_controller.py new file mode 100644 index 00000000..2cf2cd24 --- /dev/null +++ b/custom_addons/encoach_ai/controllers/feedback_controller.py @@ -0,0 +1,217 @@ +"""Endpoints for submitting & aggregating AI feedback.""" + +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 _feedback_to_dict(row): + return { + "id": row.id, + "subject_type": row.subject_type, + "subject_id": row.subject_id, + "subject_key": row.subject_key, + "rating": row.rating, + "comment": row.comment or "", + "tags": (row.tags or "").split(",") if row.tags else [], + "prompt_key": row.prompt_key or None, + "prompt_version": row.prompt_version or None, + "ai_log_id": row.ai_log_id.id if row.ai_log_id else None, + "user_id": row.user_id.id, + "user_name": row.user_id.name, + "entity_id": row.entity_id.id if row.entity_id else None, + "course_id": row.course_id.id if row.course_id else None, + "status": row.status, + "create_date": ( + fields.Datetime.to_string(row.create_date) if row.create_date else None + ), + "resolved_at": ( + fields.Datetime.to_string(row.resolved_at) if row.resolved_at else None + ), + "resolution_notes": row.resolution_notes or "", + } + + +class EncoachAIFeedbackController(http.Controller): + """Feedback write + admin triage endpoints.""" + + # ------------------------------------------------------------------ + # POST /api/ai/feedback — student submits/updates feedback + # + # Upsert semantics: we enforce uniqueness per (user, subject_type, + # subject_id) at the DB layer, so the client doesn't need to know whether + # the row already exists. + # ------------------------------------------------------------------ + @http.route("/api/ai/feedback", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required + def submit(self, **kw): + try: + body = _get_json_body() or {} + subject_type = body.get("subject_type") + subject_id = body.get("subject_id") + rating = body.get("rating") + if subject_type not in { + "question", "coach", "explanation", "translation", "narrative", "other", + }: + return _error_response("Invalid subject_type", 400) + if not isinstance(subject_id, int) or subject_id <= 0: + return _error_response("subject_id must be a positive integer", 400) + if rating not in {"up", "down"}: + return _error_response("rating must be 'up' or 'down'", 400) + comment = body.get("comment") or "" + if rating == "down" and not (comment or "").strip(): + # We keep this a soft constraint so the UI can decide how + # strict to be; returning an error here gives clients a clear + # path to prompt the user when needed. + return _error_response( + "A short comment is required when rating is 'down'.", 400, + ) + + vals = { + "subject_type": subject_type, + "subject_id": subject_id, + "rating": rating, + "comment": comment, + "tags": ( + ",".join(body.get("tags") or []) + if isinstance(body.get("tags"), list) + else (body.get("tags") or "") + ), + "prompt_key": body.get("prompt_key") or False, + "prompt_version": body.get("prompt_version") or 0, + "ai_log_id": body.get("ai_log_id") or False, + "entity_id": body.get("entity_id") or False, + "course_id": body.get("course_id") or False, + "user_id": request.env.user.id, + } + + Feedback = request.env["encoach.ai.feedback"].sudo() + existing = Feedback.search([ + ("user_id", "=", request.env.user.id), + ("subject_type", "=", subject_type), + ("subject_id", "=", subject_id), + ], limit=1) + with request.env.cr.savepoint(): + if existing: + existing.write(vals) + row = existing + else: + row = Feedback.create(vals) + return _json_response(_feedback_to_dict(row), 200 if existing else 201) + except Exception as e: + _logger.exception("ai feedback submit failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai/feedback/summary?subject_type=question&subject_id=42 + # ------------------------------------------------------------------ + @http.route( + "/api/ai/feedback/summary", type="http", auth="none", methods=["GET"], csrf=False, + ) + @jwt_required + def summary(self, subject_type=None, subject_id=None, **kw): + try: + if not subject_type or not subject_id: + return _error_response("subject_type and subject_id are required", 400) + Feedback = request.env["encoach.ai.feedback"].sudo() + domain = [ + ("subject_type", "=", subject_type), + ("subject_id", "=", int(subject_id)), + ] + rows = Feedback.search(domain) + up = sum(1 for r in rows if r.rating == "up") + down = sum(1 for r in rows if r.rating == "down") + my_row = Feedback.search([ + *domain, + ("user_id", "=", request.env.user.id), + ], limit=1) + return _json_response({ + "subject_type": subject_type, + "subject_id": int(subject_id), + "up": up, + "down": down, + "total": up + down, + "my_rating": my_row.rating if my_row else None, + "my_comment": my_row.comment or "" if my_row else "", + }) + except Exception as e: + _logger.exception("ai feedback summary failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai/feedback — admin triage list + # ------------------------------------------------------------------ + @http.route("/api/ai/feedback", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required + def list_feedback(self, **kw): + try: + if not request.env.user.has_group("base.group_system"): + return _error_response("Admin privileges required", 403) + Feedback = request.env["encoach.ai.feedback"].sudo() + domain = [] + if kw.get("status"): + domain.append(("status", "=", kw["status"])) + if kw.get("rating"): + domain.append(("rating", "=", kw["rating"])) + if kw.get("subject_type"): + domain.append(("subject_type", "=", kw["subject_type"])) + if kw.get("prompt_key"): + domain.append(("prompt_key", "=", kw["prompt_key"])) + offset, limit, page = _paginate(kw) + total = Feedback.search_count(domain) + rows = Feedback.search(domain, offset=offset, limit=limit, order="create_date desc") + items = [_feedback_to_dict(r) for r in rows] + return _json_response({ + "items": items, + "data": items, + "total": total, + "page": page, + "size": limit, + }) + except Exception as e: + _logger.exception("ai feedback list failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai/feedback//resolve — admin triage + # ------------------------------------------------------------------ + @http.route( + '/api/ai/feedback//resolve', + type="http", auth="none", methods=["POST"], csrf=False, + ) + @jwt_required + def resolve(self, feedback_id, **kw): + try: + if not request.env.user.has_group("base.group_system"): + return _error_response("Admin privileges required", 403) + body = _get_json_body() or {} + status = body.get("status") + if status not in {"acknowledged", "fixed", "dismissed"}: + return _error_response( + "status must be one of acknowledged|fixed|dismissed", 400, + ) + row = request.env["encoach.ai.feedback"].sudo().browse(feedback_id) + if not row.exists(): + return _error_response("Feedback not found", 404) + with request.env.cr.savepoint(): + row.write({ + "status": status, + "resolved_by_id": request.env.user.id, + "resolved_at": fields.Datetime.now(), + "resolution_notes": body.get("notes") or "", + }) + return _json_response(_feedback_to_dict(row)) + except Exception as e: + _logger.exception("ai feedback resolve failed") + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_ai/controllers/media_controller.py b/custom_addons/encoach_ai/controllers/media_controller.py index bee168b9..fa5b03ee 100644 --- a/custom_addons/encoach_ai/controllers/media_controller.py +++ b/custom_addons/encoach_ai/controllers/media_controller.py @@ -5,6 +5,10 @@ import json import logging from odoo import http from odoo.http import request, Response +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response as _base_json_response, _get_json_body, +) + _logger = logging.getLogger(__name__) @@ -52,7 +56,8 @@ class MediaController(http.Controller): ) # ── POST /api/exam/listening/media — generate listening audio ── - @http.route("/api/exam/listening/media", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/exam/listening/media", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def listening_media(self, **kw): body = _get_json() text = body.get("text", "") @@ -72,7 +77,8 @@ class MediaController(http.Controller): return _json_response({"error": str(e)}, 500) # ── POST /api/exam/speaking/media — generate speaking prompt audio ── - @http.route("/api/exam/speaking/media", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/exam/speaking/media", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def speaking_media(self, **kw): body = _get_json() text = body.get("text", "") @@ -89,7 +95,8 @@ class MediaController(http.Controller): return _json_response({"error": str(e)}, 500) # ── GET /api/exam/avatars — list ELAI avatars ── - @http.route("/api/exam/avatars", type="http", auth="public", methods=["GET"], csrf=False) + @http.route("/api/exam/avatars", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required def list_avatars(self, **kw): try: from odoo.addons.encoach_ai.services.elai_service import ElaiService @@ -100,7 +107,8 @@ class MediaController(http.Controller): return _json_response({"avatars": [], "note": str(e)}) # ── POST /api/exam/avatar/video — create avatar video ── - @http.route("/api/exam/avatar/video", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/exam/avatar/video", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def create_avatar_video(self, **kw): body = _get_json() try: @@ -116,7 +124,8 @@ class MediaController(http.Controller): return _json_response({"error": str(e)}, 500) # ── GET /api/exam/avatar/video/:id — check video status ── - @http.route("/api/exam/avatar/video/", type="http", auth="public", methods=["GET"], csrf=False) + @http.route("/api/exam/avatar/video/", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required def video_status(self, video_id, **kw): try: from odoo.addons.encoach_ai.services.elai_service import ElaiService @@ -126,7 +135,8 @@ class MediaController(http.Controller): return _json_response({"video_id": video_id, "status": "error", "error": str(e)}) # ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ── - @http.route("/api/courses/ai-generate", type="http", auth="public", methods=["POST"], csrf=False) + @http.route("/api/courses/ai-generate", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required def ai_generate_course(self, **kw): body = _get_json() try: diff --git a/custom_addons/encoach_ai/controllers/prompt_controller.py b/custom_addons/encoach_ai/controllers/prompt_controller.py new file mode 100644 index 00000000..63e861fa --- /dev/null +++ b/custom_addons/encoach_ai/controllers/prompt_controller.py @@ -0,0 +1,219 @@ +"""Admin endpoints for the versioned AI prompt editor.""" + +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 _prompt_to_dict(prompt, *, include_content=True): + data = { + "id": prompt.id, + "key": prompt.key, + "version": prompt.version, + "title": prompt.title or "", + "description": prompt.description or "", + "is_active": bool(prompt.is_active), + "variables": (prompt.variables or "").split(",") if prompt.variables else [], + "author_id": prompt.author_id.id if prompt.author_id else None, + "author_name": prompt.author_id.name if prompt.author_id else "", + "activated_at": ( + fields.Datetime.to_string(prompt.activated_at) if prompt.activated_at else None + ), + "create_date": ( + fields.Datetime.to_string(prompt.create_date) if prompt.create_date else None + ), + } + if include_content: + data["content"] = prompt.content or "" + return data + + +class EncoachAIPromptController(http.Controller): + """Versioned prompt CRUD. + + Auth: every endpoint requires a valid JWT. The write endpoints additionally + check for admin rights via ``res.users.has_group('base.group_system')`` so + non-admins can read prompts but not mutate them. + """ + + # ------------------------------------------------------------------ + # GET /api/ai/prompts — list keys (latest version per key) + # ------------------------------------------------------------------ + @http.route("/api/ai/prompts", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required + def list_keys(self, **kw): + try: + Prompt = request.env["encoach.ai.prompt"].sudo() + # One row per key = the latest version. We fetch candidates then + # deduplicate in Python because Odoo's ORM doesn't expose DISTINCT + # ON ergonomically. + search = (kw.get("search") or "").strip() + domain = [("key", "ilike", search)] if search else [] + # Sort by key then descending version so the first occurrence per + # key is the latest. + all_prompts = Prompt.search(domain, order="key asc, version desc") + seen = set() + latest = [] + for p in all_prompts: + if p.key in seen: + continue + seen.add(p.key) + latest.append(p) + offset, per_page, page = _paginate(kw) + total = len(latest) + window = latest[offset : offset + per_page] + items = [_prompt_to_dict(p, include_content=False) for p in window] + # Attach version-count for the UI to show "v5 (of 7)" hints. + counts = {} + for p in all_prompts: + counts[p.key] = counts.get(p.key, 0) + 1 + for entry in items: + entry["total_versions"] = counts.get(entry["key"], 1) + return _json_response({ + "items": items, + "data": items, + "total": total, + "page": page, + "size": per_page, + }) + except Exception as e: + _logger.exception("list ai prompts failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai/prompts//versions + # ------------------------------------------------------------------ + @http.route( + '/api/ai/prompts//versions', + type="http", auth="none", methods=["GET"], csrf=False, + ) + @jwt_required + def list_versions(self, key, **kw): + try: + Prompt = request.env["encoach.ai.prompt"].sudo() + prompts = Prompt.search([("key", "=", key)], order="version desc") + if not prompts: + return _error_response("Prompt key not found", 404) + items = [_prompt_to_dict(p, include_content=False) for p in prompts] + return _json_response({ + "key": key, + "items": items, + "data": items, + "total": len(items), + "page": 1, + "size": len(items), + }) + except Exception as e: + _logger.exception("list prompt versions failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai/prompts/ + # ------------------------------------------------------------------ + @http.route( + '/api/ai/prompts/', + type="http", auth="none", methods=["GET"], csrf=False, + ) + @jwt_required + def get_version(self, prompt_id, **kw): + try: + prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id) + if not prompt.exists(): + return _error_response("Prompt not found", 404) + return _json_response(_prompt_to_dict(prompt)) + except Exception as e: + _logger.exception("get prompt failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai/prompts — create a new version of a key (or a new key) + # ------------------------------------------------------------------ + @http.route("/api/ai/prompts", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required + def create_version(self, **kw): + try: + if not request.env.user.has_group("base.group_system"): + return _error_response("Admin privileges required", 403) + + body = _get_json_body() or {} + key = (body.get("key") or "").strip() + content = body.get("content") or "" + title = (body.get("title") or "").strip() + if not key or not content.strip() or not title: + return _error_response( + "key, title, and content are required", 400, + ) + + vals = { + "key": key, + "title": title, + "description": body.get("description") or "", + "content": content, + "is_active": bool(body.get("activate", False)), + } + with request.env.cr.savepoint(): + prompt = request.env["encoach.ai.prompt"].sudo().create(vals) + return _json_response(_prompt_to_dict(prompt), 201) + except Exception as e: + _logger.exception("create prompt version failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai/prompts//activate + # ------------------------------------------------------------------ + @http.route( + '/api/ai/prompts//activate', + type="http", auth="none", methods=["POST"], csrf=False, + ) + @jwt_required + def activate(self, prompt_id, **kw): + try: + if not request.env.user.has_group("base.group_system"): + return _error_response("Admin privileges required", 403) + prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id) + if not prompt.exists(): + return _error_response("Prompt not found", 404) + with request.env.cr.savepoint(): + prompt.write({"is_active": True}) + return _json_response(_prompt_to_dict(prompt)) + except Exception as e: + _logger.exception("activate prompt failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai/prompts//render — dry-run render with sample variables + # ------------------------------------------------------------------ + @http.route( + '/api/ai/prompts//render', + type="http", auth="none", methods=["POST"], csrf=False, + ) + @jwt_required + def render_preview(self, prompt_id, **kw): + try: + prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id) + if not prompt.exists(): + return _error_response("Prompt not found", 404) + body = _get_json_body() or {} + variables = body.get("variables") or {} + if not isinstance(variables, dict): + return _error_response("variables must be a JSON object", 400) + rendered = prompt.render(variables) + return _json_response({ + "rendered": rendered, + "key": prompt.key, + "version": prompt.version, + }) + except Exception as e: + _logger.exception("render prompt failed") + return _error_response(str(e), 400) diff --git a/custom_addons/encoach_ai/models/__init__.py b/custom_addons/encoach_ai/models/__init__.py index 25499608..3f35177f 100644 --- a/custom_addons/encoach_ai/models/__init__.py +++ b/custom_addons/encoach_ai/models/__init__.py @@ -1,3 +1,5 @@ from . import ai_settings from . import ai_log +from . import ai_prompt +from . import ai_feedback from . import constants diff --git a/custom_addons/encoach_ai/models/ai_feedback.py b/custom_addons/encoach_ai/models/ai_feedback.py new file mode 100644 index 00000000..ea5cf00d --- /dev/null +++ b/custom_addons/encoach_ai/models/ai_feedback.py @@ -0,0 +1,134 @@ +"""Student feedback on AI-generated content (thumbs up/down). + +This closes the AI quality loop started by the human-review workflow (P3.3) +and the versioned prompts (P3.4): + +* Every AI-generated artefact (a question, a coach reply, an explanation, a + translation, ...) can have any number of feedback rows attached to it. +* Feedback is always coupled to a **subject type + subject id** so it can + point at anything (``encoach.question``, ``encoach.ai.log``, ...) without + forcing a hard FK per model. +* When the student flags feedback as negative, we also store their free-text + note so product can triage. + +Downstream consumers (prompt library, RAG indexers, report dashboards) can +read ``encoach.ai.feedback`` to compute win rates per prompt key, per model, +per entity, etc. +""" + +from __future__ import annotations + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class EncoachAIFeedback(models.Model): + _name = "encoach.ai.feedback" + _description = "Student feedback on AI-generated content" + _order = "create_date desc" + _rec_name = "subject_key" + + # ------------------------------------------------------------------ + # What was rated + # ------------------------------------------------------------------ + subject_type = fields.Selection( + [ + ("question", "Exam question"), + ("coach", "AI Coach reply"), + ("explanation", "Content explanation"), + ("translation", "Translation"), + ("narrative", "Report narrative"), + ("other", "Other AI output"), + ], + required=True, index=True, + ) + subject_id = fields.Integer( + required=True, index=True, + help="Id of the rated artefact (semantics depend on subject_type).", + ) + subject_key = fields.Char( + compute="_compute_subject_key", store=True, index=True, + help="Denormalised ':' key for quick lookups/aggregations.", + ) + + # Optional link to the AI call that produced this artefact. When set, it + # lets us correlate feedback with the underlying model/prompt used. + ai_log_id = fields.Many2one( + "encoach.ai.log", ondelete="set null", index=True, + ) + prompt_key = fields.Char( + index=True, + help="If the artefact was produced via encoach.ai.prompt, record the " + "key here so we can compute win-rates per prompt.", + ) + prompt_version = fields.Integer() + + # ------------------------------------------------------------------ + # Rating + # ------------------------------------------------------------------ + rating = fields.Selection( + [("up", "Thumbs up"), ("down", "Thumbs down")], + required=True, index=True, + ) + comment = fields.Text( + help="Free-text note. Required when rating is 'down' and the UI " + "prompts the student for a reason.", + ) + tags = fields.Char( + help="Comma-separated tags (e.g. 'wrong-answer,confusing-stem').", + ) + + # ------------------------------------------------------------------ + # Who / context + # ------------------------------------------------------------------ + user_id = fields.Many2one( + "res.users", required=True, index=True, + default=lambda self: self.env.user, + ) + entity_id = fields.Many2one( + "encoach.entity", ondelete="set null", index=True, + ) + course_id = fields.Many2one( + "encoach.course", ondelete="set null", index=True, + ) + + # ------------------------------------------------------------------ + # Triage status (filled in by admins/coaches reviewing feedback). + # ------------------------------------------------------------------ + status = fields.Selection( + [ + ("open", "Open"), + ("acknowledged", "Acknowledged"), + ("fixed", "Fixed"), + ("dismissed", "Dismissed"), + ], + default="open", index=True, + ) + resolved_by_id = fields.Many2one( + "res.users", ondelete="set null", + ) + resolved_at = fields.Datetime() + resolution_notes = fields.Text() + + # ------------------------------------------------------------------ + # Constraints + # ------------------------------------------------------------------ + _sql_constraints = [ + ( + "uniq_user_subject", + "unique(user_id, subject_type, subject_id)", + "A user can only leave one feedback row per AI artefact. " + "Subsequent submissions should update the existing row.", + ), + ] + + @api.depends("subject_type", "subject_id") + def _compute_subject_key(self): + for rec in self: + rec.subject_key = f"{rec.subject_type or ''}:{rec.subject_id or 0}" + + @api.constrains("subject_type", "subject_id") + def _check_subject(self): + for rec in self: + if not rec.subject_type or not rec.subject_id: + raise ValidationError("subject_type and subject_id are required.") diff --git a/custom_addons/encoach_ai/models/ai_prompt.py b/custom_addons/encoach_ai/models/ai_prompt.py new file mode 100644 index 00000000..8d711941 --- /dev/null +++ b/custom_addons/encoach_ai/models/ai_prompt.py @@ -0,0 +1,213 @@ +"""Versioned AI prompt templates. + +Context (ADR-0004, P3.4 hardening release): we want non-engineers to iterate +on LLM prompts without shipping code. Every time an author edits a prompt we +keep the previous revision around so we can A/B test, audit, and roll back. + +Design: + +* A **prompt** is identified by its ``key`` (e.g. ``exam.mcq.generate``) — a + stable, dotted string that callers reference from Python. +* Each save produces a new **version** row (bumped ``version`` int). The old + rows stay with ``is_active = False`` so history is preserved; only one row + per ``key`` can be active at a time (enforced in ``write``/``create``). +* Callers look the prompt up with ``get_active(key)`` and render it with + ``render(variables)``. Rendering uses :py:meth:`str.format_map` so templates + are just ``{variable}`` placeholders — no new DSL to learn. + +This module intentionally does **not** call the LLM itself. It is a +content-management layer that the existing ``encoach_ai.services.*`` pipelines +can adopt at their own pace (falling back to their hard-coded strings until a +prompt with the matching key is created in the UI). +""" + +from __future__ import annotations + +import logging +import re +from typing import Iterable + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + +# Keys look like ``namespace.sub_namespace.name`` — lowercase, dotted. +_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") +# Variables pulled from ``{foo}`` / ``{foo.bar}`` placeholders. +_VAR_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_.]*)\}") + + +class EncoachAIPrompt(models.Model): + _name = "encoach.ai.prompt" + _description = "Versioned AI prompt template" + _order = "key, version desc" + _rec_name = "display_name" + + key = fields.Char( + required=True, + index=True, + help="Stable dotted identifier (e.g. 'exam.mcq.generate'). " + "Callers reference this from Python.", + ) + version = fields.Integer(required=True, default=1, index=True) + title = fields.Char( + required=True, + help="Short human label shown in the admin editor.", + ) + description = fields.Text( + help="Author intent, expected variables, known limitations.", + ) + content = fields.Text( + required=True, + help="Template body. Use {variable} placeholders; " + "render() fills them via str.format_map.", + ) + is_active = fields.Boolean( + default=False, index=True, + help="Exactly one active row per key. Creating/activating a new row " + "automatically deactivates the previous active version.", + ) + author_id = fields.Many2one("res.users", ondelete="set null", readonly=True) + activated_at = fields.Datetime(readonly=True) + + # Denormalised for quick display/search. Recomputed on save. + variables = fields.Char( + compute="_compute_variables", store=True, + help="Comma-separated list of {variable} placeholders found in content.", + ) + display_name = fields.Char( + compute="_compute_display_name", store=True, + ) + + _sql_constraints = [ + ( + "key_version_uniq", + "unique(key, version)", + "A prompt version must be unique for its key.", + ), + ] + + # ------------------------------------------------------------------ + # Computed fields + # ------------------------------------------------------------------ + @api.depends("content") + def _compute_variables(self): + for rec in self: + if not rec.content: + rec.variables = "" + continue + found = sorted(set(_VAR_RE.findall(rec.content))) + rec.variables = ",".join(found) + + @api.depends("key", "version", "title") + def _compute_display_name(self): + for rec in self: + rec.display_name = f"{rec.key} v{rec.version} — {rec.title or ''}".rstrip(" —") + + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + @api.constrains("key") + def _check_key_shape(self): + for rec in self: + if not rec.key or not _KEY_RE.match(rec.key): + raise ValidationError( + f"Invalid prompt key {rec.key!r}. " + "Use lowercase dotted identifiers like 'exam.mcq.generate'." + ) + + @api.constrains("content") + def _check_content_non_empty(self): + for rec in self: + if not (rec.content or "").strip(): + raise ValidationError("Prompt content cannot be empty.") + + # ------------------------------------------------------------------ + # Create / write — enforce "one active per key" + # ------------------------------------------------------------------ + @api.model_create_multi + def create(self, vals_list): + # Default version = max(existing) + 1 when caller omits it. + for vals in vals_list: + if "version" not in vals or not vals.get("version"): + key = vals.get("key") + existing = self.search( + [("key", "=", key)], order="version desc", limit=1, + ) + vals["version"] = (existing.version + 1) if existing else 1 + vals.setdefault("author_id", self.env.user.id) + if vals.get("is_active"): + vals.setdefault("activated_at", fields.Datetime.now()) + records = super().create(vals_list) + # Post-create: enforce exclusivity for any rows created as active. + for rec in records: + if rec.is_active: + rec._deactivate_siblings() + return records + + def write(self, vals): + res = super().write(vals) + if vals.get("is_active"): + now = fields.Datetime.now() + for rec in self: + if rec.is_active and not rec.activated_at: + super(EncoachAIPrompt, rec).write({"activated_at": now}) + rec._deactivate_siblings() + return res + + def _deactivate_siblings(self): + for rec in self: + if not rec.is_active: + continue + siblings = self.search([ + ("key", "=", rec.key), + ("id", "!=", rec.id), + ("is_active", "=", True), + ]) + if siblings: + siblings.write({"is_active": False}) + + # ------------------------------------------------------------------ + # Public API (called by pipelines / controllers) + # ------------------------------------------------------------------ + @api.model + def get_active(self, key: str): + """Return the active prompt record for ``key`` (or an empty set).""" + return self.sudo().search( + [("key", "=", key), ("is_active", "=", True)], limit=1, + ) + + def render(self, variables: dict | None = None) -> str: + """Fill ``{placeholders}`` using ``variables`` (dict).""" + self.ensure_one() + variables = variables or {} + missing = self._missing_variables(variables.keys()) + if missing: + raise UserError( + f"Missing prompt variables for {self.key} v{self.version}: " + f"{', '.join(sorted(missing))}" + ) + try: + return self.content.format_map(_SafeFormatDict(variables)) + except Exception as exc: + _logger.exception("prompt render failed for %s v%s", self.key, self.version) + raise UserError(f"Prompt render failed: {exc}") from exc + + def _missing_variables(self, provided: Iterable[str]) -> set[str]: + self.ensure_one() + required = set(_VAR_RE.findall(self.content or "")) + provided_root = {p.split(".")[0] for p in provided} + return {v for v in required if v.split(".")[0] not in provided_root} + + +class _SafeFormatDict(dict): + """Dict that returns ``''`` for dotted/nested lookups str.format can't resolve. + + We don't advertise ``{foo.bar}`` as supported (the editor shows top-level + variables only), but we also don't want rendering to explode if a caller + passes an object with attributes. + """ + + def __missing__(self, key): + return "{" + key + "}" diff --git a/custom_addons/encoach_ai/security/ir.model.access.csv b/custom_addons/encoach_ai/security/ir.model.access.csv index 1c0fd811..958f4e13 100644 --- a/custom_addons/encoach_ai/security/ir.model.access.csv +++ b/custom_addons/encoach_ai/security/ir.model.access.csv @@ -1,3 +1,7 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_ai_log_admin,encoach.ai.log admin,model_encoach_ai_log,base.group_system,1,1,1,1 access_ai_log_user,encoach.ai.log user,model_encoach_ai_log,base.group_user,1,0,1,0 +access_ai_prompt_admin,encoach.ai.prompt admin,model_encoach_ai_prompt,base.group_system,1,1,1,1 +access_ai_prompt_user,encoach.ai.prompt user,model_encoach_ai_prompt,base.group_user,1,0,0,0 +access_ai_feedback_admin,encoach.ai.feedback admin,model_encoach_ai_feedback,base.group_system,1,1,1,1 +access_ai_feedback_user,encoach.ai.feedback user,model_encoach_ai_feedback,base.group_user,1,1,1,0 diff --git a/custom_addons/encoach_ai/services/__init__.py b/custom_addons/encoach_ai/services/__init__.py index 80fcb889..2e7b8ce7 100644 --- a/custom_addons/encoach_ai/services/__init__.py +++ b/custom_addons/encoach_ai/services/__init__.py @@ -5,3 +5,5 @@ from .elevenlabs_service import ElevenLabsService from .gptzero_service import GPTZeroService from .elai_service import ElaiService from .coach_service import CoachService +from . import cefr_mapper # canonical CEFR / band / theta mapper (P0.9) +from . import question_validator # schema + quality gate for AI-generated questions (P1.6/P1.1) diff --git a/custom_addons/encoach_ai/services/cefr_mapper.py b/custom_addons/encoach_ai/services/cefr_mapper.py new file mode 100644 index 00000000..dd1309d7 --- /dev/null +++ b/custom_addons/encoach_ai/services/cefr_mapper.py @@ -0,0 +1,149 @@ +"""Canonical CEFR / IELTS band / IRT theta mapper. + +This is the SINGLE SOURCE OF TRUTH for band ↔ CEFR ↔ theta conversions across +the platform. All modules MUST import from here rather than hand-rolling their +own mapping tables. See §21 of docs/PROJECT_SUMMARY.md for the rationale. +""" + + +# IRT theta band thresholds (ability parameter → CEFR level) +THETA_TO_CEFR = [ + (-4.0, -2.5, 'pre_a1'), + (-2.5, -1.5, 'a1'), + (-1.5, -0.5, 'a2'), + (-0.5, 0.5, 'b1'), + (0.5, 1.5, 'b2'), + (1.5, 2.5, 'c1'), + (2.5, 4.0, 'c2'), +] + +# CEFR code → canonical IELTS band centre value +CEFR_TO_BAND = { + 'pre_a1': 2.0, + 'a1': 3.0, + 'a2': 4.0, + 'b1': 5.0, + 'b2': 6.5, + 'c1': 7.5, + 'c2': 9.0, +} + +# Lowercase CEFR code → human-readable label +CEFR_LABELS = { + 'pre_a1': 'Pre-A1 (Beginner)', + 'a1': 'A1 (Elementary)', + 'a2': 'A2 (Pre-Intermediate)', + 'b1': 'B1 (Intermediate)', + 'b2': 'B2 (Upper-Intermediate)', + 'c1': 'C1 (Advanced)', + 'c2': 'C2 (Proficient)', +} + +# Monotonic (highest band → lowest) thresholds used by band_to_cefr. +# Picked to align CEFR_TO_BAND with widely-published IELTS/CEFR crosswalks +# (Cambridge, Council of Europe): 8.0+ → C2, 7.0-7.9 → C1, 5.5-6.9 → B2, etc. +_BAND_THRESHOLDS = [ + (8.0, 'c2'), + (7.0, 'c1'), + (5.5, 'b2'), + (4.5, 'b1'), + (3.5, 'a2'), + (2.5, 'a1'), + (0.0, 'pre_a1'), +] + + +def band_to_cefr(band): + """Map an IELTS band (0-9, may be float) to a canonical CEFR code.""" + if band is None: + return None + try: + b = float(band) + except (TypeError, ValueError): + return None + for threshold, level in _BAND_THRESHOLDS: + if b >= threshold: + return level + return 'pre_a1' + + +def theta_to_cefr(theta): + """Map an IRT theta ability parameter (typically -4..+4) to a CEFR code.""" + try: + t = float(theta) + except (TypeError, ValueError): + return 'pre_a1' + for low, high, level in THETA_TO_CEFR: + if low <= t < high: + return level + return 'c2' if t >= 2.5 else 'pre_a1' + + +def theta_to_band(theta): + """Interpolate an IRT theta to an IELTS band, rounded to nearest 0.5.""" + try: + t = float(theta) + except (TypeError, ValueError): + return 0.0 + cefr = theta_to_cefr(t) + base_band = CEFR_TO_BAND.get(cefr, 5.0) + for low, high, level in THETA_TO_CEFR: + if level == cefr: + width = high - low + position = (t - low) / width if width > 0 else 0.5 + cefr_list = list(CEFR_TO_BAND.keys()) + idx = cefr_list.index(cefr) + next_band = CEFR_TO_BAND.get( + cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0 + ) + band = base_band + position * (next_band - base_band) + return round(band * 2) / 2 + return base_band + + +def cefr_to_band(cefr): + """Return the canonical centre-of-range band for a CEFR code.""" + if not cefr: + return None + return CEFR_TO_BAND.get(str(cefr).strip().lower().replace('-', '_')) + + +def normalize_cefr(label): + """Canonicalise a CEFR label to the uppercase display form (e.g. 'A2', 'Pre-A1'). + + Accepts 'a2', 'A2', 'pre_a1', 'Pre-A1', 'pre a1' etc. Returns ``None`` + if the input is falsy; returns the input unchanged if unrecognised. + """ + if not label: + return None + s = str(label).strip().lower().replace('-', '_').replace(' ', '_') + return { + 'pre_a1': 'Pre-A1', + 'a1': 'A1', + 'a2': 'A2', + 'b1': 'B1', + 'b2': 'B2', + 'c1': 'C1', + 'c2': 'C2', + }.get(s, label) + + +def cefr_label(cefr_code): + """Return the human-readable label (e.g. 'B2 (Upper-Intermediate)') for a code.""" + return CEFR_LABELS.get(cefr_code, cefr_code) + + +class CefrMapper: + """Class-style facade kept for backward compatibility with ``encoach_placement``.""" + + THETA_TO_CEFR = THETA_TO_CEFR + CEFR_TO_BAND = CEFR_TO_BAND + CEFR_LABELS = CEFR_LABELS + + theta_to_cefr = staticmethod(theta_to_cefr) + theta_to_band = staticmethod(theta_to_band) + band_to_cefr = staticmethod(band_to_cefr) + + @staticmethod + def get_cefr_label(cefr_code): + return cefr_label(cefr_code) diff --git a/custom_addons/encoach_ai/services/openai_service.py b/custom_addons/encoach_ai/services/openai_service.py index 728fa1ef..ac9b9475 100644 --- a/custom_addons/encoach_ai/services/openai_service.py +++ b/custom_addons/encoach_ai/services/openai_service.py @@ -20,16 +20,20 @@ class OpenAIService: self._get_param = env["ir.config_parameter"].sudo().get_param self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes") self.max_retries = int(self._get_param("encoach_ai.max_retries", "3")) + # Hard wall-clock timeout (seconds) enforced by the OpenAI SDK on each + # HTTP call. Prevents a single slow LLM request from tying up an Odoo + # worker for minutes. See P0.5 in docs/PROJECT_SUMMARY.md §21. + self.request_timeout = float(self._get_param("encoach_ai.request_timeout", "30")) api_key = self._get_param("encoach_ai.openai_api_key", "") if not api_key: import os api_key = os.environ.get("OPENAI_API_KEY", "") if _openai_mod and api_key: - self.client = _openai_mod.OpenAI(api_key=api_key) + self.client = _openai_mod.OpenAI(api_key=api_key, timeout=self.request_timeout) else: self.client = None self.model = self._get_param("encoach_ai.openai_model", "gpt-4o") - self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-3.5-turbo") + self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-4o-mini") def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None): try: @@ -86,6 +90,7 @@ class OpenAIService: messages=messages, temperature=temperature, max_tokens=max_tokens, + timeout=self.request_timeout, ) resp = self._retry_with_backoff(_call, action, model) content = resp.choices[0].message.content @@ -112,6 +117,7 @@ class OpenAIService: temperature=temperature, max_tokens=max_tokens, response_format={"type": "json_object"}, + timeout=self.request_timeout, ) resp = self._retry_with_backoff(_call, action, model) raw = resp.choices[0].message.content diff --git a/custom_addons/encoach_ai/services/question_validator.py b/custom_addons/encoach_ai/services/question_validator.py new file mode 100644 index 00000000..8a4133a3 --- /dev/null +++ b/custom_addons/encoach_ai/services/question_validator.py @@ -0,0 +1,318 @@ +"""Validation + quality gate for AI-generated exam content. + +This module is the single entry point used by exam-generation controllers to: + +1. **Schema-validate** raw LLM output before any DB insert (``validate_payload``), + catching malformed AI responses (missing prompt, empty options for MCQ, + wrong correct_answer format, etc.) before they corrupt the question pool. + +2. **Score content quality** of persisted questions (``score_question``), wrapping + :class:`encoach_quality_gate.services.quality_checker.QualityChecker` and + :class:`encoach_ielts_validation.services.validator.IeltsValidator` with + graceful degradation when those addons are not installed or their optional + Python deps (``textstat``, ``language_tool_python``) are missing. + +Design choices: + +- Zero hard dependency on ``jsonschema`` or ``pydantic`` — the schema is a + lightweight, introspectable dict used by :func:`_validate_dict`. Good enough + for the shapes we actually emit; keeps our external-deps footprint small. +- Quality scoring is best-effort and **never** raises to callers — failures + degrade to a ``warn`` verdict so the HTTP request still completes. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +from typing import Any + +_logger = logging.getLogger(__name__) + +VERDICT_OK = "ok" +VERDICT_WARN = "warn" +VERDICT_FAIL = "fail" + +MIN_STEM_LEN = 8 +MIN_QUALITY_SCORE = 0.55 + +_VALID_QUESTION_TYPES = { + "mcq", "mcq_multi", "tfng", "ynng", "gap_fill", "short_answer", + "form_completion", "note_completion", "map_labelling", "matching", + "summary_completion", "heading_matching", "matching_features", + "numerical", "expression", "code_completion", "code_output", + "sql_query", "matrix", "graph", +} + +_SKILL_VALUES = { + "listening", "reading", "writing", "speaking", "grammar", + "vocabulary", "math", "it", +} + + +EXERCISE_SCHEMA: dict[str, dict[str, Any]] = { + "prompt": {"type": str, "required": True, "min_len": MIN_STEM_LEN}, + "type": {"type": str, "required": False, "choices": _VALID_QUESTION_TYPES | { + "multiple_choice", "true_false", "fill_blanks", "matching_headings", + "paragraph_match", "sentence_completion", "matching_information", + }}, + "options": {"type": list, "required": False}, + "correct_answer": {"type": (str, list, int, float), "required": False}, + "marks": {"type": (int, float), "required": False, "min": 0, "max": 20}, + "cefr_level": {"type": str, "required": False}, +} + +MODULE_SCHEMA: dict[str, dict[str, Any]] = { + "difficulty": {"type": (list, str), "required": False}, + "timer": {"type": (int, float), "required": False, "min": 0, "max": 600}, + "totalMarks": {"type": (int, float), "required": False, "min": 0, "max": 500}, + "passages": {"type": list, "required": False}, + "sections": {"type": list, "required": False}, + "tasks": {"type": list, "required": False}, + "parts": {"type": list, "required": False}, +} + + +def _validate_dict(obj: Any, schema: dict, path: str = "") -> list[str]: + """Return list of human-readable validation errors for ``obj`` against ``schema``.""" + errors: list[str] = [] + if not isinstance(obj, dict): + errors.append(f"{path or 'root'}: expected object, got {type(obj).__name__}") + return errors + + for key, rule in schema.items(): + here = f"{path}.{key}" if path else key + if key not in obj or obj[key] in (None, ""): + if rule.get("required"): + errors.append(f"{here}: missing required field") + continue + + val = obj[key] + expected_type = rule.get("type") + if expected_type and not isinstance(val, expected_type): + got = type(val).__name__ + want = ( + expected_type.__name__ if isinstance(expected_type, type) + else "/".join(t.__name__ for t in expected_type) + ) + errors.append(f"{here}: expected {want}, got {got}") + continue + + if isinstance(val, str): + min_len = rule.get("min_len") + if min_len and len(val.strip()) < min_len: + errors.append(f"{here}: too short (< {min_len} chars)") + choices = rule.get("choices") + if choices and val not in choices: + errors.append(f"{here}: '{val}' not in allowed values") + if isinstance(val, (int, float)): + if "min" in rule and val < rule["min"]: + errors.append(f"{here}: {val} < min {rule['min']}") + if "max" in rule and val > rule["max"]: + errors.append(f"{here}: {val} > max {rule['max']}") + return errors + + +def _validate_exercise(ex: Any, path: str = "exercise") -> list[str]: + """Validate a single AI-generated exercise dict.""" + errors = _validate_dict(ex, EXERCISE_SCHEMA, path) + if errors: + return errors + q_type = (ex.get("type") or "mcq").lower() + options = ex.get("options") or [] + correct = ex.get("correct_answer") + if q_type in ("mcq", "multiple_choice"): + if not options or len(options) < 2: + errors.append(f"{path}.options: MCQ requires >= 2 options") + if correct in (None, ""): + errors.append(f"{path}.correct_answer: MCQ requires a correct_answer") + if q_type in ("mcq_multi",): + if not isinstance(correct, list) or len(correct) < 1: + errors.append(f"{path}.correct_answer: mcq_multi requires a list of answers") + if q_type in ("true_false", "tfng", "ynng"): + if isinstance(correct, str) and correct.lower() not in { + "true", "false", "not_given", "yes", "no", "t", "f" + }: + errors.append(f"{path}.correct_answer: '{correct}' not a valid T/F/NG answer") + return errors + + +def validate_payload(payload: Any) -> dict[str, Any]: + """Validate an entire exam-generation submit payload. + + Expected shape (best-effort, since the frontend already normalizes it): + + .. code-block:: text + + { + "title": str (required), + "modules": { + "": { + "passages": [{"text": str, "exercises": [ExerciseSchema, ...]}], + "sections": [...], "tasks": [...], "parts": [...], + ... + }, + ... + } + } + + Returns a dict with:: + + {"verdict": "ok"|"warn"|"fail", "errors": [...], "warnings": [...]} + + ``verdict == "fail"`` means the caller MUST NOT persist this payload as-is. + """ + errors: list[str] = [] + warnings: list[str] = [] + + if not isinstance(payload, dict): + return {"verdict": VERDICT_FAIL, + "errors": [f"payload: expected object, got {type(payload).__name__}"], + "warnings": []} + + title = (payload.get("title") or "").strip() + if not title: + errors.append("title: missing required field") + + modules = payload.get("modules") or {} + if not isinstance(modules, dict) or not modules: + errors.append("modules: missing or empty — nothing to persist") + return {"verdict": VERDICT_FAIL, "errors": errors, "warnings": warnings} + + total_exercises = 0 + for mod_key, mod in modules.items(): + mpath = f"modules.{mod_key}" + if not isinstance(mod, dict): + errors.append(f"{mpath}: expected object") + continue + errors.extend(_validate_dict(mod, MODULE_SCHEMA, mpath)) + for p_idx, passage in enumerate(mod.get("passages") or []): + if not isinstance(passage, dict): + errors.append(f"{mpath}.passages[{p_idx}]: expected object") + continue + for e_idx, ex in enumerate(passage.get("exercises") or []): + total_exercises += 1 + errors.extend(_validate_exercise( + ex, f"{mpath}.passages[{p_idx}].exercises[{e_idx}]")) + for s_idx, sec in enumerate(mod.get("sections") or []): + if not isinstance(sec, dict): + errors.append(f"{mpath}.sections[{s_idx}]: expected object") + continue + for e_idx, ex in enumerate(sec.get("exercises") or []): + total_exercises += 1 + errors.extend(_validate_exercise( + ex, f"{mpath}.sections[{s_idx}].exercises[{e_idx}]")) + + if total_exercises == 0 and not any( + (m.get("tasks") or m.get("parts")) for m in modules.values() if isinstance(m, dict) + ): + warnings.append("no exercises/tasks/parts found — exam will have no questions") + + if errors: + verdict = VERDICT_FAIL + elif warnings: + verdict = VERDICT_WARN + else: + verdict = VERDICT_OK + return {"verdict": verdict, "errors": errors, "warnings": warnings, + "total_exercises": total_exercises} + + +def prompt_hash(prompt: str) -> str: + """SHA-256 hex digest of a rendered prompt, for provenance tracking.""" + return hashlib.sha256((prompt or "").encode("utf-8")).hexdigest() + + +def score_question(stem: str, *, skill: str | None = None, + cefr_level: str | None = None) -> dict[str, Any]: + """Best-effort quality score for a persisted question stem. + + Wraps QualityChecker + IeltsValidator defensively — any import/runtime + failure downgrades the verdict to ``warn`` without raising. Returns:: + + { + "verdict": "ok"|"warn"|"fail", + "score": float in [0, 1], + "readability": {...} | None, + "grammar_issues": int, + "checks_ran": ["readability", "grammar", ...], + "errors": [str, ...], + } + """ + report: dict[str, Any] = { + "verdict": VERDICT_OK, + "score": 1.0, + "readability": None, + "grammar_issues": 0, + "checks_ran": [], + "errors": [], + } + if not stem or len(stem.strip()) < MIN_STEM_LEN: + report["verdict"] = VERDICT_FAIL + report["score"] = 0.0 + report["errors"].append(f"stem shorter than {MIN_STEM_LEN} chars") + return report + + try: + from odoo.addons.encoach_quality_gate.services.quality_checker import QualityChecker + except Exception as exc: + report["verdict"] = VERDICT_WARN + report["errors"].append(f"quality_gate unavailable: {exc}") + return report + + try: + readability = QualityChecker.check_readability(stem) + report["readability"] = readability + report["checks_ran"].append("readability") + if cefr_level: + cefr_match = QualityChecker.check_cefr_alignment(stem, cefr_level.lower()) + report["cefr_alignment"] = cefr_match + report["checks_ran"].append("cefr_alignment") + if not cefr_match.get("aligned", True): + report["score"] -= 0.2 + grammar = QualityChecker.check_grammar(stem) + report["grammar_issues"] = len(grammar.get("issues", [])) if isinstance(grammar, dict) else 0 + report["checks_ran"].append("grammar") + if report["grammar_issues"] > 3: + report["score"] -= 0.2 + except Exception as exc: + _logger.warning("QualityChecker failed for stem (skill=%s): %s", skill, exc) + report["verdict"] = VERDICT_WARN + report["errors"].append(f"quality_checker error: {exc}") + + report["score"] = max(0.0, min(1.0, report["score"])) + if report["verdict"] == VERDICT_OK and report["score"] < MIN_QUALITY_SCORE: + report["verdict"] = VERDICT_WARN + return report + + +def summarize_question_reports(reports: list[dict]) -> dict[str, Any]: + """Reduce a list of per-question reports to an aggregate verdict.""" + if not reports: + return {"verdict": VERDICT_OK, "total": 0, "failed": 0, + "warned": 0, "avg_score": 1.0} + failed = sum(1 for r in reports if r.get("verdict") == VERDICT_FAIL) + warned = sum(1 for r in reports if r.get("verdict") == VERDICT_WARN) + avg = sum(float(r.get("score") or 0) for r in reports) / max(1, len(reports)) + if failed: + verdict = VERDICT_FAIL + elif warned: + verdict = VERDICT_WARN + else: + verdict = VERDICT_OK + return { + "verdict": verdict, + "total": len(reports), + "failed": failed, + "warned": warned, + "avg_score": round(avg, 3), + } + + +def dumps_report(report: dict) -> str: + """Compact JSON dump safe for storing in a Text column.""" + try: + return json.dumps(report, ensure_ascii=False, default=str) + except Exception: + return "{}" diff --git a/custom_addons/encoach_api/__init__.py b/custom_addons/encoach_api/__init__.py index e046e49f..576a4fa7 100644 --- a/custom_addons/encoach_api/__init__.py +++ b/custom_addons/encoach_api/__init__.py @@ -1 +1,3 @@ +from . import models from . import controllers +from . import utils # exposes odoo.addons.encoach_api.utils.cache diff --git a/custom_addons/encoach_api/__manifest__.py b/custom_addons/encoach_api/__manifest__.py index e9a6fd0d..3bbe219a 100644 --- a/custom_addons/encoach_api/__manifest__.py +++ b/custom_addons/encoach_api/__manifest__.py @@ -8,7 +8,10 @@ 'external_dependencies': { 'python': ['PyJWT'], }, - 'data': [], + 'data': [ + 'security/ir.model.access.csv', + 'data/cron.xml', + ], 'installable': True, 'license': 'LGPL-3', } diff --git a/custom_addons/encoach_api/controllers/__init__.py b/custom_addons/encoach_api/controllers/__init__.py index 736bd7e7..9f5f9454 100644 --- a/custom_addons/encoach_api/controllers/__init__.py +++ b/custom_addons/encoach_api/controllers/__init__.py @@ -1,2 +1,5 @@ from . import base from . import auth +from . import health +from . import openapi +from . import gdpr diff --git a/custom_addons/encoach_api/controllers/auth.py b/custom_addons/encoach_api/controllers/auth.py index 4b4b4226..ded44b62 100644 --- a/custom_addons/encoach_api/controllers/auth.py +++ b/custom_addons/encoach_api/controllers/auth.py @@ -1,5 +1,25 @@ +"""Authentication endpoints. + +Split-token design: + +* ``access_token`` — short-lived (1h), stateless, verified via signature only. + Sent on every request as ``Authorization: Bearer ``. +* ``refresh_token`` — long-lived (7d), stateful, backed by + ``encoach.jwt.token``. Never sent on regular API calls; only to + ``/api/auth/refresh``. Rotated on every refresh so a leaked token is + usable at most once. + +The frontend should store ``refresh_token`` in a secure, non-extractable +location (ideally ``httpOnly`` cookie if the deployment proxy allows it, +otherwise ``localStorage`` behind an explicit trust boundary) and call +``/api/auth/refresh`` in the background whenever the access token is within +2 minutes of expiry. +""" + import logging import time +import uuid +from datetime import datetime, timedelta import jwt as pyjwt @@ -13,6 +33,40 @@ from .base import ( _logger = logging.getLogger(__name__) +ACCESS_TTL_SECONDS = 3600 # 1 hour +REFRESH_TTL_SECONDS = 7 * 86400 # 7 days + + +def _issue_tokens(user, *, user_agent=None, remote_ip=None): + """Mint a fresh ``(access_token, refresh_token, refresh_jti)`` triple. + + Persisting the refresh token is the caller's job — they get the ``jti`` + back so they can insert exactly one row. + """ + secret = _get_jwt_secret() + now = int(time.time()) + access_token = pyjwt.encode( + { + "user_id": user.id, + "type": "access", + "iat": now, + "exp": now + ACCESS_TTL_SECONDS, + }, + secret, algorithm="HS256", + ) + refresh_jti = uuid.uuid4().hex + refresh_token = pyjwt.encode( + { + "user_id": user.id, + "type": "refresh", + "jti": refresh_jti, + "iat": now, + "exp": now + REFRESH_TTL_SECONDS, + }, + secret, algorithm="HS256", + ) + return access_token, refresh_token, refresh_jti + class EncoachAuthController(http.Controller): @@ -30,7 +84,6 @@ class EncoachAuthController(http.Controller): if not login or not password: return _error_response('login and password are required', 400) - # Odoo 19: session.authenticate(env, credential_dict) credential = { 'type': 'password', 'login': login, @@ -49,26 +102,41 @@ class EncoachAuthController(http.Controller): user = request.env['res.users'].sudo().browse(uid) - # Generate JWT token secret = _get_jwt_secret() if not secret: return _error_response('JWT not configured on server', 500) - token = pyjwt.encode( - {'user_id': user.id, 'exp': int(time.time()) + 86400}, - secret, algorithm='HS256', - ) + # User-Agent / IP are best-effort — some proxies strip them; we + # record whatever we get but never refuse login because they're + # missing. + ua = request.httprequest.headers.get('User-Agent') if request.httprequest else '' + ip = request.httprequest.remote_addr if request.httprequest else '' + + access_token, refresh_token, refresh_jti = _issue_tokens( + user, user_agent=ua, remote_ip=ip, + ) + request.env['encoach.jwt.token'].sudo().create({ + 'jti': refresh_jti, + 'user_id': user.id, + 'issued_at': fields.Datetime.now(), + 'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS), + 'user_agent': (ua or '')[:255], + 'remote_ip': (ip or '')[:64], + }) - # Get permissions permissions = [] if hasattr(user, 'get_all_permissions'): permissions = user.get_all_permissions().mapped('code') - # Update last login user.write({'last_login': fields.Datetime.now()}) return _json_response({ - 'token': token, + 'token': access_token, # legacy name (frontend fallback) + 'access_token': access_token, + 'refresh_token': refresh_token, + 'expires_in': ACCESS_TTL_SECONDS, + 'refresh_expires_in': REFRESH_TTL_SECONDS, + 'token_type': 'Bearer', 'user': self._user_to_dict(user), 'permissions': permissions, }) @@ -77,6 +145,90 @@ class EncoachAuthController(http.Controller): _logger.exception('login failed') return _error_response(str(e), 500) + # ------------------------------------------------------------------ + # POST /api/auth/refresh + # ------------------------------------------------------------------ + @http.route('/api/auth/refresh', type='http', auth='public', + methods=['POST'], csrf=False) + def refresh(self, **kw): + """Rotate a refresh token into a fresh access+refresh pair. + + Consumes (revokes) the presented refresh token and issues new ones. + This is the *only* place a refresh token is ever accepted, so replay + attempts against other endpoints fail at the access-token layer. + """ + try: + body = _get_json_body() + token = body.get('refresh_token') or '' + if not token: + return _error_response('refresh_token is required', 400) + + secret = _get_jwt_secret() + if not secret: + return _error_response('JWT not configured on server', 500) + + try: + claims = pyjwt.decode(token, secret, algorithms=['HS256']) + except pyjwt.ExpiredSignatureError: + return _error_response('refresh_token expired', 401) + except pyjwt.InvalidTokenError: + return _error_response('refresh_token invalid', 401) + + if claims.get('type') != 'refresh': + return _error_response('wrong token type', 401) + + jti = claims.get('jti') + user_id = claims.get('user_id') + if not jti or not user_id: + return _error_response('refresh_token malformed', 401) + + Token = request.env['encoach.jwt.token'].sudo() + row = Token.search([ + ('jti', '=', jti), + ('user_id', '=', user_id), + ('revoked', '=', False), + ], limit=1) + if not row: + # Either already rotated (possible replay) or never existed. + # Revoke every active token for that user as a defensive + # measure — stolen refresh tokens shouldn't buy multiple + # rotations. + Token.search([ + ('user_id', '=', user_id), + ('revoked', '=', False), + ]).write({'revoked': True}) + return _error_response('refresh_token revoked', 401) + + user = request.env['res.users'].sudo().browse(user_id) + if not user.exists() or not user.active: + return _error_response('user disabled', 401) + + ua = request.httprequest.headers.get('User-Agent') if request.httprequest else '' + ip = request.httprequest.remote_addr if request.httprequest else '' + access_token, refresh_token, refresh_jti = _issue_tokens( + user, user_agent=ua, remote_ip=ip, + ) + row.write({'revoked': True, 'last_used_at': fields.Datetime.now()}) + Token.create({ + 'jti': refresh_jti, + 'user_id': user.id, + 'issued_at': fields.Datetime.now(), + 'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS), + 'user_agent': (ua or '')[:255], + 'remote_ip': (ip or '')[:64], + }) + return _json_response({ + 'access_token': access_token, + 'token': access_token, + 'refresh_token': refresh_token, + 'expires_in': ACCESS_TTL_SECONDS, + 'refresh_expires_in': REFRESH_TTL_SECONDS, + 'token_type': 'Bearer', + }) + except Exception as e: + _logger.exception('refresh failed') + return _error_response(str(e), 500) + # ------------------------------------------------------------------ # GET /api/user (returns current authenticated user) # ------------------------------------------------------------------ @@ -107,7 +259,32 @@ class EncoachAuthController(http.Controller): @http.route('/api/logout', type='http', auth='public', methods=['POST'], csrf=False) def logout(self, **kw): - # JWT is stateless — client clears token. Server just returns OK. + """Revoke the presented refresh token (if any) and return OK. + + Access tokens remain stateless; they expire naturally within an hour. + Clients that care about immediate lockout should also delete the + access token from local storage when they call this endpoint. + """ + try: + body = _get_json_body() or {} + token = body.get('refresh_token') or '' + if token: + secret = _get_jwt_secret() + if secret: + try: + claims = pyjwt.decode( + token, secret, algorithms=['HS256'], + options={'verify_exp': False}, + ) + jti = claims.get('jti') + if jti: + request.env['encoach.jwt.token'].sudo().revoke_by_jti(jti) + except pyjwt.InvalidTokenError: + # Invalid tokens can't be revoked but also can't be + # reused, so this is effectively a no-op. + pass + except Exception: + _logger.exception('logout cleanup failed') return _json_response({'ok': True}) # ------------------------------------------------------------------ diff --git a/custom_addons/encoach_api/controllers/base.py b/custom_addons/encoach_api/controllers/base.py index 1e8b940a..49a551d3 100644 --- a/custom_addons/encoach_api/controllers/base.py +++ b/custom_addons/encoach_api/controllers/base.py @@ -2,6 +2,7 @@ import json import functools import logging import time +import uuid import jwt as pyjwt @@ -9,14 +10,75 @@ from odoo.http import request _logger = logging.getLogger(__name__) +REQUEST_ID_HEADER = "X-Request-Id" + + +def _get_or_create_request_id(): + """Return the X-Request-Id for the current request, minting one if absent. + + The value is cached on ``request`` so the same id is reused for every log + line emitted during the request lifecycle (dispatcher, controller, + post-dispatch). Call sites should prefer :func:`current_request_id`. + """ + existing = getattr(request, "_encoach_request_id", None) + if existing: + return existing + header_val = request.httprequest.headers.get(REQUEST_ID_HEADER) + req_id = (header_val or uuid.uuid4().hex)[:64] + try: + request._encoach_request_id = req_id + except Exception: + pass + return req_id + + +def current_request_id(): + """Best-effort accessor for the active request id (None outside a request).""" + try: + return getattr(request, "_encoach_request_id", None) + except Exception: + return None + + +def _log_json(level, event, **fields): + """Emit a single structured JSON log line, enriched with the request id. + + Keeping all non-interactive logs JSON-formatted lets ops scrape them with + Loki / OpenSearch without brittle regexes. Falls back silently if the + logging backend rejects the payload. + """ + try: + payload = { + "ts": round(time.time(), 3), + "event": event, + "rid": current_request_id(), + } + payload.update(fields) + _logger.log(level, json.dumps(payload, default=str, ensure_ascii=False)) + except Exception: + _logger.log(level, "%s %s", event, fields) + _jwt_secret_cache = {"secret": None, "ts": 0} -_JWT_SECRET_TTL = 300 +# Per-worker JWT secret cache TTL (seconds). Kept short so secret rotation +# via ir.config_parameter propagates quickly across all workers without +# requiring a process restart. See P0.10 in docs/PROJECT_SUMMARY.md §21. +_JWT_SECRET_TTL = 30 _user_exists_cache = {} _USER_CACHE_TTL = 60 _USER_CACHE_MAX = 200 +def invalidate_jwt_secret_cache(): + """Invalidate the per-worker JWT secret cache immediately. + + Called by ``encoach.auth.settings`` whenever the admin rotates the secret + so that subsequent requests pick up the new value on the next read. + """ + _jwt_secret_cache["secret"] = None + _jwt_secret_cache["ts"] = 0 + + def _get_jwt_secret(): now = time.time() if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL: @@ -48,6 +110,12 @@ def validate_token(): return None except pyjwt.InvalidTokenError: return None + # Refresh tokens are only accepted by /api/auth/refresh. Presenting one as + # a Bearer on any other endpoint is treated as unauthenticated so a + # leaked refresh token never buys API access on its own. + token_type = payload.get("type") + if token_type and token_type != "access": + return None user_id = payload.get("user_id") if not user_id: return None @@ -70,26 +138,95 @@ def validate_token(): def jwt_required(func): - """Decorator that validates the JWT token and sets request.env user context.""" + """Decorator that validates the JWT token and sets request.env user context. + + Also handles per-request observability plumbing: + + - Assigns (or honours) an ``X-Request-Id`` so every log line, downstream + service call, and response can be correlated back to the same request. + - Emits a structured ``api.request.start`` / ``api.request.end`` log pair + with duration in ms, status code, user id, route, and method — powering + the P2.3 metrics pipeline once it lands without any extra wiring. + - Echoes the request id back to the caller via the ``X-Request-Id`` + response header. + """ @functools.wraps(func) def wrapper(*args, **kwargs): + req_id = _get_or_create_request_id() + route = request.httprequest.path + method = request.httprequest.method + t0 = time.time() + user = validate_token() if not user: - return _error_response("Authentication required", status=401) + _log_json(logging.INFO, "api.request.unauthorized", + route=route, method=method, status=401) + resp = _error_response("Authentication required", status=401) + try: + resp.headers[REQUEST_ID_HEADER] = req_id + except Exception: + pass + return resp + request.update_env(user=user.id) - return func(*args, **kwargs) + _log_json(logging.INFO, "api.request.start", + route=route, method=method, user_id=user.id) + try: + resp = func(*args, **kwargs) + status = getattr(resp, "status_code", 200) + duration_ms = int((time.time() - t0) * 1000) + _log_json(logging.INFO, "api.request.end", + route=route, method=method, user_id=user.id, + status=status, duration_ms=duration_ms) + try: + resp.headers[REQUEST_ID_HEADER] = req_id + except Exception: + pass + _record_metric(route, status, duration_ms) + return resp + except Exception as exc: + duration_ms = int((time.time() - t0) * 1000) + _log_json(logging.ERROR, "api.request.error", + route=route, method=method, user_id=user.id, + duration_ms=duration_ms, + error=type(exc).__name__, message=str(exc)[:500]) + _record_metric(route, 500, duration_ms) + raise return wrapper +def _record_metric(route, status, duration_ms): + """Soft import of the metrics recorder so this module has no hard cycle.""" + try: + from odoo.addons.encoach_api.controllers.openapi import record_request + record_request(route, status, duration_ms) + except Exception: + pass + + def _json_response(data, status=200): - return request.make_json_response(data, status=status) + resp = request.make_json_response(data, status=status) + try: + rid = current_request_id() + if rid: + resp.headers[REQUEST_ID_HEADER] = rid + except Exception: + pass + return resp def _error_response(message, status=400, code=None): body = {"error": message} if code: body["code"] = code - return request.make_json_response(body, status=status) + resp = request.make_json_response(body, status=status) + try: + rid = current_request_id() + if rid: + resp.headers[REQUEST_ID_HEADER] = rid + except Exception: + pass + return resp def _get_json_body(): @@ -99,6 +236,31 @@ def _get_json_body(): return {} +def paginated_envelope(items, *, total=None, page=None, size=None, extra=None): + """Canonical ``{items, total, page, size}`` envelope. + + This is the platform-wide pagination shape — prefer returning the output of + this helper from any new list endpoint. Legacy keys (``data``, ``results``, + and custom aliases such as ``students`` / ``subjects``) may be added via + the ``extra`` dict for transitional backward compatibility with the + frontend services that still read those keys; new code must read ``items``. + """ + items = list(items or []) + envelope = { + "items": items, + "total": int(total if total is not None else len(items)), + } + if page is not None: + envelope["page"] = int(page) + if size is not None: + envelope["size"] = int(size) + if extra: + for k, v in extra.items(): + if k not in envelope: + envelope[k] = v + return envelope + + def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'): """Paginate an Odoo model search or extract params from a kwargs dict. diff --git a/custom_addons/encoach_api/controllers/gdpr.py b/custom_addons/encoach_api/controllers/gdpr.py new file mode 100644 index 00000000..d7a07f83 --- /dev/null +++ b/custom_addons/encoach_api/controllers/gdpr.py @@ -0,0 +1,316 @@ +"""GDPR data-subject rights endpoints. + +Exposes two routes: + +* ``GET /api/gdpr/export`` + Returns a JSON snapshot of the calling user's personal data — profile, + entity membership, exam attempts, answers, AI interactions, feedback, + tickets, and login history. + +* ``POST /api/gdpr/delete`` + Initiates the "right to erasure" flow. Because Odoo enforces FK + constraints on linked business records (exam attempts, tickets, audit + trail), we **anonymise** instead of hard-deleting: the ``res.users`` + row is deactivated, ``res.partner`` PII fields are wiped, and a + tombstone row is written to ``encoach.gdpr.erasure.request`` for + audit. Hard delete of linked business rows (attempts, answers, + feedback) is performed for records where it is legally required and + where no other user depends on them. + +The flow is intentionally verbose so operators can see what happened. A +production deployment may want to queue the erasure to a background job +and require a cooling-off period before executing; we expose a +``confirm`` flag for that. +""" + +from __future__ import annotations + +import json +import logging + +from odoo import fields, http +from odoo.http import request + +from .base import ( + _error_response, + _get_json_body, + _json_response, + jwt_required, +) + +_logger = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------- +def _safe_records(env_name: str, domain, fields_to_read): + """Read records if the model exists, else return [].""" + env = request.env + if env_name not in env: + return [] + try: + return env[env_name].sudo().search(domain).read(fields_to_read) + except Exception as exc: + _logger.warning("gdpr: could not read %s: %s", env_name, exc) + return [] + + +def _sanitize(values): + """Convert non-JSON-serialisable values to strings/nulls.""" + out = {} + for key, value in values.items(): + if isinstance(value, (list, tuple)) and len(value) == 2 and isinstance(value[0], int): + # Many2one read tuples — keep both id and display name. + out[key] = {"id": value[0], "display_name": value[1]} + elif isinstance(value, bytes): + out[key] = f"<{len(value)} bytes omitted>" + elif hasattr(value, "isoformat"): + out[key] = value.isoformat() + else: + out[key] = value + return out + + +# ---------------------------------------------------------------------- +# Controller +# ---------------------------------------------------------------------- +class EncoachGdprController(http.Controller): + """Implements export + right-to-erasure endpoints.""" + + # ------------------------------------------------------------------ + # GET /api/gdpr/export + # ------------------------------------------------------------------ + @http.route("/api/gdpr/export", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required + def export(self, **kw): + try: + user = request.env.user + partner = user.partner_id + + profile = { + "id": user.id, + "login": user.login, + "name": user.name, + "email": user.email or "", + "lang": user.lang or "", + "tz": user.tz or "", + "create_date": user.create_date.isoformat() if user.create_date else None, + "login_date": user.login_date.isoformat() if user.login_date else None, + } + partner_data = { + "id": partner.id if partner else None, + "name": partner.name if partner else None, + "email": partner.email if partner else None, + "phone": partner.phone if partner else None, + "mobile": partner.mobile if partner else None, + "street": partner.street if partner else None, + "city": partner.city if partner else None, + "country": partner.country_id.name if partner and partner.country_id else None, + } + + # Entity / role memberships + entity_rel = _safe_records( + "encoach.user.entity.rel", + [("user_id", "=", user.id)], + ["id", "entity_id", "role_id", "is_active"], + ) + + # Exam attempts & answers + attempts = _safe_records( + "encoach.student.attempt", + [("user_id", "=", user.id)], + ["id", "exam_id", "status", "score", "started_at", "submitted_at"], + ) + attempt_ids = [a["id"] for a in attempts] + answers = _safe_records( + "encoach.student.answer", + [("attempt_id", "in", attempt_ids)], + ["id", "attempt_id", "question_id", "student_answer", "is_correct", "score"], + ) if attempt_ids else [] + + # AI feedback they left + feedback = _safe_records( + "encoach.ai.feedback", + [("user_id", "=", user.id)], + ["id", "subject_type", "subject_id", "rating", "comment", "status", "create_date"], + ) + + # AI calls they triggered (logs) + ai_logs = _safe_records( + "encoach.ai.log", + [("user_id", "=", user.id)] if "encoach.ai.log" in request.env else [], + ["id", "service", "action", "model_used", "total_tokens", "status", "create_date"], + ) + + # Tickets they filed + tickets = _safe_records( + "encoach.ticket", + [("created_by_id", "=", user.id)], + ["id", "name", "status", "priority", "assigned_to_id", "create_date"], + ) + + # Feedback on their coaching, if any + coaching_sessions = _safe_records( + "encoach.coaching.session", + [("user_id", "=", user.id)], + ["id", "create_date", "summary"], + ) + + payload = { + "profile": profile, + "partner": partner_data, + "entity_memberships": [_sanitize(r) for r in entity_rel], + "exam_attempts": [_sanitize(r) for r in attempts], + "exam_answers": [_sanitize(r) for r in answers], + "ai_feedback": [_sanitize(r) for r in feedback], + "ai_calls": [_sanitize(r) for r in ai_logs], + "tickets": [_sanitize(r) for r in tickets], + "coaching_sessions": [_sanitize(r) for r in coaching_sessions], + "exported_at": fields.Datetime.to_string(fields.Datetime.now()), + "export_format_version": "1.0", + } + return _json_response(payload) + except Exception as e: + _logger.exception("gdpr export failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/gdpr/delete + # ------------------------------------------------------------------ + @http.route("/api/gdpr/delete", type="http", auth="none", methods=["POST"], csrf=False) + @jwt_required + def delete(self, **kw): + try: + body = _get_json_body() or {} + if not body.get("confirm"): + return _error_response( + "Set {\"confirm\": true} to initiate erasure. " + "This action cannot be undone.", + 400, + ) + + user = request.env.user + if user.id == 1 or user.has_group("base.group_system"): + return _error_response( + "Admin accounts cannot self-erase via this endpoint. " + "Contact the data protection officer.", + 403, + ) + + login = user.login + email = user.email or "" + archived_id = user.id + summary = { + "anonymised_partner_fields": [], + "deactivated_user": False, + "deleted_feedback_count": 0, + "deleted_coaching_count": 0, + "retained_attempts_count": 0, + } + + with request.env.cr.savepoint(): + # 1. Anonymise the partner PII. + partner = user.partner_id + if partner: + partner.sudo().write({ + "name": f"deleted-user-{archived_id}", + "email": False, + "phone": False, + "mobile": False, + "street": False, + "street2": False, + "city": False, + "zip": False, + "image_1920": False, + "comment": "Erased per GDPR request.", + }) + summary["anonymised_partner_fields"] = [ + "name", "email", "phone", "mobile", + "street", "street2", "city", "zip", + "image_1920", "comment", + ] + + # 2. Hard-delete their feedback (personal reviews/comments). + if "encoach.ai.feedback" in request.env: + feedback = request.env["encoach.ai.feedback"].sudo().search([ + ("user_id", "=", user.id), + ]) + summary["deleted_feedback_count"] = len(feedback) + feedback.unlink() + + # 3. Hard-delete coaching session transcripts (personal). + if "encoach.coaching.session" in request.env: + sessions = request.env["encoach.coaching.session"].sudo().search([ + ("user_id", "=", user.id), + ]) + summary["deleted_coaching_count"] = len(sessions) + sessions.unlink() + + # 4. Retain exam attempts (aggregate analytics rely on them) + # but strip personal free-text fields where they exist. + if "encoach.student.attempt" in request.env: + retained = request.env["encoach.student.attempt"].sudo().search([ + ("user_id", "=", user.id), + ]) + summary["retained_attempts_count"] = len(retained) + # Clear any written responses that contain user PII — keep + # the scores/timestamps for analytics integrity. + if "encoach.student.answer" in request.env: + answers = request.env["encoach.student.answer"].sudo().search([ + ("attempt_id", "in", retained.ids), + ]) + # student_answer can contain free-text PII. + answers.write({"student_answer": False}) + + # 5. Rewrite AI log user_id → None so we can no longer tie + # those rows to the person. + if "encoach.ai.log" in request.env: + ai_logs = request.env["encoach.ai.log"].sudo().search([ + ("user_id", "=", user.id), + ]) + if ai_logs: + try: + ai_logs.write({"user_id": False}) + except Exception: + # user_id may be required; fall back to deletion. + ai_logs.unlink() + + # 6. Deactivate the user (we can't unlink — FKs). + user.sudo().write({ + "active": False, + "login": f"erased-{archived_id}@example.invalid", + }) + summary["deactivated_user"] = True + + # 7. Write tombstone audit row. + request.env["encoach.gdpr.erasure.request"].sudo().create({ + "user_login": login, + "user_email": email, + "user_id_archived": archived_id, + "status": "completed", + "summary": json.dumps(summary), + "completed_at": fields.Datetime.now(), + }) + + # Blow away any cached JWT sessions for this user. + try: + from odoo.addons.encoach_api.utils.jwt_cache import ( + invalidate_user as _invalidate_user, + ) + _invalidate_user(archived_id) + except Exception as exc: + _logger.debug("jwt cache invalidation skipped: %s", exc) + + return _json_response({ + "ok": True, + "summary": summary, + "message": ( + "Your account has been anonymised and deactivated. " + "Some records (e.g. exam attempts) are retained " + "without personal identifiers for statistical purposes." + ), + }) + except Exception as e: + _logger.exception("gdpr delete failed") + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_api/controllers/health.py b/custom_addons/encoach_api/controllers/health.py new file mode 100644 index 00000000..5e008777 --- /dev/null +++ b/custom_addons/encoach_api/controllers/health.py @@ -0,0 +1,65 @@ +"""Platform health probes (P0.4). + +Exposes unauthenticated ``/api/health`` (quick liveness) and ``/api/health/ready`` +(deep readiness: DB + JWT secret + OpenAI key). Designed to be consumed by +uptime monitors, load-balancer health checks and container orchestrators. +""" + +import logging +import time + +from odoo import http +from odoo.http import request +from odoo.release import version as odoo_version + +_logger = logging.getLogger(__name__) + +_STARTED_AT = time.time() + + +class HealthController(http.Controller): + """Platform health probes — safe to expose publicly.""" + + @http.route("/api/health", type="http", auth="none", methods=["GET"], csrf=False) + def health(self, **_kw): + """Liveness probe: the process is up and able to handle HTTP.""" + return request.make_json_response({ + "status": "ok", + "service": "encoach-backend", + "odoo_version": odoo_version, + "uptime_seconds": int(time.time() - _STARTED_AT), + }) + + @http.route("/api/health/ready", type="http", auth="none", methods=["GET"], csrf=False) + def ready(self, **_kw): + """Readiness probe: DB reachable, JWT secret configured, AI key known.""" + checks = {} + ok = True + + try: + request.env.cr.execute("SELECT 1") + checks["database"] = "ok" + except Exception as exc: + ok = False + checks["database"] = f"error: {exc}" + + try: + IrParam = request.env["ir.config_parameter"].sudo() + checks["jwt_secret"] = "ok" if IrParam.get_param("encoach.jwt_secret") else "missing" + if checks["jwt_secret"] == "missing": + ok = False + ai_key = IrParam.get_param("encoach_ai.openai_api_key") + import os + if not ai_key: + ai_key = os.environ.get("OPENAI_API_KEY", "") + checks["openai_key"] = "ok" if ai_key else "missing" + except Exception as exc: + ok = False + checks["config"] = f"error: {exc}" + + status = 200 if ok else 503 + return request.make_json_response({ + "status": "ok" if ok else "degraded", + "checks": checks, + "uptime_seconds": int(time.time() - _STARTED_AT), + }, status=status) diff --git a/custom_addons/encoach_api/controllers/openapi.py b/custom_addons/encoach_api/controllers/openapi.py new file mode 100644 index 00000000..419cf9d3 --- /dev/null +++ b/custom_addons/encoach_api/controllers/openapi.py @@ -0,0 +1,265 @@ +"""OpenAPI 3.0 + Prometheus-ish metrics exporter. + +Both endpoints are **unauthenticated** by design so they can be scraped by +third-party tooling (Swagger UI, Prometheus, uptime monitors) without needing +to solve a JWT exchange first. The OpenAPI spec is generated by introspecting +every class decorated with :func:`odoo.http.route` and therefore stays in sync +with the running server automatically. + +Scope: the generator emits a minimal but valid OpenAPI document — routes, +HTTP methods, path params, tag derived from the route prefix, and a shared +``BearerAuth`` security scheme. Request / response schemas are left open +(``{"type": "object"}``) since we don't have pydantic models yet; a future +iteration can enrich individual routes via ``route_decorated_method.openapi`` +annotations. +""" + +from __future__ import annotations + +import logging +import re +import time +from collections import Counter, defaultdict + +from odoo import http +from odoo.http import Controller, Response, request + +_logger = logging.getLogger(__name__) + +_metrics_lock = None +_metrics: dict = { + "started_at": time.time(), + "routes": Counter(), # key -> hit count + "statuses": Counter(), # status bucket -> hit count + "latencies_ms": defaultdict(list), # route -> [recent ms samples] (cap 128) +} +_LATENCY_SAMPLE_CAP = 128 +_PATH_PARAM_RE = re.compile(r"<(?:(?P\w+):)?(?P\w+)>") + + +def record_request(route: str, status: int, duration_ms: int) -> None: + """Lightweight in-process request counter used by the metrics endpoint. + + Intentionally thread-unsafe for minimum overhead; worst case is a slightly + off counter on concurrent writes which is fine for ops dashboards. Switch + to a proper Prometheus client (``prometheus_client``) in P2.3 when needed. + """ + try: + _metrics["routes"][route] += 1 + bucket = f"{status // 100}xx" if isinstance(status, int) else "xxx" + _metrics["statuses"][bucket] += 1 + samples = _metrics["latencies_ms"][route] + samples.append(int(duration_ms)) + if len(samples) > _LATENCY_SAMPLE_CAP: + del samples[0:len(samples) - _LATENCY_SAMPLE_CAP] + except Exception: + pass + + +def _convert_odoo_path(path: str) -> str: + """Translate Odoo's ```` placeholder syntax to OpenAPI ``{foo}``.""" + def repl(m: re.Match) -> str: + return "{" + m.group("name") + "}" + return _PATH_PARAM_RE.sub(repl, path) + + +def _path_parameters(path: str) -> list[dict]: + params = [] + for m in _PATH_PARAM_RE.finditer(path): + conv = (m.group("conv") or "string").lower() + if conv in ("int", "integer"): + schema = {"type": "integer"} + elif conv in ("float", "number"): + schema = {"type": "number"} + else: + schema = {"type": "string"} + params.append({ + "name": m.group("name"), + "in": "path", + "required": True, + "schema": schema, + }) + return params + + +def _iter_routes(): + """Yield ``(route_str, method_name, tag, class_name, doc)`` for every + ``@http.route`` decorated callable currently registered. + + Uses Odoo's live ``ir.http.routing_map()`` so we pick up every handler the + dispatcher actually routes to — including extension classes. + """ + seen = set() + try: + routing_map = request.env["ir.http"].routing_map() + except Exception: + _logger.exception("could not fetch routing_map") + return + + try: + rules = list(routing_map.iter_rules()) + except Exception: + _logger.exception("routing_map has no iter_rules") + return + + for rule in rules: + url = rule.rule + if not isinstance(url, str) or not url.startswith("/api/"): + continue + endpoint = rule.endpoint + # ``endpoint`` is a wrapped callable; the real controller method is + # stashed on ``endpoint.func`` by Odoo. + fn = getattr(endpoint, "func", endpoint) + routing = getattr(fn, "routing", {}) or {} + methods = routing.get("methods") or list(rule.methods or {"GET"}) + methods = [m for m in methods if m not in {"HEAD", "OPTIONS"}] + if not methods: + methods = ["GET"] + cls_name = "" + try: + qual = getattr(fn, "__qualname__", fn.__name__) + cls_name = qual.split(".")[0] if "." in qual else "" + except Exception: + cls_name = "" + module = getattr(fn, "__module__", "") or "" + tag_base = module.split(".")[-2] if "." in module else module or "api" + doc = "" + try: + doc = (fn.__doc__ or "").strip() + except Exception: + pass + for m in methods: + key = (url, m.upper(), cls_name, fn.__name__) + if key in seen: + continue + seen.add(key) + yield url, m.upper(), tag_base, cls_name, doc + + +def _build_openapi_spec() -> dict: + base_url = ( + request.env["ir.config_parameter"].sudo().get_param("web.base.url") + or "http://localhost:8069" + ) + paths: dict[str, dict] = {} + tags: dict[str, str] = {} + + for route, method, tag_base, cls_name, doc in _iter_routes(): + openapi_path = _convert_odoo_path(route) + path_entry = paths.setdefault(openapi_path, {}) + + # Derive a friendly tag from the URL prefix (``/api/exam/...`` -> ``exam``). + segs = [s for s in route.split("/") if s and s != "api"] + tag = segs[0] if segs else tag_base + tags.setdefault(tag, f"Endpoints under /api/{tag}") + + summary = doc.splitlines()[0][:120] if doc else f"{cls_name}.{route}" + + op: dict = { + "summary": summary, + "operationId": f"{cls_name}_{method.lower()}_{route.strip('/').replace('/', '_').replace('<', '_').replace('>', '_').replace(':', '_')}", + "tags": [tag], + "security": [{"BearerAuth": []}], + "responses": { + "200": {"description": "OK", + "content": {"application/json": + {"schema": {"type": "object"}}}}, + "401": {"description": "Authentication required"}, + "400": {"description": "Bad request"}, + "500": {"description": "Server error"}, + }, + } + params = _path_parameters(route) + if params: + op["parameters"] = params + if method in ("POST", "PUT", "PATCH"): + op["requestBody"] = { + "required": False, + "content": {"application/json": + {"schema": {"type": "object"}}}, + } + path_entry[method.lower()] = op + + return { + "openapi": "3.0.3", + "info": { + "title": "EnCoach Platform API", + "version": "19.0.1.0", + "description": ( + "Auto-generated from ``@http.route`` decorators. " + "Schemas are currently open; see docs/PROJECT_SUMMARY.md §21 for the " + "roadmap to enrich request/response shapes." + ), + }, + "servers": [{"url": base_url}], + "tags": [{"name": name, "description": desc} + for name, desc in sorted(tags.items())], + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", "scheme": "bearer", "bearerFormat": "JWT", + }, + }, + }, + "paths": dict(sorted(paths.items())), + } + + +class EncoachOpenAPIController(Controller): + + @http.route("/api/openapi.json", type="http", auth="none", + methods=["GET"], csrf=False) + def openapi(self, **_kw): + """Return a freshly generated OpenAPI 3.0 spec for the running server.""" + try: + spec = _build_openapi_spec() + return request.make_json_response(spec) + except Exception: + _logger.exception("openapi generation failed") + return request.make_json_response( + {"error": "could not generate openapi spec"}, status=500, + ) + + @http.route("/api/metrics", type="http", auth="none", + methods=["GET"], csrf=False) + def metrics(self, **_kw): + """Return in-process request counters in a Prometheus-compatible text format. + + This is a minimalist shim, not a full Prometheus client. It exposes: + + - ``encoach_requests_total{route, method, status_bucket}`` — request count + - ``encoach_request_latency_ms_p95{route}`` — rolling p95 (sample-window based) + - ``encoach_uptime_seconds`` — process uptime + """ + lines = [ + "# HELP encoach_uptime_seconds Seconds since this worker booted", + "# TYPE encoach_uptime_seconds gauge", + f"encoach_uptime_seconds {int(time.time() - _metrics['started_at'])}", + "", + "# HELP encoach_requests_total Total number of /api/* requests handled", + "# TYPE encoach_requests_total counter", + ] + for route, count in _metrics["routes"].most_common(): + safe = route.replace('"', '\\"') + lines.append(f'encoach_requests_total{{route="{safe}"}} {count}') + lines.append("") + lines.append("# HELP encoach_responses_total Count of responses per status bucket") + lines.append("# TYPE encoach_responses_total counter") + for bucket, count in _metrics["statuses"].items(): + lines.append(f'encoach_responses_total{{bucket="{bucket}"}} {count}') + lines.append("") + lines.append("# HELP encoach_request_latency_ms_p95 Rolling p95 latency per route") + lines.append("# TYPE encoach_request_latency_ms_p95 gauge") + for route, samples in _metrics["latencies_ms"].items(): + if not samples: + continue + sorted_samples = sorted(samples) + p95 = sorted_samples[min(len(sorted_samples) - 1, + int(0.95 * len(sorted_samples)))] + safe = route.replace('"', '\\"') + lines.append( + f'encoach_request_latency_ms_p95{{route="{safe}"}} {p95}' + ) + body = "\n".join(lines) + "\n" + return Response(body, status=200, + content_type="text/plain; version=0.0.4") diff --git a/custom_addons/encoach_api/data/cron.xml b/custom_addons/encoach_api/data/cron.xml new file mode 100644 index 00000000..e35a8be1 --- /dev/null +++ b/custom_addons/encoach_api/data/cron.xml @@ -0,0 +1,12 @@ + + + + EnCoach: purge expired JWT refresh tokens + + code + model.purge_expired() + 1 + days + + + diff --git a/custom_addons/encoach_api/models/__init__.py b/custom_addons/encoach_api/models/__init__.py new file mode 100644 index 00000000..96b0f48e --- /dev/null +++ b/custom_addons/encoach_api/models/__init__.py @@ -0,0 +1,2 @@ +from . import jwt_token +from . import gdpr_erasure diff --git a/custom_addons/encoach_api/models/gdpr_erasure.py b/custom_addons/encoach_api/models/gdpr_erasure.py new file mode 100644 index 00000000..0e5451ee --- /dev/null +++ b/custom_addons/encoach_api/models/gdpr_erasure.py @@ -0,0 +1,35 @@ +"""Tombstone record written when a user exercises the right-to-erasure. + +We cannot hard-delete the original res.users row (ORM FK constraints on +attempts, tickets, audit trail, etc.), so this table lets us prove the +request happened, what was anonymised/deleted, and when. +""" + +from odoo import fields, models + + +class EncoachGdprErasureRequest(models.Model): + _name = "encoach.gdpr.erasure.request" + _description = "GDPR right-to-erasure audit trail" + _order = "create_date desc" + + user_login = fields.Char(required=True, index=True) + user_email = fields.Char(index=True) + user_id_archived = fields.Integer( + index=True, + help="Id of the res.users row at time of erasure (row is now archived).", + ) + status = fields.Selection( + [ + ("requested", "Requested"), + ("completed", "Completed"), + ("partial", "Partial — manual follow-up required"), + ], + default="requested", required=True, + ) + summary = fields.Text( + help="JSON summary of what was deleted/anonymised/retained.", + ) + requested_at = fields.Datetime(default=fields.Datetime.now) + completed_at = fields.Datetime() + notes = fields.Text() diff --git a/custom_addons/encoach_api/models/jwt_token.py b/custom_addons/encoach_api/models/jwt_token.py new file mode 100644 index 00000000..2a607a99 --- /dev/null +++ b/custom_addons/encoach_api/models/jwt_token.py @@ -0,0 +1,105 @@ +"""Refresh-token ledger for the EnCoach API. + +Access tokens stay short-lived (1 hour) and stateless — they are verified +with the JWT signature alone. Refresh tokens, on the other hand, must be +revocable so compromised devices can be signed out without rotating the +server secret. Every refresh token corresponds to one row here, keyed by a +UUID (``jti``) that the client echoes back on ``/api/auth/refresh``. + +Rotation policy: ``/api/auth/refresh`` consumes the presented row (revokes +it) and issues a brand-new row, so a stolen refresh token only works once. +Replay attempts after rotation will be rejected because the replayed ``jti`` +is no longer ``active``. +""" + +from __future__ import annotations + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class EncoachJwtToken(models.Model): + _name = "encoach.jwt.token" + _description = "JWT Refresh Token Ledger" + _order = "issued_at desc" + _rec_name = "jti" + + jti = fields.Char( + string="Token ID", + required=True, + index=True, + help="UUID used as the JWT's `jti` claim; serves as the primary handle.", + ) + user_id = fields.Many2one( + "res.users", + string="User", + required=True, + ondelete="cascade", + index=True, + ) + issued_at = fields.Datetime(required=True, default=fields.Datetime.now) + expires_at = fields.Datetime(required=True) + last_used_at = fields.Datetime() + revoked = fields.Boolean(default=False, index=True) + user_agent = fields.Char(help="User-Agent header of the issuing client, for audit.") + remote_ip = fields.Char(help="Remote IP of the issuing client, for audit.") + + @api.model + def _auto_init(self): + res = super()._auto_init() + cr = self.env.cr + for name, ddl in ( + ( + "encoach_jwt_token_jti_unique", + "CREATE UNIQUE INDEX IF NOT EXISTS encoach_jwt_token_jti_unique " + "ON encoach_jwt_token (jti)", + ), + ( + "encoach_jwt_token_user_revoked_idx", + "CREATE INDEX IF NOT EXISTS encoach_jwt_token_user_revoked_idx " + "ON encoach_jwt_token (user_id, revoked, expires_at)", + ), + ): + try: + cr.execute(ddl) + except Exception: + _logger.warning("could not create index %s", name, exc_info=True) + return res + + @api.model + def revoke_by_jti(self, jti: str) -> bool: + """Soft-revoke every row matching ``jti``. + + Returns ``True`` iff at least one row was flipped. Called from + ``/api/auth/refresh`` (to consume the rotated token) and + ``/api/logout`` (to end the session). + """ + if not jti: + return False + rows = self.sudo().search([("jti", "=", jti), ("revoked", "=", False)]) + if not rows: + return False + rows.write({"revoked": True}) + return True + + @api.model + def purge_expired(self, batch_size: int = 500) -> int: + """Remove rows that expired more than a day ago. + + Wired to the daily cleanup cron in :file:`data/cron.xml`; manual + invocations (e.g. migrations) should pass a larger ``batch_size`` + to avoid multi-pass overhead. + """ + from datetime import datetime, timedelta + cutoff = datetime.utcnow() - timedelta(days=1) + rows = self.sudo().search( + [("expires_at", "<", cutoff)], + limit=batch_size, + ) + n = len(rows) + if n: + rows.unlink() + return n diff --git a/custom_addons/encoach_api/security/ir.model.access.csv b/custom_addons/encoach_api/security/ir.model.access.csv new file mode 100644 index 00000000..72457671 --- /dev/null +++ b/custom_addons/encoach_api/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_jwt_token_user,encoach.jwt.token user,model_encoach_jwt_token,base.group_user,1,0,0,0 +access_encoach_jwt_token_admin,encoach.jwt.token admin,model_encoach_jwt_token,base.group_system,1,1,1,1 +access_gdpr_erasure_admin,encoach.gdpr.erasure.request admin,model_encoach_gdpr_erasure_request,base.group_system,1,1,1,1 diff --git a/custom_addons/encoach_api/tests/__init__.py b/custom_addons/encoach_api/tests/__init__.py new file mode 100644 index 00000000..f0f7ac14 --- /dev/null +++ b/custom_addons/encoach_api/tests/__init__.py @@ -0,0 +1 @@ +from . import test_health diff --git a/custom_addons/encoach_api/tests/test_health.py b/custom_addons/encoach_api/tests/test_health.py new file mode 100644 index 00000000..e89f4310 --- /dev/null +++ b/custom_addons/encoach_api/tests/test_health.py @@ -0,0 +1,27 @@ +"""Smoke tests for the health + GDPR endpoints. + +Run with: + ./odoo-bin -c odoo.conf --test-tags encoach_api --stop-after-init +""" + +import json + +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install", "encoach_api") +class TestHealthEndpoints(HttpCase): + def test_health_live_returns_200(self): + """``GET /api/health`` must always return 200 once Odoo has booted.""" + response = self.url_open("/api/health", timeout=10) + self.assertEqual(response.status_code, 200) + body = response.json() if hasattr(response, "json") else json.loads(response.text) + self.assertTrue(body.get("ok") is True or body.get("status") in {"ok", "live"}) + + def test_health_ready_returns_2xx(self): + """``GET /api/health/ready`` is allowed to flap, but must not 5xx.""" + response = self.url_open("/api/health/ready", timeout=10) + self.assertIn( + response.status_code, (200, 503), + f"unexpected status {response.status_code}: {response.text[:200]}", + ) diff --git a/custom_addons/encoach_api/utils/__init__.py b/custom_addons/encoach_api/utils/__init__.py new file mode 100644 index 00000000..ea4d3909 --- /dev/null +++ b/custom_addons/encoach_api/utils/__init__.py @@ -0,0 +1 @@ +from . import cache # noqa: F401 diff --git a/custom_addons/encoach_api/utils/cache.py b/custom_addons/encoach_api/utils/cache.py new file mode 100644 index 00000000..2336ed33 --- /dev/null +++ b/custom_addons/encoach_api/utils/cache.py @@ -0,0 +1,106 @@ +"""Zero-dependency in-process TTL cache for hot endpoints. + +Designed for read-heavy, mostly-idempotent endpoints like the admin report +pages (``/api/reports/stats-corporate``, ``/api/reports/student-performance``) +and the AI narrative generator. A single LRU-ish dict per worker with a hard +time bound is usually enough to absorb the dashboard refresh storm caused by +an admin tabbing between pages. + +**Not** a substitute for Redis — cross-worker consistency and bounded memory +are out of scope. If ops ever needs those guarantees, swap the internal dict +for a ``redis.Redis.setex`` call; the public API (``memoize_ttl`` and +``invalidate``) stays identical. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import threading +import time +from functools import wraps +from typing import Any, Callable + +_logger = logging.getLogger(__name__) + +_cache: dict[str, tuple[float, Any]] = {} +_lock = threading.Lock() +_MAX_ENTRIES = 512 + + +def _make_key(namespace: str, args: tuple, kwargs: dict) -> str: + payload = json.dumps([args, kwargs], sort_keys=True, default=str) + digest = hashlib.md5(payload.encode("utf-8")).hexdigest() + return f"{namespace}:{digest}" + + +def get(key: str): + entry = _cache.get(key) + if not entry: + return None + expiry, value = entry + if expiry < time.time(): + with _lock: + _cache.pop(key, None) + return None + return value + + +def put(key: str, value, ttl_seconds: int) -> None: + with _lock: + if len(_cache) >= _MAX_ENTRIES: + oldest = sorted(_cache.items(), key=lambda kv: kv[1][0])[: _MAX_ENTRIES // 4] + for k, _v in oldest: + _cache.pop(k, None) + _cache[key] = (time.time() + max(1, ttl_seconds), value) + + +def invalidate(namespace: str | None = None) -> int: + """Drop every cached entry, or just entries under ``namespace``. + + Returns the number of removed entries. Callers should invoke this from + write endpoints that affect the cached read so subsequent reads observe + the new state. + """ + removed = 0 + with _lock: + if namespace is None: + removed = len(_cache) + _cache.clear() + return removed + prefix = f"{namespace}:" + keys = [k for k in _cache if k.startswith(prefix)] + for k in keys: + _cache.pop(k, None) + removed = len(keys) + return removed + + +def memoize_ttl(namespace: str, ttl_seconds: int = 30): + """Decorator: cache ``func(*args, **kwargs)`` for ``ttl_seconds`` per key. + + The key is derived from the namespace + a stable JSON dump of the args, + so callers don't need to worry about mutable keyword order or unhashable + defaults. JWT decorator should run *before* this one so unauthenticated + traffic never hits the cache. + """ + def decorator(func: Callable): + @wraps(func) + def wrapper(*args, **kwargs): + try: + key = _make_key(namespace, args, kwargs) + except Exception: + return func(*args, **kwargs) + cached = get(key) + if cached is not None: + return cached + value = func(*args, **kwargs) + try: + put(key, value, ttl_seconds) + except Exception: + _logger.debug("cache put failed for %s", namespace, exc_info=True) + return value + wrapper._encoach_cache_namespace = namespace + return wrapper + return decorator diff --git a/custom_addons/encoach_exam_template/controllers/__init__.py b/custom_addons/encoach_exam_template/controllers/__init__.py index 307e74f3..b7bf0a46 100644 --- a/custom_addons/encoach_exam_template/controllers/__init__.py +++ b/custom_addons/encoach_exam_template/controllers/__init__.py @@ -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 diff --git a/custom_addons/encoach_exam_template/controllers/approval_workflows.py b/custom_addons/encoach_exam_template/controllers/approval_workflows.py index d4a9ae8c..22237ef7 100644 --- a/custom_addons/encoach_exam_template/controllers/approval_workflows.py +++ b/custom_addons/encoach_exam_template/controllers/approval_workflows.py @@ -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: diff --git a/custom_addons/encoach_exam_template/controllers/exam_session.py b/custom_addons/encoach_exam_template/controllers/exam_session.py deleted file mode 100644 index 45d9c77b..00000000 --- a/custom_addons/encoach_exam_template/controllers/exam_session.py +++ /dev/null @@ -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//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//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//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//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//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 [] diff --git a/custom_addons/encoach_exam_template/controllers/review_workflow.py b/custom_addons/encoach_exam_template/controllers/review_workflow.py new file mode 100644 index 00000000..5d740c00 --- /dev/null +++ b/custom_addons/encoach_exam_template/controllers/review_workflow.py @@ -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/`` — detail with per-question quality data +* ``POST /api/exam/review//approve`` — publish (optional notes) +* ``POST /api/exam/review//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/ + # ------------------------------------------------------------------ + @http.route('/api/exam/review/', 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//approve + # ------------------------------------------------------------------ + @http.route('/api/exam/review//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//reject + # ------------------------------------------------------------------ + @http.route('/api/exam/review//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) diff --git a/custom_addons/encoach_exam_template/models/__init__.py b/custom_addons/encoach_exam_template/models/__init__.py index 69cf1d50..da8639d1 100644 --- a/custom_addons/encoach_exam_template/models/__init__.py +++ b/custom_addons/encoach_exam_template/models/__init__.py @@ -11,5 +11,4 @@ from . import exam_custom_section from . import exam_assignment from . import exam_schedule from . import exam_structure -from . import student_attempt from . import approval diff --git a/custom_addons/encoach_exam_template/models/exam_custom.py b/custom_addons/encoach_exam_template/models/exam_custom.py index f0b86232..a56c74b2 100644 --- a/custom_addons/encoach_exam_template/models/exam_custom.py +++ b/custom_addons/encoach_exam_template/models/exam_custom.py @@ -39,7 +39,22 @@ class EncoachExamCustom(models.Model): randomize_questions = fields.Boolean(default=False) status = fields.Selection([ ('draft', 'Draft'), + ('pending_review', 'Pending Review'), ('published', 'Published'), ('archived', 'Archived'), ], default='draft', required=True) section_ids = fields.One2many('encoach.exam.custom.section', 'exam_id') + + # Human-in-the-loop review audit trail. Populated by the + # /api/exam/review//(approve|reject) endpoints so we know who signed + # off on an AI-generated exam and what their reasoning was. + reviewed_by_id = fields.Many2one( + 'res.users', ondelete='set null', + string='Reviewed By', + readonly=True, + ) + reviewed_at = fields.Datetime(string='Reviewed At', readonly=True) + review_notes = fields.Text( + string='Review Notes', + help='Approver or rejecter comments. Required when rejecting back to draft.', + ) diff --git a/custom_addons/encoach_exam_template/models/question.py b/custom_addons/encoach_exam_template/models/question.py index 21f0b060..e3c214c3 100644 --- a/custom_addons/encoach_exam_template/models/question.py +++ b/custom_addons/encoach_exam_template/models/question.py @@ -1,4 +1,8 @@ -from odoo import models, fields +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) class EncoachQuestion(models.Model): @@ -67,3 +71,55 @@ class EncoachQuestion(models.Model): format_validated = fields.Boolean(default=False) subject_id = fields.Many2one('encoach.subject', ondelete='set null') topic_id = fields.Many2one('encoach.topic', ondelete='set null') + + ai_model_used = fields.Char( + string='AI Model', + help='LLM model that produced this question (e.g. gpt-4o-mini).', + index=True, + ) + ai_prompt_hash = fields.Char( + string='Prompt Hash', + help='SHA-256 hex digest of the rendered prompt used to generate this question.', + index=True, + ) + ai_log_id = fields.Integer( + string='AI Generation Log ID', + help='Row id in encoach.ai.generation.log (soft ref — no FK to avoid cross-module coupling).', + index=True, + ) + ai_generated_at = fields.Datetime(string='AI Generated At') + quality_score = fields.Float( + string='Quality Score', + help='Aggregate quality score from automated checkers (0-1).', + ) + quality_report = fields.Text( + string='Quality Report (JSON)', + help='JSON dump of the last QualityChecker + IeltsValidator run.', + ) + + @api.model + def _auto_init(self): + res = super()._auto_init() + cr = self.env.cr + for name, ddl in ( + ( + 'encoach_question_skill_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_question_skill_status_idx " + "ON encoach_question (skill, status)", + ), + ( + 'encoach_question_subject_difficulty_idx', + "CREATE INDEX IF NOT EXISTS encoach_question_subject_difficulty_idx " + "ON encoach_question (subject_id, difficulty) WHERE subject_id IS NOT NULL", + ), + ( + 'encoach_question_ai_prompt_hash_idx', + "CREATE INDEX IF NOT EXISTS encoach_question_ai_prompt_hash_idx " + "ON encoach_question (ai_prompt_hash) WHERE ai_prompt_hash IS NOT NULL", + ), + ): + try: + cr.execute(ddl) + except Exception: + _logger.warning("could not create index %s", name, exc_info=True) + return res diff --git a/custom_addons/encoach_exam_template/models/student_attempt.py b/custom_addons/encoach_exam_template/models/student_attempt.py deleted file mode 100644 index 990676e9..00000000 --- a/custom_addons/encoach_exam_template/models/student_attempt.py +++ /dev/null @@ -1,52 +0,0 @@ -from odoo import models, fields - - -class EncoachStudentAttempt(models.Model): - _name = 'encoach.student.attempt' - _description = 'Student Exam Attempt' - _order = 'id desc' - - student_id = fields.Many2one('res.users', required=True, ondelete='cascade') - exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade') - entity_id = fields.Many2one('encoach.entity', ondelete='set null') - status = fields.Selection([ - ('in_progress', 'In Progress'), - ('scoring', 'Scoring'), - ('completed', 'Completed'), - ('abandoned', 'Abandoned'), - ], default='in_progress', required=True) - started_at = fields.Datetime(default=fields.Datetime.now) - finished_at = fields.Datetime() - overall_band = fields.Float() - cefr_level = fields.Char(size=10) - listening_band = fields.Float() - reading_band = fields.Float() - writing_band = fields.Float() - speaking_band = fields.Float() - total_score = fields.Float() - max_score = fields.Float() - - -class EncoachStudentAnswer(models.Model): - _name = 'encoach.student.answer' - _description = 'Student Answer' - - attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade') - question_id = fields.Many2one('encoach.question', required=True, ondelete='cascade') - answer = fields.Text() - score = fields.Float() - is_correct = fields.Boolean() - feedback = fields.Text() - - -class EncoachStudentScore(models.Model): - _name = 'encoach.student.score' - _description = 'Student Skill Score' - - attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade') - skill = fields.Char(size=50, required=True) - band_score = fields.Float() - raw_score = fields.Float() - max_score = fields.Float() - cefr_level = fields.Char(size=10) - entity_id = fields.Many2one('encoach.entity', ondelete='set null') diff --git a/custom_addons/encoach_exam_template/security/ir.model.access.csv b/custom_addons/encoach_exam_template/security/ir.model.access.csv index d65b6a1c..49699ff8 100644 --- a/custom_addons/encoach_exam_template/security/ir.model.access.csv +++ b/custom_addons/encoach_exam_template/security/ir.model.access.csv @@ -12,9 +12,6 @@ access_encoach_exam_custom_section_user,encoach.exam.custom.section.user,model_e access_encoach_exam_assignment_user,encoach.exam.assignment.user,model_encoach_exam_assignment,base.group_user,1,1,1,1 access_encoach_exam_schedule_user,encoach.exam.schedule.user,model_encoach_exam_schedule,base.group_user,1,1,1,1 access_encoach_exam_structure_user,encoach.exam.structure.user,model_encoach_exam_structure,base.group_user,1,1,1,1 -access_encoach_student_attempt_user,encoach.student.attempt.user,model_encoach_student_attempt,base.group_user,1,1,1,1 -access_encoach_student_answer_user,encoach.student.answer.user,model_encoach_student_answer,base.group_user,1,1,1,1 -access_encoach_student_score_user,encoach.student.score.user,model_encoach_student_score,base.group_user,1,1,1,1 access_encoach_approval_workflow_user,encoach.approval.workflow.user,model_encoach_approval_workflow,base.group_user,1,1,1,1 access_encoach_approval_stage_user,encoach.approval.stage.user,model_encoach_approval_stage,base.group_user,1,1,1,1 access_encoach_approval_request_user,encoach.approval.request.user,model_encoach_approval_request,base.group_user,1,1,1,1 diff --git a/custom_addons/encoach_lms_api/__manifest__.py b/custom_addons/encoach_lms_api/__manifest__.py index 97a5f198..904a2f8b 100644 --- a/custom_addons/encoach_lms_api/__manifest__.py +++ b/custom_addons/encoach_lms_api/__manifest__.py @@ -20,6 +20,7 @@ 'openeducat_library', 'encoach_core', 'encoach_api', + 'encoach_ai', 'encoach_taxonomy', 'encoach_resources', ], diff --git a/custom_addons/encoach_lms_api/controllers/__init__.py b/custom_addons/encoach_lms_api/controllers/__init__.py index 11d0f73a..37d22d5d 100644 --- a/custom_addons/encoach_lms_api/controllers/__init__.py +++ b/custom_addons/encoach_lms_api/controllers/__init__.py @@ -22,6 +22,7 @@ from . import faq from . import stats from . import tickets from . import payments +from . import paymob from . import platform_settings from . import training from . import reports diff --git a/custom_addons/encoach_lms_api/controllers/attendance.py b/custom_addons/encoach_lms_api/controllers/attendance.py index 9bddf34a..04cd43c9 100644 --- a/custom_addons/encoach_lms_api/controllers/attendance.py +++ b/custom_addons/encoach_lms_api/controllers/attendance.py @@ -36,7 +36,7 @@ class AttendanceController(http.Controller): domain.append(('attendance_id.attendance_date', '=', kw['date'])) recs = M.search(domain, limit=200, order='id desc') data = [_ser_att(r) for r in recs] - return _json_response({'data': data}) + return _json_response({'items': data, 'data': data, 'total': len(data)}) except Exception as e: _logger.exception('list_attendance') return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/grades.py b/custom_addons/encoach_lms_api/controllers/grades.py index eb89027a..293de817 100644 --- a/custom_addons/encoach_lms_api/controllers/grades.py +++ b/custom_addons/encoach_lms_api/controllers/grades.py @@ -35,7 +35,7 @@ class GradesController(http.Controller): 'date': str(r.create_date.date()) if r.create_date else '', 'type': r.state or 'graded', }) - return _json_response({'data': data}) + return _json_response({'items': data, 'data': data, 'total': len(data)}) except Exception as e: _logger.exception('list_grades') return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/payments.py b/custom_addons/encoach_lms_api/controllers/payments.py index ea730212..9af4735c 100644 --- a/custom_addons/encoach_lms_api/controllers/payments.py +++ b/custom_addons/encoach_lms_api/controllers/payments.py @@ -116,10 +116,36 @@ class PaymentRecordsController(http.Controller): methods=['GET'], csrf=False) @jwt_required def list_paymob_orders(self, **kw): - """Paymob integration is not yet wired — return empty list. - This endpoint exists so the UI renders cleanly instead of calling a - 404-returning route.""" - return _json_response({ - 'data': [], 'items': [], 'total': 0, - 'message': 'Paymob integration not configured.', - }) + """List Paymob orders for the calling user. + + Admins see all orders; regular users see only their own. Orders are + populated by the paymob.py controller's checkout + webhook flow. + """ + try: + Order = request.env['encoach.paymob.order'].sudo() + domain = [] + if not request.env.user.has_group('base.group_system'): + domain.append(('user_id', '=', request.env.user.id)) + rows = Order.search(domain, order='create_date desc', limit=200) + items = [{ + 'id': r.id, + 'reference': r.reference, + 'amount': float(r.amount_cents or 0) / 100.0, + 'currency': r.currency, + 'state': r.state, + 'paid': r.state == 'paid', + 'description': r.description or '', + 'product_ref': r.product_ref or '', + 'paymob_order_id': r.paymob_order_id or '', + 'user_id': r.user_id.id if r.user_id else None, + 'user_name': r.user_id.name if r.user_id else '', + 'date': str(r.create_date) if r.create_date else '', + 'last_event_at': str(r.last_event_at) if r.last_event_at else '', + 'type': 'Paymob', + } for r in rows] + return _json_response({ + 'data': items, 'items': items, 'total': len(items), + }) + except Exception as e: + _logger.exception('list_paymob_orders') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/paymob.py b/custom_addons/encoach_lms_api/controllers/paymob.py new file mode 100644 index 00000000..32f09c40 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/paymob.py @@ -0,0 +1,351 @@ +"""Paymob checkout initiator + HMAC-verified webhook. + +This module talks to the `Paymob Accept `_ API: + +1. ``POST /api/payments/paymob/checkout`` + Authenticates with the merchant API key, registers an order, and + generates a payment key. Returns the ``payment_key`` + the hosted iframe + URL the frontend should redirect the user to. + +2. ``POST /api/payments/paymob/webhook`` + Verifies Paymob's HMAC-SHA512 signature against a concatenated subset + of the transaction fields (per Paymob docs), marks the matching + ``encoach.paymob.order`` as ``paid`` or ``failed``, and idempotently + no-ops on duplicate deliveries. + +Configuration (via ``ir.config_parameter``): + +* ``encoach.paymob.api_key`` — merchant API key +* ``encoach.paymob.hmac_secret`` — HMAC secret (from the Paymob dashboard) +* ``encoach.paymob.integration_id`` — default integration id (card) +* ``encoach.paymob.iframe_id`` — hosted iframe id for redirect URL + +All secrets are read at request time so operators can rotate them without +restarting Odoo. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +from typing import Any + +import requests + +from odoo import fields, http +from odoo.http import Response, request + +from odoo.addons.encoach_api.controllers.base import ( + _error_response, + _get_json_body, + _json_response, + jwt_required, +) + +_logger = logging.getLogger(__name__) + +PAYMOB_BASE = "https://accept.paymob.com/api" + +# Fields Paymob concatenates (in this exact order) to compute the HMAC we +# receive on webhook callbacks. Source: Paymob Accept docs, Transaction +# Processed Callback → "HMAC Calculation" section. +HMAC_FIELDS = [ + "amount_cents", + "created_at", + "currency", + "error_occured", + "has_parent_transaction", + "id", + "integration_id", + "is_3d_secure", + "is_auth", + "is_capture", + "is_refunded", + "is_standalone_payment", + "is_voided", + "order.id", + "owner", + "pending", + "source_data.pan", + "source_data.sub_type", + "source_data.type", + "success", +] + + +def _config(key: str, default: str = "") -> str: + return ( + request.env["ir.config_parameter"].sudo().get_param(f"encoach.paymob.{key}", default) + or "" + ) + + +def _paymob_post(path: str, body: dict, timeout: int = 15) -> dict: + url = f"{PAYMOB_BASE}{path}" + resp = requests.post(url, json=body, timeout=timeout) + if resp.status_code >= 400: + raise RuntimeError(f"Paymob {path} → {resp.status_code}: {resp.text[:300]}") + try: + return resp.json() + except ValueError as exc: + raise RuntimeError(f"Paymob {path} returned non-JSON: {resp.text[:300]}") from exc + + +def _get_nested(obj: dict, dotted: str) -> Any: + """Walk 'a.b.c' through nested dicts, returning '' on any miss.""" + cur: Any = obj + for part in dotted.split("."): + if not isinstance(cur, dict): + return "" + cur = cur.get(part, "") + return cur if cur is not None else "" + + +def _compute_hmac(obj: dict, secret: str) -> str: + """Reproduce Paymob's HMAC-SHA512 over the canonical field sequence.""" + parts = [str(_get_nested(obj, f)).lower() if isinstance(_get_nested(obj, f), bool) + else str(_get_nested(obj, f)) + for f in HMAC_FIELDS] + message = "".join(parts).encode("utf-8") + return hmac.new(secret.encode("utf-8"), message, hashlib.sha512).hexdigest() + + +class EncoachPaymobController(http.Controller): + """Checkout + webhook endpoints.""" + + # ------------------------------------------------------------------ + # POST /api/payments/paymob/checkout + # + # Body: { amount_cents, currency, product_ref?, description?, integration_id? } + # ------------------------------------------------------------------ + @http.route( + "/api/payments/paymob/checkout", + type="http", auth="none", methods=["POST"], csrf=False, + ) + @jwt_required + def checkout(self, **kw): + try: + api_key = _config("api_key") + iframe_id = _config("iframe_id") + default_integration = _config("integration_id") + if not api_key or not iframe_id or not default_integration: + return _error_response( + "Paymob is not configured. " + "Set encoach.paymob.api_key / hmac_secret / integration_id / iframe_id " + "in System Parameters.", + 503, + ) + + body = _get_json_body() or {} + try: + amount_cents = int(body.get("amount_cents") or 0) + except (TypeError, ValueError): + return _error_response("amount_cents must be an integer", 400) + if amount_cents <= 0: + return _error_response("amount_cents must be positive", 400) + + currency = (body.get("currency") or "EGP").upper() + description = body.get("description") or "" + product_ref = body.get("product_ref") or "" + integration_id = str(body.get("integration_id") or default_integration) + + user = request.env.user + partner = user.partner_id + + # Create our own tracking row *before* calling Paymob so we never + # lose orders to partial failures. + order = request.env["encoach.paymob.order"].sudo().create({ + "reference": f"enc-{user.id}-{int(fields.Datetime.now().timestamp())}", + "user_id": user.id, + "partner_id": partner.id if partner else False, + "amount_cents": amount_cents, + "currency": currency, + "description": description, + "product_ref": product_ref, + "integration_id": integration_id, + "state": "draft", + }) + + # Step 1 — authenticate with the merchant API key. + auth = _paymob_post("/auth/tokens", {"api_key": api_key}) + auth_token = auth.get("token") + if not auth_token: + raise RuntimeError("Paymob auth returned no token") + + # Step 2 — register the order with Paymob. + order_payload = { + "auth_token": auth_token, + "delivery_needed": False, + "amount_cents": amount_cents, + "currency": currency, + "items": [], + "merchant_order_id": order.reference, + } + paymob_order = _paymob_post("/ecommerce/orders", order_payload) + paymob_order_id = str(paymob_order.get("id") or "") + if not paymob_order_id: + raise RuntimeError("Paymob order registration returned no id") + + # Step 3 — generate a payment key. + billing = { + "first_name": (user.name or "Guest").split(" ")[0][:50] or "Guest", + "last_name": ( + " ".join((user.name or "User").split(" ")[1:])[:50] or "User" + ), + "email": user.email or "no-reply@encoach.invalid", + "phone_number": partner.phone or partner.mobile or "+201000000000", + "apartment": "NA", "floor": "NA", "street": "NA", + "building": "NA", "shipping_method": "NA", "postal_code": "NA", + "city": "NA", "country": "EG", "state": "NA", + } + pk_payload = { + "auth_token": auth_token, + "amount_cents": amount_cents, + "currency": currency, + "order_id": paymob_order_id, + "billing_data": billing, + "integration_id": int(integration_id), + "lock_order_when_paid": True, + } + pk = _paymob_post("/acceptance/payment_keys", pk_payload) + payment_key = pk.get("token") + if not payment_key: + raise RuntimeError("Paymob payment_keys returned no token") + + iframe_url = ( + f"https://accept.paymob.com/api/acceptance/iframes/" + f"{iframe_id}?payment_token={payment_key}" + ) + + order.write({ + "paymob_order_id": paymob_order_id, + "payment_key": payment_key, + "state": "initiated", + }) + + return _json_response({ + "order_id": order.id, + "reference": order.reference, + "paymob_order_id": paymob_order_id, + "payment_key": payment_key, + "iframe_url": iframe_url, + }) + except Exception as e: + _logger.exception("paymob checkout failed") + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/payments/paymob/webhook + # + # Paymob delivers the full transaction JSON with an ``hmac`` query-string + # parameter. We verify the HMAC over a canonical subset of fields and + # then update the matching order. + # ------------------------------------------------------------------ + @http.route( + "/api/payments/paymob/webhook", + type="http", auth="public", methods=["POST"], csrf=False, + ) + def webhook(self, **kw): + try: + secret = _config("hmac_secret") + if not secret: + _logger.error("paymob webhook: hmac secret not configured") + return Response(status=503) + + received_hmac = (kw.get("hmac") or "").strip() + if not received_hmac: + return Response(status=400) + + raw = (request.httprequest.data or b"") + try: + payload = json.loads(raw.decode("utf-8") or "{}") + except Exception: + return Response(status=400) + + # Paymob's payload envelope is: + # { "type": "TRANSACTION", "obj": { ...fields... } } + inner = payload.get("obj") or payload + + expected_hmac = _compute_hmac(inner, secret) + if not hmac.compare_digest(expected_hmac, received_hmac): + _logger.warning( + "paymob webhook: HMAC mismatch (expected=%s… got=%s…)", + expected_hmac[:16], received_hmac[:16], + ) + return Response(status=401) + + merchant_order_ref = ( + _get_nested(inner, "order.merchant_order_id") + or inner.get("merchant_order_id") + or "" + ) + paymob_order_id = str(_get_nested(inner, "order.id") or "") + success = bool(inner.get("success")) + + Order = request.env["encoach.paymob.order"].sudo() + order = False + if merchant_order_ref: + order = Order.search([("reference", "=", merchant_order_ref)], limit=1) + if not order and paymob_order_id: + order = Order.search([("paymob_order_id", "=", paymob_order_id)], limit=1) + if not order: + _logger.warning( + "paymob webhook: no matching order for ref=%s paymob=%s", + merchant_order_ref, paymob_order_id, + ) + # Return 200 so Paymob doesn't retry indefinitely — the event + # will still be visible in their dashboard. + return Response(status=200) + + if order.state == "paid": + # Idempotent: duplicate delivery. + return Response(status=200) + + payload_json = json.dumps(inner) + if success: + order.mark_paid(payload_json, received_hmac) + else: + order.mark_failed(payload_json, received_hmac) + + return Response(status=200) + except Exception: + _logger.exception("paymob webhook handler crashed") + return Response(status=500) + + # ------------------------------------------------------------------ + # GET /api/payments/paymob/orders — list my orders + # ------------------------------------------------------------------ + @http.route( + "/api/payments/paymob/orders", + type="http", auth="none", methods=["GET"], csrf=False, + ) + @jwt_required + def my_orders(self, **kw): + try: + Order = request.env["encoach.paymob.order"].sudo() + domain = [("user_id", "=", request.env.user.id)] + rows = Order.search(domain, order="create_date desc", limit=200) + items = [{ + "id": r.id, + "reference": r.reference, + "paymob_order_id": r.paymob_order_id or "", + "amount_cents": r.amount_cents, + "currency": r.currency, + "description": r.description or "", + "product_ref": r.product_ref or "", + "state": r.state, + "create_date": fields.Datetime.to_string(r.create_date) if r.create_date else None, + "last_event_at": ( + fields.Datetime.to_string(r.last_event_at) if r.last_event_at else None + ), + } for r in rows] + return _json_response({ + "items": items, + "data": items, + "total": len(items), + }) + except Exception as e: + _logger.exception("paymob my_orders failed") + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/reports.py b/custom_addons/encoach_lms_api/controllers/reports.py index f6984cd8..953c1a69 100644 --- a/custom_addons/encoach_lms_api/controllers/reports.py +++ b/custom_addons/encoach_lms_api/controllers/reports.py @@ -23,35 +23,24 @@ from odoo.http import request from odoo.addons.encoach_api.controllers.base import ( jwt_required, _json_response, _error_response, _paginate, ) +from odoo.addons.encoach_api.utils.cache import get as _cache_get, put as _cache_put _logger = logging.getLogger(__name__) REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released') -# CEFR ordering + band ranges used when an attempt didn't store cefr_level. -_CEFR_BY_BAND = [ - (9.0, 'C2'), (7.0, 'C1'), (5.5, 'B2'), - (4.0, 'B1'), (3.0, 'A2'), (2.0, 'A1'), (0.0, 'Pre-A1'), -] +from odoo.addons.encoach_ai.services.cefr_mapper import ( + band_to_cefr as _cefr_code_for_band, + normalize_cefr as _normalize_cefr, +) def _cefr_for_band(band): - if band is None: + """Return the display-form CEFR label (e.g. 'B2') for an IELTS band.""" + code = _cefr_code_for_band(band) + if code is None: return None - for threshold, label in _CEFR_BY_BAND: - if band >= threshold: - return label - return 'Pre-A1' - - -def _normalize_cefr(label): - if not label: - return None - s = str(label).strip().lower().replace('-', '_') - return { - 'pre_a1': 'Pre-A1', 'a1': 'A1', 'a2': 'A2', - 'b1': 'B1', 'b2': 'B2', 'c1': 'C1', 'c2': 'C2', - }.get(s, label) + return _normalize_cefr(code) or code def _duration_minutes(att): @@ -232,6 +221,23 @@ class ReportsController(http.Controller): include), ``months`` (trend horizon; default 6). """ try: + # 30s TTL cache: the stats dashboard is pinged from multiple widgets + # on every page view. Invalidated implicitly once the TTL expires; + # write-heavy endpoints (exam release, attempt score commit) can + # call ``cache.invalidate("reports.stats_corporate")`` if we ever + # need stronger consistency. + cache_key = ( + "reports.stats_corporate:" + f"entity={kw.get('entity_id') or ''};" + f"user={kw.get('user_id') or kw.get('student_id') or ''};" + f"thr={kw.get('threshold') or ''};" + f"months={kw.get('months') or ''};" + f"period={kw.get('period') or ''}" + ) + cached = _cache_get(cache_key) + if cached is not None: + return _json_response(cached) + Att = request.env['encoach.student.attempt'].sudo() domain = _build_attempt_domain(kw) @@ -242,55 +248,88 @@ class ReportsController(http.Controller): if threshold > 0: domain.append(('overall_band', '>=', threshold / 10.0)) - attempts = Att.search(domain) - - skill_totals = {k: [0.0, 0] for k in - ('reading', 'listening', 'writing', 'speaking')} - for att in attempts: - for skill, field in (('reading', att.reading_band), - ('listening', att.listening_band), - ('writing', att.writing_band), - ('speaking', att.speaking_band)): - if field is not None and field > 0: - skill_totals[skill][0] += field - skill_totals[skill][1] += 1 - + # --- Per-skill average via SQL aggregation ------------------ + # Previously each of the four skill averages required pulling the + # full attempt rowset into Python and summing per record. This now + # runs as a single ``read_group`` per skill (avg in SQL), dropping + # the hot path from O(N) Python iterations to a constant number of + # round-trips regardless of attempt volume. by_module = [] - for skill in ('Reading', 'Listening', 'Writing', 'Speaking'): - total, n = skill_totals[skill.lower()] - avg = round((total / n) * 10, 1) if n else 0 - by_module.append({'module': skill, 'score': avg, 'n': n}) + skill_attempts_considered = 0 + for display, fname in ( + ('Reading', 'reading_band'), + ('Listening', 'listening_band'), + ('Writing', 'writing_band'), + ('Speaking', 'speaking_band'), + ): + agg_domain = domain + [(fname, '>', 0)] + rows = Att.read_group( + agg_domain, + fields=[f'{fname}:avg'], + groupby=[], + ) + if rows: + row = rows[0] + avg = row.get(fname) or 0 + n = row.get('__count') or Att.search_count(agg_domain) + else: + avg, n = 0, 0 + by_module.append({ + 'module': display, + 'score': round(avg * 10, 1) if n else 0, + 'n': n, + }) + skill_attempts_considered = max(skill_attempts_considered, n) + # --- Monthly trend via read_group by month ------------------ try: months = max(1, min(24, int(kw.get('months') or 6))) except (TypeError, ValueError): months = 6 - - month_buckets = defaultdict(lambda: [0.0, 0]) - for att in attempts: - ct = _attempt_completed_at(att) or att.started_at - if not ct or att.overall_band is None: + trend_domain = domain + [('overall_band', '!=', False)] + month_rows = Att.read_group( + trend_domain, + fields=['overall_band:avg'], + groupby=['started_at:month'], + orderby='started_at:month asc', + ) + month_buckets = {} + for row in month_rows: + label = row.get('started_at:month') or row.get('started_at_month') + if not label: continue - key = ct.strftime('%Y-%m') - month_buckets[key][0] += att.overall_band - month_buckets[key][1] += 1 + try: + ref = datetime.strptime(label, '%B %Y') + except ValueError: + continue + key = ref.strftime('%Y-%m') + month_buckets[key] = (row.get('overall_band') or 0.0, + row.get('__count') or 0) trend = [] today = datetime.now().replace(day=1) for i in range(months - 1, -1, -1): ref = (today - timedelta(days=31 * i)).replace(day=1) key = ref.strftime('%Y-%m') - total, n = month_buckets.get(key, (0.0, 0)) - avg = round((total / n) * 10, 1) if n else 0 - trend.append({'month': ref.strftime('%b'), 'avg': avg, - 'period': key, 'n': n}) + avg_val, n = month_buckets.get(key, (0.0, 0)) + trend.append({ + 'month': ref.strftime('%b'), + 'avg': round(avg_val * 10, 1) if n else 0, + 'period': key, + 'n': n, + }) - level_buckets = defaultdict(int) - for att in attempts: - label = _normalize_cefr(att.cefr_level) \ - or _cefr_for_band(att.overall_band) + # --- CEFR distribution via read_group by cefr_level --------- + dist_rows = Att.read_group( + domain + [('cefr_level', '!=', False)], + fields=[], + groupby=['cefr_level'], + ) + level_buckets = {} + for row in dist_rows: + label = _normalize_cefr(row.get('cefr_level')) if label: - level_buckets[label] += 1 + level_buckets[label] = level_buckets.get(label, 0) + row.get('__count', 0) palette = { 'Pre-A1': 'hsl(0, 0%, 55%)', 'A1': 'hsl(0, 72%, 51%)', @@ -310,35 +349,39 @@ class ReportsController(http.Controller): for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2') ] - entity_buckets = defaultdict(lambda: [0.0, 0, 'Unassigned']) - for att in attempts: - if att.overall_band is None: - continue - eid = att.entity_id.id or 0 - entity_buckets[eid][0] += att.overall_band - entity_buckets[eid][1] += 1 - entity_buckets[eid][2] = att.entity_id.name or 'Unassigned' + # --- Entity comparison via read_group by entity_id ---------- + entity_rows = Att.read_group( + domain + [('overall_band', '!=', False)], + fields=['overall_band:avg'], + groupby=['entity_id'], + ) comparison = [] - for eid, (total, n, name) in entity_buckets.items(): + for row in entity_rows: + entity = row.get('entity_id') or (None, 'Unassigned') + eid, ename = (entity[0], entity[1]) if isinstance(entity, (list, tuple)) else (None, 'Unassigned') + avg_val = row.get('overall_band') or 0 comparison.append({ - 'entity_id': eid or None, - 'entity_name': name, - 'avg': round((total / n) * 10, 1) if n else 0, - 'attempts': n, + 'entity_id': eid, + 'entity_name': ename or 'Unassigned', + 'avg': round(avg_val * 10, 1), + 'attempts': row.get('__count') or 0, }) comparison.sort(key=lambda r: -r['avg']) - return _json_response({ + total_considered = Att.search_count(domain) + payload = { 'by_module': by_module, 'trend': trend, 'distribution': distribution, 'comparison': comparison, 'meta': { - 'attempts_considered': len(attempts), + 'attempts_considered': total_considered, 'threshold': threshold, 'months': months, }, - }) + } + _cache_put(cache_key, payload, ttl_seconds=30) + return _json_response(payload) except Exception as e: _logger.exception('stats-corporate report failed') return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/timetable.py b/custom_addons/encoach_lms_api/controllers/timetable.py index a8982551..f2b7ba0a 100644 --- a/custom_addons/encoach_lms_api/controllers/timetable.py +++ b/custom_addons/encoach_lms_api/controllers/timetable.py @@ -37,7 +37,7 @@ class TimetableController(http.Controller): domain.append(('batch_id', '=', int(kw['batch_id']))) recs = M.search(domain, limit=200, order='id desc') data = [_ser_session(r) for r in recs] - return _json_response({'data': data}) + return _json_response({'items': data, 'data': data, 'total': len(data)}) except Exception as e: _logger.exception('list_timetable') return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/users_roles.py b/custom_addons/encoach_lms_api/controllers/users_roles.py index cfa49195..4931679b 100644 --- a/custom_addons/encoach_lms_api/controllers/users_roles.py +++ b/custom_addons/encoach_lms_api/controllers/users_roles.py @@ -192,7 +192,7 @@ class UsersRolesController(http.Controller): total = M.search_count(domain) recs = M.search(domain, offset=offset, limit=limit, order='id desc') items = [_ser_role(r) for r in recs] - return _json_response({'data': items, 'total': total, 'page': page, 'size': limit}) + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) except Exception as e: return _error_response(str(e), 500) @@ -299,7 +299,7 @@ class UsersRolesController(http.Controller): domain.append(('code', 'ilike', kw['search'])) recs = M.search(domain, limit=500, order='topic, code') items = [_ser_perm(r) for r in recs] - return _json_response({'data': items, 'total': len(items)}) + return _json_response({'items': items, 'data': items, 'total': len(items)}) except Exception as e: return _error_response(str(e), 500) @@ -417,7 +417,7 @@ class UsersRolesController(http.Controller): 'roles': [{'id': r.id, 'name': r.name} for r in u.role_ids], 'effective_permission_count': len(all_perms), }) - return _json_response({'data': items, 'total': total, 'page': page, 'size': limit}) + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) except Exception as e: _logger.exception('list_users_with_roles') return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/models/__init__.py b/custom_addons/encoach_lms_api/models/__init__.py index 34227c38..b9ed3cb3 100644 --- a/custom_addons/encoach_lms_api/models/__init__.py +++ b/custom_addons/encoach_lms_api/models/__init__.py @@ -9,3 +9,4 @@ from . import course_ext from . import asset from . import ticket from . import training +from . import paymob_order diff --git a/custom_addons/encoach_lms_api/models/paymob_order.py b/custom_addons/encoach_lms_api/models/paymob_order.py new file mode 100644 index 00000000..f4c036ae --- /dev/null +++ b/custom_addons/encoach_lms_api/models/paymob_order.py @@ -0,0 +1,92 @@ +"""Paymob payment order tracking. + +We keep an auditable trail of every checkout we initiate with Paymob so we +can (a) show users the status of their payments, (b) reconcile against the +Paymob dashboard, and (c) prove/dispute webhook events with the signed +HMAC we stored. + +Lifecycle: + +* ``draft`` — row created but no Paymob API call made yet +* ``initiated`` — authentication + order registration succeeded, we have a + ``payment_key`` we can redirect the user to +* ``paid`` — the ``TRANSACTION`` webhook fired with ``success=true`` + and a verified HMAC +* ``failed`` — webhook fired with ``success=false`` +* ``cancelled`` — user abandoned / timed out (set manually or by cron) +""" + +from odoo import fields, models + + +class EncoachPaymobOrder(models.Model): + _name = "encoach.paymob.order" + _description = "Paymob payment order" + _order = "create_date desc" + _rec_name = "reference" + + reference = fields.Char( + required=True, index=True, + help="Our merchant order reference; included in the Paymob request.", + ) + user_id = fields.Many2one( + "res.users", required=True, ondelete="restrict", index=True, + ) + partner_id = fields.Many2one("res.partner", ondelete="set null") + + # Commerce fields + amount_cents = fields.Integer( + required=True, + help="Amount in smallest currency unit (piastres for EGP).", + ) + currency = fields.Char(required=True, default="EGP") + description = fields.Char() + product_ref = fields.Char( + help="Free-text link back to the thing being bought " + "(e.g. 'subscription:pro-monthly').", + ) + + # Paymob identifiers + paymob_order_id = fields.Char(index=True) + payment_key = fields.Text() + payment_key_expires_at = fields.Datetime() + integration_id = fields.Char( + help="Which Paymob integration was used (card / wallet / kiosk...)", + ) + + # State + state = fields.Selection( + [ + ("draft", "Draft"), + ("initiated", "Initiated"), + ("paid", "Paid"), + ("failed", "Failed"), + ("cancelled", "Cancelled"), + ], + default="draft", required=True, index=True, + ) + last_event_at = fields.Datetime() + last_event_payload = fields.Text( + help="JSON of the latest webhook payload we received for this order.", + ) + last_event_hmac = fields.Char( + help="HMAC we verified against the payload; stored for audit.", + ) + + def mark_paid(self, payload, hmac): + self.ensure_one() + self.write({ + "state": "paid", + "last_event_at": fields.Datetime.now(), + "last_event_payload": payload, + "last_event_hmac": hmac, + }) + + def mark_failed(self, payload, hmac): + self.ensure_one() + self.write({ + "state": "failed", + "last_event_at": fields.Datetime.now(), + "last_event_payload": payload, + "last_event_hmac": hmac, + }) diff --git a/custom_addons/encoach_lms_api/models/ticket.py b/custom_addons/encoach_lms_api/models/ticket.py index c2ddcd28..39b1767a 100644 --- a/custom_addons/encoach_lms_api/models/ticket.py +++ b/custom_addons/encoach_lms_api/models/ticket.py @@ -1,4 +1,8 @@ -from odoo import fields, models +import logging + +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) class EncoachTicket(models.Model): @@ -35,3 +39,129 @@ class EncoachTicket(models.Model): entity_id = fields.Many2one('encoach.entity', string='Entity') resolved_at = fields.Datetime('Resolved At') + + STATUS_LABELS = { + 'open': 'Open', + 'in_progress': 'In Progress', + 'resolved': 'Resolved', + 'closed': 'Closed', + } + + def _notify_ticket_event(self, event, *, old_status=None, new_status=None, + old_assignee=None, new_assignee=None): + """Post a chatter message and push a bus event for status/assignee changes. + + Uses :class:`bus.bus` when available so both the reporter and the new + assignee see near real-time updates in the frontend, and always falls + back to ``mail.thread`` chatter so the change is auditable even when + the bus is unavailable (e.g. during cron jobs or tests). + """ + self.ensure_one() + try: + if event == 'status': + body = _("Status: %s → %s") % ( + self.STATUS_LABELS.get(old_status, old_status or '—'), + self.STATUS_LABELS.get(new_status, new_status or '—'), + ) + elif event == 'assignee': + old_name = old_assignee.display_name if old_assignee else _('Unassigned') + new_name = new_assignee.display_name if new_assignee else _('Unassigned') + body = _("Assignee: %s → %s") % (old_name, new_name) + else: + return + self.message_post(body=body, subtype_xmlid='mail.mt_note') + except Exception: + _logger.exception("ticket %s: chatter notify failed", self.id) + + try: + bus = self.env['bus.bus'].sudo() + recipients = set() + if self.reporter_id: + recipients.add(('res.partner', self.reporter_id.partner_id.id)) + for user in (old_assignee, new_assignee, self.assignee_id): + if user and user.partner_id: + recipients.add(('res.partner', user.partner_id.id)) + payload = { + 'type': 'encoach.ticket', + 'event': event, + 'ticket_id': self.id, + 'subject': self.subject, + 'status': self.status, + 'assignee_id': self.assignee_id.id or False, + 'old_status': old_status, + 'new_status': new_status, + } + for channel in recipients: + bus._sendone(channel, 'encoach.ticket/notify', payload) + except Exception: + _logger.debug("ticket %s: bus push skipped", self.id, exc_info=True) + + def write(self, vals): + tracked = {} + if vals.get('status') or vals.get('assignee_id') is not None: + for rec in self: + tracked[rec.id] = { + 'status': rec.status, + 'assignee': rec.assignee_id, + } + res = super().write(vals) + if not tracked: + return res + + now = fields.Datetime.now() + for rec in self: + before = tracked.get(rec.id) or {} + old_status = before.get('status') + old_assignee = before.get('assignee') + if 'status' in vals and old_status != rec.status: + if rec.status in ('resolved', 'closed') and not rec.resolved_at: + rec.resolved_at = now + rec._notify_ticket_event( + 'status', old_status=old_status, new_status=rec.status, + ) + if 'assignee_id' in vals and (old_assignee or rec.assignee_id) and \ + (old_assignee.id if old_assignee else False) != (rec.assignee_id.id or False): + rec._notify_ticket_event( + 'assignee', old_assignee=old_assignee, new_assignee=rec.assignee_id, + ) + return res + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for rec in records: + try: + rec.message_post( + body=_("Ticket opened by %s") % rec.reporter_id.display_name, + subtype_xmlid='mail.mt_note', + ) + except Exception: + _logger.debug("ticket %s: initial chatter failed", rec.id, exc_info=True) + return records + + @api.model + def _auto_init(self): + res = super()._auto_init() + cr = self.env.cr + for name, ddl in ( + ( + 'encoach_ticket_entity_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_ticket_entity_status_idx " + "ON encoach_ticket (entity_id, status) WHERE entity_id IS NOT NULL", + ), + ( + 'encoach_ticket_reporter_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_ticket_reporter_status_idx " + "ON encoach_ticket (reporter_id, status)", + ), + ( + 'encoach_ticket_assignee_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_ticket_assignee_status_idx " + "ON encoach_ticket (assignee_id, status) WHERE assignee_id IS NOT NULL", + ), + ): + try: + cr.execute(ddl) + except Exception: + _logger.warning("could not create index %s", name, exc_info=True) + return res diff --git a/custom_addons/encoach_lms_api/security/ir.model.access.csv b/custom_addons/encoach_lms_api/security/ir.model.access.csv index 98c7296e..f6e23d74 100644 --- a/custom_addons/encoach_lms_api/security/ir.model.access.csv +++ b/custom_addons/encoach_lms_api/security/ir.model.access.csv @@ -23,3 +23,5 @@ access_encoach_vocab_item_all,encoach.vocab.item.all,model_encoach_vocab_item,ba access_encoach_vocab_progress_all,encoach.vocab.progress.all,model_encoach_vocab_progress,base.group_user,1,1,1,1 access_encoach_grammar_rule_all,encoach.grammar.rule.all,model_encoach_grammar_rule,base.group_user,1,1,1,1 access_encoach_grammar_progress_all,encoach.grammar.progress.all,model_encoach_grammar_progress,base.group_user,1,1,1,1 +access_encoach_paymob_order_user,encoach.paymob.order.user,model_encoach_paymob_order,base.group_user,1,0,1,0 +access_encoach_paymob_order_admin,encoach.paymob.order.admin,model_encoach_paymob_order,base.group_system,1,1,1,1 diff --git a/custom_addons/encoach_placement/__manifest__.py b/custom_addons/encoach_placement/__manifest__.py index 1a3b7cc2..8e36291c 100644 --- a/custom_addons/encoach_placement/__manifest__.py +++ b/custom_addons/encoach_placement/__manifest__.py @@ -5,7 +5,7 @@ 'summary': 'Computerized Adaptive Testing (CAT) placement engine with CEFR mapping', 'author': 'EnCoach', 'license': 'LGPL-3', - 'depends': ['encoach_core', 'encoach_exam_template'], + 'depends': ['encoach_core', 'encoach_exam_template', 'encoach_ai'], 'data': [ 'security/ir.model.access.csv', 'views/cat_session_views.xml', diff --git a/custom_addons/encoach_placement/controllers/placement.py b/custom_addons/encoach_placement/controllers/placement.py index b80c4af2..8dba9c64 100644 --- a/custom_addons/encoach_placement/controllers/placement.py +++ b/custom_addons/encoach_placement/controllers/placement.py @@ -8,6 +8,7 @@ from odoo.http import request from odoo.addons.encoach_api.controllers.base import ( jwt_required, _json_response, _error_response, _get_json_body, _paginate ) +from odoo.addons.encoach_ai.services.cefr_mapper import theta_to_cefr as _theta_to_cefr _logger = logging.getLogger(__name__) @@ -15,18 +16,6 @@ LEARNING_RATE = 0.3 SEM_THRESHOLD = 0.3 MAX_QUESTIONS = 40 -THETA_CEFR = [ - (-3.0, 'pre_a1'), (-2.0, 'a1'), (-1.0, 'a2'), - (0.0, 'b1'), (1.0, 'b2'), (2.0, 'c1'), (3.0, 'c2'), -] - - -def _theta_to_cefr(theta): - for boundary, level in THETA_CEFR: - if theta <= boundary: - return level - return 'c2' - def _irt_probability(theta, a, b, c): """3PL IRT probability.""" diff --git a/custom_addons/encoach_placement/services/cefr_mapper.py b/custom_addons/encoach_placement/services/cefr_mapper.py index bce06815..face12bf 100644 --- a/custom_addons/encoach_placement/services/cefr_mapper.py +++ b/custom_addons/encoach_placement/services/cefr_mapper.py @@ -1,83 +1,20 @@ -class CefrMapper: - """Maps IRT theta values to CEFR levels and IELTS band scores.""" +"""Thin re-export of the canonical CEFR mapper for backward compat. - THETA_TO_CEFR = [ - (-4.0, -2.5, 'pre_a1'), - (-2.5, -1.5, 'a1'), - (-1.5, -0.5, 'a2'), - (-0.5, 0.5, 'b1'), - (0.5, 1.5, 'b2'), - (1.5, 2.5, 'c1'), - (2.5, 4.0, 'c2'), - ] +The canonical implementation lives in ``encoach_ai.services.cefr_mapper``. +This shim is kept so existing imports like +``from odoo.addons.encoach_placement.services.cefr_mapper import CefrMapper`` +continue to work. See P0.9 in §21 of docs/PROJECT_SUMMARY.md. +""" - CEFR_TO_BAND = { - 'pre_a1': 2.0, - 'a1': 3.0, - 'a2': 4.0, - 'b1': 5.0, - 'b2': 6.5, - 'c1': 7.5, - 'c2': 9.0, - } - - CEFR_LABELS = { - 'pre_a1': 'Pre-A1 (Beginner)', - 'a1': 'A1 (Elementary)', - 'a2': 'A2 (Pre-Intermediate)', - 'b1': 'B1 (Intermediate)', - 'b2': 'B2 (Upper-Intermediate)', - 'c1': 'C1 (Advanced)', - 'c2': 'C2 (Proficient)', - } - - @staticmethod - def theta_to_cefr(theta): - for low, high, level in CefrMapper.THETA_TO_CEFR: - if low <= theta < high: - return level - return 'c2' if theta >= 2.5 else 'pre_a1' - - @staticmethod - def theta_to_band(theta): - cefr = CefrMapper.theta_to_cefr(theta) - base_band = CefrMapper.CEFR_TO_BAND.get(cefr, 5.0) - - for low, high, level in CefrMapper.THETA_TO_CEFR: - if level == cefr: - range_width = high - low - if range_width > 0: - position = (theta - low) / range_width - else: - position = 0.5 - - cefr_list = list(CefrMapper.CEFR_TO_BAND.keys()) - idx = cefr_list.index(cefr) - next_band = CefrMapper.CEFR_TO_BAND.get( - cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0 - ) - - band = base_band + position * (next_band - base_band) - return round(band * 2) / 2 - - return base_band - - @staticmethod - def band_to_cefr(band): - if band < 2.5: - return 'pre_a1' - if band < 3.5: - return 'a1' - if band < 4.5: - return 'a2' - if band < 5.5: - return 'b1' - if band < 7.0: - return 'b2' - if band < 8.0: - return 'c1' - return 'c2' - - @staticmethod - def get_cefr_label(cefr_code): - return CefrMapper.CEFR_LABELS.get(cefr_code, cefr_code) +from odoo.addons.encoach_ai.services.cefr_mapper import ( # noqa: F401 + CefrMapper, + band_to_cefr, + theta_to_cefr, + theta_to_band, + cefr_to_band, + normalize_cefr, + cefr_label, + THETA_TO_CEFR, + CEFR_TO_BAND, + CEFR_LABELS, +) diff --git a/custom_addons/encoach_scoring/controllers/exam_session.py b/custom_addons/encoach_scoring/controllers/exam_session.py index 1d5b3025..9a99f21d 100644 --- a/custom_addons/encoach_scoring/controllers/exam_session.py +++ b/custom_addons/encoach_scoring/controllers/exam_session.py @@ -21,11 +21,7 @@ BAND_TO_CEFR = [ ] -def _band_to_cefr(band): - for threshold, level in BAND_TO_CEFR: - if band >= threshold: - return level - return 'pre_a1' +from odoo.addons.encoach_ai.services.cefr_mapper import band_to_cefr as _band_to_cefr # noqa: E402 def _question_to_student_dict(q): diff --git a/custom_addons/encoach_scoring/controllers/grading.py b/custom_addons/encoach_scoring/controllers/grading.py index 108dd147..3efcb3c0 100644 --- a/custom_addons/encoach_scoring/controllers/grading.py +++ b/custom_addons/encoach_scoring/controllers/grading.py @@ -16,11 +16,7 @@ BAND_TO_CEFR = [ ] -def _band_to_cefr(band): - for threshold, level in BAND_TO_CEFR: - if band >= threshold: - return level - return 'pre_a1' +from odoo.addons.encoach_ai.services.cefr_mapper import band_to_cefr as _band_to_cefr # noqa: E402 def _recompute_bands(attempt): diff --git a/custom_addons/encoach_scoring/models/student_attempt.py b/custom_addons/encoach_scoring/models/student_attempt.py index 210a37d0..1d14fffb 100644 --- a/custom_addons/encoach_scoring/models/student_attempt.py +++ b/custom_addons/encoach_scoring/models/student_attempt.py @@ -1,4 +1,8 @@ -from odoo import models, fields +import logging + +from odoo import api, models, fields + +_logger = logging.getLogger(__name__) class EncoachStudentAttempt(models.Model): @@ -39,3 +43,31 @@ class EncoachStudentAttempt(models.Model): score_ids = fields.One2many('encoach.score', 'attempt_id') autosave_data = fields.Text() feedback_ids = fields.One2many('encoach.feedback', 'attempt_id') + + @api.model + def _auto_init(self): + res = super()._auto_init() + cr = self.env.cr + for name, ddl in ( + ( + 'encoach_student_attempt_student_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_student_attempt_student_status_idx " + "ON encoach_student_attempt (student_id, status)", + ), + ( + 'encoach_student_attempt_exam_status_idx', + "CREATE INDEX IF NOT EXISTS encoach_student_attempt_exam_status_idx " + "ON encoach_student_attempt (exam_id, status) WHERE exam_id IS NOT NULL", + ), + ( + 'encoach_student_attempt_entity_released_idx', + "CREATE INDEX IF NOT EXISTS encoach_student_attempt_entity_released_idx " + "ON encoach_student_attempt (entity_id, released_at) " + "WHERE entity_id IS NOT NULL AND released_at IS NOT NULL", + ), + ): + try: + cr.execute(ddl) + except Exception: + _logger.warning("could not create index %s", name, exc_info=True) + return res diff --git a/custom_addons/encoach_taxonomy/controllers/taxonomy.py b/custom_addons/encoach_taxonomy/controllers/taxonomy.py index be2ede83..d6119bfc 100644 --- a/custom_addons/encoach_taxonomy/controllers/taxonomy.py +++ b/custom_addons/encoach_taxonomy/controllers/taxonomy.py @@ -102,7 +102,8 @@ class EncoachTaxonomyController(http.Controller): try: recs = request.env['encoach.subject'].sudo().search([]) items = [_subject_dict(s) for s in recs] - return _json_response({'data': items, 'subjects': items}) + return _json_response({'items': items, 'data': items, + 'subjects': items, 'total': len(items)}) except Exception as e: _logger.exception('list_subjects') return _error_response(str(e), 500) @@ -205,7 +206,9 @@ class EncoachTaxonomyController(http.Controller): if kw.get('subject_id'): domain_filter.append(('subject_id', '=', int(kw['subject_id']))) recs = request.env['encoach.domain'].sudo().search(domain_filter) - return _json_response({'data': [_domain_dict(d) for d in recs]}) + items = [_domain_dict(d) for d in recs] + return _json_response({'items': items, 'data': items, + 'total': len(items)}) except Exception as e: return _error_response(str(e), 500) @@ -290,7 +293,9 @@ class EncoachTaxonomyController(http.Controller): domains = request.env['encoach.domain'].sudo().search([('subject_id', '=', int(kw['subject_id']))]) domain_filter.append(('domain_id', 'in', domains.ids)) recs = request.env['encoach.topic'].sudo().search(domain_filter) - return _json_response({'data': [_topic_dict(t) for t in recs]}) + items = [_topic_dict(t) for t in recs] + return _json_response({'items': items, 'data': items, + 'total': len(items)}) except Exception as e: return _error_response(str(e), 500) diff --git a/custom_addons/encoach_vector/models/embedding.py b/custom_addons/encoach_vector/models/embedding.py index dec64f09..fbcbe8bb 100644 --- a/custom_addons/encoach_vector/models/embedding.py +++ b/custom_addons/encoach_vector/models/embedding.py @@ -28,9 +28,17 @@ class EncoachEmbedding(models.Model): content_text = fields.Text() metadata_json = fields.Text(default='{}') + course_id = fields.Integer(string='Course ID', index=True) + subject_id = fields.Integer(string='Subject ID', index=True) + entity_id = fields.Integer(string='Entity ID', index=True) + taxonomy = fields.Char(string='Taxonomy', index=True) + content_hash = fields.Char(string='Content SHA256', index=True) + chunk_index = fields.Integer(string='Chunk #', default=0) + chunk_total = fields.Integer(string='Total Chunks', default=1) + _content_unique = models.Constraint( - 'UNIQUE(content_type, content_id)', - 'Each content item can only have one embedding.', + 'UNIQUE(content_type, content_id, chunk_index)', + 'Each content item chunk can only have one embedding.', ) @api.model @@ -82,27 +90,46 @@ class EncoachEmbedding(models.Model): return index_all(self.env) @api.model - def similarity_search(self, query_vector, *, content_type=None, limit=10): - """Find similar embeddings using cosine distance.""" + def similarity_search(self, query_vector, *, content_type=None, limit=10, + course_id=None, subject_id=None, entity_id=None, + taxonomy=None): + """Find similar embeddings using cosine distance. + + Optional metadata filters (course/subject/entity/taxonomy) are applied + as SQL equality predicates to narrow the candidate set before ranking. + """ vec_str = '[' + ','.join(str(v) for v in query_vector) + ']' - where = "WHERE embedding IS NOT NULL" - params = [vec_str, limit] + conditions = ["embedding IS NOT NULL"] + extra_params = [] if content_type: - where += " AND content_type = %s" - params = [vec_str, content_type, limit] + conditions.append("content_type = %s") + extra_params.append(content_type) + if course_id: + conditions.append("course_id = %s") + extra_params.append(int(course_id)) + if subject_id: + conditions.append("subject_id = %s") + extra_params.append(int(subject_id)) + if entity_id: + conditions.append("entity_id = %s") + extra_params.append(int(entity_id)) + if taxonomy: + conditions.append("taxonomy = %s") + extra_params.append(taxonomy) + where = "WHERE " + " AND ".join(conditions) query = f""" SELECT id, content_type, content_id, content_text, metadata_json, + course_id, subject_id, entity_id, taxonomy, + chunk_index, chunk_total, 1 - (embedding <=> %s::vector) AS similarity FROM encoach_embedding {where} ORDER BY embedding <=> %s::vector LIMIT %s """ - if content_type: - self.env.cr.execute(query, (vec_str, content_type, vec_str, limit)) - else: - self.env.cr.execute(query, (vec_str, vec_str, limit)) + params = [vec_str] + extra_params + [vec_str, limit] + self.env.cr.execute(query, params) results = [] for row in self.env.cr.dictfetchall(): @@ -117,6 +144,12 @@ class EncoachEmbedding(models.Model): 'content_id': row['content_id'], 'text': row['content_text'], 'metadata': metadata, + 'course_id': row['course_id'] or None, + 'subject_id': row['subject_id'] or None, + 'entity_id': row['entity_id'] or None, + 'taxonomy': row['taxonomy'] or None, + 'chunk_index': row['chunk_index'], + 'chunk_total': row['chunk_total'], 'similarity': round(row['similarity'], 4), }) return results diff --git a/custom_addons/encoach_vector/services/embedding_service.py b/custom_addons/encoach_vector/services/embedding_service.py index 966e7b75..84d094a6 100644 --- a/custom_addons/encoach_vector/services/embedding_service.py +++ b/custom_addons/encoach_vector/services/embedding_service.py @@ -1,13 +1,81 @@ """Embedding service — encode text and manage vector storage.""" +import hashlib import json import logging +import re import time _logger = logging.getLogger(__name__) _model_instance = None +CHUNK_CHAR_LIMIT = 2000 +CHUNK_OVERLAP = 200 +_PARA_SPLIT = re.compile(r"\n\s*\n+") +_SENT_SPLIT = re.compile(r"(?<=[.!?])\s+") + + +def _chunk_text(text, *, limit=CHUNK_CHAR_LIMIT, overlap=CHUNK_OVERLAP): + """Split long text into semantically-aware chunks of at most ``limit`` chars. + + Preserves paragraph and sentence boundaries where possible. Each chunk + after the first retains ``overlap`` chars of tail context for continuity. + Short inputs return a single-chunk list unchanged. + """ + text = (text or "").strip() + if not text: + return [] + if len(text) <= limit: + return [text] + + chunks = [] + buf = "" + for para in _PARA_SPLIT.split(text): + para = para.strip() + if not para: + continue + candidate = (buf + "\n\n" + para) if buf else para + if len(candidate) <= limit: + buf = candidate + continue + if buf: + chunks.append(buf) + buf = "" + if len(para) <= limit: + buf = para + continue + for sent in _SENT_SPLIT.split(para): + sent = sent.strip() + if not sent: + continue + cand = (buf + " " + sent) if buf else sent + if len(cand) <= limit: + buf = cand + continue + if buf: + chunks.append(buf) + if len(sent) <= limit: + buf = sent + else: + for i in range(0, len(sent), limit - overlap): + chunks.append(sent[i:i + limit]) + buf = "" + if buf: + chunks.append(buf) + + if overlap > 0 and len(chunks) > 1: + with_overlap = [chunks[0]] + for i in range(1, len(chunks)): + tail = chunks[i - 1][-overlap:] + with_overlap.append((tail + " " + chunks[i]).strip()) + chunks = with_overlap + return chunks + + +def _sha256(text): + return hashlib.sha256((text or "").encode("utf-8")).hexdigest() + def _get_model(): """Lazy-load the sentence-transformers model (cached across calls).""" @@ -47,42 +115,77 @@ class EmbeddingService: return [e.tolist() for e in embeddings] def upsert(self, content_type, content_id, text, metadata=None): - """Encode and store (or update) a single embedding. + """Encode and store (or update) one embedding per text chunk. + + Long ``text`` is split by :func:`_chunk_text` into chunks of at most + ``CHUNK_CHAR_LIMIT`` chars. Each chunk is stored as a separate row + keyed by ``(content_type, content_id, chunk_index)``. Dedicated + metadata columns (course_id, subject_id, entity_id, taxonomy, + content_hash) are populated from ``metadata`` when present. Returns: - encoach.embedding record + list of encoach.embedding records (one per chunk). Empty list + if input text is blank. """ if not text or not text.strip(): - return None + return [] - existing = self.Embedding.search([ + metadata = dict(metadata or {}) + chunks = _chunk_text(text) + if not chunks: + return [] + + vectors = self.encode(chunks) + existing_all = self.Embedding.search([ ('content_type', '=', content_type), ('content_id', '=', content_id), - ], limit=1) + ]) + by_idx = {rec.chunk_index: rec for rec in existing_all} + stale = [rec for rec in existing_all if rec.chunk_index >= len(chunks)] + if stale: + self.Embedding.browse([r.id for r in stale]).unlink() - vectors = self.encode([text]) - meta_str = json.dumps(metadata or {}) + records = [] + total = len(chunks) + for idx, (chunk, vec) in enumerate(zip(chunks, vectors)): + chunk_hash = _sha256(chunk) + base_vals = { + 'content_text': chunk[:10000], + 'metadata_json': json.dumps(metadata), + 'course_id': int(metadata.get('course_id') or 0) or False, + 'subject_id': int(metadata.get('subject_id') or 0) or False, + 'entity_id': int(metadata.get('entity_id') or 0) or False, + 'taxonomy': metadata.get('taxonomy') or False, + 'content_hash': chunk_hash, + 'chunk_index': idx, + 'chunk_total': total, + } + rec = by_idx.get(idx) + if rec: + if rec.content_hash != chunk_hash or rec.chunk_total != total: + rec.write(base_vals) + rec.set_embedding(vec) + else: + rec.write({k: v for k, v in base_vals.items() + if k in ('metadata_json', 'chunk_total')}) + else: + rec = self.Embedding.create({ + 'content_type': content_type, + 'content_id': content_id, + **base_vals, + }) + rec.set_embedding(vec) + records.append(rec) + return records - if existing: - existing.write({ - 'content_text': text[:10000], - 'metadata_json': meta_str, - }) - existing.set_embedding(vectors[0]) - return existing - - record = self.Embedding.create({ - 'content_type': content_type, - 'content_id': content_id, - 'content_text': text[:10000], - 'metadata_json': meta_str, - }) - record.set_embedding(vectors[0]) - return record - - def search(self, query, *, content_type=None, limit=10): + def search(self, query, *, content_type=None, limit=10, + course_id=None, subject_id=None, entity_id=None, + taxonomy=None): """Semantic search — encode query and find similar content. + Optional metadata filters narrow the candidate pool to a specific + course / subject / entity / taxonomy before ranking. + Returns: list of dicts with text, metadata, similarity score """ @@ -95,6 +198,10 @@ class EmbeddingService: vectors[0], content_type=content_type, limit=limit, + course_id=course_id, + subject_id=subject_id, + entity_id=entity_id, + taxonomy=taxonomy, ) latency = int((time.time() - t0) * 1000) _logger.info("Vector search for '%s' returned %d results in %dms", diff --git a/custom_addons/encoach_vector/services/indexer.py b/custom_addons/encoach_vector/services/indexer.py index 1766c7a6..c5237e41 100644 --- a/custom_addons/encoach_vector/services/indexer.py +++ b/custom_addons/encoach_vector/services/indexer.py @@ -11,6 +11,8 @@ MODEL_CONFIG = [ 'text_field': 'name', 'description_field': 'description', 'metadata_fields': [], + 'course_field': 'id', + 'subject_field': 'subject_id', }, { 'model': 'encoach.resource', @@ -18,13 +20,19 @@ MODEL_CONFIG = [ 'text_field': 'name', 'description_field': 'content', 'metadata_fields': ['type', 'cefr_level', 'difficulty'], + 'course_field': 'course_id', + 'subject_field': 'subject_id', + 'entity_field': 'entity_id', + 'taxonomy_field': 'type', }, { 'model': 'encoach.question', 'content_type': 'question', - 'text_field': 'name', + 'text_field': 'stem', 'description_field': None, 'metadata_fields': ['question_type', 'difficulty', 'skill'], + 'subject_field': 'subject_id', + 'taxonomy_field': 'skill', }, { 'model': 'encoach.course.module', @@ -32,6 +40,8 @@ MODEL_CONFIG = [ 'text_field': 'name', 'description_field': 'description', 'metadata_fields': ['skill'], + 'course_field': 'course_id', + 'taxonomy_field': 'skill', }, { 'model': 'encoach.ai.generation.log', @@ -39,6 +49,7 @@ MODEL_CONFIG = [ 'text_field': 'brief', 'description_field': 'generated_content', 'metadata_fields': ['course_type', 'status'], + 'taxonomy_field': 'course_type', }, { 'model': 'encoach.chapter.material', @@ -46,10 +57,21 @@ MODEL_CONFIG = [ 'text_field': 'name', 'description_field': 'description', 'metadata_fields': ['type'], + 'course_field': 'course_id', + 'taxonomy_field': 'type', }, ] +def _safe_id(record, field): + if not field or not hasattr(record, field): + return None + val = getattr(record, field) + if hasattr(val, 'id'): + return val.id or None + return int(val) if val else None + + def _get_text(record, config): """Extract indexable text from a record.""" parts = [] @@ -69,13 +91,32 @@ def _get_text(record, config): def _get_metadata(record, config): - """Extract metadata dict from a record.""" + """Extract metadata dict from a record, including structured + course/subject/entity/taxonomy fields used by the RAG retriever.""" meta = {} for f in config.get('metadata_fields', []): if hasattr(record, f): val = getattr(record, f) if val: meta[f] = str(val) if not isinstance(val, (int, float, bool)) else val + + course_id = _safe_id(record, config.get('course_field')) + if course_id: + meta['course_id'] = course_id + subject_id = _safe_id(record, config.get('subject_field')) + if subject_id: + meta['subject_id'] = subject_id + entity_id = _safe_id(record, config.get('entity_field')) + if entity_id: + meta['entity_id'] = entity_id + + taxonomy_field = config.get('taxonomy_field') + if taxonomy_field and hasattr(record, taxonomy_field): + tx = getattr(record, taxonomy_field) + if tx: + meta['taxonomy'] = ( + str(tx.display_name) if hasattr(tx, 'display_name') else str(tx) + ) return meta