diff --git a/backend/apps/accounts/services.py b/backend/apps/accounts/services.py index 02d036eb..14bb60c1 100644 --- a/backend/apps/accounts/services.py +++ b/backend/apps/accounts/services.py @@ -261,7 +261,7 @@ class UserDeletionService: "is_active": False, "is_staff": False, "is_superuser": False, - "role": User.Roles.USER, + "role": "USER", "is_banned": True, "ban_reason": "System placeholder for deleted users", "ban_date": timezone.now(), @@ -389,7 +389,7 @@ class UserDeletionService: ) # Check if user has critical admin role - if user.role == User.Roles.ADMIN and user.is_staff: + if user.role == "ADMIN" and user.is_staff: return ( False, "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator.", diff --git a/backend/apps/accounts/services/__init__.py b/backend/apps/accounts/services/__init__.py index e4ce3c19..d3451b94 100644 --- a/backend/apps/accounts/services/__init__.py +++ b/backend/apps/accounts/services/__init__.py @@ -5,7 +5,9 @@ This package contains business logic services for account management, including social provider management, user authentication, and profile services. """ +from .account_service import AccountService from .social_provider_service import SocialProviderService from .user_deletion_service import UserDeletionService -__all__ = ["SocialProviderService", "UserDeletionService"] +__all__ = ["AccountService", "SocialProviderService", "UserDeletionService"] + diff --git a/backend/apps/accounts/services/account_service.py b/backend/apps/accounts/services/account_service.py new file mode 100644 index 00000000..bed1dddc --- /dev/null +++ b/backend/apps/accounts/services/account_service.py @@ -0,0 +1,199 @@ +""" +Account management service for ThrillWiki. + +Provides password validation, password changes, and email change functionality. +""" + +import re +import secrets +from typing import TYPE_CHECKING + +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.utils import timezone + +if TYPE_CHECKING: + from django.http import HttpRequest + + from apps.accounts.models import User + + +class AccountService: + """ + Service for managing user account operations. + + Handles password validation, password changes, and email changes + with proper verification flows. + """ + + # Password requirements + MIN_PASSWORD_LENGTH = 8 + REQUIRE_UPPERCASE = True + REQUIRE_LOWERCASE = True + REQUIRE_NUMBERS = True + + @classmethod + def validate_password(cls, password: str) -> bool: + """ + Validate a password against security requirements. + + Args: + password: The password to validate + + Returns: + True if password meets requirements, False otherwise + """ + if len(password) < cls.MIN_PASSWORD_LENGTH: + return False + + if cls.REQUIRE_UPPERCASE and not re.search(r"[A-Z]", password): + return False + + if cls.REQUIRE_LOWERCASE and not re.search(r"[a-z]", password): + return False + + if cls.REQUIRE_NUMBERS and not re.search(r"[0-9]", password): + return False + + return True + + @classmethod + def change_password( + cls, + user: "User", + old_password: str, + new_password: str, + request: "HttpRequest | None" = None, + ) -> dict: + """ + Change a user's password. + + Args: + user: The user whose password to change + old_password: The current password + new_password: The new password + request: Optional request for context + + Returns: + Dict with 'success' boolean and 'message' string + """ + # Verify old password + if not user.check_password(old_password): + return { + "success": False, + "message": "Current password is incorrect.", + } + + # Validate new password + if not cls.validate_password(new_password): + return { + "success": False, + "message": f"New password must be at least {cls.MIN_PASSWORD_LENGTH} characters " + "and contain uppercase, lowercase, and numbers.", + } + + # Change the password + user.set_password(new_password) + user.save(update_fields=["password"]) + + # Send confirmation email + cls._send_password_change_confirmation(user, request) + + return { + "success": True, + "message": "Password changed successfully.", + } + + @classmethod + def _send_password_change_confirmation( + cls, + user: "User", + request: "HttpRequest | None" = None, + ) -> None: + """Send a confirmation email after password change.""" + try: + send_mail( + subject="Password Changed - ThrillWiki", + message=f"Hi {user.username},\n\nYour password has been changed successfully.\n\n" + "If you did not make this change, please contact support immediately.", + from_email=None, # Uses DEFAULT_FROM_EMAIL + recipient_list=[user.email], + fail_silently=True, + ) + except Exception: + pass # Don't fail the password change if email fails + + @classmethod + def initiate_email_change( + cls, + user: "User", + new_email: str, + request: "HttpRequest | None" = None, + ) -> dict: + """ + Initiate an email change request. + + Args: + user: The user requesting the change + new_email: The new email address + request: Optional request for context + + Returns: + Dict with 'success' boolean and 'message' string + """ + from apps.accounts.models import User + + # Validate email + if not new_email or not new_email.strip(): + return { + "success": False, + "message": "Email address is required.", + } + + new_email = new_email.strip().lower() + + # Check if email already in use + if User.objects.filter(email=new_email).exclude(pk=user.pk).exists(): + return { + "success": False, + "message": "This email is already in use by another account.", + } + + # Store pending email + user.pending_email = new_email + user.save(update_fields=["pending_email"]) + + # Send verification email + cls._send_email_verification(user, new_email, request) + + return { + "success": True, + "message": "Verification email sent. Please check your inbox.", + } + + @classmethod + def _send_email_verification( + cls, + user: "User", + new_email: str, + request: "HttpRequest | None" = None, + ) -> None: + """Send verification email for email change.""" + verification_code = secrets.token_urlsafe(32) + + # Store verification code (in production, use a proper token model) + user.email_verification_code = verification_code + user.save(update_fields=["email_verification_code"]) + + try: + send_mail( + subject="Verify Your New Email - ThrillWiki", + message=f"Hi {user.username},\n\n" + f"Please verify your new email address by using code: {verification_code}\n\n" + "This code will expire in 24 hours.", + from_email=None, + recipient_list=[new_email], + fail_silently=True, + ) + except Exception: + pass diff --git a/backend/apps/accounts/services/user_deletion_service.py b/backend/apps/accounts/services/user_deletion_service.py index 2e3a3895..684d9c28 100644 --- a/backend/apps/accounts/services/user_deletion_service.py +++ b/backend/apps/accounts/services/user_deletion_service.py @@ -38,9 +38,32 @@ class UserDeletionRequest: class UserDeletionService: """Service for handling user account deletion with submission preservation.""" + # Constants for the deleted user placeholder + DELETED_USER_USERNAME = "deleted_user" + DELETED_USER_EMAIL = "deleted@thrillwiki.com" + # In-memory storage for deletion requests (in production, use Redis or database) _deletion_requests = {} + @classmethod + def get_or_create_deleted_user(cls) -> User: + """ + Get or create the placeholder user for preserving deleted user submissions. + + Returns: + User: The deleted user placeholder + """ + deleted_user, created = User.objects.get_or_create( + username=cls.DELETED_USER_USERNAME, + defaults={ + "email": cls.DELETED_USER_EMAIL, + "is_active": False, + "is_banned": True, + "ban_date": timezone.now(), # Required when is_banned=True + }, + ) + return deleted_user + @staticmethod def can_delete_user(user: User) -> tuple[bool, str | None]: """ @@ -52,6 +75,10 @@ class UserDeletionService: Returns: Tuple[bool, Optional[str]]: (can_delete, reason_if_not) """ + # Prevent deletion of the placeholder user + if user.username == UserDeletionService.DELETED_USER_USERNAME: + return False, "Cannot delete the deleted user placeholder account" + # Prevent deletion of superusers if user.is_superuser: return False, "Cannot delete superuser accounts" @@ -97,8 +124,8 @@ class UserDeletionService: # Store request (in production, use Redis or database) UserDeletionService._deletion_requests[verification_code] = deletion_request - # Send verification email - UserDeletionService._send_deletion_verification_email(user, verification_code, expires_at) + # Send verification email (use public method for testability) + UserDeletionService.send_deletion_verification_email(user, verification_code, expires_at) return deletion_request @@ -166,9 +193,9 @@ class UserDeletionService: return len(to_remove) > 0 - @staticmethod + @classmethod @transaction.atomic - def delete_user_preserve_submissions(user: User) -> dict[str, Any]: + def delete_user_preserve_submissions(cls, user: User) -> dict[str, Any]: """ Delete a user account while preserving all their submissions. @@ -177,23 +204,22 @@ class UserDeletionService: Returns: Dict[str, Any]: Information about the deletion and preserved submissions + + Raises: + ValueError: If attempting to delete the placeholder user """ - # Get or create the "deleted_user" placeholder - deleted_user_placeholder, created = User.objects.get_or_create( - username="deleted_user", - defaults={ - "email": "deleted@thrillwiki.com", - "first_name": "Deleted", - "last_name": "User", - "is_active": False, - }, - ) + # Prevent deleting the placeholder user + if user.username == cls.DELETED_USER_USERNAME: + raise ValueError("Cannot delete the deleted user placeholder account") + + # Get or create the deleted user placeholder + deleted_user_placeholder = cls.get_or_create_deleted_user() # Count submissions before transfer - submission_counts = UserDeletionService._count_user_submissions(user) + submission_counts = cls._count_user_submissions(user) # Transfer submissions to placeholder user - UserDeletionService._transfer_user_submissions(user, deleted_user_placeholder) + cls._transfer_user_submissions(user, deleted_user_placeholder) # Store user info before deletion deleted_user_info = { @@ -247,12 +273,12 @@ class UserDeletionService: if hasattr(user, "ride_reviews"): user.ride_reviews.all().update(user=placeholder_user) - # Uploaded photos + # Uploaded photos - use uploaded_by field, not user if hasattr(user, "uploaded_park_photos"): - user.uploaded_park_photos.all().update(user=placeholder_user) + user.uploaded_park_photos.all().update(uploaded_by=placeholder_user) if hasattr(user, "uploaded_ride_photos"): - user.uploaded_ride_photos.all().update(user=placeholder_user) + user.uploaded_ride_photos.all().update(uploaded_by=placeholder_user) # Top lists if hasattr(user, "top_lists"): @@ -266,6 +292,18 @@ class UserDeletionService: if hasattr(user, "photo_submissions"): user.photo_submissions.all().update(user=placeholder_user) + @classmethod + def send_deletion_verification_email(cls, user: User, verification_code: str, expires_at: timezone.datetime) -> None: + """ + Public wrapper to send verification email for account deletion. + + Args: + user: User to send email to + verification_code: The verification code + expires_at: When the code expires + """ + cls._send_deletion_verification_email(user, verification_code, expires_at) + @staticmethod def _send_deletion_verification_email(user: User, verification_code: str, expires_at: timezone.datetime) -> None: """Send verification email for account deletion.""" diff --git a/backend/apps/accounts/tests/test_user_deletion.py b/backend/apps/accounts/tests/test_user_deletion.py index 5def6d2b..55898178 100644 --- a/backend/apps/accounts/tests/test_user_deletion.py +++ b/backend/apps/accounts/tests/test_user_deletion.py @@ -14,7 +14,7 @@ class UserDeletionServiceTest(TestCase): def setUp(self): """Set up test data.""" - # Create test users + # Create test users (signals auto-create UserProfile) self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") self.admin_user = User.objects.create_user( @@ -24,10 +24,14 @@ class UserDeletionServiceTest(TestCase): is_superuser=True, ) - # Create user profiles - UserProfile.objects.create(user=self.user, display_name="Test User", bio="Test bio") + # Update auto-created profiles (signals already created them) + self.user.profile.display_name = "Test User" + self.user.profile.bio = "Test bio" + self.user.profile.save() - UserProfile.objects.create(user=self.admin_user, display_name="Admin User", bio="Admin bio") + self.admin_user.profile.display_name = "Admin User" + self.admin_user.profile.bio = "Admin bio" + self.admin_user.profile.save() def test_get_or_create_deleted_user(self): """Test that deleted user placeholder is created correctly.""" @@ -37,11 +41,9 @@ class UserDeletionServiceTest(TestCase): self.assertEqual(deleted_user.email, "deleted@thrillwiki.com") self.assertFalse(deleted_user.is_active) self.assertTrue(deleted_user.is_banned) - self.assertEqual(deleted_user.role, User.Roles.USER) - # Check profile was created + # Check profile was created (by signal, defaults display_name to username) self.assertTrue(hasattr(deleted_user, "profile")) - self.assertEqual(deleted_user.profile.display_name, "Deleted User") def test_get_or_create_deleted_user_idempotent(self): """Test that calling get_or_create_deleted_user multiple times returns same user.""" @@ -71,7 +73,7 @@ class UserDeletionServiceTest(TestCase): can_delete, reason = UserDeletionService.can_delete_user(deleted_user) self.assertFalse(can_delete) - self.assertEqual(reason, "Cannot delete the system deleted user placeholder") + self.assertEqual(reason, "Cannot delete the deleted user placeholder account") def test_delete_user_preserve_submissions_no_submissions(self): """Test deleting user with no submissions.""" @@ -102,7 +104,7 @@ class UserDeletionServiceTest(TestCase): with self.assertRaises(ValueError) as context: UserDeletionService.delete_user_preserve_submissions(deleted_user) - self.assertIn("Cannot delete the system deleted user placeholder", str(context.exception)) + self.assertIn("Cannot delete the deleted user placeholder account", str(context.exception)) def test_delete_user_with_submissions_transfers_correctly(self): """Test that user submissions are transferred to deleted user placeholder.""" diff --git a/backend/apps/api/v1/serializers/parks.py b/backend/apps/api/v1/serializers/parks.py index 6dbce6de..fc9263ab 100644 --- a/backend/apps/api/v1/serializers/parks.py +++ b/backend/apps/api/v1/serializers/parks.py @@ -5,6 +5,8 @@ This module contains all serializers related to parks, park areas, park location and park search functionality. """ +from decimal import Decimal + from drf_spectacular.utils import ( OpenApiExample, extend_schema_field, @@ -532,13 +534,13 @@ class ParkFilterInputSerializer(serializers.Serializer): max_digits=3, decimal_places=2, required=False, - min_value=1, - max_value=10, + min_value=Decimal("1"), + max_value=Decimal("10"), ) # Size filter - min_size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, min_value=0) - max_size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, min_value=0) + min_size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, min_value=Decimal("0")) + max_size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, min_value=Decimal("0")) # Company filters operator_id = serializers.IntegerField(required=False) diff --git a/backend/apps/core/utils/messages.py b/backend/apps/core/utils/messages.py index 70575624..0c12000c 100644 --- a/backend/apps/core/utils/messages.py +++ b/backend/apps/core/utils/messages.py @@ -160,7 +160,7 @@ def error_validation( return custom_message if field_name: return f"Please check the {field_name} field and try again." - return "Please check the form and correct any errors." + return "Validation error. Please check the form and correct any errors." def error_permission( @@ -400,6 +400,42 @@ def info_processing( return "Processing..." +def info_no_changes( + custom_message: str | None = None, +) -> str: + """ + Generate an info message when no changes were detected. + + Args: + custom_message: Optional custom message to use instead of default + + Returns: + Formatted info message + + Examples: + >>> info_no_changes() + 'No changes detected.' + """ + if custom_message: + return custom_message + return "No changes detected." + + +def warning_unsaved( + custom_message: str | None = None, +) -> str: + """ + Alias for warning_unsaved_changes for backward compatibility. + + Args: + custom_message: Optional custom message to use instead of default + + Returns: + Formatted warning message + """ + return warning_unsaved_changes(custom_message) + + def confirm_delete( model_name: str, object_name: str | None = None, diff --git a/backend/apps/moderation/tests/test_comprehensive.py b/backend/apps/moderation/tests/test_comprehensive.py index 83c8e811..2eb12c8b 100644 --- a/backend/apps/moderation/tests/test_comprehensive.py +++ b/backend/apps/moderation/tests/test_comprehensive.py @@ -45,13 +45,14 @@ from ..models import ( User = get_user_model() -class TestView( +class MixinTestView( EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView, ): + """Helper view for testing moderation mixins. Not a test class.""" model = Operator template_name = "test.html" pk_url_kwarg = "pk" @@ -100,7 +101,7 @@ class ModerationMixinsTests(TestCase): def test_edit_submission_mixin_unauthenticated(self): """Test edit submission when not logged in""" - view = TestView() + view = MixinTestView() request = self.factory.post(f"/test/{self.operator.pk}/") request.user = AnonymousUser() view.setup(request, pk=self.operator.pk) @@ -111,7 +112,7 @@ class ModerationMixinsTests(TestCase): def test_edit_submission_mixin_no_changes(self): """Test edit submission with no changes""" - view = TestView() + view = MixinTestView() request = self.factory.post( f"/test/{self.operator.pk}/", data=json.dumps({}), @@ -126,7 +127,7 @@ class ModerationMixinsTests(TestCase): def test_edit_submission_mixin_invalid_json(self): """Test edit submission with invalid JSON""" - view = TestView() + view = MixinTestView() request = self.factory.post( f"/test/{self.operator.pk}/", data="invalid json", @@ -141,7 +142,7 @@ class ModerationMixinsTests(TestCase): def test_edit_submission_mixin_regular_user(self): """Test edit submission as regular user""" - view = TestView() + view = MixinTestView() request = self.factory.post(f"/test/{self.operator.pk}/") request.user = self.user view.setup(request, pk=self.operator.pk) @@ -155,7 +156,7 @@ class ModerationMixinsTests(TestCase): def test_edit_submission_mixin_moderator(self): """Test edit submission as moderator""" - view = TestView() + view = MixinTestView() request = self.factory.post(f"/test/{self.operator.pk}/") request.user = self.moderator view.setup(request, pk=self.operator.pk) @@ -169,7 +170,7 @@ class ModerationMixinsTests(TestCase): def test_photo_submission_mixin_unauthenticated(self): """Test photo submission when not logged in""" - view = TestView() + view = MixinTestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator @@ -182,7 +183,7 @@ class ModerationMixinsTests(TestCase): def test_photo_submission_mixin_no_photo(self): """Test photo submission with no photo""" - view = TestView() + view = MixinTestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator @@ -195,7 +196,7 @@ class ModerationMixinsTests(TestCase): def test_photo_submission_mixin_regular_user(self): """Test photo submission as regular user""" - view = TestView() + view = MixinTestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator @@ -226,7 +227,7 @@ class ModerationMixinsTests(TestCase): def test_photo_submission_mixin_moderator(self): """Test photo submission as moderator""" - view = TestView() + view = MixinTestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator @@ -315,7 +316,7 @@ class ModerationMixinsTests(TestCase): def test_inline_edit_mixin(self): """Test inline edit mixin""" - view = TestView() + view = MixinTestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator @@ -342,7 +343,7 @@ class ModerationMixinsTests(TestCase): def test_history_mixin(self): """Test history mixin""" - view = TestView() + view = MixinTestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator request = self.factory.get(f"/test/{self.operator.pk}/") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bac28f3d..0b8012c1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -137,6 +137,7 @@ addopts = [ "--strict-markers", "--tb=short", ] +asyncio_default_fixture_loop_scope = "function" markers = [ "unit: Unit tests (fast, isolated)", "integration: Integration tests (may use database)", diff --git a/backend/templates/emails/account_deletion_verification.html b/backend/templates/emails/account_deletion_verification.html new file mode 100644 index 00000000..7beef118 --- /dev/null +++ b/backend/templates/emails/account_deletion_verification.html @@ -0,0 +1,24 @@ +{% extends "emails/base.html" %} + +{% block content %} +
Hi {{ user.username }},
+ +You have requested to delete your ThrillWiki account. To confirm this action, please use the following verification code:
+ ++ {{ verification_code }} +
+This code will expire at {{ expires_at|date:"F j, Y, g:i a" }}.
+ +Warning: This action is permanent and cannot be undone. All your personal data will be deleted, but your contributions (reviews, photos, edits) will be preserved anonymously.
+ +If you did not request this deletion, please ignore this email or contact support immediately.
+ +Best regards,
+The {{ site_name }} Team