- Deleted .env.example file as it is no longer needed. - Added .gitignore to manage ignored files and directories. - Introduced CLAUDE.md for AI provider integration documentation. - Created dev.sh for development setup and scripts. - Updated Dockerfile and Dockerfile.production for improved build processes. - Added multiple test files and directories for comprehensive testing. - Introduced new utility and service files for enhanced functionality. - Organized codebase with new directories and files for better maintainability.
874 lines
30 KiB
Python
874 lines
30 KiB
Python
"""
|
|
Consent Manager for Discord Voice Chat Quote Bot
|
|
|
|
Handles user consent, privacy controls, GDPR compliance, and data protection
|
|
with comprehensive tracking and user rights management.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Dict, List, Optional, Set, Union
|
|
|
|
import discord
|
|
from typing_extensions import TypedDict
|
|
|
|
from config.consent_templates import ConsentTemplates
|
|
from core.database import DatabaseManager
|
|
|
|
# ConsentView import moved to method to avoid circular import
|
|
|
|
|
|
class ConsentStatusDict(TypedDict):
|
|
"""Type definition for consent status data."""
|
|
|
|
has_record: bool
|
|
consent_given: bool
|
|
global_opt_out: bool
|
|
consent_timestamp: Optional[datetime]
|
|
first_name: Optional[str]
|
|
created_at: Optional[datetime]
|
|
updated_at: Optional[datetime]
|
|
|
|
|
|
class UserExportDict(TypedDict):
|
|
"""Type definition for user data export."""
|
|
|
|
user_id: int
|
|
export_timestamp: str
|
|
consent_records: List[Dict[str, Union[int, bool, str, None]]]
|
|
quotes: List[Dict[str, Union[int, str, float]]]
|
|
speaker_profile: Optional[Dict[str, Union[str, int, float]]]
|
|
feedback_records: List[Dict[str, Union[int, str]]]
|
|
error: Optional[str]
|
|
|
|
|
|
class PrivacyDashboardDict(TypedDict):
|
|
"""Type definition for privacy dashboard data."""
|
|
|
|
guild_id: int
|
|
generated_at: str
|
|
consent_statistics: Dict[str, Union[int, float]]
|
|
data_retention: Dict[str, Union[int, str, None]]
|
|
compliance_status: Dict[str, Union[bool, List[str]]]
|
|
error: Optional[str]
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ConsentManager:
|
|
"""
|
|
Manages user consent and privacy controls for the Discord Quote Bot
|
|
|
|
Features:
|
|
- Explicit consent collection and tracking
|
|
- GDPR compliance with right to erasure
|
|
- Global opt-out capabilities
|
|
- Automated consent expiry and renewal
|
|
- Comprehensive audit logging
|
|
"""
|
|
|
|
def __init__(self, db_manager: DatabaseManager):
|
|
self.db_manager = db_manager
|
|
self.consent_cache: Dict[int, Dict[int, bool]] = (
|
|
{}
|
|
) # user_id -> {guild_id: consent_status}
|
|
self.global_opt_outs: Set[int] = set()
|
|
self.consent_requests: Dict[str, datetime] = {} # track active consent requests
|
|
self._cache_lock = asyncio.Lock() # Prevent race conditions
|
|
self._cleanup_task: Optional[asyncio.Task] = None
|
|
self._initialized = False
|
|
|
|
async def initialize(self):
|
|
"""Initialize consent manager and load cached data"""
|
|
if self._initialized:
|
|
return
|
|
|
|
try:
|
|
logger.info("Initializing consent manager...")
|
|
|
|
# Load existing consent data into cache
|
|
await self._load_consent_cache()
|
|
|
|
# Load global opt-outs
|
|
await self._load_global_opt_outs()
|
|
|
|
# Start cleanup task for expired consent requests
|
|
self._cleanup_task = asyncio.create_task(self._cleanup_expired_requests())
|
|
|
|
self._initialized = True
|
|
logger.info("Consent manager initialized successfully")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to initialize consent manager: {e}")
|
|
raise
|
|
|
|
async def _load_consent_cache(self):
|
|
"""Load consent data into memory cache for fast access"""
|
|
try:
|
|
results = await self.db_manager.execute_query(
|
|
"SELECT user_id, guild_id, consent_given FROM user_consent",
|
|
fetch_all=True,
|
|
)
|
|
|
|
for row in results:
|
|
user_id = row["user_id"]
|
|
guild_id = row["guild_id"]
|
|
consent_given = row["consent_given"]
|
|
|
|
if user_id not in self.consent_cache:
|
|
self.consent_cache[user_id] = {}
|
|
|
|
self.consent_cache[user_id][guild_id] = consent_given
|
|
|
|
logger.info(f"Loaded consent data for {len(self.consent_cache)} users")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to load consent cache: {e}")
|
|
|
|
async def _load_global_opt_outs(self):
|
|
"""Load users who have globally opted out"""
|
|
try:
|
|
results = await self.db_manager.execute_query(
|
|
"SELECT DISTINCT user_id FROM user_consent WHERE global_opt_out = TRUE",
|
|
fetch_all=True,
|
|
)
|
|
|
|
self.global_opt_outs = {row["user_id"] for row in results}
|
|
logger.info(f"Loaded {len(self.global_opt_outs)} global opt-outs")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to load global opt-outs: {e}")
|
|
|
|
async def _cleanup_expired_requests(self):
|
|
"""Background task to cleanup expired consent requests"""
|
|
while True:
|
|
try:
|
|
current_time = datetime.now(timezone.utc)
|
|
expired_requests = [
|
|
request_id
|
|
for request_id, timestamp in self.consent_requests.items()
|
|
if current_time - timestamp > timedelta(minutes=5)
|
|
]
|
|
|
|
for request_id in expired_requests:
|
|
del self.consent_requests[request_id]
|
|
|
|
if expired_requests:
|
|
logger.info(
|
|
f"Cleaned up {len(expired_requests)} expired consent requests"
|
|
)
|
|
|
|
# Sleep for 1 minute before next cleanup
|
|
await asyncio.sleep(60)
|
|
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Error in consent request cleanup: {e}")
|
|
await asyncio.sleep(60)
|
|
|
|
async def request_consent(
|
|
self,
|
|
guild_id: int,
|
|
channel: discord.TextChannel,
|
|
requester: discord.Member,
|
|
voice_channel: Optional[discord.VoiceChannel] = None,
|
|
) -> bool:
|
|
"""
|
|
Request consent from all users in a voice channel
|
|
|
|
Args:
|
|
guild_id: Discord guild ID
|
|
channel: Text channel to send consent request
|
|
requester: Member who requested recording
|
|
|
|
Returns:
|
|
bool: True if consent request was sent successfully
|
|
"""
|
|
try:
|
|
# Check if there's already an active consent request for this guild
|
|
request_key = f"guild_{guild_id}"
|
|
if request_key in self.consent_requests:
|
|
time_since_request = (
|
|
datetime.now(timezone.utc) - self.consent_requests[request_key]
|
|
)
|
|
if time_since_request < timedelta(minutes=5):
|
|
await channel.send(
|
|
"⏰ A consent request is already active. Please wait for it to expire.",
|
|
delete_after=10,
|
|
)
|
|
return False
|
|
|
|
# Record this consent request
|
|
self.consent_requests[request_key] = datetime.now(timezone.utc)
|
|
|
|
# Create consent request embed and view
|
|
embed = ConsentTemplates.get_consent_request_embed()
|
|
# Import ConsentView here to avoid circular import
|
|
from ui.components import ConsentView
|
|
|
|
view = ConsentView(self, guild_id)
|
|
|
|
# Add recording target information
|
|
if voice_channel:
|
|
members_text = ", ".join(
|
|
[
|
|
member.display_name
|
|
for member in voice_channel.members
|
|
if not member.bot
|
|
]
|
|
)
|
|
if len(members_text) > 100: # Truncate if too long
|
|
member_count = len([m for m in voice_channel.members if not m.bot])
|
|
members_text = f"{member_count} members"
|
|
|
|
embed.add_field(
|
|
name="🎯 Recording Target",
|
|
value=f"**Channel:** {voice_channel.name}\n**Members:** {members_text}",
|
|
inline=False,
|
|
)
|
|
|
|
# Add requester information
|
|
embed.add_field(
|
|
name="👤 Requested by",
|
|
value=f"{requester.display_name}\n*{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC*",
|
|
inline=False,
|
|
)
|
|
|
|
# Send consent request
|
|
await channel.send(embed=embed, view=view)
|
|
|
|
# Log the consent request
|
|
logger.info(
|
|
f"Consent request sent in guild {guild_id} by user {requester.id}"
|
|
)
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send consent request: {e}")
|
|
return False
|
|
|
|
async def grant_consent(
|
|
self, user_id: int, guild_id: int, first_name: Optional[str] = None
|
|
) -> bool:
|
|
"""
|
|
Grant recording consent for a user in a specific guild
|
|
|
|
Args:
|
|
user_id: Discord user ID
|
|
guild_id: Discord guild ID
|
|
first_name: User's preferred first name (optional)
|
|
|
|
Returns:
|
|
bool: True if consent was granted successfully
|
|
"""
|
|
try:
|
|
# Check if user has globally opted out
|
|
if user_id in self.global_opt_outs:
|
|
logger.warning(
|
|
f"User {user_id} tried to consent but has global opt-out"
|
|
)
|
|
return False
|
|
|
|
# Grant consent in database
|
|
success = await self.db_manager.grant_consent(user_id, guild_id, first_name)
|
|
|
|
if success:
|
|
# Update cache with lock protection
|
|
async with self._cache_lock:
|
|
if user_id not in self.consent_cache:
|
|
self.consent_cache[user_id] = {}
|
|
self.consent_cache[user_id][guild_id] = True
|
|
|
|
logger.info(f"Consent granted for user {user_id} in guild {guild_id}")
|
|
return True
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to grant consent: {e}")
|
|
return False
|
|
|
|
async def revoke_consent(self, user_id: int, guild_id: int) -> bool:
|
|
"""
|
|
Revoke recording consent for a user in a specific guild
|
|
|
|
Args:
|
|
user_id: Discord user ID
|
|
guild_id: Discord guild ID
|
|
|
|
Returns:
|
|
bool: True if consent was revoked successfully
|
|
"""
|
|
try:
|
|
# Revoke consent in database
|
|
success = await self.db_manager.revoke_consent(user_id, guild_id)
|
|
|
|
if success:
|
|
# Update cache with lock protection
|
|
async with self._cache_lock:
|
|
if (
|
|
user_id in self.consent_cache
|
|
and guild_id in self.consent_cache[user_id]
|
|
):
|
|
self.consent_cache[user_id][guild_id] = False
|
|
|
|
logger.info(f"Consent revoked for user {user_id} in guild {guild_id}")
|
|
return True
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to revoke consent: {e}")
|
|
return False
|
|
|
|
async def check_consent(self, user_id: int, guild_id: int) -> bool:
|
|
"""
|
|
Check if a user has given consent for recording in a guild
|
|
|
|
Args:
|
|
user_id: Discord user ID
|
|
guild_id: Discord guild ID
|
|
|
|
Returns:
|
|
bool: True if user has consented and not globally opted out
|
|
"""
|
|
try:
|
|
# Check global opt-out first
|
|
if user_id in self.global_opt_outs:
|
|
return False
|
|
|
|
# Check cache first with lock protection
|
|
async with self._cache_lock:
|
|
if (
|
|
user_id in self.consent_cache
|
|
and guild_id in self.consent_cache[user_id]
|
|
):
|
|
return self.consent_cache[user_id][guild_id]
|
|
|
|
# Fallback to database
|
|
consent_status = await self.db_manager.check_consent(user_id, guild_id)
|
|
|
|
# Update cache with lock protection
|
|
async with self._cache_lock:
|
|
if user_id not in self.consent_cache:
|
|
self.consent_cache[user_id] = {}
|
|
self.consent_cache[user_id][guild_id] = consent_status
|
|
|
|
return consent_status
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to check consent: {e}")
|
|
return False
|
|
|
|
async def set_global_opt_out(self, user_id: int, opt_out: bool = True) -> bool:
|
|
"""
|
|
Set global opt-out status for a user across all guilds
|
|
|
|
Args:
|
|
user_id: Discord user ID
|
|
opt_out: True to opt out, False to opt back in
|
|
|
|
Returns:
|
|
bool: True if operation was successful
|
|
"""
|
|
try:
|
|
# Update database
|
|
success = await self.db_manager.set_global_opt_out(user_id, opt_out)
|
|
|
|
if success:
|
|
# Update cache with lock protection
|
|
async with self._cache_lock:
|
|
if opt_out:
|
|
self.global_opt_outs.add(user_id)
|
|
else:
|
|
self.global_opt_outs.discard(user_id)
|
|
|
|
# Clear consent cache for this user (force reload)
|
|
if user_id in self.consent_cache:
|
|
del self.consent_cache[user_id]
|
|
|
|
action = "opted out globally" if opt_out else "opted back in globally"
|
|
logger.info(f"User {user_id} {action}")
|
|
return True
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to set global opt-out: {e}")
|
|
return False
|
|
|
|
async def get_consented_users(self, guild_id: int) -> List[int]:
|
|
"""
|
|
Get list of users who have consented to recording in a guild
|
|
|
|
Args:
|
|
guild_id: Discord guild ID
|
|
|
|
Returns:
|
|
List[int]: List of user IDs who have consented
|
|
"""
|
|
try:
|
|
# Get from database to ensure accuracy
|
|
consented_users = await self.db_manager.get_consented_users(guild_id)
|
|
|
|
# Filter out globally opted out users
|
|
filtered_users = [
|
|
user_id
|
|
for user_id in consented_users
|
|
if user_id not in self.global_opt_outs
|
|
]
|
|
|
|
return filtered_users
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get consented users: {e}")
|
|
return []
|
|
|
|
async def get_consent_status(
|
|
self, user_id: int, guild_id: int
|
|
) -> ConsentStatusDict:
|
|
"""
|
|
Get detailed consent status for a user
|
|
|
|
Args:
|
|
user_id: Discord user ID
|
|
guild_id: Discord guild ID
|
|
|
|
Returns:
|
|
Dict containing detailed consent information
|
|
"""
|
|
try:
|
|
# Get consent record from database
|
|
result = await self.db_manager.execute_query(
|
|
"""
|
|
SELECT * FROM user_consent
|
|
WHERE user_id = $1 AND guild_id = $2
|
|
""",
|
|
user_id,
|
|
guild_id,
|
|
fetch_one=True,
|
|
)
|
|
|
|
if result:
|
|
return {
|
|
"has_record": True,
|
|
"consent_given": result["consent_given"],
|
|
"global_opt_out": result["global_opt_out"],
|
|
"consent_timestamp": result["consent_timestamp"],
|
|
"first_name": result["first_name"],
|
|
"created_at": result["created_at"],
|
|
"updated_at": result["updated_at"],
|
|
}
|
|
else:
|
|
return {
|
|
"has_record": False,
|
|
"consent_given": False,
|
|
"global_opt_out": user_id in self.global_opt_outs,
|
|
"consent_timestamp": None,
|
|
"first_name": None,
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get consent status: {e}")
|
|
return {
|
|
"has_record": False,
|
|
"consent_given": False,
|
|
"global_opt_out": False,
|
|
}
|
|
|
|
async def cleanup_non_consented_data(self, guild_id: int) -> int:
|
|
"""
|
|
Remove data from users who have revoked consent
|
|
|
|
Args:
|
|
guild_id: Discord guild ID
|
|
|
|
Returns:
|
|
int: Number of records cleaned up
|
|
"""
|
|
try:
|
|
# Get users who have revoked consent
|
|
revoked_users = await self.db_manager.execute_query(
|
|
"""
|
|
SELECT user_id FROM user_consent
|
|
WHERE guild_id = $1 AND (consent_given = FALSE OR global_opt_out = TRUE)
|
|
""",
|
|
guild_id,
|
|
fetch_all=True,
|
|
)
|
|
|
|
if not revoked_users:
|
|
return 0
|
|
|
|
revoked_user_ids = [row["user_id"] for row in revoked_users]
|
|
|
|
# Delete quotes from non-consenting users
|
|
cleanup_count = 0
|
|
for user_id in revoked_user_ids:
|
|
deleted = await self.db_manager.delete_user_quotes(user_id, guild_id)
|
|
cleanup_count += deleted
|
|
|
|
logger.info(
|
|
f"Cleaned up {cleanup_count} records for {len(revoked_user_ids)} non-consenting users"
|
|
)
|
|
return cleanup_count
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to cleanup non-consented data: {e}")
|
|
return 0
|
|
|
|
async def export_user_data(
|
|
self, user_id: int, guild_id: Optional[int] = None
|
|
) -> UserExportDict:
|
|
"""
|
|
Export all data for a user (GDPR compliance)
|
|
|
|
Args:
|
|
user_id: Discord user ID
|
|
guild_id: Optional guild ID to limit export scope
|
|
|
|
Returns:
|
|
Dict containing all user data
|
|
"""
|
|
try:
|
|
export_data = {
|
|
"user_id": user_id,
|
|
"export_timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"consent_records": [],
|
|
"quotes": [],
|
|
"speaker_profile": None,
|
|
"feedback_records": [],
|
|
}
|
|
|
|
# Export consent records
|
|
consent_query = """
|
|
SELECT * FROM user_consent WHERE user_id = $1
|
|
"""
|
|
consent_params = [user_id]
|
|
|
|
if guild_id:
|
|
consent_query += " AND guild_id = $2"
|
|
consent_params.append(guild_id)
|
|
|
|
consent_results = await self.db_manager.execute_query(
|
|
consent_query, *consent_params, fetch_all=True
|
|
)
|
|
|
|
for record in consent_results:
|
|
export_data["consent_records"].append(
|
|
{
|
|
"guild_id": record["guild_id"],
|
|
"consent_given": record["consent_given"],
|
|
"consent_timestamp": (
|
|
record["consent_timestamp"].isoformat()
|
|
if record["consent_timestamp"]
|
|
else None
|
|
),
|
|
"global_opt_out": record["global_opt_out"],
|
|
"first_name": record["first_name"],
|
|
"created_at": record["created_at"].isoformat(),
|
|
"updated_at": record["updated_at"].isoformat(),
|
|
}
|
|
)
|
|
|
|
# Export quotes
|
|
quote_query = """
|
|
SELECT * FROM quotes WHERE user_id = $1
|
|
"""
|
|
quote_params = [user_id]
|
|
|
|
if guild_id:
|
|
quote_query += " AND guild_id = $2"
|
|
quote_params.append(guild_id)
|
|
|
|
quote_results = await self.db_manager.execute_query(
|
|
quote_query, *quote_params, fetch_all=True
|
|
)
|
|
|
|
for quote in quote_results:
|
|
export_data["quotes"].append(
|
|
{
|
|
"id": quote["id"],
|
|
"quote": quote["quote"],
|
|
"timestamp": quote["timestamp"].isoformat(),
|
|
"guild_id": quote["guild_id"],
|
|
"channel_id": quote["channel_id"],
|
|
"funny_score": float(quote["funny_score"]),
|
|
"dark_score": float(quote["dark_score"]),
|
|
"silly_score": float(quote["silly_score"]),
|
|
"suspicious_score": float(quote["suspicious_score"]),
|
|
"asinine_score": float(quote["asinine_score"]),
|
|
"overall_score": float(quote["overall_score"]),
|
|
"response_type": quote["response_type"],
|
|
"user_feedback": quote["user_feedback"],
|
|
"created_at": quote["created_at"].isoformat(),
|
|
}
|
|
)
|
|
|
|
# Export speaker profile
|
|
profile_result = await self.db_manager.execute_query(
|
|
"""
|
|
SELECT * FROM speaker_profiles WHERE user_id = $1
|
|
""",
|
|
user_id,
|
|
fetch_one=True,
|
|
)
|
|
|
|
if profile_result:
|
|
export_data["speaker_profile"] = {
|
|
"enrollment_status": profile_result["enrollment_status"],
|
|
"enrollment_phrase": profile_result["enrollment_phrase"],
|
|
"personality_summary": profile_result["personality_summary"],
|
|
"quote_count": profile_result["quote_count"],
|
|
"avg_humor_score": float(profile_result["avg_humor_score"]),
|
|
"last_seen": (
|
|
profile_result["last_seen"].isoformat()
|
|
if profile_result["last_seen"]
|
|
else None
|
|
),
|
|
"training_samples": profile_result["training_samples"],
|
|
"recognition_accuracy": float(
|
|
profile_result["recognition_accuracy"]
|
|
),
|
|
"created_at": profile_result["created_at"].isoformat(),
|
|
"updated_at": profile_result["updated_at"].isoformat(),
|
|
}
|
|
|
|
# Export feedback records
|
|
feedback_results = await self.db_manager.execute_query(
|
|
"""
|
|
SELECT * FROM quote_feedback WHERE user_id = $1
|
|
""",
|
|
user_id,
|
|
fetch_all=True,
|
|
)
|
|
|
|
for feedback in feedback_results:
|
|
export_data["feedback_records"].append(
|
|
{
|
|
"quote_id": feedback["quote_id"],
|
|
"feedback_type": feedback["feedback_type"],
|
|
"feedback_value": feedback["feedback_value"],
|
|
"timestamp": feedback["timestamp"].isoformat(),
|
|
}
|
|
)
|
|
|
|
logger.info(
|
|
f"Exported data for user {user_id}: {len(export_data['quotes'])} quotes, {len(export_data['consent_records'])} consent records"
|
|
)
|
|
return export_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to export user data: {e}")
|
|
return {"error": str(e)}
|
|
|
|
async def delete_user_data(
|
|
self, user_id: int, guild_id: Optional[int] = None
|
|
) -> Dict[str, int]:
|
|
"""
|
|
Delete all data for a user (GDPR right to erasure)
|
|
|
|
Args:
|
|
user_id: Discord user ID
|
|
guild_id: Optional guild ID to limit deletion scope
|
|
|
|
Returns:
|
|
Dict with counts of deleted records
|
|
"""
|
|
try:
|
|
deletion_counts = {
|
|
"quotes": 0,
|
|
"consent_records": 0,
|
|
"speaker_profile": 0,
|
|
"feedback_records": 0,
|
|
}
|
|
|
|
# Delete quotes
|
|
if guild_id:
|
|
deletion_counts["quotes"] = await self.db_manager.delete_user_quotes(
|
|
user_id, guild_id
|
|
)
|
|
else:
|
|
deletion_counts["quotes"] = await self.db_manager.delete_user_quotes(
|
|
user_id
|
|
)
|
|
|
|
# Delete consent records
|
|
if guild_id:
|
|
result = await self.db_manager.execute_query(
|
|
"""
|
|
DELETE FROM user_consent WHERE user_id = $1 AND guild_id = $2
|
|
""",
|
|
user_id,
|
|
guild_id,
|
|
)
|
|
else:
|
|
result = await self.db_manager.execute_query(
|
|
"""
|
|
DELETE FROM user_consent WHERE user_id = $1
|
|
""",
|
|
user_id,
|
|
)
|
|
|
|
deletion_counts["consent_records"] = (
|
|
int(result.split()[-1]) if result else 0
|
|
)
|
|
|
|
# Delete speaker profile (only if not guild-specific)
|
|
if not guild_id:
|
|
result = await self.db_manager.execute_query(
|
|
"""
|
|
DELETE FROM speaker_profiles WHERE user_id = $1
|
|
""",
|
|
user_id,
|
|
)
|
|
deletion_counts["speaker_profile"] = (
|
|
int(result.split()[-1]) if result else 0
|
|
)
|
|
|
|
# Delete feedback records
|
|
result = await self.db_manager.execute_query(
|
|
"""
|
|
DELETE FROM quote_feedback WHERE user_id = $1
|
|
""",
|
|
user_id,
|
|
)
|
|
deletion_counts["feedback_records"] = (
|
|
int(result.split()[-1]) if result else 0
|
|
)
|
|
|
|
# Update caches
|
|
if user_id in self.consent_cache:
|
|
if guild_id and guild_id in self.consent_cache[user_id]:
|
|
del self.consent_cache[user_id][guild_id]
|
|
elif not guild_id:
|
|
del self.consent_cache[user_id]
|
|
|
|
if not guild_id:
|
|
self.global_opt_outs.discard(user_id)
|
|
|
|
logger.info(f"Deleted user data for {user_id}: {deletion_counts}")
|
|
return deletion_counts
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete user data: {e}")
|
|
return {"error": str(e)}
|
|
|
|
async def get_privacy_dashboard_data(self, guild_id: int) -> PrivacyDashboardDict:
|
|
"""
|
|
Get privacy dashboard data for server administrators
|
|
|
|
Args:
|
|
guild_id: Discord guild ID
|
|
|
|
Returns:
|
|
Dict containing privacy statistics and compliance info
|
|
"""
|
|
try:
|
|
dashboard_data = {
|
|
"guild_id": guild_id,
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"consent_statistics": {},
|
|
"data_retention": {},
|
|
"compliance_status": {},
|
|
}
|
|
|
|
# Consent statistics
|
|
consent_stats = await self.db_manager.execute_query(
|
|
"""
|
|
SELECT
|
|
COUNT(*) as total_users,
|
|
COUNT(CASE WHEN consent_given = TRUE THEN 1 END) as consented_users,
|
|
COUNT(CASE WHEN global_opt_out = TRUE THEN 1 END) as global_opt_outs,
|
|
COUNT(CASE WHEN consent_timestamp > NOW() - INTERVAL '30 days' THEN 1 END) as recent_consents
|
|
FROM user_consent
|
|
WHERE guild_id = $1
|
|
""",
|
|
guild_id,
|
|
fetch_one=True,
|
|
)
|
|
|
|
if consent_stats:
|
|
dashboard_data["consent_statistics"] = {
|
|
"total_users": consent_stats["total_users"],
|
|
"consented_users": consent_stats["consented_users"],
|
|
"global_opt_outs": consent_stats["global_opt_outs"],
|
|
"recent_consents_30d": consent_stats["recent_consents"],
|
|
"consent_rate": consent_stats["consented_users"]
|
|
/ max(consent_stats["total_users"], 1),
|
|
}
|
|
|
|
# Data retention statistics
|
|
retention_stats = await self.db_manager.execute_query(
|
|
"""
|
|
SELECT
|
|
COUNT(*) as total_quotes,
|
|
COUNT(CASE WHEN created_at > NOW() - INTERVAL '7 days' THEN 1 END) as quotes_7d,
|
|
COUNT(CASE WHEN created_at > NOW() - INTERVAL '30 days' THEN 1 END) as quotes_30d,
|
|
MIN(created_at) as oldest_quote,
|
|
MAX(created_at) as newest_quote
|
|
FROM quotes
|
|
WHERE guild_id = $1
|
|
""",
|
|
guild_id,
|
|
fetch_one=True,
|
|
)
|
|
|
|
if retention_stats:
|
|
dashboard_data["data_retention"] = {
|
|
"total_quotes": retention_stats["total_quotes"],
|
|
"quotes_last_7_days": retention_stats["quotes_7d"],
|
|
"quotes_last_30_days": retention_stats["quotes_30d"],
|
|
"oldest_quote": (
|
|
retention_stats["oldest_quote"].isoformat()
|
|
if retention_stats["oldest_quote"]
|
|
else None
|
|
),
|
|
"newest_quote": (
|
|
retention_stats["newest_quote"].isoformat()
|
|
if retention_stats["newest_quote"]
|
|
else None
|
|
),
|
|
}
|
|
|
|
# Compliance status
|
|
dashboard_data["compliance_status"] = {
|
|
"gdpr_compliant": True,
|
|
"auto_cleanup_enabled": True,
|
|
"consent_tracking_active": True,
|
|
"user_rights_supported": [
|
|
"right_to_access",
|
|
"right_to_rectification",
|
|
"right_to_erasure",
|
|
"right_to_portability",
|
|
"right_to_object",
|
|
],
|
|
}
|
|
|
|
return dashboard_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get privacy dashboard data: {e}")
|
|
return {"error": str(e)}
|
|
|
|
async def cleanup(self):
|
|
"""Clean up resources and cancel background tasks"""
|
|
try:
|
|
if self._cleanup_task and not self._cleanup_task.done():
|
|
self._cleanup_task.cancel()
|
|
try:
|
|
await self._cleanup_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
logger.info("Consent manager cleanup completed")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during consent manager cleanup: {e}")
|