feat(dsr): Go DSR deprecated, Python Export-Endpoint, Frontend an Backend-APIs anbinden
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 34s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 17s

- Go: DEPRECATED-Kommentare an allen DSR-Handlern und Routes
- Python: GET /dsr/export?format=csv|json (Semikolon-CSV, 12 Spalten)
- API-Client: 12 neue Funktionen (verify, assign, extend, complete, reject, communications, exception-checks, history)
- Detail-Seite: Alle Actions verdrahtet (keine Coming-soon-Alerts mehr), Communications + Art.17(3)-Checks + Audit-Log live
- Haupt-Seite: CSV-Export-Button im Header
- Tests: 54/54 bestanden (4 neue Export-Tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-06 18:21:43 +01:00
parent 3593a4ff78
commit 095eff26d9
7 changed files with 526 additions and 102 deletions

View File

@@ -13,7 +13,21 @@ import {
DSRCommunication,
DSRVerifyIdentityRequest
} from '@/lib/sdk/dsr/types'
import { fetchSDKDSR, updateSDKDSRStatus } from '@/lib/sdk/dsr/api'
import {
fetchSDKDSR,
updateSDKDSRStatus,
verifyDSRIdentity,
assignDSR,
extendDSRDeadline,
completeDSR,
rejectDSR,
fetchDSRCommunications,
sendDSRCommunication,
fetchDSRExceptionChecks,
initDSRExceptionChecks,
updateDSRExceptionCheck,
fetchDSRHistory,
} from '@/lib/sdk/dsr/api'
import {
DSRWorkflowStepper,
DSRIdentityModal,
@@ -22,12 +36,6 @@ import {
DSRDataExportComponent
} from '@/components/sdk/dsr'
// =============================================================================
// MOCK COMMUNICATIONS
// =============================================================================
// TODO: Backend fehlt — Communications API noch nicht implementiert
// =============================================================================
// COMPONENTS
// =============================================================================
@@ -155,56 +163,38 @@ function ActionButtons({
)
}
function AuditLog({ request }: { request: DSRRequest }) {
type AuditEvent = { action: string; timestamp: string; user: string }
const events: AuditEvent[] = [
{ action: 'Erstellt', timestamp: request.createdAt, user: request.createdBy }
]
if (request.assignment.assignedAt) {
events.push({
action: `Zugewiesen an ${request.assignment.assignedTo}`,
timestamp: request.assignment.assignedAt,
user: request.assignment.assignedBy || 'System'
})
}
if (request.identityVerification.verifiedAt) {
events.push({
action: 'Identitaet verifiziert',
timestamp: request.identityVerification.verifiedAt,
user: request.identityVerification.verifiedBy || 'System'
})
}
if (request.completedAt) {
events.push({
action: request.status === 'rejected' ? 'Abgelehnt' : 'Abgeschlossen',
timestamp: request.completedAt,
user: request.updatedBy || 'System'
})
}
function AuditLog({ history }: { history: any[] }) {
return (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-700">Aktivitaeten</h4>
<div className="space-y-2">
{events.map((event, idx) => (
<div key={idx} className="flex items-start gap-2 text-xs">
{history.length === 0 && (
<div className="text-xs text-gray-400">Keine Eintraege</div>
)}
{history.map((entry, idx) => (
<div key={entry.id || idx} className="flex items-start gap-2 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 mt-1.5 flex-shrink-0" />
<div>
<div className="text-gray-900">{event.action}</div>
<div className="text-gray-900">
{entry.previous_status
? `${entry.previous_status}${entry.new_status}`
: entry.new_status
}
{entry.comment && `: ${entry.comment}`}
</div>
<div className="text-gray-500">
{new Date(event.timestamp).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
{entry.created_at
? new Date(entry.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: ''
}
{' - '}
{event.user}
{entry.changed_by || 'System'}
</div>
</div>
</div>
@@ -225,10 +215,17 @@ export default function DSRDetailPage() {
const [request, setRequest] = useState<DSRRequest | null>(null)
const [communications, setCommunications] = useState<DSRCommunication[]>([])
const [history, setHistory] = useState<any[]>([])
const [exceptionChecks, setExceptionChecks] = useState<any[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showIdentityModal, setShowIdentityModal] = useState(false)
const [activeContentTab, setActiveContentTab] = useState<'details' | 'communication' | 'type-specific'>('details')
const reloadRequest = async () => {
const found = await fetchSDKDSR(requestId)
if (found) setRequest(found)
}
// Load data from SDK backend
useEffect(() => {
const loadData = async () => {
@@ -237,8 +234,40 @@ export default function DSRDetailPage() {
const found = await fetchSDKDSR(requestId)
if (found) {
setRequest(found)
// TODO: Backend fehlt — Communications API noch nicht implementiert
setCommunications([])
// Load communications, history, and exception checks in parallel
const [comms, hist] = await Promise.all([
fetchDSRCommunications(requestId).catch(() => []),
fetchDSRHistory(requestId).catch(() => []),
])
setCommunications(comms.map((c: any) => ({
id: c.id,
dsrId: c.dsr_id,
type: c.communication_type,
channel: c.channel,
subject: c.subject,
content: c.content,
createdAt: c.created_at,
createdBy: c.created_by,
sentAt: c.sent_at,
sentBy: c.sent_by,
})))
setHistory(hist)
// Load exception checks for erasure requests
if (found.type === 'erasure') {
try {
const checks = await fetchDSRExceptionChecks(requestId)
if (checks.length === 0) {
// Auto-initialize if none exist
const initialized = await initDSRExceptionChecks(requestId)
setExceptionChecks(initialized)
} else {
setExceptionChecks(checks)
}
} catch {
setExceptionChecks([])
}
}
}
} catch (error) {
console.error('Failed to load DSR:', error)
@@ -252,46 +281,109 @@ export default function DSRDetailPage() {
const handleVerifyIdentity = async (verification: DSRVerifyIdentityRequest) => {
if (!request) return
try {
await updateSDKDSRStatus(request.id, 'verified')
setRequest({
...request,
identityVerification: {
verified: true,
method: verification.method,
verifiedAt: new Date().toISOString(),
verifiedBy: 'Current User',
notes: verification.notes
},
status: request.status === 'identity_verification' ? 'processing' : request.status
const updated = await verifyDSRIdentity(request.id, {
method: verification.method,
notes: verification.notes,
document_ref: verification.documentRef,
})
setRequest(updated)
// Reload history
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
} catch (err) {
console.error('Failed to verify identity:', err)
// Still update locally as fallback
setRequest({
...request,
identityVerification: {
verified: true,
method: verification.method,
verifiedAt: new Date().toISOString(),
verifiedBy: 'Current User',
notes: verification.notes
},
status: request.status === 'identity_verification' ? 'processing' : request.status
})
}
}
const handleAssign = async () => {
if (!request) return
const assignee = prompt('Zuweisen an (Name/ID):')
if (!assignee) return
try {
const updated = await assignDSR(request.id, assignee)
setRequest(updated)
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
} catch (err) {
console.error('Failed to assign:', err)
}
}
const handleExtendDeadline = async () => {
if (!request) return
const reason = prompt('Grund fuer die Fristverlaengerung:')
if (!reason) return
const daysStr = prompt('Um wie viele Tage verlaengern? (Standard: 60)', '60')
const days = parseInt(daysStr || '60', 10) || 60
try {
const updated = await extendDSRDeadline(request.id, reason, days)
setRequest(updated)
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
} catch (err) {
console.error('Failed to extend deadline:', err)
}
}
const handleComplete = async () => {
if (!request) return
const summary = prompt('Zusammenfassung der Bearbeitung:')
if (summary === null) return
try {
const updated = await completeDSR(request.id, summary || undefined)
setRequest(updated)
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
} catch (err) {
console.error('Failed to complete:', err)
}
}
const handleReject = async () => {
if (!request) return
const reason = prompt('Ablehnungsgrund:')
if (!reason) return
const legalBasis = prompt('Rechtsgrundlage (optional):')
try {
const updated = await rejectDSR(request.id, reason, legalBasis || undefined)
setRequest(updated)
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
} catch (err) {
console.error('Failed to reject:', err)
}
}
const handleSendCommunication = async (message: any) => {
const newComm: DSRCommunication = {
id: `comm-${Date.now()}`,
dsrId: requestId,
...message,
createdAt: new Date().toISOString(),
createdBy: 'Current User',
sentAt: message.type === 'outgoing' ? new Date().toISOString() : undefined,
sentBy: message.type === 'outgoing' ? 'Current User' : undefined
if (!request) return
try {
const result = await sendDSRCommunication(requestId, {
communication_type: message.type || 'outgoing',
channel: message.channel || 'email',
subject: message.subject,
content: message.content,
})
// Map backend response to frontend format
const newComm: DSRCommunication = {
id: result.id,
dsrId: result.dsr_id,
type: result.communication_type,
channel: result.channel,
subject: result.subject,
content: result.content,
createdAt: result.created_at,
createdBy: result.created_by || 'Current User',
sentAt: result.sent_at,
sentBy: result.sent_by,
}
setCommunications(prev => [newComm, ...prev])
} catch (err) {
console.error('Failed to send communication:', err)
}
}
const handleExceptionCheckChange = async (checkId: string, applies: boolean, notes?: string) => {
try {
const updated = await updateDSRExceptionCheck(requestId, checkId, { applies, notes })
setExceptionChecks(prev => prev.map(c => c.id === checkId ? updated : c))
} catch (err) {
console.error('Failed to update exception check:', err)
}
setCommunications(prev => [newComm, ...prev])
}
if (isLoading) {
@@ -528,10 +620,48 @@ export default function DSRDetailPage() {
<div>
{/* Art. 17 - Erasure */}
{request.type === 'erasure' && (
<DSRErasureChecklistComponent
checklist={request.erasureChecklist}
onChange={(checklist) => setRequest({ ...request, erasureChecklist: checklist })}
/>
<div className="space-y-4">
<DSRErasureChecklistComponent
checklist={request.erasureChecklist}
onChange={(checklist) => setRequest({ ...request, erasureChecklist: checklist })}
/>
{/* Art. 17(3) Exception Checks from Backend */}
{exceptionChecks.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Art. 17(3) Ausnahmepruefung</h3>
<p className="text-sm text-gray-500 mb-4">
Pruefen Sie, ob eine der gesetzlichen Ausnahmen zur Loeschpflicht greift.
</p>
<div className="space-y-3">
{exceptionChecks.map((check) => (
<div key={check.id} className="bg-gray-50 rounded-xl p-4 border border-gray-200">
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={check.applies || false}
onChange={(e) => handleExceptionCheckChange(check.id, e.target.checked, check.notes)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div className="flex-1">
<div className="font-medium text-gray-900 text-sm">
{check.article}: {check.label}
</div>
<div className="text-xs text-gray-500 mt-0.5">{check.description}</div>
{check.checked_by && (
<div className="text-xs text-gray-400 mt-1">
Geprueft von {check.checked_by} am{' '}
{check.checked_at ? new Date(check.checked_at).toLocaleDateString('de-DE') : '-'}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Art. 15/20 - Data Export */}
@@ -697,16 +827,16 @@ export default function DSRDetailPage() {
<ActionButtons
request={request}
onVerifyIdentity={() => setShowIdentityModal(true)}
onExtendDeadline={() => alert('Fristverlaengerung - Coming soon')}
onComplete={() => alert('Abschliessen - Coming soon')}
onReject={() => alert('Ablehnen - Coming soon')}
onAssign={() => alert('Zuweisen - Coming soon')}
onExtendDeadline={handleExtendDeadline}
onComplete={handleComplete}
onReject={handleReject}
onAssign={handleAssign}
/>
</div>
{/* Audit Log Card */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<AuditLog request={request} />
<AuditLog history={history} />
</div>
</div>
</div>

View File

@@ -808,15 +808,31 @@ export default function DSRPage() {
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Anfrage erfassen
</button>
<div className="flex items-center gap-2">
<button
onClick={() => {
const link = document.createElement('a')
link.href = '/api/sdk/v1/compliance/dsr/export?format=csv'
link.download = 'dsr_export.csv'
link.click()
}}
className="flex items-center gap-2 px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
CSV Export
</button>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Anfrage erfassen
</button>
</div>
</StepHeader>
{/* Tab Navigation */}

View File

@@ -245,6 +245,171 @@ export async function updateSDKDSRStatus(id: string, status: string): Promise<vo
}
}
// =============================================================================
// WORKFLOW ACTIONS
// =============================================================================
/**
* Verify identity of DSR requester
*/
export async function verifyDSRIdentity(id: string, data: { method: string; notes?: string; document_ref?: string }): Promise<DSRRequest> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/verify-identity`, {
method: 'POST',
headers: getSdkHeaders(),
body: JSON.stringify(data),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return transformBackendDSR(await res.json())
}
/**
* Assign DSR to a user
*/
export async function assignDSR(id: string, assigneeId: string): Promise<DSRRequest> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/assign`, {
method: 'POST',
headers: getSdkHeaders(),
body: JSON.stringify({ assignee_id: assigneeId }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return transformBackendDSR(await res.json())
}
/**
* Extend DSR deadline (Art. 12 Abs. 3 DSGVO)
*/
export async function extendDSRDeadline(id: string, reason: string, days: number = 60): Promise<DSRRequest> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/extend`, {
method: 'POST',
headers: getSdkHeaders(),
body: JSON.stringify({ reason, days }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return transformBackendDSR(await res.json())
}
/**
* Complete a DSR
*/
export async function completeDSR(id: string, summary?: string): Promise<DSRRequest> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/complete`, {
method: 'POST',
headers: getSdkHeaders(),
body: JSON.stringify({ summary }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return transformBackendDSR(await res.json())
}
/**
* Reject a DSR with legal basis
*/
export async function rejectDSR(id: string, reason: string, legalBasis?: string): Promise<DSRRequest> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/reject`, {
method: 'POST',
headers: getSdkHeaders(),
body: JSON.stringify({ reason, legal_basis: legalBasis }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return transformBackendDSR(await res.json())
}
// =============================================================================
// COMMUNICATIONS
// =============================================================================
/**
* Fetch communications for a DSR
*/
export async function fetchDSRCommunications(id: string): Promise<any[]> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/communications`, {
headers: getSdkHeaders(),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
/**
* Send a communication for a DSR
*/
export async function sendDSRCommunication(id: string, data: { communication_type?: string; channel?: string; subject?: string; content: string }): Promise<any> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/communicate`, {
method: 'POST',
headers: getSdkHeaders(),
body: JSON.stringify({ communication_type: 'outgoing', channel: 'email', ...data }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
// =============================================================================
// EXCEPTION CHECKS (Art. 17)
// =============================================================================
/**
* Fetch exception checks for an erasure DSR
*/
export async function fetchDSRExceptionChecks(id: string): Promise<any[]> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/exception-checks`, {
headers: getSdkHeaders(),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
/**
* Initialize Art. 17(3) exception checks for an erasure DSR
*/
export async function initDSRExceptionChecks(id: string): Promise<any[]> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/exception-checks/init`, {
method: 'POST',
headers: getSdkHeaders(),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
/**
* Update a single exception check
*/
export async function updateDSRExceptionCheck(dsrId: string, checkId: string, data: { applies: boolean; notes?: string }): Promise<any> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${dsrId}/exception-checks/${checkId}`, {
method: 'PUT',
headers: getSdkHeaders(),
body: JSON.stringify(data),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
// =============================================================================
// HISTORY
// =============================================================================
/**
* Fetch status change history for a DSR
*/
export async function fetchDSRHistory(id: string): Promise<any[]> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/history`, {
headers: getSdkHeaders(),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
/**
* Update DSR fields (priority, notes, etc.)
*/
export async function updateDSR(id: string, data: Record<string, any>): Promise<DSRRequest> {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}`, {
method: 'PUT',
headers: getSdkHeaders(),
body: JSON.stringify(data),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return transformBackendDSR(await res.json())
}
// =============================================================================
// MOCK DATA FUNCTIONS (kept as fallback)
// =============================================================================

View File

@@ -273,6 +273,8 @@ func main() {
}
// DSR - Data Subject Requests / Betroffenenrechte (Art. 15-22)
// DEPRECATED: DSR is now managed by backend-compliance (Python).
// Use: GET/POST/PUT /api/compliance/dsr/* on backend-compliance:8002
dsr := dsgvoRoutes.Group("/dsr")
{
dsr.GET("", dsgvoHandlers.ListDSRs)
@@ -304,7 +306,7 @@ func main() {
{
exports.GET("/vvt", dsgvoHandlers.ExportVVT) // DEPRECATED: use backend-compliance /vvt/export?format=csv
exports.GET("/tom", dsgvoHandlers.ExportTOM) // DEPRECATED: use backend-compliance /tom/export?format=csv
exports.GET("/dsr", dsgvoHandlers.ExportDSR)
exports.GET("/dsr", dsgvoHandlers.ExportDSR) // DEPRECATED: use backend-compliance /dsr/export?format=csv
exports.GET("/retention", dsgvoHandlers.ExportRetentionPolicies)
}
}

View File

@@ -220,9 +220,12 @@ func (h *DSGVOHandlers) CreateTOM(c *gin.Context) {
// ============================================================================
// DSR - Data Subject Requests
// DEPRECATED: DSR is now managed by backend-compliance (Python/FastAPI).
// Use: /api/compliance/dsr/* endpoints on backend-compliance:8002
// ============================================================================
// ListDSRs returns all DSRs for a tenant
// DEPRECATED: Use backend-compliance GET /api/compliance/dsr
func (h *DSGVOHandlers) ListDSRs(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {
@@ -243,6 +246,7 @@ func (h *DSGVOHandlers) ListDSRs(c *gin.Context) {
}
// GetDSR returns a DSR by ID
// DEPRECATED: Use backend-compliance GET /api/compliance/dsr/{id}
func (h *DSGVOHandlers) GetDSR(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -264,6 +268,7 @@ func (h *DSGVOHandlers) GetDSR(c *gin.Context) {
}
// CreateDSR creates a new DSR
// DEPRECATED: Use backend-compliance POST /api/compliance/dsr
func (h *DSGVOHandlers) CreateDSR(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
@@ -293,6 +298,7 @@ func (h *DSGVOHandlers) CreateDSR(c *gin.Context) {
}
// UpdateDSR updates a DSR
// DEPRECATED: Use backend-compliance PUT /api/compliance/dsr/{id}
func (h *DSGVOHandlers) UpdateDSR(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -612,6 +618,7 @@ func (h *DSGVOHandlers) ExportTOM(c *gin.Context) {
}
// ExportDSR exports DSR overview as CSV/JSON
// DEPRECATED: Use backend-compliance GET /api/compliance/dsr/export?format=csv|json
func (h *DSGVOHandlers) ExportDSR(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
if tenantID == uuid.Nil {

View File

@@ -4,11 +4,14 @@ DSR (Data Subject Request) Routes — Betroffenenanfragen nach DSGVO Art. 15-21.
Native Python/FastAPI Implementierung, ersetzt Go consent-service Proxy.
"""
import io
import csv
import uuid
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import text, func, and_, or_, cast, String
@@ -438,6 +441,61 @@ async def get_dsr_stats(
}
# =============================================================================
# Export
# =============================================================================
@router.get("/export")
async def export_dsrs(
format: str = Query("csv", pattern="^(csv|json)$"),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Exportiert alle DSRs als CSV oder JSON."""
tid = uuid.UUID(tenant_id)
dsrs = db.query(DSRRequestDB).filter(
DSRRequestDB.tenant_id == tid,
).order_by(DSRRequestDB.created_at.desc()).all()
if format == "json":
return {
"exported_at": datetime.utcnow().isoformat(),
"total": len(dsrs),
"requests": [_dsr_to_dict(d) for d in dsrs],
}
# CSV export (semicolon-separated, matching Go format + extended fields)
output = io.StringIO()
writer = csv.writer(output, delimiter=';', quoting=csv.QUOTE_MINIMAL)
writer.writerow([
"ID", "Referenznummer", "Typ", "Name", "E-Mail", "Status",
"Prioritaet", "Eingegangen", "Frist", "Abgeschlossen", "Quelle", "Zugewiesen",
])
for dsr in dsrs:
writer.writerow([
str(dsr.id),
dsr.request_number or "",
dsr.request_type or "",
dsr.requester_name or "",
dsr.requester_email or "",
dsr.status or "",
dsr.priority or "",
dsr.received_at.strftime("%Y-%m-%d") if dsr.received_at else "",
dsr.deadline_at.strftime("%Y-%m-%d") if dsr.deadline_at else "",
dsr.completed_at.strftime("%Y-%m-%d") if dsr.completed_at else "",
dsr.source or "",
dsr.assigned_to or "",
])
output.seek(0)
return StreamingResponse(
output,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=dsr_export.csv"},
)
# =============================================================================
# Deadline Processing (MUST be before /{dsr_id} to avoid path conflicts)
# =============================================================================

View File

@@ -697,3 +697,49 @@ class TestDSRTemplates:
fake_id = str(uuid.uuid4())
resp = client.get(f"/api/compliance/dsr/templates/{fake_id}/versions", headers=HEADERS)
assert resp.status_code == 404
class TestDSRExport:
"""Tests for DSR export endpoint."""
def test_export_csv_empty(self):
resp = client.get("/api/compliance/dsr/export?format=csv", headers=HEADERS)
assert resp.status_code == 200
assert "text/csv" in resp.headers.get("content-type", "")
lines = resp.text.strip().split("\n")
assert len(lines) == 1 # Header only
assert "Referenznummer" in lines[0]
assert "Zugewiesen" in lines[0]
def test_export_csv_with_data(self):
# Create a DSR first
body = {
"request_type": "access",
"requester_name": "Export Test",
"requester_email": "export@example.de",
"source": "email",
}
create_resp = client.post("/api/compliance/dsr", json=body, headers=HEADERS)
assert create_resp.status_code == 200
resp = client.get("/api/compliance/dsr/export?format=csv", headers=HEADERS)
assert resp.status_code == 200
lines = resp.text.strip().split("\n")
assert len(lines) >= 2 # Header + at least 1 data row
# Check data row contains our test data
assert "Export Test" in lines[1]
assert "export@example.de" in lines[1]
assert "access" in lines[1]
def test_export_json(self):
resp = client.get("/api/compliance/dsr/export?format=json", headers=HEADERS)
assert resp.status_code == 200
data = resp.json()
assert "exported_at" in data
assert "total" in data
assert "requests" in data
assert isinstance(data["requests"], list)
def test_export_invalid_format(self):
resp = client.get("/api/compliance/dsr/export?format=xml", headers=HEADERS)
assert resp.status_code == 422 # Validation error