mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-07-30 15:28:30 -04:00
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:
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user