Files
encoach_backend_new_v2/custom_addons/encoach_api/controllers/gdpr.py
Yamen Ahmad 3972023a30 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
2026-04-19 14:16:09 +04:00

317 lines
13 KiB
Python

"""GDPR data-subject rights endpoints.
Exposes two routes:
* ``GET /api/gdpr/export``
Returns a JSON snapshot of the calling user's personal data — profile,
entity membership, exam attempts, answers, AI interactions, feedback,
tickets, and login history.
* ``POST /api/gdpr/delete``
Initiates the "right to erasure" flow. Because Odoo enforces FK
constraints on linked business records (exam attempts, tickets, audit
trail), we **anonymise** instead of hard-deleting: the ``res.users``
row is deactivated, ``res.partner`` PII fields are wiped, and a
tombstone row is written to ``encoach.gdpr.erasure.request`` for
audit. Hard delete of linked business rows (attempts, answers,
feedback) is performed for records where it is legally required and
where no other user depends on them.
The flow is intentionally verbose so operators can see what happened. A
production deployment may want to queue the erasure to a background job
and require a cooling-off period before executing; we expose a
``confirm`` flag for that.
"""
from __future__ import annotations
import json
import logging
from odoo import fields, http
from odoo.http import request
from .base import (
_error_response,
_get_json_body,
_json_response,
jwt_required,
)
_logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def _safe_records(env_name: str, domain, fields_to_read):
"""Read records if the model exists, else return []."""
env = request.env
if env_name not in env:
return []
try:
return env[env_name].sudo().search(domain).read(fields_to_read)
except Exception as exc:
_logger.warning("gdpr: could not read %s: %s", env_name, exc)
return []
def _sanitize(values):
"""Convert non-JSON-serialisable values to strings/nulls."""
out = {}
for key, value in values.items():
if isinstance(value, (list, tuple)) and len(value) == 2 and isinstance(value[0], int):
# Many2one read tuples — keep both id and display name.
out[key] = {"id": value[0], "display_name": value[1]}
elif isinstance(value, bytes):
out[key] = f"<{len(value)} bytes omitted>"
elif hasattr(value, "isoformat"):
out[key] = value.isoformat()
else:
out[key] = value
return out
# ----------------------------------------------------------------------
# Controller
# ----------------------------------------------------------------------
class EncoachGdprController(http.Controller):
"""Implements export + right-to-erasure endpoints."""
# ------------------------------------------------------------------
# GET /api/gdpr/export
# ------------------------------------------------------------------
@http.route("/api/gdpr/export", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def export(self, **kw):
try:
user = request.env.user
partner = user.partner_id
profile = {
"id": user.id,
"login": user.login,
"name": user.name,
"email": user.email or "",
"lang": user.lang or "",
"tz": user.tz or "",
"create_date": user.create_date.isoformat() if user.create_date else None,
"login_date": user.login_date.isoformat() if user.login_date else None,
}
partner_data = {
"id": partner.id if partner else None,
"name": partner.name if partner else None,
"email": partner.email if partner else None,
"phone": partner.phone if partner else None,
"mobile": partner.mobile if partner else None,
"street": partner.street if partner else None,
"city": partner.city if partner else None,
"country": partner.country_id.name if partner and partner.country_id else None,
}
# Entity / role memberships
entity_rel = _safe_records(
"encoach.user.entity.rel",
[("user_id", "=", user.id)],
["id", "entity_id", "role_id", "is_active"],
)
# Exam attempts & answers
attempts = _safe_records(
"encoach.student.attempt",
[("user_id", "=", user.id)],
["id", "exam_id", "status", "score", "started_at", "submitted_at"],
)
attempt_ids = [a["id"] for a in attempts]
answers = _safe_records(
"encoach.student.answer",
[("attempt_id", "in", attempt_ids)],
["id", "attempt_id", "question_id", "student_answer", "is_correct", "score"],
) if attempt_ids else []
# AI feedback they left
feedback = _safe_records(
"encoach.ai.feedback",
[("user_id", "=", user.id)],
["id", "subject_type", "subject_id", "rating", "comment", "status", "create_date"],
)
# AI calls they triggered (logs)
ai_logs = _safe_records(
"encoach.ai.log",
[("user_id", "=", user.id)] if "encoach.ai.log" in request.env else [],
["id", "service", "action", "model_used", "total_tokens", "status", "create_date"],
)
# Tickets they filed
tickets = _safe_records(
"encoach.ticket",
[("created_by_id", "=", user.id)],
["id", "name", "status", "priority", "assigned_to_id", "create_date"],
)
# Feedback on their coaching, if any
coaching_sessions = _safe_records(
"encoach.coaching.session",
[("user_id", "=", user.id)],
["id", "create_date", "summary"],
)
payload = {
"profile": profile,
"partner": partner_data,
"entity_memberships": [_sanitize(r) for r in entity_rel],
"exam_attempts": [_sanitize(r) for r in attempts],
"exam_answers": [_sanitize(r) for r in answers],
"ai_feedback": [_sanitize(r) for r in feedback],
"ai_calls": [_sanitize(r) for r in ai_logs],
"tickets": [_sanitize(r) for r in tickets],
"coaching_sessions": [_sanitize(r) for r in coaching_sessions],
"exported_at": fields.Datetime.to_string(fields.Datetime.now()),
"export_format_version": "1.0",
}
return _json_response(payload)
except Exception as e:
_logger.exception("gdpr export failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/gdpr/delete
# ------------------------------------------------------------------
@http.route("/api/gdpr/delete", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def delete(self, **kw):
try:
body = _get_json_body() or {}
if not body.get("confirm"):
return _error_response(
"Set {\"confirm\": true} to initiate erasure. "
"This action cannot be undone.",
400,
)
user = request.env.user
if user.id == 1 or user.has_group("base.group_system"):
return _error_response(
"Admin accounts cannot self-erase via this endpoint. "
"Contact the data protection officer.",
403,
)
login = user.login
email = user.email or ""
archived_id = user.id
summary = {
"anonymised_partner_fields": [],
"deactivated_user": False,
"deleted_feedback_count": 0,
"deleted_coaching_count": 0,
"retained_attempts_count": 0,
}
with request.env.cr.savepoint():
# 1. Anonymise the partner PII.
partner = user.partner_id
if partner:
partner.sudo().write({
"name": f"deleted-user-{archived_id}",
"email": False,
"phone": False,
"mobile": False,
"street": False,
"street2": False,
"city": False,
"zip": False,
"image_1920": False,
"comment": "Erased per GDPR request.",
})
summary["anonymised_partner_fields"] = [
"name", "email", "phone", "mobile",
"street", "street2", "city", "zip",
"image_1920", "comment",
]
# 2. Hard-delete their feedback (personal reviews/comments).
if "encoach.ai.feedback" in request.env:
feedback = request.env["encoach.ai.feedback"].sudo().search([
("user_id", "=", user.id),
])
summary["deleted_feedback_count"] = len(feedback)
feedback.unlink()
# 3. Hard-delete coaching session transcripts (personal).
if "encoach.coaching.session" in request.env:
sessions = request.env["encoach.coaching.session"].sudo().search([
("user_id", "=", user.id),
])
summary["deleted_coaching_count"] = len(sessions)
sessions.unlink()
# 4. Retain exam attempts (aggregate analytics rely on them)
# but strip personal free-text fields where they exist.
if "encoach.student.attempt" in request.env:
retained = request.env["encoach.student.attempt"].sudo().search([
("user_id", "=", user.id),
])
summary["retained_attempts_count"] = len(retained)
# Clear any written responses that contain user PII — keep
# the scores/timestamps for analytics integrity.
if "encoach.student.answer" in request.env:
answers = request.env["encoach.student.answer"].sudo().search([
("attempt_id", "in", retained.ids),
])
# student_answer can contain free-text PII.
answers.write({"student_answer": False})
# 5. Rewrite AI log user_id → None so we can no longer tie
# those rows to the person.
if "encoach.ai.log" in request.env:
ai_logs = request.env["encoach.ai.log"].sudo().search([
("user_id", "=", user.id),
])
if ai_logs:
try:
ai_logs.write({"user_id": False})
except Exception:
# user_id may be required; fall back to deletion.
ai_logs.unlink()
# 6. Deactivate the user (we can't unlink — FKs).
user.sudo().write({
"active": False,
"login": f"erased-{archived_id}@example.invalid",
})
summary["deactivated_user"] = True
# 7. Write tombstone audit row.
request.env["encoach.gdpr.erasure.request"].sudo().create({
"user_login": login,
"user_email": email,
"user_id_archived": archived_id,
"status": "completed",
"summary": json.dumps(summary),
"completed_at": fields.Datetime.now(),
})
# Blow away any cached JWT sessions for this user.
try:
from odoo.addons.encoach_api.utils.jwt_cache import (
invalidate_user as _invalidate_user,
)
_invalidate_user(archived_id)
except Exception as exc:
_logger.debug("jwt cache invalidation skipped: %s", exc)
return _json_response({
"ok": True,
"summary": summary,
"message": (
"Your account has been anonymised and deactivated. "
"Some records (e.g. exam attempts) are retained "
"without personal identifiers for statistical purposes."
),
})
except Exception as e:
_logger.exception("gdpr delete failed")
return _error_response(str(e), 500)