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:
@@ -9,3 +9,4 @@ from . import course_ext
|
||||
from . import asset
|
||||
from . import ticket
|
||||
from . import training
|
||||
from . import paymob_order
|
||||
|
||||
92
custom_addons/encoach_lms_api/models/paymob_order.py
Normal file
92
custom_addons/encoach_lms_api/models/paymob_order.py
Normal 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,
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user