mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-19 04:49:37 +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:
parent
d9070e68bb
commit
3f9a6767bd
22 changed files with 686 additions and 289 deletions
|
@ -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'
|
||||
|
||||
def ready(self):
|
||||
import adventures.signals # Import signals when the app is ready
|
|
@ -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()
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# 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 permissions are allowed if the object is public
|
||||
# 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):
|
||||
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 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 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
|
||||
return False
|
||||
|
|
|
@ -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', '<EFBFBD>')
|
||||
icon = category_data.get('icon', '🌍')
|
||||
else:
|
||||
name = category_data.name.lower()
|
||||
display_name = category_data.display_name
|
||||
|
@ -163,15 +181,24 @@ 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()
|
||||
|
||||
return adventure
|
||||
|
@ -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')
|
||||
|
|
23
backend/server/adventures/signals.py
Normal file
23
backend/server/adventures/signals.py
Normal 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'])
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
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)
|
|
@ -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)
|
||||
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)
|
|
@ -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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}`, {
|
||||
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<number>) {
|
||||
async function removeFromCollection(event: CustomEvent<string>) {
|
||||
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 @@
|
|||
</script>
|
||||
|
||||
{#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 isWarningModalOpen}
|
||||
|
@ -269,23 +297,14 @@
|
|||
</button>
|
||||
</li>
|
||||
|
||||
{#if adventure.collection && 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}
|
||||
{#if user?.uuid == adventure.user_id}
|
||||
<li>
|
||||
<button
|
||||
on:click={() => (isCollectionModalOpen = true)}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
{$t('adventures.add_to_collection')}
|
||||
{$t('collection.manage_collections')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
@ -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 @@
|
|||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-base-300">
|
||||
{#if type == 'link'}
|
||||
<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 linkedCollectionList && linkedCollectionList
|
||||
.map(String)
|
||||
.includes(String(collection.id))}
|
||||
<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}
|
||||
<div class="flex justify-between items-center">
|
||||
<button
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
|
||||
let collections: Collection[] = [];
|
||||
|
||||
export let linkedCollectionList: string[] | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
|
@ -30,10 +32,14 @@
|
|||
dispatch('close');
|
||||
}
|
||||
|
||||
function link(event: CustomEvent<number>) {
|
||||
function link(event: CustomEvent<string>) {
|
||||
dispatch('link', event.detail);
|
||||
}
|
||||
|
||||
function unlink(event: CustomEvent<string>) {
|
||||
dispatch('unlink', event.detail);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
dispatch('close');
|
||||
|
@ -46,9 +52,15 @@
|
|||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<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>
|
||||
<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}
|
||||
<CollectionCard {collection} type="link" on:link={link} />
|
||||
<CollectionCard
|
||||
{collection}
|
||||
type="link"
|
||||
on:link={link}
|
||||
bind:linkedCollectionList
|
||||
on:unlink={unlink}
|
||||
/>
|
||||
{/each}
|
||||
{#if collections.length === 0}
|
||||
<p class="text-center text-lg">{$t('adventures.no_collections_found')}</p>
|
||||
|
|
|
@ -36,7 +36,7 @@ export type Adventure = {
|
|||
timezone: string | null;
|
||||
notes: string;
|
||||
}[];
|
||||
collection?: string | null;
|
||||
collections?: string[] | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
is_public: boolean;
|
||||
|
|
|
@ -523,7 +523,8 @@
|
|||
"collection_edit_success": "Collection edited successfully!",
|
||||
"error_editing_collection": "Error editing collection",
|
||||
"edit_collection": "Edit Collection",
|
||||
"public_collection": "Public Collection"
|
||||
"public_collection": "Public Collection",
|
||||
"manage_collections": "Manage Collections"
|
||||
},
|
||||
"notes": {
|
||||
"note_deleted": "Note deleted successfully!",
|
||||
|
|
|
@ -20,22 +20,10 @@ export const load = (async (event) => {
|
|||
};
|
||||
} else {
|
||||
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 {
|
||||
props: {
|
||||
adventure,
|
||||
collection
|
||||
adventure
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -360,12 +360,18 @@
|
|||
? `🌍 ${$t('adventures.public')}`
|
||||
: `🔒 ${$t('adventures.private')}`}
|
||||
</div>
|
||||
{#if data.props.collection}
|
||||
<!-- {#if data.props.collection}
|
||||
<div class="badge badge-sm badge-outline">
|
||||
📚 <a href="/collections/{data.props.collection.id}" class="link"
|
||||
>{data.props.collection.name}</a
|
||||
>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -362,12 +362,21 @@
|
|||
} else {
|
||||
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}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ collection: collection.id.toString() })
|
||||
body: JSON.stringify({ collections: adventure.collections })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
|
@ -550,6 +559,7 @@
|
|||
on:close={() => {
|
||||
isShowingLinkModal = false;
|
||||
}}
|
||||
collectionId={collection.id}
|
||||
on:add={addAdventure}
|
||||
/>
|
||||
{/if}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue