""" 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