system-prompts-and-models-o.../salesflow-saas/backend/app/services/pdpl/data_rights.py
Claude 141f10db76
feat: Add conversation intelligence, message writer, sales agent, APIs, and templates
Continuing Phase 3-6 implementation:

- AI: conversation_intelligence.py (Arabic dialogue analysis, buying signals)
- AI: message_writer.py (Arabic/English multi-channel message generation)
- AI: sales_agent.py (autonomous WhatsApp qualification bot)
- API: compliance.py (PDPL consent & data rights endpoints)
- API: inbox.py (unified multi-channel inbox)
- API: proposals.py (CPQ quote management endpoints)
- API: sequences.py (multi-channel sequence management)
- Services: territory_manager.py (Saudi region-based lead routing)
- Seeds: contracting_template.json (Saudi contracting industry template)
- Updated: router.py, consent_manager.py, data_rights.py

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

227 lines
10 KiB
Python

"""PDPL data subject rights handler.
Right to access, correction, deletion, restriction + SDAIA compliance reports.
"""
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
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.consent import PDPLConsent, DataRequest, DataRequestStatus, DataRequestType
from app.models.lead import Lead
from app.models.message import Message
logger = logging.getLogger(__name__)
HARD_DELETE_DELAY_DAYS = 30
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]
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
class DataRightsHandler:
"""Handles PDPL data subject rights for Dealix contacts."""
def __init__(self, db: AsyncSession):
self.db = db
async def export_data(self, contact_id: UUID, tenant_id: UUID) -> DataExport:
"""Export all personal data held for a contact (right to access)."""
lead = await self._get_lead(contact_id, tenant_id)
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()
]
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()
]
logger.info("PDPL data export: contact=%s", contact_id)
return DataExport(
contact_id=contact_id,
personal_data={"name": lead.name, "phone": lead.phone, "email": lead.email,
"source": lead.source, "status": lead.status, "score": lead.score, "notes": lead.notes},
consents=consents, messages=messages, exported_at=datetime.now(timezone.utc),
)
async def correct_data(self, data: CorrectionInput) -> CorrectionResult:
"""Update personal data fields with audit trail."""
lead = await self._get_lead(data.contact_id, data.tenant_id)
allowed_fields = {"name", "phone", "email", "notes"}
previous: dict[str, Any] = {}
updated: 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.append(field)
await self.db.flush()
self.db.add(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}, handled_by=data.actor_id,
))
await self.db.flush()
logger.info("PDPL correction: contact=%s fields=%s", data.contact_id, updated)
return CorrectionResult(contact_id=data.contact_id, fields_updated=updated,
previous_values=previous, updated_at=datetime.now(timezone.utc))
async def delete_data(self, contact_id: UUID, tenant_id: UUID,
actor_id: Optional[UUID] = None) -> DeletionResult:
"""Soft-delete contact; schedule hard-delete after 30 days."""
lead = await self._get_lead(contact_id, tenant_id)
now = datetime.now(timezone.utc)
hard_at = now + timedelta(days=HARD_DELETE_DELAY_DAYS)
lead.status = "deleted"
lead.notes = f"[PDPL deletion {now.isoformat()}] " + (lead.notes or "")
lead.extra_metadata = {**(lead.extra_metadata or {}),
"_pdpl_soft_deleted": True, "_pdpl_hard_delete_at": hard_at.isoformat()}
# Revoke active consents
cq = await self.db.execute(
select(PDPLConsent).where(PDPLConsent.contact_id == contact_id,
PDPLConsent.tenant_id == tenant_id, PDPLConsent.status == "granted")
)
for c in cq.scalars().all():
c.status = "revoked"
c.revoked_at = now
self.db.add(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_at.isoformat()}, handled_by=actor_id,
))
await self.db.flush()
logger.info("PDPL deletion: contact=%s hard_delete=%s", contact_id, hard_at)
return DeletionResult(
contact_id=contact_id, status="soft_deleted", soft_deleted_at=now,
hard_delete_scheduled=hard_at,
message=f"Hard delete scheduled for {hard_at.date()}",
message_ar=f"الحذف النهائي مجدول بتاريخ {hard_at.date()}",
)
async def restrict_processing(self, contact_id: UUID, tenant_id: UUID,
actor_id: Optional[UUID] = None) -> RestrictionResult:
"""Flag 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()}
self.db.add(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,
))
await self.db.flush()
logger.info("PDPL restriction: contact=%s", contact_id)
return RestrictionResult(
contact_id=contact_id, restricted=True,
message="Contact processing restricted per PDPL",
message_ar="تم تقييد معالجة بيانات جهة الاتصال وفقًا لنظام حماية البيانات",
)
async def generate_compliance_report(self, tenant_id: UUID) -> ComplianceReport:
"""Generate SDAIA-ready compliance report."""
now = datetime.now(timezone.utc)
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
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
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 = {r[0]: r[1] for r in type_rows}
# Average resolution time
avg_hours: Optional[float] = None
done = (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 done:
deltas = [(r.completed_at - r.requested_at).total_seconds() / 3600
for r in done if r.completed_at and r.requested_at]
avg_hours = round(sum(deltas) / len(deltas), 2) if deltas else None
logger.info("PDPL 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,
)
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("جهة الاتصال غير موجودة")
return lead