673 lines
24 KiB
Python
673 lines
24 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Generic GDPR data deletion engine.
|
|
Automatically discovers and deletes user data across all databases and tables.
|
|
|
|
Design:
|
|
- Schema-based: Inspects database schemas to find user-related columns
|
|
- Generic: Works with any new features/tables without code changes
|
|
- Safe: Anonymizes audit logs instead of deleting them
|
|
- Comprehensive: Covers all databases (App, Management, Chat, Feature-DBs)
|
|
"""
|
|
|
|
import logging
|
|
from typing import List, Dict, Any, Set, Tuple
|
|
from modules.shared.timeUtils import getUtcTimestamp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Tables to SKIP (never delete from these)
|
|
PROTECTED_TABLES = {
|
|
"_system", # System metadata table
|
|
"UserInDB", # User account table (deleted separately at the end)
|
|
}
|
|
|
|
# Tables to ANONYMIZE instead of DELETE (for compliance)
|
|
ANONYMIZE_TABLES = {
|
|
"AuditEvent", # Audit logs must be retained for compliance
|
|
"AuthEvent", # Authentication logs must be retained for compliance
|
|
}
|
|
|
|
# User reference column patterns to search for
|
|
USER_COLUMNS = [
|
|
"userId",
|
|
"createdBy",
|
|
"usedBy",
|
|
"revokedBy",
|
|
"_createdBy",
|
|
"_modifiedBy",
|
|
]
|
|
|
|
|
|
def _getTableColumns(dbConnector, tableName: str) -> List[str]:
|
|
"""
|
|
Get all column names for a table by inspecting the schema.
|
|
|
|
Args:
|
|
dbConnector: DatabaseConnector instance
|
|
tableName: Name of the table
|
|
|
|
Returns:
|
|
List of column names
|
|
"""
|
|
try:
|
|
query = """
|
|
SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = %s
|
|
AND table_schema = 'public'
|
|
ORDER BY ordinal_position
|
|
"""
|
|
|
|
cursor = dbConnector.connection.cursor()
|
|
cursor.execute(query, (tableName,))
|
|
columns = [row[0] for row in cursor.fetchall()]
|
|
cursor.close()
|
|
|
|
return columns
|
|
except Exception as e:
|
|
logger.error(f"Error getting columns for table {tableName}: {e}")
|
|
return []
|
|
|
|
|
|
def _getAllTables(dbConnector) -> List[str]:
|
|
"""
|
|
Get all table names from a database, sorted by dependency order.
|
|
Child tables (with foreign keys) come before parent tables.
|
|
|
|
Args:
|
|
dbConnector: DatabaseConnector instance
|
|
|
|
Returns:
|
|
List of table names in deletion order
|
|
"""
|
|
try:
|
|
# Get all tables
|
|
query = """
|
|
SELECT table_name
|
|
FROM information_schema.tables
|
|
WHERE table_schema = 'public'
|
|
AND table_type = 'BASE TABLE'
|
|
ORDER BY table_name
|
|
"""
|
|
|
|
cursor = dbConnector.connection.cursor()
|
|
cursor.execute(query)
|
|
allTables = [row[0] for row in cursor.fetchall()]
|
|
|
|
# Get foreign key relationships to determine dependency order
|
|
fkQuery = """
|
|
SELECT
|
|
tc.table_name,
|
|
ccu.table_name AS foreign_table_name
|
|
FROM information_schema.table_constraints AS tc
|
|
JOIN information_schema.key_column_usage AS kcu
|
|
ON tc.constraint_name = kcu.constraint_name
|
|
AND tc.table_schema = kcu.table_schema
|
|
JOIN information_schema.constraint_column_usage AS ccu
|
|
ON ccu.constraint_name = tc.constraint_name
|
|
AND ccu.table_schema = tc.table_schema
|
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
AND tc.table_schema = 'public'
|
|
"""
|
|
|
|
cursor.execute(fkQuery)
|
|
foreignKeys = cursor.fetchall()
|
|
cursor.close()
|
|
|
|
# Build dependency graph (child -> parent mapping)
|
|
dependencies = {}
|
|
for childTable, parentTable in foreignKeys:
|
|
if childTable not in dependencies:
|
|
dependencies[childTable] = []
|
|
dependencies[childTable].append(parentTable)
|
|
|
|
# Sort tables by dependency (topological sort)
|
|
sortedTables = []
|
|
visited = set()
|
|
|
|
def visit(table):
|
|
if table in visited or table not in allTables:
|
|
return
|
|
visited.add(table)
|
|
|
|
# Visit dependencies first (parents)
|
|
if table in dependencies:
|
|
for parent in dependencies[table]:
|
|
visit(parent)
|
|
|
|
sortedTables.append(table)
|
|
|
|
# Visit all tables
|
|
for table in allTables:
|
|
visit(table)
|
|
|
|
# Reverse to get deletion order (children before parents)
|
|
sortedTables.reverse()
|
|
|
|
# Filter out protected tables
|
|
return [t for t in sortedTables if t not in PROTECTED_TABLES]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting tables from database: {e}")
|
|
# Fallback: return simple list without ordering
|
|
try:
|
|
query = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'"
|
|
cursor = dbConnector.connection.cursor()
|
|
cursor.execute(query)
|
|
tables = [row[0] for row in cursor.fetchall()]
|
|
cursor.close()
|
|
return [t for t in tables if t not in PROTECTED_TABLES]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _getPrimaryKeyColumns(dbConnector, tableName: str) -> List[str]:
|
|
"""
|
|
Get primary key column(s) for a table.
|
|
|
|
Args:
|
|
dbConnector: DatabaseConnector instance
|
|
tableName: Name of the table
|
|
|
|
Returns:
|
|
List of primary key column names
|
|
"""
|
|
try:
|
|
query = """
|
|
SELECT a.attname
|
|
FROM pg_index i
|
|
JOIN pg_attribute a ON a.attrelid = i.indrelid
|
|
AND a.attnum = ANY(i.indkey)
|
|
WHERE i.indrelid = %s::regclass
|
|
AND i.indisprimary
|
|
"""
|
|
|
|
cursor = dbConnector.connection.cursor()
|
|
cursor.execute(query, (tableName,))
|
|
pkColumns = [row[0] for row in cursor.fetchall()]
|
|
cursor.close()
|
|
|
|
return pkColumns
|
|
except Exception as e:
|
|
logger.debug(f"Could not get primary key for {tableName}: {e}")
|
|
return ["id"] # Fallback to 'id'
|
|
|
|
|
|
def _findUserReferencesInTable(
|
|
dbConnector,
|
|
tableName: str,
|
|
userId: str
|
|
) -> Dict[str, List[Tuple]]:
|
|
"""
|
|
Find all records in a table that reference a user.
|
|
|
|
Args:
|
|
dbConnector: DatabaseConnector instance
|
|
tableName: Name of the table
|
|
userId: User ID to search for
|
|
|
|
Returns:
|
|
Dict mapping column names to lists of primary key tuples
|
|
"""
|
|
try:
|
|
# Get all columns for this table
|
|
columns = _getTableColumns(dbConnector, tableName)
|
|
|
|
# Find user-related columns in this table
|
|
userColumns = [col for col in columns if col in USER_COLUMNS]
|
|
|
|
if not userColumns:
|
|
return {}
|
|
|
|
# Get primary key columns
|
|
pkColumns = _getPrimaryKeyColumns(dbConnector, tableName)
|
|
|
|
if not pkColumns:
|
|
logger.warning(f"Table {tableName} has no primary key, skipping")
|
|
return {}
|
|
|
|
references = {}
|
|
cursor = dbConnector.connection.cursor()
|
|
|
|
for userColumn in userColumns:
|
|
# Build SELECT for primary key columns
|
|
pkSelect = ", ".join([f'"{pk}"' for pk in pkColumns])
|
|
query = f'SELECT {pkSelect} FROM "{tableName}" WHERE "{userColumn}" = %s'
|
|
|
|
cursor.execute(query, (userId,))
|
|
recordKeys = cursor.fetchall()
|
|
|
|
if recordKeys:
|
|
references[userColumn] = recordKeys
|
|
logger.debug(f"Found {len(recordKeys)} records in {tableName}.{userColumn} for user {userId}")
|
|
|
|
cursor.close()
|
|
return references
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error finding user references in {tableName}: {e}")
|
|
return {}
|
|
|
|
|
|
def _anonymizeRecords(
|
|
dbConnector,
|
|
tableName: str,
|
|
columnName: str,
|
|
recordKeys: List[Tuple],
|
|
pkColumns: List[str],
|
|
anonymousValue: str = "deleted_user"
|
|
) -> int:
|
|
"""
|
|
Anonymize user references in records (set to 'deleted_user').
|
|
|
|
Args:
|
|
dbConnector: DatabaseConnector instance
|
|
tableName: Name of the table
|
|
columnName: Name of the column to anonymize
|
|
recordKeys: List of primary key tuples
|
|
pkColumns: List of primary key column names
|
|
anonymousValue: Value to set (default: "deleted_user")
|
|
|
|
Returns:
|
|
Number of records anonymized
|
|
"""
|
|
if not recordKeys:
|
|
return 0
|
|
|
|
try:
|
|
cursor = dbConnector.connection.cursor()
|
|
count = 0
|
|
|
|
for recordKey in recordKeys:
|
|
# Build WHERE clause for primary key
|
|
whereClause = " AND ".join([f'"{pk}" = %s' for pk in pkColumns])
|
|
|
|
# Check if table has _modifiedAt column
|
|
columns = _getTableColumns(dbConnector, tableName)
|
|
hasModifiedAt = "_modifiedAt" in columns
|
|
|
|
if hasModifiedAt:
|
|
query = f'UPDATE "{tableName}" SET "{columnName}" = %s, "_modifiedAt" = %s WHERE {whereClause}'
|
|
params = [anonymousValue, getUtcTimestamp()]
|
|
else:
|
|
query = f'UPDATE "{tableName}" SET "{columnName}" = %s WHERE {whereClause}'
|
|
params = [anonymousValue]
|
|
|
|
# Add primary key values to params
|
|
if isinstance(recordKey, tuple):
|
|
params.extend(recordKey)
|
|
else:
|
|
params.append(recordKey)
|
|
|
|
cursor.execute(query, params)
|
|
count += cursor.rowcount
|
|
|
|
dbConnector.connection.commit()
|
|
cursor.close()
|
|
|
|
logger.info(f"Anonymized {count} records in {tableName}.{columnName}")
|
|
return count
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error anonymizing records in {tableName}.{columnName}: {e}")
|
|
dbConnector.connection.rollback()
|
|
return 0
|
|
|
|
|
|
def _deleteRecords(
|
|
dbConnector,
|
|
tableName: str,
|
|
recordKeys: List[Tuple],
|
|
pkColumns: List[str]
|
|
) -> int:
|
|
"""
|
|
Delete records from a table.
|
|
|
|
Args:
|
|
dbConnector: DatabaseConnector instance
|
|
tableName: Name of the table
|
|
recordKeys: List of primary key tuples
|
|
pkColumns: List of primary key column names
|
|
|
|
Returns:
|
|
Number of records deleted
|
|
"""
|
|
if not recordKeys:
|
|
return 0
|
|
|
|
try:
|
|
cursor = dbConnector.connection.cursor()
|
|
count = 0
|
|
|
|
for recordKey in recordKeys:
|
|
# Build WHERE clause for primary key
|
|
whereClause = " AND ".join([f'"{pk}" = %s' for pk in pkColumns])
|
|
query = f'DELETE FROM "{tableName}" WHERE {whereClause}'
|
|
|
|
# Prepare params
|
|
if isinstance(recordKey, tuple):
|
|
params = list(recordKey)
|
|
else:
|
|
params = [recordKey]
|
|
|
|
cursor.execute(query, params)
|
|
count += cursor.rowcount
|
|
|
|
dbConnector.connection.commit()
|
|
cursor.close()
|
|
|
|
logger.info(f"Deleted {count} records from {tableName}")
|
|
return count
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error deleting records from {tableName}: {e}")
|
|
dbConnector.connection.rollback()
|
|
return 0
|
|
|
|
|
|
def deleteUserDataFromDatabase(
|
|
dbConnector,
|
|
userId: str,
|
|
databaseName: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Delete or anonymize all user data from a single database.
|
|
|
|
Args:
|
|
dbConnector: DatabaseConnector instance
|
|
userId: User ID to delete
|
|
databaseName: Name of the database (for logging)
|
|
|
|
Returns:
|
|
Dict with deletion statistics
|
|
"""
|
|
stats = {
|
|
"database": databaseName,
|
|
"tablesProcessed": 0,
|
|
"recordsDeleted": 0,
|
|
"recordsAnonymized": 0,
|
|
"errors": []
|
|
}
|
|
|
|
try:
|
|
# Get all tables in this database
|
|
tables = _getAllTables(dbConnector)
|
|
|
|
logger.info(f"Processing {len(tables)} tables in {databaseName} for user {userId}")
|
|
|
|
for tableName in tables:
|
|
try:
|
|
# Get primary key columns for this table
|
|
pkColumns = _getPrimaryKeyColumns(dbConnector, tableName)
|
|
|
|
if not pkColumns:
|
|
logger.debug(f"Skipping {tableName} - no primary key")
|
|
continue
|
|
|
|
# Find user references in this table
|
|
references = _findUserReferencesInTable(dbConnector, tableName, userId)
|
|
|
|
if not references:
|
|
continue
|
|
|
|
stats["tablesProcessed"] += 1
|
|
|
|
# Decide: Anonymize or Delete?
|
|
shouldAnonymize = tableName in ANONYMIZE_TABLES
|
|
|
|
for columnName, recordKeys in references.items():
|
|
if shouldAnonymize:
|
|
# Anonymize audit/log tables
|
|
count = _anonymizeRecords(
|
|
dbConnector, tableName, columnName, recordKeys, pkColumns
|
|
)
|
|
stats["recordsAnonymized"] += count
|
|
else:
|
|
# Delete from regular tables
|
|
count = _deleteRecords(dbConnector, tableName, recordKeys, pkColumns)
|
|
stats["recordsDeleted"] += count
|
|
|
|
except Exception as tableErr:
|
|
errorMsg = f"Error processing table {tableName}: {tableErr}"
|
|
logger.error(errorMsg)
|
|
stats["errors"].append(errorMsg)
|
|
|
|
logger.info(f"Completed deletion in {databaseName}: {stats}")
|
|
return stats
|
|
|
|
except Exception as e:
|
|
errorMsg = f"Error processing database {databaseName}: {e}"
|
|
logger.error(errorMsg)
|
|
stats["errors"].append(errorMsg)
|
|
return stats
|
|
|
|
|
|
def deleteUserDataAcrossAllDatabases(userId: str, currentUser) -> Dict[str, Any]:
|
|
"""
|
|
Delete or anonymize all user data across ALL databases.
|
|
|
|
This is the main entry point for GDPR Article 17 (Right to Erasure).
|
|
|
|
Features:
|
|
- Automatically discovers all databases and tables
|
|
- Schema-based: No hardcoded table lists
|
|
- Safe: Anonymizes audit logs instead of deleting them
|
|
- Comprehensive: Covers App, Management, Chat, and all Feature DBs
|
|
|
|
Args:
|
|
userId: User ID to delete
|
|
currentUser: User object (for interface access)
|
|
|
|
Returns:
|
|
Dict with comprehensive deletion statistics
|
|
"""
|
|
allStats = {
|
|
"userId": userId,
|
|
"deletedAt": getUtcTimestamp(),
|
|
"databases": [],
|
|
"totalTablesProcessed": 0,
|
|
"totalRecordsDeleted": 0,
|
|
"totalRecordsAnonymized": 0,
|
|
"errors": []
|
|
}
|
|
|
|
try:
|
|
# Import all database interfaces
|
|
from modules.interfaces.interfaceDbApp import getRootInterface as getAppInterface
|
|
from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface
|
|
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
|
|
|
|
# 1. Process App DB (poweron_app)
|
|
try:
|
|
appInterface = getAppInterface()
|
|
appStats = deleteUserDataFromDatabase(appInterface.db, userId, "poweron_app")
|
|
allStats["databases"].append(appStats)
|
|
allStats["totalTablesProcessed"] += appStats["tablesProcessed"]
|
|
allStats["totalRecordsDeleted"] += appStats["recordsDeleted"]
|
|
allStats["totalRecordsAnonymized"] += appStats["recordsAnonymized"]
|
|
allStats["errors"].extend(appStats["errors"])
|
|
except Exception as appErr:
|
|
errorMsg = f"Error processing App DB: {appErr}"
|
|
logger.error(errorMsg)
|
|
allStats["errors"].append(errorMsg)
|
|
|
|
# 2. Process Management DB (poweron_management)
|
|
try:
|
|
mgmtInterface = getMgmtInterface(currentUser)
|
|
mgmtStats = deleteUserDataFromDatabase(mgmtInterface.db, userId, "poweron_management")
|
|
allStats["databases"].append(mgmtStats)
|
|
allStats["totalTablesProcessed"] += mgmtStats["tablesProcessed"]
|
|
allStats["totalRecordsDeleted"] += mgmtStats["recordsDeleted"]
|
|
allStats["totalRecordsAnonymized"] += mgmtStats["recordsAnonymized"]
|
|
allStats["errors"].extend(mgmtStats["errors"])
|
|
except Exception as mgmtErr:
|
|
errorMsg = f"Error processing Management DB: {mgmtErr}"
|
|
logger.error(errorMsg)
|
|
allStats["errors"].append(errorMsg)
|
|
|
|
# 3. Process Chat DB (poweron_chat)
|
|
try:
|
|
chatInterface = getChatInterface(currentUser)
|
|
chatStats = deleteUserDataFromDatabase(chatInterface.db, userId, "poweron_chat")
|
|
allStats["databases"].append(chatStats)
|
|
allStats["totalTablesProcessed"] += chatStats["tablesProcessed"]
|
|
allStats["totalRecordsDeleted"] += chatStats["recordsDeleted"]
|
|
allStats["totalRecordsAnonymized"] += chatStats["recordsAnonymized"]
|
|
allStats["errors"].extend(chatStats["errors"])
|
|
except Exception as chatErr:
|
|
errorMsg = f"Error processing Chat DB: {chatErr}"
|
|
logger.error(errorMsg)
|
|
allStats["errors"].append(errorMsg)
|
|
|
|
# 4. Process Feature DBs (discover dynamically)
|
|
try:
|
|
featureStats = _deleteUserDataFromFeatureDatabases(userId, currentUser)
|
|
allStats["databases"].extend(featureStats["databases"])
|
|
allStats["totalTablesProcessed"] += featureStats["totalTablesProcessed"]
|
|
allStats["totalRecordsDeleted"] += featureStats["totalRecordsDeleted"]
|
|
allStats["totalRecordsAnonymized"] += featureStats["totalRecordsAnonymized"]
|
|
allStats["errors"].extend(featureStats["errors"])
|
|
except Exception as featureErr:
|
|
errorMsg = f"Error processing Feature DBs: {featureErr}"
|
|
logger.error(errorMsg)
|
|
allStats["errors"].append(errorMsg)
|
|
|
|
# Log summary
|
|
logger.info(f"GDPR deletion completed for user {userId}: "
|
|
f"{allStats['totalRecordsDeleted']} deleted, "
|
|
f"{allStats['totalRecordsAnonymized']} anonymized across "
|
|
f"{len(allStats['databases'])} databases")
|
|
|
|
return allStats
|
|
|
|
except Exception as e:
|
|
logger.error(f"Fatal error in deleteUserDataAcrossAllDatabases: {e}")
|
|
allStats["errors"].append(f"Fatal error: {e}")
|
|
return allStats
|
|
|
|
|
|
def _deleteUserDataFromFeatureDatabases(userId: str, currentUser) -> Dict[str, Any]:
|
|
"""
|
|
Delete user data from all feature-specific databases.
|
|
Discovers feature interfaces dynamically.
|
|
|
|
Args:
|
|
userId: User ID to delete
|
|
currentUser: User object
|
|
|
|
Returns:
|
|
Dict with deletion statistics
|
|
"""
|
|
stats = {
|
|
"databases": [],
|
|
"totalTablesProcessed": 0,
|
|
"totalRecordsDeleted": 0,
|
|
"totalRecordsAnonymized": 0,
|
|
"errors": []
|
|
}
|
|
|
|
try:
|
|
# Get all feature instances for this user to determine which feature DBs to check
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.datamodels.datamodelMembership import FeatureAccess
|
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
|
|
|
rootInterface = getRootInterface()
|
|
|
|
# Get all feature accesses for this user using interface method
|
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(userId))
|
|
|
|
# Collect unique feature codes
|
|
featureCodes: Set[str] = set()
|
|
for fa in featureAccesses:
|
|
instanceId = fa.featureInstanceId
|
|
instance = rootInterface.getFeatureInstance(instanceId)
|
|
if instance:
|
|
featureCode = instance.featureCode
|
|
if featureCode:
|
|
featureCodes.add(featureCode)
|
|
|
|
logger.info(f"Found {len(featureCodes)} feature types to process: {featureCodes}")
|
|
|
|
# Process each feature type
|
|
for featureCode in featureCodes:
|
|
try:
|
|
dbName = f"poweron_{featureCode}"
|
|
|
|
# Try to get feature interface
|
|
featureInterface = None
|
|
|
|
if featureCode == "trustee":
|
|
from modules.features.trustee.interfaceFeatureTrustee import getInterface as getTrusteeInterface
|
|
featureInterface = getTrusteeInterface(currentUser)
|
|
elif featureCode == "realestate":
|
|
from modules.features.realestate.interfaceFeatureRealEstate import getInterface as getRealEstateInterface
|
|
featureInterface = getRealEstateInterface(currentUser)
|
|
elif featureCode == "chatbot":
|
|
from modules.features.chatbot.interfaceFeatureChatbot import getInterface as getChatbotInterface
|
|
featureInterface = getChatbotInterface(currentUser)
|
|
elif featureCode == "neutralization":
|
|
from modules.features.neutralization.interfaceFeatureNeutralizer import getInterface as getNeutralizerInterface
|
|
featureInterface = getNeutralizerInterface(currentUser)
|
|
else:
|
|
logger.warning(f"No interface found for feature code: {featureCode}")
|
|
continue
|
|
|
|
if featureInterface and hasattr(featureInterface, 'db'):
|
|
featureStats = deleteUserDataFromDatabase(
|
|
featureInterface.db,
|
|
userId,
|
|
dbName
|
|
)
|
|
stats["databases"].append(featureStats)
|
|
stats["totalTablesProcessed"] += featureStats["tablesProcessed"]
|
|
stats["totalRecordsDeleted"] += featureStats["recordsDeleted"]
|
|
stats["totalRecordsAnonymized"] += featureStats["recordsAnonymized"]
|
|
stats["errors"].extend(featureStats["errors"])
|
|
|
|
except Exception as featureErr:
|
|
errorMsg = f"Error processing feature {featureCode}: {featureErr}"
|
|
logger.warning(errorMsg)
|
|
stats["errors"].append(errorMsg)
|
|
|
|
return stats
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in _deleteUserDataFromFeatureDatabases: {e}")
|
|
stats["errors"].append(f"Feature DB error: {e}")
|
|
return stats
|
|
|
|
|
|
def buildDeletionSummary(stats: Dict[str, Any]) -> List[str]:
|
|
"""
|
|
Build a human-readable summary of the deletion operation.
|
|
|
|
Args:
|
|
stats: Statistics dict from deleteUserDataAcrossAllDatabases
|
|
|
|
Returns:
|
|
List of summary strings
|
|
"""
|
|
summary = []
|
|
|
|
for dbStats in stats.get("databases", []):
|
|
dbName = dbStats.get("database", "unknown")
|
|
deleted = dbStats.get("recordsDeleted", 0)
|
|
anonymized = dbStats.get("recordsAnonymized", 0)
|
|
|
|
if deleted > 0 or anonymized > 0:
|
|
parts = []
|
|
if deleted > 0:
|
|
parts.append(f"{deleted} deleted")
|
|
if anonymized > 0:
|
|
parts.append(f"{anonymized} anonymized")
|
|
summary.append(f"{dbName}: {', '.join(parts)}")
|
|
|
|
# Add totals
|
|
totalDeleted = stats.get("totalRecordsDeleted", 0)
|
|
totalAnonymized = stats.get("totalRecordsAnonymized", 0)
|
|
summary.append(f"Total: {totalDeleted} deleted, {totalAnonymized} anonymized")
|
|
|
|
return summary
|