system-prompts-and-models-o.../salesflow-saas/backend/app/services/pdpl/data_rights.py
Claude a329957a3b
feat: Add AI engine, PDPL compliance, sequences, CPQ, and governance layers
Phase 1-6 implementation for Dealix AI Revenue OS:

- AI Arabic Engine: NLP (arabic_nlp.py), lead scoring (lead_scoring.py)
- PDPL Compliance: consent manager, data rights handler, consent model
- Sequence Engine: multi-channel sequences with WhatsApp/Email/SMS
- CPQ System: quote engine, AI proposal generator
- Security Gate: pre-release checks, PDPL message validation
- Tool Verification: agent action audit trail
- Project Operating Files: AGENTS.md, CLAUDE.md
- Project Memory: architecture, ADRs, provider routing, PDPL checklist
- Design System: IBM Plex Sans Arabic tokens, RTL-safe components
- Sequence/Consent models for database

https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
2026-04-11 07:40:39 +00:00

353 lines
12 KiB
Python

"""PDPL data subject rights handler.
Implements: right to access, correction, deletion, restriction of processing.
Generates compliance reports for SDAIA audits.
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from uuid import UUID
from pydantic import BaseModel as Schema
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.consent import (
PDPLConsent, PDPLConsentAudit, DataRequest,
DataRequestStatus, DataRequestType,
)
from app.models.lead import Lead
from app.models.message import Message
logger = logging.getLogger(__name__)
HARD_DELETE_DELAY_DAYS = 30
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class DataExport(Schema):
contact_id: UUID
personal_data: dict
consents: list[dict]
messages: list[dict]
exported_at: datetime
class CorrectionInput(Schema):
contact_id: UUID
tenant_id: UUID
corrections: dict[str, Any] # field_name -> new_value
actor_id: Optional[UUID] = None
reason: Optional[str] = None
class CorrectionResult(Schema):
contact_id: UUID
fields_updated: list[str]
previous_values: dict
updated_at: datetime
class DeletionResult(Schema):
contact_id: UUID
status: str
soft_deleted_at: datetime
hard_delete_scheduled: datetime
message: str
message_ar: str
class RestrictionResult(Schema):
contact_id: UUID
restricted: bool
message: str
message_ar: str
class ComplianceReport(Schema):
tenant_id: UUID
generated_at: datetime
total_consents: int
active_consents: int
revoked_consents: int
expired_consents: int
pending_requests: int
completed_requests: int
requests_by_type: dict[str, int]
avg_resolution_hours: Optional[float] = None
violations_detected: int
# ---------------------------------------------------------------------------
# DataRightsHandler
# ---------------------------------------------------------------------------
class DataRightsHandler:
"""Handles PDPL data subject rights for Dealix contacts."""
def __init__(self, db: AsyncSession):
self.db = db
# -- export (right to access) -------------------------------------------
async def export_data(self, contact_id: UUID, tenant_id: UUID) -> DataExport:
"""Export all personal data held for a contact as structured JSON."""
lead = await self._get_lead(contact_id, tenant_id)
# Consent records
consents_q = await self.db.execute(
select(PDPLConsent).where(
PDPLConsent.contact_id == contact_id,
PDPLConsent.tenant_id == tenant_id,
)
)
consents = [
{"purpose": c.purpose, "channel": c.channel, "status": c.status,
"granted_at": c.granted_at.isoformat() if c.granted_at else None,
"expires_at": c.expires_at.isoformat() if c.expires_at else None}
for c in consents_q.scalars().all()
]
# Messages
msgs_q = await self.db.execute(
select(Message).where(Message.lead_id == contact_id).limit(500)
)
messages = [
{"channel": m.channel, "direction": m.direction,
"content": m.content, "sent_at": m.sent_at.isoformat() if m.sent_at else None}
for m in msgs_q.scalars().all()
]
personal = {
"name": lead.name,
"phone": lead.phone,
"email": lead.email,
"source": lead.source,
"status": lead.status,
"score": lead.score,
"notes": lead.notes,
}
logger.info("PDPL data export completed: contact=%s", contact_id)
return DataExport(
contact_id=contact_id,
personal_data=personal,
consents=consents,
messages=messages,
exported_at=datetime.now(timezone.utc),
)
# -- correction ----------------------------------------------------------
async def correct_data(self, data: CorrectionInput) -> CorrectionResult:
"""Update personal data fields with full audit trail."""
lead = await self._get_lead(data.contact_id, data.tenant_id)
allowed_fields = {"name", "phone", "email", "notes"}
previous: dict[str, Any] = {}
updated_fields: list[str] = []
for field, new_val in data.corrections.items():
if field not in allowed_fields:
logger.warning("PDPL correction rejected for field=%s", field)
continue
previous[field] = getattr(lead, field, None)
setattr(lead, field, new_val)
updated_fields.append(field)
await self.db.flush()
# Audit via data request record
req = DataRequest(
contact_id=data.contact_id,
tenant_id=data.tenant_id,
request_type=DataRequestType.CORRECTION.value,
status=DataRequestStatus.COMPLETED.value,
requested_at=datetime.now(timezone.utc),
completed_at=datetime.now(timezone.utc),
response_data={"corrections": data.corrections, "previous": previous, "reason": data.reason},
handled_by=data.actor_id,
)
self.db.add(req)
await self.db.flush()
logger.info("PDPL data correction: contact=%s fields=%s", data.contact_id, updated_fields)
return CorrectionResult(
contact_id=data.contact_id,
fields_updated=updated_fields,
previous_values=previous,
updated_at=datetime.now(timezone.utc),
)
# -- deletion (right to erasure) ----------------------------------------
async def delete_data(self, contact_id: UUID, tenant_id: UUID, actor_id: Optional[UUID] = None) -> DeletionResult:
"""Soft-delete contact now; schedule hard-delete after 30 days."""
lead = await self._get_lead(contact_id, tenant_id)
now = datetime.now(timezone.utc)
hard_delete_at = now + timedelta(days=HARD_DELETE_DELAY_DAYS)
# Soft-delete: mark status and clear PII
lead.status = "deleted"
lead.notes = f"[PDPL deletion requested {now.isoformat()}] " + (lead.notes or "")
lead.extra_metadata = {
**(lead.extra_metadata or {}),
"_pdpl_soft_deleted": True,
"_pdpl_hard_delete_at": hard_delete_at.isoformat(),
}
# Revoke all active consents
consents_q = await self.db.execute(
select(PDPLConsent).where(
PDPLConsent.contact_id == contact_id,
PDPLConsent.tenant_id == tenant_id,
PDPLConsent.status == "granted",
)
)
for consent in consents_q.scalars().all():
consent.status = "revoked"
consent.revoked_at = now
# Record the request
req = DataRequest(
contact_id=contact_id,
tenant_id=tenant_id,
request_type=DataRequestType.DELETION.value,
status=DataRequestStatus.PROCESSING.value,
requested_at=now,
response_data={"hard_delete_at": hard_delete_at.isoformat()},
handled_by=actor_id,
)
self.db.add(req)
await self.db.flush()
logger.info("PDPL deletion scheduled: contact=%s hard_delete=%s", contact_id, hard_delete_at)
return DeletionResult(
contact_id=contact_id,
status="soft_deleted",
soft_deleted_at=now,
hard_delete_scheduled=hard_delete_at,
message=f"Contact soft-deleted. Hard delete scheduled for {hard_delete_at.date()}",
message_ar=f"تم حذف جهة الاتصال مبدئيًا. الحذف النهائي مجدول بتاريخ {hard_delete_at.date()}",
)
# -- restriction ---------------------------------------------------------
async def restrict_processing(
self, contact_id: UUID, tenant_id: UUID, actor_id: Optional[UUID] = None
) -> RestrictionResult:
"""Flag a contact as restricted -- no outbound processing allowed."""
lead = await self._get_lead(contact_id, tenant_id)
lead.extra_metadata = {
**(lead.extra_metadata or {}),
"_pdpl_restricted": True,
"_pdpl_restricted_at": datetime.now(timezone.utc).isoformat(),
}
req = DataRequest(
contact_id=contact_id,
tenant_id=tenant_id,
request_type=DataRequestType.RESTRICTION.value,
status=DataRequestStatus.COMPLETED.value,
requested_at=datetime.now(timezone.utc),
completed_at=datetime.now(timezone.utc),
response_data={"restricted": True},
handled_by=actor_id,
)
self.db.add(req)
await self.db.flush()
logger.info("PDPL processing restricted: contact=%s", contact_id)
return RestrictionResult(
contact_id=contact_id,
restricted=True,
message="Contact processing restricted per PDPL request",
message_ar="تم تقييد معالجة بيانات جهة الاتصال وفقًا لطلب نظام حماية البيانات",
)
# -- compliance report ---------------------------------------------------
async def generate_compliance_report(self, tenant_id: UUID) -> ComplianceReport:
"""Generate SDAIA-ready compliance report for a tenant."""
now = datetime.now(timezone.utc)
# Consent counts
total = (await self.db.execute(
select(func.count()).where(PDPLConsent.tenant_id == tenant_id)
)).scalar() or 0
active = (await self.db.execute(
select(func.count()).where(PDPLConsent.tenant_id == tenant_id, PDPLConsent.status == "granted")
)).scalar() or 0
revoked = (await self.db.execute(
select(func.count()).where(PDPLConsent.tenant_id == tenant_id, PDPLConsent.status == "revoked")
)).scalar() or 0
expired = (await self.db.execute(
select(func.count()).where(PDPLConsent.tenant_id == tenant_id, PDPLConsent.status == "expired")
)).scalar() or 0
# Data requests
pending = (await self.db.execute(
select(func.count()).where(DataRequest.tenant_id == tenant_id, DataRequest.status == "pending")
)).scalar() or 0
completed = (await self.db.execute(
select(func.count()).where(DataRequest.tenant_id == tenant_id, DataRequest.status == "completed")
)).scalar() or 0
# Breakdown by type
type_rows = (await self.db.execute(
select(DataRequest.request_type, func.count())
.where(DataRequest.tenant_id == tenant_id)
.group_by(DataRequest.request_type)
)).all()
by_type = {row[0]: row[1] for row in type_rows}
# Avg resolution time
avg_hours: Optional[float] = None
completed_reqs = (await self.db.execute(
select(DataRequest).where(
DataRequest.tenant_id == tenant_id,
DataRequest.status == "completed",
DataRequest.completed_at.isnot(None),
).limit(500)
)).scalars().all()
if completed_reqs:
deltas = [
(r.completed_at - r.requested_at).total_seconds() / 3600
for r in completed_reqs if r.completed_at and r.requested_at
]
avg_hours = round(sum(deltas) / len(deltas), 2) if deltas else None
logger.info("PDPL compliance report generated: tenant=%s", tenant_id)
return ComplianceReport(
tenant_id=tenant_id,
generated_at=now,
total_consents=total,
active_consents=active,
revoked_consents=revoked,
expired_consents=expired,
pending_requests=pending,
completed_requests=completed,
requests_by_type=by_type,
avg_resolution_hours=avg_hours,
violations_detected=0,
)
# -- private helpers -----------------------------------------------------
async def _get_lead(self, contact_id: UUID, tenant_id: UUID) -> Lead:
result = await self.db.execute(
select(Lead).where(Lead.id == contact_id, Lead.tenant_id == tenant_id)
)
lead = result.scalar_one_or_none()
if not lead:
raise ValueError("جهة الاتصال غير موجودة") # Contact not found
return lead