Skip to content

Commit 85938eb

Browse files
feat: merge GSSoC PR #1930 by @Daksh7785 (conflict-free)
2 parents 86f48ef + d03cd2a commit 85938eb

6 files changed

Lines changed: 693 additions & 0 deletions

File tree

PLATFORM_MAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ This document provides a comprehensive breakdown of all 30+ pages and interactio
2323
| **Auto-Resolve** | AI troubleshooting hub. | Interactive chat for self-resolution. |
2424
| **Notifications** | Activity center. | Real-time updates on ticket movement. |
2525
| **User Profile** | Identity management. | Personal details, company affiliation. |
26+
| **Privacy Settings** | Consent & compliance center. | Manage consent preferences, export personal data (JSON/CSV), request/cancel account deletion. |
2627

2728
## 🏢 Layer 3: Company Admin Portal
2829
| Page | Description | Key Features |

backend/main.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,11 @@ async def lifespan(app: FastAPI):
13291329
asyncio.create_task(digest_scheduler_loop_async(supabase, interval_seconds=3600))
13301330
print("[Startup] Weekly digest email scheduler started (interval=3600s)")
13311331

1332+
# Start background privacy retention scheduler loop (checks daily - interval=86400s)
1333+
from backend.routes.privacy import privacy_retention_scheduler_loop_async
1334+
asyncio.create_task(privacy_retention_scheduler_loop_async(supabase, interval_seconds=86400))
1335+
print("[Startup] Privacy retention background scanner started (interval=86400s)")
1336+
13321337
print("[Startup] Classifier V2 Shadow: Ready.")
13331338
print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
13341339
print("[Startup] Ready.")
@@ -1676,6 +1681,10 @@ async def metrics(request: Request):
16761681
from backend.sentiment_router import router as sentiment_router
16771682
app.include_router(sentiment_router)
16781683

1684+
# Privacy / GDPR compliance router
1685+
from backend.routes.privacy import router as privacy_router
1686+
app.include_router(privacy_router)
1687+
16791688

16801689
# ---------------------------------------------------------------------------
16811690
# Custom Swagger UI with branding

backend/routes/privacy.py

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
from __future__ import annotations
2+
3+
import io
4+
import json
5+
import logging
6+
from datetime import datetime, timezone
7+
from typing import Any, Optional, Dict, List
8+
9+
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
10+
from fastapi.responses import StreamingResponse
11+
from pydantic import BaseModel, Field
12+
13+
from backend.auth_cookie import get_current_user
14+
from backend.limiter import limiter
15+
from backend.services.gdpr_service import load as load_gdpr_service
16+
17+
logger = logging.getLogger(__name__)
18+
19+
router = APIRouter(tags=["privacy"])
20+
21+
class ConsentPreferences(BaseModel):
22+
marketing_emails: Optional[bool] = None
23+
product_updates: Optional[bool] = None
24+
announcements: Optional[bool] = None
25+
usage_analytics: Optional[bool] = None
26+
performance_monitoring: Optional[bool] = None
27+
behavior_tracking: Optional[bool] = None
28+
experimental_features: Optional[bool] = None
29+
research_participation: Optional[bool] = None
30+
31+
class ConsentUpdateRequest(BaseModel):
32+
user_id: Optional[str] = None
33+
consent: Optional[Dict[str, Any]] = None
34+
actor: Optional[str] = "user"
35+
36+
class ExportRequest(BaseModel):
37+
format: str = "json"
38+
39+
class DeletionRequestInput(BaseModel):
40+
user_id: Optional[str] = None
41+
reason: Optional[str] = ""
42+
43+
class StatusUpdateRequest(BaseModel):
44+
admin_notes: Optional[str] = None
45+
46+
# Helper to load GDPR Service
47+
def get_gdpr_service() -> Any:
48+
return load_gdpr_service()
49+
50+
# Helper to check DNT and override preferences
51+
def apply_dnt_override(request: Request, prefs: dict) -> dict:
52+
dnt = request.headers.get("dnt") or request.headers.get("DNT")
53+
if dnt == "1":
54+
logger.info("DNT signal detected: overriding analytics & tracking consent states to False")
55+
prefs["usage_analytics"] = False
56+
prefs["behavior_tracking"] = False
57+
return prefs
58+
59+
# --- Preferences/Consent Routes ---
60+
61+
@router.get("/api/privacy/preferences")
62+
@router.get("/privacy/consent")
63+
@router.get("/api/privacy/consents")
64+
@router.get("/privacy/consents")
65+
@limiter.limit("30/minute")
66+
async def get_privacy_preferences_route(
67+
request: Request,
68+
user_id: Optional[str] = None,
69+
user: dict = Depends(get_current_user),
70+
service = Depends(get_gdpr_service)
71+
):
72+
uid = user.get("id") or user.get("sub")
73+
if not uid and user_id:
74+
uid = user_id
75+
if not uid:
76+
raise HTTPException(status_code=401, detail="Authentication required")
77+
78+
prefs = service.get_privacy_preferences(uid)
79+
prefs = apply_dnt_override(request, prefs)
80+
81+
# If legacy client requested `/privacy/consent`, return legacy wrapper
82+
if request.url.path == "/privacy/consent":
83+
return {
84+
"consent": prefs,
85+
"updated_at": datetime.now(timezone.utc).isoformat(),
86+
"user_id": uid
87+
}
88+
return prefs
89+
90+
@router.post("/api/privacy/preferences")
91+
@router.post("/privacy/consent")
92+
@router.put("/privacy/consent")
93+
@router.put("/api/privacy/consents")
94+
@router.put("/privacy/consents")
95+
@limiter.limit("15/minute")
96+
async def update_privacy_preferences_route(
97+
request: Request,
98+
user: dict = Depends(get_current_user),
99+
service = Depends(get_gdpr_service)
100+
):
101+
body_data = {}
102+
try:
103+
body_data = await request.json()
104+
except Exception:
105+
pass
106+
107+
# Handle nested consent object or direct preferences
108+
consent_data = body_data.get("consent") if isinstance(body_data.get("consent"), dict) else body_data
109+
110+
# Clean non-preference fields from consent_data
111+
clean_consent = {}
112+
for key in ["marketing_emails", "product_updates", "announcements", "usage_analytics",
113+
"performance_monitoring", "behavior_tracking", "experimental_features", "research_participation"]:
114+
if key in consent_data:
115+
clean_consent[key] = bool(consent_data[key])
116+
117+
uid = user.get("id") or user.get("sub")
118+
if not uid:
119+
raise HTTPException(status_code=401, detail="Authentication required")
120+
121+
updated_prefs = service.update_privacy_preferences(uid, clean_consent)
122+
123+
if request.url.path in ("/privacy/consent", "/api/privacy/consent"):
124+
return {
125+
"consent": updated_prefs,
126+
"updated_at": datetime.now(timezone.utc).isoformat(),
127+
"user_id": uid
128+
}
129+
return updated_prefs
130+
131+
# --- Privacy Requests Routes ---
132+
133+
@router.get("/api/privacy/requests")
134+
@router.get("/privacy/requests")
135+
@router.get("/api/privacy/delete-status")
136+
@limiter.limit("30/minute")
137+
async def get_privacy_requests_route(
138+
request: Request,
139+
user_id: Optional[str] = None,
140+
user: dict = Depends(get_current_user),
141+
service = Depends(get_gdpr_service)
142+
):
143+
uid = user.get("id") or user.get("sub")
144+
if not uid and user_id:
145+
uid = user_id
146+
if not uid:
147+
raise HTTPException(status_code=401, detail="Authentication required")
148+
149+
reqs = service.get_privacy_requests(uid)
150+
return reqs
151+
152+
@router.post("/api/privacy/delete-request")
153+
@router.post("/privacy/request_deletion")
154+
@limiter.limit("5/minute")
155+
async def request_deletion_route(
156+
request: Request,
157+
body: Optional[DeletionRequestInput] = None,
158+
user: dict = Depends(get_current_user),
159+
service = Depends(get_gdpr_service)
160+
):
161+
uid = user.get("id") or user.get("sub")
162+
if not uid:
163+
raise HTTPException(status_code=401, detail="Authentication required")
164+
165+
res = service.submit_privacy_request(uid, "deletion")
166+
return res
167+
168+
@router.post("/api/privacy/cancel-delete")
169+
@limiter.limit("5/minute")
170+
async def cancel_deletion_route(
171+
request: Request,
172+
user: dict = Depends(get_current_user),
173+
service = Depends(get_gdpr_service)
174+
):
175+
uid = user.get("id") or user.get("sub")
176+
if not uid:
177+
raise HTTPException(status_code=401, detail="Authentication required")
178+
179+
res = service.supabase.table("privacy_requests").select("*").eq("user_id", uid).eq("request_type", "deletion").eq("status", "Submitted").execute()
180+
requests = res.data or []
181+
cancelled_count = 0
182+
for req in requests:
183+
service.update_privacy_request_status(req["id"], "Completed", "Cancelled by User")
184+
cancelled_count += 1
185+
return {"status": "success", "cancelled_requests": cancelled_count}
186+
187+
@router.post("/api/admin/privacy/requests/{request_id}/approve")
188+
@limiter.limit("10/minute")
189+
async def update_request_status_route(
190+
request_id: str,
191+
body: StatusUpdateRequest,
192+
request: Request,
193+
user: dict = Depends(get_current_user),
194+
service = Depends(get_gdpr_service)
195+
):
196+
uid = user.get("id") or user.get("sub")
197+
if not uid:
198+
raise HTTPException(status_code=401, detail="Authentication required")
199+
200+
# Check permissions (either the owner of request, or admin)
201+
res = service.supabase.table("privacy_requests").select("*").eq("id", request_id).execute()
202+
if not res.data:
203+
raise HTTPException(status_code=404, detail="Request not found")
204+
205+
req = res.data[0]
206+
is_owner = str(req.get("user_id")) == str(uid)
207+
role = (user.get("user_metadata") or {}).get("role") or user.get("role")
208+
is_admin = role in ("admin", "company_admin", "master_admin")
209+
210+
if not is_owner and not is_admin:
211+
raise HTTPException(status_code=403, detail="Forbidden")
212+
213+
admin_notes = body.admin_notes or ""
214+
# Decide status
215+
status_val = "Completed"
216+
if "cancel" in admin_notes.lower() or "cancelled" in admin_notes.lower():
217+
status_val = "Completed"
218+
if not admin_notes:
219+
admin_notes = "Cancelled by User"
220+
221+
updated = service.update_privacy_request_status(request_id, status_val, admin_notes)
222+
return updated
223+
224+
# --- Export Data Route ---
225+
226+
@router.get("/api/privacy/export")
227+
@router.post("/api/privacy/export")
228+
@router.get("/privacy/export")
229+
@router.post("/privacy/export")
230+
@limiter.limit("5/minute")
231+
async def export_data_route(
232+
request: Request,
233+
user_id: Optional[str] = None,
234+
user: dict = Depends(get_current_user),
235+
service = Depends(get_gdpr_service)
236+
):
237+
uid = user.get("id") or user.get("sub")
238+
if not uid and user_id:
239+
uid = user_id
240+
if not uid:
241+
raise HTTPException(status_code=401, detail="Authentication required")
242+
243+
# Determine format
244+
fmt = "json"
245+
if request.method == "POST":
246+
try:
247+
body = await request.json()
248+
fmt = str(body.get("format", "json")).lower()
249+
except Exception:
250+
pass
251+
else:
252+
fmt = str(request.query_params.get("format", "json")).lower()
253+
254+
if fmt not in ("json", "csv"):
255+
fmt = "json"
256+
257+
# Generate data
258+
export_data = service.generate_user_data_export(uid)
259+
260+
# Log audit
261+
service.submit_privacy_request(uid, "export")
262+
263+
if fmt == "csv":
264+
csv_stream = service.export_to_csv_zip_stream(export_data)
265+
return StreamingResponse(
266+
csv_stream,
267+
media_type="text/csv",
268+
headers={"Content-Disposition": f"attachment; filename=helpdesk_export_{uid}.csv"}
269+
)
270+
else:
271+
# JSON format download file
272+
json_bytes = json.dumps(export_data, default=str, indent=2).encode("utf-8")
273+
bio = io.BytesIO(json_bytes)
274+
return StreamingResponse(
275+
bio,
276+
media_type="application/json",
277+
headers={"Content-Disposition": f"attachment; filename=helpdesk_export_{uid}.json"}
278+
)
279+
280+
# --- Daily Background Retention Scheduler Loop ---
281+
282+
import asyncio
283+
284+
async def privacy_retention_scheduler_loop_async(supabase_client: Any, interval_seconds: int = 86400):
285+
"""Background task loop that runs privacy lifecycle operations periodically (e.g. daily)."""
286+
logger.info("Privacy retention scheduler loop started (interval=%ds)", interval_seconds)
287+
# Give the server startup some settle time before first scan
288+
await asyncio.sleep(10)
289+
service = load_gdpr_service(supabase_client)
290+
while True:
291+
try:
292+
logger.info("[PrivacyScheduler] Starting daily compliance retention check...")
293+
294+
# 1. Clean up expired attachments (resolved tickets older than 90 days)
295+
attachments_wiped = service.cleanup_expired_attachments(days=90)
296+
297+
# 2. Archive tickets resolved/closed over 1 year ago
298+
tickets_archived = service.archive_old_tickets(years=1)
299+
300+
# 3. Clean up inactive accounts (inactive for 2+ years)
301+
inactive_accounts_processed = service.cleanup_inactive_accounts(years=2)
302+
303+
# 4. Process deletion requests past the 30-day grace period
304+
deletions_executed = service.process_expired_deletion_requests(days=30)
305+
306+
logger.info(
307+
"[PrivacyScheduler] Check finished: Wiped %d attachments, Archived %d tickets, Flagged %d inactive accounts, Executed %d erasures",
308+
attachments_wiped, tickets_archived, inactive_accounts_processed, deletions_executed
309+
)
310+
except Exception as e:
311+
logger.error("[PrivacyScheduler] Error during retention check: %s", e)
312+
313+
await asyncio.sleep(interval_seconds)

backend/services/gdpr_service.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,35 @@ def cleanup_inactive_accounts(self, years: int = 2) -> int:
388388
logger.error("Privacy Lifecycle: failed inactive account cleanup: %s", exc)
389389
return count
390390

391+
def process_expired_deletion_requests(self, days: int = 30) -> int:
392+
"""Process deletion requests that have passed the grace period (default 30 days)."""
393+
count = 0
394+
try:
395+
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
396+
res = (
397+
self.supabase.table("privacy_requests")
398+
.select("id, user_id")
399+
.eq("request_type", "deletion")
400+
.eq("status", "Submitted")
401+
.lt("created_at", cutoff)
402+
.execute()
403+
)
404+
requests = res.data or []
405+
for req in requests:
406+
user_id = req["user_id"]
407+
req_id = req["id"]
408+
try:
409+
self.execute_account_deletion_anonymization(user_id)
410+
self.update_privacy_request_status(req_id, "Completed", "Grace period expired, account permanently deleted.")
411+
count += 1
412+
except Exception as e:
413+
logger.error("Failed to process deletion request %s for user %s: %s", req_id, user_id, e)
414+
if count > 0:
415+
logger.info("Privacy Lifecycle: executed %d expired deletion requests", count)
416+
except Exception as exc:
417+
logger.error("Privacy Lifecycle: failed to process expired deletion requests: %s", exc)
418+
return count
419+
391420

392421
_instance: Optional[GdprService] = None
393422

0 commit comments

Comments
 (0)