from typing import List from django.db import transaction from django.utils.dateparse import parse_date, parse_datetime from rest_framework.exceptions import ValidationError, PermissionDenied from adventures.models import CollectionItineraryItem @transaction.atomic def reorder_itinerary_items(user, items_data: List[dict]): """Reorder itinerary items in bulk. Args: user: requesting user (for permission checks) items_data: list of dicts with keys `id`, `date`, `order` Returns: List[CollectionItineraryItem]: updated items (unsaved instances are saved by this function) Raises: ValidationError, PermissionDenied """ if not items_data: raise ValidationError({"items": "This field is required and must not be empty."}) if not isinstance(items_data, list): raise ValidationError({"items": "Must be a list of item updates."}) # Resolve ids and fetch items item_ids = [item.get('id') for item in items_data if item.get('id')] items_qs = CollectionItineraryItem.objects.filter(id__in=item_ids).select_related('collection') if items_qs.count() != len(item_ids): raise ValidationError({"items": "One or more items not found."}) items_map = {str(it.id): it for it in items_qs} # Permission checks: user must be collection owner or in shared_with for item_id in item_ids: item = items_map.get(item_id) if not item: continue collection = item.collection if not (collection.user == user or collection.shared_with.filter(id=user.id).exists()): raise PermissionDenied("You do not have permission to modify items in this collection.") # Two-phase update to avoid unique constraint races: # 1) assign very large temporary order values (guaranteed > existing orders) # 2) assign final date/order values temp_offset = 1_000_000 temp_updates = [] for i, item_data in enumerate(items_data): item_id = item_data.get('id') if not item_id: continue item = items_map.get(item_id) if not item: continue item.order = temp_offset + i temp_updates.append(item) if temp_updates: CollectionItineraryItem.objects.bulk_update(temp_updates, ['order']) # Finalize updated_items = [] for item_data in items_data: item_id = item_data.get('id') if not item_id: continue item = items_map.get(item_id) if not item: continue new_date = item_data.get('date') new_is_global = item_data.get('is_global') new_order = item_data.get('order') # If is_global is explicitly provided, set it and reconcile date accordingly if new_is_global is not None: item.is_global = bool(new_is_global) if item.is_global: item.date = None if (new_date is not None) and (not item.is_global): # validate date is within collection bounds (if collection has start/end) parsed = None try: parsed = parse_date(str(new_date)) except Exception: parsed = None if parsed is None: try: dt = parse_datetime(str(new_date)) if dt: parsed = dt.date() except Exception: parsed = None collection = item.collection if parsed and collection: if collection.start_date and parsed < collection.start_date: raise ValidationError({"items": f"Item {item_id} date {parsed} is before collection start date {collection.start_date}."}) if collection.end_date and parsed > collection.end_date: raise ValidationError({"items": f"Item {item_id} date {parsed} is after collection end date {collection.end_date}."}) item.date = new_date if new_order is not None: item.order = new_order updated_items.append(item) if updated_items: CollectionItineraryItem.objects.bulk_update(updated_items, ['date', 'is_global', 'order']) return updated_items