mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 06:51:08 -05:00
feat: major API restructure and Vue.js frontend integration
- 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
This commit is contained in:
@@ -2,7 +2,14 @@ from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.utils.html import format_html
|
||||
from django.contrib.auth.models import Group
|
||||
from .models import User, UserProfile, EmailVerification, TopList, TopListItem
|
||||
from .models import (
|
||||
User,
|
||||
UserProfile,
|
||||
EmailVerification,
|
||||
PasswordReset,
|
||||
TopList,
|
||||
TopListItem,
|
||||
)
|
||||
|
||||
|
||||
class UserProfileInline(admin.StackedInline):
|
||||
@@ -280,3 +287,74 @@ class TopListItemAdmin(admin.ModelAdmin):
|
||||
("List Information", {"fields": ("top_list", "rank")}),
|
||||
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(PasswordReset)
|
||||
class PasswordResetAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for password reset tokens"""
|
||||
|
||||
list_display = (
|
||||
"user",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"is_expired",
|
||||
"used",
|
||||
)
|
||||
list_filter = (
|
||||
"used",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
search_fields = (
|
||||
"user__username",
|
||||
"user__email",
|
||||
"token",
|
||||
)
|
||||
readonly_fields = (
|
||||
"token",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
date_hierarchy = "created_at"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Reset Details",
|
||||
{
|
||||
"fields": (
|
||||
"user",
|
||||
"token",
|
||||
"used",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timing",
|
||||
{
|
||||
"fields": (
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Status", boolean=True)
|
||||
def is_expired(self, obj):
|
||||
"""Display expiration status with color coding"""
|
||||
from django.utils import timezone
|
||||
|
||||
if obj.used:
|
||||
return format_html('<span style="color: blue;">Used</span>')
|
||||
elif timezone.now() > obj.expires_at:
|
||||
return format_html('<span style="color: red;">Expired</span>')
|
||||
return format_html('<span style="color: green;">Valid</span>')
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Disable manual creation of password reset tokens"""
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Allow viewing but restrict editing of password reset tokens"""
|
||||
return getattr(request.user, "is_superuser", False)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Set up social authentication providers for development'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get the current site
|
||||
site = Site.objects.get_current()
|
||||
self.stdout.write(f'Setting up social providers for site: {site}')
|
||||
|
||||
# Clear existing social apps to avoid duplicates
|
||||
deleted_count = SocialApp.objects.all().delete()[0]
|
||||
self.stdout.write(f'Cleared {deleted_count} existing social apps')
|
||||
|
||||
# Create Google social app
|
||||
google_app = SocialApp.objects.create(
|
||||
provider='google',
|
||||
name='Google',
|
||||
client_id='demo-google-client-id.apps.googleusercontent.com',
|
||||
secret='demo-google-client-secret',
|
||||
key='',
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS('✅ Created Google social app')
|
||||
)
|
||||
|
||||
# Create Discord social app
|
||||
discord_app = SocialApp.objects.create(
|
||||
provider='discord',
|
||||
name='Discord',
|
||||
client_id='demo-discord-client-id',
|
||||
secret='demo-discord-client-secret',
|
||||
key='',
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS('✅ Created Discord social app')
|
||||
)
|
||||
|
||||
# List all social apps
|
||||
self.stdout.write('\nConfigured social apps:')
|
||||
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()}')
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-24 18:23
|
||||
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="toplistevent",
|
||||
name="pgh_context",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistevent",
|
||||
name="pgh_obj",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistevent",
|
||||
name="user",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistitemevent",
|
||||
name="content_type",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistitemevent",
|
||||
name="pgh_context",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistitemevent",
|
||||
name="pgh_obj",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistitemevent",
|
||||
name="top_list",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="toplist",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="toplist",
|
||||
name="update_update",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="toplistitem",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="toplistitem",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="TopListEvent",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="TopListItemEvent",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,439 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-24 19:11
|
||||
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0002_remove_toplistevent_pgh_context_and_more"),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EmailVerificationEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("token", models.CharField(max_length=64)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("last_sent", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PasswordResetEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("token", models.CharField(max_length=64)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("expires_at", models.DateTimeField()),
|
||||
("used", models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
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()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"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"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_id",
|
||||
models.CharField(
|
||||
editable=False,
|
||||
help_text="Unique identifier for this user that remains constant even if the username changes",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("USER", "User"),
|
||||
("MODERATOR", "Moderator"),
|
||||
("ADMIN", "Admin"),
|
||||
("SUPERUSER", "Superuser"),
|
||||
],
|
||||
default="USER",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("is_banned", models.BooleanField(default=False)),
|
||||
("ban_reason", models.TextField(blank=True)),
|
||||
("ban_date", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"pending_email",
|
||||
models.EmailField(blank=True, max_length=254, null=True),
|
||||
),
|
||||
(
|
||||
"theme_preference",
|
||||
models.CharField(
|
||||
choices=[("light", "Light"), ("dark", "Dark")],
|
||||
default="light",
|
||||
max_length=5,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserProfileEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"profile_id",
|
||||
models.CharField(
|
||||
editable=False,
|
||||
help_text="Unique identifier for this profile that remains constant",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"display_name",
|
||||
models.CharField(
|
||||
help_text="This is the name that will be displayed on the site",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
("avatar", models.ImageField(blank=True, upload_to="avatars/")),
|
||||
("pronouns", models.CharField(blank=True, max_length=50)),
|
||||
("bio", models.TextField(blank=True, max_length=500)),
|
||||
("twitter", models.URLField(blank=True)),
|
||||
("instagram", models.URLField(blank=True)),
|
||||
("youtube", models.URLField(blank=True)),
|
||||
("discord", models.CharField(blank=True, max_length=100)),
|
||||
("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)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="emailverification",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_emailverificationevent" ("created_at", "id", "last_sent", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "user_id") VALUES (NEW."created_at", NEW."id", NEW."last_sent", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."token", NEW."user_id"); RETURN NULL;',
|
||||
hash="c485bf0cd5bea8a05ef2d4ae309b60eff42abd84",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_53748",
|
||||
table="accounts_emailverification",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="emailverification",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_emailverificationevent" ("created_at", "id", "last_sent", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "user_id") VALUES (NEW."created_at", NEW."id", NEW."last_sent", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."token", NEW."user_id"); RETURN NULL;',
|
||||
hash="c20942bdc0713db74310da8da8c3138ca4c3bba9",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_7a2a8",
|
||||
table="accounts_emailverification",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="passwordreset",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_passwordresetevent" ("created_at", "expires_at", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "used", "user_id") VALUES (NEW."created_at", NEW."expires_at", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."token", NEW."used", NEW."user_id"); RETURN NULL;',
|
||||
hash="496ac059671b25460cdf2ca20d0e43b14d417a26",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_d2b72",
|
||||
table="accounts_passwordreset",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="passwordreset",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_passwordresetevent" ("created_at", "expires_at", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "used", "user_id") VALUES (NEW."created_at", NEW."expires_at", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."token", NEW."used", NEW."user_id"); RETURN NULL;',
|
||||
hash="c40acc416f85287b4a6fcc06724626707df90016",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_526d2",
|
||||
table="accounts_passwordreset",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userevent" ("ban_date", "ban_reason", "date_joined", "email", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "role", "theme_preference", "user_id", "username") VALUES (NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."role", NEW."theme_preference", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="b6992f02a4c1135fef9527e3f1ed330e2e626267",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_3867c",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userevent" ("ban_date", "ban_reason", "date_joined", "email", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "role", "theme_preference", "user_id", "username") VALUES (NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."role", NEW."theme_preference", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="6c3271b9f184dc137da7b9e42b0ae9f72d47c9c2",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e890",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userprofile",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userprofileevent" ("avatar", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||
hash="af6a89f13ff879d978a1154bbcf4664de0fcf913",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_c09d7",
|
||||
table="accounts_userprofile",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userprofile",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userprofileevent" ("avatar", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||
hash="37e99b5cc374ec0a3fc44d2482b411cba63fa84d",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_87ef6",
|
||||
table="accounts_userprofile",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailverificationevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailverificationevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.emailverification",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailverificationevent",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="passwordresetevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="passwordresetevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.passwordreset",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="passwordresetevent",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofileevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofileevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.userprofile",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofileevent",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -5,8 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
import os
|
||||
import secrets
|
||||
from apps.core.history import TrackedModel
|
||||
|
||||
# import pghistory
|
||||
import pghistory
|
||||
|
||||
|
||||
def generate_random_id(model_class, id_field):
|
||||
@@ -23,6 +22,7 @@ def generate_random_id(model_class, id_field):
|
||||
return new_id
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class User(AbstractUser):
|
||||
class Roles(models.TextChoices):
|
||||
USER = "USER", _("User")
|
||||
@@ -79,6 +79,7 @@ class User(AbstractUser):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class UserProfile(models.Model):
|
||||
# Read-only ID
|
||||
profile_id = models.CharField(
|
||||
@@ -137,6 +138,7 @@ class UserProfile(models.Model):
|
||||
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)
|
||||
@@ -151,6 +153,7 @@ class EmailVerification(models.Model):
|
||||
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)
|
||||
|
||||
246
backend/apps/accounts/serializers.py
Normal file
246
backend/apps/accounts/serializers.py
Normal file
@@ -0,0 +1,246 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from .models import User, PasswordReset
|
||||
from apps.email_service.services import EmailService
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
User serializer for API responses
|
||||
"""
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id', 'username', 'email', 'first_name', 'last_name',
|
||||
'date_joined', 'is_active', 'avatar_url'
|
||||
]
|
||||
read_only_fields = ['id', 'date_joined', 'is_active']
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get user avatar URL"""
|
||||
if hasattr(obj, 'profile') and obj.profile.avatar:
|
||||
return obj.profile.avatar.url
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
username = attrs.get('username')
|
||||
password = attrs.get('password')
|
||||
|
||||
if username and password:
|
||||
return attrs
|
||||
|
||||
raise serializers.ValidationError(
|
||||
'Must include username/email and password.'
|
||||
)
|
||||
|
||||
|
||||
class SignupSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for user registration
|
||||
"""
|
||||
password = serializers.CharField(
|
||||
write_only=True,
|
||||
validators=[validate_password],
|
||||
style={'input_type': 'password'}
|
||||
)
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True,
|
||||
style={'input_type': 'password'}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'username', 'email', 'first_name', 'last_name',
|
||||
'password', 'password_confirm'
|
||||
]
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': True},
|
||||
'email': {'required': True},
|
||||
}
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email is unique"""
|
||||
if UserModel.objects.filter(email=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
"A user with this email already exists."
|
||||
)
|
||||
return value
|
||||
|
||||
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."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate passwords match"""
|
||||
password = attrs.get('password')
|
||||
password_confirm = attrs.get('password_confirm')
|
||||
|
||||
if password != password_confirm:
|
||||
raise serializers.ValidationError({
|
||||
'password_confirm': 'Passwords do not match.'
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create user with validated data"""
|
||||
validated_data.pop('password_confirm', None)
|
||||
password = validated_data.pop('password')
|
||||
|
||||
user = UserModel.objects.create(
|
||||
**validated_data
|
||||
)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class PasswordResetSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for password reset request
|
||||
"""
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email exists"""
|
||||
try:
|
||||
user = UserModel.objects.get(email=value)
|
||||
self.user = user
|
||||
return value
|
||||
except UserModel.DoesNotExist:
|
||||
# Don't reveal if email exists or not for security
|
||||
return value
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Send password reset email if user exists"""
|
||||
if hasattr(self, 'user'):
|
||||
# Create password reset token
|
||||
token = get_random_string(64)
|
||||
PasswordReset.objects.update_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
'token': token,
|
||||
'expires_at': timezone.now() + timedelta(hours=24),
|
||||
'used': False
|
||||
}
|
||||
)
|
||||
|
||||
# Send reset email
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
site = get_current_site(request)
|
||||
reset_url = f"{request.scheme}://{site.domain}/reset-password/{token}/"
|
||||
|
||||
context = {
|
||||
'user': self.user,
|
||||
'reset_url': reset_url,
|
||||
'site_name': site.name,
|
||||
}
|
||||
|
||||
email_html = render_to_string(
|
||||
'accounts/email/password_reset.html',
|
||||
context
|
||||
)
|
||||
|
||||
EmailService.send_email(
|
||||
to=getattr(self.user, 'email', None),
|
||||
subject="Reset your password",
|
||||
text=f"Click the link to reset your password: {reset_url}",
|
||||
site=site,
|
||||
html=email_html,
|
||||
)
|
||||
|
||||
|
||||
class PasswordChangeSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for password change
|
||||
"""
|
||||
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'}
|
||||
)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
"""Validate old password is correct"""
|
||||
user = self.context['request'].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError(
|
||||
'Old password is incorrect.'
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate new passwords match"""
|
||||
new_password = attrs.get('new_password')
|
||||
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.'
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Change user password"""
|
||||
user = self.context['request'].user
|
||||
new_password = self.initial_data.get(
|
||||
'new_password') if self.initial_data else None
|
||||
|
||||
if new_password is None:
|
||||
raise serializers.ValidationError('New password is required.')
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class SocialProviderSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for social authentication providers
|
||||
"""
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
login_url = serializers.URLField()
|
||||
Reference in New Issue
Block a user