mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 02:11:08 -05:00
- Implemented PrimeProgress component with support for labels, helper text, and various styles (size, variant, color). - Created PrimeSelect component with dropdown functionality, custom templates, and validation states. - Developed PrimeSkeleton component for loading placeholders with different shapes and animations. - Updated index.ts to export new components for easy import. - Enhanced PrimeVueTest.vue to include tests for new components and their functionalities. - Introduced a custom ThrillWiki theme for PrimeVue with tailored color schemes and component styles. - Added ambient type declarations for various components to improve TypeScript support.
537 lines
17 KiB
Python
537 lines
17 KiB
Python
"""
|
|
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 .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",
|
|
"first_name": "John",
|
|
"last_name": "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()
|
|
|
|
class Meta:
|
|
model = UserModel
|
|
fields = [
|
|
"id",
|
|
"username",
|
|
"email",
|
|
"first_name",
|
|
"last_name",
|
|
"date_joined",
|
|
"is_active",
|
|
"avatar_url",
|
|
]
|
|
read_only_fields = ["id", "date_joined", "is_active"]
|
|
|
|
@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."""
|
|
|
|
token = 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",
|
|
"first_name",
|
|
"last_name",
|
|
"password",
|
|
"password_confirm",
|
|
]
|
|
extra_kwargs = {
|
|
"password": {"write_only": True},
|
|
"email": {"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."""
|
|
|
|
token = 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)
|