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

@@ -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")),