1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 12:59:36 +02:00

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.
This commit is contained in:
Sean Morley 2025-06-12 15:54:01 -04:00
parent d9070e68bb
commit 3f9a6767bd
22 changed files with 686 additions and 289 deletions

View file

@ -1,6 +1,8 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
class AdventuresConfig(AppConfig): class AdventuresConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'adventures' name = 'adventures'
def ready(self):
import adventures.signals # Import signals when the app is ready

View file

@ -3,20 +3,15 @@ from django.db.models import Q
class AdventureManager(models.Manager): class AdventureManager(models.Manager):
def retrieve_adventures(self, user, include_owned=False, include_shared=False, include_public=False): def retrieve_adventures(self, user, include_owned=False, include_shared=False, include_public=False):
# Initialize the query with an empty Q object
query = Q() query = Q()
# Add owned adventures to the query if included
if include_owned: 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: 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: if include_public:
query |= Q(is_public=True) query |= Q(is_public=True)
# Perform the query with the final Q object and remove duplicates
return self.filter(query).distinct() return self.filter(query).distinct()

View file

@ -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',
),
]

View file

@ -15,6 +15,7 @@ from django.core.exceptions import ValidationError
from django.utils import timezone from django.utils import timezone
def background_geocode_and_assign(adventure_id: str): def background_geocode_and_assign(adventure_id: str):
print(f"[Adventure Geocode Thread] Starting geocode for adventure {adventure_id}")
try: try:
adventure = Adventure.objects.get(id=adventure_id) adventure = Adventure.objects.get(id=adventure_id)
if not (adventure.latitude and adventure.longitude): 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) 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) 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
objects = AdventureManager() 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): def is_visited_status(self):
current_date = timezone.now().date() current_date = timezone.now().date()
for visit in self.visits.all(): for visit in self.visits.all():
@ -601,17 +596,33 @@ class Adventure(models.Model):
return True return True
return False return False
def clean(self): def clean(self, skip_shared_validation=False):
if self.collection: """
if self.collection.is_public and not self.is_public: Validate model constraints.
raise ValidationError('Adventures associated with a public collection must be public. Collection: ' + self.trip.name + ' Adventure: ' + self.name) skip_shared_validation: Skip validation when called by shared users
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) # 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.category:
if self.user_id != self.category.user_id: 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: if force_insert and force_update:
raise ValueError("Cannot force both insert and updating in model saving.") 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) 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 # ⛔ Skip threading if called from geocode background thread
if _skip_geocode: if _skip_geocode:
return result return result
@ -636,7 +656,6 @@ class Adventure(models.Model):
return result return result
def __str__(self): def __str__(self):
return self.name return self.name
@ -656,13 +675,13 @@ class Collection(models.Model):
shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True) shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True)
link = models.URLField(blank=True, null=True, max_length=2083) link = models.URLField(blank=True, null=True, max_length=2083)
# if connected adventures are private and collection is public, raise an error # if connected adventures are private and collection is public, raise an error
def clean(self): def clean(self):
if self.is_public and self.pk: # Only check if the instance has a primary key 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: 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): def __str__(self):
return self.name return self.name

View file

@ -2,78 +2,99 @@ from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission): 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): 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: if request.method in permissions.SAFE_METHODS:
return True return True
# obj.user_id is FK to User, compare with request.user
# Write permissions are only allowed to the owner of the object.
return obj.user_id == request.user return obj.user_id == request.user
class IsPublicReadOnly(permissions.BasePermission): class IsPublicReadOnly(permissions.BasePermission):
""" """
Custom permission to only allow read-only access to public objects, Read-only if public or owner, write only for owner.
and write access to the owner of the object.
""" """
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
# Read permissions are allowed if the object is public
if request.method in permissions.SAFE_METHODS: 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 == request.user
# Write permissions are only allowed to the owner of the object
return obj.user_id == request.user return obj.user_id == request.user
class CollectionShared(permissions.BasePermission): class CollectionShared(permissions.BasePermission):
""" """
Custom permission to only allow read-only access to public objects, Allow full access if user is in shared_with of collection(s) or owner,
and write access to the owner of the object. read-only if public or shared_with,
write only if owner or shared_with.
""" """
def has_object_permission(self, request, view, obj): 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 # Check if user is in shared_with of any collections related to the obj
if obj.shared_with and obj.shared_with.filter(id=request.user.id).exists(): # If obj is a Collection itself:
return True if hasattr(obj, 'shared_with'):
if obj.shared_with.filter(id=user.id).exists():
return True
# Write permissions are allowed if the object is shared with the user # If obj is an Adventure (has collections M2M)
if request.method not in permissions.SAFE_METHODS and obj.shared_with.filter(id=request.user.id).exists(): if hasattr(obj, 'collections'):
return True # 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 permissions are allowed if the object is public # Read permission if public or owner
if request.method in permissions.SAFE_METHODS: 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): class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
""" """
Custom permission to allow: Full access for owners and users shared via collections,
- Full access for shared users read-only for others if public.
- Full access for owners
- Read-only access for others on safe methods
""" """
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
user = request.user
if not user or not user.is_authenticated:
return request.method in permissions.SAFE_METHODS and obj.is_public
# Allow GET only for a public object # If safe method (read), allow if:
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
# Always allow GET, HEAD, or OPTIONS requests (safe methods)
if request.method in permissions.SAFE_METHODS: 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 return True
# Allow all actions for the owner return False
return obj.user_id == request.user

View file

@ -101,12 +101,17 @@ class AdventureSerializer(CustomModelSerializer):
country = CountrySerializer(read_only=True) country = CountrySerializer(read_only=True)
region = RegionSerializer(read_only=True) region = RegionSerializer(read_only=True)
city = CitySerializer(read_only=True) city = CitySerializer(read_only=True)
collections = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Collection.objects.all(),
required=False
)
class Meta: class Meta:
model = Adventure model = Adventure
fields = [ fields = [
'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location', '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' 'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user', 'city', 'country', 'region'
] ]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited', 'user'] 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 # Filter out None values from the serialized data
return [image for image in serializer.data if image is not None] 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): def validate_category(self, category_data):
if isinstance(category_data, Category): if isinstance(category_data, Category):
return category_data return category_data
@ -137,7 +155,7 @@ class AdventureSerializer(CustomModelSerializer):
if isinstance(category_data, dict): if isinstance(category_data, dict):
name = category_data.get('name', '').lower() name = category_data.get('name', '').lower()
display_name = category_data.get('display_name', name) display_name = category_data.get('display_name', name)
icon = category_data.get('icon', '<EFBFBD>') icon = category_data.get('icon', '🌍')
else: else:
name = category_data.name.lower() name = category_data.name.lower()
display_name = category_data.display_name display_name = category_data.display_name
@ -163,15 +181,24 @@ class AdventureSerializer(CustomModelSerializer):
def create(self, validated_data): def create(self, validated_data):
visits_data = validated_data.pop('visits', None) visits_data = validated_data.pop('visits', None)
category_data = validated_data.pop('category', None) category_data = validated_data.pop('category', None)
collections_data = validated_data.pop('collections', [])
print(category_data) print(category_data)
adventure = Adventure.objects.create(**validated_data) adventure = Adventure.objects.create(**validated_data)
# Handle visits
for visit_data in visits_data: for visit_data in visits_data:
Visit.objects.create(adventure=adventure, **visit_data) Visit.objects.create(adventure=adventure, **visit_data)
# Handle category
if category_data: if category_data:
category = self.get_or_create_category(category_data) category = self.get_or_create_category(category_data)
adventure.category = category adventure.category = category
# Handle collections - set after adventure is saved
if collections_data:
adventure.collections.set(collections_data)
adventure.save() adventure.save()
return adventure return adventure
@ -181,13 +208,27 @@ class AdventureSerializer(CustomModelSerializer):
visits_data = validated_data.pop('visits', []) visits_data = validated_data.pop('visits', [])
category_data = validated_data.pop('category', None) 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(): for attr, value in validated_data.items():
setattr(instance, attr, value) 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) category = self.get_or_create_category(category_data)
instance.category = category 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: if has_visits:
current_visits = instance.visits.all() current_visits = instance.visits.all()
current_visit_ids = set(current_visits.values_list('id', flat=True)) current_visit_ids = set(current_visits.values_list('id', flat=True))
@ -352,7 +393,7 @@ class ChecklistSerializer(CustomModelSerializer):
return data return data
class CollectionSerializer(CustomModelSerializer): 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') transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set')
notes = NoteSerializer(many=True, read_only=True, source='note_set') notes = NoteSerializer(many=True, read_only=True, source='note_set')
checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set') checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set')

View file

@ -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'])

View file

@ -15,9 +15,12 @@ def checkFilePermission(fileId, user, mediaType):
return True return True
elif adventure.user_id == user: elif adventure.user_id == user:
return True return True
elif adventure.collection: elif adventure.collections.exists():
if adventure.collection.shared_with.filter(id=user.id).exists(): # Check if the user is in any collection's shared_with list
return True for collection in adventure.collections.all():
if collection.shared_with.filter(id=user.id).exists():
return True
return False
else: else:
return False return False
except AdventureImage.DoesNotExist: except AdventureImage.DoesNotExist:
@ -27,14 +30,18 @@ def checkFilePermission(fileId, user, mediaType):
# Construct the full relative path to match the database field # Construct the full relative path to match the database field
attachment_path = f"attachments/{fileId}" attachment_path = f"attachments/{fileId}"
# Fetch the Attachment object # Fetch the Attachment object
attachment = Attachment.objects.get(file=attachment_path).adventure attachment = Attachment.objects.get(file=attachment_path)
if attachment.is_public: adventure = attachment.adventure
if adventure.is_public:
return True return True
elif attachment.user_id == user: elif adventure.user_id == user:
return True return True
elif attachment.collection: elif adventure.collections.exists():
if attachment.collection.shared_with.filter(id=user.id).exists(): # Check if the user is in any collection's shared_with list
return True for collection in adventure.collections.all():
if collection.shared_with.filter(id=user.id).exists():
return True
return False
else: else:
return False return False
except Attachment.DoesNotExist: except Attachment.DoesNotExist:

View file

@ -51,10 +51,16 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
if adventure.user_id != request.user: if adventure.user_id != request.user:
# Check if the adventure has a collection # Check if the adventure has any collections
if adventure.collection: if adventure.collections.exists():
# Check if the user is in the collection's shared_with list # Check if the user is in the shared_with list of any of the adventure's collections
if not adventure.collection.shared_with.filter(id=request.user.id).exists(): 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) return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
else: else:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) 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( queryset = AdventureImage.objects.filter(
Q(adventure__id=adventure_uuid) & ( Q(adventure__id=adventure_uuid) & (
Q(adventure__user_id=request.user) | # User owns the adventure 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() ).distinct()
@ -200,7 +206,7 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
# Updated to include images from adventures the user owns OR has shared access to # Updated to include images from adventures the user owns OR has shared access to
return AdventureImage.objects.filter( return AdventureImage.objects.filter(
Q(adventure__user_id=self.request.user) | # User owns the adventure 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() ).distinct()
def perform_create(self, serializer): def perform_create(self, serializer):

View file

@ -3,69 +3,41 @@ from django.db import transaction
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import Q, Max from django.db.models import Q, Max
from django.db.models.functions import Lower 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.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
import requests
from adventures.models import Adventure, Category, Transportation, Lodging from adventures.models import Adventure, Category, Transportation, Lodging
from adventures.permissions import IsOwnerOrSharedWithFullAccess from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.serializers import AdventureSerializer, TransportationSerializer, LodgingSerializer from adventures.serializers import AdventureSerializer, TransportationSerializer, LodgingSerializer
from adventures.utils import pagination from adventures.utils import pagination
import requests
class AdventureViewSet(viewsets.ModelViewSet): class AdventureViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing Adventure objects with support for filtering, sorting,
and sharing functionality.
"""
serializer_class = AdventureSerializer serializer_class = AdventureSerializer
permission_classes = [IsOwnerOrSharedWithFullAccess] permission_classes = [IsOwnerOrSharedWithFullAccess]
pagination_class = pagination.StandardResultsSetPagination pagination_class = pagination.StandardResultsSetPagination
def apply_sorting(self, queryset): # ==================== QUERYSET & PERMISSIONS ====================
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)
def get_queryset(self): def get_queryset(self):
""" """
Returns the queryset for the AdventureViewSet. Unauthenticated users can only Returns queryset based on user authentication and action type.
retrieve public adventures, while authenticated users can access their own, Public actions allow unauthenticated access to public adventures.
shared, and public adventures depending on the action.
""" """
user = self.request.user user = self.request.user
# Actions that allow public access (include 'retrieve' and your custom action)
public_allowed_actions = {'retrieve', 'additional_info'} public_allowed_actions = {'retrieve', 'additional_info'}
if not user.is_authenticated: if not user.is_authenticated:
if self.action in public_allowed_actions: 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() return Adventure.objects.none()
include_public = self.action in public_allowed_actions include_public = self.action in public_allowed_actions
@ -76,131 +48,273 @@ class AdventureViewSet(viewsets.ModelViewSet):
include_shared=True include_shared=True
).order_by('-updated_at') ).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): def perform_update(self, serializer):
adventure = serializer.save() """Update adventure."""
if adventure.collection: # Just save the adventure - the signal will handle publicity updates
adventure.is_public = adventure.collection.is_public serializer.save()
adventure.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']) @action(detail=False, methods=['get'])
def filtered(self, request): def filtered(self, request):
"""Filter adventures by category types and visit status."""
types = request.query_params.get('types', '').split(',') types = request.query_params.get('types', '').split(',')
is_visited = request.query_params.get('is_visited', 'all')
# Handle 'all' types
if 'all' in 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: else:
# Validate provided types
if not types or not all( 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( queryset = Adventure.objects.filter(
category__in=Category.objects.filter(name__in=types, user_id=request.user), category__in=Category.objects.filter(name__in=types, user_id=request.user),
user_id=request.user.id user_id=request.user.id
) )
is_visited_param = request.query_params.get('is_visited') # Apply visit status filtering
if is_visited_param is not None: queryset = self._apply_visit_filtering(queryset, request)
# 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()
queryset = self.apply_sorting(queryset) queryset = self.apply_sorting(queryset)
return self.paginate_and_respond(queryset, request) return self.paginate_and_respond(queryset, request)
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])
def all(self, request): def all(self, request):
"""Get all adventures (public and owned) with optional collection filtering."""
if not request.user.is_authenticated: if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400) return Response({"error": "User is not authenticated"}, status=400)
include_collections = request.query_params.get('include_collections', 'false') == 'true' include_collections = request.query_params.get('include_collections', 'false') == 'true'
queryset = Adventure.objects.filter(
Q(is_public=True) | Q(user_id=request.user.id), # Build queryset with collection filtering
collection=None if not include_collections else Q() 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) queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) 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') @action(detail=True, methods=['get'], url_path='additional-info')
def additional_info(self, request, pk=None): def additional_info(self, request, pk=None):
"""Get adventure with additional sunrise/sunset information."""
adventure = self.get_object() adventure = self.get_object()
user = request.user user = request.user
# Allow if public # Validate access permissions
if not adventure.is_public: if not self._has_adventure_access(adventure, user):
# Only allow owner or shared collection members return Response(
if not user.is_authenticated or adventure.user_id != user: {"error": "User does not have permission to access this adventure"},
if not (adventure.collection and adventure.collection.shared_with.filter(uuid=user.uuid).exists()): status=status.HTTP_403_FORBIDDEN
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) serializer = self.get_serializer(adventure)
response_data = serializer.data 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 = [] sun_times = []
for visit in visits: for visit in visits:
date = visit.get('start_date') date = visit.get('start_date')
if date and adventure.longitude and adventure.latitude: if not (date and adventure.longitude and adventure.latitude):
api_url = f'https://api.sunrisesunset.io/json?lat={adventure.latitude}&lng={adventure.longitude}&date={date}' continue
res = requests.get(api_url)
if res.status_code == 200: api_url = (
data = res.json() 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', {}) results = data.get('results', {})
if results.get('sunrise') and results.get('sunset'): if results.get('sunrise') and results.get('sunset'):
sun_times.append({ sun_times.append({
"date": date, "date": date,
@ -208,6 +322,20 @@ class AdventureViewSet(viewsets.ModelViewSet):
"sunrise": results.get('sunrise'), "sunrise": results.get('sunrise'),
"sunset": results.get('sunset') "sunset": results.get('sunset')
}) })
except requests.RequestException:
# Skip this visit if API call fails
continue
response_data['sun_times'] = sun_times return sun_times
return Response(response_data)
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)

View file

@ -26,10 +26,16 @@ class AttachmentViewSet(viewsets.ModelViewSet):
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
if adventure.user_id != request.user: if adventure.user_id != request.user:
# Check if the adventure has a collection # Check if the adventure has any collections
if adventure.collection: if adventure.collections.exists():
# Check if the user is in the collection's shared_with list # Check if the user is in the shared_with list of any of the adventure's collections
if not adventure.collection.shared_with.filter(id=request.user.id).exists(): 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) return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
else: else:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) 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) return super().create(request, *args, **kwargs)
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(user_id=self.request.user) 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)

View file

@ -4,7 +4,7 @@ from django.db import transaction
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response 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.permissions import CollectionShared
from adventures.serializers import CollectionSerializer from adventures.serializers import CollectionSerializer
from users.models import CustomUser as User from users.models import CustomUser as User
@ -106,23 +106,40 @@ class CollectionViewSet(viewsets.ModelViewSet):
if 'is_public' in serializer.validated_data: if 'is_public' in serializer.validated_data:
new_public_status = serializer.validated_data['is_public'] 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: 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}") 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) return Response({"error": "User does not own the collection"}, status=400)
# Update associated adventures to match the collection's is_public status # Get all adventures in this collection
Adventure.objects.filter(collection=instance).update(is_public=new_public_status) adventures_in_collection = Adventure.objects.filter(collections=instance)
# do the same for transportations 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 = []
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) 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) 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) # Log the action (optional)
action = "public" if new_public_status else "private" 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) self.perform_update(serializer)

View file

@ -7,7 +7,7 @@ AdventureLog can be installed in a variety of ways, depending on your platform o
::: tip Quick Start Script ::: tip Quick Start Script
**The fastest way to get started:** **The fastest way to get started:**
[Install AdventureLog with a single command →](quick_start.md) [Install AdventureLog with a single command →](quick_start.md)
Perfect for and Docker beginners. Perfect for Docker beginners.
::: :::
## 🐳 Popular Installation Methods ## 🐳 Popular Installation Methods

View file

@ -88,38 +88,61 @@
} }
} }
async function removeFromCollection() { async function linkCollection(event: CustomEvent<string>) {
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}`, { let res = await fetch(`/api/adventures/${adventure.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ collection: null }) body: JSON.stringify({ collections: updatedCollections })
}); });
if (res.ok) { if (res.ok) {
addToast('info', `${$t('adventures.collection_remove_success')}`); // Only update the adventure.collections after server confirms success
dispatch('delete', adventure.id); adventure.collections = updatedCollections;
addToast('info', `${$t('adventures.collection_link_success')}`);
} else { } else {
addToast('error', `${$t('adventures.collection_remove_error')}`); addToast('error', `${$t('adventures.collection_link_error')}`);
} }
} }
async function linkCollection(event: CustomEvent<number>) { async function removeFromCollection(event: CustomEvent<string>) {
let collectionId = event.detail; let collectionId = event.detail;
let res = await fetch(`/api/adventures/${adventure.id}`, { if (!collectionId) {
method: 'PATCH', addToast('error', `${$t('adventures.collection_remove_error')}`);
headers: { return;
'Content-Type': 'application/json' }
},
body: JSON.stringify({ collection: collectionId }) // Create a copy to avoid modifying the original directly
}); if (adventure.collections) {
if (res.ok) { const updatedCollections = adventure.collections.filter(
console.log('Adventure linked to collection'); (c) => String(c) !== String(collectionId)
addToast('info', `${$t('adventures.collection_link_success')}`); );
isCollectionModalOpen = false;
dispatch('delete', adventure.id); let res = await fetch(`/api/adventures/${adventure.id}`, {
} else { method: 'PATCH',
addToast('error', `${$t('adventures.collection_link_error')}`); 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 @@
</script> </script>
{#if isCollectionModalOpen} {#if isCollectionModalOpen}
<CollectionLink on:link={linkCollection} on:close={() => (isCollectionModalOpen = false)} /> <CollectionLink
on:link={(e) => linkCollection(e)}
on:unlink={(e) => removeFromCollection(e)}
on:close={() => (isCollectionModalOpen = false)}
linkedCollectionList={adventure.collections}
/>
{/if} {/if}
{#if isWarningModalOpen} {#if isWarningModalOpen}
@ -269,23 +297,14 @@
</button> </button>
</li> </li>
{#if adventure.collection && user?.uuid == adventure.user_id} {#if user?.uuid == adventure.user_id}
<li>
<button on:click={removeFromCollection} class="flex items-center gap-2">
<LinkVariantRemove class="w-4 h-4" />
{$t('adventures.remove_from_collection')}
</button>
</li>
{/if}
{#if !adventure.collection}
<li> <li>
<button <button
on:click={() => (isCollectionModalOpen = true)} on:click={() => (isCollectionModalOpen = true)}
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<Plus class="w-4 h-4" /> <Plus class="w-4 h-4" />
{$t('adventures.add_to_collection')} {$t('collection.manage_collections')}
</button> </button>
</li> </li>
{/if} {/if}

View file

@ -12,21 +12,31 @@
let isLoading: boolean = true; let isLoading: boolean = true;
export let user: User | null; export let user: User | null;
export let collectionId: string;
onMount(async () => { onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) { if (modal) {
modal.showModal(); modal.showModal();
} }
let res = await fetch(`/api/adventures/all/?include_collections=false`, { let res = await fetch(`/api/adventures/all/?include_collections=true`, {
method: 'GET' method: 'GET'
}); });
const newAdventures = await res.json(); 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; adventures = newAdventures;
} }
// No need to reassign adventures to newAdventures here, keep the filtered result
isLoading = false; isLoading = false;
}); });

View file

@ -14,6 +14,7 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import Plus from '~icons/mdi/plus'; import Plus from '~icons/mdi/plus';
import Minus from '~icons/mdi/minus';
import DotsHorizontal from '~icons/mdi/dots-horizontal'; import DotsHorizontal from '~icons/mdi/dots-horizontal';
import TrashCan from '~icons/mdi/trashcan'; import TrashCan from '~icons/mdi/trashcan';
import DeleteWarning from './DeleteWarning.svelte'; import DeleteWarning from './DeleteWarning.svelte';
@ -23,6 +24,7 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let type: String | undefined | null; export let type: String | undefined | null;
export let linkedCollectionList: string[] | null = null;
let isShareModalOpen: boolean = false; let isShareModalOpen: boolean = false;
function editAdventure() { function editAdventure() {
@ -138,10 +140,25 @@
<!-- Actions --> <!-- Actions -->
<div class="pt-4 border-t border-base-300"> <div class="pt-4 border-t border-base-300">
{#if type == 'link'} {#if type == 'link'}
<button class="btn btn-primary btn-block" on:click={() => dispatch('link', collection.id)}> {#if linkedCollectionList && linkedCollectionList
<Plus class="w-4 h-4" /> .map(String)
{$t('adventures.add_to_collection')} .includes(String(collection.id))}
</button> <button
class="btn btn-error btn-block"
on:click={() => dispatch('unlink', collection.id)}
>
<Minus class="w-4 h-4" />
{$t('adventures.remove_from_collection')}
</button>
{:else}
<button
class="btn btn-primary btn-block"
on:click={() => dispatch('link', collection.id)}
>
<Plus class="w-4 h-4" />
{$t('adventures.add_to_collection')}
</button>
{/if}
{:else} {:else}
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<button <button

View file

@ -9,6 +9,8 @@
let collections: Collection[] = []; let collections: Collection[] = [];
export let linkedCollectionList: string[] | null = null;
onMount(async () => { onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) { if (modal) {
@ -30,10 +32,14 @@
dispatch('close'); dispatch('close');
} }
function link(event: CustomEvent<number>) { function link(event: CustomEvent<string>) {
dispatch('link', event.detail); dispatch('link', event.detail);
} }
function unlink(event: CustomEvent<string>) {
dispatch('unlink', event.detail);
}
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
dispatch('close'); dispatch('close');
@ -46,9 +52,15 @@
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box w-11/12 max-w-5xl" role="dialog" on:keydown={handleKeydown} tabindex="0"> <div class="modal-box w-11/12 max-w-5xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h1 class="text-center font-bold text-4xl mb-6">{$t('adventures.my_collections')}</h1> <h1 class="text-center font-bold text-4xl mb-6">{$t('adventures.my_collections')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center"> <div class="flex flex-wrap gap-4 mr-4 justify-center content-center mb-4">
{#each collections as collection} {#each collections as collection}
<CollectionCard {collection} type="link" on:link={link} /> <CollectionCard
{collection}
type="link"
on:link={link}
bind:linkedCollectionList
on:unlink={unlink}
/>
{/each} {/each}
{#if collections.length === 0} {#if collections.length === 0}
<p class="text-center text-lg">{$t('adventures.no_collections_found')}</p> <p class="text-center text-lg">{$t('adventures.no_collections_found')}</p>

View file

@ -36,7 +36,7 @@ export type Adventure = {
timezone: string | null; timezone: string | null;
notes: string; notes: string;
}[]; }[];
collection?: string | null; collections?: string[] | null;
latitude: number | null; latitude: number | null;
longitude: number | null; longitude: number | null;
is_public: boolean; is_public: boolean;

View file

@ -523,7 +523,8 @@
"collection_edit_success": "Collection edited successfully!", "collection_edit_success": "Collection edited successfully!",
"error_editing_collection": "Error editing collection", "error_editing_collection": "Error editing collection",
"edit_collection": "Edit Collection", "edit_collection": "Edit Collection",
"public_collection": "Public Collection" "public_collection": "Public Collection",
"manage_collections": "Manage Collections"
}, },
"notes": { "notes": {
"note_deleted": "Note deleted successfully!", "note_deleted": "Note deleted successfully!",

View file

@ -20,22 +20,10 @@ export const load = (async (event) => {
}; };
} else { } else {
let adventure = (await request.json()) as AdditionalAdventure; let adventure = (await request.json()) as AdditionalAdventure;
let collection: Collection | null = null;
if (adventure.collection) {
let res2 = await fetch(`${endpoint}/api/collections/${adventure.collection}/`, {
headers: {
Cookie: `sessionid=${event.cookies.get('sessionid')}`
},
credentials: 'include'
});
collection = await res2.json();
}
return { return {
props: { props: {
adventure, adventure
collection
} }
}; };
} }

View file

@ -360,12 +360,18 @@
? `🌍 ${$t('adventures.public')}` ? `🌍 ${$t('adventures.public')}`
: `🔒 ${$t('adventures.private')}`} : `🔒 ${$t('adventures.private')}`}
</div> </div>
{#if data.props.collection} <!-- {#if data.props.collection}
<div class="badge badge-sm badge-outline"> <div class="badge badge-sm badge-outline">
📚 <a href="/collections/{data.props.collection.id}" class="link" 📚 <a href="/collections/{data.props.collection.id}" class="link"
>{data.props.collection.name}</a >{data.props.collection.name}</a
> >
</div> </div>
{/if} -->
{#if adventure.collections && adventure.collections.length > 0}
<div class="badge badge-sm badge-outline">
📚
<p>{adventure.collections.length} {$t('navbar.collections')}</p>
</div>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -362,12 +362,21 @@
} else { } else {
let adventure = event.detail; let adventure = event.detail;
// add the collection id to the adventure collections array
if (!adventure.collections) {
adventure.collections = [collection.id];
} else {
if (!adventure.collections.includes(collection.id)) {
adventure.collections.push(collection.id);
}
}
let res = await fetch(`/api/adventures/${adventure.id}/`, { let res = await fetch(`/api/adventures/${adventure.id}/`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ collection: collection.id.toString() }) body: JSON.stringify({ collections: adventure.collections })
}); });
if (res.ok) { if (res.ok) {
@ -550,6 +559,7 @@
on:close={() => { on:close={() => {
isShowingLinkModal = false; isShowingLinkModal = false;
}} }}
collectionId={collection.id}
on:add={addAdventure} on:add={addAdventure}
/> />
{/if} {/if}