feat: Refactor rides app with unique constraints, mixins, and enhanced documentation

- Added migration to convert unique_together constraints to UniqueConstraint for RideModel.
- Introduced RideFormMixin for handling entity suggestions in ride forms.
- Created comprehensive code standards documentation outlining formatting, docstring requirements, complexity guidelines, and testing requirements.
- Established error handling guidelines with a structured exception hierarchy and best practices for API and view error handling.
- Documented view pattern guidelines, emphasizing the use of CBVs, FBVs, and ViewSets with examples.
- Implemented a benchmarking script for query performance analysis and optimization.
- Developed security documentation detailing measures, configurations, and a security checklist.
- Compiled a database optimization guide covering indexing strategies, query optimization patterns, and computed fields.
This commit is contained in:
pacnpal
2025-12-22 11:17:31 -05:00
parent 45d97b6e68
commit 2e35f8c5d9
71 changed files with 8036 additions and 1462 deletions

View File

@@ -1,7 +1,15 @@
"""
Management command to reset the database and create an admin user.
Security Note: This command uses a mix of raw SQL (for PostgreSQL-specific operations
like dropping all tables) and Django ORM (for creating users). The raw SQL operations
use quote_ident() for table/sequence names which is safe from SQL injection.
WARNING: This command is destructive and should only be used in development.
"""
from django.core.management.base import BaseCommand
from django.db import connection
from django.contrib.auth.hashers import make_password
import uuid
class Command(BaseCommand):
@@ -10,7 +18,8 @@ class Command(BaseCommand):
def handle(self, *args, **options):
self.stdout.write("Resetting database...")
# Drop all tables
# Drop all tables using PostgreSQL-specific operations
# Security: Using quote_ident() to safely quote table/sequence names
with connection.cursor() as cursor:
cursor.execute(
"""
@@ -21,7 +30,7 @@ class Command(BaseCommand):
SELECT tablename FROM pg_tables
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'DROP TABLE IF EXISTS ' || \
EXECUTE 'DROP TABLE IF EXISTS ' ||
quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
@@ -38,7 +47,7 @@ class Command(BaseCommand):
SELECT sequencename FROM pg_sequences
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'ALTER SEQUENCE ' || \
EXECUTE 'ALTER SEQUENCE ' ||
quote_ident(r.sequencename) || ' RESTART WITH 1';
END LOOP;
END $$;
@@ -54,51 +63,25 @@ class Command(BaseCommand):
self.stdout.write("Migrations applied.")
# Create superuser using raw SQL
# Create superuser using Django ORM (safer than raw SQL)
try:
with connection.cursor() as cursor:
# Create user
user_id = str(uuid.uuid4())[:10]
cursor.execute(
"""
INSERT INTO accounts_user (
username, password, email, is_superuser, is_staff,
is_active, date_joined, user_id, first_name,
last_name, role, is_banned, ban_reason,
theme_preference
) VALUES (
'admin', %s, 'admin@thrillwiki.com', true, true,
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
'light'
) RETURNING id;
""",
[make_password("admin"), user_id],
)
from apps.accounts.models import User, UserProfile
result = cursor.fetchone()
if result is None:
raise Exception("Failed to create user - no ID returned")
user_db_id = result[0]
# Security: Using Django ORM instead of raw SQL for user creation
user = User.objects.create_superuser(
username='admin',
email='admin@thrillwiki.com',
password='admin',
role='SUPERUSER',
)
# Create profile
profile_id = str(uuid.uuid4())[:10]
cursor.execute(
"""
INSERT INTO accounts_userprofile (
profile_id, display_name, pronouns, bio,
twitter, instagram, youtube, discord,
coaster_credits, dark_ride_credits,
flat_ride_credits, water_ride_credits,
user_id, avatar
) VALUES (
%s, 'Admin', 'they/them', 'ThrillWiki Administrator',
'', '', '', '',
0, 0, 0, 0,
%s, ''
);
""",
[profile_id, user_db_id],
)
# Create profile using ORM
UserProfile.objects.create(
user=user,
display_name='Admin',
pronouns='they/them',
bio='ThrillWiki Administrator',
)
self.stdout.write("Superuser created.")
except Exception as e:

View File

@@ -0,0 +1,40 @@
"""
Add performance indexes and constraints to User model.
This migration adds:
1. db_index=True to is_banned and role fields for faster filtering
2. Composite index on (is_banned, role) for common query patterns
3. CheckConstraint to ensure banned users have a ban_date set
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0012_alter_toplist_category_and_more'),
]
operations = [
# Add db_index to is_banned field
migrations.AlterField(
model_name='user',
name='is_banned',
field=models.BooleanField(default=False, db_index=True),
),
# Add composite index for common query patterns
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'),
),
# Add CheckConstraint for ban consistency
migrations.AddConstraint(
model_name='user',
constraint=models.CheckConstraint(
name='user_ban_consistency',
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
violation_error_message='Banned users must have a ban_date set'
),
),
]

View File

@@ -49,8 +49,9 @@ class User(AbstractUser):
domain="accounts",
max_length=10,
default="USER",
db_index=True,
)
is_banned = models.BooleanField(default=False)
is_banned = models.BooleanField(default=False, db_index=True)
ban_reason = models.TextField(blank=True)
ban_date = models.DateTimeField(null=True, blank=True)
pending_email = models.EmailField(blank=True, null=True)
@@ -127,6 +128,18 @@ class User(AbstractUser):
return profile.display_name
return self.username
class Meta:
indexes = [
models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'),
]
constraints = [
models.CheckConstraint(
name='user_ban_consistency',
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
violation_error_message='Banned users must have a ban_date set'
),
]
def save(self, *args, **kwargs):
if not self.user_id:
self.user_id = generate_random_id(User, "user_id")

View File

@@ -2,16 +2,281 @@
User management services for ThrillWiki.
This module contains services for user account management including
user deletion while preserving submissions.
user deletion while preserving submissions, password management,
and email change functionality.
Recent additions:
- AccountService: Handles password and email change operations
- UserDeletionService: Manages user deletion while preserving content
"""
from typing import Optional
from django.db import transaction
from django.utils import timezone
import logging
import re
from typing import Any, Dict, Optional
from django.conf import settings
from django.contrib.auth import update_session_auth_hash
from django.contrib.sites.models import Site
from django.contrib.sites.shortcuts import get_current_site
from django.db import transaction
from django.http import HttpRequest
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.crypto import get_random_string
from django_forwardemail.services import EmailService
from .models import User, UserProfile, UserDeletionRequest
from .models import EmailVerification, User, UserDeletionRequest, UserProfile
logger = logging.getLogger(__name__)
class AccountService:
"""Service for account management operations including password and email changes."""
@staticmethod
def validate_password(password: str) -> bool:
"""
Validate password meets requirements.
Args:
password: The password to validate
Returns:
True if password meets requirements, False otherwise
"""
return (
len(password) >= 8
and bool(re.search(r"[A-Z]", password))
and bool(re.search(r"[a-z]", password))
and bool(re.search(r"[0-9]", password))
)
@staticmethod
def change_password(
*,
user: User,
old_password: str,
new_password: str,
request: HttpRequest,
) -> Dict[str, Any]:
"""
Change user password with validation and notification.
Validates the old password, checks new password requirements,
updates the password, and sends a confirmation email.
Args:
user: The user whose password is being changed
old_password: Current password for verification
new_password: New password to set
request: HTTP request for session handling
Returns:
Dictionary with success status, message, and optional redirect URL:
{
'success': bool,
'message': str,
'redirect_url': Optional[str]
}
"""
# Verify old password
if not user.check_password(old_password):
logger.warning(
f"Password change failed: incorrect current password for user {user.id}"
)
return {
'success': False,
'message': "Current password is incorrect",
'redirect_url': None
}
# Validate new password
if not AccountService.validate_password(new_password):
return {
'success': False,
'message': "Password must be at least 8 characters and contain uppercase, lowercase, and numbers",
'redirect_url': None
}
# Update password
user.set_password(new_password)
user.save()
# Keep user logged in after password change
update_session_auth_hash(request, user)
# Send confirmation email
AccountService._send_password_change_confirmation(request, user)
logger.info(f"Password changed successfully for user {user.id}")
return {
'success': True,
'message': "Password changed successfully. Please check your email for confirmation.",
'redirect_url': None
}
@staticmethod
def _send_password_change_confirmation(request: HttpRequest, user: User) -> None:
"""Send password change confirmation email."""
site = get_current_site(request)
context = {
"user": user,
"site_name": site.name,
}
email_html = render_to_string(
"accounts/email/password_change_confirmation.html", context
)
try:
EmailService.send_email(
to=user.email,
subject="Password Changed Successfully",
text="Your password has been changed successfully.",
site=site,
html=email_html,
)
except Exception as e:
logger.error(f"Failed to send password change confirmation email: {e}")
@staticmethod
def initiate_email_change(
*,
user: User,
new_email: str,
request: HttpRequest,
) -> Dict[str, Any]:
"""
Initiate email change with verification.
Creates a verification token and sends a verification email
to the new email address.
Args:
user: The user changing their email
new_email: The new email address
request: HTTP request for site context
Returns:
Dictionary with success status and message:
{
'success': bool,
'message': str
}
"""
if not new_email:
return {
'success': False,
'message': "New email is required"
}
# Check if email is already in use
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
return {
'success': False,
'message': "This email address is already in use"
}
# Generate verification token
token = get_random_string(64)
# Create or update email verification record
EmailVerification.objects.update_or_create(
user=user,
defaults={"token": token}
)
# Store pending email
user.pending_email = new_email
user.save()
# Send verification email
AccountService._send_email_verification(request, user, new_email, token)
logger.info(f"Email change initiated for user {user.id} to {new_email}")
return {
'success': True,
'message': "Verification email sent to your new email address"
}
@staticmethod
def _send_email_verification(
request: HttpRequest,
user: User,
new_email: str,
token: str
) -> None:
"""Send email verification for email change."""
from django.urls import reverse
site = get_current_site(request)
verification_url = reverse("verify_email", kwargs={"token": token})
context = {
"user": user,
"verification_url": verification_url,
"site_name": site.name,
}
email_html = render_to_string("accounts/email/verify_email.html", context)
try:
EmailService.send_email(
to=new_email,
subject="Verify your new email address",
text="Click the link to verify your new email address",
site=site,
html=email_html,
)
except Exception as e:
logger.error(f"Failed to send email verification: {e}")
@staticmethod
def verify_email_change(*, token: str) -> Dict[str, Any]:
"""
Verify email change token and update user email.
Args:
token: The verification token
Returns:
Dictionary with success status and message
"""
try:
verification = EmailVerification.objects.select_related("user").get(
token=token
)
except EmailVerification.DoesNotExist:
return {
'success': False,
'message': "Invalid or expired verification token"
}
user = verification.user
if not user.pending_email:
return {
'success': False,
'message': "No pending email change found"
}
# Update email
old_email = user.email
user.email = user.pending_email
user.pending_email = None
user.save()
# Delete verification record
verification.delete()
logger.info(f"Email changed for user {user.id} from {old_email} to {user.email}")
return {
'success': True,
'message': "Email address updated successfully"
}
class UserDeletionService:

View File

@@ -0,0 +1,101 @@
"""
Tests for model constraints and validators in the accounts app.
These tests verify that:
1. CheckConstraints raise appropriate errors
2. Validators work correctly
3. Business rules are enforced at the model level
"""
from django.test import TestCase
from django.db import IntegrityError
from django.core.exceptions import ValidationError
from django.utils import timezone
from apps.accounts.models import User
class UserConstraintTests(TestCase):
"""Tests for User model constraints."""
def test_banned_user_without_ban_date_raises_error(self):
"""Verify banned users must have a ban_date set."""
user = User(
username="testuser",
email="test@example.com",
is_banned=True,
ban_date=None, # This should violate the constraint
)
# The constraint should be enforced at database level
with self.assertRaises(IntegrityError):
user.save()
def test_banned_user_with_ban_date_saves_successfully(self):
"""Verify banned users with ban_date save successfully."""
user = User.objects.create_user(
username="testuser2",
email="test2@example.com",
password="testpass123",
is_banned=True,
ban_date=timezone.now(),
)
self.assertIsNotNone(user.pk)
self.assertTrue(user.is_banned)
self.assertIsNotNone(user.ban_date)
def test_non_banned_user_without_ban_date_saves_successfully(self):
"""Verify non-banned users can be saved without ban_date."""
user = User.objects.create_user(
username="testuser3",
email="test3@example.com",
password="testpass123",
is_banned=False,
ban_date=None,
)
self.assertIsNotNone(user.pk)
self.assertFalse(user.is_banned)
def test_user_id_is_auto_generated(self):
"""Verify user_id is automatically generated on save."""
user = User.objects.create_user(
username="testuser4",
email="test4@example.com",
password="testpass123",
)
self.assertIsNotNone(user.user_id)
self.assertTrue(len(user.user_id) >= 4)
def test_user_id_is_unique(self):
"""Verify user_id is unique across users."""
user1 = User.objects.create_user(
username="testuser5",
email="test5@example.com",
password="testpass123",
)
user2 = User.objects.create_user(
username="testuser6",
email="test6@example.com",
password="testpass123",
)
self.assertNotEqual(user1.user_id, user2.user_id)
class UserIndexTests(TestCase):
"""Tests for User model indexes."""
def test_is_banned_field_is_indexed(self):
"""Verify is_banned field has db_index=True."""
field = User._meta.get_field('is_banned')
self.assertTrue(field.db_index)
def test_role_field_is_indexed(self):
"""Verify role field has db_index=True."""
field = User._meta.get_field('role')
self.assertTrue(field.db_index)
def test_composite_index_exists(self):
"""Verify composite index on (is_banned, role) exists."""
indexes = User._meta.indexes
index_names = [idx.name for idx in indexes]
self.assertIn('accounts_user_banned_role_idx', index_names)