feat: Introduce lists and reviews apps, refactor user list functionality from accounts, and add user profile fields.

This commit is contained in:
pacnpal
2025-12-26 09:27:44 -05:00
parent ed04b30469
commit cd8868a591
37 changed files with 5900 additions and 281 deletions

View File

@@ -31,8 +31,6 @@ from apps.core.admin import (
from .models import (
EmailVerification,
PasswordReset,
TopList,
TopListItem,
User,
UserProfile,
)
@@ -81,18 +79,6 @@ class UserProfileInline(admin.StackedInline):
)
class TopListItemInline(admin.TabularInline):
"""
Inline admin for TopListItem within TopList admin.
Shows list items ordered by rank with content linking.
"""
model = TopListItem
extra = 1
fields = ("content_type", "object_id", "rank", "notes")
ordering = ("rank",)
show_change_link = True
@admin.register(User)
@@ -683,181 +669,4 @@ class PasswordResetAdmin(ReadOnlyAdminMixin, BaseModelAdmin):
return actions
@admin.register(TopList)
class TopListAdmin(
QueryOptimizationMixin, ExportActionMixin, TimestampFieldsMixin, BaseModelAdmin
):
"""
Admin interface for user top lists.
Manages user-created top lists with inline item editing
and category filtering.
"""
list_display = (
"title",
"user_link",
"category",
"item_count",
"created_at",
"updated_at",
)
list_filter = ("category", "created_at", "updated_at")
list_select_related = ["user"]
list_prefetch_related = ["items"]
search_fields = ("title", "user__username", "description")
autocomplete_fields = ["user"]
inlines = [TopListItemInline]
export_fields = ["id", "title", "user", "category", "created_at", "updated_at"]
export_filename_prefix = "top_lists"
fieldsets = (
(
"Basic Information",
{
"fields": ("user", "title", "category", "description"),
"description": "List identification and categorization.",
},
),
(
"Timestamps",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
readonly_fields = ("created_at", "updated_at")
@admin.display(description="User")
def user_link(self, obj):
"""Display user as clickable link."""
if obj.user:
from django.urls import reverse
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
return "-"
@admin.display(description="Items")
def item_count(self, obj):
"""Display count of items in the list."""
if hasattr(obj, "_item_count"):
return obj._item_count
return obj.items.count()
def get_queryset(self, request):
"""Optimize queryset with item count annotation."""
qs = super().get_queryset(request)
qs = qs.annotate(_item_count=Count("items", distinct=True))
return qs
@admin.action(description="Publish selected lists")
def publish_lists(self, request, queryset):
"""Mark selected lists as published."""
# Assuming there's a published field
self.message_user(request, f"Published {queryset.count()} lists.")
@admin.action(description="Unpublish selected lists")
def unpublish_lists(self, request, queryset):
"""Mark selected lists as unpublished."""
self.message_user(request, f"Unpublished {queryset.count()} lists.")
def get_actions(self, request):
"""Add custom actions."""
actions = super().get_actions(request)
actions["publish_lists"] = (
self.publish_lists,
"publish_lists",
"Publish selected lists",
)
actions["unpublish_lists"] = (
self.unpublish_lists,
"unpublish_lists",
"Unpublish selected lists",
)
return actions
@admin.register(TopListItem)
class TopListItemAdmin(QueryOptimizationMixin, BaseModelAdmin):
"""
Admin interface for top list items.
Manages individual items within top lists with
content type linking and reordering.
"""
list_display = (
"top_list_link",
"content_type",
"object_id",
"rank",
"content_preview",
)
list_filter = ("top_list__category", "content_type", "rank")
list_select_related = ["top_list", "top_list__user", "content_type"]
search_fields = ("top_list__title", "notes", "top_list__user__username")
autocomplete_fields = ["top_list"]
ordering = ("top_list", "rank")
fieldsets = (
(
"List Information",
{
"fields": ("top_list", "rank"),
"description": "The list this item belongs to and its position.",
},
),
(
"Item Details",
{
"fields": ("content_type", "object_id", "notes"),
"description": "The content this item references.",
},
),
)
@admin.display(description="Top List")
def top_list_link(self, obj):
"""Display top list as clickable link."""
if obj.top_list:
from django.urls import reverse
url = reverse("admin:accounts_toplist_change", args=[obj.top_list.pk])
return format_html('<a href="{}">{}</a>', url, obj.top_list.title)
return "-"
@admin.display(description="Content")
def content_preview(self, obj):
"""Display preview of linked content."""
try:
content_obj = obj.content_type.get_object_for_this_type(pk=obj.object_id)
return str(content_obj)[:50]
except Exception:
return format_html('<span style="color: red;">Not found</span>')
@admin.action(description="Move up in list")
def move_up(self, request, queryset):
"""Move selected items up in their lists."""
for item in queryset:
if item.rank > 1:
item.rank -= 1
item.save(update_fields=["rank"])
self.message_user(request, "Items moved up.")
@admin.action(description="Move down in list")
def move_down(self, request, queryset):
"""Move selected items down in their lists."""
for item in queryset:
item.rank += 1
item.save(update_fields=["rank"])
self.message_user(request, "Items moved down.")
def get_actions(self, request):
"""Add reordering actions."""
actions = super().get_actions(request)
actions["move_up"] = (self.move_up, "move_up", "Move up in list")
actions["move_down"] = (self.move_down, "move_down", "Move down in list")
return actions

View File

@@ -112,6 +112,51 @@ theme_preferences = ChoiceGroup(
)
# =============================================================================
# UNIT SYSTEMS
# =============================================================================
unit_systems = ChoiceGroup(
name="unit_systems",
choices=[
RichChoice(
value="metric",
label="Metric",
description="Use metric units (meters, km/h)",
metadata={
"color": "blue",
"icon": "ruler",
"css_class": "text-blue-600 bg-blue-50",
"units": {
"distance": "m",
"speed": "km/h",
"weight": "kg",
"large_distance": "km",
},
"sort_order": 1,
}
),
RichChoice(
value="imperial",
label="Imperial",
description="Use imperial units (feet, mph)",
metadata={
"color": "green",
"icon": "ruler",
"css_class": "text-green-600 bg-green-50",
"units": {
"distance": "ft",
"speed": "mph",
"weight": "lbs",
"large_distance": "mi",
},
"sort_order": 2,
}
),
]
)
# =============================================================================
# PRIVACY LEVELS
# =============================================================================
@@ -557,6 +602,7 @@ notification_priorities = ChoiceGroup(
# Register each choice group individually
register_choices("user_roles", user_roles.choices, "accounts", "User role classifications")
register_choices("theme_preferences", theme_preferences.choices, "accounts", "Theme preference options")
register_choices("unit_systems", unit_systems.choices, "accounts", "Unit system preferences")
register_choices("privacy_levels", privacy_levels.choices, "accounts", "Privacy level settings")
register_choices("top_list_categories", top_list_categories.choices, "accounts", "Top list category types")
register_choices("notification_types", notification_types.choices, "accounts", "Notification type classifications")

View File

@@ -11,6 +11,7 @@ from django.utils import timezone
from apps.core.history import TrackedModel
from apps.core.choices import RichChoiceField
import pghistory
# from django_cloudflareimages_toolkit.models import CloudflareImage
def generate_random_id(model_class, id_field):
@@ -214,10 +215,11 @@ class UserProfile(models.Model):
help_text="Legacy display name field - use User.display_name instead",
)
avatar = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
"django_cloudflareimages_toolkit.CloudflareImage",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="user_profiles",
help_text="User's avatar image",
)
pronouns = models.CharField(
@@ -225,6 +227,16 @@ class UserProfile(models.Model):
)
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)"
)
unit_system = RichChoiceField(
choice_group="unit_systems",
domain="accounts",
max_length=10,
default="metric",
help_text="Preferred measurement system",
)
# Social media links
twitter = models.URLField(blank=True, help_text="Twitter profile URL")
@@ -380,65 +392,6 @@ class PasswordReset(models.Model):
verbose_name_plural = "Password Resets"
# @pghistory.track()
class TopList(TrackedModel):
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="top_lists",
help_text="User who created this list",
)
title = models.CharField(max_length=100, help_text="Title of the top list")
category = RichChoiceField(
choice_group="top_list_categories",
domain="accounts",
max_length=2,
help_text="Category of items in this list",
)
description = models.TextField(blank=True, help_text="Description of the list")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
verbose_name = "Top List"
verbose_name_plural = "Top Lists"
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",
help_text="Top list this item belongs to",
)
content_type = models.ForeignKey(
"contenttypes.ContentType",
on_delete=models.CASCADE,
help_text="Type of item (park, ride, etc.)",
)
object_id = models.PositiveIntegerField(help_text="ID of the item")
rank = models.PositiveIntegerField(help_text="Position in the list")
notes = models.TextField(blank=True, help_text="User's notes about this item")
class Meta(TrackedModel.Meta):
verbose_name = "Top List Item"
verbose_name_plural = "Top List Items"
ordering = ["rank"]
unique_together = [["top_list", "rank"]]
def __str__(self):
return f"#{self.rank} in {self.top_list.title}"
@pghistory.track()

View File

@@ -19,7 +19,9 @@ class UserSerializer(serializers.ModelSerializer):
"""
avatar_url = serializers.SerializerMethodField()
display_name = serializers.SerializerMethodField()
display_name = serializers.CharField(source="profile.display_name", required=False)
unit_system = serializers.CharField(source="profile.unit_system", required=False)
location = serializers.CharField(source="profile.location", required=False)
class Meta:
model = User
@@ -31,6 +33,8 @@ class UserSerializer(serializers.ModelSerializer):
"date_joined",
"is_active",
"avatar_url",
"unit_system",
"location",
]
read_only_fields = ["id", "date_joined", "is_active"]
@@ -40,9 +44,15 @@ class UserSerializer(serializers.ModelSerializer):
return obj.profile.avatar.url
return None
def get_display_name(self, obj) -> str:
"""Get user display name"""
return obj.get_display_name()
def update(self, instance, validated_data):
profile_data = validated_data.pop("profile", {})
profile = instance.profile
for attr, value in profile_data.items():
setattr(profile, attr, value)
profile.save()
return super().update(instance, validated_data)
class LoginSerializer(serializers.Serializer):

View File

@@ -20,10 +20,10 @@ from django.core.files.uploadedfile import UploadedFile
from apps.accounts.models import (
User,
PasswordReset,
TopList,
EmailVerification,
UserProfile,
)
from apps.lists.models import UserList
from django_forwardemail.services import EmailService
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
@@ -208,9 +208,9 @@ class ProfileView(DetailView):
.order_by("-created_at")[:5]
)
def _get_user_top_lists(self, user: User) -> QuerySet[TopList]:
def _get_user_top_lists(self, user: User) -> QuerySet[UserList]:
return (
TopList.objects.filter(user=user)
UserList.objects.filter(user=user)
.select_related("user", "user__profile")
.prefetch_related("items")
.order_by("-created_at")[:5]
@@ -232,6 +232,12 @@ class SettingsView(LoginRequiredMixin, TemplateView):
if display_name := request.POST.get("display_name"):
profile.display_name = display_name
if unit_system := request.POST.get("unit_system"):
profile.unit_system = unit_system
if location := request.POST.get("location"):
profile.location = location
if "avatar" in request.FILES:
avatar_file = cast(UploadedFile, request.FILES["avatar"])
profile.avatar.save(avatar_file.name, avatar_file, save=False)

View File

@@ -13,7 +13,7 @@ from apps.api.v1.serializers.accounts import (
PrivacySettingsSerializer,
SecuritySettingsSerializer,
UserStatisticsSerializer,
TopListSerializer,
UserListSerializer,
AccountUpdateSerializer,
ProfileUpdateSerializer,
ThemePreferenceSerializer,
@@ -26,10 +26,10 @@ from apps.accounts.services import UserDeletionService
from apps.accounts.models import (
User,
UserProfile,
TopList,
UserNotification,
NotificationPreference,
)
from apps.lists.models import UserList
import logging
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
@@ -831,7 +831,7 @@ def check_user_deletion_eligibility(request, user_id):
user, "uploaded_ride_photos", user.__class__.objects.none()
).count(),
"top_lists": getattr(
user, "top_lists", user.__class__.objects.none()
user, "user_lists", user.__class__.objects.none()
).count(),
"edit_submissions": getattr(
user, "edit_submissions", user.__class__.objects.none()
@@ -1318,7 +1318,7 @@ def get_user_statistics(request):
"rides_ridden": RideReview.objects.filter(user=user).values("ride").distinct().count(),
"reviews_written": ParkReview.objects.filter(user=user).count() + RideReview.objects.filter(user=user).count(),
"photos_uploaded": total_photos_uploaded,
"top_lists_created": TopList.objects.filter(user=user).count(),
"top_lists_created": UserList.objects.filter(user=user).count(),
"member_since": user.date_joined,
"last_activity": user.last_login,
}
@@ -1335,7 +1335,7 @@ def get_user_statistics(request):
summary="Get user's top lists",
description="Get all top lists created by the authenticated user.",
responses={
200: TopListSerializer(many=True),
200: UserListSerializer(many=True),
401: {"description": "Authentication required"},
},
tags=["User Content"],
@@ -1344,8 +1344,8 @@ def get_user_statistics(request):
@permission_classes([IsAuthenticated])
def get_user_top_lists(request):
"""Get user's top lists."""
top_lists = TopList.objects.filter(user=request.user).order_by("-created_at")
serializer = TopListSerializer(top_lists, many=True)
top_lists = UserList.objects.filter(user=request.user).order_by("-created_at")
serializer = UserListSerializer(top_lists, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -1353,9 +1353,9 @@ def get_user_top_lists(request):
operation_id="create_top_list",
summary="Create a new top list",
description="Create a new top list for the authenticated user.",
request=TopListSerializer,
request=UserListSerializer,
responses={
201: TopListSerializer,
201: UserListSerializer,
400: {"description": "Validation error"},
},
tags=["User Content"],
@@ -1364,7 +1364,7 @@ def get_user_top_lists(request):
@permission_classes([IsAuthenticated])
def create_top_list(request):
"""Create a new top list."""
serializer = TopListSerializer(data=request.data, context={"request": request})
serializer = UserListSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save(user=request.user)
@@ -1377,9 +1377,9 @@ def create_top_list(request):
operation_id="update_top_list",
summary="Update a top list",
description="Update a top list owned by the authenticated user.",
request=TopListSerializer,
request=UserListSerializer,
responses={
200: TopListSerializer,
200: UserListSerializer,
400: {"description": "Validation error"},
404: {"description": "Top list not found"},
},
@@ -1390,14 +1390,14 @@ def create_top_list(request):
def update_top_list(request, list_id):
"""Update a top list."""
try:
top_list = TopList.objects.get(id=list_id, user=request.user)
except TopList.DoesNotExist:
top_list = UserList.objects.get(id=list_id, user=request.user)
except UserList.DoesNotExist:
return Response(
{"error": "Top list not found"},
status=status.HTTP_404_NOT_FOUND
)
serializer = TopListSerializer(
serializer = UserListSerializer(
top_list, data=request.data, partial=True, context={"request": request}
)
@@ -1423,10 +1423,10 @@ def update_top_list(request, list_id):
def delete_top_list(request, list_id):
"""Delete a top list."""
try:
top_list = TopList.objects.get(id=list_id, user=request.user)
top_list = UserList.objects.get(id=list_id, user=request.user)
top_list.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except TopList.DoesNotExist:
except UserList.DoesNotExist:
return Response(
{"error": "Top list not found"},
status=status.HTTP_404_NOT_FOUND

View File

@@ -1081,3 +1081,45 @@ class ParkImageSettingsAPIView(APIView):
park, context={"request": request}
)
return Response(output_serializer.data)
# --- Operator list ----------------------------------------------------------
@extend_schema(
summary="List park operators",
description="List all companies with OPERATOR role, including park counts.",
responses={
200: OpenApiTypes.OBJECT,
},
tags=["Parks"],
)
class OperatorListAPIView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
if not MODELS_AVAILABLE:
return Response(
{"detail": "Models not available"},
status=status.HTTP_501_NOT_IMPLEMENTED
)
operators = (
Company.objects.filter(roles__contains=["OPERATOR"])
.annotate(park_count=Count("operated_parks"))
.only("id", "name", "slug", "roles", "description")
.order_by("name")
)
# Simple serialization
data = [
{
"id": op.id,
"name": op.name,
"slug": op.slug,
"description": op.description,
"park_count": op.park_count,
}
for op in operators
]
return Response({
"results": data,
"count": len(data)
})

View File

@@ -16,15 +16,24 @@ from .park_views import (
CompanySearchAPIView,
ParkSearchSuggestionsAPIView,
ParkImageSettingsAPIView,
OperatorListAPIView,
)
from .park_rides_views import (
ParkRidesListAPIView,
ParkRideDetailAPIView,
ParkComprehensiveDetailAPIView,
)
from apps.parks.views import location_search, reverse_geocode
from .views import ParkPhotoViewSet, HybridParkAPIView, ParkFilterMetadataAPIView
from .ride_photos_views import RidePhotoViewSet
from .ride_photos_views import RidePhotoViewSet
from .ride_reviews_views import RideReviewViewSet
from apps.parks.views_roadtrip import (
CreateTripView,
FindParksAlongRouteView,
GeocodeAddressView,
ParkDistanceCalculatorView,
)
# Create router for nested photo endpoints
router = DefaultRouter()
@@ -84,4 +93,19 @@ urlpatterns = [
# Nested ride review endpoints - reviews for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
# Nested ride review endpoints - reviews for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
# Roadtrip API endpoints
path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip-create"),
path("roadtrip/find-along-route/", FindParksAlongRouteView.as_view(), name="roadtrip-find"),
path("roadtrip/geocode/", GeocodeAddressView.as_view(), name="roadtrip-geocode"),
path("roadtrip/distance/", ParkDistanceCalculatorView.as_view(), name="roadtrip-distance"),
# Operator endpoints
path("operators/", OperatorListAPIView.as_view(), name="operator-list"),
# Location search endpoints
path("search/location/", location_search, name="location-search"),
path("search/reverse-geocode/", reverse_geocode, name="reverse-geocode"),
]

View File

@@ -21,6 +21,8 @@ from .views import (
RideImageSettingsAPIView,
HybridRideAPIView,
RideFilterMetadataAPIView,
ManufacturerListAPIView,
DesignerListAPIView,
)
from .photo_views import RidePhotoViewSet
@@ -56,6 +58,10 @@ urlpatterns = [
RideSearchSuggestionsAPIView.as_view(),
name="ride-search-suggestions",
),
# Manufacturer and Designer endpoints
path("manufacturers/", ManufacturerListAPIView.as_view(), name="manufacturer-list"),
path("designers/", DesignerListAPIView.as_view(), name="designer-list"),
# Ride model management endpoints - nested under rides/manufacturers
path(
"manufacturers/<slug:manufacturer_slug>/",

View File

@@ -2456,3 +2456,56 @@ class RideFilterMetadataAPIView(APIView):
# Reuse the same filter extraction logic
view = HybridRideAPIView()
return view._extract_filters(query_params)
# === MANUFACTURER & DESIGNER LISTS ===
class BaseCompanyListAPIView(APIView):
permission_classes = [permissions.AllowAny]
role = None
def get(self, request: Request) -> Response:
if not MODELS_AVAILABLE:
return Response(
{"detail": "Models not available"},
status=status.HTTP_501_NOT_IMPLEMENTED
)
companies = (
Company.objects.filter(roles__contains=[self.role])
.annotate(ride_count=Count("manufactured_rides" if self.role == "MANUFACTURER" else "designed_rides"))
.only("id", "name", "slug", "roles", "description")
.order_by("name")
)
data = [
{
"id": c.id,
"name": c.name,
"slug": c.slug,
"description": c.description,
"ride_count": c.ride_count,
}
for c in companies
]
return Response({
"results": data,
"count": len(data)
})
@extend_schema(
summary="List manufacturers",
description="List all companies with MANUFACTURER role.",
responses={200: OpenApiTypes.OBJECT},
tags=["Rides"],
)
class ManufacturerListAPIView(BaseCompanyListAPIView):
role = "MANUFACTURER"
@extend_schema(
summary="List designers",
description="List all companies with DESIGNER role.",
responses={200: OpenApiTypes.OBJECT},
tags=["Rides"],
)
class DesignerListAPIView(BaseCompanyListAPIView):
role = "DESIGNER"

View File

@@ -14,10 +14,10 @@ from drf_spectacular.utils import (
from apps.accounts.models import (
User,
UserProfile,
TopList,
UserNotification,
NotificationPreference,
)
from apps.lists.models import UserList
from apps.core.choices.serializers import RichChoiceFieldSerializer
UserModel = get_user_model()
@@ -85,6 +85,8 @@ class UserProfileSerializer(serializers.ModelSerializer):
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
"unit_system",
"location",
]
read_only_fields = ["profile_id", "avatar_url", "avatar_variants"]
@@ -503,8 +505,8 @@ class UserStatisticsSerializer(serializers.Serializer):
@extend_schema_serializer(
examples=[
OpenApiExample(
"Top List Example",
summary="User's top list",
"User List Example",
summary="User's list",
description="A user's ranked list of rides or parks",
value={
"id": 1,
@@ -518,13 +520,13 @@ class UserStatisticsSerializer(serializers.Serializer):
)
]
)
class TopListSerializer(serializers.ModelSerializer):
"""Serializer for user's top lists."""
class UserListSerializer(serializers.ModelSerializer):
"""Serializer for user's lists."""
items_count = serializers.SerializerMethodField()
class Meta:
model = TopList
model = UserList
fields = [
"id",
"title",
@@ -611,6 +613,8 @@ class ProfileUpdateSerializer(serializers.ModelSerializer):
"instagram",
"youtube",
"discord",
"unit_system",
"location",
]
def validate_display_name(self, value):

View File

@@ -73,6 +73,7 @@ urlpatterns = [
path("email/", include("apps.api.v1.email.urls")),
path("core/", include("apps.api.v1.core.urls")),
path("maps/", include("apps.api.v1.maps.urls")),
path("lists/", include("apps.lists.urls")),
path("moderation/", include("apps.moderation.urls")),
# Cloudflare Images Toolkit API endpoints
path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")),

View File

@@ -0,0 +1,70 @@
# Generated by Django 5.1.6 on 2025-12-26 14:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("core", "0003_pageviewevent_slughistoryevent_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="slughistory",
options={
"ordering": ["-created_at"],
"verbose_name": "Slug History",
"verbose_name_plural": "Slug Histories",
},
),
migrations.AlterField(
model_name="slughistory",
name="content_type",
field=models.ForeignKey(
help_text="Type of model this slug belongs to",
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
migrations.AlterField(
model_name="slughistory",
name="object_id",
field=models.CharField(
help_text="ID of the object this slug belongs to", max_length=50
),
),
migrations.AlterField(
model_name="slughistory",
name="old_slug",
field=models.SlugField(help_text="Previous slug value", max_length=200),
),
migrations.AlterField(
model_name="slughistoryevent",
name="content_type",
field=models.ForeignKey(
db_constraint=False,
help_text="Type of model this slug belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
migrations.AlterField(
model_name="slughistoryevent",
name="object_id",
field=models.CharField(
help_text="ID of the object this slug belongs to", max_length=50
),
),
migrations.AlterField(
model_name="slughistoryevent",
name="old_slug",
field=models.SlugField(
db_index=False, help_text="Previous slug value", max_length=200
),
),
]

View File

@@ -0,0 +1,18 @@
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
"""
Custom permission to only allow owners of an object to edit it.
"""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed to any request,
# so we'll always allow GET, HEAD or OPTIONS requests.
if request.method in permissions.SAFE_METHODS:
return True
# Write permissions are only allowed to the owner of the object.
# Assumes the model instance has an `user` attribute.
if hasattr(obj, 'user'):
return obj.user == request.user
return False

View File

View File

@@ -0,0 +1,90 @@
from django.contrib import admin
from django.db.models import Count
from django.utils.html import format_html
from apps.core.admin import (
BaseModelAdmin,
ExportActionMixin,
QueryOptimizationMixin,
TimestampFieldsMixin,
)
from .models import UserList, ListItem
class ListItemInline(admin.TabularInline):
"""Inline admin for ListItem within UserList admin."""
model = ListItem
extra = 1
fields = ("content_type", "object_id", "rank", "notes")
ordering = ("rank",)
show_change_link = True
@admin.register(UserList)
class UserListAdmin(QueryOptimizationMixin, ExportActionMixin, TimestampFieldsMixin, BaseModelAdmin):
"""Admin interface for UserList."""
list_display = (
"title",
"user_link",
"category",
"is_public",
"item_count",
"created_at",
"updated_at",
)
list_filter = ("category", "is_public", "created_at", "updated_at")
list_select_related = ["user"]
list_prefetch_related = ["items"]
search_fields = ("title", "user__username", "description")
autocomplete_fields = ["user"]
inlines = [ListItemInline]
export_fields = ["id", "title", "user", "category", "is_public", "created_at", "updated_at"]
export_filename_prefix = "user_lists"
fieldsets = (
(
"Basic Information",
{
"fields": ("user", "title", "category", "description", "is_public"),
"description": "List identification and categorization.",
},
),
(
"Timestamps",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
readonly_fields = ("created_at", "updated_at")
@admin.display(description="User")
def user_link(self, obj):
if obj.user:
from django.urls import reverse
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
return "-"
@admin.display(description="Items")
def item_count(self, obj):
return obj.items.count()
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.annotate(_item_count=Count("items", distinct=True))
return qs
@admin.register(ListItem)
class ListItemAdmin(QueryOptimizationMixin, BaseModelAdmin):
"""Admin interface for ListItem."""
list_display = (
"user_list",
"content_type",
"object_id",
"rank",
)
list_filter = ("user_list__category", "content_type", "rank")
list_select_related = ["user_list", "user_list__user", "content_type"]

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class ListsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.lists"

View File

@@ -0,0 +1,284 @@
# Generated by Django 5.1.6 on 2025-12-26 14:13
import apps.core.choices.fields
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0006_delete_aggregateevent"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ListItem",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("object_id", models.PositiveIntegerField(help_text="ID of the item")),
("rank", models.PositiveIntegerField(help_text="Position in the list")),
("notes", models.TextField(blank=True, help_text="User's notes about this item")),
(
"content_type",
models.ForeignKey(
help_text="Type of item (park, ride, etc.)",
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
],
options={
"verbose_name": "List Item",
"verbose_name_plural": "List Items",
"ordering": ["rank"],
"abstract": False,
},
),
migrations.CreateModel(
name="UserList",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(help_text="Title of the list", max_length=100)),
(
"category",
apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="top_list_categories",
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("PK", "Park"),
],
domain="accounts",
help_text="Category of items in this list",
max_length=2,
),
),
("description", models.TextField(blank=True, help_text="Description of the list")),
("is_public", models.BooleanField(default=True, help_text="Whether this list is visible to others")),
(
"user",
models.ForeignKey(
help_text="User who created this list",
on_delete=django.db.models.deletion.CASCADE,
related_name="user_lists",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "User List",
"verbose_name_plural": "User Lists",
"ordering": ["-updated_at"],
"abstract": False,
},
),
migrations.CreateModel(
name="ListItemEvent",
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()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("object_id", models.PositiveIntegerField(help_text="ID of the item")),
("rank", models.PositiveIntegerField(help_text="Position in the list")),
("notes", models.TextField(blank=True, help_text="User's notes about this item")),
(
"content_type",
models.ForeignKey(
db_constraint=False,
help_text="Type of item (park, ride, etc.)",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="lists.listitem",
),
),
(
"user_list",
models.ForeignKey(
db_constraint=False,
help_text="List this item belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="lists.userlist",
),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="listitem",
name="user_list",
field=models.ForeignKey(
help_text="List this item belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="lists.userlist",
),
),
migrations.CreateModel(
name="UserListEvent",
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()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(help_text="Title of the list", max_length=100)),
(
"category",
apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="top_list_categories",
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("PK", "Park"),
],
domain="accounts",
help_text="Category of items in this list",
max_length=2,
),
),
("description", models.TextField(blank=True, help_text="Description of the list")),
("is_public", models.BooleanField(default=True, help_text="Whether this list is visible to others")),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="lists.userlist",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
help_text="User who created this list",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="userlist",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "lists_userlistevent" ("category", "created_at", "description", "id", "is_public", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", NEW."is_public", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="702082b0a9ed526aa1bffbec0839e9a2d7641f42",
operation="INSERT",
pgid="pgtrigger_insert_insert_7a128",
table="lists_userlist",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userlist",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "lists_userlistevent" ("category", "created_at", "description", "id", "is_public", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", NEW."is_public", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="843e25a795f48bb1dfbb3c5723598823a71e0da8",
operation="UPDATE",
pgid="pgtrigger_update_update_1d718",
table="lists_userlist",
when="AFTER",
),
),
),
migrations.AlterUniqueTogether(
name="listitem",
unique_together={("user_list", "rank")},
),
pgtrigger.migrations.AddTrigger(
model_name="listitem",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "lists_listitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "updated_at", "user_list_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."updated_at", NEW."user_list_id"); RETURN NULL;',
hash="09893103c0995cb295cdf83421583a93266593bb",
operation="INSERT",
pgid="pgtrigger_insert_insert_bb169",
table="lists_listitem",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="listitem",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "lists_listitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "updated_at", "user_list_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."updated_at", NEW."user_list_id"); RETURN NULL;',
hash="5617f50c7404a18a24f08bd237aecd466b496339",
operation="UPDATE",
pgid="pgtrigger_update_update_2b5a0",
table="lists_listitem",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,61 @@
from django.db import models
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from apps.core.history import TrackedModel
from apps.core.choices import RichChoiceField
import pghistory
@pghistory.track()
class UserList(TrackedModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="user_lists",
help_text="User who created this list",
)
title = models.CharField(max_length=100, help_text="Title of the list")
category = RichChoiceField(
choice_group="top_list_categories",
domain="accounts",
max_length=2,
help_text="Category of items in this list",
)
description = models.TextField(blank=True, help_text="Description of the list")
is_public = models.BooleanField(default=True, help_text="Whether this list is visible to others")
class Meta(TrackedModel.Meta):
verbose_name = "User List"
verbose_name_plural = "User Lists"
ordering = ["-updated_at"]
def __str__(self):
return f"{self.user.username}'s {self.category} List: {self.title}"
@pghistory.track()
class ListItem(TrackedModel):
user_list = models.ForeignKey(
UserList,
on_delete=models.CASCADE,
related_name="items",
help_text="List this item belongs to",
)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
help_text="Type of item (park, ride, etc.)",
)
object_id = models.PositiveIntegerField(help_text="ID of the item")
content_object = GenericForeignKey("content_type", "object_id")
rank = models.PositiveIntegerField(help_text="Position in the list")
notes = models.TextField(blank=True, help_text="User's notes about this item")
class Meta(TrackedModel.Meta):
verbose_name = "List Item"
verbose_name_plural = "List Items"
ordering = ["rank"]
unique_together = [["user_list", "rank"]]
def __str__(self):
return f"#{self.rank} in {self.user_list.title}"

View File

@@ -0,0 +1,57 @@
from rest_framework import serializers
from .models import UserList, ListItem
from apps.accounts.serializers import UserSerializer
class ListItemSerializer(serializers.ModelSerializer):
class Meta:
model = ListItem
fields = [
"id",
"user_list",
"content_type",
"object_id",
"rank",
"notes",
"created_at",
"updated_at",
"content_object_data",
]
read_only_fields = ["id", "created_at", "updated_at"]
content_object_data = serializers.SerializerMethodField()
def get_content_object_data(self, obj):
"""
Return serialized data for the content object (Park or Ride).
"""
# Avoid circular imports
from apps.api.v1.parks.serializers import ParkListSerializer
from apps.api.v1.rides.serializers import RideListSerializer
from apps.parks.models import Park
from apps.rides.models import Ride
if isinstance(obj.content_object, Park):
return ParkListSerializer(obj.content_object, context=self.context).data
elif isinstance(obj.content_object, Ride):
return RideListSerializer(obj.content_object, context=self.context).data
return None
class UserListSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
items = ListItemSerializer(many=True, read_only=True)
class Meta:
model = UserList
fields = [
"id",
"user",
"title",
"category",
"description",
"is_public",
"items",
"created_at",
"updated_at",
]
read_only_fields = ["id", "user", "created_at", "updated_at"]

View File

@@ -0,0 +1,11 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import UserListViewSet, ListItemViewSet
router = DefaultRouter()
router.register(r"lists", UserListViewSet, basename="list")
router.register(r"list-items", ListItemViewSet, basename="list-item")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,28 @@
from django.db.models import Q
from rest_framework import viewsets, permissions
from .models import UserList, ListItem
from .serializers import UserListSerializer, ListItemSerializer
from apps.core.permissions import IsOwnerOrReadOnly
class UserListViewSet(viewsets.ModelViewSet):
serializer_class = UserListSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
lookup_field = "id"
def get_queryset(self):
# Users can see their own lists and public lists
if self.request.user.is_authenticated:
return UserList.objects.filter(Q(is_public=True) | Q(user=self.request.user))
return UserList.objects.filter(is_public=True)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class ListItemViewSet(viewsets.ModelViewSet):
serializer_class = ListItemSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
lookup_field = "id"
def get_queryset(self):
return ListItem.objects.filter(user_list__is_public=True) | ListItem.objects.filter(user_list__user=self.request.user)

View File

@@ -0,0 +1,750 @@
# Generated by Django 5.1.6 on 2025-12-26 14:10
import apps.core.state_machine.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("moderation", "0007_convert_status_to_richfsmfield"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name="bulkoperation",
options={
"ordering": ["-created_at"],
"verbose_name": "Bulk Operation",
"verbose_name_plural": "Bulk Operations",
},
),
migrations.AlterModelOptions(
name="editsubmission",
options={
"ordering": ["-created_at"],
"verbose_name": "Edit Submission",
"verbose_name_plural": "Edit Submissions",
},
),
migrations.AlterModelOptions(
name="moderationaction",
options={
"ordering": ["-created_at"],
"verbose_name": "Moderation Action",
"verbose_name_plural": "Moderation Actions",
},
),
migrations.AlterModelOptions(
name="moderationqueue",
options={
"ordering": ["priority", "created_at"],
"verbose_name": "Moderation Queue Item",
"verbose_name_plural": "Moderation Queue Items",
},
),
migrations.AlterModelOptions(
name="moderationreport",
options={
"ordering": ["-created_at"],
"verbose_name": "Moderation Report",
"verbose_name_plural": "Moderation Reports",
},
),
migrations.AlterModelOptions(
name="photosubmission",
options={
"ordering": ["-created_at"],
"verbose_name": "Photo Submission",
"verbose_name_plural": "Photo Submissions",
},
),
migrations.AlterField(
model_name="bulkoperation",
name="completed_at",
field=models.DateTimeField(
blank=True, help_text="When this operation completed", null=True
),
),
migrations.AlterField(
model_name="bulkoperation",
name="created_by",
field=models.ForeignKey(
help_text="User who created this operation",
on_delete=django.db.models.deletion.CASCADE,
related_name="bulk_operations_created",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="bulkoperation",
name="started_at",
field=models.DateTimeField(
blank=True, help_text="When this operation started", null=True
),
),
migrations.AlterField(
model_name="bulkoperation",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this operation was last updated"
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="completed_at",
field=models.DateTimeField(
blank=True, help_text="When this operation completed", null=True
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="created_by",
field=models.ForeignKey(
db_constraint=False,
help_text="User who created this operation",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="started_at",
field=models.DateTimeField(
blank=True, help_text="When this operation started", null=True
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="bulk_operation_statuses",
choices=[
("PENDING", "Pending"),
("RUNNING", "Running"),
("COMPLETED", "Completed"),
("FAILED", "Failed"),
("CANCELLED", "Cancelled"),
],
default="PENDING",
domain="moderation",
max_length=20,
),
),
migrations.AlterField(
model_name="bulkoperationevent",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this operation was last updated"
),
),
migrations.AlterField(
model_name="editsubmission",
name="content_type",
field=models.ForeignKey(
help_text="Type of object being edited",
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
migrations.AlterField(
model_name="editsubmission",
name="handled_at",
field=models.DateTimeField(
blank=True, help_text="When this submission was handled", null=True
),
),
migrations.AlterField(
model_name="editsubmission",
name="handled_by",
field=models.ForeignKey(
blank=True,
help_text="Moderator who handled this submission",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="handled_submissions",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="editsubmission",
name="object_id",
field=models.PositiveIntegerField(
blank=True,
help_text="ID of object being edited (null for new objects)",
null=True,
),
),
migrations.AlterField(
model_name="editsubmission",
name="user",
field=models.ForeignKey(
help_text="User who submitted this edit",
on_delete=django.db.models.deletion.CASCADE,
related_name="edit_submissions",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="editsubmissionevent",
name="content_type",
field=models.ForeignKey(
db_constraint=False,
help_text="Type of object being edited",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
migrations.AlterField(
model_name="editsubmissionevent",
name="handled_at",
field=models.DateTimeField(
blank=True, help_text="When this submission was handled", null=True
),
),
migrations.AlterField(
model_name="editsubmissionevent",
name="handled_by",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Moderator who handled this submission",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="editsubmissionevent",
name="object_id",
field=models.PositiveIntegerField(
blank=True,
help_text="ID of object being edited (null for new objects)",
null=True,
),
),
migrations.AlterField(
model_name="editsubmissionevent",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="edit_submission_statuses",
choices=[
("PENDING", "Pending"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
],
default="PENDING",
domain="moderation",
max_length=20,
),
),
migrations.AlterField(
model_name="editsubmissionevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
help_text="User who submitted this edit",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationaction",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this action was created"
),
),
migrations.AlterField(
model_name="moderationaction",
name="moderator",
field=models.ForeignKey(
help_text="Moderator who took this action",
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_actions_taken",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationaction",
name="related_report",
field=models.ForeignKey(
blank=True,
help_text="Related moderation report",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="actions_taken",
to="moderation.moderationreport",
),
),
migrations.AlterField(
model_name="moderationaction",
name="target_user",
field=models.ForeignKey(
help_text="User this action was taken against",
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_actions_received",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationaction",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this action was last updated"
),
),
migrations.AlterField(
model_name="moderationactionevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this action was created"
),
),
migrations.AlterField(
model_name="moderationactionevent",
name="moderator",
field=models.ForeignKey(
db_constraint=False,
help_text="Moderator who took this action",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationactionevent",
name="related_report",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Related moderation report",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="moderation.moderationreport",
),
),
migrations.AlterField(
model_name="moderationactionevent",
name="target_user",
field=models.ForeignKey(
db_constraint=False,
help_text="User this action was taken against",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationactionevent",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this action was last updated"
),
),
migrations.AlterField(
model_name="moderationqueue",
name="assigned_at",
field=models.DateTimeField(
blank=True, help_text="When this item was assigned", null=True
),
),
migrations.AlterField(
model_name="moderationqueue",
name="assigned_to",
field=models.ForeignKey(
blank=True,
help_text="Moderator assigned to this item",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="assigned_queue_items",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationqueue",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this item was created"
),
),
migrations.AlterField(
model_name="moderationqueue",
name="flagged_by",
field=models.ForeignKey(
blank=True,
help_text="User who flagged this item",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="flagged_queue_items",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationqueue",
name="related_report",
field=models.ForeignKey(
blank=True,
help_text="Related moderation report",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="queue_items",
to="moderation.moderationreport",
),
),
migrations.AlterField(
model_name="moderationqueue",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this item was last updated"
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="assigned_at",
field=models.DateTimeField(
blank=True, help_text="When this item was assigned", null=True
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="assigned_to",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Moderator assigned to this item",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this item was created"
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="flagged_by",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="User who flagged this item",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="related_report",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Related moderation report",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="moderation.moderationreport",
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="moderation_queue_statuses",
choices=[
("PENDING", "Pending"),
("IN_PROGRESS", "In Progress"),
("COMPLETED", "Completed"),
("CANCELLED", "Cancelled"),
],
default="PENDING",
domain="moderation",
max_length=20,
),
),
migrations.AlterField(
model_name="moderationqueueevent",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this item was last updated"
),
),
migrations.AlterField(
model_name="moderationreport",
name="assigned_moderator",
field=models.ForeignKey(
blank=True,
help_text="Moderator assigned to handle this report",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="assigned_moderation_reports",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationreport",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this report was created"
),
),
migrations.AlterField(
model_name="moderationreport",
name="reported_by",
field=models.ForeignKey(
help_text="User who made this report",
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_reports_made",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationreport",
name="resolved_at",
field=models.DateTimeField(
blank=True, help_text="When this report was resolved", null=True
),
),
migrations.AlterField(
model_name="moderationreport",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this report was last updated"
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="assigned_moderator",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Moderator assigned to handle this report",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, help_text="When this report was created"
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="reported_by",
field=models.ForeignKey(
db_constraint=False,
help_text="User who made this report",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="resolved_at",
field=models.DateTimeField(
blank=True, help_text="When this report was resolved", null=True
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="moderation_report_statuses",
choices=[
("PENDING", "Pending Review"),
("UNDER_REVIEW", "Under Review"),
("RESOLVED", "Resolved"),
("DISMISSED", "Dismissed"),
],
default="PENDING",
domain="moderation",
max_length=20,
),
),
migrations.AlterField(
model_name="moderationreportevent",
name="updated_at",
field=models.DateTimeField(
auto_now=True, help_text="When this report was last updated"
),
),
migrations.AlterField(
model_name="photosubmission",
name="caption",
field=models.CharField(
blank=True, help_text="Photo caption", max_length=255
),
),
migrations.AlterField(
model_name="photosubmission",
name="content_type",
field=models.ForeignKey(
help_text="Type of object this photo is for",
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
migrations.AlterField(
model_name="photosubmission",
name="date_taken",
field=models.DateField(
blank=True, help_text="Date the photo was taken", null=True
),
),
migrations.AlterField(
model_name="photosubmission",
name="handled_at",
field=models.DateTimeField(
blank=True, help_text="When this submission was handled", null=True
),
),
migrations.AlterField(
model_name="photosubmission",
name="handled_by",
field=models.ForeignKey(
blank=True,
help_text="Moderator who handled this submission",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="handled_photos",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="photosubmission",
name="object_id",
field=models.PositiveIntegerField(
help_text="ID of object this photo is for"
),
),
migrations.AlterField(
model_name="photosubmission",
name="user",
field=models.ForeignKey(
help_text="User who submitted this photo",
on_delete=django.db.models.deletion.CASCADE,
related_name="photo_submissions",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="photosubmissionevent",
name="caption",
field=models.CharField(
blank=True, help_text="Photo caption", max_length=255
),
),
migrations.AlterField(
model_name="photosubmissionevent",
name="content_type",
field=models.ForeignKey(
db_constraint=False,
help_text="Type of object this photo is for",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
migrations.AlterField(
model_name="photosubmissionevent",
name="date_taken",
field=models.DateField(
blank=True, help_text="Date the photo was taken", null=True
),
),
migrations.AlterField(
model_name="photosubmissionevent",
name="handled_at",
field=models.DateTimeField(
blank=True, help_text="When this submission was handled", null=True
),
),
migrations.AlterField(
model_name="photosubmissionevent",
name="handled_by",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Moderator who handled this submission",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="photosubmissionevent",
name="object_id",
field=models.PositiveIntegerField(
help_text="ID of object this photo is for"
),
),
migrations.AlterField(
model_name="photosubmissionevent",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="photo_submission_statuses",
choices=[
("PENDING", "Pending"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
],
default="PENDING",
domain="moderation",
max_length=20,
),
),
migrations.AlterField(
model_name="photosubmissionevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
help_text="User who submitted this photo",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -0,0 +1,760 @@
# Generated by Django 5.1.6 on 2025-12-26 14:10
import apps.core.choices.fields
import apps.core.state_machine.fields
import django.contrib.postgres.fields
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0024_add_timezone_default"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name="company",
options={
"ordering": ["name"],
"verbose_name": "Company",
"verbose_name_plural": "Companies",
},
),
migrations.AlterModelOptions(
name="park",
options={
"ordering": ["name"],
"verbose_name": "Park",
"verbose_name_plural": "Parks",
},
),
migrations.AlterModelOptions(
name="parkarea",
options={
"ordering": ["park", "name"],
"verbose_name": "Park Area",
"verbose_name_plural": "Park Areas",
},
),
migrations.AlterModelOptions(
name="parkphoto",
options={
"ordering": ["-is_primary", "-created_at"],
"verbose_name": "Park Photo",
"verbose_name_plural": "Park Photos",
},
),
migrations.AlterModelOptions(
name="parkreview",
options={
"ordering": ["-created_at"],
"verbose_name": "Park Review",
"verbose_name_plural": "Park Reviews",
},
),
migrations.AlterField(
model_name="company",
name="description",
field=models.TextField(
blank=True, help_text="Detailed company description"
),
),
migrations.AlterField(
model_name="company",
name="founded_year",
field=models.PositiveIntegerField(
blank=True, help_text="Year the company was founded", null=True
),
),
migrations.AlterField(
model_name="company",
name="name",
field=models.CharField(help_text="Company name", max_length=255),
),
migrations.AlterField(
model_name="company",
name="parks_count",
field=models.IntegerField(
default=0, help_text="Number of parks operated (auto-calculated)"
),
),
migrations.AlterField(
model_name="company",
name="rides_count",
field=models.IntegerField(
default=0, help_text="Number of rides manufactured (auto-calculated)"
),
),
migrations.AlterField(
model_name="company",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="company_roles",
choices=[
("OPERATOR", "Park Operator"),
("PROPERTY_OWNER", "Property Owner"),
],
domain="parks",
max_length=20,
),
blank=True,
default=list,
help_text="Company roles (operator, manufacturer, etc.)",
size=None,
),
),
migrations.AlterField(
model_name="company",
name="slug",
field=models.SlugField(
help_text="URL-friendly identifier", max_length=255, unique=True
),
),
migrations.AlterField(
model_name="company",
name="website",
field=models.URLField(blank=True, help_text="Company website URL"),
),
migrations.AlterField(
model_name="companyevent",
name="description",
field=models.TextField(
blank=True, help_text="Detailed company description"
),
),
migrations.AlterField(
model_name="companyevent",
name="founded_year",
field=models.PositiveIntegerField(
blank=True, help_text="Year the company was founded", null=True
),
),
migrations.AlterField(
model_name="companyevent",
name="name",
field=models.CharField(help_text="Company name", max_length=255),
),
migrations.AlterField(
model_name="companyevent",
name="parks_count",
field=models.IntegerField(
default=0, help_text="Number of parks operated (auto-calculated)"
),
),
migrations.AlterField(
model_name="companyevent",
name="rides_count",
field=models.IntegerField(
default=0, help_text="Number of rides manufactured (auto-calculated)"
),
),
migrations.AlterField(
model_name="companyevent",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="company_roles",
choices=[
("OPERATOR", "Park Operator"),
("PROPERTY_OWNER", "Property Owner"),
],
domain="parks",
max_length=20,
),
blank=True,
default=list,
help_text="Company roles (operator, manufacturer, etc.)",
size=None,
),
),
migrations.AlterField(
model_name="companyevent",
name="slug",
field=models.SlugField(
db_index=False, help_text="URL-friendly identifier", max_length=255
),
),
migrations.AlterField(
model_name="companyevent",
name="website",
field=models.URLField(blank=True, help_text="Company website URL"),
),
migrations.AlterField(
model_name="companyheadquarters",
name="company",
field=models.OneToOneField(
help_text="Company this headquarters belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="headquarters",
to="parks.company",
),
),
migrations.AlterField(
model_name="companyheadquartersevent",
name="company",
field=models.ForeignKey(
db_constraint=False,
help_text="Company this headquarters belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.company",
),
),
migrations.AlterField(
model_name="park",
name="average_rating",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average user rating (110)",
max_digits=3,
null=True,
),
),
migrations.AlterField(
model_name="park",
name="closing_date",
field=models.DateField(blank=True, help_text="Closing date", null=True),
),
migrations.AlterField(
model_name="park",
name="coaster_count",
field=models.IntegerField(
blank=True, help_text="Total coaster count", null=True
),
),
migrations.AlterField(
model_name="park",
name="description",
field=models.TextField(blank=True, help_text="Park description"),
),
migrations.AlterField(
model_name="park",
name="name",
field=models.CharField(help_text="Park name", max_length=255),
),
migrations.AlterField(
model_name="park",
name="opening_date",
field=models.DateField(blank=True, help_text="Opening date", null=True),
),
migrations.AlterField(
model_name="park",
name="operating_season",
field=models.CharField(
blank=True, help_text="Operating season", max_length=255
),
),
migrations.AlterField(
model_name="park",
name="ride_count",
field=models.IntegerField(
blank=True, help_text="Total ride count", null=True
),
),
migrations.AlterField(
model_name="park",
name="size_acres",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Park size in acres",
max_digits=10,
null=True,
),
),
migrations.AlterField(
model_name="park",
name="slug",
field=models.SlugField(
help_text="URL-friendly identifier", max_length=255, unique=True
),
),
migrations.AlterField(
model_name="park",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="statuses",
choices=[],
default="OPERATING",
domain="parks",
max_length=20,
),
),
migrations.AlterField(
model_name="park",
name="website",
field=models.URLField(blank=True, help_text="Official website URL"),
),
migrations.AlterField(
model_name="parkarea",
name="closing_date",
field=models.DateField(
blank=True, help_text="Date this area closed (if applicable)", null=True
),
),
migrations.AlterField(
model_name="parkarea",
name="description",
field=models.TextField(
blank=True, help_text="Detailed description of the area"
),
),
migrations.AlterField(
model_name="parkarea",
name="name",
field=models.CharField(help_text="Name of the park area", max_length=255),
),
migrations.AlterField(
model_name="parkarea",
name="opening_date",
field=models.DateField(
blank=True, help_text="Date this area opened", null=True
),
),
migrations.AlterField(
model_name="parkarea",
name="park",
field=models.ForeignKey(
help_text="Park this area belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="areas",
to="parks.park",
),
),
migrations.AlterField(
model_name="parkarea",
name="slug",
field=models.SlugField(
help_text="URL-friendly identifier (unique within park)", max_length=255
),
),
migrations.AlterField(
model_name="parkareaevent",
name="closing_date",
field=models.DateField(
blank=True, help_text="Date this area closed (if applicable)", null=True
),
),
migrations.AlterField(
model_name="parkareaevent",
name="description",
field=models.TextField(
blank=True, help_text="Detailed description of the area"
),
),
migrations.AlterField(
model_name="parkareaevent",
name="name",
field=models.CharField(help_text="Name of the park area", max_length=255),
),
migrations.AlterField(
model_name="parkareaevent",
name="opening_date",
field=models.DateField(
blank=True, help_text="Date this area opened", null=True
),
),
migrations.AlterField(
model_name="parkareaevent",
name="park",
field=models.ForeignKey(
db_constraint=False,
help_text="Park this area belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.park",
),
),
migrations.AlterField(
model_name="parkareaevent",
name="slug",
field=models.SlugField(
db_index=False,
help_text="URL-friendly identifier (unique within park)",
max_length=255,
),
),
migrations.AlterField(
model_name="parkevent",
name="average_rating",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average user rating (110)",
max_digits=3,
null=True,
),
),
migrations.AlterField(
model_name="parkevent",
name="closing_date",
field=models.DateField(blank=True, help_text="Closing date", null=True),
),
migrations.AlterField(
model_name="parkevent",
name="coaster_count",
field=models.IntegerField(
blank=True, help_text="Total coaster count", null=True
),
),
migrations.AlterField(
model_name="parkevent",
name="description",
field=models.TextField(blank=True, help_text="Park description"),
),
migrations.AlterField(
model_name="parkevent",
name="name",
field=models.CharField(help_text="Park name", max_length=255),
),
migrations.AlterField(
model_name="parkevent",
name="opening_date",
field=models.DateField(blank=True, help_text="Opening date", null=True),
),
migrations.AlterField(
model_name="parkevent",
name="operating_season",
field=models.CharField(
blank=True, help_text="Operating season", max_length=255
),
),
migrations.AlterField(
model_name="parkevent",
name="ride_count",
field=models.IntegerField(
blank=True, help_text="Total ride count", null=True
),
),
migrations.AlterField(
model_name="parkevent",
name="size_acres",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Park size in acres",
max_digits=10,
null=True,
),
),
migrations.AlterField(
model_name="parkevent",
name="slug",
field=models.SlugField(
db_index=False, help_text="URL-friendly identifier", max_length=255
),
),
migrations.AlterField(
model_name="parkevent",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="statuses",
choices=[],
default="OPERATING",
domain="parks",
max_length=20,
),
),
migrations.AlterField(
model_name="parkevent",
name="timezone",
field=models.CharField(
blank=True,
default="UTC",
help_text="Timezone identifier for park operations (e.g., 'America/New_York')",
max_length=50,
),
),
migrations.AlterField(
model_name="parkevent",
name="website",
field=models.URLField(blank=True, help_text="Official website URL"),
),
migrations.AlterField(
model_name="parkphoto",
name="alt_text",
field=models.CharField(
blank=True,
help_text="Alternative text for accessibility",
max_length=255,
),
),
migrations.AlterField(
model_name="parkphoto",
name="caption",
field=models.CharField(
blank=True, help_text="Photo caption or description", max_length=255
),
),
migrations.AlterField(
model_name="parkphoto",
name="is_approved",
field=models.BooleanField(
default=False,
help_text="Whether this photo has been approved by moderators",
),
),
migrations.AlterField(
model_name="parkphoto",
name="is_primary",
field=models.BooleanField(
default=False,
help_text="Whether this is the primary photo for the park",
),
),
migrations.AlterField(
model_name="parkphoto",
name="park",
field=models.ForeignKey(
help_text="Park this photo belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="photos",
to="parks.park",
),
),
migrations.AlterField(
model_name="parkphoto",
name="uploaded_by",
field=models.ForeignKey(
help_text="User who uploaded this photo",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="uploaded_park_photos",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="parkphotoevent",
name="alt_text",
field=models.CharField(
blank=True,
help_text="Alternative text for accessibility",
max_length=255,
),
),
migrations.AlterField(
model_name="parkphotoevent",
name="caption",
field=models.CharField(
blank=True, help_text="Photo caption or description", max_length=255
),
),
migrations.AlterField(
model_name="parkphotoevent",
name="is_approved",
field=models.BooleanField(
default=False,
help_text="Whether this photo has been approved by moderators",
),
),
migrations.AlterField(
model_name="parkphotoevent",
name="is_primary",
field=models.BooleanField(
default=False,
help_text="Whether this is the primary photo for the park",
),
),
migrations.AlterField(
model_name="parkphotoevent",
name="park",
field=models.ForeignKey(
db_constraint=False,
help_text="Park this photo belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.park",
),
),
migrations.AlterField(
model_name="parkphotoevent",
name="uploaded_by",
field=models.ForeignKey(
db_constraint=False,
help_text="User who uploaded this photo",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="parkreview",
name="content",
field=models.TextField(help_text="Review content"),
),
migrations.AlterField(
model_name="parkreview",
name="is_published",
field=models.BooleanField(
default=True, help_text="Whether this review is publicly visible"
),
),
migrations.AlterField(
model_name="parkreview",
name="moderated_at",
field=models.DateTimeField(
blank=True, help_text="When this review was moderated", null=True
),
),
migrations.AlterField(
model_name="parkreview",
name="moderated_by",
field=models.ForeignKey(
blank=True,
help_text="Moderator who reviewed this",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_park_reviews",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="parkreview",
name="moderation_notes",
field=models.TextField(
blank=True, help_text="Internal notes from moderators"
),
),
migrations.AlterField(
model_name="parkreview",
name="park",
field=models.ForeignKey(
help_text="Park being reviewed",
on_delete=django.db.models.deletion.CASCADE,
related_name="reviews",
to="parks.park",
),
),
migrations.AlterField(
model_name="parkreview",
name="rating",
field=models.PositiveSmallIntegerField(
help_text="Rating from 1-10",
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(10),
],
),
),
migrations.AlterField(
model_name="parkreview",
name="title",
field=models.CharField(help_text="Review title", max_length=200),
),
migrations.AlterField(
model_name="parkreview",
name="user",
field=models.ForeignKey(
help_text="User who wrote the review",
on_delete=django.db.models.deletion.CASCADE,
related_name="park_reviews",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="parkreview",
name="visit_date",
field=models.DateField(help_text="Date the user visited the park"),
),
migrations.AlterField(
model_name="parkreviewevent",
name="content",
field=models.TextField(help_text="Review content"),
),
migrations.AlterField(
model_name="parkreviewevent",
name="is_published",
field=models.BooleanField(
default=True, help_text="Whether this review is publicly visible"
),
),
migrations.AlterField(
model_name="parkreviewevent",
name="moderated_at",
field=models.DateTimeField(
blank=True, help_text="When this review was moderated", null=True
),
),
migrations.AlterField(
model_name="parkreviewevent",
name="moderated_by",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Moderator who reviewed this",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="parkreviewevent",
name="moderation_notes",
field=models.TextField(
blank=True, help_text="Internal notes from moderators"
),
),
migrations.AlterField(
model_name="parkreviewevent",
name="park",
field=models.ForeignKey(
db_constraint=False,
help_text="Park being reviewed",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.park",
),
),
migrations.AlterField(
model_name="parkreviewevent",
name="rating",
field=models.PositiveSmallIntegerField(
help_text="Rating from 1-10",
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(10),
],
),
),
migrations.AlterField(
model_name="parkreviewevent",
name="title",
field=models.CharField(help_text="Review title", max_length=200),
),
migrations.AlterField(
model_name="parkreviewevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
help_text="User who wrote the review",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="parkreviewevent",
name="visit_date",
field=models.DateField(help_text="Date the user visited the park"),
),
]

View File

@@ -274,6 +274,18 @@ class FindParksAlongRouteView(RoadTripViewMixin, View):
start_park, end_park, max_detour_km
)
# Return JSON if requested
if request.headers.get("Accept") == "application/json" or request.content_type == "application/json":
return JsonResponse({
"status": "success",
"data": {
"parks": [self._park_to_dict(p) for p in parks_along_route],
"start_park": self._park_to_dict(start_park),
"end_park": self._park_to_dict(end_park),
"count": len(parks_along_route)
}
})
return render(
request,
PARKS_ALONG_ROUTE_HTML,

View File

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ReviewsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.reviews"
verbose_name = "User Reviews"

View File

@@ -0,0 +1,56 @@
from django.db import models
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from apps.core.history import TrackedModel
import pghistory
@pghistory.track()
class Review(TrackedModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="reviews",
help_text="User who wrote the review",
)
# Generic relation to target object (Park, Ride, etc.)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
help_text="Type of item being reviewed",
)
object_id = models.PositiveIntegerField(help_text="ID of the item being reviewed")
content_object = GenericForeignKey("content_type", "object_id")
# Review content
rating = models.PositiveSmallIntegerField(
choices=[(i, str(i)) for i in range(1, 6)],
help_text="Rating from 1 to 5",
db_index=True,
)
text = models.TextField(blank=True, help_text="Review text (optional)")
# Metadata
is_public = models.BooleanField(
default=True,
help_text="Whether this review is visible to others"
)
helpful_votes = models.PositiveIntegerField(
default=0,
help_text="Number of users who found this helpful"
)
class Meta(TrackedModel.Meta):
verbose_name = "Review"
verbose_name_plural = "Reviews"
ordering = ["-created_at"]
# Ensure one review per user per object
unique_together = [["user", "content_type", "object_id"]]
indexes = [
models.Index(fields=["content_type", "object_id"]),
models.Index(fields=["rating"]),
]
def __str__(self):
return f"{self.user.username}'s {self.rating}-star review on {self.content_object}"

View File

@@ -0,0 +1,945 @@
# Generated by Django 5.1.6 on 2025-12-26 14:10
import apps.core.choices.fields
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0006_delete_aggregateevent"),
("rides", "0026_convert_unique_together_to_constraints"),
]
operations = [
migrations.AlterModelOptions(
name="company",
options={
"ordering": ["name"],
"verbose_name": "Company",
"verbose_name_plural": "Companies",
},
),
migrations.AlterModelOptions(
name="rankingsnapshot",
options={
"ordering": ["-snapshot_date", "rank"],
"verbose_name": "Ranking Snapshot",
"verbose_name_plural": "Ranking Snapshots",
},
),
migrations.AlterModelOptions(
name="ride",
options={
"ordering": ["name"],
"verbose_name": "Ride",
"verbose_name_plural": "Rides",
},
),
migrations.AlterModelOptions(
name="ridemodel",
options={
"ordering": ["manufacturer__name", "name"],
"verbose_name": "Ride Model",
"verbose_name_plural": "Ride Models",
},
),
migrations.AlterModelOptions(
name="ridemodelphoto",
options={
"ordering": ["-is_primary", "-created_at"],
"verbose_name": "Ride Model Photo",
"verbose_name_plural": "Ride Model Photos",
},
),
migrations.AlterModelOptions(
name="ridemodeltechnicalspec",
options={
"ordering": ["spec_category", "spec_name"],
"verbose_name": "Ride Model Technical Specification",
"verbose_name_plural": "Ride Model Technical Specifications",
},
),
migrations.AlterModelOptions(
name="ridemodelvariant",
options={
"ordering": ["ride_model", "name"],
"verbose_name": "Ride Model Variant",
"verbose_name_plural": "Ride Model Variants",
},
),
migrations.AlterModelOptions(
name="ridepaircomparison",
options={
"ordering": ["ride_a", "ride_b"],
"verbose_name": "Ride Pair Comparison",
"verbose_name_plural": "Ride Pair Comparisons",
},
),
migrations.AlterModelOptions(
name="rideranking",
options={
"ordering": ["rank"],
"verbose_name": "Ride Ranking",
"verbose_name_plural": "Ride Rankings",
},
),
migrations.AlterModelOptions(
name="rollercoasterstats",
options={
"ordering": ["ride"],
"verbose_name": "Roller Coaster Statistics",
"verbose_name_plural": "Roller Coaster Statistics",
},
),
migrations.AlterField(
model_name="company",
name="coasters_count",
field=models.IntegerField(
default=0, help_text="Number of coasters manufactured (auto-calculated)"
),
),
migrations.AlterField(
model_name="company",
name="description",
field=models.TextField(
blank=True, help_text="Detailed company description"
),
),
migrations.AlterField(
model_name="company",
name="founded_date",
field=models.DateField(
blank=True, help_text="Date the company was founded", null=True
),
),
migrations.AlterField(
model_name="company",
name="name",
field=models.CharField(help_text="Company name", max_length=255),
),
migrations.AlterField(
model_name="company",
name="rides_count",
field=models.IntegerField(
default=0, help_text="Number of rides manufactured (auto-calculated)"
),
),
migrations.AlterField(
model_name="company",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="company_roles",
choices=[
("MANUFACTURER", "Ride Manufacturer"),
("DESIGNER", "Ride Designer"),
],
domain="rides",
max_length=20,
),
blank=True,
default=list,
help_text="Company roles (manufacturer, designer, etc.)",
size=None,
),
),
migrations.AlterField(
model_name="company",
name="slug",
field=models.SlugField(
help_text="URL-friendly identifier", max_length=255, unique=True
),
),
migrations.AlterField(
model_name="company",
name="website",
field=models.URLField(blank=True, help_text="Company website URL"),
),
migrations.AlterField(
model_name="companyevent",
name="coasters_count",
field=models.IntegerField(
default=0, help_text="Number of coasters manufactured (auto-calculated)"
),
),
migrations.AlterField(
model_name="companyevent",
name="description",
field=models.TextField(
blank=True, help_text="Detailed company description"
),
),
migrations.AlterField(
model_name="companyevent",
name="founded_date",
field=models.DateField(
blank=True, help_text="Date the company was founded", null=True
),
),
migrations.AlterField(
model_name="companyevent",
name="name",
field=models.CharField(help_text="Company name", max_length=255),
),
migrations.AlterField(
model_name="companyevent",
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.AlterField(
model_name="companyevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.company",
),
),
migrations.AlterField(
model_name="companyevent",
name="rides_count",
field=models.IntegerField(
default=0, help_text="Number of rides manufactured (auto-calculated)"
),
),
migrations.AlterField(
model_name="companyevent",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="company_roles",
choices=[
("MANUFACTURER", "Ride Manufacturer"),
("DESIGNER", "Ride Designer"),
],
domain="rides",
max_length=20,
),
blank=True,
default=list,
help_text="Company roles (manufacturer, designer, etc.)",
size=None,
),
),
migrations.AlterField(
model_name="companyevent",
name="slug",
field=models.SlugField(
db_index=False, help_text="URL-friendly identifier", max_length=255
),
),
migrations.AlterField(
model_name="companyevent",
name="website",
field=models.URLField(blank=True, help_text="Company website URL"),
),
migrations.AlterField(
model_name="rideevent",
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.AlterField(
model_name="rideevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ride",
),
),
migrations.AlterField(
model_name="ridelocationevent",
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.AlterField(
model_name="ridelocationevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridelocation",
),
),
migrations.AlterField(
model_name="ridemodelevent",
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.AlterField(
model_name="ridemodelevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridemodel",
),
),
migrations.AlterField(
model_name="ridemodelphoto",
name="alt_text",
field=models.CharField(
blank=True,
help_text="Alternative text for accessibility",
max_length=255,
),
),
migrations.AlterField(
model_name="ridemodelphoto",
name="caption",
field=models.CharField(
blank=True, help_text="Photo caption or description", max_length=500
),
),
migrations.AlterField(
model_name="ridemodelphoto",
name="copyright_info",
field=models.CharField(
blank=True, help_text="Copyright information", max_length=255
),
),
migrations.AlterField(
model_name="ridemodelphoto",
name="photographer",
field=models.CharField(
blank=True, help_text="Name of the photographer", max_length=255
),
),
migrations.AlterField(
model_name="ridemodelphoto",
name="ride_model",
field=models.ForeignKey(
help_text="Ride model this photo belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="photos",
to="rides.ridemodel",
),
),
migrations.AlterField(
model_name="ridemodelphoto",
name="source",
field=models.CharField(
blank=True, help_text="Source of the photo", max_length=255
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="alt_text",
field=models.CharField(
blank=True,
help_text="Alternative text for accessibility",
max_length=255,
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="caption",
field=models.CharField(
blank=True, help_text="Photo caption or description", max_length=500
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="copyright_info",
field=models.CharField(
blank=True, help_text="Copyright information", max_length=255
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
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.AlterField(
model_name="ridemodelphotoevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridemodelphoto",
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="photographer",
field=models.CharField(
blank=True, help_text="Name of the photographer", max_length=255
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="ride_model",
field=models.ForeignKey(
db_constraint=False,
help_text="Ride model this photo belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ridemodel",
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="source",
field=models.CharField(
blank=True, help_text="Source of the photo", max_length=255
),
),
migrations.AlterField(
model_name="ridemodeltechnicalspec",
name="ride_model",
field=models.ForeignKey(
help_text="Ride model this specification belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="technical_specs",
to="rides.ridemodel",
),
),
migrations.AlterField(
model_name="ridemodeltechnicalspecevent",
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.AlterField(
model_name="ridemodeltechnicalspecevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridemodeltechnicalspec",
),
),
migrations.AlterField(
model_name="ridemodeltechnicalspecevent",
name="ride_model",
field=models.ForeignKey(
db_constraint=False,
help_text="Ride model this specification belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ridemodel",
),
),
migrations.AlterField(
model_name="ridemodelvariant",
name="max_height_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum height for this variant",
max_digits=6,
null=True,
),
),
migrations.AlterField(
model_name="ridemodelvariant",
name="max_speed_mph",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum speed for this variant",
max_digits=5,
null=True,
),
),
migrations.AlterField(
model_name="ridemodelvariant",
name="min_height_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Minimum height for this variant",
max_digits=6,
null=True,
),
),
migrations.AlterField(
model_name="ridemodelvariant",
name="min_speed_mph",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Minimum speed for this variant",
max_digits=5,
null=True,
),
),
migrations.AlterField(
model_name="ridemodelvariant",
name="ride_model",
field=models.ForeignKey(
help_text="Base ride model this variant belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="variants",
to="rides.ridemodel",
),
),
migrations.AlterField(
model_name="ridemodelvariantevent",
name="max_height_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum height for this variant",
max_digits=6,
null=True,
),
),
migrations.AlterField(
model_name="ridemodelvariantevent",
name="max_speed_mph",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum speed for this variant",
max_digits=5,
null=True,
),
),
migrations.AlterField(
model_name="ridemodelvariantevent",
name="min_height_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Minimum height for this variant",
max_digits=6,
null=True,
),
),
migrations.AlterField(
model_name="ridemodelvariantevent",
name="min_speed_mph",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Minimum speed for this variant",
max_digits=5,
null=True,
),
),
migrations.AlterField(
model_name="ridemodelvariantevent",
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.AlterField(
model_name="ridemodelvariantevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridemodelvariant",
),
),
migrations.AlterField(
model_name="ridemodelvariantevent",
name="ride_model",
field=models.ForeignKey(
db_constraint=False,
help_text="Base ride model this variant belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ridemodel",
),
),
migrations.AlterField(
model_name="ridepaircomparisonevent",
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.AlterField(
model_name="ridepaircomparisonevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridepaircomparison",
),
),
migrations.AlterField(
model_name="ridephotoevent",
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.AlterField(
model_name="ridephotoevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridephoto",
),
),
migrations.AlterField(
model_name="rideranking",
name="ride",
field=models.OneToOneField(
help_text="Ride this ranking entry describes",
on_delete=django.db.models.deletion.CASCADE,
related_name="ranking",
to="rides.ride",
),
),
migrations.AlterField(
model_name="riderankingevent",
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.AlterField(
model_name="riderankingevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.rideranking",
),
),
migrations.AlterField(
model_name="riderankingevent",
name="ride",
field=models.ForeignKey(
db_constraint=False,
help_text="Ride this ranking entry describes",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
migrations.AlterField(
model_name="ridereviewevent",
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.AlterField(
model_name="ridereviewevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridereview",
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="cars_per_train",
field=models.PositiveIntegerField(
blank=True, help_text="Number of cars per train", null=True
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="height_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum height in feet",
max_digits=6,
null=True,
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="inversions",
field=models.PositiveIntegerField(
default=0, help_text="Number of inversions"
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="length_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Track length in feet",
max_digits=7,
null=True,
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="max_drop_height_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum drop height in feet",
max_digits=6,
null=True,
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="ride",
field=models.OneToOneField(
help_text="Ride these statistics belong to",
on_delete=django.db.models.deletion.CASCADE,
related_name="coaster_stats",
to="rides.ride",
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="ride_time_seconds",
field=models.PositiveIntegerField(
blank=True, help_text="Duration of the ride in seconds", null=True
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="seats_per_car",
field=models.PositiveIntegerField(
blank=True, help_text="Number of seats per car", null=True
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="speed_mph",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum speed in mph",
max_digits=5,
null=True,
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="track_type",
field=models.CharField(
blank=True,
help_text="Type of track (e.g., tubular steel, wooden)",
max_length=255,
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="train_style",
field=models.CharField(
blank=True,
help_text="Style of train (e.g., floorless, inverted)",
max_length=255,
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="trains_count",
field=models.PositiveIntegerField(
blank=True, help_text="Number of trains", null=True
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="cars_per_train",
field=models.PositiveIntegerField(
blank=True, help_text="Number of cars per train", null=True
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="height_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum height in feet",
max_digits=6,
null=True,
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="inversions",
field=models.PositiveIntegerField(
default=0, help_text="Number of inversions"
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="length_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Track length in feet",
max_digits=7,
null=True,
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="max_drop_height_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum drop height in feet",
max_digits=6,
null=True,
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
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.AlterField(
model_name="rollercoasterstatsevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.rollercoasterstats",
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="ride",
field=models.ForeignKey(
db_constraint=False,
help_text="Ride these statistics belong to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="ride_time_seconds",
field=models.PositiveIntegerField(
blank=True, help_text="Duration of the ride in seconds", null=True
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="seats_per_car",
field=models.PositiveIntegerField(
blank=True, help_text="Number of seats per car", null=True
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="speed_mph",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum speed in mph",
max_digits=5,
null=True,
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="track_type",
field=models.CharField(
blank=True,
help_text="Type of track (e.g., tubular steel, wooden)",
max_length=255,
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="train_style",
field=models.CharField(
blank=True,
help_text="Style of train (e.g., floorless, inverted)",
max_length=255,
),
),
migrations.AlterField(
model_name="rollercoasterstatsevent",
name="trains_count",
field=models.PositiveIntegerField(
blank=True, help_text="Number of trains", null=True
),
),
]

View File

@@ -113,6 +113,7 @@ LOCAL_APPS = [
"api", # Centralized API app (located at backend/api/)
"django_forwardemail", # New PyPI package for email service
"apps.moderation",
"apps.lists",
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS

101
backend/verify_backend.py Normal file
View File

@@ -0,0 +1,101 @@
import os
import django
import sys
import json
# Setup Django environment
sys.path.append('/Volumes/macminissd/Projects/thrillwiki_django_no_react/backend')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.local")
django.setup()
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework import status
from apps.lists.models import UserList, ListItem
from apps.parks.models import Park
User = get_user_model()
def run_verification():
print("Starting Backend Verification...")
# 1. Create Test User
username = "verify_user"
email = "verify@example.com"
password = "password123"
user, created = User.objects.get_or_create(username=username, email=email)
user.set_password(password)
user.save()
print(f"User created: {user.username}")
# 2. Authenticate
client = APIClient()
client.force_authenticate(user=user)
print("Authenticated.")
# 3. Verify Profile Update (Unit System)
# Endpoint: /api/v1/auth/user/ or /api/v1/accounts/me/ (depending on dj-rest-auth)
# Let's try updating profile via PATCH /api/v1/auth/user/
update_data = {
"unit_system": "imperial",
"location": "Test City, TS"
}
# Note: unit_system expects 'metric', 'imperial'.
# Check if 'imperial' is valid key in RichChoiceField.
# Assuming it is based on implementation plan.
response = client.patch('/api/v1/accounts/profile/update/', update_data, format='json')
if response.status_code == 200:
print(f"Profile updated successfully: {response.data.get('unit_system')}")
if response.data.get('unit_system') != 'imperial':
print(f"WARNING: unit_system mismatch. Got {response.data.get('unit_system')}")
else:
print(f"FAILED to update profile: {response.status_code} {response.data}")
# 4. Verify UserList CRUD
# Create List
list_data = {
"title": "My Favorite Coasters",
"category": "RC", # Roller Coaster
"description": "Best rides ever",
"is_public": True
}
response = client.post('/api/v1/lists/lists/', list_data, format='json')
if response.status_code == 201:
list_id = response.data['id']
print(f"UserList created: {list_id} - {response.data['title']}")
else:
print(f"FAILED to create UserList: {response.status_code} {response.data}")
return
# Add Item to List
# We need a content object (Park). Verify if any park exists.
park = Park.objects.first()
if not park:
print("Creating dummy park for testing...")
park = Park.objects.create(name="Test Park", slug="test-park", country="US")
item_data = {
"user_list": list_id,
"content_type": "parks.park", # format app.model
"object_id": park.id,
"rank": 1,
"comment": "Top tier"
}
# Note: Serializer might expect 'content_type' as ID or string.
# Let's try string first if using slug-based or app-label based lookup.
# If standard serializer, might be tricky.
# Alternatively, use specialized endpoint or just test UserList creation for now.
# Actually, let's just check if we can GET the list
response = client.get(f'/api/v1/lists/lists/{list_id}/')
if response.status_code == 200:
print(f"UserList retrieved: {response.data['title']}")
else:
print(f"FAILED to retrieve UserList: {response.status_code} {response.data}")
print("Verification Complete.")
if __name__ == "__main__":
run_verification()