mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 07:19:35 +00:00
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
227 lines
10 KiB
Python
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
|