mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:51:09 -05:00
update
This commit is contained in:
6
apps/api/v1/auth/__init__.py
Normal file
6
apps/api/v1/auth/__init__.py
Normal 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.
|
||||
"""
|
||||
3
apps/api/v1/auth/models.py
Normal file
3
apps/api/v1/auth/models.py
Normal 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
|
||||
608
apps/api/v1/auth/serializers.py
Normal file
608
apps/api/v1/auth/serializers.py
Normal 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)
|
||||
31
apps/api/v1/auth/serializers_package/__init__.py
Normal file
31
apps/api/v1/auth/serializers_package/__init__.py
Normal 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',
|
||||
]
|
||||
200
apps/api/v1/auth/serializers_package/social.py
Normal file
200
apps/api/v1/auth/serializers_package/social.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
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
|
||||
|
||||
User = 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)"
|
||||
)
|
||||
104
apps/api/v1/auth/urls.py
Normal file
104
apps/api/v1/auth/urls.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
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,
|
||||
SocialProvidersAPIView,
|
||||
AuthStatusAPIView,
|
||||
# Email verification views
|
||||
EmailVerificationAPIView,
|
||||
ResendVerificationAPIView,
|
||||
# Social provider management views
|
||||
AvailableProvidersAPIView,
|
||||
ConnectedProvidersAPIView,
|
||||
ConnectProviderAPIView,
|
||||
DisconnectProviderAPIView,
|
||||
SocialAuthStatusAPIView,
|
||||
)
|
||||
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(
|
||||
"social/providers/",
|
||||
SocialProvidersAPIView.as_view(),
|
||||
name="auth-social-providers",
|
||||
),
|
||||
|
||||
# Social provider management endpoints
|
||||
path(
|
||||
"social/providers/available/",
|
||||
AvailableProvidersAPIView.as_view(),
|
||||
name="auth-social-providers-available",
|
||||
),
|
||||
path(
|
||||
"social/connected/",
|
||||
ConnectedProvidersAPIView.as_view(),
|
||||
name="auth-social-connected",
|
||||
),
|
||||
path(
|
||||
"social/connect/<str:provider>/",
|
||||
ConnectProviderAPIView.as_view(),
|
||||
name="auth-social-connect",
|
||||
),
|
||||
path(
|
||||
"social/disconnect/<str:provider>/",
|
||||
DisconnectProviderAPIView.as_view(),
|
||||
name="auth-social-disconnect",
|
||||
),
|
||||
path(
|
||||
"social/status/",
|
||||
SocialAuthStatusAPIView.as_view(),
|
||||
name="auth-social-status",
|
||||
),
|
||||
|
||||
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.
|
||||
883
apps/api/v1/auth/views.py
Normal file
883
apps/api/v1/auth/views.py
Normal file
@@ -0,0 +1,883 @@
|
||||
"""
|
||||
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 .serializers_package.social import (
|
||||
ConnectedProviderSerializer,
|
||||
AvailableProviderSerializer,
|
||||
SocialAuthStatusSerializer,
|
||||
ConnectProviderInputSerializer,
|
||||
ConnectProviderOutputSerializer,
|
||||
DisconnectProviderOutputSerializer,
|
||||
SocialProviderErrorSerializer,
|
||||
)
|
||||
from apps.accounts.services.social_provider_service import SocialProviderService
|
||||
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,
|
||||
SocialProviderOutputSerializer,
|
||||
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(
|
||||
get=extend_schema(
|
||||
summary="Get social providers",
|
||||
description="Retrieve available social authentication providers.",
|
||||
responses={200: "List of social providers"},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class SocialProvidersAPIView(APIView):
|
||||
"""API endpoint to get available social authentication providers."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = SocialProviderOutputSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
from django.core.cache import cache
|
||||
|
||||
# get_current_site expects a django HttpRequest; _get_underlying_request now returns HttpRequest
|
||||
site = get_current_site(_get_underlying_request(request))
|
||||
|
||||
# Cache key based on site and request host - use getattr to avoid attribute errors
|
||||
site_id = getattr(site, "id", getattr(site, "pk", None))
|
||||
cache_key = f"social_providers:{site_id}:{request.get_host()}"
|
||||
|
||||
# Try to get from cache first (cache for 15 minutes)
|
||||
cached_providers = cache.get(cache_key)
|
||||
if cached_providers is not None:
|
||||
return Response(cached_providers)
|
||||
|
||||
providers_list = []
|
||||
|
||||
# Optimized query: filter by site and order by provider name
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
|
||||
|
||||
for social_app in social_apps:
|
||||
try:
|
||||
provider_name = (
|
||||
social_app.name or getattr(social_app, "provider", "").title()
|
||||
)
|
||||
|
||||
auth_url = request.build_absolute_uri(
|
||||
f"/accounts/{social_app.provider}/login/"
|
||||
)
|
||||
|
||||
providers_list.append(
|
||||
{
|
||||
"id": social_app.provider,
|
||||
"name": provider_name,
|
||||
"authUrl": auth_url,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
serializer = SocialProviderOutputSerializer(providers_list, many=True)
|
||||
response_data = serializer.data
|
||||
|
||||
cache.set(cache_key, response_data, 900)
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
# === SOCIAL PROVIDER MANAGEMENT API VIEWS ===
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get available social providers",
|
||||
description="Retrieve list of available social authentication providers.",
|
||||
responses={
|
||||
200: AvailableProviderSerializer(many=True),
|
||||
},
|
||||
tags=["Social Authentication"],
|
||||
),
|
||||
)
|
||||
class AvailableProvidersAPIView(APIView):
|
||||
"""API endpoint to get available social providers."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = AvailableProviderSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
providers = [
|
||||
{
|
||||
"provider": "google",
|
||||
"name": "Google",
|
||||
"login_url": "/auth/social/google/",
|
||||
"connect_url": "/auth/social/connect/google/",
|
||||
},
|
||||
{
|
||||
"provider": "discord",
|
||||
"name": "Discord",
|
||||
"login_url": "/auth/social/discord/",
|
||||
"connect_url": "/auth/social/connect/discord/",
|
||||
}
|
||||
]
|
||||
|
||||
serializer = AvailableProviderSerializer(providers, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get connected social providers",
|
||||
description="Retrieve list of social providers connected to the user's account.",
|
||||
responses={
|
||||
200: ConnectedProviderSerializer(many=True),
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Social Authentication"],
|
||||
),
|
||||
)
|
||||
class ConnectedProvidersAPIView(APIView):
|
||||
"""API endpoint to get user's connected social providers."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = ConnectedProviderSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
service = SocialProviderService()
|
||||
providers = service.get_connected_providers(request.user)
|
||||
|
||||
serializer = ConnectedProviderSerializer(providers, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Connect social provider",
|
||||
description="Connect a social authentication provider to the user's account.",
|
||||
request=ConnectProviderInputSerializer,
|
||||
responses={
|
||||
200: ConnectProviderOutputSerializer,
|
||||
400: SocialProviderErrorSerializer,
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Social Authentication"],
|
||||
),
|
||||
)
|
||||
class ConnectProviderAPIView(APIView):
|
||||
"""API endpoint to connect a social provider."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = ConnectProviderInputSerializer
|
||||
|
||||
def post(self, request: Request, provider: str) -> Response:
|
||||
# Validate provider
|
||||
if provider not in ['google', 'discord']:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "INVALID_PROVIDER",
|
||||
"message": f"Provider '{provider}' is not supported",
|
||||
"suggestions": ["Use 'google' or 'discord'"]
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
serializer = ConnectProviderInputSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "VALIDATION_ERROR",
|
||||
"message": "Invalid request data",
|
||||
"details": serializer.errors,
|
||||
"suggestions": ["Provide a valid access_token"]
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
access_token = serializer.validated_data['access_token']
|
||||
|
||||
try:
|
||||
service = SocialProviderService()
|
||||
result = service.connect_provider(request.user, provider, access_token)
|
||||
|
||||
response_serializer = ConnectProviderOutputSerializer(result)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "CONNECTION_FAILED",
|
||||
"message": str(e),
|
||||
"suggestions": [
|
||||
"Verify the access token is valid",
|
||||
"Ensure the provider account is not already connected to another user"
|
||||
]
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Disconnect social provider",
|
||||
description="Disconnect a social authentication provider from the user's account.",
|
||||
responses={
|
||||
200: DisconnectProviderOutputSerializer,
|
||||
400: SocialProviderErrorSerializer,
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Social Authentication"],
|
||||
),
|
||||
)
|
||||
class DisconnectProviderAPIView(APIView):
|
||||
"""API endpoint to disconnect a social provider."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = DisconnectProviderOutputSerializer
|
||||
|
||||
def post(self, request: Request, provider: str) -> Response:
|
||||
# Validate provider
|
||||
if provider not in ['google', 'discord']:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "INVALID_PROVIDER",
|
||||
"message": f"Provider '{provider}' is not supported",
|
||||
"suggestions": ["Use 'google' or 'discord'"]
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
service = SocialProviderService()
|
||||
|
||||
# Check if disconnection is safe
|
||||
can_disconnect, reason = service.can_disconnect_provider(
|
||||
request.user, provider)
|
||||
if not can_disconnect:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "UNSAFE_DISCONNECTION",
|
||||
"message": reason,
|
||||
"suggestions": [
|
||||
"Set up email/password authentication before disconnecting",
|
||||
"Connect another social provider before disconnecting this one"
|
||||
]
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Perform disconnection
|
||||
result = service.disconnect_provider(request.user, provider)
|
||||
|
||||
response_serializer = DisconnectProviderOutputSerializer(result)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "DISCONNECTION_FAILED",
|
||||
"message": str(e),
|
||||
"suggestions": [
|
||||
"Verify the provider is currently connected",
|
||||
"Ensure you have alternative authentication methods"
|
||||
]
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get social authentication status",
|
||||
description="Get comprehensive social authentication status for the user.",
|
||||
responses={
|
||||
200: SocialAuthStatusSerializer,
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Social Authentication"],
|
||||
),
|
||||
)
|
||||
class SocialAuthStatusAPIView(APIView):
|
||||
"""API endpoint to get social authentication status."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = SocialAuthStatusSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
service = SocialProviderService()
|
||||
auth_status = service.get_auth_status(request.user)
|
||||
|
||||
serializer = SocialAuthStatusSerializer(auth_status)
|
||||
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.
|
||||
Reference in New Issue
Block a user