feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -8,4 +8,4 @@ including social provider management, user authentication, and profile services.
from .social_provider_service import SocialProviderService
from .user_deletion_service import UserDeletionService
__all__ = ['SocialProviderService', 'UserDeletionService']
__all__ = ["SocialProviderService", "UserDeletionService"]

View File

@@ -139,7 +139,9 @@ class NotificationService:
UserNotification: The created notification
"""
title = f"Your {submission_type} needs attention"
message = f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
message = (
f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
)
message += f"\n\nReason: {rejection_reason}"
if additional_message:
@@ -216,9 +218,7 @@ class NotificationService:
preferences = NotificationPreference.objects.create(user=user)
# Send email notification if enabled
if preferences.should_send_notification(
notification.notification_type, "email"
):
if preferences.should_send_notification(notification.notification_type, "email"):
NotificationService._send_email_notification(notification)
# Toast notifications are always created (the notification object itself)
@@ -261,14 +261,10 @@ class NotificationService:
notification.email_sent_at = timezone.now()
notification.save(update_fields=["email_sent", "email_sent_at"])
logger.info(
f"Email notification sent to {user.email} for notification {notification.id}"
)
logger.info(f"Email notification sent to {user.email} for notification {notification.id}")
except Exception as e:
logger.error(
f"Failed to send email notification {notification.id}: {str(e)}"
)
logger.error(f"Failed to send email notification {notification.id}: {str(e)}")
@staticmethod
def get_user_notifications(
@@ -298,9 +294,7 @@ class NotificationService:
queryset = queryset.filter(notification_type__in=notification_types)
# Exclude expired notifications
queryset = queryset.filter(
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
)
queryset = queryset.filter(models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now()))
if limit:
queryset = queryset[:limit]
@@ -308,9 +302,7 @@ class NotificationService:
return list(queryset)
@staticmethod
def mark_notifications_read(
user: User, notification_ids: list[int] | None = None
) -> int:
def mark_notifications_read(user: User, notification_ids: list[int] | None = None) -> int:
"""
Mark notifications as read for a user.
@@ -341,9 +333,7 @@ class NotificationService:
"""
cutoff_date = timezone.now() - timedelta(days=days)
old_notifications = UserNotification.objects.filter(
is_read=True, read_at__lt=cutoff_date
)
old_notifications = UserNotification.objects.filter(is_read=True, read_at__lt=cutoff_date)
count = old_notifications.count()
old_notifications.delete()

View File

@@ -40,23 +40,20 @@ class SocialProviderService:
"""
try:
# Count remaining social accounts after disconnection
remaining_social_accounts = user.socialaccount_set.exclude(
provider=provider
).count()
remaining_social_accounts = user.socialaccount_set.exclude(provider=provider).count()
# Check if user has email/password auth
has_password_auth = (
user.email and
user.has_usable_password() and
bool(user.password) # Not empty/unusable
)
has_password_auth = user.email and user.has_usable_password() and bool(user.password) # Not empty/unusable
# Allow disconnection only if alternative auth exists
can_disconnect = remaining_social_accounts > 0 or has_password_auth
if not can_disconnect:
if remaining_social_accounts == 0 and not has_password_auth:
return False, "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first."
return (
False,
"Cannot disconnect your only authentication method. Please set up a password or connect another social provider first.",
)
elif not has_password_auth:
return False, "Please set up email/password authentication before disconnecting this provider."
else:
@@ -65,8 +62,7 @@ class SocialProviderService:
return True, "Provider can be safely disconnected."
except Exception as e:
logger.error(
f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}")
logger.error(f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}")
return False, "Unable to verify disconnection safety. Please try again."
@staticmethod
@@ -84,18 +80,16 @@ class SocialProviderService:
connected_providers = []
for social_account in user.socialaccount_set.all():
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
user, social_account.provider
)
can_disconnect, reason = SocialProviderService.can_disconnect_provider(user, social_account.provider)
provider_info = {
'provider': social_account.provider,
'provider_name': social_account.get_provider().name,
'uid': social_account.uid,
'date_joined': social_account.date_joined,
'can_disconnect': can_disconnect,
'disconnect_reason': reason if not can_disconnect else None,
'extra_data': social_account.extra_data
"provider": social_account.provider,
"provider_name": social_account.get_provider().name,
"uid": social_account.uid,
"date_joined": social_account.date_joined,
"can_disconnect": can_disconnect,
"disconnect_reason": reason if not can_disconnect else None,
"extra_data": social_account.extra_data,
}
connected_providers.append(provider_info)
@@ -122,28 +116,25 @@ class SocialProviderService:
available_providers = []
# Get all social apps configured for this site
social_apps = SocialApp.objects.filter(sites=site).order_by('provider')
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
for social_app in social_apps:
try:
provider = registry.by_id(social_app.provider)
provider_info = {
'id': social_app.provider,
'name': provider.name,
'auth_url': request.build_absolute_uri(
f'/accounts/{social_app.provider}/login/'
"id": social_app.provider,
"name": provider.name,
"auth_url": request.build_absolute_uri(f"/accounts/{social_app.provider}/login/"),
"connect_url": request.build_absolute_uri(
f"/api/v1/auth/social/connect/{social_app.provider}/"
),
'connect_url': request.build_absolute_uri(
f'/api/v1/auth/social/connect/{social_app.provider}/'
)
}
available_providers.append(provider_info)
except Exception as e:
logger.warning(
f"Error processing provider {social_app.provider}: {e}")
logger.warning(f"Error processing provider {social_app.provider}: {e}")
continue
return available_providers
@@ -166,8 +157,7 @@ class SocialProviderService:
"""
try:
# First check if disconnection is allowed
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
user, provider)
can_disconnect, reason = SocialProviderService.can_disconnect_provider(user, provider)
if not can_disconnect:
return False, reason
@@ -182,8 +172,7 @@ class SocialProviderService:
deleted_count = social_accounts.count()
social_accounts.delete()
logger.info(
f"User {user.id} disconnected {deleted_count} {provider} account(s)")
logger.info(f"User {user.id} disconnected {deleted_count} {provider} account(s)")
return True, f"{provider.title()} account disconnected successfully."
@@ -205,31 +194,24 @@ class SocialProviderService:
try:
connected_providers = SocialProviderService.get_connected_providers(user)
has_password_auth = (
user.email and
user.has_usable_password() and
bool(user.password)
)
has_password_auth = user.email and user.has_usable_password() and bool(user.password)
auth_methods_count = len(connected_providers) + \
(1 if has_password_auth else 0)
auth_methods_count = len(connected_providers) + (1 if has_password_auth else 0)
return {
'user_id': user.id,
'username': user.username,
'email': user.email,
'has_password_auth': has_password_auth,
'connected_providers': connected_providers,
'total_auth_methods': auth_methods_count,
'can_disconnect_any': auth_methods_count > 1,
'requires_password_setup': not has_password_auth and len(connected_providers) == 1
"user_id": user.id,
"username": user.username,
"email": user.email,
"has_password_auth": has_password_auth,
"connected_providers": connected_providers,
"total_auth_methods": auth_methods_count,
"can_disconnect_any": auth_methods_count > 1,
"requires_password_setup": not has_password_auth and len(connected_providers) == 1,
}
except Exception as e:
logger.error(f"Error getting auth status for user {user.id}: {e}")
return {
'error': 'Unable to retrieve authentication status'
}
return {"error": "Unable to retrieve authentication status"}
@staticmethod
def validate_provider_exists(provider: str) -> tuple[bool, str]:

View File

@@ -59,7 +59,7 @@ class UserDeletionService:
return False, "Cannot delete staff accounts"
# Check for system users (if you have any special system accounts)
if hasattr(user, 'role') and user.role in ['ADMIN', 'MODERATOR']:
if hasattr(user, "role") and user.role in ["ADMIN", "MODERATOR"]:
return False, "Cannot delete admin or moderator accounts"
return True, None
@@ -84,8 +84,7 @@ class UserDeletionService:
raise ValueError(reason)
# Generate verification code
verification_code = ''.join(secrets.choice(
string.ascii_uppercase + string.digits) for _ in range(8))
verification_code = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
# Set expiration (24 hours from now)
expires_at = timezone.now() + timezone.timedelta(hours=24)
@@ -97,8 +96,7 @@ class UserDeletionService:
UserDeletionService._deletion_requests[verification_code] = deletion_request
# Send verification email
UserDeletionService._send_deletion_verification_email(
user, verification_code, expires_at)
UserDeletionService._send_deletion_verification_email(user, verification_code, expires_at)
return deletion_request
@@ -136,10 +134,10 @@ class UserDeletionService:
del UserDeletionService._deletion_requests[verification_code]
# Add verification info to result
result['deletion_request'] = {
'verification_code': verification_code,
'created_at': deletion_request.created_at,
'verified_at': timezone.now(),
result["deletion_request"] = {
"verification_code": verification_code,
"created_at": deletion_request.created_at,
"verified_at": timezone.now(),
}
return result
@@ -180,13 +178,13 @@ class UserDeletionService:
"""
# Get or create the "deleted_user" placeholder
deleted_user_placeholder, created = User.objects.get_or_create(
username='deleted_user',
username="deleted_user",
defaults={
'email': 'deleted@thrillwiki.com',
'first_name': 'Deleted',
'last_name': 'User',
'is_active': False,
}
"email": "deleted@thrillwiki.com",
"first_name": "Deleted",
"last_name": "User",
"is_active": False,
},
)
# Count submissions before transfer
@@ -197,22 +195,22 @@ class UserDeletionService:
# Store user info before deletion
deleted_user_info = {
'username': user.username,
'user_id': getattr(user, 'user_id', user.id),
'email': user.email,
'date_joined': user.date_joined,
"username": user.username,
"user_id": getattr(user, "user_id", user.id),
"email": user.email,
"date_joined": user.date_joined,
}
# Delete the user account
user.delete()
return {
'deleted_user': deleted_user_info,
'preserved_submissions': submission_counts,
'transferred_to': {
'username': deleted_user_placeholder.username,
'user_id': getattr(deleted_user_placeholder, 'user_id', deleted_user_placeholder.id),
}
"deleted_user": deleted_user_info,
"preserved_submissions": submission_counts,
"transferred_to": {
"username": deleted_user_placeholder.username,
"user_id": getattr(deleted_user_placeholder, "user_id", deleted_user_placeholder.id),
},
}
@staticmethod
@@ -222,20 +220,13 @@ class UserDeletionService:
# Count different types of submissions
# Note: These are placeholder counts - adjust based on your actual models
counts['park_reviews'] = getattr(
user, 'park_reviews', user.__class__.objects.none()).count()
counts['ride_reviews'] = getattr(
user, 'ride_reviews', user.__class__.objects.none()).count()
counts['uploaded_park_photos'] = getattr(
user, 'uploaded_park_photos', user.__class__.objects.none()).count()
counts['uploaded_ride_photos'] = getattr(
user, 'uploaded_ride_photos', user.__class__.objects.none()).count()
counts['top_lists'] = getattr(
user, 'top_lists', user.__class__.objects.none()).count()
counts['edit_submissions'] = getattr(
user, 'edit_submissions', user.__class__.objects.none()).count()
counts['photo_submissions'] = getattr(
user, 'photo_submissions', user.__class__.objects.none()).count()
counts["park_reviews"] = getattr(user, "park_reviews", user.__class__.objects.none()).count()
counts["ride_reviews"] = getattr(user, "ride_reviews", user.__class__.objects.none()).count()
counts["uploaded_park_photos"] = getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count()
counts["uploaded_ride_photos"] = getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count()
counts["top_lists"] = getattr(user, "top_lists", user.__class__.objects.none()).count()
counts["edit_submissions"] = getattr(user, "edit_submissions", user.__class__.objects.none()).count()
counts["photo_submissions"] = getattr(user, "photo_submissions", user.__class__.objects.none()).count()
return counts
@@ -247,30 +238,30 @@ class UserDeletionService:
# Note: Adjust these based on your actual model relationships
# Park reviews
if hasattr(user, 'park_reviews'):
if hasattr(user, "park_reviews"):
user.park_reviews.all().update(user=placeholder_user)
# Ride reviews
if hasattr(user, 'ride_reviews'):
if hasattr(user, "ride_reviews"):
user.ride_reviews.all().update(user=placeholder_user)
# Uploaded photos
if hasattr(user, 'uploaded_park_photos'):
if hasattr(user, "uploaded_park_photos"):
user.uploaded_park_photos.all().update(user=placeholder_user)
if hasattr(user, 'uploaded_ride_photos'):
if hasattr(user, "uploaded_ride_photos"):
user.uploaded_ride_photos.all().update(user=placeholder_user)
# Top lists
if hasattr(user, 'top_lists'):
if hasattr(user, "top_lists"):
user.top_lists.all().update(user=placeholder_user)
# Edit submissions
if hasattr(user, 'edit_submissions'):
if hasattr(user, "edit_submissions"):
user.edit_submissions.all().update(user=placeholder_user)
# Photo submissions
if hasattr(user, 'photo_submissions'):
if hasattr(user, "photo_submissions"):
user.photo_submissions.all().update(user=placeholder_user)
@staticmethod
@@ -278,18 +269,16 @@ class UserDeletionService:
"""Send verification email for account deletion."""
try:
context = {
'user': user,
'verification_code': verification_code,
'expires_at': expires_at,
'site_name': 'ThrillWiki',
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
"user": user,
"verification_code": verification_code,
"expires_at": expires_at,
"site_name": "ThrillWiki",
"site_url": getattr(settings, "SITE_URL", "https://thrillwiki.com"),
}
subject = 'ThrillWiki: Confirm Account Deletion'
html_message = render_to_string(
'emails/account_deletion_verification.html', context)
plain_message = render_to_string(
'emails/account_deletion_verification.txt', context)
subject = "ThrillWiki: Confirm Account Deletion"
html_message = render_to_string("emails/account_deletion_verification.html", context)
plain_message = render_to_string("emails/account_deletion_verification.txt", context)
send_mail(
subject=subject,
@@ -303,6 +292,5 @@ class UserDeletionService:
logger.info(f"Deletion verification email sent to {user.email}")
except Exception as e:
logger.error(
f"Failed to send deletion verification email to {user.email}: {str(e)}")
logger.error(f"Failed to send deletion verification email to {user.email}: {str(e)}")
raise