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:
2
custom_addons/encoach_api/models/__init__.py
Normal file
2
custom_addons/encoach_api/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import jwt_token
|
||||
from . import gdpr_erasure
|
||||
35
custom_addons/encoach_api/models/gdpr_erasure.py
Normal file
35
custom_addons/encoach_api/models/gdpr_erasure.py
Normal 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()
|
||||
105
custom_addons/encoach_api/models/jwt_token.py
Normal file
105
custom_addons/encoach_api/models/jwt_token.py
Normal 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
|
||||
Reference in New Issue
Block a user