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:
@@ -20,6 +20,7 @@
|
||||
'openeducat_library',
|
||||
'encoach_core',
|
||||
'encoach_api',
|
||||
'encoach_ai',
|
||||
'encoach_taxonomy',
|
||||
'encoach_resources',
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
351
custom_addons/encoach_lms_api/controllers/paymob.py
Normal file
351
custom_addons/encoach_lms_api/controllers/paymob.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Reference in New Issue
Block a user