import os from django.db.models import Q from .models import ( Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem, CollectionItineraryDay, ) from rest_framework import serializers from main.utils import CustomModelSerializer from users.serializers import CustomUserDetailsSerializer from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer from geopy.distance import geodesic from integrations.models import ImmichIntegration from adventures.utils.geojson import gpx_to_geojson import gpxpy import logging logger = logging.getLogger(__name__) def _build_profile_pic_url(user): """Return absolute-ish profile pic URL using PUBLIC_URL if available.""" if not getattr(user, "profile_pic", None): return None public_url = os.environ.get("PUBLIC_URL", "http://127.0.0.1:8000").rstrip("/") public_url = public_url.replace("'", "") return f"{public_url}/media/{user.profile_pic.name}" def _serialize_collaborator(user, owner_id=None, request_user=None): if not user: return None return { "uuid": str(user.uuid), "username": user.username, "first_name": user.first_name, "last_name": user.last_name, "profile_pic": _build_profile_pic_url(user), "public_profile": bool(getattr(user, "public_profile", False)), "is_owner": owner_id == user.id, "is_current_user": bool(request_user and request_user.id == user.id), } class ContentImageSerializer(CustomModelSerializer): class Meta: model = ContentImage fields = ["id", "image", "is_primary", "user", "immich_id"] read_only_fields = ["id", "user"] def to_representation(self, instance): # If immich_id is set, check for user integration once integration = None if instance.immich_id: integration = ImmichIntegration.objects.filter(user=instance.user).first() if not integration: return None # Skip if Immich image but no integration # Base representation representation = super().to_representation(instance) # Prepare public URL once public_url = ( os.environ.get("PUBLIC_URL", "http://127.0.0.1:8000") .rstrip("/") .replace("'", "") ) if instance.immich_id: # Use Immich integration URL representation["image"] = ( f"{public_url}/api/integrations/immich/{integration.id}/get/{instance.immich_id}" ) elif instance.image: # Use local image URL representation["image"] = f"{public_url}/media/{instance.image.name}" return representation class AttachmentSerializer(CustomModelSerializer): extension = serializers.SerializerMethodField() geojson = serializers.SerializerMethodField() class Meta: model = ContentAttachment fields = ["id", "file", "extension", "name", "user", "geojson"] read_only_fields = ["id", "user"] def get_extension(self, obj): return obj.file.name.split(".")[-1] def to_representation(self, instance): representation = super().to_representation(instance) if instance.file: public_url = os.environ.get("PUBLIC_URL", "http://127.0.0.1:8000").rstrip( "/" ) # print(public_url) # remove any ' from the url public_url = public_url.replace("'", "") representation["file"] = f"{public_url}/media/{instance.file.name}" return representation def get_geojson(self, obj): if obj.file and obj.file.name.endswith(".gpx"): return gpx_to_geojson(obj.file) return None class CategorySerializer(serializers.ModelSerializer): num_locations = serializers.SerializerMethodField() class Meta: model = Category fields = ["id", "name", "display_name", "icon", "num_locations"] read_only_fields = ["id", "num_locations"] def validate_name(self, value): return value.lower() def create(self, validated_data): user = self.context["request"].user validated_data["name"] = validated_data["name"].lower() return Category.objects.create(user=user, **validated_data) def update(self, instance, validated_data): for attr, value in validated_data.items(): setattr(instance, attr, value) if "name" in validated_data: instance.name = validated_data["name"].lower() instance.save() return instance def get_num_locations(self, obj): return Location.objects.filter(category=obj, user=obj.user).count() class TrailSerializer(CustomModelSerializer): provider = serializers.SerializerMethodField() wanderer_data = serializers.SerializerMethodField() wanderer_link = serializers.SerializerMethodField() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._wanderer_integration_cache = {} class Meta: model = Trail fields = [ "id", "user", "name", "location", "created_at", "link", "wanderer_id", "provider", "wanderer_data", "wanderer_link", ] read_only_fields = ["id", "created_at", "user", "provider"] def _get_wanderer_integration(self, user): """Cache wanderer integration to avoid multiple database queries""" if user.id not in self._wanderer_integration_cache: from integrations.models import WandererIntegration self._wanderer_integration_cache[user.id] = ( WandererIntegration.objects.filter(user=user).first() ) return self._wanderer_integration_cache[user.id] def get_provider(self, obj): if obj.wanderer_id: return "Wanderer" # check the link to get the provider such as Strava, AllTrails, etc. if obj.link: if "strava" in obj.link: return "Strava" elif "alltrails" in obj.link: return "AllTrails" elif "komoot" in obj.link: return "Komoot" elif "outdooractive" in obj.link: return "Outdooractive" return "External Link" def get_wanderer_data(self, obj): if not obj.wanderer_id: return None # Use cached integration integration = self._get_wanderer_integration(obj.user) if not integration: return None # Fetch the Wanderer trail data from integrations.wanderer_services import fetch_trail_by_id try: trail_data = fetch_trail_by_id(integration, obj.wanderer_id) if not trail_data: return None # Cache the trail data and link on the object to avoid refetching obj._wanderer_data = trail_data base_url = integration.server_url.rstrip("/") obj._wanderer_link = f"{base_url}/trails/{obj.wanderer_id}" return trail_data except Exception as e: logger.error( f"Error fetching Wanderer trail data for {obj.wanderer_id}: {e}" ) return None def get_wanderer_link(self, obj): if not obj.wanderer_id: return None # Use cached integration integration = self._get_wanderer_integration(obj.user) if not integration: return None base_url = integration.server_url.rstrip("/") return f"{base_url}/trail/view/@{integration.username}/{obj.wanderer_id}" class ActivitySerializer(CustomModelSerializer): geojson = serializers.SerializerMethodField() class Meta: model = Activity fields = [ "id", "user", "visit", "trail", "gpx_file", "name", "sport_type", "distance", "moving_time", "elapsed_time", "rest_time", "elevation_gain", "elevation_loss", "elev_high", "elev_low", "start_date", "start_date_local", "timezone", "average_speed", "max_speed", "average_cadence", "calories", "start_lat", "start_lng", "end_lat", "end_lng", "external_service_id", "geojson", ] read_only_fields = ["id", "user"] def to_representation(self, instance): representation = super().to_representation(instance) if instance.gpx_file: public_url = ( os.environ.get("PUBLIC_URL", "http://127.0.0.1:8000") .rstrip("/") .replace("'", "") ) representation["gpx_file"] = f"{public_url}/media/{instance.gpx_file.name}" return representation def get_geojson(self, obj): return gpx_to_geojson(obj.gpx_file) class VisitSerializer(serializers.ModelSerializer): activities = ActivitySerializer(many=True, read_only=True, required=False) class Meta: model = Visit fields = [ "id", "start_date", "end_date", "timezone", "notes", "activities", "location", "created_at", "updated_at", ] read_only_fields = ["id", "created_at", "updated_at"] def create(self, validated_data): if not validated_data.get("end_date") and validated_data.get("start_date"): validated_data["end_date"] = validated_data["start_date"] return super().create(validated_data) class CalendarVisitSerializer(serializers.ModelSerializer): class Meta: model = Visit fields = ["id", "start_date", "end_date", "timezone"] class CalendarLocationSerializer(serializers.ModelSerializer): visits = CalendarVisitSerializer(many=True, read_only=True) category = serializers.SerializerMethodField() class Meta: model = Location fields = ["id", "name", "location", "category", "visits"] def get_category(self, obj): if not obj.category: return None return { "name": obj.category.name, "icon": obj.category.icon, } class LocationSerializer(CustomModelSerializer): images = serializers.SerializerMethodField() visits = VisitSerializer(many=True, read_only=False, required=False) attachments = AttachmentSerializer(many=True, read_only=True) category = CategorySerializer(read_only=False, required=False) is_visited = serializers.SerializerMethodField() country = CountrySerializer(read_only=True) region = RegionSerializer(read_only=True) city = CitySerializer(read_only=True) collections = serializers.PrimaryKeyRelatedField( many=True, queryset=Collection.objects.all(), required=False ) trails = TrailSerializer(many=True, read_only=True, required=False) class Meta: model = Location fields = [ "id", "name", "description", "rating", "tags", "location", "is_public", "collections", "created_at", "updated_at", "images", "link", "longitude", "latitude", "visits", "is_visited", "category", "attachments", "user", "city", "country", "region", "trails", "price", "price_currency", ] read_only_fields = ["id", "created_at", "updated_at", "user", "is_visited"] # Makes it so the whole user object is returned in the serializer instead of just the user uuid def to_representation(self, instance): representation = super().to_representation(instance) is_nested = self.context.get("nested", False) allowed_nested_fields = set(self.context.get("allowed_nested_fields", [])) if not is_nested: # Full representation for standalone locations representation["user"] = CustomUserDetailsSerializer( instance.user, context=self.context ).data else: # Slim representation for nested contexts, but keep allowed fields fields_to_remove = [ "visits", "attachments", "trails", "collections", "user", "city", "country", "region", ] for field in fields_to_remove: # Keep field if explicitly allowed for nested mode if field not in allowed_nested_fields: representation.pop(field, None) return representation def get_images(self, obj): serializer = ContentImageSerializer( obj.images.all(), many=True, context=self.context ) # Filter out None values from the serialized data return [image for image in serializer.data if image is not None] def validate_collections(self, collections): """Validate that collections are compatible with the location being created/updated""" if not collections: return collections user = self.context["request"].user # Get the location being updated (if this is an update operation) location_owner = getattr(self.instance, "user", None) if self.instance else user # For updates, we need to check if collections are being added or removed current_collections = ( set(self.instance.collections.all()) if self.instance else set() ) new_collections_set = set(collections) collections_to_add = new_collections_set - current_collections collections_to_remove = current_collections - new_collections_set # Validate collections being added for collection in collections_to_add: # Check if user has permission to use this collection user_has_shared_access = collection.shared_with.filter(id=user.id).exists() if collection.user != user and not user_has_shared_access: raise serializers.ValidationError( f"The requested collection does not belong to the current user." ) # Check location owner compatibility - both directions if collection.user != location_owner: # If user owns the collection but not the location, location owner must have shared access if collection.user == user: location_owner_has_shared_access = ( collection.shared_with.filter(id=location_owner.id).exists() if location_owner else False ) if not location_owner_has_shared_access: raise serializers.ValidationError( f"Locations must be associated with collections owned by the same user or shared collections. Collection owner: {collection.user.username} Location owner: {location_owner.username if location_owner else 'None'}" ) # If using someone else's collection, location owner must have shared access else: location_owner_has_shared_access = ( collection.shared_with.filter(id=location_owner.id).exists() if location_owner else False ) if not location_owner_has_shared_access: raise serializers.ValidationError( "Location cannot be added to collection unless the location owner has shared access to the collection." ) # Validate collections being removed - allow if user owns the collection OR owns the location for collection in collections_to_remove: user_owns_collection = collection.user == user user_owns_location = location_owner == user if location_owner else False user_has_shared_access = collection.shared_with.filter(id=user.id).exists() if not ( user_owns_collection or user_owns_location or user_has_shared_access ): raise serializers.ValidationError( "You don't have permission to remove this location from one of the collections it's linked to." ) return collections def validate_category(self, category_data): if isinstance(category_data, Category): return category_data if category_data: user = self.context["request"].user name = category_data.get("name", "").lower() existing_category = Category.objects.filter(user=user, name=name).first() if existing_category: return existing_category category_data["name"] = name return category_data def get_or_create_category(self, category_data): user = self.context["request"].user if isinstance(category_data, Category): return category_data if isinstance(category_data, dict): name = category_data.get("name", "").lower() display_name = category_data.get("display_name", name) icon = category_data.get("icon", "🌍") else: name = category_data.name.lower() display_name = category_data.display_name icon = category_data.icon category, created = Category.objects.get_or_create( user=user, name=name, defaults={"display_name": display_name, "icon": icon} ) return category def get_is_visited(self, obj): return obj.is_visited_status() def create(self, validated_data): category_data = validated_data.pop("category", None) collections_data = validated_data.pop("collections", []) location = Location.objects.create(**validated_data) # Handle category if category_data: category = self.get_or_create_category(category_data) location.category = category # Handle collections - set after location is saved if collections_data: location.collections.set(collections_data) location.save() return location def update(self, instance, validated_data): category_data = validated_data.pop("category", None) visits_data = validated_data.pop("visits", None) collections_data = validated_data.pop("collections", None) # Update regular fields for attr, value in validated_data.items(): setattr(instance, attr, value) # Handle category - ONLY allow the location owner to change categories user = self.context["request"].user if category_data and instance.user == user: # Only the owner can set categories category = self.get_or_create_category(category_data) instance.category = category # If not the owner, ignore category changes # Save the location first so that user-supplied field values (including # is_public) are persisted before the m2m_changed signal fires. instance.save() # Handle collections - only update if collections were provided. # NOTE: .set() triggers the m2m_changed signal which may override # is_public based on collection publicity. By saving first we ensure # the user's explicit value reaches the DB before the signal runs. if collections_data is not None: instance.collections.set(collections_data) # Handle visits - replace all visits if provided if visits_data is not None: instance.visits.all().delete() for visit_data in visits_data: Visit.objects.create(location=instance, **visit_data) return instance class MapPinSerializer(serializers.ModelSerializer): is_visited = serializers.SerializerMethodField() category = CategorySerializer(read_only=True, required=False) class Meta: model = Location fields = ["id", "name", "latitude", "longitude", "is_visited", "category"] read_only_fields = [ "id", "name", "latitude", "longitude", "is_visited", "category", ] def get_is_visited(self, obj): return obj.is_visited_status() class TransportationSerializer(CustomModelSerializer): distance = serializers.SerializerMethodField() images = serializers.SerializerMethodField() attachments = serializers.SerializerMethodField() travel_duration_minutes = serializers.SerializerMethodField() class Meta: model = Transportation fields = [ "id", "user", "type", "name", "description", "rating", "price", "price_currency", "link", "date", "flight_number", "from_location", "to_location", "is_public", "collection", "created_at", "updated_at", "end_date", "origin_latitude", "origin_longitude", "destination_latitude", "destination_longitude", "start_timezone", "end_timezone", "distance", "images", "attachments", "start_code", "end_code", "travel_duration_minutes", ] read_only_fields = [ "id", "created_at", "updated_at", "user", "distance", "travel_duration_minutes", ] def get_images(self, obj): serializer = ContentImageSerializer( obj.images.all(), many=True, context=self.context ) # Filter out None values from the serialized data return [image for image in serializer.data if image is not None] def get_attachments(self, obj): serializer = AttachmentSerializer( obj.attachments.all(), many=True, context=self.context ) # Filter out None values from the serialized data return [attachment for attachment in serializer.data if attachment is not None] def get_distance(self, obj): gpx_distance = self._get_gpx_distance_km(obj) if gpx_distance is not None: return gpx_distance if ( obj.origin_latitude and obj.origin_longitude and obj.destination_latitude and obj.destination_longitude ): try: origin = (float(obj.origin_latitude), float(obj.origin_longitude)) destination = ( float(obj.destination_latitude), float(obj.destination_longitude), ) return round(geodesic(origin, destination).km, 2) except ValueError: return None return None def _get_gpx_distance_km(self, obj): gpx_attachments = obj.attachments.filter(file__iendswith=".gpx") for attachment in gpx_attachments: distance_km = self._parse_gpx_distance_km(attachment.file) if distance_km is not None: return distance_km return None def _parse_gpx_distance_km(self, gpx_file_field): try: with gpx_file_field.open("r") as gpx_file: gpx = gpxpy.parse(gpx_file) total_meters = 0.0 for track in gpx.tracks: for segment in track.segments: segment_length = segment.length_3d() or segment.length_2d() if segment_length: total_meters += segment_length for route in gpx.routes: route_length = route.length_3d() or route.length_2d() if route_length: total_meters += route_length if total_meters > 0: return round(total_meters / 1000, 2) except Exception as exc: logger.warning( "Failed to calculate GPX distance for file %s: %s", getattr(gpx_file_field, "name", "unknown"), exc, ) return None def get_travel_duration_minutes(self, obj): if not obj.date or not obj.end_date: return None if self._is_all_day(obj.date) and self._is_all_day(obj.end_date): return None try: total_minutes = int((obj.end_date - obj.date).total_seconds() // 60) return total_minutes if total_minutes >= 0 else None except Exception: logger.warning( "Failed to calculate travel duration for transportation %s", getattr(obj, "id", "unknown"), exc_info=True, ) return None def _is_all_day(self, dt_value): return ( dt_value.time().hour == 0 and dt_value.time().minute == 0 and dt_value.time().second == 0 and dt_value.time().microsecond == 0 ) class LodgingSerializer(CustomModelSerializer): images = serializers.SerializerMethodField() attachments = serializers.SerializerMethodField() class Meta: model = Lodging fields = [ "id", "user", "name", "description", "rating", "link", "check_in", "check_out", "reservation_number", "price", "price_currency", "latitude", "longitude", "location", "is_public", "collection", "created_at", "updated_at", "type", "timezone", "images", "attachments", ] read_only_fields = ["id", "created_at", "updated_at", "user"] def get_images(self, obj): serializer = ContentImageSerializer( obj.images.all(), many=True, context=self.context ) # Filter out None values from the serialized data return [image for image in serializer.data if image is not None] def get_attachments(self, obj): serializer = AttachmentSerializer( obj.attachments.all(), many=True, context=self.context ) # Filter out None values from the serialized data return [attachment for attachment in serializer.data if attachment is not None] class NoteSerializer(CustomModelSerializer): class Meta: model = Note fields = [ "id", "user", "name", "content", "date", "links", "is_public", "collection", "created_at", "updated_at", ] read_only_fields = ["id", "created_at", "updated_at", "user"] class ChecklistItemSerializer(CustomModelSerializer): class Meta: model = ChecklistItem fields = [ "id", "user", "name", "is_checked", "checklist", "created_at", "updated_at", ] read_only_fields = ["id", "created_at", "updated_at", "user", "checklist"] class ChecklistSerializer(CustomModelSerializer): items = ChecklistItemSerializer(many=True, source="checklistitem_set") class Meta: model = Checklist fields = [ "id", "user", "name", "date", "is_public", "collection", "created_at", "updated_at", "items", ] read_only_fields = ["id", "created_at", "updated_at", "user"] def create(self, validated_data): items_data = validated_data.pop("checklistitem_set") checklist = Checklist.objects.create(**validated_data) for item_data in items_data: # Remove user from item_data to avoid constraint issues item_data.pop("user", None) # Set user from the parent checklist ChecklistItem.objects.create( checklist=checklist, user=checklist.user, **item_data ) return checklist def update(self, instance, validated_data): items_data = validated_data.pop("checklistitem_set", []) # Update Checklist fields for attr, value in validated_data.items(): setattr(instance, attr, value) instance.save() # Get current items current_items = instance.checklistitem_set.all() current_item_ids = set(current_items.values_list("id", flat=True)) # Update or create items updated_item_ids = set() for item_data in items_data: # Remove user from item_data to avoid constraint issues item_data.pop("user", None) item_id = item_data.get("id") if item_id: if item_id in current_item_ids: item = current_items.get(id=item_id) for attr, value in item_data.items(): setattr(item, attr, value) item.save() updated_item_ids.add(item_id) else: # If ID is provided but doesn't exist, create new item ChecklistItem.objects.create( checklist=instance, user=instance.user, **item_data ) else: # If no ID is provided, create new item ChecklistItem.objects.create( checklist=instance, user=instance.user, **item_data ) # Delete items that are not in the updated data items_to_delete = current_item_ids - updated_item_ids instance.checklistitem_set.filter(id__in=items_to_delete).delete() return instance def validate(self, data): # Check if the collection is public and the checklist is not collection = data.get("collection") is_public = data.get("is_public", False) if collection and collection.is_public and not is_public: raise serializers.ValidationError( "Checklists associated with a public collection must be public." ) return data class CollectionSerializer(CustomModelSerializer): collaborators = serializers.SerializerMethodField() locations = serializers.SerializerMethodField() transportations = serializers.SerializerMethodField() notes = serializers.SerializerMethodField() checklists = serializers.SerializerMethodField() lodging = serializers.SerializerMethodField() status = serializers.SerializerMethodField() days_until_start = serializers.SerializerMethodField() primary_image = ContentImageSerializer(read_only=True) primary_image_id = serializers.PrimaryKeyRelatedField( queryset=ContentImage.objects.all(), source="primary_image", write_only=True, required=False, allow_null=True, ) # Override link as CharField so DRF's URLField doesn't reject invalid # values before validate_link() can clean them up. link = serializers.CharField(required=False, allow_blank=True, allow_null=True) class Meta: model = Collection fields = [ "id", "description", "user", "name", "is_public", "locations", "created_at", "start_date", "end_date", "transportations", "notes", "updated_at", "checklists", "is_archived", "shared_with", "collaborators", "link", "lodging", "status", "days_until_start", "primary_image", "primary_image_id", ] read_only_fields = [ "id", "created_at", "updated_at", "user", "shared_with", "status", "days_until_start", "primary_image", ] def validate_link(self, value): """Convert empty or invalid URLs to None so Django doesn't reject them.""" if not value or not value.strip(): return None from django.core.validators import URLValidator from django.core.exceptions import ValidationError as DjangoValidationError validator = URLValidator() try: validator(value) except DjangoValidationError: return None return value def get_collaborators(self, obj): request = self.context.get("request") request_user = getattr(request, "user", None) if request else None users = [] if obj.user: users.append(obj.user) users.extend(list(obj.shared_with.all())) collaborators = [] seen = set() for user in users: if not user: continue key = str(user.uuid) if key in seen: continue seen.add(key) serialized = _serialize_collaborator( user, owner_id=obj.user_id, request_user=request_user ) if serialized: collaborators.append(serialized) return collaborators def get_locations(self, obj): if self.context.get("nested", False): allowed_nested_fields = set(self.context.get("allowed_nested_fields", [])) return LocationSerializer( obj.locations.all(), many=True, context={ **self.context, "nested": True, "allowed_nested_fields": allowed_nested_fields, }, ).data return LocationSerializer( obj.locations.all(), many=True, context=self.context ).data def get_transportations(self, obj): # Only include transportations if not in nested context if self.context.get("nested", False): return [] return TransportationSerializer( obj.transportation_set.all(), many=True, context=self.context ).data def get_notes(self, obj): # Only include notes if not in nested context if self.context.get("nested", False): return [] return NoteSerializer(obj.note_set.all(), many=True, context=self.context).data def get_checklists(self, obj): # Only include checklists if not in nested context if self.context.get("nested", False): return [] return ChecklistSerializer( obj.checklist_set.all(), many=True, context=self.context ).data def get_lodging(self, obj): # Only include lodging if not in nested context if self.context.get("nested", False): return [] return LodgingSerializer( obj.lodging_set.all(), many=True, context=self.context ).data def get_status(self, obj): """Calculate the status of the collection based on dates""" from datetime import date # If no dates, it's a folder if not obj.start_date or not obj.end_date: return "folder" today = date.today() # Future trip if obj.start_date > today: return "upcoming" # Past trip if obj.end_date < today: return "completed" # Current trip return "in_progress" def get_days_until_start(self, obj): """Calculate days until start for upcoming collections""" from datetime import date if not obj.start_date: return None today = date.today() if obj.start_date > today: return (obj.start_date - today).days return None def validate(self, attrs): data = super().validate(attrs) # Only validate primary image when explicitly provided if "primary_image" not in data: return data primary_image = data.get("primary_image") if primary_image is None: return data request = self.context.get("request") if request and primary_image.user != request.user: raise serializers.ValidationError( {"primary_image_id": "You can only choose cover images you own."} ) if self.instance and not self._image_belongs_to_collection( primary_image, self.instance ): raise serializers.ValidationError( { "primary_image_id": "Cover image must come from a location in this collection." } ) return data def _image_belongs_to_collection(self, image, collection): if ContentImage.objects.filter( id=image.id, location__collections=collection ).exists(): return True if ContentImage.objects.filter( id=image.id, visit__location__collections=collection ).exists(): return True return False def to_representation(self, instance): representation = super().to_representation(instance) # Make it display the user uuid for the shared users instead of the PK shared_uuids = [] for user in instance.shared_with.all(): shared_uuids.append(str(user.uuid)) representation["shared_with"] = shared_uuids # If nested, remove the heavy fields entirely from the response if self.context.get("nested", False): fields_to_remove = ["transportations", "notes", "checklists", "lodging"] for field in fields_to_remove: representation.pop(field, None) return representation class CollectionInviteSerializer(serializers.ModelSerializer): name = serializers.CharField(source="collection.name", read_only=True) collection_owner_username = serializers.CharField( source="collection.user.username", read_only=True ) collection_user_first_name = serializers.CharField( source="collection.user.first_name", read_only=True ) collection_user_last_name = serializers.CharField( source="collection.user.last_name", read_only=True ) class Meta: model = CollectionInvite fields = [ "id", "collection", "created_at", "name", "collection_owner_username", "collection_user_first_name", "collection_user_last_name", ] read_only_fields = ["id", "created_at"] class UltraSlimCollectionSerializer(serializers.ModelSerializer): location_images = serializers.SerializerMethodField() location_count = serializers.SerializerMethodField() status = serializers.SerializerMethodField() days_until_start = serializers.SerializerMethodField() primary_image = ContentImageSerializer(read_only=True) collaborators = serializers.SerializerMethodField() class Meta: model = Collection fields = [ "id", "user", "name", "description", "is_public", "start_date", "end_date", "is_archived", "link", "created_at", "updated_at", "location_images", "location_count", "shared_with", "collaborators", "status", "days_until_start", "primary_image", ] read_only_fields = fields # All fields are read-only for listing def get_collaborators(self, obj): request = self.context.get("request") request_user = getattr(request, "user", None) if request else None users = [] if obj.user: users.append(obj.user) users.extend(list(obj.shared_with.all())) collaborators = [] seen = set() for user in users: if not user: continue key = str(user.uuid) if key in seen: continue seen.add(key) serialized = _serialize_collaborator( user, owner_id=obj.user_id, request_user=request_user ) if serialized: collaborators.append(serialized) return collaborators def get_location_images(self, obj): """Get collection-related primary images (location/lodging/transportation).""" images = [] prefetched = getattr(obj, "_prefetched_objects_cache", {}) has_prefetch = all( relation in prefetched for relation in ("locations", "lodging_set", "transportation_set") ) if has_prefetch: for location in obj.locations.all(): images.extend(getattr(location, "primary_images", [])) for lodging in obj.lodging_set.all(): images.extend(getattr(lodging, "primary_images", [])) for transportation in obj.transportation_set.all(): images.extend(getattr(transportation, "primary_images", [])) else: images = list( ContentImage.objects.filter( Q(location__collections=obj) | Q(lodging__collection=obj) | Q(transportation__collection=obj), is_primary=True, ) .select_related("user") .distinct() ) if obj.primary_image and obj.primary_image.id not in { image.id for image in images }: images.append(obj.primary_image) def sort_key(image): if obj.primary_image and image.id == obj.primary_image.id: return (0, str(image.id)) if image.is_primary: return (1, str(image.id)) return (2, str(image.id)) images.sort(key=sort_key) serializer = ContentImageSerializer( images, many=True, context={"request": self.context.get("request")} ) # Filter out None values from the serialized data return [image for image in serializer.data if image is not None] def get_location_count(self, obj): """Get count of locations in this collection""" # This uses the cached count if available, or does a simple count query return obj.locations.count() def get_status(self, obj): """Calculate the status of the collection based on dates""" from datetime import date # If no dates, it's a folder if not obj.start_date or not obj.end_date: return "folder" today = date.today() # Future trip if obj.start_date > today: return "upcoming" # Past trip if obj.end_date < today: return "completed" # Current trip return "in_progress" def get_days_until_start(self, obj): """Calculate days until start for upcoming collections""" from datetime import date if not obj.start_date: return None today = date.today() if obj.start_date > today: return (obj.start_date - today).days return None def to_representation(self, instance): representation = super().to_representation(instance) # make it show the uuid instead of the pk for the user representation["user"] = str(instance.user.uuid) # Make it display the user uuid for the shared users instead of the PK shared_uuids = [] for user in instance.shared_with.all(): shared_uuids.append(str(user.uuid)) representation["shared_with"] = shared_uuids return representation class CollectionItineraryDaySerializer(CustomModelSerializer): class Meta: model = CollectionItineraryDay fields = [ "id", "collection", "date", "name", "description", "created_at", "updated_at", ] read_only_fields = ["id", "created_at", "updated_at"] def update(self, instance, validated_data): # Security: Prevent changing collection or date after creation # This prevents shared users from reassigning itinerary days to themselves validated_data.pop("collection", None) validated_data.pop("date", None) return super().update(instance, validated_data) class CollectionItineraryItemSerializer(CustomModelSerializer): item = serializers.SerializerMethodField() start_datetime = serializers.ReadOnlyField() end_datetime = serializers.ReadOnlyField() object_name = serializers.ReadOnlyField(source="content_type.model") class Meta: model = CollectionItineraryItem fields = [ "id", "collection", "content_type", "object_id", "item", "date", "is_global", "order", "start_datetime", "end_datetime", "created_at", "object_name", ] read_only_fields = [ "id", "created_at", "start_datetime", "end_datetime", "item", "object_name", ] def update(self, instance, validated_data): # Security: Prevent changing collection, content_type, or object_id after creation # This prevents shared users from reassigning itinerary items to themselves # or linking items to objects they don't have permission to access validated_data.pop("collection", None) validated_data.pop("content_type", None) validated_data.pop("object_id", None) return super().update(instance, validated_data) def get_item(self, obj): """Return id and type for the linked item""" if not obj.item: return None return { "id": str(obj.item.id), "type": obj.content_type.model, }