from django.dispatch import receiver from django.db.models.signals import post_save from django.contrib.auth.models import AbstractUser from django.contrib.contenttypes.fields import GenericForeignKey from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ import secrets from datetime import timedelta from django.utils import timezone from apps.core.history import TrackedModel from apps.core.choices import RichChoiceField import pghistory def generate_random_id(model_class, id_field): """Generate a random ID starting at 4 digits, expanding to 5 if needed""" while True: # Try to get a 4-digit number first new_id = str(secrets.SystemRandom().randint(1000, 9999)) if not model_class.objects.filter(**{id_field: new_id}).exists(): return new_id # If all 4-digit numbers are taken, try 5 digits new_id = str(secrets.SystemRandom().randint(10000, 99999)) if not model_class.objects.filter(**{id_field: new_id}).exists(): return new_id @pghistory.track() class User(AbstractUser): # Override inherited fields to remove them first_name = None last_name = None # Read-only ID user_id = models.CharField( max_length=10, unique=True, editable=False, help_text=( "Unique identifier for this user that remains constant even if the " "username changes" ), ) role = RichChoiceField( choice_group="user_roles", domain="accounts", max_length=10, default="USER", db_index=True, help_text="User role (user, moderator, admin)", ) is_banned = models.BooleanField( default=False, db_index=True, help_text="Whether this user is banned" ) ban_reason = models.TextField(blank=True, help_text="Reason for ban") ban_date = models.DateTimeField( null=True, blank=True, help_text="Date the user was banned" ) pending_email = models.EmailField(blank=True, null=True) theme_preference = RichChoiceField( choice_group="theme_preferences", domain="accounts", max_length=5, default="light", help_text="User's theme preference (light/dark)", ) # Notification preferences email_notifications = models.BooleanField( default=True, help_text="Whether to send email notifications" ) push_notifications = models.BooleanField( default=False, help_text="Whether to send push notifications" ) # Privacy settings privacy_level = RichChoiceField( choice_group="privacy_levels", domain="accounts", max_length=10, default="public", help_text="Overall privacy level", ) show_email = models.BooleanField( default=False, help_text="Whether to show email on profile" ) show_real_name = models.BooleanField( default=True, help_text="Whether to show real name on profile" ) show_join_date = models.BooleanField( default=True, help_text="Whether to show join date on profile" ) show_statistics = models.BooleanField( default=True, help_text="Whether to show statistics on profile" ) show_reviews = models.BooleanField( default=True, help_text="Whether to show reviews on profile" ) show_photos = models.BooleanField( default=True, help_text="Whether to show photos on profile" ) show_top_lists = models.BooleanField( default=True, help_text="Whether to show top lists on profile" ) allow_friend_requests = models.BooleanField( default=True, help_text="Whether to allow friend requests" ) allow_messages = models.BooleanField( default=True, help_text="Whether to allow direct messages" ) allow_profile_comments = models.BooleanField( default=False, help_text="Whether to allow profile comments" ) search_visibility = models.BooleanField( default=True, help_text="Whether profile appears in search results" ) activity_visibility = RichChoiceField( choice_group="privacy_levels", domain="accounts", max_length=10, default="friends", help_text="Who can see user activity", ) # Security settings two_factor_enabled = models.BooleanField( default=False, help_text="Whether two-factor authentication is enabled" ) login_notifications = models.BooleanField( default=True, help_text="Whether to send login notifications" ) session_timeout = models.IntegerField( default=30, help_text="Session timeout in days" ) login_history_retention = models.IntegerField( default=90, help_text="How long to retain login history (days)" ) last_password_change = models.DateTimeField( auto_now_add=True, help_text="When the password was last changed" ) # Display name - core user data for better performance display_name = models.CharField( max_length=50, blank=True, help_text="Display name shown throughout the site. Falls back to username if not set.", ) # Detailed notification preferences (JSON field for flexibility) notification_preferences = models.JSONField( default=dict, blank=True, help_text="Detailed notification preferences stored as JSON", ) def __str__(self): return self.get_display_name() def get_absolute_url(self): return reverse("profile", kwargs={"username": self.username}) def get_display_name(self): """Get the user's display name, falling back to username if not set""" if self.display_name: return self.display_name # Fallback to profile display_name for backward compatibility profile = getattr(self, "profile", None) if profile and profile.display_name: return profile.display_name return self.username class Meta: verbose_name = "User" verbose_name_plural = "Users" indexes = [ models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'), ] constraints = [ models.CheckConstraint( name='user_ban_consistency', check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False), violation_error_message='Banned users must have a ban_date set' ), ] def save(self, *args, **kwargs): if not self.user_id: self.user_id = generate_random_id(User, "user_id") super().save(*args, **kwargs) @pghistory.track() class UserProfile(models.Model): # Read-only ID profile_id = models.CharField( max_length=10, unique=True, editable=False, help_text="Unique identifier for this profile that remains constant", ) user = models.OneToOneField( User, on_delete=models.CASCADE, related_name="profile", help_text="User this profile belongs to", ) display_name = models.CharField( max_length=50, blank=True, help_text="Legacy display name field - use User.display_name instead", ) avatar = models.ForeignKey( 'django_cloudflareimages_toolkit.CloudflareImage', on_delete=models.SET_NULL, null=True, blank=True, help_text="User's avatar image", ) pronouns = models.CharField( max_length=50, blank=True, help_text="User's preferred pronouns" ) bio = models.TextField(max_length=500, blank=True, help_text="User biography") # Social media links twitter = models.URLField(blank=True, help_text="Twitter profile URL") instagram = models.URLField(blank=True, help_text="Instagram profile URL") youtube = models.URLField(blank=True, help_text="YouTube channel URL") discord = models.CharField(max_length=100, blank=True, help_text="Discord username") # Ride statistics coaster_credits = models.IntegerField( default=0, help_text="Number of roller coasters ridden" ) dark_ride_credits = models.IntegerField( default=0, help_text="Number of dark rides ridden" ) flat_ride_credits = models.IntegerField( default=0, help_text="Number of flat rides ridden" ) water_ride_credits = models.IntegerField( default=0, help_text="Number of water rides ridden" ) def get_avatar_url(self): """ Return the avatar URL or generate a default letter-based avatar URL """ if self.avatar and self.avatar.is_uploaded: # Try to get avatar variant first, fallback to public avatar_url = self.avatar.get_url('avatar') if avatar_url: return avatar_url # Fallback to public variant public_url = self.avatar.get_url('public') if public_url: return public_url # Last fallback - try any available variant if self.avatar.variants: if isinstance(self.avatar.variants, list) and self.avatar.variants: return self.avatar.variants[0] elif isinstance(self.avatar.variants, dict): # Return first available variant for variant_url in self.avatar.variants.values(): if variant_url: return variant_url # Generate default letter-based avatar using first letter of username first_letter = self.user.username[0].upper() if self.user.username else "U" # Use a service like UI Avatars or generate a simple colored avatar return f"https://ui-avatars.com/api/?name={first_letter}&size=200&background=random&color=fff&bold=true" def get_avatar_variants(self): """ Return avatar variants for different use cases """ if self.avatar and self.avatar.is_uploaded: variants = {} # Try to get specific variants thumbnail_url = self.avatar.get_url('thumbnail') avatar_url = self.avatar.get_url('avatar') large_url = self.avatar.get_url('large') public_url = self.avatar.get_url('public') # Use specific variants if available, otherwise fallback to public or first available fallback_url = public_url if not fallback_url and self.avatar.variants: if isinstance(self.avatar.variants, list) and self.avatar.variants: fallback_url = self.avatar.variants[0] elif isinstance(self.avatar.variants, dict): fallback_url = next(iter(self.avatar.variants.values()), None) variants = { "thumbnail": thumbnail_url or fallback_url, "avatar": avatar_url or fallback_url, "large": large_url or fallback_url, } # Only return variants if we have at least one valid URL if any(variants.values()): return variants # For default avatars, return the same URL for all variants default_url = self.get_avatar_url() return { "thumbnail": default_url, "avatar": default_url, "large": default_url, } def save(self, *args, **kwargs): # If no display name is set, use the username if not self.display_name: self.display_name = self.user.username if not self.profile_id: self.profile_id = generate_random_id(UserProfile, "profile_id") super().save(*args, **kwargs) def __str__(self): return self.display_name class Meta: verbose_name = "User Profile" verbose_name_plural = "User Profiles" ordering = ["user"] @pghistory.track() class EmailVerification(models.Model): user = models.OneToOneField( User, on_delete=models.CASCADE, help_text="User this verification belongs to", ) token = models.CharField( max_length=64, unique=True, help_text="Verification token" ) created_at = models.DateTimeField( auto_now_add=True, help_text="When this verification was created" ) last_sent = models.DateTimeField( auto_now_add=True, help_text="When the verification email was last sent" ) def __str__(self): return f"Email verification for {self.user.username}" class Meta: verbose_name = "Email Verification" verbose_name_plural = "Email Verifications" @pghistory.track() class PasswordReset(models.Model): user = models.ForeignKey( User, on_delete=models.CASCADE, help_text="User requesting password reset", ) token = models.CharField(max_length=64, help_text="Reset token") created_at = models.DateTimeField( auto_now_add=True, help_text="When this reset was requested" ) expires_at = models.DateTimeField(help_text="When this reset token expires") used = models.BooleanField(default=False, help_text="Whether this token has been used") def __str__(self): return f"Password reset for {self.user.username}" class Meta: verbose_name = "Password Reset" verbose_name_plural = "Password Resets" # @pghistory.track() class TopList(TrackedModel): user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="top_lists", help_text="User who created this list", ) title = models.CharField(max_length=100, help_text="Title of the top list") category = RichChoiceField( choice_group="top_list_categories", domain="accounts", max_length=2, help_text="Category of items in this list", ) description = models.TextField(blank=True, help_text="Description of the list") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta(TrackedModel.Meta): verbose_name = "Top List" verbose_name_plural = "Top Lists" ordering = ["-updated_at"] def __str__(self): return ( f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}" ) # @pghistory.track() class TopListItem(TrackedModel): top_list = models.ForeignKey( TopList, on_delete=models.CASCADE, related_name="items", help_text="Top list this item belongs to", ) content_type = models.ForeignKey( "contenttypes.ContentType", on_delete=models.CASCADE, help_text="Type of item (park, ride, etc.)", ) object_id = models.PositiveIntegerField(help_text="ID of the item") rank = models.PositiveIntegerField(help_text="Position in the list") notes = models.TextField(blank=True, help_text="User's notes about this item") class Meta(TrackedModel.Meta): verbose_name = "Top List Item" verbose_name_plural = "Top List Items" ordering = ["rank"] unique_together = [["top_list", "rank"]] def __str__(self): return f"#{self.rank} in {self.top_list.title}" @pghistory.track() class UserDeletionRequest(models.Model): """ Model to track user deletion requests with email verification. When a user requests to delete their account, a verification code is sent to their email. The deletion is only processed when they provide the correct code. """ user = models.OneToOneField( User, on_delete=models.CASCADE, related_name="deletion_request" ) verification_code = models.CharField( max_length=32, unique=True, help_text="Unique verification code sent to user's email", ) created_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField(help_text="When this deletion request expires") email_sent_at = models.DateTimeField( null=True, blank=True, help_text="When the verification email was sent" ) attempts = models.PositiveIntegerField( default=0, help_text="Number of verification attempts made" ) max_attempts = models.PositiveIntegerField( default=5, help_text="Maximum number of verification attempts allowed" ) is_used = models.BooleanField( default=False, help_text="Whether this deletion request has been used" ) class Meta: verbose_name = "User Deletion Request" verbose_name_plural = "User Deletion Requests" ordering = ["-created_at"] indexes = [ models.Index(fields=["verification_code"]), models.Index(fields=["expires_at"]), models.Index(fields=["user", "is_used"]), ] def __str__(self): return f"Deletion request for {self.user.username} - {self.verification_code}" def save(self, *args, **kwargs): if not self.verification_code: self.verification_code = self.generate_verification_code() if not self.expires_at: # Deletion requests expire after 24 hours self.expires_at = timezone.now() + timedelta(hours=24) super().save(*args, **kwargs) @staticmethod def generate_verification_code(): """Generate a unique 8-character verification code.""" while True: # Generate a random 8-character alphanumeric code code = "".join( secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8) ) # Ensure it's unique if not UserDeletionRequest.objects.filter(verification_code=code).exists(): return code def is_expired(self): """Check if this deletion request has expired.""" return timezone.now() > self.expires_at def is_valid(self): """Check if this deletion request is still valid.""" return ( not self.is_used and not self.is_expired() and self.attempts < self.max_attempts ) def increment_attempts(self): """Increment the number of verification attempts.""" self.attempts += 1 self.save(update_fields=["attempts"]) def mark_as_used(self): """Mark this deletion request as used.""" self.is_used = True self.save(update_fields=["is_used"]) @classmethod def cleanup_expired(cls): """Remove expired deletion requests.""" expired_requests = cls.objects.filter( expires_at__lt=timezone.now(), is_used=False ) count = expired_requests.count() expired_requests.delete() return count @pghistory.track() class UserNotification(TrackedModel): """ Model to store user notifications for various events. This includes submission approvals, rejections, system announcements, and other user-relevant notifications. """ # Core fields user = models.ForeignKey( User, on_delete=models.CASCADE, related_name="notifications", help_text="User this notification is for", ) notification_type = RichChoiceField( choice_group="notification_types", domain="accounts", max_length=30, ) title = models.CharField(max_length=200, help_text="Notification title") message = models.TextField(help_text="Notification message") # Optional related object (submission, review, etc.) content_type = models.ForeignKey( "contenttypes.ContentType", on_delete=models.CASCADE, null=True, blank=True, help_text="Type of related object", ) object_id = models.PositiveIntegerField( null=True, blank=True, help_text="ID of related object" ) related_object = GenericForeignKey("content_type", "object_id") # Metadata priority = RichChoiceField( choice_group="notification_priorities", domain="accounts", max_length=10, default="normal", ) # Status tracking is_read = models.BooleanField( default=False, help_text="Whether this notification has been read" ) read_at = models.DateTimeField( null=True, blank=True, help_text="When this notification was read" ) # Delivery tracking email_sent = models.BooleanField(default=False, help_text="Whether email was sent") email_sent_at = models.DateTimeField( null=True, blank=True, help_text="When email was sent" ) push_sent = models.BooleanField( default=False, help_text="Whether push notification was sent" ) push_sent_at = models.DateTimeField( null=True, blank=True, help_text="When push notification was sent" ) # Additional data (JSON field for flexibility) extra_data = models.JSONField(default=dict, blank=True) # Timestamps created_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField(null=True, blank=True) class Meta(TrackedModel.Meta): verbose_name = "User Notification" verbose_name_plural = "User Notifications" ordering = ["-created_at"] indexes = [ models.Index(fields=["user", "is_read"]), models.Index(fields=["user", "notification_type"]), models.Index(fields=["created_at"]), models.Index(fields=["expires_at"]), ] def __str__(self): return f"{self.user.username}: {self.title}" def mark_as_read(self): """Mark notification as read.""" if not self.is_read: self.is_read = True self.read_at = timezone.now() self.save(update_fields=["is_read", "read_at"]) def is_expired(self): """Check if notification has expired.""" if not self.expires_at: return False return timezone.now() > self.expires_at @classmethod def cleanup_expired(cls): """Remove expired notifications.""" expired_notifications = cls.objects.filter(expires_at__lt=timezone.now()) count = expired_notifications.count() expired_notifications.delete() return count @classmethod def mark_all_read_for_user(cls, user): """Mark all notifications as read for a specific user.""" return cls.objects.filter(user=user, is_read=False).update( is_read=True, read_at=timezone.now() ) @pghistory.track() class NotificationPreference(TrackedModel): """ User preferences for different types of notifications. This allows users to control which notifications they receive and through which channels (email, push, in-app). """ user = models.OneToOneField( User, on_delete=models.CASCADE, related_name="notification_preference", help_text="User these preferences belong to", ) # Submission notifications submission_approved_email = models.BooleanField(default=True) submission_approved_push = models.BooleanField(default=True) submission_approved_inapp = models.BooleanField(default=True) submission_rejected_email = models.BooleanField(default=True) submission_rejected_push = models.BooleanField(default=True) submission_rejected_inapp = models.BooleanField(default=True) submission_pending_email = models.BooleanField(default=False) submission_pending_push = models.BooleanField(default=False) submission_pending_inapp = models.BooleanField(default=True) # Review notifications review_reply_email = models.BooleanField(default=True) review_reply_push = models.BooleanField(default=True) review_reply_inapp = models.BooleanField(default=True) review_helpful_email = models.BooleanField(default=False) review_helpful_push = models.BooleanField(default=True) review_helpful_inapp = models.BooleanField(default=True) # Social notifications friend_request_email = models.BooleanField(default=True) friend_request_push = models.BooleanField(default=True) friend_request_inapp = models.BooleanField(default=True) friend_accepted_email = models.BooleanField(default=False) friend_accepted_push = models.BooleanField(default=True) friend_accepted_inapp = models.BooleanField(default=True) message_received_email = models.BooleanField(default=True) message_received_push = models.BooleanField(default=True) message_received_inapp = models.BooleanField(default=True) # System notifications system_announcement_email = models.BooleanField(default=True) system_announcement_push = models.BooleanField(default=False) system_announcement_inapp = models.BooleanField(default=True) account_security_email = models.BooleanField(default=True) account_security_push = models.BooleanField(default=True) account_security_inapp = models.BooleanField(default=True) feature_update_email = models.BooleanField(default=True) feature_update_push = models.BooleanField(default=False) feature_update_inapp = models.BooleanField(default=True) # Achievement notifications achievement_unlocked_email = models.BooleanField(default=False) achievement_unlocked_push = models.BooleanField(default=True) achievement_unlocked_inapp = models.BooleanField(default=True) milestone_reached_email = models.BooleanField(default=False) milestone_reached_push = models.BooleanField(default=True) milestone_reached_inapp = models.BooleanField(default=True) class Meta(TrackedModel.Meta): verbose_name = "Notification Preference" verbose_name_plural = "Notification Preferences" def __str__(self): return f"Notification preferences for {self.user.username}" def should_send_notification(self, notification_type, channel): """ Check if a notification should be sent for a specific type and channel. Args: notification_type: The type of notification (from UserNotification.NotificationType) channel: The delivery channel ('email', 'push', 'inapp') Returns: bool: True if notification should be sent, False otherwise """ field_name = f"{notification_type}_{channel}" return getattr(self, field_name, False) # Signal handlers for automatic notification preference creation @receiver(post_save, sender=User) def create_notification_preference(sender, instance, created, **kwargs): """Create notification preferences when a new user is created.""" if created: NotificationPreference.objects.create(user=instance)