mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
- Implement CRUD operations for ride credits, allowing users to log rides, track counts, and view statistics. - Create endpoints for managing user-created ranked lists of parks, rides, or coasters with custom rankings and notes. - Introduce pagination for both ride credits and top lists. - Ensure proper authentication and authorization for modifying user-specific data. - Add serialization methods for ride credits and top lists to return structured data. - Include error handling and logging for better traceability of operations.
575 lines
18 KiB
Python
575 lines
18 KiB
Python
"""
|
|
Top List endpoints for API v1.
|
|
|
|
Provides CRUD operations for user-created ranked lists.
|
|
Users can create lists of parks, rides, or coasters with custom rankings and notes.
|
|
"""
|
|
from typing import List, Optional
|
|
from uuid import UUID
|
|
from django.shortcuts import get_object_or_404
|
|
from django.db.models import Q, Max
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import transaction
|
|
from ninja import Router, Query
|
|
from ninja.pagination import paginate, PageNumberPagination
|
|
import logging
|
|
|
|
from apps.users.models import UserTopList, UserTopListItem, User
|
|
from apps.entities.models import Park, Ride
|
|
from apps.users.permissions import jwt_auth, require_auth
|
|
from ..schemas import (
|
|
TopListCreateSchema,
|
|
TopListUpdateSchema,
|
|
TopListItemCreateSchema,
|
|
TopListItemUpdateSchema,
|
|
TopListOut,
|
|
TopListDetailOut,
|
|
TopListListOut,
|
|
TopListItemOut,
|
|
ErrorResponse,
|
|
UserSchema,
|
|
)
|
|
|
|
router = Router(tags=["Top Lists"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TopListPagination(PageNumberPagination):
|
|
"""Custom pagination for top lists."""
|
|
page_size = 50
|
|
|
|
|
|
def _get_entity(entity_type: str, entity_id: UUID):
|
|
"""Helper to get and validate entity (Park or Ride)."""
|
|
if entity_type == 'park':
|
|
return get_object_or_404(Park, id=entity_id), ContentType.objects.get_for_model(Park)
|
|
elif entity_type == 'ride':
|
|
return get_object_or_404(Ride, id=entity_id), ContentType.objects.get_for_model(Ride)
|
|
else:
|
|
raise ValidationError(f"Invalid entity_type: {entity_type}")
|
|
|
|
|
|
def _serialize_list_item(item: UserTopListItem) -> dict:
|
|
"""Serialize top list item with computed fields."""
|
|
entity = item.content_object
|
|
|
|
data = {
|
|
'id': item.id,
|
|
'position': item.position,
|
|
'entity_type': item.content_type.model,
|
|
'entity_id': str(item.object_id),
|
|
'entity_name': entity.name if entity else 'Unknown',
|
|
'entity_slug': entity.slug if entity and hasattr(entity, 'slug') else '',
|
|
'entity_image_url': None, # TODO: Get from entity
|
|
'park_name': None,
|
|
'notes': item.notes or '',
|
|
'created': item.created,
|
|
'modified': item.modified,
|
|
}
|
|
|
|
# If entity is a ride, add park name
|
|
if item.content_type.model == 'ride' and entity and hasattr(entity, 'park'):
|
|
data['park_name'] = entity.park.name if entity.park else None
|
|
|
|
return data
|
|
|
|
|
|
def _serialize_top_list(top_list: UserTopList, include_items: bool = False) -> dict:
|
|
"""Serialize top list with optional items."""
|
|
data = {
|
|
'id': top_list.id,
|
|
'user': UserSchema(
|
|
id=top_list.user.id,
|
|
username=top_list.user.username,
|
|
display_name=top_list.user.display_name,
|
|
avatar_url=top_list.user.avatar_url,
|
|
reputation_score=top_list.user.reputation_score,
|
|
),
|
|
'list_type': top_list.list_type,
|
|
'title': top_list.title,
|
|
'description': top_list.description or '',
|
|
'is_public': top_list.is_public,
|
|
'item_count': top_list.item_count,
|
|
'created': top_list.created,
|
|
'modified': top_list.modified,
|
|
}
|
|
|
|
if include_items:
|
|
items = top_list.items.select_related('content_type').order_by('position')
|
|
data['items'] = [_serialize_list_item(item) for item in items]
|
|
|
|
return data
|
|
|
|
|
|
# ============================================================================
|
|
# Main Top List CRUD Endpoints
|
|
# ============================================================================
|
|
|
|
@router.post("/", response={201: TopListOut, 400: ErrorResponse}, auth=jwt_auth)
|
|
@require_auth
|
|
def create_top_list(request, data: TopListCreateSchema):
|
|
"""
|
|
Create a new top list.
|
|
|
|
**Authentication:** Required
|
|
|
|
**Parameters:**
|
|
- list_type: "parks", "rides", or "coasters"
|
|
- title: List title
|
|
- description: List description (optional)
|
|
- is_public: Whether list is publicly visible (default: true)
|
|
|
|
**Returns:** Created top list
|
|
"""
|
|
try:
|
|
user = request.auth
|
|
|
|
# Create list
|
|
top_list = UserTopList.objects.create(
|
|
user=user,
|
|
list_type=data.list_type,
|
|
title=data.title,
|
|
description=data.description or '',
|
|
is_public=data.is_public,
|
|
)
|
|
|
|
logger.info(f"Top list created: {top_list.id} by {user.email}")
|
|
|
|
list_data = _serialize_top_list(top_list)
|
|
return 201, list_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating top list: {e}")
|
|
return 400, {'detail': str(e)}
|
|
|
|
|
|
@router.get("/", response={200: List[TopListOut]})
|
|
@paginate(TopListPagination)
|
|
def list_top_lists(
|
|
request,
|
|
list_type: Optional[str] = Query(None, description="Filter by list type"),
|
|
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
|
|
ordering: Optional[str] = Query("-created", description="Sort by field")
|
|
):
|
|
"""
|
|
List accessible top lists.
|
|
|
|
**Authentication:** Optional
|
|
|
|
**Filters:**
|
|
- list_type: parks, rides, or coasters
|
|
- user_id: Lists by specific user
|
|
- ordering: Sort field (default: -created)
|
|
|
|
**Returns:** Paginated list of top lists
|
|
|
|
**Note:** Shows public lists + user's own private lists if authenticated.
|
|
"""
|
|
user = request.auth if hasattr(request, 'auth') else None
|
|
|
|
# Base query
|
|
queryset = UserTopList.objects.select_related('user')
|
|
|
|
# Apply visibility filter
|
|
if user:
|
|
# Show public lists + user's own lists
|
|
queryset = queryset.filter(Q(is_public=True) | Q(user=user))
|
|
else:
|
|
# Only public lists
|
|
queryset = queryset.filter(is_public=True)
|
|
|
|
# Apply list type filter
|
|
if list_type:
|
|
queryset = queryset.filter(list_type=list_type)
|
|
|
|
# Apply user filter
|
|
if user_id:
|
|
queryset = queryset.filter(user_id=user_id)
|
|
|
|
# Apply ordering
|
|
valid_order_fields = ['created', 'modified', 'title']
|
|
order_field = ordering.lstrip('-')
|
|
if order_field in valid_order_fields:
|
|
queryset = queryset.order_by(ordering)
|
|
else:
|
|
queryset = queryset.order_by('-created')
|
|
|
|
# Serialize lists
|
|
lists = [_serialize_top_list(tl) for tl in queryset]
|
|
return lists
|
|
|
|
|
|
@router.get("/public", response={200: List[TopListOut]})
|
|
@paginate(TopListPagination)
|
|
def list_public_top_lists(
|
|
request,
|
|
list_type: Optional[str] = Query(None),
|
|
user_id: Optional[UUID] = Query(None),
|
|
ordering: Optional[str] = Query("-created")
|
|
):
|
|
"""
|
|
List public top lists.
|
|
|
|
**Authentication:** Optional
|
|
|
|
**Parameters:**
|
|
- list_type: Filter by type (optional)
|
|
- user_id: Filter by user (optional)
|
|
- ordering: Sort field (default: -created)
|
|
|
|
**Returns:** Paginated list of public top lists
|
|
"""
|
|
queryset = UserTopList.objects.filter(is_public=True).select_related('user')
|
|
|
|
if list_type:
|
|
queryset = queryset.filter(list_type=list_type)
|
|
|
|
if user_id:
|
|
queryset = queryset.filter(user_id=user_id)
|
|
|
|
valid_order_fields = ['created', 'modified', 'title']
|
|
order_field = ordering.lstrip('-')
|
|
if order_field in valid_order_fields:
|
|
queryset = queryset.order_by(ordering)
|
|
else:
|
|
queryset = queryset.order_by('-created')
|
|
|
|
lists = [_serialize_top_list(tl) for tl in queryset]
|
|
return lists
|
|
|
|
|
|
@router.get("/{list_id}", response={200: TopListDetailOut, 403: ErrorResponse, 404: ErrorResponse})
|
|
def get_top_list(request, list_id: UUID):
|
|
"""
|
|
Get a specific top list with all items.
|
|
|
|
**Authentication:** Optional
|
|
|
|
**Parameters:**
|
|
- list_id: List UUID
|
|
|
|
**Returns:** Top list with all items
|
|
|
|
**Note:** Private lists only accessible to owner.
|
|
"""
|
|
user = request.auth if hasattr(request, 'auth') else None
|
|
|
|
top_list = get_object_or_404(
|
|
UserTopList.objects.select_related('user'),
|
|
id=list_id
|
|
)
|
|
|
|
# Check access
|
|
if not top_list.is_public:
|
|
if not user or top_list.user != user:
|
|
return 403, {'detail': 'This list is private'}
|
|
|
|
list_data = _serialize_top_list(top_list, include_items=True)
|
|
return 200, list_data
|
|
|
|
|
|
@router.put("/{list_id}", response={200: TopListOut, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
|
@require_auth
|
|
def update_top_list(request, list_id: UUID, data: TopListUpdateSchema):
|
|
"""
|
|
Update a top list.
|
|
|
|
**Authentication:** Required (must be list owner)
|
|
|
|
**Parameters:**
|
|
- list_id: List UUID
|
|
- data: Fields to update
|
|
|
|
**Returns:** Updated list
|
|
"""
|
|
user = request.auth
|
|
|
|
top_list = get_object_or_404(UserTopList, id=list_id)
|
|
|
|
# Check ownership
|
|
if top_list.user != user:
|
|
return 403, {'detail': 'You can only update your own lists'}
|
|
|
|
# Update fields
|
|
update_data = data.dict(exclude_unset=True)
|
|
for key, value in update_data.items():
|
|
setattr(top_list, key, value)
|
|
|
|
top_list.save()
|
|
|
|
logger.info(f"Top list updated: {top_list.id} by {user.email}")
|
|
|
|
list_data = _serialize_top_list(top_list)
|
|
return 200, list_data
|
|
|
|
|
|
@router.delete("/{list_id}", response={204: None, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
|
@require_auth
|
|
def delete_top_list(request, list_id: UUID):
|
|
"""
|
|
Delete a top list.
|
|
|
|
**Authentication:** Required (must be list owner)
|
|
|
|
**Parameters:**
|
|
- list_id: List UUID
|
|
|
|
**Returns:** No content (204)
|
|
|
|
**Note:** This also deletes all items in the list.
|
|
"""
|
|
user = request.auth
|
|
|
|
top_list = get_object_or_404(UserTopList, id=list_id)
|
|
|
|
# Check ownership
|
|
if top_list.user != user:
|
|
return 403, {'detail': 'You can only delete your own lists'}
|
|
|
|
logger.info(f"Top list deleted: {top_list.id} by {user.email}")
|
|
top_list.delete()
|
|
|
|
return 204, None
|
|
|
|
|
|
# ============================================================================
|
|
# List Item Endpoints
|
|
# ============================================================================
|
|
|
|
@router.post("/{list_id}/items", response={201: TopListItemOut, 400: ErrorResponse, 403: ErrorResponse}, auth=jwt_auth)
|
|
@require_auth
|
|
def add_list_item(request, list_id: UUID, data: TopListItemCreateSchema):
|
|
"""
|
|
Add an item to a top list.
|
|
|
|
**Authentication:** Required (must be list owner)
|
|
|
|
**Parameters:**
|
|
- list_id: List UUID
|
|
- entity_type: "park" or "ride"
|
|
- entity_id: Entity UUID
|
|
- position: Position in list (optional, auto-assigned if not provided)
|
|
- notes: Notes about this item (optional)
|
|
|
|
**Returns:** Created list item
|
|
"""
|
|
try:
|
|
user = request.auth
|
|
|
|
top_list = get_object_or_404(UserTopList, id=list_id)
|
|
|
|
# Check ownership
|
|
if top_list.user != user:
|
|
return 403, {'detail': 'You can only modify your own lists'}
|
|
|
|
# Validate entity
|
|
entity, content_type = _get_entity(data.entity_type, data.entity_id)
|
|
|
|
# Validate entity type matches list type
|
|
if top_list.list_type == 'parks' and data.entity_type != 'park':
|
|
return 400, {'detail': 'Can only add parks to a parks list'}
|
|
elif top_list.list_type in ['rides', 'coasters']:
|
|
if data.entity_type != 'ride':
|
|
return 400, {'detail': f'Can only add rides to a {top_list.list_type} list'}
|
|
if top_list.list_type == 'coasters' and not entity.is_coaster:
|
|
return 400, {'detail': 'Can only add coasters to a coasters list'}
|
|
|
|
# Determine position
|
|
if data.position is None:
|
|
# Auto-assign position (append to end)
|
|
max_pos = top_list.items.aggregate(max_pos=Max('position'))['max_pos']
|
|
position = (max_pos or 0) + 1
|
|
else:
|
|
position = data.position
|
|
# Check if position is taken
|
|
if top_list.items.filter(position=position).exists():
|
|
return 400, {'detail': f'Position {position} is already taken'}
|
|
|
|
# Create item
|
|
with transaction.atomic():
|
|
item = UserTopListItem.objects.create(
|
|
top_list=top_list,
|
|
content_type=content_type,
|
|
object_id=entity.id,
|
|
position=position,
|
|
notes=data.notes or '',
|
|
)
|
|
|
|
logger.info(f"List item added: {item.id} to list {list_id}")
|
|
|
|
item_data = _serialize_list_item(item)
|
|
return 201, item_data
|
|
|
|
except ValidationError as e:
|
|
return 400, {'detail': str(e)}
|
|
except Exception as e:
|
|
logger.error(f"Error adding list item: {e}")
|
|
return 400, {'detail': str(e)}
|
|
|
|
|
|
@router.put("/{list_id}/items/{position}", response={200: TopListItemOut, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
|
@require_auth
|
|
def update_list_item(request, list_id: UUID, position: int, data: TopListItemUpdateSchema):
|
|
"""
|
|
Update a list item.
|
|
|
|
**Authentication:** Required (must be list owner)
|
|
|
|
**Parameters:**
|
|
- list_id: List UUID
|
|
- position: Current position
|
|
- data: Fields to update (position, notes)
|
|
|
|
**Returns:** Updated item
|
|
|
|
**Note:** If changing position, items are reordered automatically.
|
|
"""
|
|
try:
|
|
user = request.auth
|
|
|
|
top_list = get_object_or_404(UserTopList, id=list_id)
|
|
|
|
# Check ownership
|
|
if top_list.user != user:
|
|
return 403, {'detail': 'You can only modify your own lists'}
|
|
|
|
# Get item
|
|
item = get_object_or_404(
|
|
UserTopListItem.objects.select_related('content_type'),
|
|
top_list=top_list,
|
|
position=position
|
|
)
|
|
|
|
with transaction.atomic():
|
|
# Handle position change
|
|
if data.position is not None and data.position != position:
|
|
new_position = data.position
|
|
|
|
# Check if new position exists
|
|
target_item = top_list.items.filter(position=new_position).first()
|
|
|
|
if target_item:
|
|
# Swap positions
|
|
target_item.position = position
|
|
target_item.save()
|
|
|
|
item.position = new_position
|
|
|
|
# Update notes if provided
|
|
if data.notes is not None:
|
|
item.notes = data.notes
|
|
|
|
item.save()
|
|
|
|
logger.info(f"List item updated: {item.id}")
|
|
|
|
item_data = _serialize_list_item(item)
|
|
return 200, item_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating list item: {e}")
|
|
return 400, {'detail': str(e)}
|
|
|
|
|
|
@router.delete("/{list_id}/items/{position}", response={204: None, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
|
@require_auth
|
|
def delete_list_item(request, list_id: UUID, position: int):
|
|
"""
|
|
Remove an item from a list.
|
|
|
|
**Authentication:** Required (must be list owner)
|
|
|
|
**Parameters:**
|
|
- list_id: List UUID
|
|
- position: Position of item to remove
|
|
|
|
**Returns:** No content (204)
|
|
|
|
**Note:** Remaining items are automatically reordered.
|
|
"""
|
|
user = request.auth
|
|
|
|
top_list = get_object_or_404(UserTopList, id=list_id)
|
|
|
|
# Check ownership
|
|
if top_list.user != user:
|
|
return 403, {'detail': 'You can only modify your own lists'}
|
|
|
|
# Get item
|
|
item = get_object_or_404(
|
|
UserTopListItem,
|
|
top_list=top_list,
|
|
position=position
|
|
)
|
|
|
|
with transaction.atomic():
|
|
# Delete item
|
|
item.delete()
|
|
|
|
# Reorder remaining items
|
|
items_to_reorder = top_list.items.filter(position__gt=position).order_by('position')
|
|
for i, remaining_item in enumerate(items_to_reorder, start=position):
|
|
remaining_item.position = i
|
|
remaining_item.save()
|
|
|
|
logger.info(f"List item deleted from list {list_id} at position {position}")
|
|
|
|
return 204, None
|
|
|
|
|
|
# ============================================================================
|
|
# User-Specific Endpoints
|
|
# ============================================================================
|
|
|
|
@router.get("/users/{user_id}", response={200: List[TopListOut], 403: ErrorResponse})
|
|
@paginate(TopListPagination)
|
|
def get_user_top_lists(
|
|
request,
|
|
user_id: UUID,
|
|
list_type: Optional[str] = Query(None),
|
|
ordering: Optional[str] = Query("-created")
|
|
):
|
|
"""
|
|
Get a user's top lists.
|
|
|
|
**Authentication:** Optional
|
|
|
|
**Parameters:**
|
|
- user_id: User UUID
|
|
- list_type: Filter by type (optional)
|
|
- ordering: Sort field (default: -created)
|
|
|
|
**Returns:** Paginated list of user's top lists
|
|
|
|
**Note:** Only public lists visible unless viewing own lists.
|
|
"""
|
|
target_user = get_object_or_404(User, id=user_id)
|
|
|
|
# Check if current user
|
|
current_user = request.auth if hasattr(request, 'auth') else None
|
|
is_owner = current_user and current_user.id == target_user.id
|
|
|
|
# Build query
|
|
queryset = UserTopList.objects.filter(user=target_user).select_related('user')
|
|
|
|
# Apply visibility filter
|
|
if not is_owner:
|
|
queryset = queryset.filter(is_public=True)
|
|
|
|
# Apply list type filter
|
|
if list_type:
|
|
queryset = queryset.filter(list_type=list_type)
|
|
|
|
# Apply ordering
|
|
valid_order_fields = ['created', 'modified', 'title']
|
|
order_field = ordering.lstrip('-')
|
|
if order_field in valid_order_fields:
|
|
queryset = queryset.order_by(ordering)
|
|
else:
|
|
queryset = queryset.order_by('-created')
|
|
|
|
# Serialize lists
|
|
lists = [_serialize_top_list(tl) for tl in queryset]
|
|
return lists
|