feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.

This commit is contained in:
pacnpal
2025-12-28 17:32:53 -05:00
parent aa56c46c27
commit c95f99ca10
452 changed files with 7948 additions and 6073 deletions

View File

@@ -1,6 +1,6 @@
from django.conf import settings
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
@@ -33,10 +33,7 @@ class CustomAccountAdapter(DefaultAccountAdapter):
"current_site": current_site,
"key": emailconfirmation.key,
}
if signup:
email_template = "account/email/email_confirmation_signup"
else:
email_template = "account/email/email_confirmation"
email_template = "account/email/email_confirmation_signup" if signup else "account/email/email_confirmation"
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)

View File

@@ -16,7 +16,6 @@ from datetime import timedelta
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group
from django.db.models import Count, Sum
from django.utils import timezone
from django.utils.html import format_html
@@ -25,7 +24,6 @@ from apps.core.admin import (
ExportActionMixin,
QueryOptimizationMixin,
ReadOnlyAdminMixin,
TimestampFieldsMixin,
)
from .models import (

View File

@@ -7,8 +7,7 @@ replacing tuple-based choices with rich, metadata-enhanced choice objects.
Last updated: 2025-01-15
"""
from apps.core.choices import RichChoice, ChoiceGroup, register_choices
from apps.core.choices import ChoiceGroup, RichChoice, register_choices
# =============================================================================
# USER ROLES

View File

@@ -1,8 +1,8 @@
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from .models import User
class UserExportService:
"""Service for exporting all user data."""
@@ -10,18 +10,18 @@ class UserExportService:
def export_user_data(user: User) -> dict:
"""
Export all data associated with a user or an object containing counts/metadata and actual data.
Args:
user: The user to export data for
Returns:
dict: The complete user data export
"""
# Import models locally to avoid circular imports
from apps.lists.models import UserList
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
from apps.lists.models import UserList
# User account and profile
user_data = {
"username": user.username,
@@ -32,7 +32,7 @@ class UserExportService:
"is_active": user.is_active,
"role": user.role,
}
profile_data = {}
if hasattr(user, "profile"):
profile = user.profile
@@ -60,11 +60,11 @@ class UserExportService:
park_reviews = list(ParkReview.objects.filter(user=user).values(
"park__name", "rating", "review", "created_at", "updated_at", "is_published"
))
ride_reviews = list(RideReview.objects.filter(user=user).values(
"ride__name", "rating", "review", "created_at", "updated_at", "is_published"
))
# Lists
user_lists = []
for user_list in UserList.objects.filter(user=user):
@@ -75,7 +75,7 @@ class UserExportService:
"created_at": user_list.created_at,
"items": items
})
export_data = {
"account": user_data,
"profile": profile_data,
@@ -90,5 +90,5 @@ class UserExportService:
"version": "1.0"
}
}
return export_data

View File

@@ -0,0 +1,106 @@
"""
Login History Model
Tracks user login events for security auditing and compliance with
the login_history_retention setting on the User model.
"""
import pghistory
from django.conf import settings
from django.db import models
@pghistory.track()
class LoginHistory(models.Model):
"""
Records each successful login attempt for a user.
Used for security auditing, login notifications, and compliance with
the user's login_history_retention preference.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="login_history",
help_text="User who logged in",
)
ip_address = models.GenericIPAddressField(
null=True,
blank=True,
help_text="IP address from which the login occurred",
)
user_agent = models.CharField(
max_length=500,
blank=True,
help_text="Browser/client user agent string",
)
login_method = models.CharField(
max_length=20,
choices=[
("PASSWORD", "Password"),
("GOOGLE", "Google OAuth"),
("DISCORD", "Discord OAuth"),
("MAGIC_LINK", "Magic Link"),
("SESSION", "Session Refresh"),
],
default="PASSWORD",
help_text="Method used for authentication",
)
login_timestamp = models.DateTimeField(
auto_now_add=True,
db_index=True,
help_text="When the login occurred",
)
success = models.BooleanField(
default=True,
help_text="Whether the login was successful",
)
# Optional geolocation data (can be populated asynchronously)
country = models.CharField(
max_length=100,
blank=True,
help_text="Country derived from IP (optional)",
)
city = models.CharField(
max_length=100,
blank=True,
help_text="City derived from IP (optional)",
)
class Meta:
verbose_name = "Login History"
verbose_name_plural = "Login History"
ordering = ["-login_timestamp"]
indexes = [
models.Index(fields=["user", "-login_timestamp"]),
models.Index(fields=["ip_address"]),
]
def __str__(self):
return f"{self.user.username} login at {self.login_timestamp}"
@classmethod
def cleanup_old_entries(cls, days=90):
"""
Remove login history entries older than the specified number of days.
Respects each user's login_history_retention preference.
"""
from datetime import timedelta
from django.utils import timezone
# Default cleanup for entries older than the specified days
cutoff = timezone.now() - timedelta(days=days)
deleted_count, _ = cls.objects.filter(
login_timestamp__lt=cutoff
).delete()
return deleted_count

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand):

View File

@@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.core.management.base import BaseCommand
class Command(BaseCommand):

View File

@@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from apps.parks.models import ParkReview, Park, ParkPhoto
from django.core.management.base import BaseCommand
from apps.parks.models import Park, ParkPhoto, ParkReview
from apps.rides.models import Ride, RidePhoto
User = get_user_model()
@@ -52,8 +53,8 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
# Clean up test files
import os
import glob
import os
# Clean up test uploads
media_patterns = [

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand):

View File

@@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission, User
from django.core.management.base import BaseCommand
class Command(BaseCommand):

View File

@@ -8,6 +8,7 @@ Usage:
"""
from django.core.management.base import BaseCommand, CommandError
from apps.accounts.models import User
from apps.accounts.services import UserDeletionService
@@ -48,10 +49,7 @@ class Command(BaseCommand):
# Find the user
try:
if username:
user = User.objects.get(username=username)
else:
user = User.objects.get(user_id=user_id)
user = User.objects.get(username=username) if username else User.objects.get(user_id=user_id)
except User.DoesNotExist:
identifier = username or user_id
raise CommandError(f'User "{identifier}" does not exist')

View File

@@ -1,7 +1,8 @@
from django.core.management.base import BaseCommand
import os
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
import os
from django.core.management.base import BaseCommand
class Command(BaseCommand):

View File

@@ -1,6 +1,7 @@
import os
from django.core.management.base import BaseCommand
from PIL import Image, ImageDraw, ImageFont
import os
def generate_avatar(letter):

View File

@@ -1,4 +1,5 @@
from django.core.management.base import BaseCommand
from apps.accounts.models import UserProfile

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
from django.db import connection

View File

@@ -1,5 +1,6 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group
from django.core.management.base import BaseCommand
from apps.accounts.models import User
from apps.accounts.signals import create_default_groups

View File

@@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand):

View File

@@ -1,9 +1,10 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from allauth.socialaccount.models import SocialApp
from dotenv import load_dotenv
import os
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
from dotenv import load_dotenv
class Command(BaseCommand):
help = "Sets up social authentication apps"

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
User = get_user_model()

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand):

View File

@@ -1,6 +1,6 @@
from allauth.socialaccount.models import SocialApp
from django.core.management.base import BaseCommand
from django.test import Client
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand):

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.conf import settings
from django.core.management.base import BaseCommand
class Command(BaseCommand):

View File

@@ -27,14 +27,14 @@ def safe_add_avatar_field(apps, schema_editor):
# Check if the column already exists
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofile'
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofile'
AND column_name='avatar_id'
""")
column_exists = cursor.fetchone() is not None
if not column_exists:
# Column doesn't exist, add it
UserProfile = apps.get_model('accounts', 'UserProfile')
@@ -55,14 +55,14 @@ def reverse_safe_add_avatar_field(apps, schema_editor):
# Check if the column exists and remove it
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofile'
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofile'
AND column_name='avatar_id'
""")
column_exists = cursor.fetchone() is not None
if column_exists:
UserProfile = apps.get_model('accounts', 'UserProfile')
field = models.ForeignKey(

View File

@@ -23,9 +23,9 @@ class Migration(migrations.Migration):
DO $$
BEGIN
IF NOT EXISTS (
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofileevent'
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofileevent'
AND column_name='avatar_id'
) THEN
ALTER TABLE accounts_userprofileevent ADD COLUMN avatar_id uuid;

View File

@@ -1,8 +1,9 @@
# Generated by Django 5.2.5 on 2025-09-15 17:35
import apps.core.choices.fields
from django.db import migrations
import apps.core.choices.fields
class Migration(migrations.Migration):

View File

@@ -1,12 +1,13 @@
# Generated by Django 5.1.6 on 2025-12-26 14:10
import apps.core.choices.fields
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
import apps.core.choices.fields
class Migration(migrations.Migration):

View File

@@ -0,0 +1,184 @@
# Generated by Django 5.2.9 on 2025-12-27 20:58
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0014_remove_toplist_user_remove_toplistitem_top_list_and_more"),
("pghistory", "0007_auto_20250421_0444"),
]
operations = [
migrations.CreateModel(
name="LoginHistory",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"ip_address",
models.GenericIPAddressField(
blank=True, help_text="IP address from which the login occurred", null=True
),
),
(
"user_agent",
models.CharField(blank=True, help_text="Browser/client user agent string", max_length=500),
),
(
"login_method",
models.CharField(
choices=[
("PASSWORD", "Password"),
("GOOGLE", "Google OAuth"),
("DISCORD", "Discord OAuth"),
("MAGIC_LINK", "Magic Link"),
("SESSION", "Session Refresh"),
],
default="PASSWORD",
help_text="Method used for authentication",
max_length=20,
),
),
(
"login_timestamp",
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When the login occurred"),
),
("success", models.BooleanField(default=True, help_text="Whether the login was successful")),
(
"country",
models.CharField(blank=True, help_text="Country derived from IP (optional)", max_length=100),
),
("city", models.CharField(blank=True, help_text="City derived from IP (optional)", max_length=100)),
(
"user",
models.ForeignKey(
help_text="User who logged in",
on_delete=django.db.models.deletion.CASCADE,
related_name="login_history",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Login History",
"verbose_name_plural": "Login History",
"ordering": ["-login_timestamp"],
},
),
migrations.CreateModel(
name="LoginHistoryEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"ip_address",
models.GenericIPAddressField(
blank=True, help_text="IP address from which the login occurred", null=True
),
),
(
"user_agent",
models.CharField(blank=True, help_text="Browser/client user agent string", max_length=500),
),
(
"login_method",
models.CharField(
choices=[
("PASSWORD", "Password"),
("GOOGLE", "Google OAuth"),
("DISCORD", "Discord OAuth"),
("MAGIC_LINK", "Magic Link"),
("SESSION", "Session Refresh"),
],
default="PASSWORD",
help_text="Method used for authentication",
max_length=20,
),
),
("login_timestamp", models.DateTimeField(auto_now_add=True, help_text="When the login occurred")),
("success", models.BooleanField(default=True, help_text="Whether the login was successful")),
(
"country",
models.CharField(blank=True, help_text="Country derived from IP (optional)", max_length=100),
),
("city", models.CharField(blank=True, help_text="City derived from IP (optional)", max_length=100)),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.loginhistory",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
help_text="User who logged in",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="loginhistory",
index=models.Index(fields=["user", "-login_timestamp"], name="accounts_lo_user_id_156da7_idx"),
),
migrations.AddIndex(
model_name="loginhistory",
index=models.Index(fields=["ip_address"], name="accounts_lo_ip_addr_142937_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="loginhistory",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_loginhistoryevent" ("city", "country", "id", "ip_address", "login_method", "login_timestamp", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "success", "user_agent", "user_id") VALUES (NEW."city", NEW."country", NEW."id", NEW."ip_address", NEW."login_method", NEW."login_timestamp", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."success", NEW."user_agent", NEW."user_id"); RETURN NULL;',
hash="9ccc4d52099a09097d02128eb427d58ae955a377",
operation="INSERT",
pgid="pgtrigger_insert_insert_dc41d",
table="accounts_loginhistory",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="loginhistory",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_loginhistoryevent" ("city", "country", "id", "ip_address", "login_method", "login_timestamp", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "success", "user_agent", "user_id") VALUES (NEW."city", NEW."country", NEW."id", NEW."ip_address", NEW."login_method", NEW."login_timestamp", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."success", NEW."user_agent", NEW."user_id"); RETURN NULL;',
hash="d5d998a5af1a55f181ebe8500a70022e8e4db724",
operation="UPDATE",
pgid="pgtrigger_update_update_110f5",
table="accounts_loginhistory",
when="AFTER",
),
),
),
]

View File

@@ -3,7 +3,7 @@ Mixins for authentication views.
"""
from django.core.exceptions import ValidationError
from apps.core.utils.turnstile import validate_turnstile_token, get_client_ip
from apps.core.utils.turnstile import get_client_ip, validate_turnstile_token
class TurnstileMixin:
@@ -15,30 +15,30 @@ class TurnstileMixin:
def validate_turnstile(self, request):
"""
Validate the Turnstile response token.
The token can be provided as:
- 'cf-turnstile-response' in POST data (form submission)
- 'turnstile_token' in JSON body (API request)
"""
# Try to get token from various sources
token = None
# Check POST data (form submissions)
if hasattr(request, 'POST'):
token = request.POST.get("cf-turnstile-response")
# Check JSON body (API requests)
if not token and hasattr(request, 'data'):
data = getattr(request, 'data', {})
if hasattr(data, 'get'):
token = data.get('turnstile_token') or data.get('cf-turnstile-response')
# Get client IP
ip = get_client_ip(request)
# Validate the token
result = validate_turnstile_token(token, ip)
if not result.get('success'):
error_msg = result.get('error', 'Captcha verification failed. Please try again.')
raise ValidationError(error_msg)

View File

@@ -1,16 +1,18 @@
from django.dispatch import receiver
from django.db.models.signals import post_save
import secrets
from datetime import timedelta
import pghistory
from django.contrib.auth.models import AbstractUser
from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import secrets
from datetime import timedelta
from django.utils import timezone
from apps.core.history import TrackedModel
from apps.core.choices import RichChoiceField
import pghistory
from apps.core.history import TrackedModel
# from django_cloudflareimages_toolkit.models import CloudflareImage
@@ -358,6 +360,9 @@ class EmailVerification(models.Model):
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this verification was created"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="When this verification was last updated"
)
last_sent = models.DateTimeField(
auto_now_add=True, help_text="When the verification email was last sent"
)

View File

@@ -3,11 +3,12 @@ Selectors for user and account-related data retrieval.
Following Django styleguide pattern for separating data access from business logic.
"""
from typing import Dict, Any
from django.db.models import QuerySet, Q, F, Count
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import timedelta
from typing import Any
from django.contrib.auth import get_user_model
from django.db.models import Count, F, Q, QuerySet
from django.utils import timezone
User = get_user_model()
@@ -196,7 +197,7 @@ def users_with_social_accounts() -> QuerySet:
)
def user_statistics_summary() -> Dict[str, Any]:
def user_statistics_summary() -> dict[str, Any]:
"""
Get overall user statistics for dashboard/analytics.

View File

@@ -1,14 +1,16 @@
from rest_framework import serializers
from datetime import timedelta
from typing import cast
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.utils.crypto import get_random_string
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from .models import User, PasswordReset
from django_forwardemail.services import EmailService
from django.template.loader import render_to_string
from typing import cast
from django.utils import timezone
from django.utils.crypto import get_random_string
from django_forwardemail.services import EmailService
from rest_framework import serializers
from .models import PasswordReset, User
UserModel = get_user_model()

View File

@@ -12,7 +12,7 @@ Recent additions:
import logging
import re
from typing import Any, Dict, Optional
from typing import Any
from django.conf import settings
from django.contrib.auth import update_session_auth_hash
@@ -58,7 +58,7 @@ class AccountService:
old_password: str,
new_password: str,
request: HttpRequest,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Change user password with validation and notification.
@@ -146,7 +146,7 @@ class AccountService:
user: User,
new_email: str,
request: HttpRequest,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Initiate email change with verification.
@@ -234,7 +234,7 @@ class AccountService:
logger.error(f"Failed to send email verification: {e}")
@staticmethod
def verify_email_change(*, token: str) -> Dict[str, Any]:
def verify_email_change(*, token: str) -> dict[str, Any]:
"""
Verify email change token and update user email.
@@ -375,35 +375,35 @@ class UserDeletionService:
# Transfer all submissions to deleted user
# Reviews
if hasattr(user, "park_reviews"):
getattr(user, "park_reviews").update(user=deleted_user)
user.park_reviews.update(user=deleted_user)
if hasattr(user, "ride_reviews"):
getattr(user, "ride_reviews").update(user=deleted_user)
user.ride_reviews.update(user=deleted_user)
# Photos
if hasattr(user, "uploaded_park_photos"):
getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user)
user.uploaded_park_photos.update(uploaded_by=deleted_user)
if hasattr(user, "uploaded_ride_photos"):
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user)
user.uploaded_ride_photos.update(uploaded_by=deleted_user)
# Top Lists
if hasattr(user, "top_lists"):
getattr(user, "top_lists").update(user=deleted_user)
user.top_lists.update(user=deleted_user)
# Moderation submissions
if hasattr(user, "edit_submissions"):
getattr(user, "edit_submissions").update(user=deleted_user)
user.edit_submissions.update(user=deleted_user)
if hasattr(user, "photo_submissions"):
getattr(user, "photo_submissions").update(user=deleted_user)
user.photo_submissions.update(user=deleted_user)
# Moderation actions - these can be set to NULL since they're not user content
if hasattr(user, "moderated_park_reviews"):
getattr(user, "moderated_park_reviews").update(moderated_by=None)
user.moderated_park_reviews.update(moderated_by=None)
if hasattr(user, "moderated_ride_reviews"):
getattr(user, "moderated_ride_reviews").update(moderated_by=None)
user.moderated_ride_reviews.update(moderated_by=None)
if hasattr(user, "handled_submissions"):
getattr(user, "handled_submissions").update(handled_by=None)
user.handled_submissions.update(handled_by=None)
if hasattr(user, "handled_photos"):
getattr(user, "handled_photos").update(handled_by=None)
user.handled_photos.update(handled_by=None)
# Store user info for the summary
user_info = {
@@ -426,7 +426,7 @@ class UserDeletionService:
}
@classmethod
def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]:
def can_delete_user(cls, user: User) -> tuple[bool, str | None]:
"""
Check if a user can be safely deleted.

View File

@@ -5,18 +5,19 @@ This service handles the creation, delivery, and management of notifications
for various events including submission approvals/rejections.
"""
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string
from django.conf import settings
from django.db import models
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
import logging
from datetime import datetime, timedelta
from typing import Any
from apps.accounts.models import User, UserNotification, NotificationPreference
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.template.loader import render_to_string
from django.utils import timezone
from django_forwardemail.services import EmailService
from apps.accounts.models import NotificationPreference, User, UserNotification
logger = logging.getLogger(__name__)
@@ -29,10 +30,10 @@ class NotificationService:
notification_type: str,
title: str,
message: str,
related_object: Optional[Any] = None,
related_object: Any | None = None,
priority: str = UserNotification.Priority.NORMAL,
extra_data: Optional[Dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
extra_data: dict[str, Any] | None = None,
expires_at: datetime | None = None,
) -> UserNotification:
"""
Create a new notification for a user.
@@ -273,9 +274,9 @@ class NotificationService:
def get_user_notifications(
user: User,
unread_only: bool = False,
notification_types: Optional[List[str]] = None,
limit: Optional[int] = None,
) -> List[UserNotification]:
notification_types: list[str] | None = None,
limit: int | None = None,
) -> list[UserNotification]:
"""
Get notifications for a user.
@@ -308,7 +309,7 @@ class NotificationService:
@staticmethod
def mark_notifications_read(
user: User, notification_ids: Optional[List[int]] = None
user: User, notification_ids: list[int] | None = None
) -> int:
"""
Mark notifications as read for a user.

View File

@@ -6,13 +6,14 @@ social authentication providers while ensuring users never lock themselves
out of their accounts.
"""
from typing import Dict, List, Tuple, TYPE_CHECKING
from django.contrib.auth import get_user_model
import logging
from typing import TYPE_CHECKING
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers import registry
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpRequest
import logging
if TYPE_CHECKING:
from apps.accounts.models import User
@@ -26,7 +27,7 @@ class SocialProviderService:
"""Service for managing social provider connections."""
@staticmethod
def can_disconnect_provider(user: User, provider: str) -> Tuple[bool, str]:
def can_disconnect_provider(user: User, provider: str) -> tuple[bool, str]:
"""
Check if a user can safely disconnect a social provider.
@@ -69,7 +70,7 @@ class SocialProviderService:
return False, "Unable to verify disconnection safety. Please try again."
@staticmethod
def get_connected_providers(user: "User") -> List[Dict]:
def get_connected_providers(user: "User") -> list[dict]:
"""
Get all social providers connected to a user's account.
@@ -106,7 +107,7 @@ class SocialProviderService:
return []
@staticmethod
def get_available_providers(request: HttpRequest) -> List[Dict]:
def get_available_providers(request: HttpRequest) -> list[dict]:
"""
Get all available social providers for the current site.
@@ -152,7 +153,7 @@ class SocialProviderService:
return []
@staticmethod
def disconnect_provider(user: "User", provider: str) -> Tuple[bool, str]:
def disconnect_provider(user: "User", provider: str) -> tuple[bool, str]:
"""
Disconnect a social provider from a user's account.
@@ -191,7 +192,7 @@ class SocialProviderService:
return False, f"Failed to disconnect {provider} account. Please try again."
@staticmethod
def get_auth_status(user: "User") -> Dict:
def get_auth_status(user: "User") -> dict:
"""
Get comprehensive authentication status for a user.
@@ -231,7 +232,7 @@ class SocialProviderService:
}
@staticmethod
def validate_provider_exists(provider: str) -> Tuple[bool, str]:
def validate_provider_exists(provider: str) -> tuple[bool, str]:
"""
Validate that a social provider is configured and available.

View File

@@ -5,19 +5,18 @@ This service handles user account deletion while preserving submissions
and maintaining data integrity across the platform.
"""
from django.utils import timezone
from django.db import transaction
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.conf import settings
from django.template.loader import render_to_string
from typing import Dict, Any, Tuple, Optional
import logging
import secrets
import string
from datetime import datetime
from typing import Any
from apps.accounts.models import User
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.db import transaction
from django.template.loader import render_to_string
from django.utils import timezone
logger = logging.getLogger(__name__)
@@ -41,7 +40,7 @@ class UserDeletionService:
_deletion_requests = {}
@staticmethod
def can_delete_user(user: User) -> Tuple[bool, Optional[str]]:
def can_delete_user(user: User) -> tuple[bool, str | None]:
"""
Check if a user can be safely deleted.
@@ -104,7 +103,7 @@ class UserDeletionService:
return deletion_request
@staticmethod
def verify_and_delete_user(verification_code: str) -> Dict[str, Any]:
def verify_and_delete_user(verification_code: str) -> dict[str, Any]:
"""
Verify deletion code and delete user account.
@@ -169,7 +168,7 @@ class UserDeletionService:
@staticmethod
@transaction.atomic
def delete_user_preserve_submissions(user: User) -> Dict[str, Any]:
def delete_user_preserve_submissions(user: User) -> dict[str, Any]:
"""
Delete a user account while preserving all their submissions.
@@ -217,7 +216,7 @@ class UserDeletionService:
}
@staticmethod
def _count_user_submissions(user: User) -> Dict[str, int]:
def _count_user_submissions(user: User) -> dict[str, int]:
"""Count all submissions for a user."""
counts = {}

View File

@@ -1,10 +1,13 @@
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
import requests
from django.contrib.auth.models import Group
from django.db import transaction
from django.contrib.auth.signals import user_logged_in
from django.core.files import File
from django.core.files.temp import NamedTemporaryFile
import requests
from django.db import transaction
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from .login_history import LoginHistory
from .models import User, UserProfile
@@ -185,3 +188,41 @@ def create_default_groups():
print(f"Permission not found: {codename}")
except Exception as e:
print(f"Error creating default groups: {str(e)}")
@receiver(user_logged_in)
def log_successful_login(sender, user, request, **kwargs):
"""
Log successful login events to LoginHistory.
This signal handler captures the IP address, user agent, and login method
for auditing and security purposes.
"""
try:
# Get IP address
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
ip_address = x_forwarded_for.split(',')[0].strip() if x_forwarded_for else request.META.get('REMOTE_ADDR')
# Get user agent
user_agent = request.META.get('HTTP_USER_AGENT', '')[:500]
# Determine login method from session or request
login_method = 'PASSWORD'
if hasattr(request, 'session'):
sociallogin = getattr(request, '_sociallogin', None)
if sociallogin:
provider = sociallogin.account.provider.upper()
if provider in ['GOOGLE', 'DISCORD']:
login_method = provider
# Create login history entry
LoginHistory.objects.create(
user=user,
ip_address=ip_address,
user_agent=user_agent,
login_method=login_method,
success=True,
)
except Exception as e:
# Don't let login history failure prevent login
print(f"Error logging login history for user {user.username}: {str(e)}")

View File

@@ -1,7 +1,9 @@
from django.test import TestCase
from unittest.mock import MagicMock, patch
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from unittest.mock import patch, MagicMock
from django.test import TestCase
from .models import User, UserProfile
from .signals import create_default_groups

View File

@@ -6,7 +6,6 @@ password reset, and top list admin classes including query optimization
and custom actions.
"""
import pytest
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase
@@ -20,7 +19,6 @@ from apps.accounts.admin import (
from apps.accounts.models import (
EmailVerification,
PasswordReset,
User,
UserProfile,
)

View File

@@ -7,9 +7,8 @@ These tests verify that:
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.test import TestCase
from django.utils import timezone
from apps.accounts.models import User

View File

@@ -2,10 +2,11 @@
Tests for user deletion while preserving submissions.
"""
from django.test import TestCase
from django.db import transaction
from apps.accounts.services import UserDeletionService
from django.test import TestCase
from apps.accounts.models import User, UserProfile
from apps.accounts.services import UserDeletionService
class UserDeletionServiceTest(TestCase):
@@ -140,13 +141,12 @@ class UserDeletionServiceTest(TestCase):
original_user_count = User.objects.count()
# Mock a failure during the deletion process
with self.assertRaises(Exception):
with transaction.atomic():
# Start the deletion process
UserDeletionService.get_or_create_deleted_user()
with self.assertRaises(Exception), transaction.atomic():
# Start the deletion process
UserDeletionService.get_or_create_deleted_user()
# Simulate an error
raise Exception("Simulated error during deletion")
# Simulate an error
raise Exception("Simulated error during deletion")
# Verify user count hasn't changed
self.assertEqual(User.objects.count(), original_user_count)

View File

@@ -1,6 +1,7 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from allauth.account.views import LogoutView
from django.contrib.auth import views as auth_views
from django.urls import path
from . import views
app_name = "accounts"

View File

@@ -1,41 +1,42 @@
from django.views.generic import DetailView, TemplateView
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404, redirect, render
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.template.loader import render_to_string
from django.utils.crypto import get_random_string
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
from django.db.models import QuerySet
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest
from django.urls import reverse
from django.contrib.auth import login
from django.core.files.uploadedfile import UploadedFile
from apps.accounts.models import (
User,
PasswordReset,
EmailVerification,
UserProfile,
)
from apps.lists.models import UserList
from django_forwardemail.services import EmailService
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
from allauth.account.views import LoginView, SignupView
from .mixins import TurnstileMixin
from typing import Dict, Any, Optional, Union, cast
from django_htmx.http import HttpResponseClientRefresh
from contextlib import suppress
import logging
import re
from contextlib import suppress
from datetime import timedelta
from typing import Any, cast
from apps.core.logging import log_exception, log_security_event
from allauth.account.views import LoginView, SignupView
from django.contrib import messages
from django.contrib.auth import get_user_model, login
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import UploadedFile
from django.db.models import QuerySet
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.views.generic import DetailView, TemplateView
from django_forwardemail.services import EmailService
from django_htmx.http import HttpResponseClientRefresh
from apps.accounts.models import (
EmailVerification,
PasswordReset,
User,
UserProfile,
)
from apps.core.logging import log_security_event
from apps.lists.models import UserList
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
from .mixins import TurnstileMixin
logger = logging.getLogger(__name__)
@@ -184,7 +185,7 @@ class ProfileView(DetailView):
def get_queryset(self) -> QuerySet[User]:
return User.objects.select_related("profile")
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
user = cast(User, self.get_object())
@@ -220,7 +221,7 @@ class ProfileView(DetailView):
class SettingsView(LoginRequiredMixin, TemplateView):
template_name = "accounts/settings.html"
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["user"] = self.request.user
return context
@@ -283,7 +284,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
def _handle_password_change(
self, request: HttpRequest
) -> Optional[HttpResponseRedirect]:
) -> HttpResponseRedirect | None:
user = cast(User, request.user)
old_password = request.POST.get("old_password", "")
new_password = request.POST.get("new_password", "")
@@ -385,7 +386,7 @@ def create_password_reset_token(user: User) -> str:
def send_password_reset_email(
user: User, site: Union[Site, RequestSite], token: str
user: User, site: Site | RequestSite, token: str
) -> None:
reset_url = reverse("password_reset_confirm", kwargs={"token": token})
context = {
@@ -435,7 +436,7 @@ def handle_password_reset(
user: User,
new_password: str,
reset: PasswordReset,
site: Union[Site, RequestSite],
site: Site | RequestSite,
) -> None:
user.set_password(new_password)
user.save()
@@ -457,7 +458,7 @@ def handle_password_reset(
def send_password_reset_confirmation(
user: User, site: Union[Site, RequestSite]
user: User, site: Site | RequestSite
) -> None:
context = {
"user": user,