diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py index 890b9b08..0b8218b2 100644 --- a/backend/server/adventures/permissions.py +++ b/backend/server/adventures/permissions.py @@ -40,6 +40,10 @@ class CollectionShared(permissions.BasePermission): - Shared users: full access to collections they're shared with and related objects - Public access: read-only for objects marked as public """ + def has_permission(self, request, view): + return (request.user and request.user.is_authenticated) or \ + request.method in permissions.SAFE_METHODS + def has_object_permission(self, request, view, obj): user = request.user if not user or not user.is_authenticated: diff --git a/backend/server/adventures/tests/test_share_image.py b/backend/server/adventures/tests/test_share_image.py index c7782eab..126691fe 100644 --- a/backend/server/adventures/tests/test_share_image.py +++ b/backend/server/adventures/tests/test_share_image.py @@ -164,3 +164,41 @@ class ShareImageApiTests(TestCase): res = self.client.get(url) self.assertEqual(res.status_code, 200) self.assertIn('attachment', res['Content-Disposition']) + + def test_shared_user_can_fetch_private_collection_share_image(self): + self.private_collection.shared_with.add(self.other) + self.client.logout() + self.client.login(username='apiother', password='testpass123') + url = f'/api/collections/{self.private_collection.id}/share-image/square/' + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res['Content-Type'], 'image/png') + + def test_anonymous_can_export_public_collection_pdf(self): + self.client.logout() + url = f'/api/collections/{self.public_collection.id}/export-pdf/' + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res['Content-Type'], 'application/pdf') + self.assertTrue(res.content.startswith(b'%PDF')) + + def test_owner_can_export_private_collection_pdf(self): + url = f'/api/collections/{self.private_collection.id}/export-pdf/' + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res['Content-Type'], 'application/pdf') + + def test_anonymous_cannot_export_private_collection_pdf(self): + self.client.logout() + url = f'/api/collections/{self.private_collection.id}/export-pdf/' + res = self.client.get(url) + self.assertIn(res.status_code, (403, 404)) + + def test_shared_user_can_export_private_collection_pdf(self): + self.private_collection.shared_with.add(self.other) + self.client.logout() + self.client.login(username='apiother', password='testpass123') + url = f'/api/collections/{self.private_collection.id}/export-pdf/' + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res['Content-Type'], 'application/pdf') diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index 6420ab5c..34fec458 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -160,7 +160,7 @@ class CollectionViewSet(viewsets.ModelViewSet): | Q(shared_with=self.request.user) | Q(invites__invited_user=self.request.user) ).distinct() - elif self.action == 'retrieve': + elif self.action in ('retrieve', 'export_pdf', 'share_image'): if not self.request.user.is_authenticated: queryset = Collection.objects.filter(is_public=True) else: diff --git a/backend/server/adventures/views/location_view.py b/backend/server/adventures/views/location_view.py index 9a6024e1..87595518 100644 --- a/backend/server/adventures/views/location_view.py +++ b/backend/server/adventures/views/location_view.py @@ -68,7 +68,7 @@ class LocationViewSet(viewsets.ModelViewSet): Public actions allow unauthenticated access to public locations. """ user = self.request.user - public_allowed_actions = {'retrieve', 'additional_info'} + public_allowed_actions = {'retrieve', 'additional_info', 'share_image'} if not user.is_authenticated: if self.action in public_allowed_actions: diff --git a/frontend/src/lib/components/collections/CollectionMap.svelte b/frontend/src/lib/components/collections/CollectionMap.svelte index 7edd625c..5a323087 100644 --- a/frontend/src/lib/components/collections/CollectionMap.svelte +++ b/frontend/src/lib/components/collections/CollectionMap.svelte @@ -16,6 +16,7 @@ export let collection: Collection; export let user: User | null = null; + export let canModify: boolean = false; // Allow disabling/enabling clustering for markers export let clusterEnabled: boolean = false; export let clusterOptions: any = { radius: 300, maxZoom: 8, minPoints: 2 }; @@ -642,65 +643,67 @@ -
-
-
-
- - - -
-

- {$t('adventures.add_to_collection')} -

-

- {$t('adventures.click_map_add_marker')} -

-
-
-
- - {#if newMarker} - - {/if} -
-
- - {#if newMarker} -
-
- - - {newLatitude?.toFixed(4)}, {newLongitude?.toFixed(4)} - -
-
- -
+
+ + {#if newMarker} + + {/if}
- {/if} + + {#if newMarker} +
+
+ + + {newLatitude?.toFixed(4)}, {newLongitude?.toFixed(4)} + +
+
+ + +
+
+ {/if} +
- +{/if}
@@ -923,6 +926,7 @@ bind:basemapType {clusterEnabled} clusterOptions={resolvedClusterOptions} + mapClickEnabled={canModify} on:mapClick={handleMapClick} > @@ -1038,7 +1042,7 @@ {/if} - {#if newMarker} + {#if canModify && newMarker}
-{#if createModalOpen} +{#if canModify && createModalOpen} l.latitude && l.longitude) || - collection?.lodging?.some((l) => l.latitude && l.longitude) || - collection?.transportations?.some( - (t) => - (t.origin_latitude && t.origin_longitude) || - (t.destination_latitude && t.destination_longitude) - ) || - false, - calendar: !isFolderView, - recommendations: true, // may be overridden by permission check below - stats: true - }; - - // Get default view based on available views - let defaultView: ViewType; - $: defaultView = (availableViews.itinerary ? 'itinerary' : 'all') as ViewType; - - // Read view from URL params and validate it's available - $: { - const view = $page.url.searchParams.get('view') as ViewType; - if ( - view && - ['all', 'itinerary', 'map', 'calendar', 'recommendations', 'stats'].includes(view) && - availableViews[view] - ) { - currentView = view; - } else { - currentView = defaultView; - } - } - // Determine whether current user can modify the collection (owner or shared user) $: canModifyCollection = (() => { const u = data.user as any; @@ -255,8 +219,44 @@ return false; })(); - // Enforce recommendations visibility only for owner/shared users - $: availableViews.recommendations = !!canModifyCollection; + // Public collections: anyone can export/share; private: owner or shared users only + $: canExportCollection = !!collection?.is_public || canModifyCollection; + + // Define available views based on collection type + $: availableViews = { + all: true, // Always available + itinerary: !isFolderView, // Only for collections with dates + map: + collection?.locations?.some((l) => l.latitude && l.longitude) || + collection?.lodging?.some((l) => l.latitude && l.longitude) || + collection?.transportations?.some( + (t) => + (t.origin_latitude && t.origin_longitude) || + (t.destination_latitude && t.destination_longitude) + ) || + false, + calendar: !isFolderView, + recommendations: canModifyCollection, + stats: true + }; + + // Get default view based on available views + let defaultView: ViewType; + $: defaultView = (availableViews.itinerary ? 'itinerary' : 'all') as ViewType; + + // Read view from URL params and validate it's available + $: { + const view = $page.url.searchParams.get('view') as ViewType; + if ( + view && + ['all', 'itinerary', 'map', 'calendar', 'recommendations', 'stats'].includes(view) && + availableViews[view] + ) { + currentView = view; + } else { + currentView = defaultView; + } + } let isExportingPdf = false; let isSocialShareModalOpen = false; @@ -850,7 +850,7 @@ /> {/if} -{#if isSocialShareModalOpen && collection} +{#if isSocialShareModalOpen && collection && canExportCollection} {/if}
-
- - -
+ {#if canExportCollection} +
+ + +
+ {/if}
@@ -1258,7 +1260,7 @@

🗺️ {$t('navbar.map')}

- +
@@ -1320,7 +1322,7 @@ {/if} - {#if currentView === 'recommendations'} + {#if currentView === 'recommendations' && canModifyCollection} {/if}