mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-24 07:19:36 +02:00
feat: Enhance collection sharing and location management features
- Implemented unsharing functionality in CollectionViewSet, including removal of user-owned locations from collections. - Refactored ContentImageViewSet to support multiple content types and improved permission checks for image uploads. - Added user ownership checks in LocationViewSet for delete operations. - Enhanced collection management in the frontend to display both owned and shared collections separately. - Updated Immich integration to handle access control based on location visibility and user permissions. - Improved UI components to show creator information and manage collection links more effectively. - Added loading states and error handling in collection fetching logic.
This commit is contained in:
parent
7f80dad94b
commit
ba162175fe
19 changed files with 641 additions and 245 deletions
|
@ -9,7 +9,7 @@ class LocationManager(models.Manager):
|
||||||
query |= Q(user=user)
|
query |= Q(user=user)
|
||||||
|
|
||||||
if include_shared:
|
if include_shared:
|
||||||
query |= Q(collections__shared_with=user)
|
query |= Q(collections__shared_with=user) | Q(collections__user=user)
|
||||||
|
|
||||||
if include_public:
|
if include_public:
|
||||||
query |= Q(is_public=True)
|
query |= Q(is_public=True)
|
||||||
|
|
|
@ -81,8 +81,13 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
||||||
# If user in shared_with of any collection related to obj
|
# If user in shared_with of any collection related to obj
|
||||||
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
|
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
|
||||||
return True
|
return True
|
||||||
|
# **FIX: Check if user OWNS any collection that contains this object**
|
||||||
|
if hasattr(obj, 'collections') and obj.collections.filter(user=user).exists():
|
||||||
|
return True
|
||||||
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
|
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
|
||||||
return True
|
return True
|
||||||
|
if hasattr(obj, 'collection') and obj.collection and obj.collection.user == user:
|
||||||
|
return True
|
||||||
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
|
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
@ -92,9 +97,14 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
||||||
return True
|
return True
|
||||||
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
|
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
|
||||||
return True
|
return True
|
||||||
|
# **FIX: Allow write access if user owns any collection containing this object**
|
||||||
|
if hasattr(obj, 'collections') and obj.collections.filter(user=user).exists():
|
||||||
|
return True
|
||||||
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
|
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
|
||||||
return True
|
return True
|
||||||
|
if hasattr(obj, 'collection') and obj.collection and obj.collection.user == user:
|
||||||
|
return True
|
||||||
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
|
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
|
@ -12,7 +12,7 @@ from integrations.models import ImmichIntegration
|
||||||
class ContentImageSerializer(CustomModelSerializer):
|
class ContentImageSerializer(CustomModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ContentImage
|
model = ContentImage
|
||||||
fields = ['id', 'image', 'location', 'is_primary', 'user', 'immich_id']
|
fields = ['id', 'image', 'is_primary', 'user', 'immich_id']
|
||||||
read_only_fields = ['id', 'user']
|
read_only_fields = ['id', 'user']
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
|
@ -42,7 +42,7 @@ class AttachmentSerializer(CustomModelSerializer):
|
||||||
extension = serializers.SerializerMethodField()
|
extension = serializers.SerializerMethodField()
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ContentAttachment
|
model = ContentAttachment
|
||||||
fields = ['id', 'file', 'location', 'extension', 'name', 'user']
|
fields = ['id', 'file', 'extension', 'name', 'user']
|
||||||
read_only_fields = ['id', 'user']
|
read_only_fields = ['id', 'user']
|
||||||
|
|
||||||
def get_extension(self, obj):
|
def get_extension(self, obj):
|
||||||
|
@ -127,16 +127,28 @@ class LocationSerializer(CustomModelSerializer):
|
||||||
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):
|
def validate_collections(self, collections):
|
||||||
"""Validate that collections belong to the same user"""
|
"""Validate that collections are compatible with the location being created/updated"""
|
||||||
if not collections:
|
if not collections:
|
||||||
return collections
|
return collections
|
||||||
|
|
||||||
user = self.context['request'].user
|
user = self.context['request'].user
|
||||||
|
|
||||||
|
# Get the location being updated (if this is an update operation)
|
||||||
|
location_owner = getattr(self.instance, 'user', None) if self.instance else user
|
||||||
|
|
||||||
for collection in collections:
|
for collection in collections:
|
||||||
|
# Check if user has permission to use this collection
|
||||||
if collection.user != user and not collection.shared_with.filter(id=user.id).exists():
|
if collection.user != user and not collection.shared_with.filter(id=user.id).exists():
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
f"Collection '{collection.name}' does not belong to the current user."
|
f"Collection '{collection.name}' does not belong to the current user."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if the location owner is compatible with the collection
|
||||||
|
if collection.user != location_owner and not collection.shared_with.filter(id=location_owner.id).exists():
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Location owned by '{location_owner.username}' cannot be added to collection '{collection.name}' owned by '{collection.user.username}' unless the location owner has shared access to the collection."
|
||||||
|
)
|
||||||
|
|
||||||
return collections
|
return collections
|
||||||
|
|
||||||
def validate_category(self, category_data):
|
def validate_category(self, category_data):
|
||||||
|
|
|
@ -6,7 +6,10 @@ from adventures.models import Location
|
||||||
def update_adventure_publicity(sender, instance, action, **kwargs):
|
def update_adventure_publicity(sender, instance, action, **kwargs):
|
||||||
"""
|
"""
|
||||||
Signal handler to update adventure publicity when collections are added/removed
|
Signal handler to update adventure publicity when collections are added/removed
|
||||||
|
This function checks if the adventure's collections contain any public collection.
|
||||||
"""
|
"""
|
||||||
|
if not isinstance(instance, Location):
|
||||||
|
return
|
||||||
# Only process when collections are added or removed
|
# Only process when collections are added or removed
|
||||||
if action in ('post_add', 'post_remove', 'post_clear'):
|
if action in ('post_add', 'post_remove', 'post_clear'):
|
||||||
collections = instance.collections.all()
|
collections = instance.collections.all()
|
||||||
|
|
|
@ -11,7 +11,7 @@ router.register(r'tags', ActivityTypesView, basename='tags')
|
||||||
router.register(r'transportations', TransportationViewSet, basename='transportations')
|
router.register(r'transportations', TransportationViewSet, basename='transportations')
|
||||||
router.register(r'notes', NoteViewSet, basename='notes')
|
router.register(r'notes', NoteViewSet, basename='notes')
|
||||||
router.register(r'checklists', ChecklistViewSet, basename='checklists')
|
router.register(r'checklists', ChecklistViewSet, basename='checklists')
|
||||||
router.register(r'images', AdventureImageViewSet, basename='images')
|
router.register(r'images', ContentImageViewSet, basename='images')
|
||||||
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
|
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
|
||||||
router.register(r'categories', CategoryViewSet, basename='categories')
|
router.register(r'categories', CategoryViewSet, basename='categories')
|
||||||
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
|
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
|
||||||
|
|
|
@ -27,12 +27,12 @@ def checkFilePermission(fileId, user, mediaType):
|
||||||
if hasattr(content_object, 'collections') and content_object.collections.exists():
|
if hasattr(content_object, 'collections') and content_object.collections.exists():
|
||||||
# For objects with multiple collections (like Location)
|
# For objects with multiple collections (like Location)
|
||||||
for collection in content_object.collections.all():
|
for collection in content_object.collections.all():
|
||||||
if collection.shared_with.filter(id=user.id).exists():
|
if collection.user == user or collection.shared_with.filter(id=user.id).exists():
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
elif hasattr(content_object, 'collection') and content_object.collection:
|
elif hasattr(content_object, 'collection') and content_object.collection:
|
||||||
# For objects with single collection (like Transportation, Note, etc.)
|
# For objects with single collection (like Transportation, Note, etc.)
|
||||||
if content_object.collection.shared_with.filter(id=user.id).exists():
|
if content_object.collection.user == user or content_object.collection.shared_with.filter(id=user.id).exists():
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
|
@ -62,12 +62,12 @@ def checkFilePermission(fileId, user, mediaType):
|
||||||
if hasattr(content_object, 'collections') and content_object.collections.exists():
|
if hasattr(content_object, 'collections') and content_object.collections.exists():
|
||||||
# For objects with multiple collections (like Location)
|
# For objects with multiple collections (like Location)
|
||||||
for collection in content_object.collections.all():
|
for collection in content_object.collections.all():
|
||||||
if collection.shared_with.filter(id=user.id).exists():
|
if collection.user == user or collection.shared_with.filter(id=user.id).exists():
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
elif hasattr(content_object, 'collection') and content_object.collection:
|
elif hasattr(content_object, 'collection') and content_object.collection:
|
||||||
# For objects with single collection (like Transportation, Note, etc.)
|
# For objects with single collection (like Transportation, Note, etc.)
|
||||||
if content_object.collection.shared_with.filter(id=user.id).exists():
|
if content_object.collection.user == user or content_object.collection.shared_with.filter(id=user.id).exists():
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -2,51 +2,97 @@ from rest_framework import viewsets, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from adventures.models import Location, ContentAttachment
|
from adventures.models import Location, ContentAttachment
|
||||||
from adventures.serializers import AttachmentSerializer
|
from adventures.serializers import AttachmentSerializer
|
||||||
|
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||||
|
|
||||||
|
|
||||||
class AttachmentViewSet(viewsets.ModelViewSet):
|
class AttachmentViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = AttachmentSerializer
|
serializer_class = AttachmentSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return ContentAttachment.objects.filter(user=self.request.user)
|
return ContentAttachment.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
location_id = request.data.get('location')
|
|
||||||
try:
|
|
||||||
location = Location.objects.get(id=location_id)
|
|
||||||
except Location.DoesNotExist:
|
|
||||||
return Response({"error": "Location not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if location.user != request.user:
|
|
||||||
# Check if the location has any collections
|
|
||||||
if location.collections.exists():
|
|
||||||
# Check if the user is in the shared_with list of any of the location's collections
|
|
||||||
user_has_access = False
|
|
||||||
for collection in location.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 location"}, status=status.HTTP_403_FORBIDDEN)
|
|
||||||
else:
|
|
||||||
return Response({"error": "User does not own this location"}, status=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
return super().create(request, *args, **kwargs)
|
# Get the content object details
|
||||||
|
content_type_id = request.data.get('content_type')
|
||||||
def perform_create(self, serializer):
|
object_id = request.data.get('object_id')
|
||||||
location_id = self.request.data.get('location')
|
|
||||||
location = Location.objects.get(id=location_id)
|
# For backward compatibility, also check for 'location' parameter
|
||||||
|
location_id = request.data.get('location')
|
||||||
|
|
||||||
|
if location_id and not (content_type_id and object_id):
|
||||||
|
# Handle legacy location-specific requests
|
||||||
|
try:
|
||||||
|
location = Location.objects.get(id=location_id)
|
||||||
|
content_type = ContentType.objects.get_for_model(Location)
|
||||||
|
content_type_id = content_type.id
|
||||||
|
object_id = location_id
|
||||||
|
except Location.DoesNotExist:
|
||||||
|
return Response({"error": "Location not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if not (content_type_id and object_id):
|
||||||
|
return Response({"error": "content_type and object_id are required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# If the location belongs to collections, set the owner to the collection owner
|
try:
|
||||||
if location.collections.exists():
|
content_type = ContentType.objects.get(id=content_type_id)
|
||||||
# Get the first collection's owner (assuming all collections have the same owner)
|
model_class = content_type.model_class()
|
||||||
collection = location.collections.first()
|
content_object = model_class.objects.get(id=object_id)
|
||||||
serializer.save(user=collection.user)
|
except (ContentType.DoesNotExist, model_class.DoesNotExist):
|
||||||
else:
|
return Response({"error": "Content object not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
# Otherwise, set the owner to the request user
|
|
||||||
serializer.save(user=self.request.user)
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
content_type_id = self.request.data.get('content_type')
|
||||||
|
object_id = self.request.data.get('object_id')
|
||||||
|
|
||||||
|
# Handle legacy location parameter
|
||||||
|
location_id = self.request.data.get('location')
|
||||||
|
if location_id and not (content_type_id and object_id):
|
||||||
|
content_type = ContentType.objects.get_for_model(Location)
|
||||||
|
content_type_id = content_type.id
|
||||||
|
object_id = location_id
|
||||||
|
|
||||||
|
content_type = ContentType.objects.get(id=content_type_id)
|
||||||
|
model_class = content_type.model_class()
|
||||||
|
content_object = model_class.objects.get(id=object_id)
|
||||||
|
|
||||||
|
# Determine the appropriate user to assign
|
||||||
|
attachment_user = self._get_attachment_user(content_object)
|
||||||
|
|
||||||
|
serializer.save(
|
||||||
|
user=attachment_user,
|
||||||
|
content_type=content_type,
|
||||||
|
object_id=object_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_attachment_user(self, content_object):
|
||||||
|
"""
|
||||||
|
Determine which user should own the attachment based on the content object.
|
||||||
|
This preserves the original logic for shared collections.
|
||||||
|
"""
|
||||||
|
# Handle Location objects
|
||||||
|
if isinstance(content_object, Location):
|
||||||
|
if content_object.collections.exists():
|
||||||
|
# Get the first collection's owner (assuming all collections have the same owner)
|
||||||
|
collection = content_object.collections.first()
|
||||||
|
return collection.user
|
||||||
|
else:
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
# Handle other content types with collections
|
||||||
|
elif hasattr(content_object, 'collection') and content_object.collection:
|
||||||
|
return content_object.collection.user
|
||||||
|
|
||||||
|
# Handle content objects with a user field
|
||||||
|
elif hasattr(content_object, 'user'):
|
||||||
|
return content_object.user
|
||||||
|
|
||||||
|
# Default to request user
|
||||||
|
return self.request.user
|
|
@ -183,9 +183,12 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||||
def unshare(self, request, pk=None, uuid=None):
|
def unshare(self, request, pk=None, uuid=None):
|
||||||
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)
|
||||||
|
|
||||||
collection = self.get_object()
|
collection = self.get_object()
|
||||||
|
|
||||||
if not uuid:
|
if not uuid:
|
||||||
return Response({"error": "User UUID is required"}, status=400)
|
return Response({"error": "User UUID is required"}, status=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(uuid=uuid, public_profile=True)
|
user = User.objects.get(uuid=uuid, public_profile=True)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
|
@ -197,9 +200,25 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||||
if not collection.shared_with.filter(id=user.id).exists():
|
if not collection.shared_with.filter(id=user.id).exists():
|
||||||
return Response({"error": "Collection is not shared with this user"}, status=400)
|
return Response({"error": "Collection is not shared with this user"}, status=400)
|
||||||
|
|
||||||
|
# Remove user from shared_with
|
||||||
collection.shared_with.remove(user)
|
collection.shared_with.remove(user)
|
||||||
|
|
||||||
|
# Handle locations owned by the unshared user that are in this collection
|
||||||
|
# These locations should be removed from the collection since they lose access
|
||||||
|
locations_to_remove = collection.locations.filter(user=user)
|
||||||
|
removed_count = locations_to_remove.count()
|
||||||
|
|
||||||
|
if locations_to_remove.exists():
|
||||||
|
# Remove these locations from the collection
|
||||||
|
collection.locations.remove(*locations_to_remove)
|
||||||
|
|
||||||
collection.save()
|
collection.save()
|
||||||
return Response({"success": f"Unshared with {user.username}"})
|
|
||||||
|
success_message = f"Unshared with {user.username}"
|
||||||
|
if removed_count > 0:
|
||||||
|
success_message += f" and removed {removed_count} location(s) they owned from the collection"
|
||||||
|
|
||||||
|
return Response({"success": success_message})
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if self.action == 'destroy':
|
if self.action == 'destroy':
|
||||||
|
|
|
@ -4,13 +4,14 @@ from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from adventures.models import Location, ContentImage
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from adventures.models import Location, Transportation, Note, Lodging, Visit, ContentImage
|
||||||
from adventures.serializers import ContentImageSerializer
|
from adventures.serializers import ContentImageSerializer
|
||||||
from integrations.models import ImmichIntegration
|
from integrations.models import ImmichIntegration
|
||||||
import uuid
|
import uuid
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
class AdventureImageViewSet(viewsets.ModelViewSet):
|
class ContentImageViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = ContentImageSerializer
|
serializer_class = ContentImageSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@ -20,23 +21,28 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def toggle_primary(self, request, *args, **kwargs):
|
def toggle_primary(self, request, *args, **kwargs):
|
||||||
# Makes the image the primary image for the location, if there is already a primary image linked to the location, it is set to false and the new image is set to true. make sure that the permission is set to the owner of the location
|
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
location = instance.location
|
content_object = instance.content_object
|
||||||
if location.user != request.user:
|
|
||||||
return Response({"error": "User does not own this location"}, status=status.HTTP_403_FORBIDDEN)
|
# Check ownership based on content type
|
||||||
|
if hasattr(content_object, 'user') and content_object.user != request.user:
|
||||||
|
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
# Check if the image is already the primary image
|
# Check if the image is already the primary image
|
||||||
if instance.is_primary:
|
if instance.is_primary:
|
||||||
return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Set the current primary image to false
|
# Set other images of the same content object to not primary
|
||||||
ContentImage.objects.filter(location=location, is_primary=True).update(is_primary=False)
|
ContentImage.objects.filter(
|
||||||
|
content_type=instance.content_type,
|
||||||
|
object_id=instance.object_id,
|
||||||
|
is_primary=True
|
||||||
|
).update(is_primary=False)
|
||||||
|
|
||||||
# Set the new image to true
|
# Set the new image to primary
|
||||||
instance.is_primary = True
|
instance.is_primary = True
|
||||||
instance.save()
|
instance.save()
|
||||||
return Response({"success": "Image set as primary image"})
|
return Response({"success": "Image set as primary image"})
|
||||||
|
@ -44,29 +50,67 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
location_id = request.data.get('location')
|
|
||||||
try:
|
|
||||||
location = Location.objects.get(id=location_id)
|
|
||||||
except Location.DoesNotExist:
|
|
||||||
return Response({"error": "location not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if location.user != request.user:
|
# Get content type and object ID from request
|
||||||
# Check if the location has any collections
|
content_type_name = request.data.get('content_type')
|
||||||
if location.collections.exists():
|
object_id = request.data.get('object_id')
|
||||||
# Check if the user is in the shared_with list of any of the location's collections
|
|
||||||
user_has_access = False
|
if not content_type_name or not object_id:
|
||||||
for collection in location.collections.all():
|
return Response({
|
||||||
if collection.shared_with.filter(id=request.user.id).exists():
|
"error": "content_type and object_id are required"
|
||||||
user_has_access = True
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
break
|
|
||||||
|
# Map content type names to model classes
|
||||||
if not user_has_access:
|
content_type_map = {
|
||||||
return Response({"error": "User does not have permission to access this location"}, status=status.HTTP_403_FORBIDDEN)
|
'location': Location,
|
||||||
else:
|
'transportation': Transportation,
|
||||||
return Response({"error": "User does not own this location"}, status=status.HTTP_403_FORBIDDEN)
|
'note': Note,
|
||||||
|
'lodging': Lodging,
|
||||||
|
'visit': Visit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_type_name not in content_type_map:
|
||||||
|
return Response({
|
||||||
|
"error": f"Invalid content_type. Must be one of: {', '.join(content_type_map.keys())}"
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Get the content type and object
|
||||||
|
try:
|
||||||
|
content_type = ContentType.objects.get_for_model(content_type_map[content_type_name])
|
||||||
|
content_object = content_type_map[content_type_name].objects.get(id=object_id)
|
||||||
|
except (ValueError, content_type_map[content_type_name].DoesNotExist):
|
||||||
|
return Response({
|
||||||
|
"error": f"{content_type_name} not found"
|
||||||
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Check permissions based on content type
|
||||||
|
if hasattr(content_object, 'user'):
|
||||||
|
if content_object.user != request.user:
|
||||||
|
# For Location, check if user has shared access
|
||||||
|
if content_type_name == 'location':
|
||||||
|
if content_object.collections.exists():
|
||||||
|
user_has_access = False
|
||||||
|
for collection in content_object.collections.all():
|
||||||
|
if collection.shared_with.filter(id=request.user.id).exists() or collection.user == request.user:
|
||||||
|
user_has_access = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_has_access:
|
||||||
|
return Response({
|
||||||
|
"error": "User does not have permission to access this content"
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
else:
|
||||||
|
return Response({
|
||||||
|
"error": "User does not own this content"
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
else:
|
||||||
|
return Response({
|
||||||
|
"error": "User does not own this content"
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
# Handle Immich ID for shared users by downloading the image
|
# Handle Immich ID for shared users by downloading the image
|
||||||
if (request.user != location.user and
|
if (hasattr(content_object, 'user') and
|
||||||
|
request.user != content_object.user and
|
||||||
'immich_id' in request.data and
|
'immich_id' in request.data and
|
||||||
request.data.get('immich_id')):
|
request.data.get('immich_id')):
|
||||||
|
|
||||||
|
@ -91,8 +135,8 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||||
immich_response.raise_for_status()
|
immich_response.raise_for_status()
|
||||||
|
|
||||||
# Create a temporary file with the downloaded content
|
# Create a temporary file with the downloaded content
|
||||||
content_type = immich_response.headers.get('Content-Type', 'image/jpeg')
|
content_type_header = immich_response.headers.get('Content-Type', 'image/jpeg')
|
||||||
if not content_type.startswith('image/'):
|
if not content_type_header.startswith('image/'):
|
||||||
return Response({
|
return Response({
|
||||||
"error": "Invalid content type returned from Immich server.",
|
"error": "Invalid content type returned from Immich server.",
|
||||||
"code": "invalid_content_type"
|
"code": "invalid_content_type"
|
||||||
|
@ -105,7 +149,7 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||||
'image/webp': '.webp',
|
'image/webp': '.webp',
|
||||||
'image/gif': '.gif'
|
'image/gif': '.gif'
|
||||||
}
|
}
|
||||||
file_ext = ext_map.get(content_type, '.jpg')
|
file_ext = ext_map.get(content_type_header, '.jpg')
|
||||||
filename = f"immich_{immich_id}{file_ext}"
|
filename = f"immich_{immich_id}{file_ext}"
|
||||||
|
|
||||||
# Create a Django ContentFile from the downloaded image
|
# Create a Django ContentFile from the downloaded image
|
||||||
|
@ -115,14 +159,20 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||||
request_data = request.data.copy()
|
request_data = request.data.copy()
|
||||||
request_data.pop('immich_id', None) # Remove immich_id
|
request_data.pop('immich_id', None) # Remove immich_id
|
||||||
request_data['image'] = image_file # Add the image file
|
request_data['image'] = image_file # Add the image file
|
||||||
|
request_data['content_type'] = content_type.id
|
||||||
|
request_data['object_id'] = object_id
|
||||||
|
|
||||||
# Create the serializer with the modified data
|
# Create the serializer with the modified data
|
||||||
serializer = self.get_serializer(data=request_data)
|
serializer = self.get_serializer(data=request_data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
# Save with the downloaded image
|
# Save with the downloaded image
|
||||||
location = serializer.validated_data['location']
|
serializer.save(
|
||||||
serializer.save(user=location.user, image=image_file)
|
user=content_object.user if hasattr(content_object, 'user') else request.user,
|
||||||
|
image=image_file,
|
||||||
|
content_type=content_type,
|
||||||
|
object_id=object_id
|
||||||
|
)
|
||||||
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
@ -137,34 +187,47 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||||
"code": "immich_processing_error"
|
"code": "immich_processing_error"
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
return super().create(request, *args, **kwargs)
|
# Add content type and object ID to request data
|
||||||
|
request_data = request.data.copy()
|
||||||
|
request_data['content_type'] = content_type.id
|
||||||
|
request_data['object_id'] = object_id
|
||||||
|
|
||||||
|
# Create serializer with modified data
|
||||||
|
serializer = self.get_serializer(data=request_data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# Save the image
|
||||||
|
serializer.save(
|
||||||
|
user=content_object.user if hasattr(content_object, 'user') else request.user,
|
||||||
|
content_type=content_type,
|
||||||
|
object_id=object_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
location_id = request.data.get('location')
|
instance = self.get_object()
|
||||||
try:
|
content_object = instance.content_object
|
||||||
location = Location.objects.get(id=location_id)
|
|
||||||
except Location.DoesNotExist:
|
|
||||||
return Response({"error": "location not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if location.user != request.user:
|
# Check ownership
|
||||||
return Response({"error": "User does not own this location"}, status=status.HTTP_403_FORBIDDEN)
|
if hasattr(content_object, 'user') and content_object.user != request.user:
|
||||||
|
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
return super().update(request, *args, **kwargs)
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
|
||||||
return super().perform_destroy(instance)
|
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
location = instance.location
|
content_object = instance.content_object
|
||||||
if location.user != request.user:
|
|
||||||
return Response({"error": "User does not own this location"}, status=status.HTTP_403_FORBIDDEN)
|
# Check ownership
|
||||||
|
if hasattr(content_object, 'user') and content_object.user != request.user:
|
||||||
|
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
return super().destroy(request, *args, **kwargs)
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@ -173,41 +236,33 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
location = instance.location
|
content_object = instance.content_object
|
||||||
if location.user != request.user:
|
|
||||||
return Response({"error": "User does not own this location"}, status=status.HTTP_403_FORBIDDEN)
|
# Check ownership
|
||||||
|
if hasattr(content_object, 'user') and content_object.user != request.user:
|
||||||
|
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
return super().partial_update(request, *args, **kwargs)
|
return super().partial_update(request, *args, **kwargs)
|
||||||
|
|
||||||
@action(detail=False, methods=['GET'], url_path='(?P<location_id>[0-9a-f-]+)')
|
|
||||||
def location_images(self, request, location_id=None, *args, **kwargs):
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
|
|
||||||
try:
|
|
||||||
location_uuid = uuid.UUID(location_id)
|
|
||||||
except ValueError:
|
|
||||||
return Response({"error": "Invalid location ID"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# Updated queryset to include images from locations the user owns OR has shared access to
|
|
||||||
queryset = ContentImage.objects.filter(
|
|
||||||
Q(location__id=location_uuid) & (
|
|
||||||
Q(location__user=request.user) | # User owns the location
|
|
||||||
Q(location__collections__shared_with=request.user) # User has shared access via collection
|
|
||||||
)
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
serializer = self.get_serializer(queryset, many=True, context={'request': request})
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# Updated to include images from locations the user owns OR has shared access to
|
"""Get all images the user has access to"""
|
||||||
return ContentImage.objects.filter(
|
if not self.request.user.is_authenticated:
|
||||||
Q(location__user=self.request.user) | # User owns the location
|
return ContentImage.objects.none()
|
||||||
Q(location__collections__shared_with=self.request.user) # User has shared access via collection
|
|
||||||
|
# Get content type for Location to handle shared access
|
||||||
|
location_content_type = ContentType.objects.get_for_model(Location)
|
||||||
|
|
||||||
|
# Build queryset with proper permissions
|
||||||
|
queryset = ContentImage.objects.filter(
|
||||||
|
Q(content_object__user=self.request.user) | # User owns the content
|
||||||
|
Q(content_type=location_content_type, content_object__collections__shared_with=self.request.user) # Shared locations
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
# Always set the image owner to the location owner, not the current user
|
# The content_type and object_id are already set in the create method
|
||||||
location = serializer.validated_data['location']
|
# Just ensure the user is set correctly
|
||||||
serializer.save(user=location.user)
|
pass
|
|
@ -141,6 +141,16 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
self.perform_update(serializer)
|
self.perform_update(serializer)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
"""Only allow the owner to delete a location."""
|
||||||
|
instance = self.get_object()
|
||||||
|
|
||||||
|
# Check if the user is the owner
|
||||||
|
if instance.user != request.user:
|
||||||
|
raise PermissionDenied("Only the owner can delete this location.")
|
||||||
|
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
# ==================== CUSTOM ACTIONS ====================
|
# ==================== CUSTOM ACTIONS ====================
|
||||||
|
|
||||||
|
@ -221,37 +231,50 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
# ==================== HELPER METHODS ====================
|
# ==================== 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 == 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):
|
def _validate_collection_update_permissions(self, instance, new_collections):
|
||||||
"""Validate permissions for collection updates (add/remove)."""
|
"""Validate collection permissions for updates, allowing collection owners to unlink locations."""
|
||||||
# Check permissions for new collections being added
|
|
||||||
for collection in new_collections:
|
|
||||||
if (collection.user != 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())
|
current_collections = set(instance.collections.all())
|
||||||
new_collections_set = set(new_collections)
|
new_collections_set = set(new_collections)
|
||||||
|
|
||||||
|
# Collections being added
|
||||||
|
collections_to_add = new_collections_set - current_collections
|
||||||
|
|
||||||
|
# Collections being removed
|
||||||
collections_to_remove = current_collections - new_collections_set
|
collections_to_remove = current_collections - new_collections_set
|
||||||
|
|
||||||
|
# Validate permissions for collections being added
|
||||||
|
for collection in collections_to_add:
|
||||||
|
# Standard validation for adding collections
|
||||||
|
if collection.user != self.request.user:
|
||||||
|
# Check if user has shared access to the collection
|
||||||
|
if not collection.shared_with.filter(uuid=self.request.user.uuid).exists():
|
||||||
|
raise PermissionDenied(
|
||||||
|
f"You don't have permission to add location to collection '{collection.name}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# For collections being removed, allow if:
|
||||||
|
# 1. User owns the location, OR
|
||||||
|
# 2. User owns the collection (even if they don't own the location)
|
||||||
for collection in collections_to_remove:
|
for collection in collections_to_remove:
|
||||||
if (collection.user != self.request.user and
|
user_owns_location = instance.user == self.request.user
|
||||||
not collection.shared_with.filter(uuid=self.request.user.uuid).exists()):
|
user_owns_collection = collection.user == self.request.user
|
||||||
raise PermissionDenied(
|
|
||||||
f"You cannot remove the adventure from collection '{collection.name}' "
|
if not (user_owns_location or user_owns_collection):
|
||||||
f"as you don't have permission."
|
# Check if user has shared access to the collection
|
||||||
)
|
if not collection.shared_with.filter(uuid=self.request.user.uuid).exists():
|
||||||
|
raise PermissionDenied(
|
||||||
|
f"You don't have permission to remove location from collection '{collection.name}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_collection_permissions(self, collections):
|
||||||
|
"""Validate permissions for all collections (used in create)."""
|
||||||
|
for collection in collections:
|
||||||
|
if collection.user != self.request.user:
|
||||||
|
# Check if user has shared access to the collection
|
||||||
|
if not collection.shared_with.filter(uuid=self.request.user.uuid).exists():
|
||||||
|
raise PermissionDenied(
|
||||||
|
f"You don't have permission to add location to collection '{collection.name}'"
|
||||||
|
)
|
||||||
|
|
||||||
def _apply_visit_filtering(self, queryset, request):
|
def _apply_visit_filtering(self, queryset, request):
|
||||||
"""Apply visit status filtering to queryset."""
|
"""Apply visit status filtering to queryset."""
|
||||||
|
@ -289,7 +312,7 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||||
# Check shared collection access
|
# Check shared collection access
|
||||||
if user.is_authenticated:
|
if user.is_authenticated:
|
||||||
for collection in adventure.collections.all():
|
for collection in adventure.collections.all():
|
||||||
if collection.shared_with.filter(uuid=user.uuid).exists():
|
if collection.shared_with.filter(uuid=user.uuid).exists() or collection.user == user:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
|
@ -270,48 +270,93 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
||||||
integration = get_object_or_404(ImmichIntegration, id=integration_id)
|
integration = get_object_or_404(ImmichIntegration, id=integration_id)
|
||||||
owner_id = integration.user
|
owner_id = integration.user
|
||||||
|
|
||||||
# Try to find the image entry with collections and sharing information
|
# Get all images for this immich_id and user
|
||||||
image_entry = (
|
image_entries = list(
|
||||||
ContentImage.objects
|
ContentImage.objects
|
||||||
.filter(immich_id=imageid, user=owner_id)
|
.filter(immich_id=imageid, user=owner_id)
|
||||||
.select_related('location')
|
.select_related('content_type')
|
||||||
.prefetch_related('location__collections', 'location__collections__shared_with')
|
|
||||||
.order_by('-location__is_public') # Public locations first
|
|
||||||
.first()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Sort by access level priority and find the best match
|
||||||
|
def get_access_priority(image_entry):
|
||||||
|
"""Return priority score for access control (lower = higher priority)"""
|
||||||
|
content_obj = image_entry.content_object
|
||||||
|
|
||||||
|
# Only handle Location objects for now (can be extended for other types)
|
||||||
|
if not hasattr(content_obj, 'is_public'):
|
||||||
|
return 999 # Low priority for non-location objects
|
||||||
|
|
||||||
|
# For Location objects, check access levels
|
||||||
|
if content_obj.is_public:
|
||||||
|
return 0 # Highest priority - public location
|
||||||
|
|
||||||
|
# Check if location is in any public collection
|
||||||
|
if hasattr(content_obj, 'collections'):
|
||||||
|
collections = content_obj.collections.all()
|
||||||
|
if any(collection.is_public for collection in collections):
|
||||||
|
return 1 # Second priority - private location in public collection
|
||||||
|
|
||||||
|
# Check for shared collections (if user is authenticated)
|
||||||
|
if (request.user.is_authenticated and
|
||||||
|
any(collection.shared_with.filter(id=request.user.id).exists()
|
||||||
|
for collection in collections)):
|
||||||
|
return 2 # Third priority - shared collection access
|
||||||
|
|
||||||
|
return 3 # Lowest priority - private location, owner access only
|
||||||
|
|
||||||
|
# Sort image entries by access priority
|
||||||
|
image_entries.sort(key=get_access_priority)
|
||||||
|
image_entry = image_entries[0] if image_entries else None
|
||||||
|
|
||||||
# Access control
|
# Access control
|
||||||
if image_entry:
|
if image_entry:
|
||||||
location = image_entry.location
|
content_obj = image_entry.content_object
|
||||||
collections = location.collections.all()
|
|
||||||
|
|
||||||
# Determine access level
|
|
||||||
is_authorized = False
|
|
||||||
|
|
||||||
# Level 1: Public location (highest priority)
|
|
||||||
if location.is_public:
|
|
||||||
is_authorized = True
|
|
||||||
|
|
||||||
# Level 2: Private location in any public collection
|
|
||||||
elif any(collection.is_public for collection in collections):
|
|
||||||
is_authorized = True
|
|
||||||
|
|
||||||
# Level 3: Owner access
|
|
||||||
elif request.user.is_authenticated and request.user == owner_id:
|
|
||||||
is_authorized = True
|
|
||||||
|
|
||||||
# Level 4: Shared collection access - check if user has access to any collection
|
|
||||||
elif (request.user.is_authenticated and
|
|
||||||
any(collection.shared_with.filter(id=request.user.id).exists()
|
|
||||||
for collection in collections)):
|
|
||||||
is_authorized = True
|
|
||||||
|
|
||||||
if not is_authorized:
|
# Only apply access control to Location objects
|
||||||
return Response({
|
if hasattr(content_obj, 'is_public'):
|
||||||
'message': 'This image belongs to a private location and you are not authorized.',
|
location = content_obj
|
||||||
'error': True,
|
|
||||||
'code': 'immich.permission_denied'
|
# Determine access level
|
||||||
}, status=status.HTTP_403_FORBIDDEN)
|
is_authorized = False
|
||||||
|
|
||||||
|
# Level 1: Public location (highest priority)
|
||||||
|
if location.is_public:
|
||||||
|
is_authorized = True
|
||||||
|
|
||||||
|
# Level 2: Private location in any public collection
|
||||||
|
elif hasattr(location, 'collections'):
|
||||||
|
collections = location.collections.all()
|
||||||
|
if any(collection.is_public for collection in collections):
|
||||||
|
is_authorized = True
|
||||||
|
|
||||||
|
# Level 3: Owner access
|
||||||
|
elif request.user.is_authenticated and request.user == owner_id:
|
||||||
|
is_authorized = True
|
||||||
|
|
||||||
|
# Level 4: Shared collection access
|
||||||
|
elif (request.user.is_authenticated and
|
||||||
|
any(collection.shared_with.filter(id=request.user.id).exists()
|
||||||
|
for collection in collections)):
|
||||||
|
is_authorized = True
|
||||||
|
else:
|
||||||
|
# Location without collections - owner access only
|
||||||
|
if request.user.is_authenticated and request.user == owner_id:
|
||||||
|
is_authorized = True
|
||||||
|
|
||||||
|
if not is_authorized:
|
||||||
|
return Response({
|
||||||
|
'message': 'This image belongs to a private location and you are not authorized.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'immich.permission_denied'
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
else:
|
||||||
|
# For non-Location objects, allow only owner access for now
|
||||||
|
if not request.user.is_authenticated or request.user != owner_id:
|
||||||
|
return Response({
|
||||||
|
'message': 'This image is not publicly accessible and you are not the owner.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'immich.permission_denied'
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
else:
|
else:
|
||||||
# No ContentImage exists; allow only the integration owner
|
# No ContentImage exists; allow only the integration owner
|
||||||
if not request.user.is_authenticated or request.user != owner_id:
|
if not request.user.is_authenticated or request.user != owner_id:
|
||||||
|
|
|
@ -12,19 +12,23 @@
|
||||||
import Search from '~icons/mdi/magnify';
|
import Search from '~icons/mdi/magnify';
|
||||||
import Clear from '~icons/mdi/close';
|
import Clear from '~icons/mdi/close';
|
||||||
import Link from '~icons/mdi/link-variant';
|
import Link from '~icons/mdi/link-variant';
|
||||||
|
import Share from '~icons/mdi/share-variant';
|
||||||
|
|
||||||
let collections: Collection[] = [];
|
let collections: Collection[] = [];
|
||||||
|
let sharedCollections: Collection[] = [];
|
||||||
|
let allCollections: Collection[] = [];
|
||||||
let filteredCollections: Collection[] = [];
|
let filteredCollections: Collection[] = [];
|
||||||
let searchQuery: string = '';
|
let searchQuery: string = '';
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
export let linkedCollectionList: string[] | null = null;
|
export let linkedCollectionList: string[] | null = null;
|
||||||
|
|
||||||
// Search functionality following worldtravel pattern
|
// Search functionality following worldtravel pattern
|
||||||
$: {
|
$: {
|
||||||
if (searchQuery === '') {
|
if (searchQuery === '') {
|
||||||
filteredCollections = collections;
|
filteredCollections = allCollections;
|
||||||
} else {
|
} else {
|
||||||
filteredCollections = collections.filter((collection) =>
|
filteredCollections = allCollections.filter((collection) =>
|
||||||
collection.name.toLowerCase().includes(searchQuery.toLowerCase())
|
collection.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -36,28 +40,57 @@
|
||||||
modal.showModal();
|
modal.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = await fetch(`/api/collections/all/`, {
|
try {
|
||||||
method: 'GET'
|
// Fetch both own collections and shared collections
|
||||||
});
|
const [ownRes, sharedRes] = await Promise.all([
|
||||||
|
fetch(`/api/collections/all/`, { method: 'GET' }),
|
||||||
|
fetch(`/api/collections/shared`, { method: 'GET' })
|
||||||
|
]);
|
||||||
|
|
||||||
let result = await res.json();
|
const ownResult = await ownRes.json();
|
||||||
|
const sharedResult = await sharedRes.json();
|
||||||
|
|
||||||
if (result.type === 'success' && result.data) {
|
// Process own collections
|
||||||
collections = result.data.adventures as Collection[];
|
if (ownResult.type === 'success' && ownResult.data) {
|
||||||
} else {
|
collections = ownResult.data.adventures as Collection[];
|
||||||
collections = result as Collection[];
|
} else {
|
||||||
|
collections = ownResult as Collection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process shared collections
|
||||||
|
if (sharedResult.type === 'success' && sharedResult.data) {
|
||||||
|
sharedCollections = sharedResult.data.adventures as Collection[];
|
||||||
|
} else {
|
||||||
|
sharedCollections = sharedResult as Collection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't combine collections - keep them separate
|
||||||
|
allCollections = collections;
|
||||||
|
|
||||||
|
// Move linked collections to the front for each collection type
|
||||||
|
if (linkedCollectionList) {
|
||||||
|
collections.sort((a, b) => {
|
||||||
|
const aLinked = linkedCollectionList?.includes(a.id);
|
||||||
|
const bLinked = linkedCollectionList?.includes(b.id);
|
||||||
|
return aLinked === bLinked ? 0 : aLinked ? -1 : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
sharedCollections.sort((a, b) => {
|
||||||
|
const aLinked = linkedCollectionList?.includes(a.id);
|
||||||
|
const bLinked = linkedCollectionList?.includes(b.id);
|
||||||
|
return aLinked === bLinked ? 0 : aLinked ? -1 : 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching collections:', error);
|
||||||
|
// Fallback to empty arrays
|
||||||
|
collections = [];
|
||||||
|
sharedCollections = [];
|
||||||
|
allCollections = [];
|
||||||
|
filteredCollections = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move linked collections to the front
|
|
||||||
if (linkedCollectionList) {
|
|
||||||
collections.sort((a, b) => {
|
|
||||||
const aLinked = linkedCollectionList?.includes(a.id);
|
|
||||||
const bLinked = linkedCollectionList?.includes(b.id);
|
|
||||||
return aLinked === bLinked ? 0 : aLinked ? -1 : 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredCollections = collections;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
@ -80,7 +113,23 @@
|
||||||
|
|
||||||
// Statistics following worldtravel pattern
|
// Statistics following worldtravel pattern
|
||||||
$: linkedCount = linkedCollectionList ? linkedCollectionList.length : 0;
|
$: linkedCount = linkedCollectionList ? linkedCollectionList.length : 0;
|
||||||
$: totalCollections = collections.length;
|
$: totalCollections = collections.length + sharedCollections.length;
|
||||||
|
$: ownCollectionsCount = collections.length;
|
||||||
|
$: sharedCollectionsCount = sharedCollections.length;
|
||||||
|
|
||||||
|
// Filtered collections for display
|
||||||
|
$: filteredOwnCollections =
|
||||||
|
searchQuery === ''
|
||||||
|
? collections
|
||||||
|
: collections.filter((collection) =>
|
||||||
|
collection.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
$: filteredSharedCollections =
|
||||||
|
searchQuery === ''
|
||||||
|
? sharedCollections
|
||||||
|
: sharedCollections.filter((collection) =>
|
||||||
|
collection.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
|
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
|
||||||
|
@ -106,7 +155,7 @@
|
||||||
{$t('adventures.my_collections')}
|
{$t('adventures.my_collections')}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-base-content/60">
|
<p class="text-sm text-base-content/60">
|
||||||
{filteredCollections.length}
|
{filteredOwnCollections.length + filteredSharedCollections.length}
|
||||||
{$t('worldtravel.of')}
|
{$t('worldtravel.of')}
|
||||||
{totalCollections}
|
{totalCollections}
|
||||||
{$t('navbar.collections')}
|
{$t('navbar.collections')}
|
||||||
|
@ -122,8 +171,15 @@
|
||||||
<div class="stat-value text-lg text-success">{linkedCount}</div>
|
<div class="stat-value text-lg text-success">{linkedCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat py-2 px-4">
|
<div class="stat py-2 px-4">
|
||||||
<div class="stat-title text-xs">{$t('collection.available')}</div>
|
<div class="stat-title text-xs">{$t('navbar.collections')}</div>
|
||||||
<div class="stat-value text-lg text-info">{totalCollections}</div>
|
<div class="stat-value text-lg text-info">{ownCollectionsCount}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat py-2 px-4">
|
||||||
|
<div class="stat-title text-xs flex items-center gap-1">
|
||||||
|
<Share class="w-3 h-3" />
|
||||||
|
{$t('collection.shared')}
|
||||||
|
</div>
|
||||||
|
<div class="stat-value text-lg text-warning">{sharedCollectionsCount}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -164,8 +220,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="px-2">
|
<div class="px-6">
|
||||||
{#if filteredCollections.length === 0}
|
{#if loading}
|
||||||
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
|
<div class="loading loading-spinner loading-lg text-primary mb-4"></div>
|
||||||
|
<p class="text-base-content/60">{$t('loading.collections')}</p>
|
||||||
|
</div>
|
||||||
|
{:else if filteredOwnCollections.length === 0 && filteredSharedCollections.length === 0}
|
||||||
<div class="flex flex-col items-center justify-center py-16">
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
|
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
|
||||||
<Collections class="w-16 h-16 text-base-content/30" />
|
<Collections class="w-16 h-16 text-base-content/30" />
|
||||||
|
@ -192,17 +253,69 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Collections Grid -->
|
<!-- Collections Grid -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-6 p-4">
|
<div class="space-y-8">
|
||||||
{#each filteredCollections as collection}
|
<!-- Own Collections Section -->
|
||||||
<CollectionCard
|
{#if filteredOwnCollections.length > 0}
|
||||||
{collection}
|
<div>
|
||||||
type="link"
|
<div class="flex items-center gap-2 mb-4">
|
||||||
on:link={link}
|
<Collections class="w-5 h-5 text-primary" />
|
||||||
bind:linkedCollectionList
|
<h2 class="text-lg font-semibold text-base-content">
|
||||||
on:unlink={unlink}
|
{$t('collection.my_collections')}
|
||||||
user={null}
|
</h2>
|
||||||
/>
|
<div class="badge badge-primary badge-sm">
|
||||||
{/each}
|
{filteredOwnCollections.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{#each filteredOwnCollections as collection}
|
||||||
|
<CollectionCard
|
||||||
|
{collection}
|
||||||
|
type="link"
|
||||||
|
on:link={link}
|
||||||
|
bind:linkedCollectionList
|
||||||
|
on:unlink={unlink}
|
||||||
|
user={null}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Shared Collections Section -->
|
||||||
|
{#if filteredSharedCollections.length > 0}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<Share class="w-5 h-5 text-warning" />
|
||||||
|
<h2 class="text-lg font-semibold text-base-content">
|
||||||
|
{$t('collection.shared_with_me')}
|
||||||
|
</h2>
|
||||||
|
<div class="badge badge-warning badge-sm">
|
||||||
|
{filteredSharedCollections.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{#each filteredSharedCollections as collection}
|
||||||
|
<div class="relative">
|
||||||
|
<CollectionCard
|
||||||
|
{collection}
|
||||||
|
type="link"
|
||||||
|
on:link={link}
|
||||||
|
bind:linkedCollectionList
|
||||||
|
on:unlink={unlink}
|
||||||
|
user={null}
|
||||||
|
/>
|
||||||
|
<!-- Shared badge overlay -->
|
||||||
|
<div class="absolute -top-2 -right-2 z-10">
|
||||||
|
<div class="badge badge-warning badge-sm gap-1 shadow-lg">
|
||||||
|
<Share class="w-3 h-3" />
|
||||||
|
{$t('collection.shared')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -68,7 +68,8 @@
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
immich_id: imageId,
|
immich_id: imageId,
|
||||||
location: location.id
|
object_id: location.id,
|
||||||
|
content_type: 'location'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
|
@ -62,6 +62,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Creator avatar helpers
|
||||||
|
$: creatorInitials =
|
||||||
|
adventure.user?.first_name && adventure.user?.last_name
|
||||||
|
? `${adventure.user.first_name[0]}${adventure.user.last_name[0]}`
|
||||||
|
: adventure.user?.first_name?.[0] || adventure.user?.username?.[0] || '?';
|
||||||
|
|
||||||
|
$: creatorDisplayName = adventure.user?.first_name
|
||||||
|
? `${adventure.user.first_name} ${adventure.user.last_name || ''}`.trim()
|
||||||
|
: adventure.user?.username || 'Unknown User';
|
||||||
|
|
||||||
// Helper functions for display
|
// Helper functions for display
|
||||||
function formatVisitCount() {
|
function formatVisitCount() {
|
||||||
const count = adventure.visits.length;
|
const count = adventure.visits.length;
|
||||||
|
@ -221,6 +231,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Creator Avatar -->
|
||||||
|
{#if adventure.user}
|
||||||
|
<div class="absolute bottom-4 right-4">
|
||||||
|
<div class="tooltip tooltip-left" data-tip={creatorDisplayName}>
|
||||||
|
<div class="avatar">
|
||||||
|
<div class="w-8 h-8 rounded-full ring-2 ring-white/50 shadow-lg">
|
||||||
|
{#if adventure.user.profile_pic}
|
||||||
|
<img
|
||||||
|
src={adventure.user.profile_pic}
|
||||||
|
alt={creatorDisplayName}
|
||||||
|
class="rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-primary-content font-semibold text-xs shadow-lg"
|
||||||
|
>
|
||||||
|
{creatorInitials.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content Section -->
|
<!-- Content Section -->
|
||||||
|
@ -280,7 +315,7 @@
|
||||||
{$t('adventures.open_details')}
|
{$t('adventures.open_details')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if (adventure.user && adventure.user.uuid == user?.uuid) || (collection && user && collection.shared_with?.includes(user.uuid))}
|
{#if (adventure.user && adventure.user.uuid == user?.uuid) || (collection && user && collection.shared_with?.includes(user.uuid)) || (collection && user && collection.user == user.uuid)}
|
||||||
<div class="dropdown dropdown-end">
|
<div class="dropdown dropdown-end">
|
||||||
<div tabindex="0" role="button" class="btn btn-square btn-sm btn-base-300">
|
<div tabindex="0" role="button" class="btn btn-square btn-sm btn-base-300">
|
||||||
<DotsHorizontal class="w-5 h-5" />
|
<DotsHorizontal class="w-5 h-5" />
|
||||||
|
@ -307,20 +342,34 @@
|
||||||
{$t('collection.manage_collections')}
|
{$t('collection.manage_collections')}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
{:else if collection && user && collection.user == user.uuid}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
on:click={() =>
|
||||||
|
removeFromCollection(
|
||||||
|
new CustomEvent('unlink', { detail: collection.id })
|
||||||
|
)}
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LinkVariantRemove class="w-4 h-4" />
|
||||||
|
{$t('adventures.remove_from_collection')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if user.uuid == adventure.user?.uuid}
|
||||||
|
<div class="divider my-1"></div>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
id="delete_adventure"
|
||||||
|
data-umami-event="Delete Adventure"
|
||||||
|
class="text-error flex items-center gap-2"
|
||||||
|
on:click={() => (isWarningModalOpen = true)}
|
||||||
|
>
|
||||||
|
<TrashCan class="w-4 h-4" />
|
||||||
|
{$t('adventures.delete')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="divider my-1"></div>
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
id="delete_adventure"
|
|
||||||
data-umami-event="Delete Adventure"
|
|
||||||
class="text-error flex items-center gap-2"
|
|
||||||
on:click={() => (isWarningModalOpen = true)}
|
|
||||||
>
|
|
||||||
<TrashCan class="w-4 h-4" />
|
|
||||||
{$t('adventures.delete')}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import type { Location, Attachment, Category, Collection } from '$lib/types';
|
import type { Location, Attachment, Category, Collection, User } from '$lib/types';
|
||||||
import { addToast } from '$lib/toasts';
|
import { addToast } from '$lib/toasts';
|
||||||
import { deserialize } from '$app/forms';
|
import { deserialize } from '$app/forms';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
export let collection: Collection | null = null;
|
export let collection: Collection | null = null;
|
||||||
|
export let user: User | null = null;
|
||||||
|
|
||||||
let fullStartDate: string = '';
|
let fullStartDate: string = '';
|
||||||
let fullEndDate: string = '';
|
let fullEndDate: string = '';
|
||||||
|
@ -92,6 +93,7 @@
|
||||||
import AttachmentCard from './AttachmentCard.svelte';
|
import AttachmentCard from './AttachmentCard.svelte';
|
||||||
import LocationDropdown from './LocationDropdown.svelte';
|
import LocationDropdown from './LocationDropdown.svelte';
|
||||||
import DateRangeCollapse from './DateRangeCollapse.svelte';
|
import DateRangeCollapse from './DateRangeCollapse.svelte';
|
||||||
|
import UserCard from './UserCard.svelte';
|
||||||
let modal: HTMLDialogElement;
|
let modal: HTMLDialogElement;
|
||||||
|
|
||||||
let wikiError: string = '';
|
let wikiError: string = '';
|
||||||
|
@ -325,7 +327,8 @@
|
||||||
async function uploadImage(file: File) {
|
async function uploadImage(file: File) {
|
||||||
let formData = new FormData();
|
let formData = new FormData();
|
||||||
formData.append('image', file);
|
formData.append('image', file);
|
||||||
formData.append('location', location.id);
|
formData.append('object_id', location.id);
|
||||||
|
formData.append('content_type', 'location');
|
||||||
|
|
||||||
let res = await fetch(`/locations?/image`, {
|
let res = await fetch(`/locations?/image`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -537,8 +540,21 @@
|
||||||
<label for="link"
|
<label for="link"
|
||||||
>{$t('adventures.category')}<span class="text-red-500">*</span></label
|
>{$t('adventures.category')}<span class="text-red-500">*</span></label
|
||||||
><br />
|
><br />
|
||||||
|
{#if (user && user.uuid == location.user?.uuid) || !locationToEdit}
|
||||||
<CategoryDropdown bind:categories bind:selected_category={location.category} />
|
<CategoryDropdown bind:categories bind:selected_category={location.category} />
|
||||||
|
{:else}
|
||||||
|
<!-- read only view of category info name and icon -->
|
||||||
|
<div
|
||||||
|
class="flex items-center space-x-3 p-3 bg-base-100 border border-base-300 rounded-lg"
|
||||||
|
>
|
||||||
|
{#if location.category?.icon}
|
||||||
|
<span class="text-2xl flex-shrink-0">{location.category.icon}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-base font-medium text-base-content">
|
||||||
|
{location.category?.display_name || location.category?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="rating">{$t('adventures.rating')}</label><br />
|
<label for="rating">{$t('adventures.rating')}</label><br />
|
||||||
|
|
|
@ -694,6 +694,7 @@
|
||||||
on:close={() => (isLocationModalOpen = false)}
|
on:close={() => (isLocationModalOpen = false)}
|
||||||
on:save={saveOrCreateAdventure}
|
on:save={saveOrCreateAdventure}
|
||||||
{collection}
|
{collection}
|
||||||
|
user={data.user}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
@ -760,7 +761,7 @@
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content z-[1] menu p-4 shadow bg-base-300 text-base-content rounded-box w-52 gap-4"
|
class="dropdown-content z-[1] menu p-4 shadow bg-base-300 text-base-content rounded-box w-52 gap-4"
|
||||||
>
|
>
|
||||||
{#if collection.user === data.user.uuid}
|
{#if collection.user === data.user.uuid || (collection.shared_with && collection.shared_with.includes(data.user.uuid))}
|
||||||
<p class="text-center font-bold text-lg">{$t('adventures.link_new')}</p>
|
<p class="text-center font-bold text-lg">{$t('adventures.link_new')}</p>
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
|
|
|
@ -172,6 +172,7 @@
|
||||||
locationToEdit={adventureToEdit}
|
locationToEdit={adventureToEdit}
|
||||||
on:close={() => (isLocationModalOpen = false)}
|
on:close={() => (isLocationModalOpen = false)}
|
||||||
on:save={saveOrCreate}
|
on:save={saveOrCreate}
|
||||||
|
user={data.user}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
|
@ -122,7 +122,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImageModal(imageIndex: number) {
|
function openImageModal(imageIndex: number) {
|
||||||
adventure_images = adventure.images.map(img => ({
|
adventure_images = adventure.images.map((img) => ({
|
||||||
image: img.image,
|
image: img.image,
|
||||||
adventure: adventure
|
adventure: adventure
|
||||||
}));
|
}));
|
||||||
|
@ -151,6 +151,7 @@
|
||||||
locationToEdit={adventure}
|
locationToEdit={adventure}
|
||||||
on:close={() => (isEditModalOpen = false)}
|
on:close={() => (isEditModalOpen = false)}
|
||||||
on:save={saveEdit}
|
on:save={saveEdit}
|
||||||
|
user={data.user}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
|
@ -461,6 +461,7 @@
|
||||||
on:close={() => (createModalOpen = false)}
|
on:close={() => (createModalOpen = false)}
|
||||||
on:save={createNewAdventure}
|
on:save={createNewAdventure}
|
||||||
{initialLatLng}
|
{initialLatLng}
|
||||||
|
user={data.user}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue