diff --git a/.env.example b/.env.example index 3ac59564..c335436d 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,9 @@ BACKEND_PORT=8016 # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # FIELD_ENCRYPTION_KEY=replace_with_fernet_key +# Optional: custom MCP HTTP endpoint path (default: api/mcp) +# DJANGO_MCP_ENDPOINT=api/mcp + # Optional: disable registration # https://adventurelog.app/docs/configuration/disable_registration.html DISABLE_REGISTRATION=False diff --git a/README.md b/README.md index 7145ab60..a1baac64 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,14 @@ Voyage aims to be simple, beautiful, and open to everyone — inheriting Adventu - Collaborators can view and edit shared itineraries (collections), making planning a breeze. - **Customizable Themes** 🎨: Choose from 10 built-in themes including Light, Dark, Dim, Night, Forest, Aqua, Catppuccin Mocha, Aesthetic Light, Aesthetic Dark, and Northern Lights. Theme selection persists across sessions. +### Travel Agent (MCP) + +Voyage provides an authenticated Travel Agent MCP endpoint for programmatic itinerary workflows (list collections, inspect itinerary details, create items, reorder timelines). See the guide: [`documentation/docs/guides/travel_agent.md`](documentation/docs/guides/travel_agent.md). + +- Default MCP path: `api/mcp` +- Override MCP path with env var: `DJANGO_MCP_ENDPOINT` +- Get token from authenticated session: `GET /auth/mcp-token/` and use header `Authorization: Token ` + ## 🧭 Roadmap @@ -126,6 +134,22 @@ Contributions are always welcome! See `contributing.md` for ways to get started. +### Pre-upgrade backup + +Before upgrading Voyage or running migrations, export a collections backup snapshot: + +```bash +docker compose exec server python manage.py export_collections_backup +``` + +Optional custom output path: + +```bash +docker compose exec server python manage.py export_collections_backup --output /code/backups/collections_backup_pre_upgrade.json +``` + +This command exports `Collection` and `CollectionItineraryItem` data with timestamp and counts. + ### Translation Voyage is available on [Weblate](https://hosted.weblate.org/projects/voyage/). If you would like to help translate Voyage into your language, please visit the link and contribute! diff --git a/backend/server/.env.example b/backend/server/.env.example index e3873031..de9ca06d 100644 --- a/backend/server/.env.example +++ b/backend/server/.env.example @@ -27,6 +27,7 @@ EMAIL_BACKEND='console' # GOOGLE_MAPS_API_KEY='key' # OSRM_BASE_URL='https://router.project-osrm.org' # replace with self-host URL if needed (e.g. http://osrm:5000) +# DJANGO_MCP_ENDPOINT='api/mcp' # optional custom MCP HTTP endpoint path # ACCOUNT_EMAIL_VERIFICATION='none' # 'none', 'optional', 'mandatory' # You can change this as needed for your environment diff --git a/backend/server/adventures/management/commands/export_collections_backup.py b/backend/server/adventures/management/commands/export_collections_backup.py new file mode 100644 index 00000000..0a196454 --- /dev/null +++ b/backend/server/adventures/management/commands/export_collections_backup.py @@ -0,0 +1,104 @@ +import json +from pathlib import Path + +from django.core.management.base import BaseCommand, CommandError +from django.core.serializers.json import DjangoJSONEncoder +from django.utils import timezone + +from adventures.models import Collection, CollectionItineraryItem + + +class Command(BaseCommand): + help = ( + "Export Collection and CollectionItineraryItem data to a JSON backup " + "file before upgrades/migrations." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--output", + type=str, + help="Optional output file path (default: ./collections_backup_.json)", + ) + + def handle(self, *args, **options): + backup_timestamp = timezone.now() + timestamp = backup_timestamp.strftime("%Y%m%d_%H%M%S") + output_path = Path( + options.get("output") or f"collections_backup_{timestamp}.json" + ) + + if output_path.parent and not output_path.parent.exists(): + raise CommandError(f"Output directory does not exist: {output_path.parent}") + + collections = list( + Collection.objects.values( + "id", + "user_id", + "name", + "description", + "is_public", + "is_archived", + "start_date", + "end_date", + "link", + "primary_image_id", + "created_at", + "updated_at", + ) + ) + + shared_with_map = { + str(collection.id): list( + collection.shared_with.values_list("id", flat=True) + ) + for collection in Collection.objects.prefetch_related("shared_with") + } + for collection in collections: + collection["shared_with_ids"] = shared_with_map.get( + str(collection["id"]), [] + ) + + itinerary_items = list( + CollectionItineraryItem.objects.select_related("content_type").values( + "id", + "collection_id", + "content_type_id", + "content_type__app_label", + "content_type__model", + "object_id", + "date", + "is_global", + "order", + "created_at", + ) + ) + + backup_payload = { + "backup_type": "collections_snapshot", + "timestamp": backup_timestamp.isoformat(), + "counts": { + "collections": len(collections), + "collection_itinerary_items": len(itinerary_items), + }, + "collections": collections, + "collection_itinerary_items": itinerary_items, + } + + try: + with output_path.open("w", encoding="utf-8") as backup_file: + json.dump(backup_payload, backup_file, indent=2, cls=DjangoJSONEncoder) + except OSError as exc: + raise CommandError(f"Failed to write backup file: {exc}") from exc + except (TypeError, ValueError) as exc: + raise CommandError(f"Failed to serialize backup data: {exc}") from exc + + self.stdout.write( + self.style.SUCCESS( + "Exported collections backup to " + f"{output_path} " + f"at {backup_timestamp.isoformat()} " + f"(collections: {len(collections)}, " + f"itinerary_items: {len(itinerary_items)})." + ) + ) diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 5b0115e8..4640ff56 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,5 +1,24 @@ import os -from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem, CollectionItineraryDay +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 @@ -15,10 +34,10 @@ 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): + 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 = 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}" @@ -28,22 +47,22 @@ def _serialize_collaborator(user, owner_id=None, request_user=None): 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), + "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'] + 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 @@ -57,187 +76,254 @@ class ContentImageSerializer(CustomModelSerializer): representation = super().to_representation(instance) # Prepare public URL once - public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/').replace("'", "") + 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}" + 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}" + 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'] + fields = ["id", "file", "extension", "name", "user", "geojson"] + read_only_fields = ["id", "user"] def get_extension(self, obj): - return obj.file.name.split('.')[-1] + 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) + 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}" + 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'): + 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'] + 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() + 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() + 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'] + 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() + + 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' + 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' - + 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('/') + 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}") + 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('/') + + 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' + "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'] + 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}" + 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): +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'] + 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'] + 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'] + fields = ["id", "start_date", "end_date", "timezone"] class CalendarLocationSerializer(serializers.ModelSerializer): @@ -246,7 +332,7 @@ class CalendarLocationSerializer(serializers.ModelSerializer): class Meta: model = Location - fields = ['id', 'name', 'location', 'category', 'visits'] + fields = ["id", "name", "location", "category", "visits"] def get_category(self, obj): if not obj.category: @@ -257,7 +343,7 @@ class CalendarLocationSerializer(serializers.ModelSerializer): "icon": obj.category.icon, } - + class LocationSerializer(CustomModelSerializer): images = serializers.SerializerMethodField() visits = VisitSerializer(many=True, read_only=False, required=False) @@ -268,36 +354,63 @@ class LocationSerializer(CustomModelSerializer): region = RegionSerializer(read_only=True) city = CitySerializer(read_only=True) collections = serializers.PrimaryKeyRelatedField( - many=True, - queryset=Collection.objects.all(), - required=False + 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' + "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'] + 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', [])) + 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 + 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' + "visits", + "attachments", + "trails", + "collections", + "user", + "city", + "country", + "region", ] for field in fields_to_remove: # Keep field if explicitly allowed for nested mode @@ -306,145 +419,151 @@ class LocationSerializer(CustomModelSerializer): return representation - def get_images(self, obj): - serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context) + 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 - + + 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 - + 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() + 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 - + 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 - + 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): + + 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() + 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 + category_data["name"] = name return category_data - + def get_or_create_category(self, category_data): - user = self.context['request'].user - + 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', '🌍') + 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 - } + 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', []) - + 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) + 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 + 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) @@ -469,19 +588,28 @@ class LocationSerializer(CustomModelSerializer): 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'] - + 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() @@ -491,22 +619,57 @@ class TransportationSerializer(CustomModelSerializer): 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' + "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", ] - 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) + 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) + 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] @@ -516,19 +679,24 @@ class TransportationSerializer(CustomModelSerializer): return gpx_distance if ( - obj.origin_latitude and obj.origin_longitude and - obj.destination_latitude and obj.destination_longitude + 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)) + 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') + 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: @@ -537,7 +705,7 @@ class TransportationSerializer(CustomModelSerializer): def _parse_gpx_distance_km(self, gpx_file_field): try: - with gpx_file_field.open('r') as gpx_file: + with gpx_file_field.open("r") as gpx_file: gpx = gpxpy.parse(gpx_file) total_meters = 0.0 @@ -558,7 +726,7 @@ class TransportationSerializer(CustomModelSerializer): except Exception as exc: logger.warning( "Failed to calculate GPX distance for file %s: %s", - getattr(gpx_file_field, 'name', 'unknown'), + getattr(gpx_file_field, "name", "unknown"), exc, ) return None @@ -589,6 +757,7 @@ class TransportationSerializer(CustomModelSerializer): and dt_value.time().microsecond == 0 ) + class LodgingSerializer(CustomModelSerializer): images = serializers.SerializerMethodField() attachments = serializers.SerializerMethodField() @@ -596,84 +765,129 @@ class LodgingSerializer(CustomModelSerializer): 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' + "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'] + read_only_fields = ["id", "created_at", "updated_at", "user"] def get_images(self, obj): - serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context) + 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) + 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 NoteSerializer(CustomModelSerializer): class Meta: model = Note fields = [ - 'id', 'user', 'name', 'content', 'date', 'links', - 'is_public', 'collection', 'created_at', 'updated_at' + "id", + "user", + "name", + "content", + "date", + "links", + "is_public", + "collection", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_at', 'updated_at', 'user'] - + 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 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') - + items = ChecklistItemSerializer(many=True, source="checklistitem_set") + class Meta: model = Checklist fields = [ - 'id', 'user', 'name', 'date', 'is_public', 'collection', 'created_at', 'updated_at', 'items' + "id", + "user", + "name", + "date", + "is_public", + "collection", + "created_at", + "updated_at", + "items", ] - read_only_fields = ['id', 'created_at', 'updated_at', 'user'] - + read_only_fields = ["id", "created_at", "updated_at", "user"] + def create(self, validated_data): - items_data = validated_data.pop('checklistitem_set') + 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) + item_data.pop("user", None) # Set user from the parent checklist ChecklistItem.objects.create( - checklist=checklist, - user=checklist.user, - **item_data + checklist=checklist, user=checklist.user, **item_data ) return checklist - + def update(self, instance, validated_data): - items_data = validated_data.pop('checklistitem_set', []) - + 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)) - + 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') + 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) @@ -684,34 +898,31 @@ class ChecklistSerializer(CustomModelSerializer): else: # If ID is provided but doesn't exist, create new item ChecklistItem.objects.create( - checklist=instance, - user=instance.user, - **item_data + 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 + 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) + 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.' + "Checklists associated with a public collection must be public." ) return data + class CollectionSerializer(CustomModelSerializer): collaborators = serializers.SerializerMethodField() locations = serializers.SerializerMethodField() @@ -724,7 +935,7 @@ class CollectionSerializer(CustomModelSerializer): primary_image = ContentImageSerializer(read_only=True) primary_image_id = serializers.PrimaryKeyRelatedField( queryset=ContentImage.objects.all(), - source='primary_image', + source="primary_image", write_only=True, required=False, allow_null=True, @@ -736,30 +947,39 @@ class CollectionSerializer(CustomModelSerializer): 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', + "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", ] - 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.""" @@ -767,6 +987,7 @@ class CollectionSerializer(CustomModelSerializer): return None from django.core.validators import URLValidator from django.core.exceptions import ValidationError as DjangoValidationError + validator = URLValidator() try: validator(value) @@ -775,8 +996,8 @@ class CollectionSerializer(CustomModelSerializer): return value def get_collaborators(self, obj): - request = self.context.get('request') - request_user = getattr(request, 'user', None) if request else None + request = self.context.get("request") + request_user = getattr(request, "user", None) if request else None users = [] if obj.user: @@ -792,140 +1013,178 @@ class CollectionSerializer(CustomModelSerializer): if key in seen: continue seen.add(key) - serialized = _serialize_collaborator(user, owner_id=obj.user_id, request_user=request_user) + 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', [])) + 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} + 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 - - 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): + if self.context.get("nested", False): return [] - return TransportationSerializer(obj.transportation_set.all(), many=True, context=self.context).data + 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): + 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): + if self.context.get("nested", False): return [] - return ChecklistSerializer(obj.checklist_set.all(), many=True, context=self.context).data + 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): + if self.context.get("nested", False): return [] - return LodgingSerializer(obj.lodging_set.all(), many=True, context=self.context).data + 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' - + return "folder" + today = date.today() - + # Future trip if obj.start_date > today: - return 'upcoming' - + return "upcoming" + # Past trip if obj.end_date < today: - return 'completed' - + return "completed" + # Current trip - return 'in_progress' - + 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: + if "primary_image" not in data: return data - primary_image = data.get('primary_image') + primary_image = data.get("primary_image") if primary_image is None: return data - request = self.context.get('request') + 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.' - }) + 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.' - }) + 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(): + 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(): + 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 - + 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'] + 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) - + 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'] + 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() @@ -934,19 +1193,34 @@ class UltraSlimCollectionSerializer(serializers.ModelSerializer): 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' + "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 + request = self.context.get("request") + request_user = getattr(request, "user", None) if request else None users = [] if obj.user: @@ -962,20 +1236,48 @@ class UltraSlimCollectionSerializer(serializers.ModelSerializer): if key in seen: continue seen.add(key) - serialized = _serialize_collaborator(user, owner_id=obj.user_id, request_user=request_user) + 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') + """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)) @@ -986,9 +1288,7 @@ class UltraSlimCollectionSerializer(serializers.ModelSerializer): images.sort(key=sort_key) serializer = ContentImageSerializer( - images, - many=True, - context={'request': self.context.get('request')} + 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] @@ -1001,91 +1301,120 @@ class UltraSlimCollectionSerializer(serializers.ModelSerializer): 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' - + return "folder" + today = date.today() - + # Future trip if obj.start_date > today: - return 'upcoming' - + return "upcoming" + # Past trip if obj.end_date < today: - return 'completed' - + return "completed" + # Current trip - return 'in_progress' - + 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) - + 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 + 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'] - + 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) + 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') - + 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'] - + 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) + 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, + "id": str(obj.item.id), + "type": obj.content_type.model, } - \ No newline at end of file diff --git a/backend/server/adventures/tests.py b/backend/server/adventures/tests.py index 010f6702..3a736237 100644 --- a/backend/server/adventures/tests.py +++ b/backend/server/adventures/tests.py @@ -1,16 +1,34 @@ +import json +import tempfile +import base64 from datetime import timedelta +from pathlib import Path from unittest.mock import Mock, patch from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from django.core.cache import cache +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase from django.utils import timezone from rest_framework.test import APIClient, APITestCase +from adventures.models import ( + Collection, + CollectionItineraryItem, + ContentImage, + Lodging, + Note, + Transportation, +) + User = get_user_model() -class WeatherEndpointTests(APITestCase): +class WeatherViewTests(APITestCase): def setUp(self): self.user = User.objects.create_user( username="weather-user", @@ -35,11 +53,38 @@ class WeatherEndpointTests(APITestCase): self.assertEqual(response.status_code, 400) self.assertIn("maximum", response.json().get("error", "").lower()) - @patch("adventures.views.weather_view.requests.get") - def test_daily_temperatures_future_date_returns_unavailable_without_external_call( - self, mock_requests_get + @patch("adventures.views.weather_view.WeatherViewSet._fetch_daily_temperature") + def test_daily_temperatures_future_date_reaches_fetch_path( + self, mock_fetch_temperature ): future_date = (timezone.now().date() + timedelta(days=10)).isoformat() + mock_fetch_temperature.return_value = { + "date": future_date, + "available": True, + "temperature_c": 22.5, + } + + response = self.client.post( + "/api/weather/daily-temperatures/", + {"days": [{"date": future_date, "latitude": 12.34, "longitude": 56.78}]}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["results"][0]["date"], future_date) + self.assertTrue(response.json()["results"][0]["available"]) + self.assertEqual(response.json()["results"][0]["temperature_c"], 22.5) + mock_fetch_temperature.assert_called_once_with(future_date, 12.34, 56.78) + + @patch("adventures.views.weather_view.requests.get") + def test_daily_temperatures_far_future_returns_unavailable_when_upstream_has_no_data( + self, mock_requests_get + ): + future_date = (timezone.now().date() + timedelta(days=3650)).isoformat() + mocked_response = Mock() + mocked_response.raise_for_status.return_value = None + mocked_response.json.return_value = {"daily": {}} + mock_requests_get.return_value = mocked_response response = self.client.post( "/api/weather/daily-temperatures/", @@ -52,7 +97,7 @@ class WeatherEndpointTests(APITestCase): response.json()["results"][0], {"date": future_date, "available": False, "temperature_c": None}, ) - mock_requests_get.assert_not_called() + self.assertEqual(mock_requests_get.call_count, 2) @patch("adventures.views.weather_view.requests.get") def test_daily_temperatures_accepts_zero_lat_lon(self, mock_requests_get): @@ -106,3 +151,166 @@ class MCPAuthTests(APITestCase): unauthenticated_client = APIClient() response = unauthenticated_client.post("/api/mcp", {}, format="json") self.assertIn(response.status_code, [401, 403]) + + +class CollectionViewSetTests(APITestCase): + def setUp(self): + self.owner = User.objects.create_user( + username="collection-owner", + email="owner@example.com", + password="password123", + ) + self.shared_user = User.objects.create_user( + username="collection-shared", + email="shared@example.com", + password="password123", + ) + + def _create_test_image_file(self, name="test.png"): + # 1x1 PNG + png_bytes = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Y9x8AAAAASUVORK5CYII=" + ) + return SimpleUploadedFile(name, png_bytes, content_type="image/png") + + def _create_collection_with_non_location_images(self): + collection = Collection.objects.create( + user=self.owner, + name="Image fallback collection", + ) + + lodging = Lodging.objects.create( + user=self.owner, + collection=collection, + name="Fallback lodge", + ) + transportation = Transportation.objects.create( + user=self.owner, + collection=collection, + type="car", + name="Fallback ride", + ) + + lodging_content_type = ContentType.objects.get_for_model(Lodging) + transportation_content_type = ContentType.objects.get_for_model(Transportation) + + ContentImage.objects.create( + user=self.owner, + content_type=lodging_content_type, + object_id=lodging.id, + image=self._create_test_image_file("lodging.png"), + is_primary=True, + ) + ContentImage.objects.create( + user=self.owner, + content_type=transportation_content_type, + object_id=transportation.id, + image=self._create_test_image_file("transport.png"), + is_primary=True, + ) + + return collection + + def test_list_includes_lodging_transportation_images_when_no_location_images(self): + collection = self._create_collection_with_non_location_images() + + self.client.force_authenticate(user=self.owner) + response = self.client.get("/api/collections/") + + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data.get("results", [])), 0) + + collection_payload = next( + item + for item in response.data["results"] + if item["id"] == str(collection.id) + ) + self.assertIn("location_images", collection_payload) + self.assertGreater(len(collection_payload["location_images"]), 0) + self.assertTrue( + any( + image.get("is_primary") + for image in collection_payload["location_images"] + ) + ) + + def test_shared_endpoint_includes_non_location_primary_images(self): + collection = self._create_collection_with_non_location_images() + collection.shared_with.add(self.shared_user) + + self.client.force_authenticate(user=self.shared_user) + response = self.client.get("/api/collections/shared/") + + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + + collection_payload = next( + item for item in response.data if item["id"] == str(collection.id) + ) + self.assertEqual(str(collection.id), collection_payload["id"]) + self.assertIn("location_images", collection_payload) + self.assertGreater(len(collection_payload["location_images"]), 0) + first_image = collection_payload["location_images"][0] + self.assertSetEqual( + set(first_image.keys()), + {"id", "image", "is_primary", "user", "immich_id"}, + ) + + +class ExportCollectionsBackupCommandTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="backup-user", + email="backup@example.com", + password="password123", + ) + self.collaborator = User.objects.create_user( + username="collab-user", + email="collab@example.com", + password="password123", + ) + self.collection = Collection.objects.create( + user=self.user, + name="My Trip", + description="Backup test collection", + ) + self.collection.shared_with.add(self.collaborator) + + note = Note.objects.create(user=self.user, name="Test item") + note_content_type = ContentType.objects.get_for_model(Note) + CollectionItineraryItem.objects.create( + collection=self.collection, + content_type=note_content_type, + object_id=note.id, + date=timezone.now().date(), + is_global=False, + order=1, + ) + + def test_export_collections_backup_writes_expected_payload(self): + with tempfile.TemporaryDirectory() as temp_dir: + output_file = Path(temp_dir) / "collections_snapshot.json" + + call_command("export_collections_backup", output=str(output_file)) + + self.assertTrue(output_file.exists()) + payload = json.loads(output_file.read_text(encoding="utf-8")) + + self.assertEqual(payload["backup_type"], "collections_snapshot") + self.assertIn("timestamp", payload) + self.assertEqual(payload["counts"]["collections"], 1) + self.assertEqual(payload["counts"]["collection_itinerary_items"], 1) + self.assertEqual(len(payload["collections"]), 1) + self.assertEqual(len(payload["collection_itinerary_items"]), 1) + self.assertEqual( + payload["collections"][0]["shared_with_ids"], + [self.collaborator.id], + ) + + def test_export_collections_backup_raises_for_missing_output_directory(self): + with tempfile.TemporaryDirectory() as temp_dir: + missing_directory = Path(temp_dir) / "missing" + output_file = missing_directory / "collections_snapshot.json" + + with self.assertRaises(CommandError): + call_command("export_collections_backup", output=str(output_file)) diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index 7d43236a..188e6abc 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -14,9 +14,29 @@ import os import json import zipfile import tempfile -from adventures.models import Collection, Location, Transportation, Note, Checklist, ChecklistItem, CollectionInvite, ContentImage, CollectionItineraryItem, Lodging, CollectionItineraryDay, ContentAttachment, Category +from adventures.models import ( + Collection, + Location, + Transportation, + Note, + Checklist, + ChecklistItem, + CollectionInvite, + ContentImage, + CollectionItineraryItem, + Lodging, + CollectionItineraryDay, + ContentAttachment, + Category, +) from adventures.permissions import CollectionShared -from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer, CollectionItineraryItemSerializer, CollectionItineraryDaySerializer +from adventures.serializers import ( + CollectionSerializer, + CollectionInviteSerializer, + UltraSlimCollectionSerializer, + CollectionItineraryItemSerializer, + CollectionItineraryDaySerializer, +) from users.models import CustomUser as User from adventures.utils import pagination from users.serializers import CustomUserDetailsSerializer as UserSerializer @@ -29,111 +49,132 @@ class CollectionViewSet(viewsets.ModelViewSet): def get_serializer_class(self): """Return different serializers based on the action""" - if self.action in ['list', 'all', 'archived', 'shared']: + if self.action in ["list", "all", "archived", "shared"]: return UltraSlimCollectionSerializer return CollectionSerializer def apply_sorting(self, queryset): - order_by = self.request.query_params.get('order_by', 'name') - order_direction = self.request.query_params.get('order_direction', 'asc') + order_by = self.request.query_params.get("order_by", "name") + order_direction = self.request.query_params.get("order_direction", "asc") - valid_order_by = ['name', 'updated_at', 'start_date'] + valid_order_by = ["name", "updated_at", "start_date"] if order_by not in valid_order_by: - order_by = 'updated_at' + order_by = "updated_at" - if order_direction not in ['asc', 'desc']: - order_direction = 'asc' + if order_direction not in ["asc", "desc"]: + order_direction = "asc" # Apply case-insensitive sorting for the 'name' field - if order_by == 'name': - queryset = queryset.annotate(lower_name=Lower('name')) - ordering = 'lower_name' - if order_direction == 'asc': - ordering = f'-{ordering}' - elif order_by == 'start_date': - ordering = 'start_date' - if order_direction == 'desc': - ordering = 'start_date' + if order_by == "name": + queryset = queryset.annotate(lower_name=Lower("name")) + ordering = "lower_name" + if order_direction == "asc": + ordering = f"-{ordering}" + elif order_by == "start_date": + ordering = "start_date" + if order_direction == "desc": + ordering = "start_date" else: - ordering = '-start_date' + ordering = "-start_date" else: - order_by == 'updated_at' - ordering = 'updated_at' - if order_direction == 'desc': - ordering = '-updated_at' + order_by == "updated_at" + ordering = "updated_at" + if order_direction == "desc": + ordering = "-updated_at" return queryset.order_by(ordering) - + def apply_status_filter(self, queryset): """Apply status filtering based on query parameter""" from datetime import date - status_filter = self.request.query_params.get('status', None) - + + status_filter = self.request.query_params.get("status", None) + if not status_filter: return queryset - + today = date.today() - - if status_filter == 'folder': + + if status_filter == "folder": # Collections without dates - return queryset.filter(Q(start_date__isnull=True) | Q(end_date__isnull=True)) - elif status_filter == 'upcoming': + return queryset.filter( + Q(start_date__isnull=True) | Q(end_date__isnull=True) + ) + elif status_filter == "upcoming": # Start date in the future return queryset.filter(start_date__gt=today) - elif status_filter == 'in_progress': + elif status_filter == "in_progress": # Currently ongoing return queryset.filter(start_date__lte=today, end_date__gte=today) - elif status_filter == 'completed': + elif status_filter == "completed": # End date in the past return queryset.filter(end_date__lt=today) - + return queryset - + def get_serializer_context(self): """Override to add nested and exclusion contexts based on query parameters""" context = super().get_serializer_context() - + # Handle nested parameter (only for full serializer actions) - if self.action not in ['list', 'all', 'archived', 'shared']: - is_nested = self.request.query_params.get('nested', 'false').lower() == 'true' + if self.action not in ["list", "all", "archived", "shared"]: + is_nested = ( + self.request.query_params.get("nested", "false").lower() == "true" + ) if is_nested: - context['nested'] = True - + context["nested"] = True + # Handle individual exclusion parameters (if using granular approach) exclude_params = [ - 'exclude_transportations', - 'exclude_notes', - 'exclude_checklists', - 'exclude_lodging' + "exclude_transportations", + "exclude_notes", + "exclude_checklists", + "exclude_lodging", ] - + for param in exclude_params: - if self.request.query_params.get(param, 'false').lower() == 'true': + if self.request.query_params.get(param, "false").lower() == "true": context[param] = True - + return context - def get_optimized_queryset_for_listing(self): - """Get optimized queryset for list actions with prefetching""" - return self.get_base_queryset().select_related('user', 'primary_image').prefetch_related( + def get_optimized_queryset_for_listing(self, queryset): + """Apply shared list-action optimizations on top of a filtered queryset.""" + return queryset.select_related("user", "primary_image").prefetch_related( Prefetch( - 'locations__images', - queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), - to_attr='primary_images' + "locations__images", + queryset=ContentImage.objects.filter(is_primary=True).select_related( + "user" + ), + to_attr="primary_images", ), - 'shared_with' + Prefetch( + "lodging_set__images", + queryset=ContentImage.objects.filter(is_primary=True).select_related( + "user" + ), + to_attr="primary_images", + ), + Prefetch( + "transportation_set__images", + queryset=ContentImage.objects.filter(is_primary=True).select_related( + "user" + ), + to_attr="primary_images", + ), + "shared_with", ) def get_base_queryset(self): """Base queryset logic extracted for reuse""" - if self.action == 'destroy': + if self.action == "destroy": queryset = Collection.objects.filter(user=self.request.user.id) - elif self.action in ['update', 'partial_update', 'leave']: + elif self.action in ["update", "partial_update", "leave"]: queryset = Collection.objects.filter( Q(user=self.request.user.id) | Q(shared_with=self.request.user) ).distinct() # Allow access to collections with pending invites for accept/decline actions - elif self.action in ['accept_invite', 'decline_invite']: + elif self.action in ["accept_invite", "decline_invite"]: if not self.request.user.is_authenticated: queryset = Collection.objects.none() else: @@ -142,7 +183,7 @@ class CollectionViewSet(viewsets.ModelViewSet): | Q(shared_with=self.request.user) | Q(invites__invited_user=self.request.user) ).distinct() - elif self.action == 'retrieve': + elif self.action == "retrieve": if not self.request.user.is_authenticated: queryset = Collection.objects.filter(is_public=True) else: @@ -157,75 +198,57 @@ class CollectionViewSet(viewsets.ModelViewSet): Q(user=self.request.user.id) & Q(is_archived=False) ).distinct() - return queryset.select_related('primary_image').prefetch_related('shared_with') + return queryset.select_related("primary_image").prefetch_related("shared_with") def get_queryset(self): - """Get queryset with optimizations for list actions""" - if self.action in ['list', 'all', 'archived', 'shared']: - return self.get_optimized_queryset_for_listing() + """Get base queryset for non-custom actions handled by ModelViewSet.""" return self.get_base_queryset() - + def list(self, request): # make sure the user is authenticated if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - + # List should only return collections owned by the requesting user (shared collections are available # via the `shared` action). - queryset = Collection.objects.filter( - Q(user=request.user.id) & Q(is_archived=False) - ).distinct().select_related('user', 'primary_image').prefetch_related( - Prefetch( - 'locations__images', - queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), - to_attr='primary_images' - ) + queryset = self.get_optimized_queryset_for_listing( + Collection.objects.filter( + Q(user=request.user.id) & Q(is_archived=False) + ).distinct() ) - + queryset = self.apply_status_filter(queryset) queryset = self.apply_sorting(queryset) return self.paginate_and_respond(queryset, request) - - @action(detail=False, methods=['get']) + + @action(detail=False, methods=["get"]) def all(self, request): if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - - queryset = Collection.objects.filter( - Q(user=request.user) - ).select_related('user', 'primary_image').prefetch_related( - Prefetch( - 'locations__images', - queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), - to_attr='primary_images' - ) + + queryset = self.get_optimized_queryset_for_listing( + Collection.objects.filter(Q(user=request.user)) ) - + queryset = self.apply_sorting(queryset) serializer = self.get_serializer(queryset, many=True) - + return Response(serializer.data) - - @action(detail=False, methods=['get']) + + @action(detail=False, methods=["get"]) def archived(self, request): if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - - queryset = Collection.objects.filter( - Q(user=request.user.id) & Q(is_archived=True) - ).select_related('user', 'primary_image').prefetch_related( - Prefetch( - 'locations__images', - queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), - to_attr='primary_images' - ) + + queryset = self.get_optimized_queryset_for_listing( + Collection.objects.filter(Q(user=request.user.id) & Q(is_archived=True)) ) - + queryset = self.apply_sorting(queryset) serializer = self.get_serializer(queryset, many=True) - + return Response(serializer.data) - + def retrieve(self, request, pk=None): """Retrieve a collection and include itinerary items and day metadata in the response.""" collection = self.get_object() @@ -234,39 +257,35 @@ class CollectionViewSet(viewsets.ModelViewSet): # Include itinerary items inline with collection details itinerary_items = CollectionItineraryItem.objects.filter(collection=collection) - itinerary_serializer = CollectionItineraryItemSerializer(itinerary_items, many=True) - data['itinerary'] = itinerary_serializer.data - + itinerary_serializer = CollectionItineraryItemSerializer( + itinerary_items, many=True + ) + data["itinerary"] = itinerary_serializer.data + # Include itinerary day metadata itinerary_days = CollectionItineraryDay.objects.filter(collection=collection) days_serializer = CollectionItineraryDaySerializer(itinerary_days, many=True) - data['itinerary_days'] = days_serializer.data + data["itinerary_days"] = days_serializer.data return Response(data) - + # make an action to retreive all locations that are shared with the user - @action(detail=False, methods=['get']) + @action(detail=False, methods=["get"]) def shared(self, request): if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - - queryset = Collection.objects.filter( - shared_with=request.user - ).select_related('user').prefetch_related( - Prefetch( - 'locations__images', - queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), - to_attr='primary_images' - ) + + queryset = self.get_optimized_queryset_for_listing( + Collection.objects.filter(shared_with=request.user) ) - + queryset = self.apply_sorting(queryset) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) - + # Created a custom action to share a collection with another user by their UUID # This action will create a CollectionInvite instead of directly sharing the collection - @action(detail=True, methods=['post'], url_path='share/(?P[^/.]+)') + @action(detail=True, methods=["post"], url_path="share/(?P[^/.]+)") def share(self, request, pk=None, uuid=None): collection = self.get_object() if not uuid: @@ -275,323 +294,378 @@ class CollectionViewSet(viewsets.ModelViewSet): user = User.objects.get(uuid=uuid, public_profile=True) except User.DoesNotExist: return Response({"error": "User not found"}, status=404) - + if user == request.user: return Response({"error": "Cannot share with yourself"}, status=400) - + # Check if user is already shared with the collection if collection.shared_with.filter(id=user.id).exists(): - return Response({"error": "Collection is already shared with this user"}, status=400) - + return Response( + {"error": "Collection is already shared with this user"}, status=400 + ) + # Check if there's already a pending invite for this user - if CollectionInvite.objects.filter(collection=collection, invited_user=user).exists(): + if CollectionInvite.objects.filter( + collection=collection, invited_user=user + ).exists(): return Response({"error": "Invite already sent to this user"}, status=400) - + # Create the invite instead of directly sharing invite = CollectionInvite.objects.create( - collection=collection, - invited_user=user + collection=collection, invited_user=user ) - + return Response({"success": f"Invite sent to {user.username}"}) - + # Custom action to list all invites for a user - @action(detail=False, methods=['get'], url_path='invites') + @action(detail=False, methods=["get"], url_path="invites") def invites(self, request): if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - + invites = CollectionInvite.objects.filter(invited_user=request.user) serializer = CollectionInviteSerializer(invites, many=True) - + return Response(serializer.data) - @action(detail=True, methods=['post'], url_path='revoke-invite/(?P[^/.]+)') + @action(detail=True, methods=["post"], url_path="revoke-invite/(?P[^/.]+)") def revoke_invite(self, request, pk=None, uuid=None): """Revoke a pending invite for a collection""" if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - + collection = self.get_object() - + if not uuid: return Response({"error": "User UUID is required"}, status=400) - + try: user = User.objects.get(uuid=uuid, public_profile=True) except User.DoesNotExist: return Response({"error": "User not found"}, status=404) - + # Only collection owner can revoke invites if collection.user != request.user: - return Response({"error": "Only collection owner can revoke invites"}, status=403) - + return Response( + {"error": "Only collection owner can revoke invites"}, status=403 + ) + try: - invite = CollectionInvite.objects.get(collection=collection, invited_user=user) + invite = CollectionInvite.objects.get( + collection=collection, invited_user=user + ) invite.delete() return Response({"success": f"Invite revoked for {user.username}"}) except CollectionInvite.DoesNotExist: - return Response({"error": "No pending invite found for this user"}, status=404) + return Response( + {"error": "No pending invite found for this user"}, status=404 + ) - @action(detail=True, methods=['post'], url_path='accept-invite') + @action(detail=True, methods=["post"], url_path="accept-invite") def accept_invite(self, request, pk=None): """Accept a collection invite""" if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - + collection = self.get_object() - + try: - invite = CollectionInvite.objects.get(collection=collection, invited_user=request.user) + invite = CollectionInvite.objects.get( + collection=collection, invited_user=request.user + ) except CollectionInvite.DoesNotExist: - return Response({"error": "No pending invite found for this collection"}, status=404) - + return Response( + {"error": "No pending invite found for this collection"}, status=404 + ) + # Add user to collection's shared_with collection.shared_with.add(request.user) - + # Delete the invite invite.delete() - - return Response({"success": f"Successfully joined collection: {collection.name}"}) - @action(detail=True, methods=['post'], url_path='decline-invite') + return Response( + {"success": f"Successfully joined collection: {collection.name}"} + ) + + @action(detail=True, methods=["post"], url_path="decline-invite") def decline_invite(self, request, pk=None): """Decline a collection invite""" if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - + collection = self.get_object() - + try: - invite = CollectionInvite.objects.get(collection=collection, invited_user=request.user) + invite = CollectionInvite.objects.get( + collection=collection, invited_user=request.user + ) invite.delete() - return Response({"success": f"Declined invite for collection: {collection.name}"}) + return Response( + {"success": f"Declined invite for collection: {collection.name}"} + ) except CollectionInvite.DoesNotExist: - return Response({"error": "No pending invite found for this collection"}, status=404) - + return Response( + {"error": "No pending invite found for this collection"}, status=404 + ) + # Action to list all users a collection **can** be shared with, excluding those already shared with and those with pending invites - @action(detail=True, methods=['get'], url_path='can-share') + @action(detail=True, methods=["get"], url_path="can-share") def can_share(self, request, pk=None): if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - + collection = self.get_object() - + # Get users with pending invites and users already shared with - users_with_pending_invites = set(str(uuid) for uuid in CollectionInvite.objects.filter(collection=collection).values_list('invited_user__uuid', flat=True)) - users_already_shared = set(str(uuid) for uuid in collection.shared_with.values_list('uuid', flat=True)) + users_with_pending_invites = set( + str(uuid) + for uuid in CollectionInvite.objects.filter( + collection=collection + ).values_list("invited_user__uuid", flat=True) + ) + users_already_shared = set( + str(uuid) for uuid in collection.shared_with.values_list("uuid", flat=True) + ) # Get all users with public profiles excluding only the owner all_users = User.objects.filter(public_profile=True).exclude(id=request.user.id) - + # Return fully serialized user data with status serializer = UserSerializer(all_users, many=True) result_data = [] for user_data in serializer.data: - user_data.pop('has_password', None) - user_data.pop('disable_password', None) + user_data.pop("has_password", None) + user_data.pop("disable_password", None) # Add status field - if user_data['uuid'] in users_with_pending_invites: - user_data['status'] = 'pending' - elif user_data['uuid'] in users_already_shared: - user_data['status'] = 'shared' + if user_data["uuid"] in users_with_pending_invites: + user_data["status"] = "pending" + elif user_data["uuid"] in users_already_shared: + user_data["status"] = "shared" else: - user_data['status'] = 'available' + user_data["status"] = "available" result_data.append(user_data) - + return Response(result_data) - @action(detail=True, methods=['post'], url_path='unshare/(?P[^/.]+)') + @action(detail=True, methods=["post"], url_path="unshare/(?P[^/.]+)") def unshare(self, request, pk=None, uuid=None): if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - + collection = self.get_object() - + if not uuid: return Response({"error": "User UUID is required"}, status=400) - + try: user = User.objects.get(uuid=uuid, public_profile=True) except User.DoesNotExist: return Response({"error": "User not found"}, status=404) - + if user == request.user: return Response({"error": "Cannot unshare with yourself"}, status=400) - + if not collection.shared_with.filter(id=user.id).exists(): - return Response({"error": "Collection is not shared with this user"}, status=400) - + return Response( + {"error": "Collection is not shared with this user"}, status=400 + ) + # Remove user from shared_with collection.shared_with.remove(user) - + # Handle locations owned by the unshared user that are in this collection # These locations should be removed from the collection since they lose access locations_to_remove = collection.locations.filter(user=user) removed_count = locations_to_remove.count() - + if locations_to_remove.exists(): # Remove these locations from the collection collection.locations.remove(*locations_to_remove) - + collection.save() - + success_message = f"Unshared with {user.username}" if removed_count > 0: success_message += f" and removed {removed_count} location(s) they owned from the collection" - + return Response({"success": success_message}) - + # Action for a shared user to leave a collection - @action(detail=True, methods=['post'], url_path='leave') + @action(detail=True, methods=["post"], url_path="leave") def leave(self, request, pk=None): if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - + collection = self.get_object() - + if request.user == collection.user: - return Response({"error": "Owner cannot leave their own collection"}, status=400) - + return Response( + {"error": "Owner cannot leave their own collection"}, status=400 + ) + if not collection.shared_with.filter(id=request.user.id).exists(): - return Response({"error": "You are not a member of this collection"}, status=400) - + return Response( + {"error": "You are not a member of this collection"}, status=400 + ) + # Remove the user from shared_with collection.shared_with.remove(request.user) - + # Handle locations owned by the user that are in this collection locations_to_remove = collection.locations.filter(user=request.user) removed_count = locations_to_remove.count() - + if locations_to_remove.exists(): # Remove these locations from the collection collection.locations.remove(*locations_to_remove) - + collection.save() - + success_message = f"You have left the collection: {collection.name}" if removed_count > 0: success_message += f" and removed {removed_count} location(s) you owned from the collection" - + return Response({"success": success_message}) - @action(detail=True, methods=['get'], url_path='export') + @action(detail=True, methods=["get"], url_path="export") def export_collection(self, request, pk=None): """Export a single collection and its related content as a ZIP file.""" collection = self.get_object() export_data = { - 'version': getattr(settings, 'VOYAGE_RELEASE_VERSION', 'unknown'), + "version": getattr(settings, "VOYAGE_RELEASE_VERSION", "unknown"), # Omit export_date to keep template-friendly exports (no dates) - 'collection': { - 'id': str(collection.id), - 'name': collection.name, - 'description': collection.description, - 'is_public': collection.is_public, + "collection": { + "id": str(collection.id), + "name": collection.name, + "description": collection.description, + "is_public": collection.is_public, # Omit start/end dates - 'link': collection.link, + "link": collection.link, }, - 'locations': [], - 'transportation': [], - 'notes': [], - 'checklists': [], - 'lodging': [], + "locations": [], + "transportation": [], + "notes": [], + "checklists": [], + "lodging": [], # Omit itinerary_items entirely - 'images': [], - 'attachments': [], - 'primary_image_ref': None, + "images": [], + "attachments": [], + "primary_image_ref": None, } image_export_map = {} - for loc in collection.locations.all().select_related('city', 'region', 'country'): + for loc in collection.locations.all().select_related( + "city", "region", "country" + ): loc_entry = { - 'id': str(loc.id), - 'name': loc.name, - 'description': loc.description, - 'location': loc.location, - 'tags': loc.tags or [], - 'rating': loc.rating, - 'link': loc.link, - 'is_public': loc.is_public, - 'longitude': float(loc.longitude) if loc.longitude is not None else None, - 'latitude': float(loc.latitude) if loc.latitude is not None else None, - 'city': loc.city.name if loc.city else None, - 'region': loc.region.name if loc.region else None, - 'country': loc.country.name if loc.country else None, - 'images': [], - 'attachments': [], + "id": str(loc.id), + "name": loc.name, + "description": loc.description, + "location": loc.location, + "tags": loc.tags or [], + "rating": loc.rating, + "link": loc.link, + "is_public": loc.is_public, + "longitude": float(loc.longitude) + if loc.longitude is not None + else None, + "latitude": float(loc.latitude) if loc.latitude is not None else None, + "city": loc.city.name if loc.city else None, + "region": loc.region.name if loc.region else None, + "country": loc.country.name if loc.country else None, + "images": [], + "attachments": [], } for img in loc.images.all(): img_export_id = f"img_{len(export_data['images'])}" image_export_map[str(img.id)] = img_export_id - export_data['images'].append({ - 'export_id': img_export_id, - 'id': str(img.id), - 'name': os.path.basename(getattr(img.image, 'name', 'image')), - 'is_primary': getattr(img, 'is_primary', False), - }) - loc_entry['images'].append(img_export_id) + export_data["images"].append( + { + "export_id": img_export_id, + "id": str(img.id), + "name": os.path.basename(getattr(img.image, "name", "image")), + "is_primary": getattr(img, "is_primary", False), + } + ) + loc_entry["images"].append(img_export_id) for att in loc.attachments.all(): att_export_id = f"att_{len(export_data['attachments'])}" - export_data['attachments'].append({ - 'export_id': att_export_id, - 'id': str(att.id), - 'name': os.path.basename(getattr(att.file, 'name', 'attachment')), - }) - loc_entry['attachments'].append(att_export_id) + export_data["attachments"].append( + { + "export_id": att_export_id, + "id": str(att.id), + "name": os.path.basename( + getattr(att.file, "name", "attachment") + ), + } + ) + loc_entry["attachments"].append(att_export_id) - export_data['locations'].append(loc_entry) + export_data["locations"].append(loc_entry) if collection.primary_image: - export_data['primary_image_ref'] = image_export_map.get(str(collection.primary_image.id)) + export_data["primary_image_ref"] = image_export_map.get( + str(collection.primary_image.id) + ) # Related content (if models have FK to collection) for t in Transportation.objects.filter(collection=collection): - export_data['transportation'].append({ - 'id': str(t.id), - 'type': getattr(t, 'transportation_type', None), - 'name': getattr(t, 'name', None), - # Omit date - 'notes': getattr(t, 'notes', None), - }) + export_data["transportation"].append( + { + "id": str(t.id), + "type": getattr(t, "transportation_type", None), + "name": getattr(t, "name", None), + # Omit date + "notes": getattr(t, "notes", None), + } + ) for n in Note.objects.filter(collection=collection): - export_data['notes'].append({ - 'id': str(n.id), - 'title': getattr(n, 'title', None), - 'content': getattr(n, 'content', ''), - # Omit created_at - }) + export_data["notes"].append( + { + "id": str(n.id), + "title": getattr(n, "title", None), + "content": getattr(n, "content", ""), + # Omit created_at + } + ) for c in Checklist.objects.filter(collection=collection): items = [] - if hasattr(c, 'items'): + if hasattr(c, "items"): items = [ { - 'name': getattr(item, 'name', None), - 'completed': getattr(item, 'completed', False), - } for item in c.items.all() + "name": getattr(item, "name", None), + "completed": getattr(item, "completed", False), + } + for item in c.items.all() ] - export_data['checklists'].append({ - 'id': str(c.id), - 'name': getattr(c, 'name', None), - 'items': items, - }) + export_data["checklists"].append( + { + "id": str(c.id), + "name": getattr(c, "name", None), + "items": items, + } + ) for l in Lodging.objects.filter(collection=collection): - export_data['lodging'].append({ - 'id': str(l.id), - 'type': getattr(l, 'lodging_type', None), - 'name': getattr(l, 'name', None), - # Omit start_date/end_date - 'notes': getattr(l, 'notes', None), - }) + export_data["lodging"].append( + { + "id": str(l.id), + "type": getattr(l, "lodging_type", None), + "name": getattr(l, "name", None), + # Omit start_date/end_date + "notes": getattr(l, "notes", None), + } + ) # Intentionally omit itinerary_items from export # Create ZIP in temp file - with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp_file: - with zipfile.ZipFile(tmp_file, 'w', zipfile.ZIP_DEFLATED) as zipf: - zipf.writestr('metadata.json', json.dumps(export_data, indent=2)) + with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_file: + with zipfile.ZipFile(tmp_file, "w", zipfile.ZIP_DEFLATED) as zipf: + zipf.writestr("metadata.json", json.dumps(export_data, indent=2)) # Write image files for loc in collection.locations.all(): @@ -600,14 +674,20 @@ class CollectionViewSet(viewsets.ModelViewSet): if not export_id: continue try: - file_name = os.path.basename(getattr(img.image, 'name', 'image')) - storage = getattr(img.image, 'storage', None) + file_name = os.path.basename( + getattr(img.image, "name", "image") + ) + storage = getattr(img.image, "storage", None) if storage: - with storage.open(img.image.name, 'rb') as f: - zipf.writestr(f'images/{export_id}-{file_name}', f.read()) - elif hasattr(img.image, 'path'): - with open(img.image.path, 'rb') as f: - zipf.writestr(f'images/{export_id}-{file_name}', f.read()) + with storage.open(img.image.name, "rb") as f: + zipf.writestr( + f"images/{export_id}-{file_name}", f.read() + ) + elif hasattr(img.image, "path"): + with open(img.image.path, "rb") as f: + zipf.writestr( + f"images/{export_id}-{file_name}", f.read() + ) except Exception: continue @@ -615,45 +695,61 @@ class CollectionViewSet(viewsets.ModelViewSet): for loc in collection.locations.all(): for att in loc.attachments.all(): try: - file_name = os.path.basename(getattr(att.file, 'name', 'attachment')) - storage = getattr(att.file, 'storage', None) + file_name = os.path.basename( + getattr(att.file, "name", "attachment") + ) + storage = getattr(att.file, "storage", None) if storage: - with storage.open(att.file.name, 'rb') as f: - zipf.writestr(f'attachments/{file_name}', f.read()) - elif hasattr(att.file, 'path'): - with open(att.file.path, 'rb') as f: - zipf.writestr(f'attachments/{file_name}', f.read()) + with storage.open(att.file.name, "rb") as f: + zipf.writestr(f"attachments/{file_name}", f.read()) + elif hasattr(att.file, "path"): + with open(att.file.path, "rb") as f: + zipf.writestr(f"attachments/{file_name}", f.read()) except Exception: continue - with open(tmp_file.name, 'rb') as fh: + with open(tmp_file.name, "rb") as fh: data = fh.read() os.unlink(tmp_file.name) filename = f"collection-{collection.name.replace(' ', '_')}.zip" - response = HttpResponse(data, content_type='application/zip') - response['Content-Disposition'] = f'attachment; filename="{filename}"' + response = HttpResponse(data, content_type="application/zip") + response["Content-Disposition"] = f'attachment; filename="{filename}"' return response - @action(detail=False, methods=['post'], url_path='import', parser_classes=[MultiPartParser]) + @action( + detail=False, + methods=["post"], + url_path="import", + parser_classes=[MultiPartParser], + ) def import_collection(self, request): """Import a single collection from a ZIP file. Handles name conflicts by appending (n).""" - upload = request.FILES.get('file') + upload = request.FILES.get("file") if not upload: - return Response({'detail': 'No file provided'}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"detail": "No file provided"}, status=status.HTTP_400_BAD_REQUEST + ) # Read zip file_bytes = upload.read() - with zipfile.ZipFile(io.BytesIO(file_bytes), 'r') as zipf: + with zipfile.ZipFile(io.BytesIO(file_bytes), "r") as zipf: try: - metadata = json.loads(zipf.read('metadata.json').decode('utf-8')) + metadata = json.loads(zipf.read("metadata.json").decode("utf-8")) except KeyError: - return Response({'detail': 'metadata.json missing'}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"detail": "metadata.json missing"}, + status=status.HTTP_400_BAD_REQUEST, + ) - base_name = (metadata.get('collection') or {}).get('name') or 'Imported Collection' + base_name = (metadata.get("collection") or {}).get( + "name" + ) or "Imported Collection" # Ensure unique name per user - existing_names = set(request.user.collection_set.values_list('name', flat=True)) + existing_names = set( + request.user.collection_set.values_list("name", flat=True) + ) unique_name = base_name if unique_name in existing_names: i = 1 @@ -667,27 +763,41 @@ class CollectionViewSet(viewsets.ModelViewSet): new_collection = Collection.objects.create( user=request.user, name=unique_name, - description=(metadata.get('collection') or {}).get('description'), - is_public=(metadata.get('collection') or {}).get('is_public', False), - start_date=__import__('datetime').date.fromisoformat((metadata.get('collection') or {}).get('start_date')) if (metadata.get('collection') or {}).get('start_date') else None, - end_date=__import__('datetime').date.fromisoformat((metadata.get('collection') or {}).get('end_date')) if (metadata.get('collection') or {}).get('end_date') else None, - link=(metadata.get('collection') or {}).get('link'), + description=(metadata.get("collection") or {}).get("description"), + is_public=(metadata.get("collection") or {}).get("is_public", False), + start_date=__import__("datetime").date.fromisoformat( + (metadata.get("collection") or {}).get("start_date") + ) + if (metadata.get("collection") or {}).get("start_date") + else None, + end_date=__import__("datetime").date.fromisoformat( + (metadata.get("collection") or {}).get("end_date") + ) + if (metadata.get("collection") or {}).get("end_date") + else None, + link=(metadata.get("collection") or {}).get("link"), ) - image_export_map = {img['export_id']: img for img in metadata.get('images', [])} - attachment_export_map = {att['export_id']: att for att in metadata.get('attachments', [])} + image_export_map = { + img["export_id"]: img for img in metadata.get("images", []) + } + attachment_export_map = { + att["export_id"]: att for att in metadata.get("attachments", []) + } # Import locations - for loc_data in metadata.get('locations', []): + for loc_data in metadata.get("locations", []): cat_obj = None - if loc_data.get('category'): - cat_obj, _ = Category.objects.get_or_create(user=request.user, name=loc_data['category']) + if loc_data.get("category"): + cat_obj, _ = Category.objects.get_or_create( + user=request.user, name=loc_data["category"] + ) # Attempt to find a very similar existing location for this user from difflib import SequenceMatcher def _ratio(a, b): - a = (a or '').strip().lower() - b = (b or '').strip().lower() + a = (a or "").strip().lower() + b = (b or "").strip().lower() if not a and not b: return 1.0 return SequenceMatcher(None, a, b).ratio() @@ -696,27 +806,42 @@ class CollectionViewSet(viewsets.ModelViewSet): try: if lat1 is None or lon1 is None or lat2 is None or lon2 is None: return False - return abs(float(lat1) - float(lat2)) <= threshold and abs(float(lon1) - float(lon2)) <= threshold + return ( + abs(float(lat1) - float(lat2)) <= threshold + and abs(float(lon1) - float(lon2)) <= threshold + ) except Exception: return False - incoming_name = loc_data.get('name') or 'Untitled' - incoming_location_text = loc_data.get('location') - incoming_lat = loc_data.get('latitude') - incoming_lon = loc_data.get('longitude') + incoming_name = loc_data.get("name") or "Untitled" + incoming_location_text = loc_data.get("location") + incoming_lat = loc_data.get("latitude") + incoming_lon = loc_data.get("longitude") existing_loc = None best_score = 0.0 for cand in Location.objects.filter(user=request.user): name_score = _ratio(incoming_name, cand.name) - loc_text_score = _ratio(incoming_location_text, getattr(cand, 'location', None)) - close_coords = _coords_close(incoming_lat, incoming_lon, cand.latitude, cand.longitude) + loc_text_score = _ratio( + incoming_location_text, getattr(cand, "location", None) + ) + close_coords = _coords_close( + incoming_lat, incoming_lon, cand.latitude, cand.longitude + ) # Define "very similar": strong name match OR decent name with location/coords match - combined_score = max(name_score, (name_score + loc_text_score) / 2.0) + combined_score = max( + name_score, (name_score + loc_text_score) / 2.0 + ) if close_coords: - combined_score = max(combined_score, name_score + 0.1) # small boost for coord proximity + combined_score = max( + combined_score, name_score + 0.1 + ) # small boost for coord proximity if combined_score > best_score and ( - name_score >= 0.92 or (name_score >= 0.85 and (loc_text_score >= 0.85 or close_coords)) + name_score >= 0.92 + or ( + name_score >= 0.85 + and (loc_text_score >= 0.85 or close_coords) + ) ): best_score = combined_score existing_loc = cand @@ -731,12 +856,12 @@ class CollectionViewSet(viewsets.ModelViewSet): loc = Location.objects.create( user=request.user, name=incoming_name, - description=loc_data.get('description'), + description=loc_data.get("description"), location=incoming_location_text, - tags=loc_data.get('tags') or [], - rating=loc_data.get('rating'), - link=loc_data.get('link'), - is_public=bool(loc_data.get('is_public', False)), + tags=loc_data.get("tags") or [], + rating=loc_data.get("rating"), + link=loc_data.get("link"), + is_public=bool(loc_data.get("is_public", False)), longitude=incoming_lon, latitude=incoming_lat, category=cat_obj, @@ -747,17 +872,20 @@ class CollectionViewSet(viewsets.ModelViewSet): # Images # Only import images for newly created locations to avoid duplicating user content if created_new_loc: - for export_id in loc_data.get('images', []): + for export_id in loc_data.get("images", []): img_meta = image_export_map.get(export_id) if not img_meta: continue prefix = f"images/{export_id}-" - member = next((m for m in zipf.namelist() if m.startswith(prefix)), None) + member = next( + (m for m in zipf.namelist() if m.startswith(prefix)), None + ) if not member: continue file_bytes_img = zipf.read(member) file_name_img = os.path.basename(member) from django.core.files.base import ContentFile + image_obj = ContentImage( user=request.user, image=ContentFile(file_bytes_img, name=file_name_img), @@ -765,22 +893,30 @@ class CollectionViewSet(viewsets.ModelViewSet): # Assign to the generic relation for Location image_obj.content_object = loc image_obj.save() - if img_meta.get('is_primary'): + if img_meta.get("is_primary"): new_collection.primary_image = image_obj - new_collection.save(update_fields=['primary_image']) + new_collection.save(update_fields=["primary_image"]) # Attachments if created_new_loc: - for export_id in loc_data.get('attachments', []): + for export_id in loc_data.get("attachments", []): att_meta = attachment_export_map.get(export_id) if not att_meta: continue - file_name_att = att_meta.get('name', '') - member = next((m for m in zipf.namelist() if m == f"attachments/{file_name_att}"), None) + file_name_att = att_meta.get("name", "") + member = next( + ( + m + for m in zipf.namelist() + if m == f"attachments/{file_name_att}" + ), + None, + ) if not member: continue file_bytes_att = zipf.read(member) from django.core.files.base import ContentFile + attachment_obj = ContentAttachment( user=request.user, file=ContentFile(file_bytes_att, name=file_name_att), @@ -792,8 +928,7 @@ class CollectionViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(new_collection) return Response(serializer.data, status=status.HTTP_201_CREATED) - - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def duplicate(self, request, pk=None): """Create a duplicate of an existing collection. @@ -835,7 +970,7 @@ class CollectionViewSet(viewsets.ModelViewSet): original_primary = original.primary_image if original_primary.image: try: - original_primary.image.open('rb') + original_primary.image.open("rb") image_bytes = original_primary.image.read() finally: try: @@ -843,7 +978,9 @@ class CollectionViewSet(viewsets.ModelViewSet): except Exception: pass - file_name = (original_primary.image.name or '').split('/')[-1] or 'image.webp' + file_name = (original_primary.image.name or "").split("/")[ + -1 + ] or "image.webp" new_primary = ContentImage( user=request.user, image=ContentFile(image_bytes, name=file_name), @@ -860,14 +997,14 @@ class CollectionViewSet(viewsets.ModelViewSet): new_primary.content_object = new_collection new_primary.save() new_collection.primary_image = new_primary - new_collection.save(update_fields=['primary_image']) + new_collection.save(update_fields=["primary_image"]) def _copy_generic_media(source_obj, target_obj): # Images for img in source_obj.images.all(): if img.image: try: - img.image.open('rb') + img.image.open("rb") image_bytes = img.image.read() finally: try: @@ -875,7 +1012,9 @@ class CollectionViewSet(viewsets.ModelViewSet): except Exception: pass - file_name = (img.image.name or '').split('/')[-1] or 'image.webp' + file_name = (img.image.name or "").split("/")[ + -1 + ] or "image.webp" media = ContentImage( user=request.user, image=ContentFile(image_bytes, name=file_name), @@ -895,7 +1034,7 @@ class CollectionViewSet(viewsets.ModelViewSet): # Attachments for attachment in source_obj.attachments.all(): try: - attachment.file.open('rb') + attachment.file.open("rb") file_bytes = attachment.file.read() finally: try: @@ -903,7 +1042,9 @@ class CollectionViewSet(viewsets.ModelViewSet): except Exception: pass - file_name = (attachment.file.name or '').split('/')[-1] or 'attachment' + file_name = (attachment.file.name or "").split("/")[ + -1 + ] or "attachment" new_attachment = ContentAttachment( user=request.user, file=ContentFile(file_bytes, name=file_name), @@ -1021,7 +1162,10 @@ class CollectionViewSet(viewsets.ModelViewSet): except Exception: import logging - logging.getLogger(__name__).exception("Failed to duplicate collection %s", pk) + + logging.getLogger(__name__).exception( + "Failed to duplicate collection %s", pk + ) return Response( {"error": "An error occurred while duplicating the collection."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -1030,29 +1174,29 @@ class CollectionViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): # This is ok because you cannot share a collection when creating it serializer.save(user=self.request.user) - + def _cleanup_out_of_range_itinerary_items(self, collection): """Delete itinerary items and day metadata outside the collection's date range.""" if not collection.start_date or not collection.end_date: # If no date range is set, don't delete anything return - + # Delete itinerary items outside the date range - deleted_items = CollectionItineraryItem.objects.filter( - collection=collection - ).exclude( - date__range=[collection.start_date, collection.end_date] - ).delete() - + deleted_items = ( + CollectionItineraryItem.objects.filter(collection=collection) + .exclude(date__range=[collection.start_date, collection.end_date]) + .delete() + ) + # Delete day metadata outside the date range - deleted_days = CollectionItineraryDay.objects.filter( - collection=collection - ).exclude( - date__range=[collection.start_date, collection.end_date] - ).delete() - + deleted_days = ( + CollectionItineraryDay.objects.filter(collection=collection) + .exclude(date__range=[collection.start_date, collection.end_date]) + .delete() + ) + return deleted_items, deleted_days - + @transaction.atomic def update(self, request, *args, **kwargs): """Override update to handle is_public cascading and clean up out-of-range itinerary items when dates change.""" @@ -1060,83 +1204,103 @@ class CollectionViewSet(viewsets.ModelViewSet): old_is_public = instance.is_public old_start_date = instance.start_date old_end_date = instance.end_date - + # Perform the standard update - partial = kwargs.pop('partial', False) + partial = kwargs.pop("partial", False) serializer = self.get_serializer(instance, data=request.data, partial=partial) serializer.is_valid(raise_exception=True) self.perform_update(serializer) - + # Check if is_public changed new_is_public = serializer.instance.is_public is_public_changed = old_is_public != new_is_public - + # Handle is_public cascading if is_public_changed: if new_is_public: # Collection is being made public, update all linked items to public - serializer.instance.locations.filter(is_public=False).update(is_public=True) - serializer.instance.transportation_set.filter(is_public=False).update(is_public=True) - serializer.instance.note_set.filter(is_public=False).update(is_public=True) - serializer.instance.checklist_set.filter(is_public=False).update(is_public=True) - serializer.instance.lodging_set.filter(is_public=False).update(is_public=True) + serializer.instance.locations.filter(is_public=False).update( + is_public=True + ) + serializer.instance.transportation_set.filter(is_public=False).update( + is_public=True + ) + serializer.instance.note_set.filter(is_public=False).update( + is_public=True + ) + serializer.instance.checklist_set.filter(is_public=False).update( + is_public=True + ) + serializer.instance.lodging_set.filter(is_public=False).update( + is_public=True + ) else: # Collection is being made private, check each linked item # Only set an item to private if it doesn't belong to any other public collection - + # Handle locations (many-to-many relationship) - locations_in_collection = serializer.instance.locations.filter(is_public=True) + locations_in_collection = serializer.instance.locations.filter( + is_public=True + ) for location in locations_in_collection: # Check if this location belongs to any other public collection - has_other_public_collection = location.collections.filter( - is_public=True - ).exclude(id=serializer.instance.id).exists() + has_other_public_collection = ( + location.collections.filter(is_public=True) + .exclude(id=serializer.instance.id) + .exists() + ) if not has_other_public_collection: location.is_public = False - location.save(update_fields=['is_public']) - + location.save(update_fields=["is_public"]) + # Handle transportations, notes, checklists, lodging (foreign key relationships) # Transportation - transportations_to_check = serializer.instance.transportation_set.filter(is_public=True) + transportations_to_check = ( + serializer.instance.transportation_set.filter(is_public=True) + ) for transportation in transportations_to_check: transportation.is_public = False - transportation.save(update_fields=['is_public']) - + transportation.save(update_fields=["is_public"]) + # Notes notes_to_check = serializer.instance.note_set.filter(is_public=True) for note in notes_to_check: note.is_public = False - note.save(update_fields=['is_public']) - + note.save(update_fields=["is_public"]) + # Checklists - checklists_to_check = serializer.instance.checklist_set.filter(is_public=True) + checklists_to_check = serializer.instance.checklist_set.filter( + is_public=True + ) for checklist in checklists_to_check: checklist.is_public = False - checklist.save(update_fields=['is_public']) - + checklist.save(update_fields=["is_public"]) + # Lodging - lodging_to_check = serializer.instance.lodging_set.filter(is_public=True) + lodging_to_check = serializer.instance.lodging_set.filter( + is_public=True + ) for lodging in lodging_to_check: lodging.is_public = False - lodging.save(update_fields=['is_public']) - + lodging.save(update_fields=["is_public"]) + # Check if dates changed new_start_date = serializer.instance.start_date new_end_date = serializer.instance.end_date - - dates_changed = (old_start_date != new_start_date or old_end_date != new_end_date) - + + dates_changed = old_start_date != new_start_date or old_end_date != new_end_date + # Clean up out-of-range items if dates changed if dates_changed: self._cleanup_out_of_range_itinerary_items(serializer.instance) - - if getattr(instance, '_prefetched_objects_cache', None): + + if getattr(instance, "_prefetched_objects_cache", None): # If 'prefetch_related' has been applied to a queryset, we need to # forcibly invalidate the prefetch cache on the instance. instance._prefetched_objects_cache = {} - + return Response(serializer.data) - + def paginate_and_respond(self, queryset, request): paginator = self.pagination_class() page = paginator.paginate_queryset(queryset, request) @@ -1144,4 +1308,4 @@ class CollectionViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(page, many=True) return paginator.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) \ No newline at end of file + return Response(serializer.data) diff --git a/backend/server/adventures/views/weather_view.py b/backend/server/adventures/views/weather_view.py index e435eb95..0452412e 100644 --- a/backend/server/adventures/views/weather_view.py +++ b/backend/server/adventures/views/weather_view.py @@ -60,12 +60,6 @@ class WeatherViewSet(viewsets.ViewSet): ) continue - if parsed_date > date_cls.today(): - results.append( - {"date": date, "available": False, "temperature_c": None} - ) - continue - try: lat = float(latitude) lon = float(longitude) diff --git a/backend/server/integrations/serializers.py b/backend/server/integrations/serializers.py index 5ae98598..7b893ad2 100644 --- a/backend/server/integrations/serializers.py +++ b/backend/server/integrations/serializers.py @@ -1,3 +1,5 @@ +from django.db import IntegrityError + from .models import ( EncryptionConfigurationError, ImmichIntegration, @@ -41,12 +43,28 @@ class UserAPIKeySerializer(serializers.ModelSerializer): def create(self, validated_data): api_key = validated_data.pop("api_key") user = self.context["request"].user - instance = UserAPIKey(user=user, **validated_data) + + provider = validated_data.get("provider") + try: + instance, _ = UserAPIKey.objects.get_or_create( + user=user, + provider=provider, + defaults={"encrypted_api_key": ""}, + ) instance.set_api_key(api_key) except EncryptionConfigurationError as exc: raise serializers.ValidationError({"api_key": str(exc)}) from exc - instance.save() + except IntegrityError: + # Defensive retry: in highly concurrent requests a competing create can + # still race. Fall back to updating the existing row instead of 500. + instance = UserAPIKey.objects.get(user=user, provider=provider) + try: + instance.set_api_key(api_key) + except EncryptionConfigurationError as exc: + raise serializers.ValidationError({"api_key": str(exc)}) from exc + + instance.save(update_fields=["encrypted_api_key", "updated_at"]) return instance def update(self, instance, validated_data): diff --git a/backend/server/integrations/tests.py b/backend/server/integrations/tests.py index 00b1fd63..e4cbdda9 100644 --- a/backend/server/integrations/tests.py +++ b/backend/server/integrations/tests.py @@ -51,3 +51,65 @@ class UserAPIKeyConfigurationTests(APITestCase): self.assertEqual(response.status_code, 400) self.assertIn("not configured", response.json().get("error", "").lower()) mock_requests_get.assert_not_called() + + +class UserAPIKeyCreateBehaviorTests(APITestCase): + @override_settings( + FIELD_ENCRYPTION_KEY="YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=" + ) + def setUp(self): + self.user = User.objects.create_user( + username="api-key-create-user", + email="apikey-create@example.com", + password="password123", + ) + self.client.force_authenticate(user=self.user) + + @override_settings( + FIELD_ENCRYPTION_KEY="YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=" + ) + def test_duplicate_provider_post_updates_existing_key(self): + first_response = self.client.post( + "/api/integrations/api-keys/", + {"provider": "google_maps", "api_key": "first-secret"}, + format="json", + ) + self.assertEqual(first_response.status_code, 201) + + second_response = self.client.post( + "/api/integrations/api-keys/", + {"provider": "google_maps", "api_key": "second-secret"}, + format="json", + ) + + self.assertEqual(second_response.status_code, 201) + + from integrations.models import UserAPIKey + + records = UserAPIKey.objects.filter(user=self.user, provider="google_maps") + self.assertEqual(records.count(), 1) + self.assertEqual(records.first().get_api_key(), "second-secret") + + @override_settings( + FIELD_ENCRYPTION_KEY="YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=" + ) + def test_provider_is_normalized_and_still_upserts(self): + self.client.post( + "/api/integrations/api-keys/", + {"provider": "Google_Maps", "api_key": "first-secret"}, + format="json", + ) + + response = self.client.post( + "/api/integrations/api-keys/", + {"provider": " google_maps ", "api_key": "rotated-secret"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + + from integrations.models import UserAPIKey + + records = UserAPIKey.objects.filter(user=self.user, provider="google_maps") + self.assertEqual(records.count(), 1) + self.assertEqual(records.first().get_api_key(), "rotated-secret") diff --git a/backend/server/main/tests.py b/backend/server/main/tests.py new file mode 100644 index 00000000..cc7d3cb6 --- /dev/null +++ b/backend/server/main/tests.py @@ -0,0 +1,38 @@ +from django.contrib.auth import get_user_model +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient, APITestCase + + +User = get_user_model() + + +class MCPTokenEndpointTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + username="mcp-token-user", + email="mcp-token@example.com", + password="password123", + ) + + def test_requires_authentication(self): + unauthenticated_client = APIClient() + response = unauthenticated_client.get("/auth/mcp-token/") + self.assertIn(response.status_code, [401, 403]) + + def test_returns_token_for_authenticated_user(self): + self.client.force_authenticate(user=self.user) + response = self.client.get("/auth/mcp-token/") + + self.assertEqual(response.status_code, 200) + self.assertIn("token", response.json()) + self.assertTrue(Token.objects.filter(user=self.user).exists()) + + def test_reuses_existing_token(self): + existing_token = Token.objects.create(user=self.user) + + self.client.force_authenticate(user=self.user) + response = self.client.get("/auth/mcp-token/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("token"), existing_token.key) + self.assertEqual(Token.objects.filter(user=self.user).count(), 1) diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index 2a45622a..df212dcb 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -10,7 +10,12 @@ from users.views import ( EnabledSocialProvidersView, DisablePasswordAuthenticationView, ) -from .views import get_csrf_token, get_public_url, serve_protected_media +from .views import ( + get_csrf_token, + get_mcp_api_token, + get_public_url, + serve_protected_media, +) from drf_yasg.views import get_schema_view from drf_yasg import openapi from mcp_server.views import MCPServerStreamableHttpView @@ -48,6 +53,7 @@ urlpatterns = [ ), name="mcp_server_streamable_http_endpoint", ), + path("auth/mcp-token/", get_mcp_api_token, name="get_mcp_api_token"), path("auth/", include("allauth.headless.urls")), # Serve protected media files re_path( diff --git a/backend/server/main/views.py b/backend/server/main/views.py index 3393e137..aab37fb7 100644 --- a/backend/server/main/views.py +++ b/backend/server/main/views.py @@ -5,21 +5,42 @@ from django.conf import settings from django.http import HttpResponse, HttpResponseForbidden from django.views.static import serve from adventures.utils.file_permissions import checkFilePermission +from rest_framework.authentication import SessionAuthentication, TokenAuthentication +from rest_framework.authtoken.models import Token +from rest_framework.decorators import ( + api_view, + authentication_classes, + permission_classes, +) +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + def get_csrf_token(request): csrf_token = get_token(request) - return JsonResponse({'csrfToken': csrf_token}) + return JsonResponse({"csrfToken": csrf_token}) + def get_public_url(request): - return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')}) + return JsonResponse({"PUBLIC_URL": getenv("PUBLIC_URL")}) + + +@api_view(["GET"]) +@authentication_classes([SessionAuthentication, TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def get_mcp_api_token(request): + token, _ = Token.objects.get_or_create(user=request.user) + return Response({"token": token.key}) + + +protected_paths = ["images/", "attachments/"] -protected_paths = ['images/', 'attachments/'] def serve_protected_media(request, path): if any([path.startswith(protected_path) for protected_path in protected_paths]): - image_id = path.split('/')[1] + image_id = path.split("/")[1] user = request.user - media_type = path.split('/')[0] + '/' + media_type = path.split("/")[0] + "/" if checkFilePermission(image_id, user, media_type): if settings.DEBUG: # In debug mode, serve the file directly @@ -27,8 +48,8 @@ def serve_protected_media(request, path): else: # In production, use X-Accel-Redirect to serve the file using Nginx response = HttpResponse() - response['Content-Type'] = '' - response['X-Accel-Redirect'] = '/protectedMedia/' + path + response["Content-Type"] = "" + response["X-Accel-Redirect"] = "/protectedMedia/" + path return response else: return HttpResponseForbidden() @@ -37,6 +58,6 @@ def serve_protected_media(request, path): return serve(request, path, document_root=settings.MEDIA_ROOT) else: response = HttpResponse() - response['Content-Type'] = '' - response['X-Accel-Redirect'] = '/protectedMedia/' + path - return response \ No newline at end of file + response["Content-Type"] = "" + response["X-Accel-Redirect"] = "/protectedMedia/" + path + return response diff --git a/documentation/.vitepress/config.mts b/documentation/.vitepress/config.mts index 091dd52d..7b50334c 100644 --- a/documentation/.vitepress/config.mts +++ b/documentation/.vitepress/config.mts @@ -255,6 +255,10 @@ export default defineConfig({ text: "Guides", collapsed: true, items: [ + { + text: "Travel Agent (MCP)", + link: "/docs/guides/travel_agent", + }, { text: "Admin Panel", link: "/docs/guides/admin_panel", diff --git a/documentation/docs/configuration/advanced_configuration.md b/documentation/docs/configuration/advanced_configuration.md index 534a8bd9..720ae62f 100644 --- a/documentation/docs/configuration/advanced_configuration.md +++ b/documentation/docs/configuration/advanced_configuration.md @@ -9,4 +9,15 @@ In addition to the primary configuration variables listed above, there are sever | `SOCIALACCOUNT_ALLOW_SIGNUP` | No | When set to `True`, signup will be allowed via social providers even if registration is disabled. | `False` | Backend | | `OSRM_BASE_URL` | No | Base URL of the OSRM routing server used for itinerary connector distance/travel-time metrics. The public OSRM demo server is used by default. Set this to point at your own OSRM instance (e.g. `http://osrm:5000`) for higher rate limits or offline use. When the OSRM server is unreachable, the backend automatically falls back to haversine-based approximations so the itinerary UI always shows metrics. | `https://router.project-osrm.org` | Backend | | `FIELD_ENCRYPTION_KEY` | No* | Fernet key used to encrypt user API keys at rest (integrations API key storage). Generate a 32-byte urlsafe base64 key (e.g. `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`). If missing/invalid, only API-key storage endpoints fail gracefully and the rest of the app remains available. | _(none)_ | Backend | -| `DJANGO_MCP_ENDPOINT` | No | HTTP path used for Django MCP server streamable endpoint. | `api/mcp` | Backend | +| `DJANGO_MCP_ENDPOINT` | No | HTTP path used for the Voyage Travel Agent MCP endpoint. Clients call this endpoint with `Authorization: Token ` using a DRF auth token for the target user account. | `api/mcp` | Backend | + +## MCP endpoint authentication details + +Voyage's MCP endpoint requires token authentication. + +- Header format: `Authorization: Token ` +- Default endpoint path: `api/mcp` +- Override path with: `DJANGO_MCP_ENDPOINT` +- Token bootstrap endpoint for authenticated sessions: `GET /auth/mcp-token/` + +For MCP usage patterns and tool-level examples, see the [Travel Agent (MCP) guide](../guides/travel_agent.md). diff --git a/documentation/docs/configuration/updating.md b/documentation/docs/configuration/updating.md index a92686b0..cfe9c804 100644 --- a/documentation/docs/configuration/updating.md +++ b/documentation/docs/configuration/updating.md @@ -1,6 +1,25 @@ # Updating -Updating Voyage when using docker can be quite easy. Run the following commands to pull the latest version and restart the containers. Make sure you backup your instance before updating just in case! +Updating Voyage when using docker can be quite easy. Run a collections backup before upgrading, then pull the latest version and restart the containers. + +## Pre-upgrade backup (recommended) + +Before running migrations or updating containers, export a collections snapshot: + +```bash +docker compose exec server python manage.py export_collections_backup +``` + +You can also provide a custom output path: + +```bash +docker compose exec server python manage.py export_collections_backup --output /code/backups/collections_backup_pre_upgrade.json +``` + +The backup file includes a timestamp, record counts, and snapshot data for: + +- `Collection` +- `CollectionItineraryItem` Note: Make sure you are in the same directory as your `docker-compose.yml` file. diff --git a/documentation/docs/guides/travel_agent.md b/documentation/docs/guides/travel_agent.md new file mode 100644 index 00000000..fc10bdbd --- /dev/null +++ b/documentation/docs/guides/travel_agent.md @@ -0,0 +1,155 @@ +# Travel Agent (MCP) + +Voyage includes a **Travel Agent** interface exposed through an **MCP-compatible HTTP endpoint**. This lets external MCP clients read and manage trip itineraries programmatically for authenticated users. + +## Endpoint + +- Default path: `api/mcp` +- Configurable with: `DJANGO_MCP_ENDPOINT` + +If you run Voyage at `https://voyage.example.com`, the default MCP URL is: + +```text +https://voyage.example.com/api/mcp +``` + +## Authentication + +MCP requests must include a DRF token in the `Authorization` header: + +```text +Authorization: Token +``` + +Use a token associated with the Voyage user account that should execute the MCP actions. + +### Get a token from an authenticated session + +Voyage exposes a token bootstrap endpoint for logged-in users: + +- `GET /auth/mcp-token/` + +Call it with your authenticated browser session (or any authenticated session cookie flow). It returns: + +```json +{ "token": "" } +``` + +Then use that token in all MCP requests with the same header format: + +```text +Authorization: Token +``` + +## Available MCP tools + +The Voyage MCP server currently exposes these tools: + +- `list_collections` +- `get_collection_details` +- `list_itinerary_items` +- `create_itinerary_item` +- `reorder_itinerary` + +### Tool parameters + +#### `list_collections` + +- No parameters. + +#### `get_collection_details` + +- `collection_id` (required, string UUID): collection to inspect. + +#### `list_itinerary_items` + +- `collection_id` (optional, string UUID): if provided, limits results to one collection. + +#### `create_itinerary_item` + +Required: + +- `collection_id` (string UUID) +- `content_type` (`location` \| `transportation` \| `note` \| `lodging` \| `visit` \| `checklist`) +- `object_id` (string UUID, id of the referenced content object) + +Optional: + +- `date` (ISO date string, required when `is_global` is `false`) +- `is_global` (boolean, default `false`; when `true`, `date` must be omitted) +- `order` (integer; if omitted, Voyage appends to the end of the relevant scope) + +#### `reorder_itinerary` + +Required: + +- `items` (list of item update objects) + +Each entry in `items` should include: + +- `id` (required, string UUID of `CollectionItineraryItem`) +- `date` (ISO date string for dated items) +- `order` (integer target order) +- `is_global` (optional boolean; include when moving between global and dated scopes) + +## End-to-end example flow + +This example shows a typical interaction from an MCP client. + +1. **Connect** to the MCP endpoint using your Voyage server URL and token header. +2. Call **`list_collections`** to find the trip/collection you want to work with. +3. Call **`get_collection_details`** for the selected collection ID to inspect current trip context. +4. Call **`list_itinerary_items`** for a specific date or collection scope. +5. Call **`create_itinerary_item`** to add a new stop (for example, a location or note) to the itinerary. +6. Call **`reorder_itinerary`** to persist the final ordering after insertion. + +### Example request headers (HTTP transport) + +```http +POST /api/mcp HTTP/1.1 +Host: voyage.example.com +Authorization: Token +Content-Type: application/json +``` + +### Example interaction sequence (conceptual) + +```text +Client -> list_collections +Server -> [{"id": "6c5d9f61-2f09-4882-b277-8884b633d36b", "name": "Japan 2026"}, ...] + +Client -> get_collection_details({"collection_id": "6c5d9f61-2f09-4882-b277-8884b633d36b"}) +Server -> {...collection metadata...} + +Client -> list_itinerary_items({"collection_id": "6c5d9f61-2f09-4882-b277-8884b633d36b"}) +Server -> [...current ordered itinerary items...] + +Client -> create_itinerary_item({ + "collection_id": "6c5d9f61-2f09-4882-b277-8884b633d36b", + "content_type": "location", + "object_id": "fe7ee379-8a2b-456d-9c59-1eafcf83979b", + "date": "2026-06-12", + "order": 3 +}) +Server -> {"id": "5eb8c40c-7e36-4709-b4ec-7dc4cfa26ca5", ...} + +Client -> reorder_itinerary({"items": [ + { + "id": "5eb8c40c-7e36-4709-b4ec-7dc4cfa26ca5", + "date": "2026-06-12", + "order": 0 + }, + { + "id": "a044f903-d788-4f67-bba7-3ee73da6d504", + "date": "2026-06-12", + "order": 1, + "is_global": false + } +]}) +Server -> [...updated itinerary items...] +``` + +## Related docs + +- [Advanced Configuration](../configuration/advanced_configuration.md) +- [How to use Voyage](../usage/usage.md) diff --git a/documentation/docs/intro/voyage_overview.md b/documentation/docs/intro/voyage_overview.md index 5fb81e41..8a7e9d65 100644 --- a/documentation/docs/intro/voyage_overview.md +++ b/documentation/docs/intro/voyage_overview.md @@ -23,6 +23,7 @@ Voyage is a full-fledged travel companion. With Voyage, you can log your adventu - Locations and itineraries can be shared via a public link or directly with other Voyage users. - Collaborators can view and edit shared itineraries (collections), making planning a breeze. - **Customizable Themes** 🎨: Choose from 10 built-in themes including Light, Dark, Dim, Night, Forest, Aqua, Catppuccin Mocha, Aesthetic Light, Aesthetic Dark, and Northern Lights. Theme selection persists across sessions. +- **Travel Agent (MCP) access** 🤖: Voyage exposes an authenticated MCP endpoint so external clients can list collections, inspect itineraries, create itinerary items, and reorder trip timelines. See the [Travel Agent (MCP) guide](../guides/travel_agent.md). ## Why Voyage? diff --git a/documentation/docs/usage/usage.md b/documentation/docs/usage/usage.md index 1e6bbdc2..becc73c1 100644 --- a/documentation/docs/usage/usage.md +++ b/documentation/docs/usage/usage.md @@ -2,6 +2,8 @@ Welcome to Voyage! This guide will help you get started with Voyage and provide you with an overview of the features available to you. +Voyage also includes a Travel Agent MCP interface for authenticated programmatic trip access and itinerary actions. See the [Travel Agent (MCP) guide](../guides/travel_agent.md). + ## Key Terms #### Locations diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index 67f0a762..88deb0ec 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -6,6 +6,7 @@ import DotsHorizontal from '~icons/mdi/dots-horizontal'; import Calendar from '~icons/mdi/calendar'; + import HelpCircle from '~icons/mdi/help-circle'; import AboutModal from './AboutModal.svelte'; import AccountMultiple from '~icons/mdi/account-multiple'; import MapMarker from '~icons/mdi/map-marker'; @@ -109,11 +110,24 @@ } }; + type NavigationItem = { + path: string; + icon: any; + label: string; + external?: boolean; + }; + // Navigation items for better organization - const navigationItems = [ + const navigationItems: NavigationItem[] = [ { path: '/locations', icon: MapMarker, label: 'locations.locations' }, { path: '/collections', icon: FormatListBulletedSquare, label: 'navbar.collections' }, { path: '/invites', icon: AccountMultiple, label: 'invites.title' }, + { + path: 'https://voyage.app/docs/usage/usage.html', + icon: HelpCircle, + label: 'navbar.documentation', + external: true + }, { path: '/worldtravel', icon: Earth, label: 'navbar.worldtravel' }, { path: '/map', icon: MapIcon, label: 'navbar.map' }, { path: '/calendar', icon: Calendar, label: 'navbar.calendar' }, @@ -149,8 +163,10 @@
  • {$t(item.label)} @@ -218,9 +234,11 @@
  • diff --git a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte index 381ba452..5a2f2b1f 100644 --- a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte +++ b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte @@ -1003,40 +1003,75 @@ return `${rounded}°C`; } - function optimizeDayOrder(dayIndex: number) { - if (!canModify || isSavingOrder) return; + type HardAnchorTiming = { + primaryTimestamp: number; + secondaryTimestamp: number; + }; - const day = days[dayIndex]; - if (!day) return; + function parseAnchorDateTime(value: string | null | undefined): number | null { + if (!value) return null; - const sortableItems = day.items.filter((item) => { - if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return false; - return !!getCoordinatesFromItineraryItem(item); - }); + const parsed = DateTime.fromISO(value); + if (!parsed.isValid) return null; - const nonSortableItems = day.items.filter((item) => { - if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return true; - return !getCoordinatesFromItineraryItem(item); - }); + const millis = parsed.toMillis(); + return Number.isFinite(millis) ? millis : null; + } - if (sortableItems.length < 2) { - addToast('info', getI18nText('itinerary.optimize_not_enough_items', 'Not enough stops to optimize')); - return; + function getHardAnchorTiming(item: ResolvedItineraryItem): HardAnchorTiming | null { + const itemType = item.item?.type || ''; + + if (itemType === 'transportation') { + const transportation = item.resolvedObject as Transportation | null; + const startTimestamp = parseAnchorDateTime(transportation?.date); + if (startTimestamp === null) return null; + + const endTimestamp = parseAnchorDateTime(transportation?.end_date); + return { + primaryTimestamp: startTimestamp, + secondaryTimestamp: endTimestamp ?? startTimestamp + }; } - const remaining = [...sortableItems]; + if (itemType === 'lodging') { + const lodging = item.resolvedObject as Lodging | null; + const checkInTimestamp = parseAnchorDateTime(lodging?.check_in); + const checkOutTimestamp = parseAnchorDateTime(lodging?.check_out); + if (checkInTimestamp === null && checkOutTimestamp === null) return null; + + const primaryTimestamp = checkInTimestamp ?? checkOutTimestamp; + if (primaryTimestamp === null) return null; + + return { + primaryTimestamp, + secondaryTimestamp: checkOutTimestamp ?? checkInTimestamp ?? primaryTimestamp + }; + } + + return null; + } + + function optimizeNearestNeighborSegment( + items: ResolvedItineraryItem[] + ): ResolvedItineraryItem[] { + if (items.length < 2) return [...items]; + + const remaining = [...items]; const sorted: ResolvedItineraryItem[] = []; const firstItem = remaining.shift(); - if (!firstItem) return; + if (!firstItem) return items; sorted.push(firstItem); while (remaining.length > 0) { const last = sorted[sorted.length - 1]; const lastCoords = getCoordinatesFromItineraryItem(last); - if (!lastCoords) break; + if (!lastCoords) { + sorted.push(...remaining); + break; + } - let nearestIndex = 0; + let nearestIndex = -1; let nearestDistance = Number.POSITIVE_INFINITY; for (let index = 0; index < remaining.length; index += 1) { @@ -1052,10 +1087,111 @@ } } + if (nearestIndex < 0) { + sorted.push(...remaining); + break; + } + sorted.push(remaining.splice(nearestIndex, 1)[0]); } - days[dayIndex].items = [...sorted, ...nonSortableItems]; + return sorted; + } + + function optimizeDayOrder(dayIndex: number) { + if (!canModify || isSavingOrder) return; + + const day = days[dayIndex]; + if (!day) return; + + const nonShadowItems = day.items.filter((item) => !item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]); + const shadowItems = day.items.filter((item) => item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]); + + const anchorEntries = nonShadowItems + .map((item, originalIndex) => { + const timing = getHardAnchorTiming(item); + if (!timing) return null; + + return { + item, + originalIndex, + ...timing + }; + }) + .filter( + (entry): entry is { + item: ResolvedItineraryItem; + originalIndex: number; + primaryTimestamp: number; + secondaryTimestamp: number; + } => !!entry + ); + + const anchorIndexSet = new Set(anchorEntries.map((entry) => entry.originalIndex)); + + const movableCoordinateItems = nonShadowItems.filter((item, originalIndex) => { + if (anchorIndexSet.has(originalIndex)) return false; + return !!getCoordinatesFromItineraryItem(item); + }); + + if (movableCoordinateItems.length < 2) { + addToast('info', getI18nText('itinerary.optimize_not_enough_items', 'Not enough stops to optimize')); + return; + } + + const anchorsByPosition = [...anchorEntries].sort( + (a, b) => a.originalIndex - b.originalIndex + ); + const chronologicalAnchors = [...anchorEntries] + .sort((a, b) => { + if (a.primaryTimestamp !== b.primaryTimestamp) { + return a.primaryTimestamp - b.primaryTimestamp; + } + if (a.secondaryTimestamp !== b.secondaryTimestamp) { + return a.secondaryTimestamp - b.secondaryTimestamp; + } + return a.originalIndex - b.originalIndex; + }) + .map((entry) => entry.item); + + const movableSegments: ResolvedItineraryItem[][] = Array.from( + { length: anchorsByPosition.length + 1 }, + () => [] + ); + + let activeSegmentIndex = 0; + let nextAnchorPositionIndex = 0; + + nonShadowItems.forEach((item, originalIndex) => { + const nextAnchor = anchorsByPosition[nextAnchorPositionIndex]; + if (nextAnchor && nextAnchor.originalIndex === originalIndex) { + nextAnchorPositionIndex += 1; + activeSegmentIndex += 1; + return; + } + + if (anchorIndexSet.has(originalIndex)) return; + if (!getCoordinatesFromItineraryItem(item)) return; + + movableSegments[activeSegmentIndex].push(item); + }); + + const optimizedPath: ResolvedItineraryItem[] = []; + for (let segmentIndex = 0; segmentIndex < movableSegments.length; segmentIndex += 1) { + const optimizedSegment = optimizeNearestNeighborSegment(movableSegments[segmentIndex]); + optimizedPath.push(...optimizedSegment); + + if (segmentIndex < chronologicalAnchors.length) { + optimizedPath.push(chronologicalAnchors[segmentIndex]); + } + } + + const nonCoordinateItems = nonShadowItems.filter((item, originalIndex) => { + if (anchorIndexSet.has(originalIndex)) return false; + return !getCoordinatesFromItineraryItem(item); + }); + + days[dayIndex].items = [...optimizedPath, ...nonCoordinateItems, ...shadowItems]; days = [...days]; isSavingOrder = true; diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index a3d7ecdf..b22a2b6e 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -734,7 +734,27 @@ "activities": "Aktivitäten", "trails": "Wanderwege", "use_imperial": "Verwenden Sie imperiale Einheiten", - "use_imperial_desc": "Verwenden Sie imperiale Einheiten (Füße, Zoll, Pfund) anstelle von metrischen Einheiten" + "use_imperial_desc": "Verwenden Sie imperiale Einheiten (Füße, Zoll, Pfund) anstelle von metrischen Einheiten", + "ai_api_keys": "KI-API-Schlüssel", + "ai_api_keys_desc": "Verwalten Sie nur schreibbare API-Schlüssel für Reiseagenten-Empfehlungen.", + "travel_agent_help_title": "So verwenden Sie den Reiseagenten", + "travel_agent_help_body": "Öffnen Sie eine Sammlung und wechseln Sie zu Empfehlungen, um mit dem Reiseagenten nach Vorschlägen zu suchen.", + "travel_agent_help_open_collections": "Sammlungen öffnen", + "travel_agent_help_setup_guide": "Einrichtungsanleitung für Reiseagenten", + "saved_api_keys": "Gespeicherte API-Schlüssel", + "no_api_keys_saved": "Noch keine API-Schlüssel gespeichert.", + "add_api_key": "API-Schlüssel hinzufügen", + "provider": "Anbieter", + "api_key_value": "API-Schlüssel", + "api_key_value_placeholder": "Geben Sie Ihren API-Schlüssel ein", + "api_key_write_only_hint": "Aus Sicherheitsgründen ist Ihr Klartextschlüssel nur zum Schreiben verfügbar und wird nach dem Speichern nicht mehr angezeigt.", + "save_api_key": "API-Schlüssel speichern", + "api_keys_saved": "API-Schlüssel gespeichert.", + "api_keys_deleted": "API-Schlüssel gelöscht.", + "api_keys_generic_error": "API-Schlüssel können derzeit nicht aktualisiert werden.", + "api_keys_value_required": "Bitte geben Sie einen API-Schlüssel ein.", + "api_keys_config_unavailable": "API-Schlüsselspeicher ist nicht verfügbar", + "api_keys_config_guidance": "Bitten Sie Ihren Serveradministrator, FIELD_ENCRYPTION_KEY zu konfigurieren, und versuchen Sie es erneut." }, "checklist": { "checklist_delete_error": "Fehler beim Löschen der Checkliste", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 15908bd0..c0e4b23b 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -735,7 +735,27 @@ "use_imperial": "Use Imperial Units", "use_imperial_desc": "Use imperial units (feet, inches, pounds) instead of metric units", "trails": "Trails", - "activities": "Activities" + "activities": "Activities", + "ai_api_keys": "AI API Keys", + "ai_api_keys_desc": "Manage write-only API keys for travel-agent recommendations.", + "saved_api_keys": "Saved API Keys", + "no_api_keys_saved": "No API keys saved yet.", + "add_api_key": "Add API Key", + "provider": "Provider", + "api_key_value": "API Key", + "api_key_value_placeholder": "Enter your API key", + "api_key_write_only_hint": "For security, your plaintext key is write-only and is never shown after saving.", + "save_api_key": "Save API Key", + "api_keys_saved": "API key saved.", + "api_keys_deleted": "API key deleted.", + "api_keys_generic_error": "Unable to update API keys right now.", + "api_keys_value_required": "Please enter an API key.", + "api_keys_config_unavailable": "API key storage is unavailable", + "api_keys_config_guidance": "Ask your server administrator to configure FIELD_ENCRYPTION_KEY and try again.", + "travel_agent_help_title": "How to use the travel agent", + "travel_agent_help_body": "Open a collection and switch to Recommendations to interact with the travel agent for place suggestions.", + "travel_agent_help_open_collections": "Open Collections", + "travel_agent_help_setup_guide": "Travel agent setup guide" }, "collection": { "collection_created": "Collection created successfully!", diff --git a/frontend/src/locales/tr.json b/frontend/src/locales/tr.json index e9932ead..cf643205 100644 --- a/frontend/src/locales/tr.json +++ b/frontend/src/locales/tr.json @@ -734,7 +734,10 @@ "use_imperial": "İngiliz Ölçü Birimlerini Kullan", "use_imperial_desc": "Metrik birimler yerine İngiliz birimlerini (fit, inç, pound) kullanın", "trails": "Patikalar", - "activities": "Aktiviteler" + "activities": "Aktiviteler", + "ai_api_keys": "Yapay zeka API anahtarları", + "saved_api_keys": "Kaydedilen API anahtarları", + "add_api_key": "API anahtarı ekle" }, "collection": { "collection_created": "Koleksiyon başarıyla oluşturuldu!", diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index b6b9aab7..28bf8862 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -51,9 +51,7 @@ if (browser) { init({ - fallbackLocale: locales.includes(navigator.language.split('-')[0]) - ? navigator.language.split('-')[0] - : 'en', + fallbackLocale: 'en', initialLocale: data.locale }); // get the locale cookie if it exists and set it as the initial locale if it exists diff --git a/frontend/src/routes/settings/+page.server.ts b/frontend/src/routes/settings/+page.server.ts index 7e28351f..2f6249de 100644 --- a/frontend/src/routes/settings/+page.server.ts +++ b/frontend/src/routes/settings/+page.server.ts @@ -16,6 +16,14 @@ type MFAAuthenticatorResponse = { }[]; }; +type UserAPIKey = { + id: string; + provider: string; + masked_api_key: string; + created_at: string; + updated_at: string; +}; + export const load: PageServerLoad = async (event) => { if (!event.locals.user) { return redirect(302, '/'); @@ -85,6 +93,21 @@ export const load: PageServerLoad = async (event) => { let wandererEnabled = integrations.wanderer.exists as boolean; let wandererExpired = integrations.wanderer.expired as boolean; + let apiKeys: UserAPIKey[] = []; + let apiKeysConfigError: string | null = null; + let apiKeysFetch = await fetch(`${endpoint}/api/integrations/api-keys/`, { + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + + if (apiKeysFetch.ok) { + apiKeys = (await apiKeysFetch.json()) as UserAPIKey[]; + } else if (apiKeysFetch.status === 503) { + const errorBody = (await apiKeysFetch.json()) as { detail?: string }; + apiKeysConfigError = errorBody.detail ?? 'API key storage is currently unavailable.'; + } + let publicUrlFetch = await fetch(`${endpoint}/public-url/`); let publicUrl = ''; if (!publicUrlFetch.ok) { @@ -101,10 +124,13 @@ export const load: PageServerLoad = async (event) => { authenticators, immichIntegration, publicUrl, + mcpTokenHeaderFormat: 'Authorization: Token ', socialProviders, googleMapsEnabled, stravaGlobalEnabled, stravaUserEnabled, + apiKeys, + apiKeysConfigError, wandererEnabled, wandererExpired } diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 173fe025..8830d58e 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -16,7 +16,6 @@ import WandererLogoSrc from '$lib/assets/wanderer.svg'; export let data: PageData; - console.log(data); let user: User; let emails: typeof data.props.emails; if (data.user) { @@ -37,6 +36,21 @@ let stravaUserEnabled = data.props.stravaUserEnabled; let wandererEnabled = data.props.wandererEnabled; let wandererExpired = data.props.wandererExpired; + type UserAPIKey = { + id: string; + provider: string; + masked_api_key: string; + created_at: string; + updated_at: string; + }; + let userApiKeys: UserAPIKey[] = data.props.apiKeys ?? []; + let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null; + let newApiKeyProvider = 'google_maps'; + let newApiKeyValue = ''; + let isSavingApiKey = false; + let deletingApiKeyId: string | null = null; + let mcpToken: string | null = null; + let isLoadingMcpToken = false; let activeSection: string = 'profile'; // typed alias for social providers to satisfy TypeScript @@ -82,6 +96,7 @@ { id: 'security', icon: '🔒', label: () => $t('settings.security') }, { id: 'emails', icon: '📧', label: () => $t('settings.emails') }, { id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') }, + { id: 'ai_api_keys', icon: '🤖', label: () => $t('settings.ai_api_keys') }, { id: 'import_export', icon: '📦', label: () => $t('settings.backup_restore') }, { id: 'admin', icon: '⚙️', label: () => $t('settings.admin') }, { id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') } @@ -401,6 +416,162 @@ newWandererIntegration.password = ''; } } + + function getApiKeysErrorMessage(errorBody: any): string { + if (errorBody?.detail) { + return errorBody.detail; + } + if (errorBody?.api_key?.[0]) { + return errorBody.api_key[0]; + } + if (errorBody?.provider?.[0]) { + return errorBody.provider[0]; + } + return $t('settings.api_keys_generic_error'); + } + + async function addUserApiKey(event: SubmitEvent) { + event.preventDefault(); + + if (!newApiKeyValue.trim()) { + addToast('error', $t('settings.api_keys_value_required')); + return; + } + + isSavingApiKey = true; + try { + const res = await fetch('/api/integrations/api-keys/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + provider: newApiKeyProvider, + api_key: newApiKeyValue + }) + }); + + let payload: any = null; + try { + payload = await res.json(); + } catch { + payload = null; + } + + if (res.ok && payload) { + const existingIndex = userApiKeys.findIndex((key) => key.provider === payload.provider); + if (existingIndex >= 0) { + const updated = [...userApiKeys]; + updated[existingIndex] = payload; + userApiKeys = updated.sort((a, b) => a.provider.localeCompare(b.provider)); + } else { + userApiKeys = [...userApiKeys, payload].sort((a, b) => a.provider.localeCompare(b.provider)); + } + newApiKeyValue = ''; + apiKeysConfigError = null; + addToast('success', $t('settings.api_keys_saved')); + return; + } + + if (res.status === 503) { + apiKeysConfigError = getApiKeysErrorMessage(payload); + addToast('error', $t('settings.api_keys_config_unavailable')); + return; + } + + addToast('error', getApiKeysErrorMessage(payload)); + } catch { + addToast('error', $t('settings.api_keys_generic_error')); + } finally { + isSavingApiKey = false; + } + } + + async function deleteUserApiKey(apiKey: UserAPIKey) { + deletingApiKeyId = apiKey.id; + try { + const res = await fetch(`/api/integrations/api-keys/${apiKey.id}/`, { + method: 'DELETE' + }); + + if (res.ok || res.status === 204) { + userApiKeys = userApiKeys.filter((key) => key.id !== apiKey.id); + addToast('success', $t('settings.api_keys_deleted')); + return; + } + + let payload: any = null; + try { + payload = await res.json(); + } catch { + payload = null; + } + + if (res.status === 503) { + apiKeysConfigError = getApiKeysErrorMessage(payload); + addToast('error', $t('settings.api_keys_config_unavailable')); + return; + } + + addToast('error', getApiKeysErrorMessage(payload)); + } catch { + addToast('error', $t('settings.api_keys_generic_error')); + } finally { + deletingApiKeyId = null; + } + } + + function getMaskedMcpToken(token: string): string { + if (token.length <= 8) { + return '••••••••'; + } + return `${token.slice(0, 4)}••••••••${token.slice(-4)}`; + } + + async function fetchOrCreateMcpToken() { + isLoadingMcpToken = true; + try { + const res = await fetch('/auth/mcp-token/', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!res.ok) { + addToast('error', $t('settings.generic_error')); + return; + } + + const payload = (await res.json()) as { token?: string }; + if (!payload.token) { + addToast('error', $t('settings.generic_error')); + return; + } + + mcpToken = payload.token; + addToast('success', 'MCP token ready.'); + } catch { + addToast('error', $t('settings.generic_error')); + } finally { + isLoadingMcpToken = false; + } + } + + async function copyMcpAuthHeader() { + if (!mcpToken) { + addToast('error', 'Generate token first.'); + return; + } + + const authHeader = `Authorization: Token ${mcpToken}`; + try { + await navigator.clipboard.writeText(authHeader); + addToast('success', $t('adventures.copied_to_clipboard')); + } catch { + addToast('error', $t('adventures.copy_failed')); + } + } {#if isMFAModalOpen} @@ -1292,6 +1463,189 @@ {/if} + + {#if activeSection === 'ai_api_keys'} +
    +
    +
    + 🤖 +
    +
    +

    {$t('settings.ai_api_keys')}

    +

    + {$t('settings.ai_api_keys_desc')} +

    +
    +
    + + {#if apiKeysConfigError} +
    + + + +
    +

    {$t('settings.api_keys_config_unavailable')}

    +

    {apiKeysConfigError}

    +

    {$t('settings.api_keys_config_guidance')}

    +
    +
    + {/if} + +
    + +
    +

    MCP Access Token

    +

    + Create or fetch your personal token for MCP clients. The same token is reused if one + already exists. +

    + +
    + + +
    + +
    +
    Token
    +
    + {mcpToken ? getMaskedMcpToken(mcpToken) : 'Not generated yet'} +
    +
    + +
    +
    Use this exact auth header format
    +
    {data.props.mcpTokenHeaderFormat}
    +
    +
    + +
    +

    {$t('settings.saved_api_keys')}

    + {#if userApiKeys.length === 0} +

    {$t('settings.no_api_keys_saved')}

    + {:else} +
    + {#each userApiKeys as apiKey} +
    +
    +
    {apiKey.provider}
    +
    + {apiKey.masked_api_key} +
    +
    + +
    + {/each} +
    + {/if} +
    + +
    +

    {$t('settings.add_api_key')}

    +
    +
    + + +
    +
    + + +

    + {$t('settings.api_key_write_only_hint')} +

    +
    + +
    +
    +
    + {/if} + {#if activeSection === 'import_export'}