feat(backend): Phase 2/3 hardening release

Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
  and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.

Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
  pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
  encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
  and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.

Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
  spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
  new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.

Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
  new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
  active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
  admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
  with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 14:16:09 +04:00
parent 1a0349c381
commit 3972023a30
64 changed files with 4121 additions and 701 deletions

View File

@@ -0,0 +1,2 @@
from . import jwt_token
from . import gdpr_erasure

View File

@@ -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()

View File

@@ -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