feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -1,2 +1,2 @@
# Import choices to trigger registration
from .choices import *
from .choices import * # noqa: F403

View File

@@ -77,8 +77,6 @@ class UserProfileInline(admin.StackedInline):
)
@admin.register(User)
class CustomUserAdmin(QueryOptimizationMixin, ExportActionMixin, UserAdmin):
"""
@@ -332,8 +330,9 @@ class CustomUserAdmin(QueryOptimizationMixin, ExportActionMixin, UserAdmin):
try:
profile = user.profile
# Credits would be recalculated from ride history here
profile.save(update_fields=["coaster_credits", "dark_ride_credits",
"flat_ride_credits", "water_ride_credits"])
profile.save(
update_fields=["coaster_credits", "dark_ride_credits", "flat_ride_credits", "water_ride_credits"]
)
count += 1
except UserProfile.DoesNotExist:
pass
@@ -442,12 +441,14 @@ class UserProfileAdmin(QueryOptimizationMixin, ExportActionMixin, BaseModelAdmin
@admin.display(description="Completeness")
def profile_completeness(self, obj):
"""Display profile completeness indicator."""
fields_filled = sum([
bool(obj.display_name),
bool(obj.avatar),
bool(obj.bio),
bool(obj.twitter or obj.instagram or obj.youtube or obj.discord),
])
fields_filled = sum(
[
bool(obj.display_name),
bool(obj.avatar),
bool(obj.bio),
bool(obj.twitter or obj.instagram or obj.youtube or obj.discord),
]
)
percentage = (fields_filled / 4) * 100
color = "green" if percentage >= 75 else "orange" if percentage >= 50 else "red"
return format_html(
@@ -529,12 +530,8 @@ class EmailVerificationAdmin(QueryOptimizationMixin, BaseModelAdmin):
def expiration_status(self, obj):
"""Display expiration status with color coding."""
if timezone.now() - obj.last_sent > timedelta(days=1):
return format_html(
'<span style="color: red; font-weight: bold;">Expired</span>'
)
return format_html(
'<span style="color: green; font-weight: bold;">Valid</span>'
)
return format_html('<span style="color: red; font-weight: bold;">Expired</span>')
return format_html('<span style="color: green; font-weight: bold;">Valid</span>')
@admin.display(description="Can Resend", boolean=True)
def can_resend(self, obj):
@@ -665,6 +662,3 @@ class PasswordResetAdmin(ReadOnlyAdminMixin, BaseModelAdmin):
"Cleanup old tokens",
)
return actions

View File

@@ -26,7 +26,7 @@ user_roles = ChoiceGroup(
"css_class": "text-blue-600 bg-blue-50",
"permissions": ["create_content", "create_reviews", "create_lists"],
"sort_order": 1,
}
},
),
RichChoice(
value="MODERATOR",
@@ -38,7 +38,7 @@ user_roles = ChoiceGroup(
"css_class": "text-green-600 bg-green-50",
"permissions": ["moderate_content", "review_submissions", "manage_reports"],
"sort_order": 2,
}
},
),
RichChoice(
value="ADMIN",
@@ -50,7 +50,7 @@ user_roles = ChoiceGroup(
"css_class": "text-purple-600 bg-purple-50",
"permissions": ["manage_users", "site_configuration", "advanced_moderation"],
"sort_order": 3,
}
},
),
RichChoice(
value="SUPERUSER",
@@ -62,9 +62,9 @@ user_roles = ChoiceGroup(
"css_class": "text-red-600 bg-red-50",
"permissions": ["full_access", "system_administration", "database_access"],
"sort_order": 4,
}
},
),
]
],
)
@@ -83,13 +83,9 @@ theme_preferences = ChoiceGroup(
"color": "yellow",
"icon": "sun",
"css_class": "text-yellow-600 bg-yellow-50",
"preview_colors": {
"background": "#ffffff",
"text": "#1f2937",
"accent": "#3b82f6"
},
"preview_colors": {"background": "#ffffff", "text": "#1f2937", "accent": "#3b82f6"},
"sort_order": 1,
}
},
),
RichChoice(
value="dark",
@@ -99,15 +95,11 @@ theme_preferences = ChoiceGroup(
"color": "gray",
"icon": "moon",
"css_class": "text-gray-600 bg-gray-50",
"preview_colors": {
"background": "#1f2937",
"text": "#f9fafb",
"accent": "#60a5fa"
},
"preview_colors": {"background": "#1f2937", "text": "#f9fafb", "accent": "#60a5fa"},
"sort_order": 2,
}
},
),
]
],
)
@@ -133,7 +125,7 @@ unit_systems = ChoiceGroup(
"large_distance": "km",
},
"sort_order": 1,
}
},
),
RichChoice(
value="imperial",
@@ -150,9 +142,9 @@ unit_systems = ChoiceGroup(
"large_distance": "mi",
},
"sort_order": 2,
}
},
),
]
],
)
@@ -177,10 +169,10 @@ privacy_levels = ChoiceGroup(
"Profile visible to all users",
"Activity appears in public feeds",
"Searchable by search engines",
"Can be found by username search"
"Can be found by username search",
],
"sort_order": 1,
}
},
),
RichChoice(
value="friends",
@@ -196,10 +188,10 @@ privacy_levels = ChoiceGroup(
"Profile visible only to friends",
"Activity hidden from public feeds",
"Not searchable by search engines",
"Requires friend request approval"
"Requires friend request approval",
],
"sort_order": 2,
}
},
),
RichChoice(
value="private",
@@ -215,12 +207,12 @@ privacy_levels = ChoiceGroup(
"Profile completely hidden",
"No activity in any feeds",
"Not discoverable by other users",
"Maximum privacy protection"
"Maximum privacy protection",
],
"sort_order": 3,
}
},
),
]
],
)
@@ -242,7 +234,7 @@ top_list_categories = ChoiceGroup(
"ride_category": "roller_coaster",
"typical_list_size": 10,
"sort_order": 1,
}
},
),
RichChoice(
value="DR",
@@ -255,7 +247,7 @@ top_list_categories = ChoiceGroup(
"ride_category": "dark_ride",
"typical_list_size": 10,
"sort_order": 2,
}
},
),
RichChoice(
value="FR",
@@ -268,7 +260,7 @@ top_list_categories = ChoiceGroup(
"ride_category": "flat_ride",
"typical_list_size": 10,
"sort_order": 3,
}
},
),
RichChoice(
value="WR",
@@ -281,7 +273,7 @@ top_list_categories = ChoiceGroup(
"ride_category": "water_ride",
"typical_list_size": 10,
"sort_order": 4,
}
},
),
RichChoice(
value="PK",
@@ -294,9 +286,9 @@ top_list_categories = ChoiceGroup(
"entity_type": "park",
"typical_list_size": 10,
"sort_order": 5,
}
},
),
]
],
)
@@ -320,7 +312,7 @@ notification_types = ChoiceGroup(
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 1,
}
},
),
RichChoice(
value="submission_rejected",
@@ -334,7 +326,7 @@ notification_types = ChoiceGroup(
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 2,
}
},
),
RichChoice(
value="submission_pending",
@@ -348,7 +340,7 @@ notification_types = ChoiceGroup(
"default_channels": ["inapp"],
"priority": "low",
"sort_order": 3,
}
},
),
# Review related
RichChoice(
@@ -363,7 +355,7 @@ notification_types = ChoiceGroup(
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 4,
}
},
),
RichChoice(
value="review_helpful",
@@ -377,7 +369,7 @@ notification_types = ChoiceGroup(
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 5,
}
},
),
# Social related
RichChoice(
@@ -392,7 +384,7 @@ notification_types = ChoiceGroup(
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 6,
}
},
),
RichChoice(
value="friend_accepted",
@@ -406,7 +398,7 @@ notification_types = ChoiceGroup(
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 7,
}
},
),
RichChoice(
value="message_received",
@@ -420,7 +412,7 @@ notification_types = ChoiceGroup(
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 8,
}
},
),
RichChoice(
value="profile_comment",
@@ -434,7 +426,7 @@ notification_types = ChoiceGroup(
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 9,
}
},
),
# System related
RichChoice(
@@ -449,7 +441,7 @@ notification_types = ChoiceGroup(
"default_channels": ["email", "inapp"],
"priority": "normal",
"sort_order": 10,
}
},
),
RichChoice(
value="account_security",
@@ -463,7 +455,7 @@ notification_types = ChoiceGroup(
"default_channels": ["email", "push", "inapp"],
"priority": "high",
"sort_order": 11,
}
},
),
RichChoice(
value="feature_update",
@@ -477,7 +469,7 @@ notification_types = ChoiceGroup(
"default_channels": ["email", "inapp"],
"priority": "low",
"sort_order": 12,
}
},
),
RichChoice(
value="maintenance",
@@ -491,7 +483,7 @@ notification_types = ChoiceGroup(
"default_channels": ["email", "inapp"],
"priority": "normal",
"sort_order": 13,
}
},
),
# Achievement related
RichChoice(
@@ -506,7 +498,7 @@ notification_types = ChoiceGroup(
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 14,
}
},
),
RichChoice(
value="milestone_reached",
@@ -520,9 +512,9 @@ notification_types = ChoiceGroup(
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 15,
}
},
),
]
],
)
@@ -545,7 +537,7 @@ notification_priorities = ChoiceGroup(
"batch_eligible": True,
"delay_minutes": 60,
"sort_order": 1,
}
},
),
RichChoice(
value="normal",
@@ -559,7 +551,7 @@ notification_priorities = ChoiceGroup(
"batch_eligible": True,
"delay_minutes": 15,
"sort_order": 2,
}
},
),
RichChoice(
value="high",
@@ -573,7 +565,7 @@ notification_priorities = ChoiceGroup(
"batch_eligible": False,
"delay_minutes": 0,
"sort_order": 3,
}
},
),
RichChoice(
value="urgent",
@@ -588,9 +580,9 @@ notification_priorities = ChoiceGroup(
"delay_minutes": 0,
"bypass_preferences": True,
"sort_order": 4,
}
},
),
]
],
)

View File

@@ -53,28 +53,34 @@ class UserExportService:
"dark_ride": profile.dark_ride_credits,
"flat_ride": profile.flat_ride_credits,
"water_ride": profile.water_ride_credits,
}
},
}
# Reviews
park_reviews = list(ParkReview.objects.filter(user=user).values(
"park__name", "rating", "review", "created_at", "updated_at", "is_published"
))
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"
))
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):
items = list(user_list.items.values("order", "content_type__model", "object_id", "comment"))
user_lists.append({
"title": user_list.title,
"description": user_list.description,
"created_at": user_list.created_at,
"items": items
})
user_lists.append(
{
"title": user_list.title,
"description": user_list.description,
"created_at": user_list.created_at,
"items": items,
}
)
export_data = {
"account": user_data,
@@ -85,10 +91,7 @@ class UserExportService:
"ride_reviews": ride_reviews,
"lists": user_lists,
},
"export_info": {
"generated_at": timezone.now(),
"version": "1.0"
}
"export_info": {"generated_at": timezone.now(), "version": "1.0"},
}
return export_data

View File

@@ -99,8 +99,6 @@ class LoginHistory(models.Model):
# 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()
deleted_count, _ = cls.objects.filter(login_timestamp__lt=cutoff).delete()
return deleted_count

View File

@@ -22,20 +22,14 @@ class Command(BaseCommand):
# Check SocialAccount
self.stdout.write("\nChecking SocialAccount table:")
for account in SocialAccount.objects.all():
self.stdout.write(
f"ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}"
)
self.stdout.write(f"ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}")
# Check SocialToken
self.stdout.write("\nChecking SocialToken table:")
for token in SocialToken.objects.all():
self.stdout.write(
f"ID: {token.pk}, Account: {token.account}, App: {token.app}"
)
self.stdout.write(f"ID: {token.pk}, Account: {token.account}, App: {token.app}")
# Check Site
self.stdout.write("\nChecking Site table:")
for site in Site.objects.all():
self.stdout.write(
f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}"
)
self.stdout.write(f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}")

View File

@@ -17,6 +17,4 @@ class Command(BaseCommand):
self.stdout.write(f"Name: {app.name}")
self.stdout.write(f"Client ID: {app.client_id}")
self.stdout.write(f"Secret: {app.secret}")
self.stdout.write(
f"Sites: {', '.join(str(site.domain) for site in app.sites.all())}"
)
self.stdout.write(f"Sites: {', '.join(str(site.domain) for site in app.sites.all())}")

View File

@@ -15,14 +15,9 @@ class Command(BaseCommand):
# Remove migration records
cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'")
cursor.execute(
"DELETE FROM django_migrations WHERE app='accounts' "
"AND name LIKE '%social%'"
)
cursor.execute("DELETE FROM django_migrations WHERE app='accounts' " "AND name LIKE '%social%'")
# Reset sequences
cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'")
self.stdout.write(
self.style.SUCCESS("Successfully cleaned up social auth configuration")
)
self.stdout.write(self.style.SUCCESS("Successfully cleaned up social auth configuration"))

View File

@@ -18,24 +18,18 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
# Delete test reviews
reviews = ParkReview.objects.filter(
user__username__in=["testuser", "moderator"]
)
reviews = ParkReview.objects.filter(user__username__in=["testuser", "moderator"])
count = reviews.count()
reviews.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
# Delete test photos - both park and ride photos
park_photos = ParkPhoto.objects.filter(
uploader__username__in=["testuser", "moderator"]
)
park_photos = ParkPhoto.objects.filter(uploader__username__in=["testuser", "moderator"])
park_count = park_photos.count()
park_photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {park_count} test park photos"))
ride_photos = RidePhoto.objects.filter(
uploader__username__in=["testuser", "moderator"]
)
ride_photos = RidePhoto.objects.filter(uploader__username__in=["testuser", "moderator"])
ride_count = ride_photos.count()
ride_photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {ride_count} test ride photos"))

View File

@@ -37,18 +37,12 @@ class Command(BaseCommand):
provider="google",
defaults={
"name": "Google",
"client_id": (
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
"apps.googleusercontent.com"
),
"client_id": ("135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2." "apps.googleusercontent.com"),
"secret": "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue",
},
)
if not created:
google_app.client_id = (
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
"apps.googleusercontent.com"
)
google_app.client_id = "135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2." "apps.googleusercontent.com"
google_app.secret = "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue"
google_app.save()
google_app.sites.add(site)

View File

@@ -14,9 +14,7 @@ class Command(BaseCommand):
)
user.set_password("testpass123")
user.save()
self.stdout.write(
self.style.SUCCESS(f"Created test user: {user.get_username()}")
)
self.stdout.write(self.style.SUCCESS(f"Created test user: {user.get_username()}"))
else:
self.stdout.write(self.style.WARNING("Test user already exists"))
@@ -47,11 +45,7 @@ class Command(BaseCommand):
# Add user to moderator group
moderator.groups.add(moderator_group)
self.stdout.write(
self.style.SUCCESS(
f"Created moderator user: {moderator.get_username()}"
)
)
self.stdout.write(self.style.SUCCESS(f"Created moderator user: {moderator.get_username()}"))
else:
self.stdout.write(self.style.WARNING("Moderator user already exists"))

View File

@@ -17,9 +17,7 @@ class Command(BaseCommand):
help = "Delete a user while preserving all their submissions"
def add_arguments(self, parser):
parser.add_argument(
"username", nargs="?", type=str, help="Username of the user to delete"
)
parser.add_argument("username", nargs="?", type=str, help="Username of the user to delete")
parser.add_argument(
"--user-id",
type=str,
@@ -30,9 +28,7 @@ class Command(BaseCommand):
action="store_true",
help="Show what would be deleted without actually deleting",
)
parser.add_argument(
"--force", action="store_true", help="Skip confirmation prompt"
)
parser.add_argument("--force", action="store_true", help="Skip confirmation prompt")
def handle(self, *args, **options):
username = options.get("username")
@@ -52,7 +48,7 @@ class Command(BaseCommand):
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')
raise CommandError(f'User "{identifier}" does not exist') from None
# Check if user can be deleted
can_delete, reason = UserDeletionService.can_delete_user(user)
@@ -61,27 +57,13 @@ class Command(BaseCommand):
# Count submissions
submission_counts = {
"park_reviews": getattr(
user, "park_reviews", user.__class__.objects.none()
).count(),
"ride_reviews": getattr(
user, "ride_reviews", user.__class__.objects.none()
).count(),
"uploaded_park_photos": getattr(
user, "uploaded_park_photos", user.__class__.objects.none()
).count(),
"uploaded_ride_photos": getattr(
user, "uploaded_ride_photos", user.__class__.objects.none()
).count(),
"top_lists": getattr(
user, "top_lists", user.__class__.objects.none()
).count(),
"edit_submissions": getattr(
user, "edit_submissions", user.__class__.objects.none()
).count(),
"photo_submissions": getattr(
user, "photo_submissions", user.__class__.objects.none()
).count(),
"park_reviews": getattr(user, "park_reviews", user.__class__.objects.none()).count(),
"ride_reviews": getattr(user, "ride_reviews", user.__class__.objects.none()).count(),
"uploaded_park_photos": getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count(),
"uploaded_ride_photos": getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count(),
"top_lists": getattr(user, "top_lists", user.__class__.objects.none()).count(),
"edit_submissions": getattr(user, "edit_submissions", user.__class__.objects.none()).count(),
"photo_submissions": getattr(user, "photo_submissions", user.__class__.objects.none()).count(),
}
total_submissions = sum(submission_counts.values())
@@ -98,9 +80,7 @@ class Command(BaseCommand):
self.stdout.write(self.style.WARNING("\nSubmissions to preserve:"))
for submission_type, count in submission_counts.items():
if count > 0:
self.stdout.write(
f' {submission_type.replace("_", " ").title()}: {count}'
)
self.stdout.write(f' {submission_type.replace("_", " ").title()}: {count}')
self.stdout.write(f"\nTotal submissions: {total_submissions}")
@@ -111,9 +91,7 @@ class Command(BaseCommand):
)
)
else:
self.stdout.write(
self.style.WARNING("\nNo submissions found for this user.")
)
self.stdout.write(self.style.WARNING("\nNo submissions found for this user."))
if dry_run:
self.stdout.write(self.style.SUCCESS("\n[DRY RUN] No changes were made."))
@@ -136,11 +114,7 @@ class Command(BaseCommand):
try:
result = UserDeletionService.delete_user_preserve_submissions(user)
self.stdout.write(
self.style.SUCCESS(
f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'
)
)
self.stdout.write(self.style.SUCCESS(f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'))
preserved_count = sum(result["preserved_submissions"].values())
if preserved_count > 0:
@@ -154,9 +128,7 @@ class Command(BaseCommand):
self.stdout.write(self.style.WARNING("\nPreservation Summary:"))
for submission_type, count in result["preserved_submissions"].items():
if count > 0:
self.stdout.write(
f' {submission_type.replace("_", " ").title()}: {count}'
)
self.stdout.write(f' {submission_type.replace("_", " ").title()}: {count}')
except Exception as e:
raise CommandError(f"Error deleting user: {str(e)}")
raise CommandError(f"Error deleting user: {str(e)}") from None

View File

@@ -7,12 +7,5 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
with connection.cursor() as cursor:
cursor.execute(
"DELETE FROM django_migrations WHERE app='rides' "
"AND name='0001_initial';"
)
self.stdout.write(
self.style.SUCCESS(
"Successfully removed rides.0001_initial from migration history"
)
)
cursor.execute("DELETE FROM django_migrations WHERE app='rides' " "AND name='0001_initial';")
self.stdout.write(self.style.SUCCESS("Successfully removed rides.0001_initial from migration history"))

View File

@@ -34,6 +34,4 @@ class Command(BaseCommand):
secret=os.getenv("DISCORD_CLIENT_SECRET"),
)
discord_app.sites.add(site)
self.stdout.write(
f"Created Discord app with client_id: {discord_app.client_id}"
)
self.stdout.write(f"Created Discord app with client_id: {discord_app.client_id}")

View File

@@ -47,9 +47,7 @@ class Command(BaseCommand):
help = "Generate avatars for letters A-Z and numbers 0-9"
def handle(self, *args, **kwargs):
characters = [chr(i) for i in range(65, 91)] + [
str(i) for i in range(10)
] # A-Z and 0-9
characters = [chr(i) for i in range(65, 91)] + [str(i) for i in range(10)] # A-Z and 0-9
for char in characters:
generate_avatar(char)
self.stdout.write(self.style.SUCCESS(f"Generated avatar for {char}"))

View File

@@ -11,6 +11,4 @@ class Command(BaseCommand):
for profile in profiles:
# This will trigger the avatar generation logic in the save method
profile.save()
self.stdout.write(
self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}")
)
self.stdout.write(self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}"))

View File

@@ -69,18 +69,18 @@ class Command(BaseCommand):
# 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',
username="admin",
email="admin@thrillwiki.com",
password="admin",
role="SUPERUSER",
)
# Create profile using ORM
UserProfile.objects.create(
user=user,
display_name='Admin',
pronouns='they/them',
bio='ThrillWiki Administrator',
display_name="Admin",
pronouns="they/them",
bio="ThrillWiki Administrator",
)
self.stdout.write("Superuser created.")

View File

@@ -30,9 +30,7 @@ class Command(BaseCommand):
google_app = SocialApp.objects.create(
provider="google",
name="Google",
client_id=(
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com"
),
client_id=("135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com"),
secret="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",
)
google_app.sites.add(site)

View File

@@ -12,13 +12,7 @@ class Command(BaseCommand):
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
# Reset sequences
cursor.execute(
"DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'"
)
cursor.execute(
"DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'"
)
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'")
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'")
self.stdout.write(
self.style.SUCCESS("Successfully reset social auth configuration")
)
self.stdout.write(self.style.SUCCESS("Successfully reset social auth configuration"))

View File

@@ -30,9 +30,7 @@ class Command(BaseCommand):
user.is_staff = True
user.save()
self.stdout.write(
self.style.SUCCESS("Successfully set up groups and permissions")
)
self.stdout.write(self.style.SUCCESS("Successfully set up groups and permissions"))
# Print summary
for group in Group.objects.all():

View File

@@ -10,7 +10,5 @@ class Command(BaseCommand):
Site.objects.all().delete()
# Create default site
site = Site.objects.create(
id=1, domain="localhost:8000", name="ThrillWiki Development"
)
site = Site.objects.create(id=1, domain="localhost:8000", name="ThrillWiki Development")
self.stdout.write(self.style.SUCCESS(f"Created site: {site.domain}"))

View File

@@ -49,27 +49,15 @@ class Command(BaseCommand):
discord_client_secret,
]
):
self.stdout.write(
self.style.ERROR("Missing required environment variables")
)
self.stdout.write(
f"DEBUG: google_client_id is None: {google_client_id is None}"
)
self.stdout.write(
f"DEBUG: google_client_secret is None: {google_client_secret is None}"
)
self.stdout.write(
f"DEBUG: discord_client_id is None: {discord_client_id is None}"
)
self.stdout.write(
f"DEBUG: discord_client_secret is None: {discord_client_secret is None}"
)
self.stdout.write(self.style.ERROR("Missing required environment variables"))
self.stdout.write(f"DEBUG: google_client_id is None: {google_client_id is None}")
self.stdout.write(f"DEBUG: google_client_secret is None: {google_client_secret is None}")
self.stdout.write(f"DEBUG: discord_client_id is None: {discord_client_id is None}")
self.stdout.write(f"DEBUG: discord_client_secret is None: {discord_client_secret is None}")
return
# Get or create the default site
site, _ = Site.objects.get_or_create(
id=1, defaults={"domain": "localhost:8000", "name": "localhost"}
)
site, _ = Site.objects.get_or_create(id=1, defaults={"domain": "localhost:8000", "name": "localhost"})
# Set up Google
google_app, created = SocialApp.objects.get_or_create(
@@ -92,11 +80,7 @@ class Command(BaseCommand):
google_app.save()
self.stdout.write("DEBUG: Successfully updated Google app")
else:
self.stdout.write(
self.style.ERROR(
"Google client_id or secret is None, skipping update."
)
)
self.stdout.write(self.style.ERROR("Google client_id or secret is None, skipping update."))
google_app.sites.add(site)
# Set up Discord
@@ -120,11 +104,7 @@ class Command(BaseCommand):
discord_app.save()
self.stdout.write("DEBUG: Successfully updated Discord app")
else:
self.stdout.write(
self.style.ERROR(
"Discord client_id or secret is None, skipping update."
)
)
self.stdout.write(self.style.ERROR("Discord client_id or secret is None, skipping update."))
discord_app.sites.add(site)
self.stdout.write(self.style.SUCCESS("Successfully set up social auth apps"))

View File

@@ -42,6 +42,4 @@ class Command(BaseCommand):
for app in SocialApp.objects.all():
self.stdout.write(f"- {app.name} ({app.provider}): {app.client_id}")
self.stdout.write(
self.style.SUCCESS(f"\nTotal social apps: {SocialApp.objects.count()}")
)
self.stdout.write(self.style.SUCCESS(f"\nTotal social apps: {SocialApp.objects.count()}"))

View File

@@ -40,9 +40,7 @@ class Command(BaseCommand):
# Show callback URL
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
self.stdout.write(
"\nCallback URL to configure in Discord Developer Portal:"
)
self.stdout.write("\nCallback URL to configure in Discord Developer Portal:")
self.stdout.write(callback_url)
# Show frontend login URL

View File

@@ -18,6 +18,4 @@ class Command(BaseCommand):
# Add all sites
for site in sites:
app.sites.add(site)
self.stdout.write(
f"Added sites: {', '.join(site.domain for site in sites)}"
)
self.stdout.write(f"Added sites: {', '.join(site.domain for site in sites)}")

View File

@@ -22,17 +22,13 @@ class Command(BaseCommand):
# Show callback URL
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
self.stdout.write(
"\nCallback URL to configure in Discord Developer Portal:"
)
self.stdout.write("\nCallback URL to configure in Discord Developer Portal:")
self.stdout.write(callback_url)
# Show OAuth2 settings
self.stdout.write("\nOAuth2 settings in settings.py:")
discord_settings = settings.SOCIALACCOUNT_PROVIDERS.get("discord", {})
self.stdout.write(
f"PKCE Enabled: {discord_settings.get('OAUTH_PKCE_ENABLED', False)}"
)
self.stdout.write(f"PKCE Enabled: {discord_settings.get('OAUTH_PKCE_ENABLED', False)}")
self.stdout.write(f"Scopes: {discord_settings.get('SCOPE', [])}")
except SocialApp.DoesNotExist:

View File

@@ -38,9 +38,7 @@ class Migration(migrations.Migration):
),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
models.DateTimeField(blank=True, null=True, verbose_name="last login"),
),
(
"is_superuser",
@@ -53,29 +51,21 @@ class Migration(migrations.Migration):
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
error_messages={"unique": "A user with that username already exists."},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
models.CharField(blank=True, max_length=150, verbose_name="first name"),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
models.CharField(blank=True, max_length=150, verbose_name="last name"),
),
(
"email",

View File

@@ -57,9 +57,7 @@ class Migration(migrations.Migration):
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
models.DateTimeField(blank=True, null=True, verbose_name="last login"),
),
(
"is_superuser",
@@ -72,34 +70,24 @@ class Migration(migrations.Migration):
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
error_messages={"unique": "A user with that username already exists."},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
models.CharField(blank=True, max_length=150, verbose_name="first name"),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
models.CharField(blank=True, max_length=150, verbose_name="last name"),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
models.EmailField(blank=True, max_length=254, verbose_name="email address"),
),
(
"is_staff",
@@ -119,9 +107,7 @@ class Migration(migrations.Migration):
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined"),
),
(
"user_id",

View File

@@ -41,9 +41,7 @@ class Migration(migrations.Migration):
("created_at", models.DateTimeField(auto_now_add=True)),
(
"expires_at",
models.DateTimeField(
help_text="When this deletion request expires"
),
models.DateTimeField(help_text="When this deletion request expires"),
),
(
"email_sent_at",
@@ -55,9 +53,7 @@ class Migration(migrations.Migration):
),
(
"attempts",
models.PositiveIntegerField(
default=0, help_text="Number of verification attempts made"
),
models.PositiveIntegerField(default=0, help_text="Number of verification attempts made"),
),
(
"max_attempts",
@@ -103,9 +99,7 @@ class Migration(migrations.Migration):
("created_at", models.DateTimeField(auto_now_add=True)),
(
"expires_at",
models.DateTimeField(
help_text="When this deletion request expires"
),
models.DateTimeField(help_text="When this deletion request expires"),
),
(
"email_sent_at",
@@ -117,9 +111,7 @@ class Migration(migrations.Migration):
),
(
"attempts",
models.PositiveIntegerField(
default=0, help_text="Number of verification attempts made"
),
models.PositiveIntegerField(default=0, help_text="Number of verification attempts made"),
),
(
"max_attempts",
@@ -171,21 +163,15 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="userdeletionrequest",
index=models.Index(
fields=["verification_code"], name="accounts_us_verific_94460d_idx"
),
index=models.Index(fields=["verification_code"], name="accounts_us_verific_94460d_idx"),
),
migrations.AddIndex(
model_name="userdeletionrequest",
index=models.Index(
fields=["expires_at"], name="accounts_us_expires_1d1dca_idx"
),
index=models.Index(fields=["expires_at"], name="accounts_us_expires_1d1dca_idx"),
),
migrations.AddIndex(
model_name="userdeletionrequest",
index=models.Index(
fields=["user", "is_used"], name="accounts_us_user_id_1ce18a_idx"
),
index=models.Index(fields=["user", "is_used"], name="accounts_us_user_id_1ce18a_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="userdeletionrequest",

View File

@@ -57,9 +57,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="user",
name="last_password_change",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
@@ -185,9 +183,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="userevent",
name="last_password_change",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(

View File

@@ -454,9 +454,7 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="usernotification",
index=models.Index(
fields=["user", "is_read"], name="accounts_us_user_id_785929_idx"
),
index=models.Index(fields=["user", "is_read"], name="accounts_us_user_id_785929_idx"),
),
migrations.AddIndex(
model_name="usernotification",
@@ -467,15 +465,11 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="usernotification",
index=models.Index(
fields=["created_at"], name="accounts_us_created_a62f54_idx"
),
index=models.Index(fields=["created_at"], name="accounts_us_created_a62f54_idx"),
),
migrations.AddIndex(
model_name="usernotification",
index=models.Index(
fields=["expires_at"], name="accounts_us_expires_f267b1_idx"
),
index=models.Index(fields=["expires_at"], name="accounts_us_expires_f267b1_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="usernotification",

View File

@@ -26,25 +26,24 @@ def safe_add_avatar_field(apps, schema_editor):
"""
# Check if the column already exists
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
cursor.execute(
"""
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')
UserProfile = apps.get_model("accounts", "UserProfile")
field = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.SET_NULL,
null=True,
blank=True
"django_cloudflareimages_toolkit.CloudflareImage", on_delete=models.SET_NULL, null=True, blank=True
)
field.set_attributes_from_name('avatar')
field.set_attributes_from_name("avatar")
schema_editor.add_field(UserProfile, field)
@@ -54,24 +53,23 @@ 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("""
cursor.execute(
"""
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')
UserProfile = apps.get_model("accounts", "UserProfile")
field = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.SET_NULL,
null=True,
blank=True
"django_cloudflareimages_toolkit.CloudflareImage", on_delete=models.SET_NULL, null=True, blank=True
)
field.set_attributes_from_name('avatar')
field.set_attributes_from_name("avatar")
schema_editor.remove_field(UserProfile, field)
@@ -89,15 +87,13 @@ class Migration(migrations.Migration):
# First, remove the old avatar column (CloudflareImageField)
migrations.RunSQL(
"ALTER TABLE accounts_userprofile DROP COLUMN IF EXISTS avatar;",
reverse_sql="-- Cannot reverse this operation"
reverse_sql="-- Cannot reverse this operation",
),
# Safely add the new avatar_id column for ForeignKey
migrations.RunPython(
safe_add_avatar_field,
reverse_safe_add_avatar_field,
),
# Run the data migration
migrations.RunPython(
migrate_avatar_data,

View File

@@ -6,17 +6,16 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0010_auto_20250830_1657'),
('django_cloudflareimages_toolkit', '0001_initial'),
("accounts", "0010_auto_20250830_1657"),
("django_cloudflareimages_toolkit", "0001_initial"),
]
operations = [
# Remove the old avatar field from the event table
migrations.RunSQL(
"ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar;",
reverse_sql="-- Cannot reverse this operation"
reverse_sql="-- Cannot reverse this operation",
),
# Add the new avatar_id field to match the main table (only if it doesn't exist)
migrations.RunSQL(
"""
@@ -32,6 +31,6 @@ class Migration(migrations.Migration):
END IF;
END $$;
""",
reverse_sql="ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar_id;"
reverse_sql="ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar_id;",
),
]

View File

@@ -13,28 +13,28 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0012_alter_toplist_category_and_more'),
("accounts", "0012_alter_toplist_category_and_more"),
]
operations = [
# Add db_index to is_banned field
migrations.AlterField(
model_name='user',
name='is_banned',
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'),
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',
model_name="user",
constraint=models.CheckConstraint(
name='user_ban_consistency',
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'
violation_error_message="Banned users must have a ban_date set",
),
),
]

View File

@@ -18,7 +18,6 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AlterModelOptions(
name="user",
options={"verbose_name": "User", "verbose_name_plural": "Users"},
@@ -58,9 +57,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="userprofile",
name="location",
field=models.CharField(
blank=True, help_text="User's location (City, Country)", max_length=100
),
field=models.CharField(blank=True, help_text="User's location (City, Country)", max_length=100),
),
migrations.AddField(
model_name="userprofile",
@@ -78,9 +75,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="userprofileevent",
name="location",
field=models.CharField(
blank=True, help_text="User's location (City, Country)", max_length=100
),
field=models.CharField(blank=True, help_text="User's location (City, Country)", max_length=100),
),
migrations.AddField(
model_name="userprofileevent",
@@ -98,23 +93,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="emailverification",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this verification was created"
),
field=models.DateTimeField(auto_now_add=True, help_text="When this verification was created"),
),
migrations.AlterField(
model_name="emailverification",
name="last_sent",
field=models.DateTimeField(
auto_now_add=True, help_text="When the verification email was last sent"
),
field=models.DateTimeField(auto_now_add=True, help_text="When the verification email was last sent"),
),
migrations.AlterField(
model_name="emailverification",
name="token",
field=models.CharField(
help_text="Verification token", max_length=64, unique=True
),
field=models.CharField(help_text="Verification token", max_length=64, unique=True),
),
migrations.AlterField(
model_name="emailverification",
@@ -128,16 +117,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="emailverificationevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this verification was created"
),
field=models.DateTimeField(auto_now_add=True, help_text="When this verification was created"),
),
migrations.AlterField(
model_name="emailverificationevent",
name="last_sent",
field=models.DateTimeField(
auto_now_add=True, help_text="When the verification email was last sent"
),
field=models.DateTimeField(auto_now_add=True, help_text="When the verification email was last sent"),
),
migrations.AlterField(
model_name="emailverificationevent",
@@ -181,9 +166,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="passwordreset",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this reset was requested"
),
field=models.DateTimeField(auto_now_add=True, help_text="When this reset was requested"),
),
migrations.AlterField(
model_name="passwordreset",
@@ -198,9 +181,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="passwordreset",
name="used",
field=models.BooleanField(
default=False, help_text="Whether this token has been used"
),
field=models.BooleanField(default=False, help_text="Whether this token has been used"),
),
migrations.AlterField(
model_name="passwordreset",
@@ -214,9 +195,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="passwordresetevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this reset was requested"
),
field=models.DateTimeField(auto_now_add=True, help_text="When this reset was requested"),
),
migrations.AlterField(
model_name="passwordresetevent",
@@ -231,9 +210,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="passwordresetevent",
name="used",
field=models.BooleanField(
default=False, help_text="Whether this token has been used"
),
field=models.BooleanField(default=False, help_text="Whether this token has been used"),
),
migrations.AlterField(
model_name="passwordresetevent",
@@ -267,30 +244,22 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="user",
name="allow_friend_requests",
field=models.BooleanField(
default=True, help_text="Whether to allow friend requests"
),
field=models.BooleanField(default=True, help_text="Whether to allow friend requests"),
),
migrations.AlterField(
model_name="user",
name="allow_messages",
field=models.BooleanField(
default=True, help_text="Whether to allow direct messages"
),
field=models.BooleanField(default=True, help_text="Whether to allow direct messages"),
),
migrations.AlterField(
model_name="user",
name="allow_profile_comments",
field=models.BooleanField(
default=False, help_text="Whether to allow profile comments"
),
field=models.BooleanField(default=False, help_text="Whether to allow profile comments"),
),
migrations.AlterField(
model_name="user",
name="ban_date",
field=models.DateTimeField(
blank=True, help_text="Date the user was banned", null=True
),
field=models.DateTimeField(blank=True, help_text="Date the user was banned", null=True),
),
migrations.AlterField(
model_name="user",
@@ -300,37 +269,27 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="user",
name="email_notifications",
field=models.BooleanField(
default=True, help_text="Whether to send email notifications"
),
field=models.BooleanField(default=True, help_text="Whether to send email notifications"),
),
migrations.AlterField(
model_name="user",
name="is_banned",
field=models.BooleanField(
db_index=True, default=False, help_text="Whether this user is banned"
),
field=models.BooleanField(db_index=True, default=False, help_text="Whether this user is banned"),
),
migrations.AlterField(
model_name="user",
name="last_password_change",
field=models.DateTimeField(
auto_now_add=True, help_text="When the password was last changed"
),
field=models.DateTimeField(auto_now_add=True, help_text="When the password was last changed"),
),
migrations.AlterField(
model_name="user",
name="login_history_retention",
field=models.IntegerField(
default=90, help_text="How long to retain login history (days)"
),
field=models.IntegerField(default=90, help_text="How long to retain login history (days)"),
),
migrations.AlterField(
model_name="user",
name="login_notifications",
field=models.BooleanField(
default=True, help_text="Whether to send login notifications"
),
field=models.BooleanField(default=True, help_text="Whether to send login notifications"),
),
migrations.AlterField(
model_name="user",
@@ -352,9 +311,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="user",
name="push_notifications",
field=models.BooleanField(
default=False, help_text="Whether to send push notifications"
),
field=models.BooleanField(default=False, help_text="Whether to send push notifications"),
),
migrations.AlterField(
model_name="user",
@@ -378,9 +335,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="user",
name="search_visibility",
field=models.BooleanField(
default=True, help_text="Whether profile appears in search results"
),
field=models.BooleanField(default=True, help_text="Whether profile appears in search results"),
),
migrations.AlterField(
model_name="user",
@@ -390,51 +345,37 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="user",
name="show_email",
field=models.BooleanField(
default=False, help_text="Whether to show email on profile"
),
field=models.BooleanField(default=False, help_text="Whether to show email on profile"),
),
migrations.AlterField(
model_name="user",
name="show_join_date",
field=models.BooleanField(
default=True, help_text="Whether to show join date on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show join date on profile"),
),
migrations.AlterField(
model_name="user",
name="show_photos",
field=models.BooleanField(
default=True, help_text="Whether to show photos on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show photos on profile"),
),
migrations.AlterField(
model_name="user",
name="show_real_name",
field=models.BooleanField(
default=True, help_text="Whether to show real name on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show real name on profile"),
),
migrations.AlterField(
model_name="user",
name="show_reviews",
field=models.BooleanField(
default=True, help_text="Whether to show reviews on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show reviews on profile"),
),
migrations.AlterField(
model_name="user",
name="show_statistics",
field=models.BooleanField(
default=True, help_text="Whether to show statistics on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show statistics on profile"),
),
migrations.AlterField(
model_name="user",
name="show_top_lists",
field=models.BooleanField(
default=True, help_text="Whether to show top lists on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show top lists on profile"),
),
migrations.AlterField(
model_name="user",
@@ -452,9 +393,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="user",
name="two_factor_enabled",
field=models.BooleanField(
default=False, help_text="Whether two-factor authentication is enabled"
),
field=models.BooleanField(default=False, help_text="Whether two-factor authentication is enabled"),
),
migrations.AlterField(
model_name="userevent",
@@ -476,30 +415,22 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userevent",
name="allow_friend_requests",
field=models.BooleanField(
default=True, help_text="Whether to allow friend requests"
),
field=models.BooleanField(default=True, help_text="Whether to allow friend requests"),
),
migrations.AlterField(
model_name="userevent",
name="allow_messages",
field=models.BooleanField(
default=True, help_text="Whether to allow direct messages"
),
field=models.BooleanField(default=True, help_text="Whether to allow direct messages"),
),
migrations.AlterField(
model_name="userevent",
name="allow_profile_comments",
field=models.BooleanField(
default=False, help_text="Whether to allow profile comments"
),
field=models.BooleanField(default=False, help_text="Whether to allow profile comments"),
),
migrations.AlterField(
model_name="userevent",
name="ban_date",
field=models.DateTimeField(
blank=True, help_text="Date the user was banned", null=True
),
field=models.DateTimeField(blank=True, help_text="Date the user was banned", null=True),
),
migrations.AlterField(
model_name="userevent",
@@ -509,37 +440,27 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userevent",
name="email_notifications",
field=models.BooleanField(
default=True, help_text="Whether to send email notifications"
),
field=models.BooleanField(default=True, help_text="Whether to send email notifications"),
),
migrations.AlterField(
model_name="userevent",
name="is_banned",
field=models.BooleanField(
default=False, help_text="Whether this user is banned"
),
field=models.BooleanField(default=False, help_text="Whether this user is banned"),
),
migrations.AlterField(
model_name="userevent",
name="last_password_change",
field=models.DateTimeField(
auto_now_add=True, help_text="When the password was last changed"
),
field=models.DateTimeField(auto_now_add=True, help_text="When the password was last changed"),
),
migrations.AlterField(
model_name="userevent",
name="login_history_retention",
field=models.IntegerField(
default=90, help_text="How long to retain login history (days)"
),
field=models.IntegerField(default=90, help_text="How long to retain login history (days)"),
),
migrations.AlterField(
model_name="userevent",
name="login_notifications",
field=models.BooleanField(
default=True, help_text="Whether to send login notifications"
),
field=models.BooleanField(default=True, help_text="Whether to send login notifications"),
),
migrations.AlterField(
model_name="userevent",
@@ -561,9 +482,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userevent",
name="push_notifications",
field=models.BooleanField(
default=False, help_text="Whether to send push notifications"
),
field=models.BooleanField(default=False, help_text="Whether to send push notifications"),
),
migrations.AlterField(
model_name="userevent",
@@ -586,9 +505,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userevent",
name="search_visibility",
field=models.BooleanField(
default=True, help_text="Whether profile appears in search results"
),
field=models.BooleanField(default=True, help_text="Whether profile appears in search results"),
),
migrations.AlterField(
model_name="userevent",
@@ -598,51 +515,37 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userevent",
name="show_email",
field=models.BooleanField(
default=False, help_text="Whether to show email on profile"
),
field=models.BooleanField(default=False, help_text="Whether to show email on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_join_date",
field=models.BooleanField(
default=True, help_text="Whether to show join date on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show join date on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_photos",
field=models.BooleanField(
default=True, help_text="Whether to show photos on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show photos on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_real_name",
field=models.BooleanField(
default=True, help_text="Whether to show real name on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show real name on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_reviews",
field=models.BooleanField(
default=True, help_text="Whether to show reviews on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show reviews on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_statistics",
field=models.BooleanField(
default=True, help_text="Whether to show statistics on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show statistics on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_top_lists",
field=models.BooleanField(
default=True, help_text="Whether to show top lists on profile"
),
field=models.BooleanField(default=True, help_text="Whether to show top lists on profile"),
),
migrations.AlterField(
model_name="userevent",
@@ -660,9 +563,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userevent",
name="two_factor_enabled",
field=models.BooleanField(
default=False, help_text="Whether two-factor authentication is enabled"
),
field=models.BooleanField(default=False, help_text="Whether two-factor authentication is enabled"),
),
migrations.AlterField(
model_name="usernotification",
@@ -678,23 +579,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="usernotification",
name="email_sent",
field=models.BooleanField(
default=False, help_text="Whether email was sent"
),
field=models.BooleanField(default=False, help_text="Whether email was sent"),
),
migrations.AlterField(
model_name="usernotification",
name="email_sent_at",
field=models.DateTimeField(
blank=True, help_text="When email was sent", null=True
),
field=models.DateTimeField(blank=True, help_text="When email was sent", null=True),
),
migrations.AlterField(
model_name="usernotification",
name="is_read",
field=models.BooleanField(
default=False, help_text="Whether this notification has been read"
),
field=models.BooleanField(default=False, help_text="Whether this notification has been read"),
),
migrations.AlterField(
model_name="usernotification",
@@ -704,30 +599,22 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="usernotification",
name="object_id",
field=models.PositiveIntegerField(
blank=True, help_text="ID of related object", null=True
),
field=models.PositiveIntegerField(blank=True, help_text="ID of related object", null=True),
),
migrations.AlterField(
model_name="usernotification",
name="push_sent",
field=models.BooleanField(
default=False, help_text="Whether push notification was sent"
),
field=models.BooleanField(default=False, help_text="Whether push notification was sent"),
),
migrations.AlterField(
model_name="usernotification",
name="push_sent_at",
field=models.DateTimeField(
blank=True, help_text="When push notification was sent", null=True
),
field=models.DateTimeField(blank=True, help_text="When push notification was sent", null=True),
),
migrations.AlterField(
model_name="usernotification",
name="read_at",
field=models.DateTimeField(
blank=True, help_text="When this notification was read", null=True
),
field=models.DateTimeField(blank=True, help_text="When this notification was read", null=True),
),
migrations.AlterField(
model_name="usernotification",
@@ -761,23 +648,17 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="usernotificationevent",
name="email_sent",
field=models.BooleanField(
default=False, help_text="Whether email was sent"
),
field=models.BooleanField(default=False, help_text="Whether email was sent"),
),
migrations.AlterField(
model_name="usernotificationevent",
name="email_sent_at",
field=models.DateTimeField(
blank=True, help_text="When email was sent", null=True
),
field=models.DateTimeField(blank=True, help_text="When email was sent", null=True),
),
migrations.AlterField(
model_name="usernotificationevent",
name="is_read",
field=models.BooleanField(
default=False, help_text="Whether this notification has been read"
),
field=models.BooleanField(default=False, help_text="Whether this notification has been read"),
),
migrations.AlterField(
model_name="usernotificationevent",
@@ -787,30 +668,22 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="usernotificationevent",
name="object_id",
field=models.PositiveIntegerField(
blank=True, help_text="ID of related object", null=True
),
field=models.PositiveIntegerField(blank=True, help_text="ID of related object", null=True),
),
migrations.AlterField(
model_name="usernotificationevent",
name="push_sent",
field=models.BooleanField(
default=False, help_text="Whether push notification was sent"
),
field=models.BooleanField(default=False, help_text="Whether push notification was sent"),
),
migrations.AlterField(
model_name="usernotificationevent",
name="push_sent_at",
field=models.DateTimeField(
blank=True, help_text="When push notification was sent", null=True
),
field=models.DateTimeField(blank=True, help_text="When push notification was sent", null=True),
),
migrations.AlterField(
model_name="usernotificationevent",
name="read_at",
field=models.DateTimeField(
blank=True, help_text="When this notification was read", null=True
),
field=models.DateTimeField(blank=True, help_text="When this notification was read", null=True),
),
migrations.AlterField(
model_name="usernotificationevent",
@@ -844,37 +717,27 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userprofile",
name="bio",
field=models.TextField(
blank=True, help_text="User biography", max_length=500
),
field=models.TextField(blank=True, help_text="User biography", max_length=500),
),
migrations.AlterField(
model_name="userprofile",
name="coaster_credits",
field=models.IntegerField(
default=0, help_text="Number of roller coasters ridden"
),
field=models.IntegerField(default=0, help_text="Number of roller coasters ridden"),
),
migrations.AlterField(
model_name="userprofile",
name="dark_ride_credits",
field=models.IntegerField(
default=0, help_text="Number of dark rides ridden"
),
field=models.IntegerField(default=0, help_text="Number of dark rides ridden"),
),
migrations.AlterField(
model_name="userprofile",
name="discord",
field=models.CharField(
blank=True, help_text="Discord username", max_length=100
),
field=models.CharField(blank=True, help_text="Discord username", max_length=100),
),
migrations.AlterField(
model_name="userprofile",
name="flat_ride_credits",
field=models.IntegerField(
default=0, help_text="Number of flat rides ridden"
),
field=models.IntegerField(default=0, help_text="Number of flat rides ridden"),
),
migrations.AlterField(
model_name="userprofile",
@@ -884,9 +747,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userprofile",
name="pronouns",
field=models.CharField(
blank=True, help_text="User's preferred pronouns", max_length=50
),
field=models.CharField(blank=True, help_text="User's preferred pronouns", max_length=50),
),
migrations.AlterField(
model_name="userprofile",
@@ -906,9 +767,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userprofile",
name="water_ride_credits",
field=models.IntegerField(
default=0, help_text="Number of water rides ridden"
),
field=models.IntegerField(default=0, help_text="Number of water rides ridden"),
),
migrations.AlterField(
model_name="userprofile",
@@ -932,37 +791,27 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userprofileevent",
name="bio",
field=models.TextField(
blank=True, help_text="User biography", max_length=500
),
field=models.TextField(blank=True, help_text="User biography", max_length=500),
),
migrations.AlterField(
model_name="userprofileevent",
name="coaster_credits",
field=models.IntegerField(
default=0, help_text="Number of roller coasters ridden"
),
field=models.IntegerField(default=0, help_text="Number of roller coasters ridden"),
),
migrations.AlterField(
model_name="userprofileevent",
name="dark_ride_credits",
field=models.IntegerField(
default=0, help_text="Number of dark rides ridden"
),
field=models.IntegerField(default=0, help_text="Number of dark rides ridden"),
),
migrations.AlterField(
model_name="userprofileevent",
name="discord",
field=models.CharField(
blank=True, help_text="Discord username", max_length=100
),
field=models.CharField(blank=True, help_text="Discord username", max_length=100),
),
migrations.AlterField(
model_name="userprofileevent",
name="flat_ride_credits",
field=models.IntegerField(
default=0, help_text="Number of flat rides ridden"
),
field=models.IntegerField(default=0, help_text="Number of flat rides ridden"),
),
migrations.AlterField(
model_name="userprofileevent",
@@ -972,9 +821,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userprofileevent",
name="pronouns",
field=models.CharField(
blank=True, help_text="User's preferred pronouns", max_length=50
),
field=models.CharField(blank=True, help_text="User's preferred pronouns", max_length=50),
),
migrations.AlterField(
model_name="userprofileevent",
@@ -996,9 +843,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="userprofileevent",
name="water_ride_credits",
field=models.IntegerField(
default=0, help_text="Number of water rides ridden"
),
field=models.IntegerField(default=0, help_text="Number of water rides ridden"),
),
migrations.AlterField(
model_name="userprofileevent",

View File

@@ -1,6 +1,7 @@
"""
Mixins for authentication views.
"""
from django.core.exceptions import ValidationError
from apps.core.utils.turnstile import get_client_ip, validate_turnstile_token
@@ -24,14 +25,14 @@ class TurnstileMixin:
token = None
# Check POST data (form submissions)
if hasattr(request, 'POST'):
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')
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)
@@ -39,6 +40,6 @@ class TurnstileMixin:
# 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.')
if not result.get("success"):
error_msg = result.get("error", "Captcha verification failed. Please try again.")
raise ValidationError(error_msg)

View File

@@ -41,10 +41,7 @@ class User(AbstractUser):
max_length=10,
unique=True,
editable=False,
help_text=(
"Unique identifier for this user that remains constant even if the "
"username changes"
),
help_text=("Unique identifier for this user that remains constant even if the " "username changes"),
)
role = RichChoiceField(
@@ -55,13 +52,9 @@ class User(AbstractUser):
db_index=True,
help_text="User role (user, moderator, admin)",
)
is_banned = models.BooleanField(
default=False, db_index=True, help_text="Whether this user is banned"
)
is_banned = models.BooleanField(default=False, db_index=True, help_text="Whether this user is banned")
ban_reason = models.TextField(blank=True, help_text="Reason for ban")
ban_date = models.DateTimeField(
null=True, blank=True, help_text="Date the user was banned"
)
ban_date = models.DateTimeField(null=True, blank=True, help_text="Date the user was banned")
pending_email = models.EmailField(blank=True, null=True)
theme_preference = RichChoiceField(
choice_group="theme_preferences",
@@ -72,12 +65,8 @@ class User(AbstractUser):
)
# Notification preferences
email_notifications = models.BooleanField(
default=True, help_text="Whether to send email notifications"
)
push_notifications = models.BooleanField(
default=False, help_text="Whether to send push notifications"
)
email_notifications = models.BooleanField(default=True, help_text="Whether to send email notifications")
push_notifications = models.BooleanField(default=False, help_text="Whether to send push notifications")
# Privacy settings
privacy_level = RichChoiceField(
@@ -87,39 +76,17 @@ class User(AbstractUser):
default="public",
help_text="Overall privacy level",
)
show_email = models.BooleanField(
default=False, help_text="Whether to show email on profile"
)
show_real_name = models.BooleanField(
default=True, help_text="Whether to show real name on profile"
)
show_join_date = models.BooleanField(
default=True, help_text="Whether to show join date on profile"
)
show_statistics = models.BooleanField(
default=True, help_text="Whether to show statistics on profile"
)
show_reviews = models.BooleanField(
default=True, help_text="Whether to show reviews on profile"
)
show_photos = models.BooleanField(
default=True, help_text="Whether to show photos on profile"
)
show_top_lists = models.BooleanField(
default=True, help_text="Whether to show top lists on profile"
)
allow_friend_requests = models.BooleanField(
default=True, help_text="Whether to allow friend requests"
)
allow_messages = models.BooleanField(
default=True, help_text="Whether to allow direct messages"
)
allow_profile_comments = models.BooleanField(
default=False, help_text="Whether to allow profile comments"
)
search_visibility = models.BooleanField(
default=True, help_text="Whether profile appears in search results"
)
show_email = models.BooleanField(default=False, help_text="Whether to show email on profile")
show_real_name = models.BooleanField(default=True, help_text="Whether to show real name on profile")
show_join_date = models.BooleanField(default=True, help_text="Whether to show join date on profile")
show_statistics = models.BooleanField(default=True, help_text="Whether to show statistics on profile")
show_reviews = models.BooleanField(default=True, help_text="Whether to show reviews on profile")
show_photos = models.BooleanField(default=True, help_text="Whether to show photos on profile")
show_top_lists = models.BooleanField(default=True, help_text="Whether to show top lists on profile")
allow_friend_requests = models.BooleanField(default=True, help_text="Whether to allow friend requests")
allow_messages = models.BooleanField(default=True, help_text="Whether to allow direct messages")
allow_profile_comments = models.BooleanField(default=False, help_text="Whether to allow profile comments")
search_visibility = models.BooleanField(default=True, help_text="Whether profile appears in search results")
activity_visibility = RichChoiceField(
choice_group="privacy_levels",
domain="accounts",
@@ -129,21 +96,11 @@ class User(AbstractUser):
)
# Security settings
two_factor_enabled = models.BooleanField(
default=False, help_text="Whether two-factor authentication is enabled"
)
login_notifications = models.BooleanField(
default=True, help_text="Whether to send login notifications"
)
session_timeout = models.IntegerField(
default=30, help_text="Session timeout in days"
)
login_history_retention = models.IntegerField(
default=90, help_text="How long to retain login history (days)"
)
last_password_change = models.DateTimeField(
auto_now_add=True, help_text="When the password was last changed"
)
two_factor_enabled = models.BooleanField(default=False, help_text="Whether two-factor authentication is enabled")
login_notifications = models.BooleanField(default=True, help_text="Whether to send login notifications")
session_timeout = models.IntegerField(default=30, help_text="Session timeout in days")
login_history_retention = models.IntegerField(default=90, help_text="How long to retain login history (days)")
last_password_change = models.DateTimeField(auto_now_add=True, help_text="When the password was last changed")
# Display name - core user data for better performance
display_name = models.CharField(
@@ -179,13 +136,13 @@ class User(AbstractUser):
verbose_name = "User"
verbose_name_plural = "Users"
indexes = [
models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'),
models.Index(fields=["is_banned", "role"], name="accounts_user_banned_role_idx"),
]
constraints = [
models.CheckConstraint(
name='user_ban_consistency',
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'
violation_error_message="Banned users must have a ban_date set",
),
]
@@ -224,14 +181,10 @@ class UserProfile(models.Model):
related_name="user_profiles",
help_text="User's avatar image",
)
pronouns = models.CharField(
max_length=50, blank=True, help_text="User's preferred pronouns"
)
pronouns = models.CharField(max_length=50, blank=True, help_text="User's preferred pronouns")
bio = models.TextField(max_length=500, blank=True, help_text="User biography")
location = models.CharField(
max_length=100, blank=True, help_text="User's location (City, Country)"
)
location = models.CharField(max_length=100, blank=True, help_text="User's location (City, Country)")
unit_system = RichChoiceField(
choice_group="unit_systems",
domain="accounts",
@@ -247,18 +200,10 @@ class UserProfile(models.Model):
discord = models.CharField(max_length=100, blank=True, help_text="Discord username")
# Ride statistics
coaster_credits = models.IntegerField(
default=0, help_text="Number of roller coasters ridden"
)
dark_ride_credits = models.IntegerField(
default=0, help_text="Number of dark rides ridden"
)
flat_ride_credits = models.IntegerField(
default=0, help_text="Number of flat rides ridden"
)
water_ride_credits = models.IntegerField(
default=0, help_text="Number of water rides ridden"
)
coaster_credits = models.IntegerField(default=0, help_text="Number of roller coasters ridden")
dark_ride_credits = models.IntegerField(default=0, help_text="Number of dark rides ridden")
flat_ride_credits = models.IntegerField(default=0, help_text="Number of flat rides ridden")
water_ride_credits = models.IntegerField(default=0, help_text="Number of water rides ridden")
def get_avatar_url(self):
"""
@@ -266,12 +211,12 @@ class UserProfile(models.Model):
"""
if self.avatar and self.avatar.is_uploaded:
# Try to get avatar variant first, fallback to public
avatar_url = self.avatar.get_url('avatar')
avatar_url = self.avatar.get_url("avatar")
if avatar_url:
return avatar_url
# Fallback to public variant
public_url = self.avatar.get_url('public')
public_url = self.avatar.get_url("public")
if public_url:
return public_url
@@ -298,10 +243,10 @@ class UserProfile(models.Model):
variants = {}
# Try to get specific variants
thumbnail_url = self.avatar.get_url('thumbnail')
avatar_url = self.avatar.get_url('avatar')
large_url = self.avatar.get_url('large')
public_url = self.avatar.get_url('public')
thumbnail_url = self.avatar.get_url("thumbnail")
avatar_url = self.avatar.get_url("avatar")
large_url = self.avatar.get_url("large")
public_url = self.avatar.get_url("public")
# Use specific variants if available, otherwise fallback to public or first available
fallback_url = public_url
@@ -354,18 +299,10 @@ class EmailVerification(models.Model):
on_delete=models.CASCADE,
help_text="User this verification belongs to",
)
token = models.CharField(
max_length=64, unique=True, help_text="Verification token"
)
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"
)
token = models.CharField(max_length=64, unique=True, help_text="Verification token")
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")
def __str__(self):
return f"Email verification for {self.user.username}"
@@ -383,9 +320,7 @@ class PasswordReset(models.Model):
help_text="User requesting password reset",
)
token = models.CharField(max_length=64, help_text="Reset token")
created_at = models.DateTimeField(
auto_now_add=True, help_text="When this reset was requested"
)
created_at = models.DateTimeField(auto_now_add=True, help_text="When this reset was requested")
expires_at = models.DateTimeField(help_text="When this reset token expires")
used = models.BooleanField(default=False, help_text="Whether this token has been used")
@@ -397,8 +332,6 @@ class PasswordReset(models.Model):
verbose_name_plural = "Password Resets"
@pghistory.track()
class UserDeletionRequest(models.Model):
"""
@@ -409,9 +342,7 @@ class UserDeletionRequest(models.Model):
provide the correct code.
"""
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name="deletion_request"
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="deletion_request")
verification_code = models.CharField(
max_length=32,
@@ -422,21 +353,13 @@ class UserDeletionRequest(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(help_text="When this deletion request expires")
email_sent_at = models.DateTimeField(
null=True, blank=True, help_text="When the verification email was sent"
)
email_sent_at = models.DateTimeField(null=True, blank=True, help_text="When the verification email was sent")
attempts = models.PositiveIntegerField(
default=0, help_text="Number of verification attempts made"
)
attempts = models.PositiveIntegerField(default=0, help_text="Number of verification attempts made")
max_attempts = models.PositiveIntegerField(
default=5, help_text="Maximum number of verification attempts allowed"
)
max_attempts = models.PositiveIntegerField(default=5, help_text="Maximum number of verification attempts allowed")
is_used = models.BooleanField(
default=False, help_text="Whether this deletion request has been used"
)
is_used = models.BooleanField(default=False, help_text="Whether this deletion request has been used")
class Meta:
verbose_name = "User Deletion Request"
@@ -466,9 +389,7 @@ class UserDeletionRequest(models.Model):
"""Generate a unique 8-character verification code."""
while True:
# Generate a random 8-character alphanumeric code
code = "".join(
secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8)
)
code = "".join(secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8))
# Ensure it's unique
if not UserDeletionRequest.objects.filter(verification_code=code).exists():
@@ -480,11 +401,7 @@ class UserDeletionRequest(models.Model):
def is_valid(self):
"""Check if this deletion request is still valid."""
return (
not self.is_used
and not self.is_expired()
and self.attempts < self.max_attempts
)
return not self.is_used and not self.is_expired() and self.attempts < self.max_attempts
def increment_attempts(self):
"""Increment the number of verification attempts."""
@@ -499,9 +416,7 @@ class UserDeletionRequest(models.Model):
@classmethod
def cleanup_expired(cls):
"""Remove expired deletion requests."""
expired_requests = cls.objects.filter(
expires_at__lt=timezone.now(), is_used=False
)
expired_requests = cls.objects.filter(expires_at__lt=timezone.now(), is_used=False)
count = expired_requests.count()
expired_requests.delete()
return count
@@ -541,9 +456,7 @@ class UserNotification(TrackedModel):
blank=True,
help_text="Type of related object",
)
object_id = models.PositiveIntegerField(
null=True, blank=True, help_text="ID of related object"
)
object_id = models.PositiveIntegerField(null=True, blank=True, help_text="ID of related object")
related_object = GenericForeignKey("content_type", "object_id")
# Metadata
@@ -555,24 +468,14 @@ class UserNotification(TrackedModel):
)
# Status tracking
is_read = models.BooleanField(
default=False, help_text="Whether this notification has been read"
)
read_at = models.DateTimeField(
null=True, blank=True, help_text="When this notification was read"
)
is_read = models.BooleanField(default=False, help_text="Whether this notification has been read")
read_at = models.DateTimeField(null=True, blank=True, help_text="When this notification was read")
# Delivery tracking
email_sent = models.BooleanField(default=False, help_text="Whether email was sent")
email_sent_at = models.DateTimeField(
null=True, blank=True, help_text="When email was sent"
)
push_sent = models.BooleanField(
default=False, help_text="Whether push notification was sent"
)
push_sent_at = models.DateTimeField(
null=True, blank=True, help_text="When push notification was sent"
)
email_sent_at = models.DateTimeField(null=True, blank=True, help_text="When email was sent")
push_sent = models.BooleanField(default=False, help_text="Whether push notification was sent")
push_sent_at = models.DateTimeField(null=True, blank=True, help_text="When push notification was sent")
# Additional data (JSON field for flexibility)
extra_data = models.JSONField(default=dict, blank=True)
@@ -619,9 +522,7 @@ class UserNotification(TrackedModel):
@classmethod
def mark_all_read_for_user(cls, user):
"""Mark all notifications as read for a specific user."""
return cls.objects.filter(user=user, is_read=False).update(
is_read=True, read_at=timezone.now()
)
return cls.objects.filter(user=user, is_read=False).update(is_read=True, read_at=timezone.now())
@pghistory.track()

View File

@@ -27,16 +27,10 @@ def user_profile_optimized(*, user_id: int) -> Any:
User.DoesNotExist: If user doesn't exist
"""
return (
User.objects.prefetch_related(
"park_reviews", "ride_reviews", "socialaccount_set"
)
User.objects.prefetch_related("park_reviews", "ride_reviews", "socialaccount_set")
.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.get(id=user_id)
@@ -53,12 +47,8 @@ def active_users_with_stats() -> QuerySet:
return (
User.objects.filter(is_active=True)
.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.order_by("-total_review_count")
@@ -112,12 +102,8 @@ def top_reviewers(*, limit: int = 10) -> QuerySet:
return (
User.objects.filter(is_active=True)
.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.filter(total_review_count__gt=0)
@@ -159,9 +145,9 @@ def users_by_registration_date(*, start_date, end_date) -> QuerySet:
Returns:
QuerySet of users registered in the date range
"""
return User.objects.filter(
date_joined__date__gte=start_date, date_joined__date__lte=end_date
).order_by("-date_joined")
return User.objects.filter(date_joined__date__gte=start_date, date_joined__date__lte=end_date).order_by(
"-date_joined"
)
def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
@@ -176,8 +162,7 @@ def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
QuerySet of matching users for autocomplete
"""
return User.objects.filter(
Q(username__icontains=query)
| Q(display_name__icontains=query),
Q(username__icontains=query) | Q(display_name__icontains=query),
is_active=True,
).order_by("username")[:limit]
@@ -210,11 +195,7 @@ def user_statistics_summary() -> dict[str, Any]:
# Users with reviews
users_with_reviews = (
User.objects.filter(
Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)
)
.distinct()
.count()
User.objects.filter(Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)).distinct().count()
)
# Recent registrations (last 30 days)
@@ -228,9 +209,7 @@ def user_statistics_summary() -> dict[str, Any]:
"staff_users": staff_users,
"users_with_reviews": users_with_reviews,
"recent_registrations": recent_registrations,
"review_participation_rate": (
(users_with_reviews / total_users * 100) if total_users > 0 else 0
),
"review_participation_rate": ((users_with_reviews / total_users * 100) if total_users > 0 else 0),
}
@@ -241,11 +220,7 @@ def users_needing_email_verification() -> QuerySet:
Returns:
QuerySet of users with unverified emails
"""
return (
User.objects.filter(is_active=True, emailaddress__verified=False)
.distinct()
.order_by("date_joined")
)
return User.objects.filter(is_active=True, emailaddress__verified=False).distinct().order_by("date_joined")
def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
@@ -260,12 +235,8 @@ def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
"""
return (
User.objects.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.filter(total_review_count__gte=min_reviews)

View File

@@ -62,12 +62,8 @@ class LoginSerializer(serializers.Serializer):
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
)
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")
@@ -89,9 +85,7 @@ class SignupSerializer(serializers.ModelSerializer):
validators=[validate_password],
style={"input_type": "password"},
)
password_confirm = serializers.CharField(
write_only=True, style={"input_type": "password"}
)
password_confirm = serializers.CharField(write_only=True, style={"input_type": "password"})
class Meta:
model = User
@@ -118,9 +112,7 @@ class SignupSerializer(serializers.ModelSerializer):
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."
)
raise serializers.ValidationError("A user with this username already exists.")
return value
def validate(self, attrs):
@@ -129,9 +121,7 @@ class SignupSerializer(serializers.ModelSerializer):
password_confirm = attrs.get("password_confirm")
if password != password_confirm:
raise serializers.ValidationError(
{"password_confirm": "Passwords do not match."}
)
raise serializers.ValidationError({"password_confirm": "Passwords do not match."})
return attrs
@@ -194,9 +184,7 @@ class PasswordResetSerializer(serializers.Serializer):
"site_name": site.name,
}
email_html = render_to_string(
"accounts/email/password_reset.html", context
)
email_html = render_to_string("accounts/email/password_reset.html", context)
# Narrow and validate email type for the static checker
email = getattr(self.user, "email", None)
@@ -218,15 +206,11 @@ class PasswordChangeSerializer(serializers.Serializer):
Serializer for password change
"""
old_password = serializers.CharField(
max_length=128, style={"input_type": "password"}
)
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"}
)
new_password_confirm = serializers.CharField(max_length=128, style={"input_type": "password"})
def validate_old_password(self, value):
"""Validate old password is correct"""
@@ -241,9 +225,7 @@ class PasswordChangeSerializer(serializers.Serializer):
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."}
)
raise serializers.ValidationError({"new_password_confirm": "New passwords do not match."})
return attrs

View File

@@ -81,21 +81,15 @@ class AccountService:
"""
# 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
}
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
"success": False,
"message": "Password must be at least 8 characters and contain uppercase, lowercase, and numbers",
"redirect_url": None,
}
# Update password
@@ -111,9 +105,9 @@ class AccountService:
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
"success": True,
"message": "Password changed successfully. Please check your email for confirmation.",
"redirect_url": None,
}
@staticmethod
@@ -125,9 +119,7 @@ class AccountService:
"site_name": site.name,
}
email_html = render_to_string(
"accounts/email/password_change_confirmation.html", context
)
email_html = render_to_string("accounts/email/password_change_confirmation.html", context)
try:
EmailService.send_email(
@@ -166,26 +158,17 @@ class AccountService:
}
"""
if not new_email:
return {
'success': False,
'message': "New email is required"
}
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"
}
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}
)
EmailVerification.objects.update_or_create(user=user, defaults={"token": token})
# Store pending email
user.pending_email = new_email
@@ -196,18 +179,10 @@ class AccountService:
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"
}
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:
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
@@ -245,22 +220,14 @@ class AccountService:
Dictionary with success status and message
"""
try:
verification = EmailVerification.objects.select_related("user").get(
token=token
)
verification = EmailVerification.objects.select_related("user").get(token=token)
except EmailVerification.DoesNotExist:
return {
'success': False,
'message': "Invalid or expired verification token"
}
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"
}
return {"success": False, "message": "No pending email change found"}
# Update email
old_email = user.email
@@ -273,10 +240,7 @@ class AccountService:
logger.info(f"Email changed for user {user.id} from {old_email} to {user.email}")
return {
'success': True,
'message': "Email address updated successfully"
}
return {"success": True, "message": "Email address updated successfully"}
class UserDeletionService:
@@ -337,39 +301,17 @@ class UserDeletionService:
# Count submissions before transfer
submission_counts = {
"park_reviews": getattr(
user, "park_reviews", user.__class__.objects.none()
).count(),
"ride_reviews": getattr(
user, "ride_reviews", user.__class__.objects.none()
).count(),
"uploaded_park_photos": getattr(
user, "uploaded_park_photos", user.__class__.objects.none()
).count(),
"uploaded_ride_photos": getattr(
user, "uploaded_ride_photos", user.__class__.objects.none()
).count(),
"top_lists": getattr(
user, "top_lists", user.__class__.objects.none()
).count(),
"edit_submissions": getattr(
user, "edit_submissions", user.__class__.objects.none()
).count(),
"photo_submissions": getattr(
user, "photo_submissions", user.__class__.objects.none()
).count(),
"moderated_park_reviews": getattr(
user, "moderated_park_reviews", user.__class__.objects.none()
).count(),
"moderated_ride_reviews": getattr(
user, "moderated_ride_reviews", user.__class__.objects.none()
).count(),
"handled_submissions": getattr(
user, "handled_submissions", user.__class__.objects.none()
).count(),
"handled_photos": getattr(
user, "handled_photos", user.__class__.objects.none()
).count(),
"park_reviews": getattr(user, "park_reviews", user.__class__.objects.none()).count(),
"ride_reviews": getattr(user, "ride_reviews", user.__class__.objects.none()).count(),
"uploaded_park_photos": getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count(),
"uploaded_ride_photos": getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count(),
"top_lists": getattr(user, "top_lists", user.__class__.objects.none()).count(),
"edit_submissions": getattr(user, "edit_submissions", user.__class__.objects.none()).count(),
"photo_submissions": getattr(user, "photo_submissions", user.__class__.objects.none()).count(),
"moderated_park_reviews": getattr(user, "moderated_park_reviews", user.__class__.objects.none()).count(),
"moderated_ride_reviews": getattr(user, "moderated_ride_reviews", user.__class__.objects.none()).count(),
"handled_submissions": getattr(user, "handled_submissions", user.__class__.objects.none()).count(),
"handled_photos": getattr(user, "handled_photos", user.__class__.objects.none()).count(),
}
# Transfer all submissions to deleted user
@@ -440,11 +382,17 @@ class UserDeletionService:
return False, "Cannot delete the system deleted user placeholder"
if user.is_superuser:
return False, "Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first."
return (
False,
"Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first.",
)
# Check if user has critical admin role
if user.role == User.Roles.ADMIN and user.is_staff:
return False, "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator."
return (
False,
"Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator.",
)
# Add any other business rules here
@@ -492,9 +440,7 @@ class UserDeletionService:
site = Site.objects.get_current()
except Site.DoesNotExist:
# Fallback to default site
site = Site.objects.get_or_create(
id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"}
)[0]
site = Site.objects.get_or_create(id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"})[0]
# Prepare email context
context = {
@@ -502,9 +448,7 @@ class UserDeletionService:
"verification_code": deletion_request.verification_code,
"expires_at": deletion_request.expires_at,
"site_name": getattr(settings, "SITE_NAME", "ThrillWiki"),
"frontend_domain": getattr(
settings, "FRONTEND_DOMAIN", "http://localhost:3000"
),
"frontend_domain": getattr(settings, "FRONTEND_DOMAIN", "http://localhost:3000"),
}
# Render email content
@@ -564,11 +508,9 @@ The ThrillWiki Team
ValueError: If verification fails
"""
try:
deletion_request = UserDeletionRequest.objects.get(
verification_code=verification_code
)
deletion_request = UserDeletionRequest.objects.get(verification_code=verification_code)
except UserDeletionRequest.DoesNotExist:
raise ValueError("Invalid verification code")
raise ValueError("Invalid verification code") from None
# Check if request is still valid
if not deletion_request.is_valid():

View File

@@ -8,4 +8,4 @@ including social provider management, user authentication, and profile services.
from .social_provider_service import SocialProviderService
from .user_deletion_service import UserDeletionService
__all__ = ['SocialProviderService', 'UserDeletionService']
__all__ = ["SocialProviderService", "UserDeletionService"]

View File

@@ -139,7 +139,9 @@ class NotificationService:
UserNotification: The created notification
"""
title = f"Your {submission_type} needs attention"
message = f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
message = (
f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
)
message += f"\n\nReason: {rejection_reason}"
if additional_message:
@@ -216,9 +218,7 @@ class NotificationService:
preferences = NotificationPreference.objects.create(user=user)
# Send email notification if enabled
if preferences.should_send_notification(
notification.notification_type, "email"
):
if preferences.should_send_notification(notification.notification_type, "email"):
NotificationService._send_email_notification(notification)
# Toast notifications are always created (the notification object itself)
@@ -261,14 +261,10 @@ class NotificationService:
notification.email_sent_at = timezone.now()
notification.save(update_fields=["email_sent", "email_sent_at"])
logger.info(
f"Email notification sent to {user.email} for notification {notification.id}"
)
logger.info(f"Email notification sent to {user.email} for notification {notification.id}")
except Exception as e:
logger.error(
f"Failed to send email notification {notification.id}: {str(e)}"
)
logger.error(f"Failed to send email notification {notification.id}: {str(e)}")
@staticmethod
def get_user_notifications(
@@ -298,9 +294,7 @@ class NotificationService:
queryset = queryset.filter(notification_type__in=notification_types)
# Exclude expired notifications
queryset = queryset.filter(
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
)
queryset = queryset.filter(models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now()))
if limit:
queryset = queryset[:limit]
@@ -308,9 +302,7 @@ class NotificationService:
return list(queryset)
@staticmethod
def mark_notifications_read(
user: User, notification_ids: list[int] | None = None
) -> int:
def mark_notifications_read(user: User, notification_ids: list[int] | None = None) -> int:
"""
Mark notifications as read for a user.
@@ -341,9 +333,7 @@ class NotificationService:
"""
cutoff_date = timezone.now() - timedelta(days=days)
old_notifications = UserNotification.objects.filter(
is_read=True, read_at__lt=cutoff_date
)
old_notifications = UserNotification.objects.filter(is_read=True, read_at__lt=cutoff_date)
count = old_notifications.count()
old_notifications.delete()

View File

@@ -40,23 +40,20 @@ class SocialProviderService:
"""
try:
# Count remaining social accounts after disconnection
remaining_social_accounts = user.socialaccount_set.exclude(
provider=provider
).count()
remaining_social_accounts = user.socialaccount_set.exclude(provider=provider).count()
# Check if user has email/password auth
has_password_auth = (
user.email and
user.has_usable_password() and
bool(user.password) # Not empty/unusable
)
has_password_auth = user.email and user.has_usable_password() and bool(user.password) # Not empty/unusable
# Allow disconnection only if alternative auth exists
can_disconnect = remaining_social_accounts > 0 or has_password_auth
if not can_disconnect:
if remaining_social_accounts == 0 and not has_password_auth:
return False, "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first."
return (
False,
"Cannot disconnect your only authentication method. Please set up a password or connect another social provider first.",
)
elif not has_password_auth:
return False, "Please set up email/password authentication before disconnecting this provider."
else:
@@ -65,8 +62,7 @@ class SocialProviderService:
return True, "Provider can be safely disconnected."
except Exception as e:
logger.error(
f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}")
logger.error(f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}")
return False, "Unable to verify disconnection safety. Please try again."
@staticmethod
@@ -84,18 +80,16 @@ class SocialProviderService:
connected_providers = []
for social_account in user.socialaccount_set.all():
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
user, social_account.provider
)
can_disconnect, reason = SocialProviderService.can_disconnect_provider(user, social_account.provider)
provider_info = {
'provider': social_account.provider,
'provider_name': social_account.get_provider().name,
'uid': social_account.uid,
'date_joined': social_account.date_joined,
'can_disconnect': can_disconnect,
'disconnect_reason': reason if not can_disconnect else None,
'extra_data': social_account.extra_data
"provider": social_account.provider,
"provider_name": social_account.get_provider().name,
"uid": social_account.uid,
"date_joined": social_account.date_joined,
"can_disconnect": can_disconnect,
"disconnect_reason": reason if not can_disconnect else None,
"extra_data": social_account.extra_data,
}
connected_providers.append(provider_info)
@@ -122,28 +116,25 @@ class SocialProviderService:
available_providers = []
# Get all social apps configured for this site
social_apps = SocialApp.objects.filter(sites=site).order_by('provider')
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
for social_app in social_apps:
try:
provider = registry.by_id(social_app.provider)
provider_info = {
'id': social_app.provider,
'name': provider.name,
'auth_url': request.build_absolute_uri(
f'/accounts/{social_app.provider}/login/'
"id": social_app.provider,
"name": provider.name,
"auth_url": request.build_absolute_uri(f"/accounts/{social_app.provider}/login/"),
"connect_url": request.build_absolute_uri(
f"/api/v1/auth/social/connect/{social_app.provider}/"
),
'connect_url': request.build_absolute_uri(
f'/api/v1/auth/social/connect/{social_app.provider}/'
)
}
available_providers.append(provider_info)
except Exception as e:
logger.warning(
f"Error processing provider {social_app.provider}: {e}")
logger.warning(f"Error processing provider {social_app.provider}: {e}")
continue
return available_providers
@@ -166,8 +157,7 @@ class SocialProviderService:
"""
try:
# First check if disconnection is allowed
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
user, provider)
can_disconnect, reason = SocialProviderService.can_disconnect_provider(user, provider)
if not can_disconnect:
return False, reason
@@ -182,8 +172,7 @@ class SocialProviderService:
deleted_count = social_accounts.count()
social_accounts.delete()
logger.info(
f"User {user.id} disconnected {deleted_count} {provider} account(s)")
logger.info(f"User {user.id} disconnected {deleted_count} {provider} account(s)")
return True, f"{provider.title()} account disconnected successfully."
@@ -205,31 +194,24 @@ class SocialProviderService:
try:
connected_providers = SocialProviderService.get_connected_providers(user)
has_password_auth = (
user.email and
user.has_usable_password() and
bool(user.password)
)
has_password_auth = user.email and user.has_usable_password() and bool(user.password)
auth_methods_count = len(connected_providers) + \
(1 if has_password_auth else 0)
auth_methods_count = len(connected_providers) + (1 if has_password_auth else 0)
return {
'user_id': user.id,
'username': user.username,
'email': user.email,
'has_password_auth': has_password_auth,
'connected_providers': connected_providers,
'total_auth_methods': auth_methods_count,
'can_disconnect_any': auth_methods_count > 1,
'requires_password_setup': not has_password_auth and len(connected_providers) == 1
"user_id": user.id,
"username": user.username,
"email": user.email,
"has_password_auth": has_password_auth,
"connected_providers": connected_providers,
"total_auth_methods": auth_methods_count,
"can_disconnect_any": auth_methods_count > 1,
"requires_password_setup": not has_password_auth and len(connected_providers) == 1,
}
except Exception as e:
logger.error(f"Error getting auth status for user {user.id}: {e}")
return {
'error': 'Unable to retrieve authentication status'
}
return {"error": "Unable to retrieve authentication status"}
@staticmethod
def validate_provider_exists(provider: str) -> tuple[bool, str]:

View File

@@ -59,7 +59,7 @@ class UserDeletionService:
return False, "Cannot delete staff accounts"
# Check for system users (if you have any special system accounts)
if hasattr(user, 'role') and user.role in ['ADMIN', 'MODERATOR']:
if hasattr(user, "role") and user.role in ["ADMIN", "MODERATOR"]:
return False, "Cannot delete admin or moderator accounts"
return True, None
@@ -84,8 +84,7 @@ class UserDeletionService:
raise ValueError(reason)
# Generate verification code
verification_code = ''.join(secrets.choice(
string.ascii_uppercase + string.digits) for _ in range(8))
verification_code = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
# Set expiration (24 hours from now)
expires_at = timezone.now() + timezone.timedelta(hours=24)
@@ -97,8 +96,7 @@ class UserDeletionService:
UserDeletionService._deletion_requests[verification_code] = deletion_request
# Send verification email
UserDeletionService._send_deletion_verification_email(
user, verification_code, expires_at)
UserDeletionService._send_deletion_verification_email(user, verification_code, expires_at)
return deletion_request
@@ -136,10 +134,10 @@ class UserDeletionService:
del UserDeletionService._deletion_requests[verification_code]
# Add verification info to result
result['deletion_request'] = {
'verification_code': verification_code,
'created_at': deletion_request.created_at,
'verified_at': timezone.now(),
result["deletion_request"] = {
"verification_code": verification_code,
"created_at": deletion_request.created_at,
"verified_at": timezone.now(),
}
return result
@@ -180,13 +178,13 @@ class UserDeletionService:
"""
# Get or create the "deleted_user" placeholder
deleted_user_placeholder, created = User.objects.get_or_create(
username='deleted_user',
username="deleted_user",
defaults={
'email': 'deleted@thrillwiki.com',
'first_name': 'Deleted',
'last_name': 'User',
'is_active': False,
}
"email": "deleted@thrillwiki.com",
"first_name": "Deleted",
"last_name": "User",
"is_active": False,
},
)
# Count submissions before transfer
@@ -197,22 +195,22 @@ class UserDeletionService:
# Store user info before deletion
deleted_user_info = {
'username': user.username,
'user_id': getattr(user, 'user_id', user.id),
'email': user.email,
'date_joined': user.date_joined,
"username": user.username,
"user_id": getattr(user, "user_id", user.id),
"email": user.email,
"date_joined": user.date_joined,
}
# Delete the user account
user.delete()
return {
'deleted_user': deleted_user_info,
'preserved_submissions': submission_counts,
'transferred_to': {
'username': deleted_user_placeholder.username,
'user_id': getattr(deleted_user_placeholder, 'user_id', deleted_user_placeholder.id),
}
"deleted_user": deleted_user_info,
"preserved_submissions": submission_counts,
"transferred_to": {
"username": deleted_user_placeholder.username,
"user_id": getattr(deleted_user_placeholder, "user_id", deleted_user_placeholder.id),
},
}
@staticmethod
@@ -222,20 +220,13 @@ class UserDeletionService:
# Count different types of submissions
# Note: These are placeholder counts - adjust based on your actual models
counts['park_reviews'] = getattr(
user, 'park_reviews', user.__class__.objects.none()).count()
counts['ride_reviews'] = getattr(
user, 'ride_reviews', user.__class__.objects.none()).count()
counts['uploaded_park_photos'] = getattr(
user, 'uploaded_park_photos', user.__class__.objects.none()).count()
counts['uploaded_ride_photos'] = getattr(
user, 'uploaded_ride_photos', user.__class__.objects.none()).count()
counts['top_lists'] = getattr(
user, 'top_lists', user.__class__.objects.none()).count()
counts['edit_submissions'] = getattr(
user, 'edit_submissions', user.__class__.objects.none()).count()
counts['photo_submissions'] = getattr(
user, 'photo_submissions', user.__class__.objects.none()).count()
counts["park_reviews"] = getattr(user, "park_reviews", user.__class__.objects.none()).count()
counts["ride_reviews"] = getattr(user, "ride_reviews", user.__class__.objects.none()).count()
counts["uploaded_park_photos"] = getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count()
counts["uploaded_ride_photos"] = getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count()
counts["top_lists"] = getattr(user, "top_lists", user.__class__.objects.none()).count()
counts["edit_submissions"] = getattr(user, "edit_submissions", user.__class__.objects.none()).count()
counts["photo_submissions"] = getattr(user, "photo_submissions", user.__class__.objects.none()).count()
return counts
@@ -247,30 +238,30 @@ class UserDeletionService:
# Note: Adjust these based on your actual model relationships
# Park reviews
if hasattr(user, 'park_reviews'):
if hasattr(user, "park_reviews"):
user.park_reviews.all().update(user=placeholder_user)
# Ride reviews
if hasattr(user, 'ride_reviews'):
if hasattr(user, "ride_reviews"):
user.ride_reviews.all().update(user=placeholder_user)
# Uploaded photos
if hasattr(user, 'uploaded_park_photos'):
if hasattr(user, "uploaded_park_photos"):
user.uploaded_park_photos.all().update(user=placeholder_user)
if hasattr(user, 'uploaded_ride_photos'):
if hasattr(user, "uploaded_ride_photos"):
user.uploaded_ride_photos.all().update(user=placeholder_user)
# Top lists
if hasattr(user, 'top_lists'):
if hasattr(user, "top_lists"):
user.top_lists.all().update(user=placeholder_user)
# Edit submissions
if hasattr(user, 'edit_submissions'):
if hasattr(user, "edit_submissions"):
user.edit_submissions.all().update(user=placeholder_user)
# Photo submissions
if hasattr(user, 'photo_submissions'):
if hasattr(user, "photo_submissions"):
user.photo_submissions.all().update(user=placeholder_user)
@staticmethod
@@ -278,18 +269,16 @@ class UserDeletionService:
"""Send verification email for account deletion."""
try:
context = {
'user': user,
'verification_code': verification_code,
'expires_at': expires_at,
'site_name': 'ThrillWiki',
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
"user": user,
"verification_code": verification_code,
"expires_at": expires_at,
"site_name": "ThrillWiki",
"site_url": getattr(settings, "SITE_URL", "https://thrillwiki.com"),
}
subject = 'ThrillWiki: Confirm Account Deletion'
html_message = render_to_string(
'emails/account_deletion_verification.html', context)
plain_message = render_to_string(
'emails/account_deletion_verification.txt', context)
subject = "ThrillWiki: Confirm Account Deletion"
html_message = render_to_string("emails/account_deletion_verification.html", context)
plain_message = render_to_string("emails/account_deletion_verification.txt", context)
send_mail(
subject=subject,
@@ -303,6 +292,5 @@ class UserDeletionService:
logger.info(f"Deletion verification email sent to {user.email}")
except Exception as e:
logger.error(
f"Failed to send deletion verification email to {user.email}: {str(e)}")
logger.error(f"Failed to send deletion verification email to {user.email}: {str(e)}")
raise

View File

@@ -108,7 +108,7 @@ def sync_user_role_with_groups(sender, instance, **kwargs):
User.Roles.MODERATOR,
]:
instance.is_staff = True
elif old_instance.role in [
elif old_instance.role in [ # noqa: SIM102
User.Roles.ADMIN,
User.Roles.MODERATOR,
]:
@@ -119,9 +119,7 @@ def sync_user_role_with_groups(sender, instance, **kwargs):
except User.DoesNotExist:
pass
except Exception as e:
print(
f"Error syncing role with groups for user {instance.username}: {str(e)}"
)
print(f"Error syncing role with groups for user {instance.username}: {str(e)}")
def create_default_groups():
@@ -200,19 +198,19 @@ def log_successful_login(sender, user, request, **kwargs):
"""
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')
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]
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)
login_method = "PASSWORD"
if hasattr(request, "session"):
sociallogin = getattr(request, "_sociallogin", None)
if sociallogin:
provider = sociallogin.account.provider.upper()
if provider in ['GOOGLE', 'DISCORD']:
if provider in ["GOOGLE", "DISCORD"]:
login_method = provider
# Create login history entry

View File

@@ -113,16 +113,10 @@ class SignalsTestCase(TestCase):
moderator_group = Group.objects.get(name=User.Roles.MODERATOR)
self.assertIsNotNone(moderator_group)
self.assertTrue(
moderator_group.permissions.filter(codename="change_review").exists()
)
self.assertFalse(
moderator_group.permissions.filter(codename="change_user").exists()
)
self.assertTrue(moderator_group.permissions.filter(codename="change_review").exists())
self.assertFalse(moderator_group.permissions.filter(codename="change_user").exists())
admin_group = Group.objects.get(name=User.Roles.ADMIN)
self.assertIsNotNone(admin_group)
self.assertTrue(
admin_group.permissions.filter(codename="change_review").exists()
)
self.assertTrue(admin_group.permissions.filter(codename="change_review").exists())
self.assertTrue(admin_group.permissions.filter(codename="change_user").exists())

View File

@@ -150,6 +150,3 @@ class TestPasswordResetAdmin(TestCase):
request.user = UserModel(is_superuser=True)
actions = self.admin.get_actions(request)
assert "cleanup_old_tokens" in actions

View File

@@ -85,16 +85,16 @@ class UserIndexTests(TestCase):
def test_is_banned_field_is_indexed(self):
"""Verify is_banned field has db_index=True."""
field = User._meta.get_field('is_banned')
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')
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)
self.assertIn("accounts_user_banned_role_idx", index_names)

View File

@@ -15,9 +15,7 @@ class UserDeletionServiceTest(TestCase):
def setUp(self):
"""Set up test data."""
# Create test users
self.user = User.objects.create_user(
username="testuser", email="test@example.com", password="testpass123"
)
self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123")
self.admin_user = User.objects.create_user(
username="admin",
@@ -27,13 +25,9 @@ class UserDeletionServiceTest(TestCase):
)
# Create user profiles
UserProfile.objects.create(
user=self.user, display_name="Test User", bio="Test bio"
)
UserProfile.objects.create(user=self.user, display_name="Test User", bio="Test bio")
UserProfile.objects.create(
user=self.admin_user, display_name="Admin User", bio="Admin bio"
)
UserProfile.objects.create(user=self.admin_user, display_name="Admin User", bio="Admin bio")
def test_get_or_create_deleted_user(self):
"""Test that deleted user placeholder is created correctly."""
@@ -108,9 +102,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 system deleted user placeholder", str(context.exception))
def test_delete_user_with_submissions_transfers_correctly(self):
"""Test that user submissions are transferred to deleted user placeholder."""
@@ -141,7 +133,7 @@ class UserDeletionServiceTest(TestCase):
original_user_count = User.objects.count()
# Mock a failure during the deletion process
with self.assertRaises(Exception), transaction.atomic():
with self.assertRaises(Exception), transaction.atomic(): # noqa: B017
# Start the deletion process
UserDeletionService.get_or_create_deleted_user()

View File

@@ -61,11 +61,7 @@ class CustomLoginView(TurnstileMixin, LoginView):
context={"user_id": user.id, "username": user.username},
request=self.request,
)
return (
HttpResponseClientRefresh()
if getattr(self.request, "htmx", False)
else response
)
return HttpResponseClientRefresh() if getattr(self.request, "htmx", False) else response
def form_invalid(self, form):
log_security_event(
@@ -116,11 +112,7 @@ class CustomSignupView(TurnstileMixin, SignupView):
},
request=self.request,
)
return (
HttpResponseClientRefresh()
if getattr(self.request, "htmx", False)
else response
)
return HttpResponseClientRefresh() if getattr(self.request, "htmx", False) else response
def form_invalid(self, form):
if getattr(self.request, "htmx", False):
@@ -260,9 +252,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
and bool(re.search(r"[0-9]", password))
)
def _send_password_change_confirmation(
self, request: HttpRequest, user: User
) -> None:
def _send_password_change_confirmation(self, request: HttpRequest, user: User) -> None:
"""Send password change confirmation email."""
site = get_current_site(request)
context = {
@@ -270,9 +260,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
"site_name": site.name,
}
email_html = render_to_string(
"accounts/email/password_change_confirmation.html", context
)
email_html = render_to_string("accounts/email/password_change_confirmation.html", context)
EmailService.send_email(
to=user.email,
@@ -282,9 +270,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
html=email_html,
)
def _handle_password_change(
self, request: HttpRequest
) -> HttpResponseRedirect | None:
def _handle_password_change(self, request: HttpRequest) -> HttpResponseRedirect | None:
user = cast(User, request.user)
old_password = request.POST.get("old_password", "")
new_password = request.POST.get("new_password", "")
@@ -327,9 +313,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
def _handle_email_change(self, request: HttpRequest) -> None:
if new_email := request.POST.get("new_email"):
self._send_email_verification(request, new_email)
messages.success(
request, "Verification email sent to your new email address"
)
messages.success(request, "Verification email sent to your new email address")
else:
messages.error(request, "New email is required")
@@ -385,9 +369,7 @@ def create_password_reset_token(user: User) -> str:
return token
def send_password_reset_email(
user: User, site: Site | RequestSite, token: str
) -> None:
def send_password_reset_email(user: User, site: Site | RequestSite, token: str) -> None:
reset_url = reverse("password_reset_confirm", kwargs={"token": token})
context = {
"user": user,
@@ -457,16 +439,12 @@ def handle_password_reset(
messages.success(request, "Password reset successfully")
def send_password_reset_confirmation(
user: User, site: Site | RequestSite
) -> None:
def send_password_reset_confirmation(user: User, site: Site | RequestSite) -> None:
context = {
"user": user,
"site_name": site.name,
}
email_html = render_to_string(
"accounts/email/password_reset_complete.html", context
)
email_html = render_to_string("accounts/email/password_reset_complete.html", context)
EmailService.send_email(
to=user.email,
@@ -479,9 +457,7 @@ def send_password_reset_confirmation(
def reset_password(request: HttpRequest, token: str) -> HttpResponse:
try:
reset = PasswordReset.objects.select_related("user").get(
token=token, expires_at__gt=timezone.now(), used=False
)
reset = PasswordReset.objects.select_related("user").get(token=token, expires_at__gt=timezone.now(), used=False)
if request.method == "POST":
if new_password := request.POST.get("new_password"):