mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 12:11:13 -05:00
- Centralize API endpoints in dedicated api app with v1 versioning - Remove individual API modules from parks and rides apps - Add event tracking system with analytics functionality - Integrate Vue.js frontend with Tailwind CSS v4 and TypeScript - Add comprehensive database migrations for event tracking - Implement user authentication and social provider setup - Add API schema documentation and serializers - Configure development environment with shared scripts - Update project structure for monorepo with frontend/backend separation
223 lines
6.9 KiB
Python
223 lines
6.9 KiB
Python
from django.contrib.auth.models import AbstractUser
|
|
from django.db import models
|
|
from django.urls import reverse
|
|
from django.utils.translation import gettext_lazy as _
|
|
import os
|
|
import secrets
|
|
from apps.core.history import TrackedModel
|
|
import pghistory
|
|
|
|
|
|
def generate_random_id(model_class, id_field):
|
|
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
|
while True:
|
|
# Try to get a 4-digit number first
|
|
new_id = str(secrets.SystemRandom().randint(1000, 9999))
|
|
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
|
return new_id
|
|
|
|
# If all 4-digit numbers are taken, try 5 digits
|
|
new_id = str(secrets.SystemRandom().randint(10000, 99999))
|
|
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
|
return new_id
|
|
|
|
|
|
@pghistory.track()
|
|
class User(AbstractUser):
|
|
class Roles(models.TextChoices):
|
|
USER = "USER", _("User")
|
|
MODERATOR = "MODERATOR", _("Moderator")
|
|
ADMIN = "ADMIN", _("Admin")
|
|
SUPERUSER = "SUPERUSER", _("Superuser")
|
|
|
|
class ThemePreference(models.TextChoices):
|
|
LIGHT = "light", _("Light")
|
|
DARK = "dark", _("Dark")
|
|
|
|
# Read-only ID
|
|
user_id = models.CharField(
|
|
max_length=10,
|
|
unique=True,
|
|
editable=False,
|
|
help_text=(
|
|
"Unique identifier for this user that remains constant even if the "
|
|
"username changes"
|
|
),
|
|
)
|
|
|
|
role = models.CharField(
|
|
max_length=10,
|
|
choices=Roles.choices,
|
|
default=Roles.USER,
|
|
)
|
|
is_banned = models.BooleanField(default=False)
|
|
ban_reason = models.TextField(blank=True)
|
|
ban_date = models.DateTimeField(null=True, blank=True)
|
|
pending_email = models.EmailField(blank=True, null=True)
|
|
theme_preference = models.CharField(
|
|
max_length=5,
|
|
choices=ThemePreference.choices,
|
|
default=ThemePreference.LIGHT,
|
|
)
|
|
|
|
def __str__(self):
|
|
return self.get_display_name()
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("profile", kwargs={"username": self.username})
|
|
|
|
def get_display_name(self):
|
|
"""Get the user's display name, falling back to username if not set"""
|
|
profile = getattr(self, "profile", None)
|
|
if profile and profile.display_name:
|
|
return profile.display_name
|
|
return self.username
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.user_id:
|
|
self.user_id = generate_random_id(User, "user_id")
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
@pghistory.track()
|
|
class UserProfile(models.Model):
|
|
# Read-only ID
|
|
profile_id = models.CharField(
|
|
max_length=10,
|
|
unique=True,
|
|
editable=False,
|
|
help_text="Unique identifier for this profile that remains constant",
|
|
)
|
|
|
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
|
display_name = models.CharField(
|
|
max_length=50,
|
|
unique=True,
|
|
help_text="This is the name that will be displayed on the site",
|
|
)
|
|
avatar = models.ImageField(upload_to="avatars/", blank=True)
|
|
pronouns = models.CharField(max_length=50, blank=True)
|
|
|
|
bio = models.TextField(max_length=500, blank=True)
|
|
|
|
# Social media links
|
|
twitter = models.URLField(blank=True)
|
|
instagram = models.URLField(blank=True)
|
|
youtube = models.URLField(blank=True)
|
|
discord = models.CharField(max_length=100, blank=True)
|
|
|
|
# Ride statistics
|
|
coaster_credits = models.IntegerField(default=0)
|
|
dark_ride_credits = models.IntegerField(default=0)
|
|
flat_ride_credits = models.IntegerField(default=0)
|
|
water_ride_credits = models.IntegerField(default=0)
|
|
|
|
def get_avatar(self):
|
|
"""
|
|
Return the avatar URL or serve a pre-generated avatar based on the
|
|
first letter of the username
|
|
"""
|
|
if self.avatar:
|
|
return self.avatar.url
|
|
first_letter = self.user.username.upper()
|
|
avatar_path = f"avatars/letters/{first_letter}_avatar.png"
|
|
if os.path.exists(avatar_path):
|
|
return f"/{avatar_path}"
|
|
return "/static/images/default-avatar.png"
|
|
|
|
def save(self, *args, **kwargs):
|
|
# If no display name is set, use the username
|
|
if not self.display_name:
|
|
self.display_name = self.user.username
|
|
|
|
if not self.profile_id:
|
|
self.profile_id = generate_random_id(UserProfile, "profile_id")
|
|
super().save(*args, **kwargs)
|
|
|
|
def __str__(self):
|
|
return self.display_name
|
|
|
|
|
|
@pghistory.track()
|
|
class EmailVerification(models.Model):
|
|
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
|
token = models.CharField(max_length=64, unique=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
last_sent = models.DateTimeField(auto_now_add=True)
|
|
|
|
def __str__(self):
|
|
return f"Email verification for {self.user.username}"
|
|
|
|
class Meta:
|
|
verbose_name = "Email Verification"
|
|
verbose_name_plural = "Email Verifications"
|
|
|
|
|
|
@pghistory.track()
|
|
class PasswordReset(models.Model):
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
token = models.CharField(max_length=64)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
expires_at = models.DateTimeField()
|
|
used = models.BooleanField(default=False)
|
|
|
|
def __str__(self):
|
|
return f"Password reset for {self.user.username}"
|
|
|
|
class Meta:
|
|
verbose_name = "Password Reset"
|
|
verbose_name_plural = "Password Resets"
|
|
|
|
|
|
# @pghistory.track()
|
|
|
|
|
|
class TopList(TrackedModel):
|
|
class Categories(models.TextChoices):
|
|
ROLLER_COASTER = "RC", _("Roller Coaster")
|
|
DARK_RIDE = "DR", _("Dark Ride")
|
|
FLAT_RIDE = "FR", _("Flat Ride")
|
|
WATER_RIDE = "WR", _("Water Ride")
|
|
PARK = "PK", _("Park")
|
|
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
related_name="top_lists", # Added related_name for User model access
|
|
)
|
|
title = models.CharField(max_length=100)
|
|
category = models.CharField(max_length=2, choices=Categories.choices)
|
|
description = models.TextField(blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta(TrackedModel.Meta):
|
|
ordering = ["-updated_at"]
|
|
|
|
def __str__(self):
|
|
return (
|
|
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
|
)
|
|
|
|
|
|
# @pghistory.track()
|
|
|
|
|
|
class TopListItem(TrackedModel):
|
|
top_list = models.ForeignKey(
|
|
TopList, on_delete=models.CASCADE, related_name="items"
|
|
)
|
|
content_type = models.ForeignKey(
|
|
"contenttypes.ContentType", on_delete=models.CASCADE
|
|
)
|
|
object_id = models.PositiveIntegerField()
|
|
rank = models.PositiveIntegerField()
|
|
notes = models.TextField(blank=True)
|
|
|
|
class Meta(TrackedModel.Meta):
|
|
ordering = ["rank"]
|
|
unique_together = [["top_list", "rank"]]
|
|
|
|
def __str__(self):
|
|
return f"#{self.rank} in {self.top_list.title}"
|