remove backend

This commit is contained in:
pacnpal
2025-09-21 20:19:12 -04:00
parent 9e724bd795
commit f3c59ad6ff
557 changed files with 1739 additions and 4836 deletions

View File

@@ -0,0 +1,6 @@
"""
Authentication API endpoints for ThrillWiki v1.
This package contains all authentication and authorization-related
API functionality including login, logout, user management, and permissions.
"""

View File

@@ -0,0 +1,3 @@
# This file is intentionally empty.
# All models are now in their appropriate apps to avoid conflicts.
# PasswordReset model is available in apps.accounts.models

View File

@@ -0,0 +1,608 @@
"""
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:
return obj.profile.get_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 and send verification email."""
validated_data.pop("password_confirm", None)
password = validated_data.pop("password")
# Create inactive user - they need to verify email first
user = UserModel.objects.create_user( # type: ignore[attr-defined]
password=password, is_active=False, **validated_data
)
# Create email verification record and send email
self._send_verification_email(user)
return user
def _send_verification_email(self, user):
"""Send email verification to the user."""
from apps.accounts.models import EmailVerification
from django.utils.crypto import get_random_string
from django_forwardemail.services import EmailService
from django.contrib.sites.shortcuts import get_current_site
import logging
logger = logging.getLogger(__name__)
# Create or update email verification record
verification, created = EmailVerification.objects.get_or_create(
user=user,
defaults={'token': get_random_string(64)}
)
if not created:
# Update existing token and timestamp
verification.token = get_random_string(64)
verification.save()
# Get current site from request context
request = self.context.get('request')
if request:
site = get_current_site(request._request)
# Build verification URL
verification_url = request.build_absolute_uri(
f"/api/v1/auth/verify-email/{verification.token}/"
)
# Send verification email
try:
response = EmailService.send_email(
to=user.email,
subject="Verify your ThrillWiki account",
text=f"""
Welcome to ThrillWiki!
Please verify your email address by clicking the link below:
{verification_url}
If you didn't create an account, you can safely ignore this email.
Thanks,
The ThrillWiki Team
""".strip(),
site=site,
)
# Log the ForwardEmail email ID from the response
email_id = response.get('id') if response else None
if email_id:
logger.info(
f"Verification email sent successfully to {user.email}. ForwardEmail ID: {email_id}")
else:
logger.info(
f"Verification email sent successfully to {user.email}. No email ID in response.")
except Exception as e:
# Log the error but don't fail registration
logger.error(f"Failed to send verification email to {user.email}: {e}")
class SignupOutputSerializer(serializers.Serializer):
"""Output serializer for successful signup."""
access = serializers.CharField(allow_null=True)
refresh = serializers.CharField(allow_null=True)
user = UserOutputSerializer()
message = serializers.CharField()
email_verification_required = serializers.BooleanField(default=False)
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_url()
@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)

View File

@@ -0,0 +1,31 @@
"""
Auth Serializers Package
This package contains social authentication-related serializers.
Main authentication serializers are imported directly from the parent serializers.py file.
"""
from .social import (
ConnectedProviderSerializer,
AvailableProviderSerializer,
SocialAuthStatusSerializer,
ConnectProviderInputSerializer,
ConnectProviderOutputSerializer,
DisconnectProviderOutputSerializer,
SocialProviderListOutputSerializer,
ConnectedProvidersListOutputSerializer,
SocialProviderErrorSerializer,
)
__all__ = [
# Social authentication serializers
'ConnectedProviderSerializer',
'AvailableProviderSerializer',
'SocialAuthStatusSerializer',
'ConnectProviderInputSerializer',
'ConnectProviderOutputSerializer',
'DisconnectProviderOutputSerializer',
'SocialProviderListOutputSerializer',
'ConnectedProvidersListOutputSerializer',
'SocialProviderErrorSerializer',
]

View File

@@ -0,0 +1,198 @@
"""
Social Provider Management Serializers
Serializers for handling social provider connection/disconnection requests
and responses in the ThrillWiki API.
"""
from rest_framework import serializers
from django.contrib.auth import get_user_model
class ConnectedProviderSerializer(serializers.Serializer):
"""Serializer for connected social provider information."""
provider = serializers.CharField(
help_text="Provider ID (e.g., 'google', 'discord')"
)
provider_name = serializers.CharField(
help_text="Human-readable provider name"
)
uid = serializers.CharField(
help_text="User ID on the social provider"
)
date_joined = serializers.DateTimeField(
help_text="When this provider was connected"
)
can_disconnect = serializers.BooleanField(
help_text="Whether this provider can be safely disconnected"
)
disconnect_reason = serializers.CharField(
allow_null=True,
required=False,
help_text="Reason why provider cannot be disconnected (if applicable)"
)
extra_data = serializers.JSONField(
required=False,
help_text="Additional data from the social provider"
)
class AvailableProviderSerializer(serializers.Serializer):
"""Serializer for available social provider information."""
id = serializers.CharField(
help_text="Provider ID (e.g., 'google', 'discord')"
)
name = serializers.CharField(
help_text="Human-readable provider name"
)
auth_url = serializers.URLField(
help_text="URL to initiate authentication with this provider"
)
connect_url = serializers.URLField(
help_text="API URL to connect this provider"
)
class SocialAuthStatusSerializer(serializers.Serializer):
"""Serializer for comprehensive social authentication status."""
user_id = serializers.IntegerField(
help_text="User's ID"
)
username = serializers.CharField(
help_text="User's username"
)
email = serializers.EmailField(
help_text="User's email address"
)
has_password_auth = serializers.BooleanField(
help_text="Whether user has email/password authentication set up"
)
connected_providers = ConnectedProviderSerializer(
many=True,
help_text="List of connected social providers"
)
total_auth_methods = serializers.IntegerField(
help_text="Total number of authentication methods available"
)
can_disconnect_any = serializers.BooleanField(
help_text="Whether user can safely disconnect any provider"
)
requires_password_setup = serializers.BooleanField(
help_text="Whether user needs to set up password before disconnecting"
)
class ConnectProviderInputSerializer(serializers.Serializer):
"""Serializer for social provider connection requests."""
provider = serializers.CharField(
help_text="Provider ID to connect (e.g., 'google', 'discord')"
)
def validate_provider(self, value):
"""Validate that the provider is supported and configured."""
from apps.accounts.services.social_provider_service import SocialProviderService
is_valid, message = SocialProviderService.validate_provider_exists(value)
if not is_valid:
raise serializers.ValidationError(message)
return value
class ConnectProviderOutputSerializer(serializers.Serializer):
"""Serializer for social provider connection responses."""
success = serializers.BooleanField(
help_text="Whether the connection was successful"
)
message = serializers.CharField(
help_text="Success or error message"
)
provider = serializers.CharField(
help_text="Provider that was connected"
)
auth_url = serializers.URLField(
required=False,
help_text="URL to complete the connection process"
)
class DisconnectProviderOutputSerializer(serializers.Serializer):
"""Serializer for social provider disconnection responses."""
success = serializers.BooleanField(
help_text="Whether the disconnection was successful"
)
message = serializers.CharField(
help_text="Success or error message"
)
provider = serializers.CharField(
help_text="Provider that was disconnected"
)
remaining_providers = serializers.ListField(
child=serializers.CharField(),
help_text="List of remaining connected providers"
)
has_password_auth = serializers.BooleanField(
help_text="Whether user still has password authentication"
)
suggestions = serializers.ListField(
child=serializers.CharField(),
required=False,
help_text="Suggestions for maintaining account access (if applicable)"
)
class SocialProviderListOutputSerializer(serializers.Serializer):
"""Serializer for listing available social providers."""
available_providers = AvailableProviderSerializer(
many=True,
help_text="List of available social providers"
)
count = serializers.IntegerField(
help_text="Number of available providers"
)
class ConnectedProvidersListOutputSerializer(serializers.Serializer):
"""Serializer for listing connected social providers."""
connected_providers = ConnectedProviderSerializer(
many=True,
help_text="List of connected social providers"
)
count = serializers.IntegerField(
help_text="Number of connected providers"
)
has_password_auth = serializers.BooleanField(
help_text="Whether user has password authentication"
)
can_disconnect_any = serializers.BooleanField(
help_text="Whether user can safely disconnect any provider"
)
class SocialProviderErrorSerializer(serializers.Serializer):
"""Serializer for social provider error responses."""
error = serializers.CharField(
help_text="Error message"
)
code = serializers.CharField(
required=False,
help_text="Error code for programmatic handling"
)
suggestions = serializers.ListField(
child=serializers.CharField(),
required=False,
help_text="Suggestions for resolving the error"
)
provider = serializers.CharField(
required=False,
help_text="Provider related to the error (if applicable)"
)

65
apps/api/v1/auth/urls.py Normal file
View File

@@ -0,0 +1,65 @@
"""
Auth domain URL Configuration for ThrillWiki API v1.
This module contains URL patterns for core authentication functionality only.
User profiles and top lists are handled by the dedicated accounts app.
"""
from django.urls import path, include
from .views import (
# Main auth views
LoginAPIView,
SignupAPIView,
LogoutAPIView,
CurrentUserAPIView,
PasswordResetAPIView,
PasswordChangeAPIView,
AuthStatusAPIView,
# Email verification views
EmailVerificationAPIView,
ResendVerificationAPIView,
)
from rest_framework_simplejwt.views import TokenRefreshView
urlpatterns = [
# Core authentication endpoints
path("login/", LoginAPIView.as_view(), name="auth-login"),
path("signup/", SignupAPIView.as_view(), name="auth-signup"),
path("logout/", LogoutAPIView.as_view(), name="auth-logout"),
path("user/", CurrentUserAPIView.as_view(), name="auth-current-user"),
# JWT token management
path("token/refresh/", TokenRefreshView.as_view(), name="auth-token-refresh"),
# Social authentication endpoints (dj-rest-auth)
path("social/", include("dj_rest_auth.registration.urls")),
path(
"password/reset/",
PasswordResetAPIView.as_view(),
name="auth-password-reset",
),
path(
"password/change/",
PasswordChangeAPIView.as_view(),
name="auth-password-change",
),
path("status/", AuthStatusAPIView.as_view(), name="auth-status"),
# Email verification endpoints
path(
"verify-email/<str:token>/",
EmailVerificationAPIView.as_view(),
name="auth-verify-email",
),
path(
"resend-verification/",
ResendVerificationAPIView.as_view(),
name="auth-resend-verification",
),
]
# Note: User profiles and top lists functionality is now handled by the accounts app
# to maintain clean separation of concerns and avoid duplicate API endpoints.

589
apps/api/v1/auth/views.py Normal file
View File

@@ -0,0 +1,589 @@
"""
Auth domain views for ThrillWiki API v1.
This module contains all authentication-related API endpoints including
login, signup, logout, password management, social authentication,
user profiles, and top lists.
"""
from django.contrib.auth import authenticate, login, logout, get_user_model
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError
from django.db.models import Q
from typing import Optional, cast # added 'cast'
from django.http import HttpRequest # new import
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from drf_spectacular.utils import extend_schema, extend_schema_view
# Import directly from the auth serializers.py file (not the serializers package)
from .serializers import (
# Authentication serializers
LoginInputSerializer,
LoginOutputSerializer,
SignupInputSerializer,
SignupOutputSerializer,
LogoutOutputSerializer,
UserOutputSerializer,
PasswordResetInputSerializer,
PasswordResetOutputSerializer,
PasswordChangeInputSerializer,
PasswordChangeOutputSerializer,
AuthStatusOutputSerializer,
)
# Handle optional dependencies with fallback classes
class FallbackTurnstileMixin:
"""Fallback mixin if TurnstileMixin is not available."""
def validate_turnstile(self, request):
pass
# Try to import the real class, use fallback if not available and ensure it's a class/type
try:
from apps.accounts.mixins import TurnstileMixin as _ImportedTurnstileMixin
# Ensure the imported object is a class/type that can be used as a base class.
# If it's not a type for any reason, fall back to the safe mixin.
if isinstance(_ImportedTurnstileMixin, type):
TurnstileMixin = _ImportedTurnstileMixin
else:
TurnstileMixin = FallbackTurnstileMixin
except Exception:
# Catch any import errors or unexpected exceptions and use the fallback mixin.
TurnstileMixin = FallbackTurnstileMixin
UserModel = get_user_model()
# Helper: safely obtain underlying HttpRequest (used by Django auth)
def _get_underlying_request(request: Request) -> HttpRequest:
"""
Return a django HttpRequest for use with Django auth and site utilities.
DRF's Request wraps the underlying HttpRequest in ._request; cast() tells the
typechecker that the returned object is indeed an HttpRequest.
"""
return cast(HttpRequest, getattr(request, "_request", request))
# Helper: encapsulate user lookup + authenticate to reduce complexity in view
def _authenticate_user_by_lookup(
email_or_username: str, password: str, request: Request
) -> Optional[UserModel]:
"""
Try a single optimized query to find a user by email OR username then authenticate.
Returns authenticated user or None.
"""
try:
# Single query to find user by email OR username
if "@" in (email_or_username or ""):
user_obj = (
UserModel.objects.select_related()
.filter(Q(email=email_or_username) | Q(username=email_or_username))
.first()
)
else:
user_obj = (
UserModel.objects.select_related()
.filter(Q(username=email_or_username) | Q(email=email_or_username))
.first()
)
if user_obj:
username_val = getattr(user_obj, "username", None)
return authenticate(
# type: ignore[arg-type]
_get_underlying_request(request),
username=username_val,
password=password,
)
except Exception:
# Fallback to authenticate directly with provided identifier
return authenticate(
# type: ignore[arg-type]
_get_underlying_request(request),
username=email_or_username,
password=password,
)
return None
# === AUTHENTICATION API VIEWS ===
@extend_schema_view(
post=extend_schema(
summary="User login",
description="Authenticate user with username/email and password.",
request=LoginInputSerializer,
responses={
200: LoginOutputSerializer,
400: "Bad Request",
},
tags=["Authentication"],
),
)
class LoginAPIView(APIView):
"""API endpoint for user login."""
permission_classes = [AllowAny]
authentication_classes = []
serializer_class = LoginInputSerializer
def post(self, request: Request) -> Response:
try:
# instantiate mixin before calling to avoid type-mismatch in static analysis
TurnstileMixin().validate_turnstile(request)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except Exception:
# If mixin doesn't do anything, continue
pass
serializer = LoginInputSerializer(data=request.data)
if serializer.is_valid():
validated = serializer.validated_data
# Use .get to satisfy static analyzers
email_or_username = validated.get("username") # type: ignore[assignment]
password = validated.get("password") # type: ignore[assignment]
if not email_or_username or not password:
return Response(
{"error": "username and password are required"},
status=status.HTTP_400_BAD_REQUEST,
)
user = _authenticate_user_by_lookup(email_or_username, password, request)
if user:
if getattr(user, "is_active", False):
# pass a real HttpRequest to Django login with backend specified
login(_get_underlying_request(request), user,
backend='django.contrib.auth.backends.ModelBackend')
# Generate JWT tokens
from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(user)
access_token = refresh.access_token
response_serializer = LoginOutputSerializer(
{
"access": str(access_token),
"refresh": str(refresh),
"user": user,
"message": "Login successful",
}
)
return Response(response_serializer.data)
else:
return Response(
{
"error": "Email verification required",
"message": "Please verify your email address before logging in. Check your email for a verification link.",
"email_verification_required": True
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{"error": "Invalid credentials"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
post=extend_schema(
summary="User registration",
description="Register a new user account. Email verification required.",
request=SignupInputSerializer,
responses={
201: SignupOutputSerializer,
400: "Bad Request",
},
tags=["Authentication"],
),
)
class SignupAPIView(APIView):
"""API endpoint for user registration."""
permission_classes = [AllowAny]
authentication_classes = []
serializer_class = SignupInputSerializer
def post(self, request: Request) -> Response:
try:
# instantiate mixin before calling to avoid type-mismatch in static analysis
TurnstileMixin().validate_turnstile(request)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except Exception:
# If mixin doesn't do anything, continue
pass
serializer = SignupInputSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
user = serializer.save()
# Don't log in the user immediately - they need to verify their email first
response_serializer = SignupOutputSerializer(
{
"access": None,
"refresh": None,
"user": user,
"message": "Registration successful. Please check your email to verify your account.",
"email_verification_required": True,
}
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
post=extend_schema(
summary="User logout",
description="Logout the current user and blacklist their refresh token.",
responses={
200: LogoutOutputSerializer,
401: "Unauthorized",
},
tags=["Authentication"],
),
)
class LogoutAPIView(APIView):
"""API endpoint for user logout."""
permission_classes = [IsAuthenticated]
serializer_class = LogoutOutputSerializer
def post(self, request: Request) -> Response:
try:
# Get refresh token from request data with proper type handling
refresh_token = None
if hasattr(request, 'data') and request.data is not None:
data = getattr(request, 'data', {})
if hasattr(data, 'get'):
refresh_token = data.get("refresh")
if refresh_token and isinstance(refresh_token, str):
# Blacklist the refresh token
from rest_framework_simplejwt.tokens import RefreshToken
try:
# Create RefreshToken from string and blacklist it
refresh_token_obj = RefreshToken(
refresh_token) # type: ignore[arg-type]
refresh_token_obj.blacklist()
except Exception:
# Token might be invalid or already blacklisted
pass
# Also delete the old token for backward compatibility
if hasattr(request.user, "auth_token"):
request.user.auth_token.delete()
# Logout from session using the underlying HttpRequest
logout(_get_underlying_request(request))
response_serializer = LogoutOutputSerializer(
{"message": "Logout successful"}
)
return Response(response_serializer.data)
except Exception:
return Response(
{"error": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@extend_schema_view(
get=extend_schema(
summary="Get current user",
description="Retrieve information about the currently authenticated user.",
responses={
200: UserOutputSerializer,
401: "Unauthorized",
},
tags=["Authentication"],
),
)
class CurrentUserAPIView(APIView):
"""API endpoint to get current user information."""
permission_classes = [IsAuthenticated]
serializer_class = UserOutputSerializer
def get(self, request: Request) -> Response:
serializer = UserOutputSerializer(request.user)
return Response(serializer.data)
@extend_schema_view(
post=extend_schema(
summary="Request password reset",
description="Send a password reset email to the user.",
request=PasswordResetInputSerializer,
responses={
200: PasswordResetOutputSerializer,
400: "Bad Request",
},
tags=["Authentication"],
),
)
class PasswordResetAPIView(APIView):
"""API endpoint to request password reset."""
permission_classes = [AllowAny]
serializer_class = PasswordResetInputSerializer
def post(self, request: Request) -> Response:
serializer = PasswordResetInputSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
serializer.save()
response_serializer = PasswordResetOutputSerializer(
{"detail": "Password reset email sent"}
)
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
post=extend_schema(
summary="Change password",
description="Change the current user's password.",
request=PasswordChangeInputSerializer,
responses={
200: PasswordChangeOutputSerializer,
400: "Bad Request",
401: "Unauthorized",
},
tags=["Authentication"],
),
)
class PasswordChangeAPIView(APIView):
"""API endpoint to change password."""
permission_classes = [IsAuthenticated]
serializer_class = PasswordChangeInputSerializer
def post(self, request: Request) -> Response:
serializer = PasswordChangeInputSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
serializer.save()
response_serializer = PasswordChangeOutputSerializer(
{"detail": "Password changed successfully"}
)
return Response(response_serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view(
post=extend_schema(
summary="Check authentication status",
description="Check if user is authenticated and return user data.",
responses={200: AuthStatusOutputSerializer},
tags=["Authentication"],
),
)
class AuthStatusAPIView(APIView):
"""API endpoint to check authentication status."""
permission_classes = [AllowAny]
serializer_class = AuthStatusOutputSerializer
def post(self, request: Request) -> Response:
if request.user.is_authenticated:
response_data = {
"authenticated": True,
"user": request.user,
}
else:
response_data = {
"authenticated": False,
"user": None,
}
serializer = AuthStatusOutputSerializer(response_data)
return Response(serializer.data)
# === EMAIL VERIFICATION API VIEWS ===
@extend_schema_view(
get=extend_schema(
summary="Verify email address",
description="Verify user's email address using verification token.",
responses={
200: {"type": "object", "properties": {"message": {"type": "string"}}},
400: "Bad Request",
404: "Token not found",
},
tags=["Authentication"],
),
)
class EmailVerificationAPIView(APIView):
"""API endpoint for email verification."""
permission_classes = [AllowAny]
authentication_classes = []
def get(self, request: Request, token: str) -> Response:
from apps.accounts.models import EmailVerification
try:
verification = EmailVerification.objects.select_related('user').get(token=token)
user = verification.user
# Activate the user
user.is_active = True
user.save()
# Delete the verification record
verification.delete()
return Response({
"message": "Email verified successfully. You can now log in.",
"success": True
})
except EmailVerification.DoesNotExist:
return Response(
{"error": "Invalid or expired verification token"},
status=status.HTTP_404_NOT_FOUND
)
@extend_schema_view(
post=extend_schema(
summary="Resend verification email",
description="Resend email verification to user's email address.",
request={"type": "object", "properties": {"email": {"type": "string", "format": "email"}}},
responses={
200: {"type": "object", "properties": {"message": {"type": "string"}}},
400: "Bad Request",
404: "User not found",
},
tags=["Authentication"],
),
)
class ResendVerificationAPIView(APIView):
"""API endpoint to resend email verification."""
permission_classes = [AllowAny]
authentication_classes = []
def post(self, request: Request) -> Response:
from apps.accounts.models import EmailVerification
from django.utils.crypto import get_random_string
from django_forwardemail.services import EmailService
from django.contrib.sites.shortcuts import get_current_site
email = request.data.get('email')
if not email:
return Response(
{"error": "Email address is required"},
status=status.HTTP_400_BAD_REQUEST
)
try:
user = UserModel.objects.get(email__iexact=email.strip().lower())
# Don't resend if user is already active
if user.is_active:
return Response(
{"error": "Email is already verified"},
status=status.HTTP_400_BAD_REQUEST
)
# Create or update verification record
verification, created = EmailVerification.objects.get_or_create(
user=user,
defaults={'token': get_random_string(64)}
)
if not created:
# Update existing token and timestamp
verification.token = get_random_string(64)
verification.save()
# Send verification email
site = get_current_site(_get_underlying_request(request))
verification_url = request.build_absolute_uri(
f"/api/v1/auth/verify-email/{verification.token}/"
)
try:
EmailService.send_email(
to=user.email,
subject="Verify your ThrillWiki account",
text=f"""
Welcome to ThrillWiki!
Please verify your email address by clicking the link below:
{verification_url}
If you didn't create an account, you can safely ignore this email.
Thanks,
The ThrillWiki Team
""".strip(),
site=site,
)
return Response({
"message": "Verification email sent successfully",
"success": True
})
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to send verification email to {user.email}: {e}")
return Response(
{"error": "Failed to send verification email"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except UserModel.DoesNotExist:
# Don't reveal whether email exists
return Response({
"message": "If the email exists, a verification email has been sent",
"success": True
})
# Note: User Profile, Top List, and Top List Item ViewSets are now handled
# by the dedicated accounts app at backend/apps/api/v1/accounts/views.py
# to avoid duplication and maintain clean separation of concerns.