mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
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
353 lines
12 KiB
Python
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
|