Implement permission checks for shared users and anonymous access in collection views. Enhance tests for fetching and exporting images and PDFs based on user permissions. Update app version to v0.12.1-beta-061626.

This commit is contained in:
Sean Morley
2026-06-16 16:01:59 -04:00
parent 5998e5edad
commit 794f403d80
10 changed files with 4096 additions and 4048 deletions

View File

@@ -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:

View File

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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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 @@
</script>
<!-- Add to Collection CTA (compact) -->
<div class="card bg-base-100 shadow-sm mb-3 border border-base-200">
<div class="card-body py-3 px-4 gap-2">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 text-primary"
>
<Plus class="w-4 h-4" />
</span>
<div class="min-w-0">
<p class="text-sm font-semibold leading-tight truncate">
{$t('adventures.add_to_collection')}
</p>
<p class="text-xs text-base-content/60 leading-tight truncate">
{$t('adventures.click_map_add_marker')}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<button type="button" class="btn btn-primary btn-xs" on:click={openCreateModal}>
<Plus class="w-4 h-4" />
{$t('adventures.add')}
</button>
{#if newMarker}
<button type="button" class="btn btn-ghost btn-xs" on:click={clearNewMarker}>
<Clear class="w-4 h-4" />
{$t('adventures.clear')}
</button>
{/if}
</div>
</div>
{#if newMarker}
<div
class="alert alert-info alert-sm flex flex-col sm:flex-row sm:items-center gap-2 py-2 px-3"
>
<div class="flex items-center gap-2 text-xs sm:text-sm">
<PinIcon class="w-4 h-4" />
<span class="truncate">
{newLatitude?.toFixed(4)}, {newLongitude?.toFixed(4)}
</span>
</div>
<div class="flex gap-2 sm:ml-auto">
<button
type="button"
class="btn btn-primary btn-xxs sm:btn-xs"
on:click={openCreateModal}
{#if canModify}
<div class="card bg-base-100 shadow-sm mb-3 border border-base-200">
<div class="card-body py-3 px-4 gap-2">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 text-primary"
>
<Plus class="w-3 h-3 sm:w-4 sm:h-4" />
{$t('adventures.add_here')}
</button>
<button type="button" class="btn btn-ghost btn-xxs sm:btn-xs" on:click={clearNewMarker}>
<Clear class="w-3 h-3 sm:w-4 sm:h-4" />
<Plus class="w-4 h-4" />
</span>
<div class="min-w-0">
<p class="text-sm font-semibold leading-tight truncate">
{$t('adventures.add_to_collection')}
</p>
<p class="text-xs text-base-content/60 leading-tight truncate">
{$t('adventures.click_map_add_marker')}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<button type="button" class="btn btn-primary btn-xs" on:click={openCreateModal}>
<Plus class="w-4 h-4" />
{$t('adventures.add')}
</button>
{#if newMarker}
<button type="button" class="btn btn-ghost btn-xs" on:click={clearNewMarker}>
<Clear class="w-4 h-4" />
{$t('adventures.clear')}
</button>
{/if}
</div>
</div>
{/if}
{#if newMarker}
<div
class="alert alert-info alert-sm flex flex-col sm:flex-row sm:items-center gap-2 py-2 px-3"
>
<div class="flex items-center gap-2 text-xs sm:text-sm">
<PinIcon class="w-4 h-4" />
<span class="truncate">
{newLatitude?.toFixed(4)}, {newLongitude?.toFixed(4)}
</span>
</div>
<div class="flex gap-2 sm:ml-auto">
<button
type="button"
class="btn btn-primary btn-xxs sm:btn-xs"
on:click={openCreateModal}
>
<Plus class="w-3 h-3 sm:w-4 sm:h-4" />
{$t('adventures.add_here')}
</button>
<button type="button" class="btn btn-ghost btn-xxs sm:btn-xs" on:click={clearNewMarker}>
<Clear class="w-3 h-3 sm:w-4 sm:h-4" />
</button>
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Filter Header -->
<div class="card bg-base-100 shadow-lg mb-4">
@@ -923,6 +926,7 @@
bind:basemapType
{clusterEnabled}
clusterOptions={resolvedClusterOptions}
mapClickEnabled={canModify}
on:mapClick={handleMapClick}
>
<svelte:fragment slot="marker" let:markerProps let:markerLngLat let:isActive let:setActive>
@@ -1038,7 +1042,7 @@
</GeoJSON>
{/if}
{#if newMarker}
{#if canModify && newMarker}
<Marker lngLat={[newMarker.lngLat.lng, newMarker.lngLat.lat]} class="map-pin">
<div
class="map-pin-hit grid place-items-center w-10 h-10 rounded-full bg-primary text-primary-content border-2 border-base-100 shadow-lg"
@@ -1051,7 +1055,7 @@
</FullMap>
</div>
{#if createModalOpen}
{#if canModify && createModalOpen}
<NewLocationModal
on:create={handleLocationCreated}
on:save={handleLocationSaved}

View File

@@ -1,4 +1,4 @@
export let appVersion = 'v0.12.1-beta-061526';
export let appVersion = 'v0.12.1-beta-061626';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.12.1';
export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2026';

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -194,42 +194,6 @@
});
})();
// 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: 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}
<SocialShareModal
type="collection"
id={collection.id}
@@ -1193,24 +1193,26 @@
</button>
{/if}
</div>
<div class="flex flex-wrap gap-2">
<button
class="btn btn-outline btn-primary gap-2"
on:click={() => (isSocialShareModalOpen = true)}
>
<ImageOutline class="w-5 h-5" aria-hidden="true" />
<span>{$t('social_share.share_externally')}</span>
</button>
<button
class="btn btn-outline btn-primary gap-2"
on:click={exportCollectionPdf}
disabled={isExportingPdf}
aria-busy={isExportingPdf}
>
<FilePdfBox class="w-5 h-5" aria-hidden="true" />
<span>{isExportingPdf ? '...' : $t('adventures.export_pdf')}</span>
</button>
</div>
{#if canExportCollection}
<div class="flex flex-wrap gap-2">
<button
class="btn btn-outline btn-primary gap-2"
on:click={() => (isSocialShareModalOpen = true)}
>
<ImageOutline class="w-5 h-5" aria-hidden="true" />
<span>{$t('social_share.share_externally')}</span>
</button>
<button
class="btn btn-outline btn-primary gap-2"
on:click={exportCollectionPdf}
disabled={isExportingPdf}
aria-busy={isExportingPdf}
>
<FilePdfBox class="w-5 h-5" aria-hidden="true" />
<span>{isExportingPdf ? '...' : $t('adventures.export_pdf')}</span>
</button>
</div>
{/if}
</div>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 sm:gap-10">
@@ -1258,7 +1260,7 @@
<div class="card-body">
<h2 class="card-title text-2xl mb-4">🗺️ {$t('navbar.map')}</h2>
<div class="rounded-lg overflow-hidden shadow-lg">
<CollectionMap bind:collection user={data.user} />
<CollectionMap bind:collection user={data.user} canModify={canModifyCollection} />
</div>
</div>
</div>
@@ -1320,7 +1322,7 @@
{/if}
<!-- Recommendations View -->
{#if currentView === 'recommendations'}
{#if currentView === 'recommendations' && canModifyCollection}
<CollectionRecommendationView bind:collection user={data.user} />
{/if}
</div>