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

@@ -9,3 +9,4 @@ from . import course_ext
from . import asset
from . import ticket
from . import training
from . import paymob_order

View File

@@ -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,
})

View File

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