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 47d09a3ce5
commit dcf5ea6941
70 changed files with 4216 additions and 711 deletions

View File

@@ -20,6 +20,7 @@
'openeducat_library',
'encoach_core',
'encoach_api',
'encoach_ai',
'encoach_taxonomy',
'encoach_resources',
],

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,351 @@
"""Paymob checkout initiator + HMAC-verified webhook.
This module talks to the `Paymob Accept <https://accept.paymob.com/>`_ 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)

View File

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

View File

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

View File

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

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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
23 access_encoach_vocab_progress_all encoach.vocab.progress.all model_encoach_vocab_progress base.group_user 1 1 1 1
24 access_encoach_grammar_rule_all encoach.grammar.rule.all model_encoach_grammar_rule base.group_user 1 1 1 1
25 access_encoach_grammar_progress_all encoach.grammar.progress.all model_encoach_grammar_progress base.group_user 1 1 1 1
26 access_encoach_paymob_order_user encoach.paymob.order.user model_encoach_paymob_order base.group_user 1 0 1 0
27 access_encoach_paymob_order_admin encoach.paymob.order.admin model_encoach_paymob_order base.group_system 1 1 1 1