""" Auth domain serializers for ThrillWiki API v1. This module contains all serializers related to authentication, user accounts, profiles, top lists, and user statistics. """ from typing import Any, Dict from rest_framework import serializers from drf_spectacular.utils import ( extend_schema_serializer, extend_schema_field, OpenApiExample, ) from django.contrib.auth.password_validation import validate_password from django.utils.crypto import get_random_string from django.contrib.auth import get_user_model from django.utils import timezone from datetime import timedelta from apps.accounts.models import PasswordReset UserModel = get_user_model() def _normalize_email(value: str) -> str: """Normalize email for consistent lookups (strip + lowercase).""" if value is None: return value return value.strip().lower() # Import shared utilities class ModelChoices: """Model choices utility class.""" @staticmethod def get_top_list_categories(): """Get top list category choices.""" return [ ("RC", "Roller Coasters"), ("DR", "Dark Rides"), ("FR", "Flat Rides"), ("WR", "Water Rides"), ("PK", "Parks"), ] # === AUTHENTICATION SERIALIZERS === @extend_schema_serializer( examples=[ OpenApiExample( "User Example", summary="Example user response", description="A typical user object", value={ "id": 1, "username": "john_doe", "email": "john@example.com", "display_name": "John Doe", "date_joined": "2024-01-01T12:00:00Z", "is_active": True, "avatar_url": "https://example.com/avatars/john.jpg", }, ) ] ) class UserOutputSerializer(serializers.ModelSerializer): """User serializer for API responses.""" avatar_url = serializers.SerializerMethodField() display_name = serializers.SerializerMethodField() class Meta: model = UserModel fields = [ "id", "username", "email", "display_name", "date_joined", "is_active", "avatar_url", ] read_only_fields = ["id", "date_joined", "is_active"] def get_display_name(self, obj): """Get the user's display name.""" return obj.get_display_name() @extend_schema_field(serializers.URLField(allow_null=True)) def get_avatar_url(self, obj) -> str | None: """Get user avatar URL.""" if hasattr(obj, "profile") and obj.profile.avatar: return obj.profile.avatar.url return None class LoginInputSerializer(serializers.Serializer): """Input serializer for user login.""" username = serializers.CharField( max_length=254, help_text="Username or email address" ) password = serializers.CharField( max_length=128, style={"input_type": "password"}, trim_whitespace=False ) def validate(self, attrs): username = attrs.get("username") password = attrs.get("password") if username and password: return attrs raise serializers.ValidationError("Must include username/email and password.") class LoginOutputSerializer(serializers.Serializer): """Output serializer for successful login.""" access = serializers.CharField() refresh = serializers.CharField() user = UserOutputSerializer() message = serializers.CharField() class SignupInputSerializer(serializers.ModelSerializer): """Input serializer for user registration.""" password = serializers.CharField( write_only=True, validators=[validate_password], style={"input_type": "password"}, ) password_confirm = serializers.CharField( write_only=True, style={"input_type": "password"} ) class Meta: model = UserModel fields = [ "username", "email", "display_name", "password", "password_confirm", ] extra_kwargs = { "password": {"write_only": True}, "email": {"required": True}, "display_name": {"required": True}, } def validate_email(self, value): """Validate email is unique (case-insensitive) and return normalized email.""" normalized = _normalize_email(value) if UserModel.objects.filter(email__iexact=normalized).exists(): raise serializers.ValidationError("A user with this email already exists.") return normalized def validate_username(self, value): """Validate username is unique.""" if UserModel.objects.filter(username=value).exists(): raise serializers.ValidationError( "A user with this username already exists." ) return value def validate(self, attrs): """Validate passwords match.""" password = attrs.get("password") password_confirm = attrs.get("password_confirm") if password != password_confirm: raise serializers.ValidationError( {"password_confirm": "Passwords do not match."} ) return attrs def create(self, validated_data): """Create user with validated data.""" validated_data.pop("password_confirm", None) password = validated_data.pop("password") # Use type: ignore for Django's create_user method which isn't properly typed user = UserModel.objects.create_user( # type: ignore[attr-defined] password=password, **validated_data ) return user class SignupOutputSerializer(serializers.Serializer): """Output serializer for successful signup.""" access = serializers.CharField() refresh = serializers.CharField() user = UserOutputSerializer() message = serializers.CharField() class PasswordResetInputSerializer(serializers.Serializer): """Input serializer for password reset request.""" email = serializers.EmailField() def validate_email(self, value): """Normalize email and attach user to the serializer when found (case-insensitive). Returns the normalized email. Does not reveal whether the email exists. """ normalized = _normalize_email(value) try: user = UserModel.objects.get(email__iexact=normalized) self.user = user except UserModel.DoesNotExist: # Do not reveal whether the email exists; keep behavior unchanged. pass return normalized def save(self, **kwargs): """Send password reset email if user exists.""" if hasattr(self, "user"): # generate a secure random token and persist it with expiry now = timezone.now() expires = now + timedelta(hours=24) # token valid for 24 hours # Persist password reset with generated token (avoid creating an unused local variable). PasswordReset.objects.create( user=self.user, token=get_random_string(64), expires_at=expires, ) # Optionally: enqueue/send an email with the token-based reset link here. # Keep token out of API responses to avoid leaking it. class PasswordResetOutputSerializer(serializers.Serializer): """Output serializer for password reset request.""" detail = serializers.CharField() class PasswordChangeInputSerializer(serializers.Serializer): """Input serializer for password change.""" old_password = serializers.CharField( max_length=128, style={"input_type": "password"} ) new_password = serializers.CharField( max_length=128, validators=[validate_password], style={"input_type": "password"}, ) new_password_confirm = serializers.CharField( max_length=128, style={"input_type": "password"} ) def validate_old_password(self, value): """Validate old password is correct.""" user = self.context["request"].user if not user.check_password(value): raise serializers.ValidationError("Old password is incorrect.") return value def validate(self, attrs): """Validate new passwords match.""" new_password = attrs.get("new_password") new_password_confirm = attrs.get("new_password_confirm") if new_password != new_password_confirm: raise serializers.ValidationError( {"new_password_confirm": "New passwords do not match."} ) return attrs def save(self, **kwargs): """Change user password.""" user = self.context["request"].user # validated_data is guaranteed to exist after is_valid() is called new_password = self.validated_data["new_password"] # type: ignore[index] user.set_password(new_password) user.save() return user class PasswordChangeOutputSerializer(serializers.Serializer): """Output serializer for password change.""" detail = serializers.CharField() class LogoutOutputSerializer(serializers.Serializer): """Output serializer for logout.""" message = serializers.CharField() class SocialProviderOutputSerializer(serializers.Serializer): """Output serializer for social authentication providers.""" id = serializers.CharField() name = serializers.CharField() authUrl = serializers.URLField() class AuthStatusOutputSerializer(serializers.Serializer): """Output serializer for authentication status check.""" authenticated = serializers.BooleanField() user = UserOutputSerializer(allow_null=True) # === USER PROFILE SERIALIZERS === @extend_schema_serializer( examples=[ OpenApiExample( "User Profile Example", summary="Example user profile response", description="A user's profile information", value={ "id": 1, "profile_id": "1234", "display_name": "Coaster Enthusiast", "bio": "Love visiting theme parks around the world!", "pronouns": "they/them", "avatar_url": "/media/avatars/user1.jpg", "coaster_credits": 150, "dark_ride_credits": 45, "flat_ride_credits": 80, "water_ride_credits": 25, "user": { "username": "coaster_fan", "date_joined": "2024-01-01T00:00:00Z", }, }, ) ] ) class UserProfileOutputSerializer(serializers.Serializer): """Output serializer for user profiles.""" id = serializers.IntegerField() profile_id = serializers.CharField() display_name = serializers.CharField() bio = serializers.CharField() pronouns = serializers.CharField() avatar_url = serializers.SerializerMethodField() twitter = serializers.URLField() instagram = serializers.URLField() youtube = serializers.URLField() discord = serializers.CharField() # Ride statistics coaster_credits = serializers.IntegerField() dark_ride_credits = serializers.IntegerField() flat_ride_credits = serializers.IntegerField() water_ride_credits = serializers.IntegerField() # User info (limited) user = serializers.SerializerMethodField() @extend_schema_field(serializers.URLField(allow_null=True)) def get_avatar_url(self, obj) -> str | None: return obj.get_avatar() @extend_schema_field(serializers.DictField()) def get_user(self, obj) -> Dict[str, Any]: return { "username": obj.user.username, "date_joined": obj.user.date_joined, } class UserProfileCreateInputSerializer(serializers.Serializer): """Input serializer for creating user profiles.""" display_name = serializers.CharField(max_length=50) bio = serializers.CharField(max_length=500, allow_blank=True, default="") pronouns = serializers.CharField(max_length=50, allow_blank=True, default="") twitter = serializers.URLField(required=False, allow_blank=True) instagram = serializers.URLField(required=False, allow_blank=True) youtube = serializers.URLField(required=False, allow_blank=True) discord = serializers.CharField(max_length=100, allow_blank=True, default="") class UserProfileUpdateInputSerializer(serializers.Serializer): """Input serializer for updating user profiles.""" display_name = serializers.CharField(max_length=50, required=False) bio = serializers.CharField(max_length=500, allow_blank=True, required=False) pronouns = serializers.CharField(max_length=50, allow_blank=True, required=False) twitter = serializers.URLField(required=False, allow_blank=True) instagram = serializers.URLField(required=False, allow_blank=True) youtube = serializers.URLField(required=False, allow_blank=True) discord = serializers.CharField(max_length=100, allow_blank=True, required=False) coaster_credits = serializers.IntegerField(required=False) dark_ride_credits = serializers.IntegerField(required=False) flat_ride_credits = serializers.IntegerField(required=False) water_ride_credits = serializers.IntegerField(required=False) # === TOP LIST SERIALIZERS === @extend_schema_serializer( examples=[ OpenApiExample( "Top List Example", summary="Example top list response", description="A user's top list of rides or parks", value={ "id": 1, "title": "My Top 10 Roller Coasters", "category": "RC", "description": "My favorite roller coasters ranked", "user": {"username": "coaster_fan", "display_name": "Coaster Fan"}, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-08-15T12:00:00Z", }, ) ] ) class TopListOutputSerializer(serializers.Serializer): """Output serializer for top lists.""" id = serializers.IntegerField() title = serializers.CharField() category = serializers.CharField() description = serializers.CharField() created_at = serializers.DateTimeField() updated_at = serializers.DateTimeField() # User info user = serializers.SerializerMethodField() @extend_schema_field(serializers.DictField()) def get_user(self, obj) -> Dict[str, Any]: return { "username": obj.user.username, "display_name": obj.user.get_display_name(), } class TopListCreateInputSerializer(serializers.Serializer): """Input serializer for creating top lists.""" title = serializers.CharField(max_length=100) category = serializers.ChoiceField(choices=ModelChoices.get_top_list_categories()) description = serializers.CharField(allow_blank=True, default="") class TopListUpdateInputSerializer(serializers.Serializer): """Input serializer for updating top lists.""" title = serializers.CharField(max_length=100, required=False) category = serializers.ChoiceField( choices=ModelChoices.get_top_list_categories(), required=False ) description = serializers.CharField(allow_blank=True, required=False) # === TOP LIST ITEM SERIALIZERS === @extend_schema_serializer( examples=[ OpenApiExample( "Top List Item Example", summary="Example top list item response", description="An item in a user's top list", value={ "id": 1, "rank": 1, "notes": "Amazing airtime and smooth ride", "object_name": "Steel Vengeance", "object_type": "Ride", "top_list": {"id": 1, "title": "My Top 10 Roller Coasters"}, }, ) ] ) class TopListItemOutputSerializer(serializers.Serializer): """Output serializer for top list items.""" id = serializers.IntegerField() rank = serializers.IntegerField() notes = serializers.CharField() object_name = serializers.SerializerMethodField() object_type = serializers.SerializerMethodField() # Top list info top_list = serializers.SerializerMethodField() @extend_schema_field(serializers.CharField()) def get_object_name(self, obj) -> str: """Get the name of the referenced object.""" # This would need to be implemented based on the generic foreign key return "Object Name" # Placeholder @extend_schema_field(serializers.CharField()) def get_object_type(self, obj) -> str: """Get the type of the referenced object.""" return obj.content_type.model_class().__name__ @extend_schema_field(serializers.DictField()) def get_top_list(self, obj) -> Dict[str, Any]: return { "id": obj.top_list.id, "title": obj.top_list.title, } class TopListItemCreateInputSerializer(serializers.Serializer): """Input serializer for creating top list items.""" top_list_id = serializers.IntegerField() content_type_id = serializers.IntegerField() object_id = serializers.IntegerField() rank = serializers.IntegerField(min_value=1) notes = serializers.CharField(allow_blank=True, default="") class TopListItemUpdateInputSerializer(serializers.Serializer): """Input serializer for updating top list items.""" rank = serializers.IntegerField(min_value=1, required=False) notes = serializers.CharField(allow_blank=True, required=False)