From 3f9a6767bdda7ba61e7dcda991bd5e146210ea37 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Thu, 12 Jun 2025 15:54:01 -0400 Subject: [PATCH] feat: Enhance Adventure and Collection Management - Added support for multiple collections in AdventureSerializer, allowing adventures to be linked to multiple collections. - Implemented validation to ensure collections belong to the current user during adventure creation and updates. - Introduced a signal to update adventure publicity based on the public status of linked collections. - Updated file permission checks to consider multiple collections when determining access rights. - Modified AdventureImageViewSet and AttachmentViewSet to check access against collections instead of a single collection. - Enhanced AdventureViewSet to support filtering and sorting adventures based on collections. - Updated frontend components to manage collections more effectively, including linking and unlinking adventures from collections. - Adjusted API endpoints and data structures to accommodate the new collections feature. - Improved user experience with appropriate notifications for collection actions. --- backend/server/adventures/apps.py | 6 +- backend/server/adventures/managers.py | 9 +- ...enture_collection_adventure_collections.py | 59 +++ backend/server/adventures/models.py | 59 ++- backend/server/adventures/permissions.py | 109 +++-- backend/server/adventures/serializers.py | 49 ++- backend/server/adventures/signals.py | 23 ++ .../adventures/utils/file_permissions.py | 25 +- .../adventures/views/adventure_image_view.py | 18 +- .../server/adventures/views/adventure_view.py | 378 ++++++++++++------ .../adventures/views/attachment_view.py | 26 +- .../adventures/views/collection_view.py | 33 +- documentation/docs/install/getting_started.md | 2 +- .../src/lib/components/AdventureCard.svelte | 83 ++-- .../src/lib/components/AdventureLink.svelte | 14 +- .../src/lib/components/CollectionCard.svelte | 25 +- .../src/lib/components/CollectionLink.svelte | 18 +- frontend/src/lib/types.ts | 2 +- frontend/src/locales/en.json | 3 +- .../routes/adventures/[id]/+page.server.ts | 14 +- .../src/routes/adventures/[id]/+page.svelte | 8 +- .../src/routes/collections/[id]/+page.svelte | 12 +- 22 files changed, 686 insertions(+), 289 deletions(-) create mode 100644 backend/server/adventures/migrations/0035_remove_adventure_collection_adventure_collections.py create mode 100644 backend/server/adventures/signals.py diff --git a/backend/server/adventures/apps.py b/backend/server/adventures/apps.py index 37a5920..e706a17 100644 --- a/backend/server/adventures/apps.py +++ b/backend/server/adventures/apps.py @@ -1,6 +1,8 @@ from django.apps import AppConfig -from django.conf import settings class AdventuresConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'adventures' \ No newline at end of file + name = 'adventures' + + def ready(self): + import adventures.signals # Import signals when the app is ready \ No newline at end of file diff --git a/backend/server/adventures/managers.py b/backend/server/adventures/managers.py index 6d8d43c..4a12194 100644 --- a/backend/server/adventures/managers.py +++ b/backend/server/adventures/managers.py @@ -3,20 +3,15 @@ from django.db.models import Q class AdventureManager(models.Manager): def retrieve_adventures(self, user, include_owned=False, include_shared=False, include_public=False): - # Initialize the query with an empty Q object query = Q() - # Add owned adventures to the query if included if include_owned: - query |= Q(user_id=user.id) + query |= Q(user_id=user) - # Add shared adventures to the query if included if include_shared: - query |= Q(collection__shared_with=user.id) + query |= Q(collections__shared_with=user) - # Add public adventures to the query if included if include_public: query |= Q(is_public=True) - # Perform the query with the final Q object and remove duplicates return self.filter(query).distinct() diff --git a/backend/server/adventures/migrations/0035_remove_adventure_collection_adventure_collections.py b/backend/server/adventures/migrations/0035_remove_adventure_collection_adventure_collections.py new file mode 100644 index 0000000..59d3580 --- /dev/null +++ b/backend/server/adventures/migrations/0035_remove_adventure_collection_adventure_collections.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.1 on 2025-06-10 03:04 + +from django.db import migrations, models + + +def migrate_collection_relationships(apps, schema_editor): + """ + Migrate existing ForeignKey relationships to ManyToMany relationships + """ + Adventure = apps.get_model('adventures', 'Adventure') + + # Get all adventures that have a collection assigned + adventures_with_collections = Adventure.objects.filter(collection__isnull=False) + + for adventure in adventures_with_collections: + # Add the existing collection to the new many-to-many field + adventure.collections.add(adventure.collection_id) + + +def reverse_migrate_collection_relationships(apps, schema_editor): + """ + Reverse migration - convert first collection back to ForeignKey + Note: This will only preserve the first collection if an adventure has multiple + """ + Adventure = apps.get_model('adventures', 'Adventure') + + for adventure in Adventure.objects.all(): + first_collection = adventure.collections.first() + if first_collection: + adventure.collection = first_collection + adventure.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0034_remove_adventureimage_unique_immich_id_per_user'), + ] + + operations = [ + # First, add the new ManyToMany field + migrations.AddField( + model_name='adventure', + name='collections', + field=models.ManyToManyField(blank=True, related_name='adventures', to='adventures.collection'), + ), + + # Migrate existing data from old field to new field + migrations.RunPython( + migrate_collection_relationships, + reverse_migrate_collection_relationships + ), + + # Finally, remove the old ForeignKey field + migrations.RemoveField( + model_name='adventure', + name='collection', + ), + ] \ No newline at end of file diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 91be2d7..40bb680 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -15,6 +15,7 @@ from django.core.exceptions import ValidationError from django.utils import timezone def background_geocode_and_assign(adventure_id: str): + print(f"[Adventure Geocode Thread] Starting geocode for adventure {adventure_id}") try: adventure = Adventure.objects.get(id=adventure_id) if not (adventure.latitude and adventure.longitude): @@ -576,20 +577,14 @@ class Adventure(models.Model): region = models.ForeignKey(Region, on_delete=models.SET_NULL, blank=True, null=True) country = models.ForeignKey(Country, on_delete=models.SET_NULL, blank=True, null=True) - collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True) + # Changed from ForeignKey to ManyToManyField + collections = models.ManyToManyField('Collection', blank=True, related_name='adventures') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) objects = AdventureManager() - # DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS - # Migrations performed in this version will remove these fields - # image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/') - # date = models.DateField(blank=True, null=True) - # end_date = models.DateField(blank=True, null=True) - # type = models.CharField(max_length=100, choices=ADVENTURE_TYPES, default='general') - def is_visited_status(self): current_date = timezone.now().date() for visit in self.visits.all(): @@ -601,17 +596,33 @@ class Adventure(models.Model): return True return False - def clean(self): - if self.collection: - if self.collection.is_public and not self.is_public: - raise ValidationError('Adventures associated with a public collection must be public. Collection: ' + self.trip.name + ' Adventure: ' + self.name) - if self.user_id != self.collection.user_id: - raise ValidationError('Adventures must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Adventure owner: ' + self.user_id.username) + def clean(self, skip_shared_validation=False): + """ + Validate model constraints. + skip_shared_validation: Skip validation when called by shared users + """ + # Skip validation if this is a shared user update + if skip_shared_validation: + return + + # Check collections after the instance is saved (in save method or separate validation) + if self.pk: # Only check if the instance has been saved + for collection in self.collections.all(): + if collection.is_public and not self.is_public: + raise ValidationError(f'Adventures associated with a public collection must be public. Collection: {collection.name} Adventure: {self.name}') + + # Only enforce same-user constraint for non-shared collections + if self.user_id != collection.user_id: + # Check if this is a shared collection scenario + # Allow if the adventure owner has access to the collection through sharing + if not collection.shared_with.filter(uuid=self.user_id.uuid).exists(): + raise ValidationError(f'Adventures must be associated with collections owned by the same user or shared collections. Collection owner: {collection.user_id.username} Adventure owner: {self.user_id.username}') + if self.category: if self.user_id != self.category.user_id: - raise ValidationError('Adventures must be associated with categories owned by the same user. Category owner: ' + self.category.user_id.username + ' Adventure owner: ' + self.user_id.username) + raise ValidationError(f'Adventures must be associated with categories owned by the same user. Category owner: {self.category.user_id.username} Adventure owner: {self.user_id.username}') - def save(self, force_insert=False, force_update=False, using=None, update_fields=None, _skip_geocode=False): + def save(self, force_insert=False, force_update=False, using=None, update_fields=None, _skip_geocode=False, _skip_shared_validation=False): if force_insert and force_update: raise ValueError("Cannot force both insert and updating in model saving.") @@ -625,6 +636,15 @@ class Adventure(models.Model): result = super().save(force_insert, force_update, using, update_fields) + # Validate collections after saving (since M2M relationships require saved instance) + if self.pk: + try: + self.clean(skip_shared_validation=_skip_shared_validation) + except ValidationError as e: + # If validation fails, you might want to handle this differently + # For now, we'll re-raise the error + raise e + # ⛔ Skip threading if called from geocode background thread if _skip_geocode: return result @@ -636,7 +656,6 @@ class Adventure(models.Model): return result - def __str__(self): return self.name @@ -656,13 +675,13 @@ class Collection(models.Model): shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True) link = models.URLField(blank=True, null=True, max_length=2083) - # if connected adventures are private and collection is public, raise an error def clean(self): if self.is_public and self.pk: # Only check if the instance has a primary key - for adventure in self.adventure_set.all(): + # Updated to use the new related_name 'adventures' + for adventure in self.adventures.all(): if not adventure.is_public: - raise ValidationError('Public collections cannot be associated with private adventures. Collection: ' + self.name + ' Adventure: ' + adventure.name) + raise ValidationError(f'Public collections cannot be associated with private adventures. Collection: {self.name} Adventure: {adventure.name}') def __str__(self): return self.name diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py index 941fbc0..ce38f74 100644 --- a/backend/server/adventures/permissions.py +++ b/backend/server/adventures/permissions.py @@ -2,78 +2,99 @@ from rest_framework import permissions class IsOwnerOrReadOnly(permissions.BasePermission): """ - Custom permission to only allow owners of an object to edit it. + Owners can edit, others have read-only access. """ - def has_object_permission(self, request, view, obj): - # Read permissions are allowed to any request, - # so we'll always allow GET, HEAD or OPTIONS requests. if request.method in permissions.SAFE_METHODS: return True - - # Write permissions are only allowed to the owner of the object. + # obj.user_id is FK to User, compare with request.user return obj.user_id == request.user class IsPublicReadOnly(permissions.BasePermission): """ - Custom permission to only allow read-only access to public objects, - and write access to the owner of the object. + Read-only if public or owner, write only for owner. """ - def has_object_permission(self, request, view, obj): - # Read permissions are allowed if the object is public if request.method in permissions.SAFE_METHODS: return obj.is_public or obj.user_id == request.user - - # Write permissions are only allowed to the owner of the object return obj.user_id == request.user - + + class CollectionShared(permissions.BasePermission): """ - Custom permission to only allow read-only access to public objects, - and write access to the owner of the object. + Allow full access if user is in shared_with of collection(s) or owner, + read-only if public or shared_with, + write only if owner or shared_with. """ - def has_object_permission(self, request, view, obj): + user = request.user + if not user or not user.is_authenticated: + # Anonymous: only read public + return request.method in permissions.SAFE_METHODS and obj.is_public - # Read permissions are allowed if the object is shared with the user - if obj.shared_with and obj.shared_with.filter(id=request.user.id).exists(): - return True - - # Write permissions are allowed if the object is shared with the user - if request.method not in permissions.SAFE_METHODS and obj.shared_with.filter(id=request.user.id).exists(): - return True + # Check if user is in shared_with of any collections related to the obj + # If obj is a Collection itself: + if hasattr(obj, 'shared_with'): + if obj.shared_with.filter(id=user.id).exists(): + return True - # Read permissions are allowed if the object is public + # If obj is an Adventure (has collections M2M) + if hasattr(obj, 'collections'): + # Check if user is in shared_with of any related collection + shared_collections = obj.collections.filter(shared_with=user) + if shared_collections.exists(): + return True + + # Read permission if public or owner if request.method in permissions.SAFE_METHODS: - return obj.is_public or obj.user_id == request.user + return obj.is_public or obj.user_id == user + + # Write permission only if owner or shared user via collections + if obj.user_id == user: + return True + + if hasattr(obj, 'collections'): + if obj.collections.filter(shared_with=user).exists(): + return True + + # Default deny + return False - # Write permissions are only allowed to the owner of the object - return obj.user_id == request.user class IsOwnerOrSharedWithFullAccess(permissions.BasePermission): """ - Custom permission to allow: - - Full access for shared users - - Full access for owners - - Read-only access for others on safe methods + Full access for owners and users shared via collections, + read-only for others if public. """ - def has_object_permission(self, request, view, obj): - - # Allow GET only for a public object - if request.method in permissions.SAFE_METHODS and obj.is_public: - return True - # Check if the object has a collection - if hasattr(obj, 'collection') and obj.collection: - # Allow all actions for shared users - if request.user in obj.collection.shared_with.all(): - return True + user = request.user + if not user or not user.is_authenticated: + return request.method in permissions.SAFE_METHODS and obj.is_public - # Always allow GET, HEAD, or OPTIONS requests (safe methods) + # If safe method (read), allow if: if request.method in permissions.SAFE_METHODS: + if obj.is_public: + return True + if obj.user_id == user: + return True + # If user in shared_with of any collection related to obj + if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists(): + return True + if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists(): + return True + if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists(): + return True + return False + + # For write methods, allow if owner or shared user + if obj.user_id == user: + return True + if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists(): + return True + if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists(): + return True + if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists(): return True - # Allow all actions for the owner - return obj.user_id == request.user \ No newline at end of file + return False diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 2708ab3..4985d2d 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -101,12 +101,17 @@ class AdventureSerializer(CustomModelSerializer): country = CountrySerializer(read_only=True) region = RegionSerializer(read_only=True) city = CitySerializer(read_only=True) + collections = serializers.PrimaryKeyRelatedField( + many=True, + queryset=Collection.objects.all(), + required=False + ) class Meta: model = Adventure fields = [ 'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location', - 'is_public', 'collection', 'created_at', 'updated_at', 'images', 'link', 'longitude', + 'is_public', 'collections', 'created_at', 'updated_at', 'images', 'link', 'longitude', 'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user', 'city', 'country', 'region' ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited', 'user'] @@ -116,6 +121,19 @@ class AdventureSerializer(CustomModelSerializer): # 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 belong to the same user""" + if not collections: + return collections + + user = self.context['request'].user + for collection in collections: + if collection.user_id != user: + raise serializers.ValidationError( + f"Collection '{collection.name}' does not belong to the current user." + ) + return collections + def validate_category(self, category_data): if isinstance(category_data, Category): return category_data @@ -137,7 +155,7 @@ class AdventureSerializer(CustomModelSerializer): if isinstance(category_data, dict): name = category_data.get('name', '').lower() display_name = category_data.get('display_name', name) - icon = category_data.get('icon', '�') + icon = category_data.get('icon', '🌍') else: name = category_data.name.lower() display_name = category_data.display_name @@ -163,14 +181,23 @@ class AdventureSerializer(CustomModelSerializer): def create(self, validated_data): visits_data = validated_data.pop('visits', None) category_data = validated_data.pop('category', None) + collections_data = validated_data.pop('collections', []) + print(category_data) adventure = Adventure.objects.create(**validated_data) + + # Handle visits for visit_data in visits_data: Visit.objects.create(adventure=adventure, **visit_data) + # Handle category if category_data: category = self.get_or_create_category(category_data) adventure.category = category + + # Handle collections - set after adventure is saved + if collections_data: + adventure.collections.set(collections_data) adventure.save() @@ -181,13 +208,27 @@ class AdventureSerializer(CustomModelSerializer): visits_data = validated_data.pop('visits', []) category_data = validated_data.pop('category', None) + collections_data = validated_data.pop('collections', None) + collections_add = validated_data.pop('collections_add', []) + collections_remove = validated_data.pop('collections_remove', []) + + # Update regular fields for attr, value in validated_data.items(): setattr(instance, attr, value) - if category_data: + # Handle category - ONLY allow the adventure owner to change categories + user = self.context['request'].user + if category_data and instance.user_id == user: + # Only the owner can set categories category = self.get_or_create_category(category_data) instance.category = category + # If not the owner, ignore category changes + # Handle collections - only update if collections were provided + if collections_data is not None: + instance.collections.set(collections_data) + + # Handle visits if has_visits: current_visits = instance.visits.all() current_visit_ids = set(current_visits.values_list('id', flat=True)) @@ -352,7 +393,7 @@ class ChecklistSerializer(CustomModelSerializer): return data class CollectionSerializer(CustomModelSerializer): - adventures = AdventureSerializer(many=True, read_only=True, source='adventure_set') + adventures = AdventureSerializer(many=True, read_only=True) transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set') notes = NoteSerializer(many=True, read_only=True, source='note_set') checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set') diff --git a/backend/server/adventures/signals.py b/backend/server/adventures/signals.py new file mode 100644 index 0000000..b8501c8 --- /dev/null +++ b/backend/server/adventures/signals.py @@ -0,0 +1,23 @@ +from django.db.models.signals import m2m_changed +from django.dispatch import receiver +from adventures.models import Adventure + +@receiver(m2m_changed, sender=Adventure.collections.through) +def update_adventure_publicity(sender, instance, action, **kwargs): + """ + Signal handler to update adventure publicity when collections are added/removed + """ + # Only process when collections are added or removed + if action in ('post_add', 'post_remove', 'post_clear'): + collections = instance.collections.all() + + if collections.exists(): + # If any collection is public, make the adventure public + has_public_collection = collections.filter(is_public=True).exists() + + if has_public_collection and not instance.is_public: + instance.is_public = True + instance.save(update_fields=['is_public']) + elif not has_public_collection and instance.is_public: + instance.is_public = False + instance.save(update_fields=['is_public']) diff --git a/backend/server/adventures/utils/file_permissions.py b/backend/server/adventures/utils/file_permissions.py index 02971bc..b9a63f0 100644 --- a/backend/server/adventures/utils/file_permissions.py +++ b/backend/server/adventures/utils/file_permissions.py @@ -15,9 +15,12 @@ def checkFilePermission(fileId, user, mediaType): return True elif adventure.user_id == user: return True - elif adventure.collection: - if adventure.collection.shared_with.filter(id=user.id).exists(): - return True + elif adventure.collections.exists(): + # Check if the user is in any collection's shared_with list + for collection in adventure.collections.all(): + if collection.shared_with.filter(id=user.id).exists(): + return True + return False else: return False except AdventureImage.DoesNotExist: @@ -27,14 +30,18 @@ def checkFilePermission(fileId, user, mediaType): # Construct the full relative path to match the database field attachment_path = f"attachments/{fileId}" # Fetch the Attachment object - attachment = Attachment.objects.get(file=attachment_path).adventure - if attachment.is_public: + attachment = Attachment.objects.get(file=attachment_path) + adventure = attachment.adventure + if adventure.is_public: return True - elif attachment.user_id == user: + elif adventure.user_id == user: return True - elif attachment.collection: - if attachment.collection.shared_with.filter(id=user.id).exists(): - return True + elif adventure.collections.exists(): + # Check if the user is in any collection's shared_with list + for collection in adventure.collections.all(): + if collection.shared_with.filter(id=user.id).exists(): + return True + return False else: return False except Attachment.DoesNotExist: diff --git a/backend/server/adventures/views/adventure_image_view.py b/backend/server/adventures/views/adventure_image_view.py index 52839de..ab7f8e1 100644 --- a/backend/server/adventures/views/adventure_image_view.py +++ b/backend/server/adventures/views/adventure_image_view.py @@ -51,10 +51,16 @@ class AdventureImageViewSet(viewsets.ModelViewSet): return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) if adventure.user_id != request.user: - # Check if the adventure has a collection - if adventure.collection: - # Check if the user is in the collection's shared_with list - if not adventure.collection.shared_with.filter(id=request.user.id).exists(): + # Check if the adventure has any collections + if adventure.collections.exists(): + # Check if the user is in the shared_with list of any of the adventure's collections + user_has_access = False + for collection in adventure.collections.all(): + if collection.shared_with.filter(id=request.user.id).exists(): + user_has_access = True + break + + if not user_has_access: return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN) else: return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) @@ -189,7 +195,7 @@ class AdventureImageViewSet(viewsets.ModelViewSet): queryset = AdventureImage.objects.filter( Q(adventure__id=adventure_uuid) & ( Q(adventure__user_id=request.user) | # User owns the adventure - Q(adventure__collection__shared_with=request.user) # User has shared access via collection + Q(adventure__collections__shared_with=request.user) # User has shared access via collection ) ).distinct() @@ -200,7 +206,7 @@ class AdventureImageViewSet(viewsets.ModelViewSet): # Updated to include images from adventures the user owns OR has shared access to return AdventureImage.objects.filter( Q(adventure__user_id=self.request.user) | # User owns the adventure - Q(adventure__collection__shared_with=self.request.user) # User has shared access via collection + Q(adventure__collections__shared_with=self.request.user) # User has shared access via collection ).distinct() def perform_create(self, serializer): diff --git a/backend/server/adventures/views/adventure_view.py b/backend/server/adventures/views/adventure_view.py index a936f86..43ca0c4 100644 --- a/backend/server/adventures/views/adventure_view.py +++ b/backend/server/adventures/views/adventure_view.py @@ -3,69 +3,41 @@ from django.db import transaction from django.core.exceptions import PermissionDenied from django.db.models import Q, Max from django.db.models.functions import Lower -from rest_framework import viewsets +from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +import requests + from adventures.models import Adventure, Category, Transportation, Lodging from adventures.permissions import IsOwnerOrSharedWithFullAccess from adventures.serializers import AdventureSerializer, TransportationSerializer, LodgingSerializer from adventures.utils import pagination -import requests + class AdventureViewSet(viewsets.ModelViewSet): + """ + ViewSet for managing Adventure objects with support for filtering, sorting, + and sharing functionality. + """ serializer_class = AdventureSerializer permission_classes = [IsOwnerOrSharedWithFullAccess] pagination_class = pagination.StandardResultsSetPagination - def apply_sorting(self, queryset): - order_by = self.request.query_params.get('order_by', 'updated_at') - order_direction = self.request.query_params.get('order_direction', 'asc') - include_collections = self.request.query_params.get('include_collections', 'true') - - valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at'] - if order_by not in valid_order_by: - order_by = 'name' - - if order_direction not in ['asc', 'desc']: - order_direction = 'asc' - - if order_by == 'date': - queryset = queryset.annotate(latest_visit=Max('visits__start_date')).filter(latest_visit__isnull=False) - ordering = 'latest_visit' - elif order_by == 'name': - queryset = queryset.annotate(lower_name=Lower('name')) - ordering = 'lower_name' - elif order_by == 'rating': - queryset = queryset.filter(rating__isnull=False) - ordering = 'rating' - else: - ordering = order_by - - if order_direction == 'desc': - ordering = f'-{ordering}' - - if order_by == 'updated_at': - ordering = '-updated_at' if order_direction == 'asc' else 'updated_at' - - if include_collections == 'false': - queryset = queryset.filter(collection=None) - - return queryset.order_by(ordering) + # ==================== QUERYSET & PERMISSIONS ==================== def get_queryset(self): """ - Returns the queryset for the AdventureViewSet. Unauthenticated users can only - retrieve public adventures, while authenticated users can access their own, - shared, and public adventures depending on the action. + Returns queryset based on user authentication and action type. + Public actions allow unauthenticated access to public adventures. """ user = self.request.user - - # Actions that allow public access (include 'retrieve' and your custom action) public_allowed_actions = {'retrieve', 'additional_info'} if not user.is_authenticated: if self.action in public_allowed_actions: - return Adventure.objects.retrieve_adventures(user, include_public=True).order_by('-updated_at') + return Adventure.objects.retrieve_adventures( + user, include_public=True + ).order_by('-updated_at') return Adventure.objects.none() include_public = self.action in public_allowed_actions @@ -76,131 +48,273 @@ class AdventureViewSet(viewsets.ModelViewSet): include_shared=True ).order_by('-updated_at') + # ==================== SORTING & FILTERING ==================== + + def apply_sorting(self, queryset): + """Apply sorting and collection filtering to queryset.""" + order_by = self.request.query_params.get('order_by', 'updated_at') + order_direction = self.request.query_params.get('order_direction', 'asc') + include_collections = self.request.query_params.get('include_collections', 'true') + + # Validate parameters + valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at'] + if order_by not in valid_order_by: + order_by = 'name' + + if order_direction not in ['asc', 'desc']: + order_direction = 'asc' + + # Apply sorting logic + queryset = self._apply_ordering(queryset, order_by, order_direction) + + # Filter adventures without collections if requested + if include_collections == 'false': + queryset = queryset.filter(collections__isnull=True) + + return queryset + + def _apply_ordering(self, queryset, order_by, order_direction): + """Apply ordering to queryset based on field type.""" + if order_by == 'date': + queryset = queryset.annotate( + latest_visit=Max('visits__start_date') + ).filter(latest_visit__isnull=False) + ordering = 'latest_visit' + elif order_by == 'name': + queryset = queryset.annotate(lower_name=Lower('name')) + ordering = 'lower_name' + elif order_by == 'rating': + queryset = queryset.filter(rating__isnull=False) + ordering = 'rating' + elif order_by == 'updated_at': + # Special handling for updated_at (reverse default order) + ordering = '-updated_at' if order_direction == 'asc' else 'updated_at' + return queryset.order_by(ordering) + else: + ordering = order_by + + # Apply direction + if order_direction == 'desc': + ordering = f'-{ordering}' + + return queryset.order_by(ordering) + + # ==================== CRUD OPERATIONS ==================== + + @transaction.atomic + def perform_create(self, serializer): + """Create adventure with collection validation and ownership logic.""" + collections = serializer.validated_data.get('collections', []) + + # Validate permissions for all collections + self._validate_collection_permissions(collections) + + # Determine what user to assign as owner + user_to_assign = self.request.user + + if collections: + # Use the current user as owner since ManyToMany allows multiple collection owners + user_to_assign = self.request.user + + serializer.save(user_id=user_to_assign) def perform_update(self, serializer): - adventure = serializer.save() - if adventure.collection: - adventure.is_public = adventure.collection.is_public - adventure.save() + """Update adventure.""" + # Just save the adventure - the signal will handle publicity updates + serializer.save() + + def update(self, request, *args, **kwargs): + """Handle adventure updates with collection permission validation.""" + instance = self.get_object() + partial = kwargs.pop('partial', False) + + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + + # Validate collection permissions if collections are being updated + if 'collections' in serializer.validated_data: + self._validate_collection_update_permissions( + instance, serializer.validated_data['collections'] + ) + else: + # Remove collections from validated_data if not provided + serializer.validated_data.pop('collections', None) + + self.perform_update(serializer) + return Response(serializer.data) + + # ==================== CUSTOM ACTIONS ==================== @action(detail=False, methods=['get']) def filtered(self, request): + """Filter adventures by category types and visit status.""" types = request.query_params.get('types', '').split(',') - is_visited = request.query_params.get('is_visited', 'all') - + + # Handle 'all' types if 'all' in types: - types = Category.objects.filter(user_id=request.user).values_list('name', flat=True) + types = Category.objects.filter( + user_id=request.user + ).values_list('name', flat=True) else: + # Validate provided types if not types or not all( - Category.objects.filter(user_id=request.user, name=type).exists() for type in types + Category.objects.filter(user_id=request.user, name=type_name).exists() + for type_name in types ): - return Response({"error": "Invalid category or no types provided"}, status=400) + return Response( + {"error": "Invalid category or no types provided"}, + status=400 + ) + # Build base queryset queryset = Adventure.objects.filter( category__in=Category.objects.filter(name__in=types, user_id=request.user), user_id=request.user.id ) - is_visited_param = request.query_params.get('is_visited') - if is_visited_param is not None: - # Convert is_visited_param to a boolean - if is_visited_param.lower() == 'true': - is_visited_bool = True - elif is_visited_param.lower() == 'false': - is_visited_bool = False - else: - is_visited_bool = None - - # Filter logic: "visited" means at least one visit with start_date <= today - now = timezone.now().date() - if is_visited_bool is True: - queryset = queryset.filter(visits__start_date__lte=now).distinct() - elif is_visited_bool is False: - queryset = queryset.exclude(visits__start_date__lte=now).distinct() - + # Apply visit status filtering + queryset = self._apply_visit_filtering(queryset, request) queryset = self.apply_sorting(queryset) + return self.paginate_and_respond(queryset, request) @action(detail=False, methods=['get']) def all(self, request): + """Get all adventures (public and owned) with optional collection filtering.""" if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) include_collections = request.query_params.get('include_collections', 'false') == 'true' - queryset = Adventure.objects.filter( - Q(is_public=True) | Q(user_id=request.user.id), - collection=None if not include_collections else Q() - ) + + # Build queryset with collection filtering + base_filter = Q(is_public=True) | Q(user_id=request.user.id) + + if include_collections: + queryset = Adventure.objects.filter(base_filter) + else: + queryset = Adventure.objects.filter(base_filter, collections__isnull=True) queryset = self.apply_sorting(queryset) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) - def update(self, request, *args, **kwargs): - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - - new_collection = serializer.validated_data.get('collection') - if new_collection and new_collection!=instance.collection: - if new_collection.user_id != request.user or instance.user_id != request.user: - raise PermissionDenied("You do not have permission to use this collection.") - elif new_collection is None and instance.collection and instance.collection.user_id != request.user: - raise PermissionDenied("You cannot remove the collection as you are not the owner.") - - self.perform_update(serializer) - return Response(serializer.data) - - @transaction.atomic - def perform_create(self, serializer): - collection = serializer.validated_data.get('collection') - - if collection and not (collection.user_id == self.request.user or collection.shared_with.filter(id=self.request.user.id).exists()): - raise PermissionDenied("You do not have permission to use this collection.") - elif collection: - serializer.save(user_id=collection.user_id, is_public=collection.is_public) - return - - serializer.save(user_id=self.request.user, is_public=collection.is_public if collection else False) - - def paginate_and_respond(self, queryset, request): - paginator = self.pagination_class() - page = paginator.paginate_queryset(queryset, request) - if page is not None: - 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) - @action(detail=True, methods=['get'], url_path='additional-info') def additional_info(self, request, pk=None): + """Get adventure with additional sunrise/sunset information.""" adventure = self.get_object() - user = request.user - # Allow if public - if not adventure.is_public: - # Only allow owner or shared collection members - if not user.is_authenticated or adventure.user_id != user: - if not (adventure.collection and adventure.collection.shared_with.filter(uuid=user.uuid).exists()): - return Response({"error": "User does not have permission to access this adventure"}, - status=status.HTTP_403_FORBIDDEN) + # Validate access permissions + if not self._has_adventure_access(adventure, user): + return Response( + {"error": "User does not have permission to access this adventure"}, + status=status.HTTP_403_FORBIDDEN + ) + # Get base adventure data serializer = self.get_serializer(adventure) response_data = serializer.data - visits = response_data.get('visits', []) + # Add sunrise/sunset data + response_data['sun_times'] = self._get_sun_times(adventure, response_data.get('visits', [])) + + return Response(response_data) + + # ==================== HELPER METHODS ==================== + + def _validate_collection_permissions(self, collections): + """Validate user has permission to use all provided collections. Only the owner or shared users can use collections.""" + for collection in collections: + if not (collection.user_id == self.request.user or + collection.shared_with.filter(uuid=self.request.user.uuid).exists()): + raise PermissionDenied( + f"You do not have permission to use collection '{collection.name}'." + ) + + def _validate_collection_update_permissions(self, instance, new_collections): + """Validate permissions for collection updates (add/remove).""" + # Check permissions for new collections being added + for collection in new_collections: + if (collection.user_id != self.request.user and + not collection.shared_with.filter(uuid=self.request.user.uuid).exists()): + raise PermissionDenied( + f"You do not have permission to use collection '{collection.name}'." + ) + + # Check permissions for collections being removed + current_collections = set(instance.collections.all()) + new_collections_set = set(new_collections) + collections_to_remove = current_collections - new_collections_set + + for collection in collections_to_remove: + if (collection.user_id != self.request.user and + not collection.shared_with.filter(uuid=self.request.user.uuid).exists()): + raise PermissionDenied( + f"You cannot remove the adventure from collection '{collection.name}' " + f"as you don't have permission." + ) + + def _apply_visit_filtering(self, queryset, request): + """Apply visit status filtering to queryset.""" + is_visited_param = request.query_params.get('is_visited') + if is_visited_param is None: + return queryset + + # Convert parameter to boolean + if is_visited_param.lower() == 'true': + is_visited_bool = True + elif is_visited_param.lower() == 'false': + is_visited_bool = False + else: + return queryset + + # Apply visit filtering + now = timezone.now().date() + if is_visited_bool: + queryset = queryset.filter(visits__start_date__lte=now).distinct() + else: + queryset = queryset.exclude(visits__start_date__lte=now).distinct() + + return queryset + + def _has_adventure_access(self, adventure, user): + """Check if user has access to adventure.""" + # Allow if public + if adventure.is_public: + return True + + # Check ownership + if user.is_authenticated and adventure.user_id == user: + return True + + # Check shared collection access + if user.is_authenticated: + for collection in adventure.collections.all(): + if collection.shared_with.filter(uuid=user.uuid).exists(): + return True + + return False + + def _get_sun_times(self, adventure, visits): + """Get sunrise/sunset times for adventure visits.""" sun_times = [] for visit in visits: date = visit.get('start_date') - if date and adventure.longitude and adventure.latitude: - api_url = f'https://api.sunrisesunset.io/json?lat={adventure.latitude}&lng={adventure.longitude}&date={date}' - res = requests.get(api_url) - if res.status_code == 200: - data = res.json() + if not (date and adventure.longitude and adventure.latitude): + continue + + api_url = ( + f'https://api.sunrisesunset.io/json?' + f'lat={adventure.latitude}&lng={adventure.longitude}&date={date}' + ) + + try: + response = requests.get(api_url) + if response.status_code == 200: + data = response.json() results = data.get('results', {}) + if results.get('sunrise') and results.get('sunset'): sun_times.append({ "date": date, @@ -208,6 +322,20 @@ class AdventureViewSet(viewsets.ModelViewSet): "sunrise": results.get('sunrise'), "sunset": results.get('sunset') }) + except requests.RequestException: + # Skip this visit if API call fails + continue - response_data['sun_times'] = sun_times - return Response(response_data) \ No newline at end of file + return sun_times + + def paginate_and_respond(self, queryset, request): + """Paginate queryset and return response.""" + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request) + + if page is not None: + 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 diff --git a/backend/server/adventures/views/attachment_view.py b/backend/server/adventures/views/attachment_view.py index 0292b16..2ca4770 100644 --- a/backend/server/adventures/views/attachment_view.py +++ b/backend/server/adventures/views/attachment_view.py @@ -26,10 +26,16 @@ class AttachmentViewSet(viewsets.ModelViewSet): return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) if adventure.user_id != request.user: - # Check if the adventure has a collection - if adventure.collection: - # Check if the user is in the collection's shared_with list - if not adventure.collection.shared_with.filter(id=request.user.id).exists(): + # Check if the adventure has any collections + if adventure.collections.exists(): + # Check if the user is in the shared_with list of any of the adventure's collections + user_has_access = False + for collection in adventure.collections.all(): + if collection.shared_with.filter(id=request.user.id).exists(): + user_has_access = True + break + + if not user_has_access: return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN) else: return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) @@ -37,4 +43,14 @@ class AttachmentViewSet(viewsets.ModelViewSet): return super().create(request, *args, **kwargs) def perform_create(self, serializer): - serializer.save(user_id=self.request.user) \ No newline at end of file + adventure_id = self.request.data.get('adventure') + adventure = Adventure.objects.get(id=adventure_id) + + # If the adventure belongs to collections, set the owner to the collection owner + if adventure.collections.exists(): + # Get the first collection's owner (assuming all collections have the same owner) + collection = adventure.collections.first() + serializer.save(user_id=collection.user_id) + else: + # Otherwise, set the owner to the request user + serializer.save(user_id=self.request.user) \ No newline at end of file diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index 1765342..fca9986 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -4,7 +4,7 @@ from django.db import transaction from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response -from adventures.models import Collection, Adventure, Transportation, Note +from adventures.models import Collection, Adventure, Transportation, Note, Checklist from adventures.permissions import CollectionShared from adventures.serializers import CollectionSerializer from users.models import CustomUser as User @@ -106,23 +106,40 @@ class CollectionViewSet(viewsets.ModelViewSet): if 'is_public' in serializer.validated_data: new_public_status = serializer.validated_data['is_public'] - # if is_publuc has changed and the user is not the owner of the collection return an error + # if is_public has changed and the user is not the owner of the collection return an error if new_public_status != instance.is_public and instance.user_id != request.user: print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user_id}") return Response({"error": "User does not own the collection"}, status=400) - # Update associated adventures to match the collection's is_public status - Adventure.objects.filter(collection=instance).update(is_public=new_public_status) + # Get all adventures in this collection + adventures_in_collection = Adventure.objects.filter(collections=instance) + + if new_public_status: + # If collection becomes public, make all adventures public + adventures_in_collection.update(is_public=True) + else: + # If collection becomes private, check each adventure + # Only set an adventure to private if ALL of its collections are private + # Collect adventures that do NOT belong to any other public collection (excluding the current one) + adventure_ids_to_set_private = [] - # do the same for transportations + for adventure in adventures_in_collection: + has_public_collection = adventure.collections.filter(is_public=True).exclude(id=instance.id).exists() + if not has_public_collection: + adventure_ids_to_set_private.append(adventure.id) + + # Bulk update those adventures + Adventure.objects.filter(id__in=adventure_ids_to_set_private).update(is_public=False) + + # Update transportations, notes, and checklists related to this collection + # These still use direct ForeignKey relationships Transportation.objects.filter(collection=instance).update(is_public=new_public_status) - - # do the same for notes Note.objects.filter(collection=instance).update(is_public=new_public_status) + Checklist.objects.filter(collection=instance).update(is_public=new_public_status) # Log the action (optional) action = "public" if new_public_status else "private" - print(f"Collection {instance.id} and its adventures were set to {action}") + print(f"Collection {instance.id} and its related objects were set to {action}") self.perform_update(serializer) diff --git a/documentation/docs/install/getting_started.md b/documentation/docs/install/getting_started.md index 87c5bee..ff8d4b5 100644 --- a/documentation/docs/install/getting_started.md +++ b/documentation/docs/install/getting_started.md @@ -7,7 +7,7 @@ AdventureLog can be installed in a variety of ways, depending on your platform o ::: tip Quick Start Script **The fastest way to get started:** [Install AdventureLog with a single command →](quick_start.md) -Perfect for and Docker beginners. +Perfect for Docker beginners. ::: ## 🐳 Popular Installation Methods diff --git a/frontend/src/lib/components/AdventureCard.svelte b/frontend/src/lib/components/AdventureCard.svelte index dbdf5e4..c1e3be3 100644 --- a/frontend/src/lib/components/AdventureCard.svelte +++ b/frontend/src/lib/components/AdventureCard.svelte @@ -88,38 +88,61 @@ } } - async function removeFromCollection() { + async function linkCollection(event: CustomEvent) { + let collectionId = event.detail; + // Create a copy to avoid modifying the original directly + const updatedCollections = adventure.collections ? [...adventure.collections] : []; + + // Add the new collection if not already present + if (!updatedCollections.some((c) => String(c) === String(collectionId))) { + updatedCollections.push(collectionId); + } + let res = await fetch(`/api/adventures/${adventure.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ collection: null }) + body: JSON.stringify({ collections: updatedCollections }) }); + if (res.ok) { - addToast('info', `${$t('adventures.collection_remove_success')}`); - dispatch('delete', adventure.id); + // Only update the adventure.collections after server confirms success + adventure.collections = updatedCollections; + addToast('info', `${$t('adventures.collection_link_success')}`); } else { - addToast('error', `${$t('adventures.collection_remove_error')}`); + addToast('error', `${$t('adventures.collection_link_error')}`); } } - async function linkCollection(event: CustomEvent) { + async function removeFromCollection(event: CustomEvent) { let collectionId = event.detail; - let res = await fetch(`/api/adventures/${adventure.id}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ collection: collectionId }) - }); - if (res.ok) { - console.log('Adventure linked to collection'); - addToast('info', `${$t('adventures.collection_link_success')}`); - isCollectionModalOpen = false; - dispatch('delete', adventure.id); - } else { - addToast('error', `${$t('adventures.collection_link_error')}`); + if (!collectionId) { + addToast('error', `${$t('adventures.collection_remove_error')}`); + return; + } + + // Create a copy to avoid modifying the original directly + if (adventure.collections) { + const updatedCollections = adventure.collections.filter( + (c) => String(c) !== String(collectionId) + ); + + let res = await fetch(`/api/adventures/${adventure.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ collections: updatedCollections }) + }); + + if (res.ok) { + // Only update adventure.collections after server confirms success + adventure.collections = updatedCollections; + addToast('info', `${$t('adventures.collection_remove_success')}`); + } else { + addToast('error', `${$t('adventures.collection_remove_error')}`); + } } } @@ -133,7 +156,12 @@ {#if isCollectionModalOpen} - (isCollectionModalOpen = false)} /> + linkCollection(e)} + on:unlink={(e) => removeFromCollection(e)} + on:close={() => (isCollectionModalOpen = false)} + linkedCollectionList={adventure.collections} + /> {/if} {#if isWarningModalOpen} @@ -269,23 +297,14 @@ - {#if adventure.collection && user?.uuid == adventure.user_id} -
  • - -
  • - {/if} - - {#if !adventure.collection} + {#if user?.uuid == adventure.user_id}
  • {/if} diff --git a/frontend/src/lib/components/AdventureLink.svelte b/frontend/src/lib/components/AdventureLink.svelte index 9d7d094..74cfd13 100644 --- a/frontend/src/lib/components/AdventureLink.svelte +++ b/frontend/src/lib/components/AdventureLink.svelte @@ -12,21 +12,31 @@ let isLoading: boolean = true; export let user: User | null; + export let collectionId: string; onMount(async () => { modal = document.getElementById('my_modal_1') as HTMLDialogElement; if (modal) { modal.showModal(); } - let res = await fetch(`/api/adventures/all/?include_collections=false`, { + let res = await fetch(`/api/adventures/all/?include_collections=true`, { method: 'GET' }); const newAdventures = await res.json(); - if (res.ok && adventures) { + // Filter out adventures that are already linked to the collections + // basically for each adventure, check if collections array contains the id of the current collection + if (collectionId) { + adventures = newAdventures.filter((adventure: Adventure) => { + // adventure.collections is an array of ids, collectionId is a single id + return !(adventure.collections ?? []).includes(collectionId); + }); + } else { adventures = newAdventures; } + + // No need to reassign adventures to newAdventures here, keep the filtered result isLoading = false; }); diff --git a/frontend/src/lib/components/CollectionCard.svelte b/frontend/src/lib/components/CollectionCard.svelte index 0ca06c9..e103adb 100644 --- a/frontend/src/lib/components/CollectionCard.svelte +++ b/frontend/src/lib/components/CollectionCard.svelte @@ -14,6 +14,7 @@ import { t } from 'svelte-i18n'; import Plus from '~icons/mdi/plus'; + import Minus from '~icons/mdi/minus'; import DotsHorizontal from '~icons/mdi/dots-horizontal'; import TrashCan from '~icons/mdi/trashcan'; import DeleteWarning from './DeleteWarning.svelte'; @@ -23,6 +24,7 @@ const dispatch = createEventDispatcher(); export let type: String | undefined | null; + export let linkedCollectionList: string[] | null = null; let isShareModalOpen: boolean = false; function editAdventure() { @@ -138,10 +140,25 @@
    {#if type == 'link'} - + {#if linkedCollectionList && linkedCollectionList + .map(String) + .includes(String(collection.id))} + + {:else} + + {/if} {:else}