mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-07-31 15:59:34 -04:00
- Added utility functions to ensure datetime values are UTC-aware, improving consistency in date handling across the application. - Introduced migrations to update existing visit records, making start and end dates timezone-aware. - Normalized all-day visit end dates to UTC midnight, ensuring accurate representation of all-day events. - Updated serializers and services to utilize new datetime utilities, enhancing the handling of visit dates in various contexts. - Added comprehensive tests for new datetime utilities to ensure correct functionality and edge case handling.
1392 lines
53 KiB
Python
1392 lines
53 KiB
Python
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, build_media_url, get_public_url
|
|
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
|
|
from adventures.utils.geo import point_to_lat_lon
|
|
from adventures.utils.datetime_utils import ensure_aware_utc, normalize_all_day_visit_dates
|
|
from adventures.utils.serializer_geo_fields import (
|
|
ActivityCoordinateMixin,
|
|
CoordinateSerializerMixin,
|
|
TransportationCoordinateMixin,
|
|
)
|
|
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
|
|
|
|
return build_media_url(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(CoordinateSerializerMixin, CustomModelSerializer):
|
|
source = serializers.ChoiceField(
|
|
choices=ContentImage.Source.choices,
|
|
required=False,
|
|
default=ContentImage.Source.UPLOAD,
|
|
)
|
|
source_url = serializers.URLField(required=False, allow_null=True, allow_blank=True)
|
|
|
|
class Meta:
|
|
model = ContentImage
|
|
fields = [
|
|
'id', 'image', 'is_primary', 'user', 'immich_id',
|
|
'source', 'source_url', 'latitude', 'longitude',
|
|
]
|
|
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 = get_public_url()
|
|
|
|
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'] = build_media_url(instance.image.name)
|
|
|
|
return representation
|
|
|
|
|
|
class ImageMapPinSerializer(serializers.ModelSerializer):
|
|
latitude = serializers.SerializerMethodField()
|
|
longitude = serializers.SerializerMethodField()
|
|
image = serializers.SerializerMethodField()
|
|
parent_type = serializers.SerializerMethodField()
|
|
parent_id = serializers.SerializerMethodField()
|
|
parent_name = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = ContentImage
|
|
fields = [
|
|
'id',
|
|
'image',
|
|
'latitude',
|
|
'longitude',
|
|
'source',
|
|
'is_primary',
|
|
'parent_type',
|
|
'parent_id',
|
|
'parent_name',
|
|
]
|
|
|
|
def get_latitude(self, obj):
|
|
lat, _ = point_to_lat_lon(obj.coordinates)
|
|
return lat
|
|
|
|
def get_longitude(self, obj):
|
|
_, lon = point_to_lat_lon(obj.coordinates)
|
|
return lon
|
|
|
|
def get_image(self, obj):
|
|
if obj.immich_id:
|
|
integration = ImmichIntegration.objects.filter(user=obj.user).first()
|
|
if not integration:
|
|
return None
|
|
public_url = get_public_url()
|
|
return f"{public_url}/api/integrations/immich/{integration.id}/get/{obj.immich_id}"
|
|
if obj.image:
|
|
return build_media_url(obj.image.name)
|
|
return None
|
|
|
|
def get_parent_type(self, obj):
|
|
return obj.content_type.model if obj.content_type_id else None
|
|
|
|
def get_parent_id(self, obj):
|
|
return str(obj.object_id)
|
|
|
|
def get_parent_name(self, obj):
|
|
parent = obj.content_object
|
|
return getattr(parent, 'name', None) if parent else None
|
|
|
|
def to_representation(self, instance):
|
|
representation = super().to_representation(instance)
|
|
if representation.get('image') is None:
|
|
return None
|
|
if representation.get('latitude') is None or representation.get('longitude') is None:
|
|
return None
|
|
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:
|
|
representation['file'] = build_media_url(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()
|
|
geojson = serializers.SerializerMethodField(read_only=True)
|
|
|
|
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', 'wanderer_author_username', 'wanderer_author_domain',
|
|
'provider', 'wanderer_data', 'wanderer_link', 'geojson',
|
|
]
|
|
read_only_fields = ['id', 'created_at', 'user', 'provider', 'wanderer_link']
|
|
|
|
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 _ensure_wanderer_author_fields(self, obj, integration, trail_data=None):
|
|
"""Backfill username/domain from Wanderer API when missing on existing trails."""
|
|
if not obj.wanderer_id or not integration:
|
|
return obj.wanderer_author_username, obj.wanderer_author_domain
|
|
|
|
username = (obj.wanderer_author_username or '').strip() or None
|
|
domain = (obj.wanderer_author_domain or '').strip() or None
|
|
if username and domain:
|
|
return username, domain
|
|
|
|
from integrations.wanderer_services import (
|
|
extract_wanderer_author_from_trail,
|
|
fetch_trail_by_id,
|
|
)
|
|
|
|
if trail_data is None:
|
|
try:
|
|
trail_data = fetch_trail_by_id(integration, obj.wanderer_id)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Could not fetch Wanderer trail %s for author backfill: %s",
|
|
obj.wanderer_id,
|
|
exc,
|
|
)
|
|
return username, domain
|
|
|
|
resolved_username, resolved_domain = extract_wanderer_author_from_trail(trail_data)
|
|
username = username or resolved_username
|
|
domain = domain or resolved_domain
|
|
|
|
updates = {}
|
|
if username and username != obj.wanderer_author_username:
|
|
updates['wanderer_author_username'] = username
|
|
obj.wanderer_author_username = username
|
|
if domain and domain != obj.wanderer_author_domain:
|
|
updates['wanderer_author_domain'] = domain
|
|
obj.wanderer_author_domain = domain
|
|
if updates:
|
|
Trail.objects.filter(pk=obj.pk).update(**updates)
|
|
|
|
return username, domain
|
|
|
|
def _build_wanderer_link(self, obj, integration, trail_data=None):
|
|
if not obj.wanderer_id or not integration:
|
|
return None
|
|
|
|
from integrations.wanderer_services import build_wanderer_trail_view_url
|
|
|
|
username, domain = self._ensure_wanderer_author_fields(
|
|
obj, integration, trail_data=trail_data
|
|
)
|
|
if not username or not domain:
|
|
return None
|
|
|
|
try:
|
|
return build_wanderer_trail_view_url(
|
|
integration.server_url,
|
|
obj.wanderer_id,
|
|
username,
|
|
domain,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Could not build Wanderer link for trail %s: %s",
|
|
obj.wanderer_id,
|
|
exc,
|
|
)
|
|
return None
|
|
|
|
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
|
|
|
|
integration = self._get_wanderer_integration(obj.user)
|
|
if not integration:
|
|
return None
|
|
|
|
if hasattr(obj, '_wanderer_data'):
|
|
return obj._wanderer_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
|
|
obj._wanderer_data = trail_data
|
|
self._ensure_wanderer_author_fields(obj, integration, trail_data=trail_data)
|
|
obj._wanderer_link = self._build_wanderer_link(
|
|
obj, integration, trail_data=trail_data
|
|
)
|
|
return trail_data
|
|
except Exception as e:
|
|
logger.error("Error fetching Wanderer trail data for %s: %s", obj.wanderer_id, e)
|
|
return None
|
|
|
|
def get_wanderer_link(self, obj):
|
|
if not obj.wanderer_id:
|
|
return None
|
|
|
|
integration = self._get_wanderer_integration(obj.user)
|
|
if not integration:
|
|
return None
|
|
|
|
if hasattr(obj, '_wanderer_link') and obj._wanderer_link:
|
|
return obj._wanderer_link
|
|
|
|
return self._build_wanderer_link(obj, integration)
|
|
|
|
def get_geojson(self, obj):
|
|
if not obj.wanderer_id:
|
|
return None
|
|
|
|
integration = self._get_wanderer_integration(obj.user)
|
|
if not integration:
|
|
return None
|
|
|
|
from integrations.wanderer_services import fetch_trail_geojson
|
|
|
|
trail_data = getattr(obj, '_wanderer_data', None)
|
|
try:
|
|
return fetch_trail_geojson(
|
|
integration,
|
|
obj.wanderer_id,
|
|
trail_data=trail_data,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Could not fetch GeoJSON for Wanderer trail %s: %s",
|
|
obj.wanderer_id,
|
|
exc,
|
|
)
|
|
return None
|
|
|
|
def _sync_wanderer_author_fields(self, validated_data, user):
|
|
wanderer_id = validated_data.get('wanderer_id')
|
|
if wanderer_id is None:
|
|
return validated_data
|
|
if not wanderer_id:
|
|
validated_data['wanderer_author_username'] = None
|
|
validated_data['wanderer_author_domain'] = None
|
|
return validated_data
|
|
|
|
username = (validated_data.get('wanderer_author_username') or '').strip() or None
|
|
domain = (validated_data.get('wanderer_author_domain') or '').strip().lstrip('@') or None
|
|
|
|
if username and domain:
|
|
validated_data['wanderer_author_username'] = username
|
|
validated_data['wanderer_author_domain'] = domain
|
|
return validated_data
|
|
|
|
integration = self._get_wanderer_integration(user)
|
|
if not integration:
|
|
return validated_data
|
|
|
|
from integrations.wanderer_services import (
|
|
extract_wanderer_author_from_trail,
|
|
fetch_trail_by_id,
|
|
)
|
|
|
|
try:
|
|
trail_data = fetch_trail_by_id(integration, wanderer_id)
|
|
resolved_username, resolved_domain = extract_wanderer_author_from_trail(trail_data)
|
|
if resolved_username:
|
|
validated_data['wanderer_author_username'] = resolved_username
|
|
if resolved_domain:
|
|
validated_data['wanderer_author_domain'] = resolved_domain
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Could not resolve Wanderer author for trail %s: %s",
|
|
wanderer_id,
|
|
exc,
|
|
)
|
|
|
|
return validated_data
|
|
|
|
def create(self, validated_data):
|
|
user = validated_data.get('user')
|
|
if user:
|
|
validated_data = self._sync_wanderer_author_fields(validated_data, user)
|
|
return super().create(validated_data)
|
|
|
|
def update(self, instance, validated_data):
|
|
if 'wanderer_id' in validated_data or (
|
|
'wanderer_author_username' in validated_data
|
|
or 'wanderer_author_domain' in validated_data
|
|
):
|
|
merged = {
|
|
'wanderer_id': validated_data.get('wanderer_id', instance.wanderer_id),
|
|
'wanderer_author_username': validated_data.get(
|
|
'wanderer_author_username',
|
|
instance.wanderer_author_username,
|
|
),
|
|
'wanderer_author_domain': validated_data.get(
|
|
'wanderer_author_domain',
|
|
instance.wanderer_author_domain,
|
|
),
|
|
}
|
|
merged = self._sync_wanderer_author_fields(merged, instance.user)
|
|
validated_data['wanderer_author_username'] = merged.get('wanderer_author_username')
|
|
validated_data['wanderer_author_domain'] = merged.get('wanderer_author_domain')
|
|
return super().update(instance, validated_data)
|
|
|
|
|
|
class ActivitySerializer(ActivityCoordinateMixin, 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:
|
|
representation['gpx_file'] = build_media_url(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 validate(self, attrs):
|
|
for field in ('start_date', 'end_date'):
|
|
if field in attrs and attrs[field] is not None:
|
|
attrs[field] = ensure_aware_utc(attrs[field])
|
|
|
|
instance = getattr(self, 'instance', None)
|
|
start = attrs.get('start_date', getattr(instance, 'start_date', None) if instance else None)
|
|
end = attrs.get('end_date', getattr(instance, 'end_date', None) if instance else None)
|
|
|
|
if start is not None:
|
|
normalized_start, normalized_end = normalize_all_day_visit_dates(start, end)
|
|
attrs['start_date'] = normalized_start
|
|
if normalized_end is not None:
|
|
attrs['end_date'] = normalized_end
|
|
|
|
return attrs
|
|
|
|
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(CoordinateSerializerMixin, 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):
|
|
validated_data = self._pop_lat_lon(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):
|
|
validated_data = self._pop_lat_lon(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(CoordinateSerializerMixin, 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(TransportationCoordinateMixin, 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
|
|
|
|
o_lat, o_lon = point_to_lat_lon(obj.origin)
|
|
d_lat, d_lon = point_to_lat_lon(obj.destination)
|
|
if o_lat is not None and o_lon is not None and d_lat is not None and d_lon is not None:
|
|
try:
|
|
origin = (o_lat, o_lon)
|
|
destination = (d_lat, d_lon)
|
|
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(CoordinateSerializerMixin, 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 primary images from locations in this collection, optimized with select_related"""
|
|
# Filter first, then slice (removed slicing)
|
|
images = list(
|
|
ContentImage.objects.filter(location__collections=obj)
|
|
.select_related('user')
|
|
)
|
|
|
|
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):
|
|
date = serializers.DateField(required=False, allow_null=True, default=None)
|
|
item = serializers.SerializerMethodField()
|
|
start_datetime = serializers.ReadOnlyField()
|
|
end_datetime = serializers.ReadOnlyField()
|
|
object_name = serializers.ReadOnlyField(source='content_type.model')
|
|
is_global = serializers.BooleanField(required=False, default=False)
|
|
|
|
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']
|
|
# DRF 3.15 generates UniqueTogetherValidator from UniqueConstraints without applying
|
|
# their conditions, causing false 400s (e.g. a dated item with order=0 is rejected
|
|
# when a global item with the same order exists). The view and DB constraints
|
|
# already enforce the correct conditional uniqueness.
|
|
validators = []
|
|
|
|
def validate(self, attrs):
|
|
data = super().validate(attrs)
|
|
|
|
is_global = data.get('is_global')
|
|
if is_global is None and self.instance is not None:
|
|
is_global = self.instance.is_global
|
|
|
|
if 'date' in data:
|
|
date = data.get('date')
|
|
elif self.instance is not None:
|
|
date = self.instance.date
|
|
else:
|
|
date = None
|
|
|
|
if is_global and date is not None:
|
|
raise serializers.ValidationError({
|
|
'date': 'Global items must not have a date.',
|
|
'is_global': 'Provide either a date or set is_global, not both.',
|
|
})
|
|
|
|
if not is_global and date is None and self.instance is None:
|
|
raise serializers.ValidationError({
|
|
'date': 'Dated items must include a date. To create a trip-wide item, set is_global=true.',
|
|
})
|
|
|
|
return data
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
class SearchHitSerializer(serializers.Serializer):
|
|
type = serializers.CharField()
|
|
id = serializers.CharField()
|
|
title = serializers.CharField()
|
|
subtitle = serializers.CharField(allow_blank=True)
|
|
url = serializers.CharField()
|
|
score = serializers.FloatField()
|
|
meta = serializers.DictField(required=False, default=dict)
|
|
|