from typing import List from datetime import date, timedelta from django.db import transaction from django.contrib.contenttypes.models import ContentType from django.utils import timezone from pytz import timezone as pytz_timezone from adventures.models import Collection, CollectionItineraryItem, Visit, Lodging, Transportation, Note, Checklist from rest_framework.exceptions import ValidationError def _datetime_to_date_in_timezone(dt, timezone_str: str | None) -> date: """ Convert a datetime to a date, accounting for timezone only if there's actual time information. If the datetime is at UTC midnight (00:00:00), treat it as a date-only value and don't convert. If the datetime has a time component, apply timezone conversion. Args: dt: datetime object (can be timezone-aware or naive) timezone_str: IANA timezone string (e.g., 'America/New_York') Returns: date: The date in the specified timezone (or UTC if date-only) """ if dt is None: return None # If it's already a date, return it if isinstance(dt, date) and not hasattr(dt, 'time'): return dt # Check if this is a date-only value (stored as UTC midnight) # If time is 00:00:00, treat it as date-only and don't apply timezone conversion if hasattr(dt, 'hour') and dt.hour == 0 and dt.minute == 0 and dt.second == 0: return dt.date() if hasattr(dt, 'date') else dt # Ensure datetime is timezone-aware (assume UTC if naive) if hasattr(dt, 'tzinfo') and dt.tzinfo is None: dt = timezone.make_aware(dt, timezone.utc) # Convert to target timezone if provided, otherwise use UTC if timezone_str: try: target_tz = pytz_timezone(timezone_str) dt = dt.astimezone(target_tz) except Exception: # If timezone conversion fails, use UTC pass return dt.date() if hasattr(dt, 'date') else dt def auto_generate_itinerary(collection: Collection) -> List[CollectionItineraryItem]: """ Auto-generate itinerary items for a collection based on dated records. Rules: - Visits: Create one item per day of the visit (spanning multiple days) - Lodging: Create one item on check_in date only - Transportation: Create one item on start date - Notes: Create one item on their date if present - Checklists: Create one item on their date if present Order within a day (incremental): 1. Lodging (check-ins) 2. Visits 3. Transportation 4. Notes 5. Checklists Args: collection: Collection to generate itinerary for Returns: List[CollectionItineraryItem]: Created itinerary items Raises: ValidationError: If collection already has itinerary items or has no dated records """ # Validation: collection must have zero itinerary items if collection.itinerary_items.exists(): raise ValidationError({ "detail": "Collection already has itinerary items. Cannot auto-generate." }) # Get collection date range if not collection.start_date or not collection.end_date: raise ValidationError({ "detail": "Collection must have start_date and end_date set." }) start_date = collection.start_date end_date = collection.end_date # Collect all items to be added, grouped by date items_by_date = {} # date -> [(content_type, object_id, priority)] # Priority order for sorting within a day PRIORITY_LODGING = 1 PRIORITY_VISIT = 2 PRIORITY_TRANSPORTATION = 3 PRIORITY_NOTE = 4 PRIORITY_CHECKLIST = 5 # Process Visits: one location item per day of the visit # Note: We reference the Location, not the Visit itself from adventures.models import Location visits = Visit.objects.filter(location__collections=collection).select_related('location').distinct() for visit in visits: if visit.start_date and visit.location: # Convert to date using visit's timezone visit_start = _datetime_to_date_in_timezone(visit.start_date, visit.timezone) visit_end = _datetime_to_date_in_timezone(visit.end_date, visit.timezone) if visit.end_date else visit_start # Only include dates within collection range visit_start = max(visit_start, start_date) visit_end = min(visit_end or visit_start, end_date) current_date = visit_start while current_date <= visit_end: if current_date not in items_by_date: items_by_date[current_date] = [] items_by_date[current_date].append(( ContentType.objects.get_for_model(Location), visit.location.id, # Use Location ID, not Visit ID PRIORITY_VISIT )) current_date += timedelta(days=1) # Process Lodging: one item on check_in date only lodgings = Lodging.objects.filter(collection=collection) for lodging in lodgings: if lodging.check_in: # Convert to date using lodging's timezone checkin_date = _datetime_to_date_in_timezone(lodging.check_in, lodging.timezone) # Only include if within collection range if start_date <= checkin_date <= end_date: if checkin_date not in items_by_date: items_by_date[checkin_date] = [] items_by_date[checkin_date].append(( ContentType.objects.get_for_model(Lodging), lodging.id, PRIORITY_LODGING )) # Process Transportation: one item on start date transportations = Transportation.objects.filter(collection=collection) for transportation in transportations: if transportation.date: # Convert to date using transportation's start timezone trans_date = _datetime_to_date_in_timezone(transportation.date, transportation.start_timezone) # Only include if within collection range if start_date <= trans_date <= end_date: if trans_date not in items_by_date: items_by_date[trans_date] = [] items_by_date[trans_date].append(( ContentType.objects.get_for_model(Transportation), transportation.id, PRIORITY_TRANSPORTATION )) # Process Notes: one item on their date notes = Note.objects.filter(collection=collection) for note in notes: if note.date: # Notes don't have timezone field, use UTC note_date = _datetime_to_date_in_timezone(note.date, None) # Only include if within collection range if start_date <= note_date <= end_date: if note_date not in items_by_date: items_by_date[note_date] = [] items_by_date[note_date].append(( ContentType.objects.get_for_model(Note), note.id, PRIORITY_NOTE )) # Process Checklists: one item on their date checklists = Checklist.objects.filter(collection=collection) for checklist in checklists: if checklist.date: # Checklists don't have timezone field, use UTC checklist_date = _datetime_to_date_in_timezone(checklist.date, None) # Only include if within collection range if start_date <= checklist_date <= end_date: if checklist_date not in items_by_date: items_by_date[checklist_date] = [] items_by_date[checklist_date].append(( ContentType.objects.get_for_model(Checklist), checklist.id, PRIORITY_CHECKLIST )) # Validation: must have at least one dated record if not items_by_date: raise ValidationError({ "detail": "No dated records found within collection date range." }) # Create itinerary items created_items = [] for day_date in sorted(items_by_date.keys()): # Sort items by priority within the day items = sorted(items_by_date[day_date], key=lambda x: x[2]) for order, (content_type, object_id, priority) in enumerate(items): itinerary_item = CollectionItineraryItem.objects.create( collection=collection, content_type=content_type, object_id=object_id, date=day_date, order=order ) created_items.append(itinerary_item) return created_items