diff --git a/backend/server/adventures/migrations/0060_alter_collectioninvite_unique_together.py b/backend/server/adventures/migrations/0060_alter_collectioninvite_unique_together.py new file mode 100644 index 0000000..23a64eb --- /dev/null +++ b/backend/server/adventures/migrations/0060_alter_collectioninvite_unique_together.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.2 on 2025-08-07 16:00 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0059_alter_activity_options'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='collectioninvite', + unique_together={('collection', 'invited_user')}, + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 1a70a4b..7434d59 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -267,8 +267,16 @@ class CollectionInvite(models.Model): def __str__(self): return f"Invite for {self.invited_user.username} to {self.collection.name}" + def clean(self): + if self.collection.user == self.invited_user: + raise ValidationError("You cannot invite yourself to your own collection.") + # dont allow if the user is already shared with the collection + if self.invited_user in self.collection.shared_with.all(): + raise ValidationError("This user is already shared with the collection.") + class Meta: verbose_name = "Collection Invite" + unique_together = ('collection', 'invited_user') class Collection(models.Model): #id = models.AutoField(primary_key=True) diff --git a/backend/server/adventures/views/activity_view.py b/backend/server/adventures/views/activity_view.py index 01a84ab..3be88a6 100644 --- a/backend/server/adventures/views/activity_view.py +++ b/backend/server/adventures/views/activity_view.py @@ -39,12 +39,12 @@ class ActivityViewSet(viewsets.ModelViewSet): Set the user when creating an activity. """ visit = serializer.validated_data.get('visit') - location = visit.location if visit else None + location = visit.location if location and not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location): raise PermissionDenied("You do not have permission to add an activity to this location.") - serializer.save(user=self.request.user) + serializer.save(user=location.user) def perform_update(self, serializer): instance = serializer.instance diff --git a/backend/server/adventures/views/trail_view.py b/backend/server/adventures/views/trail_view.py index 65e573f..cb9952a 100644 --- a/backend/server/adventures/views/trail_view.py +++ b/backend/server/adventures/views/trail_view.py @@ -21,8 +21,8 @@ class TrailViewSet(viewsets.ModelViewSet): user = self.request.user if not user or not user.is_authenticated: - return Trail.objects.none() - + raise PermissionDenied("You must be authenticated to view trails.") + # Build the filter for accessible locations location_filter = Q(location__user=user) # User owns the location @@ -37,13 +37,14 @@ class TrailViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): location = serializer.validated_data.get('location') - # Optional: import this if not in the same file - # from adventures.permissions import IsOwnerOrSharedWithFullAccess - if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location): raise PermissionDenied("You do not have permission to add a trail to this location.") - serializer.save(user=self.request.user) + # dont allow a user who does not own the location to attach a wanderer trail + if location.user != self.request.user and serializer.validated_data.get('wanderer_id'): + raise PermissionDenied("You cannot attach a wanderer trail to a location you do not own.") + + serializer.save(user=location.user) def perform_update(self, serializer): instance = serializer.instance diff --git a/backend/server/adventures/views/visit_view.py b/backend/server/adventures/views/visit_view.py index c0ae171..357ea05 100644 --- a/backend/server/adventures/views/visit_view.py +++ b/backend/server/adventures/views/visit_view.py @@ -21,7 +21,7 @@ class VisitViewSet(viewsets.ModelViewSet): user = self.request.user if not user or not user.is_authenticated: - return Visit.objects.none() + raise PermissionDenied("You must be authenticated to view visits.") # Build the filter for accessible locations location_filter = Q(location__user=user) # User owns the location diff --git a/backend/server/integrations/views/wanderer_view.py b/backend/server/integrations/views/wanderer_view.py index ffd8802..3229e12 100644 --- a/backend/server/integrations/views/wanderer_view.py +++ b/backend/server/integrations/views/wanderer_view.py @@ -35,6 +35,9 @@ class WandererIntegrationViewSet(viewsets.ViewSet): def create(self, request): if WandererIntegration.objects.filter(user=request.user).exists(): raise ValidationError("Wanderer integration already exists. Use UPDATE instead.") + + if not request.user.is_authenticated: + raise ValidationError("You must be authenticated to create a Wanderer integration.") server_url = request.data.get("server_url") username = request.data.get("username") @@ -66,6 +69,12 @@ class WandererIntegrationViewSet(viewsets.ViewSet): def update(self, request, pk=None): inst = self._get_obj() + + if not inst: + raise NotFound("Wanderer integration not found.") + if not self.request.user.is_authenticated: + raise ValidationError("You must be authenticated to update the integration.") + changed = False for field in ("server_url", "username"): if field in request.data and getattr(inst, field) != request.data[field]: @@ -94,6 +103,12 @@ class WandererIntegrationViewSet(viewsets.ViewSet): @action(detail=False, methods=["post"]) def disable(self, request): inst = self._get_obj() + + if not inst: + raise NotFound("Wanderer integration not found.") + if not self.request.user.is_authenticated: + raise ValidationError("You must be authenticated to disable the integration.") + inst.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -101,6 +116,9 @@ class WandererIntegrationViewSet(viewsets.ViewSet): def refresh(self, request): inst = self._get_obj() + if not self.request.user.is_authenticated: + raise ValidationError("You must be authenticated to refresh the integration.") + password = request.data.get("password") try: session = get_valid_session(inst, password_for_reauth=password) @@ -116,6 +134,9 @@ class WandererIntegrationViewSet(viewsets.ViewSet): @action(detail=False, methods=["get"], url_path='trails') def trails(self, request): inst = self._get_obj() + + if not self.request.user.is_authenticated: + raise ValidationError("You must be authenticated to access trails.") # Check if we need to prompt for password password = request.query_params.get("password") # Allow password via query param if needed diff --git a/backend/server/integrations/wanderer_services.py b/backend/server/integrations/wanderer_services.py index 0ee968a..b7e7473 100644 --- a/backend/server/integrations/wanderer_services.py +++ b/backend/server/integrations/wanderer_services.py @@ -78,6 +78,9 @@ def get_valid_session(integration: WandererIntegration, password_for_reauth: str """ now = django_timezone.now() session = requests.Session() + + if not integration: + raise IntegrationError("No Wanderer integration found.") # Check if we have a valid token if integration.token and integration.token_expiry and integration.token_expiry > now: diff --git a/frontend/src/lib/components/NewLocationModal.svelte b/frontend/src/lib/components/NewLocationModal.svelte index d5a14a0..4f738a4 100644 --- a/frontend/src/lib/components/NewLocationModal.svelte +++ b/frontend/src/lib/components/NewLocationModal.svelte @@ -252,6 +252,7 @@ { steps[1].selected = false; @@ -281,6 +282,7 @@ bind:images={location.images} bind:attachments={location.attachments} bind:trails={location.trails} + userIsOwner={user?.uuid === location.user?.uuid} on:back={() => { steps[2].selected = false; steps[1].selected = true; diff --git a/frontend/src/lib/components/locations/LocationDetails.svelte b/frontend/src/lib/components/locations/LocationDetails.svelte index 94154dd..c8b4819 100644 --- a/frontend/src/lib/components/locations/LocationDetails.svelte +++ b/frontend/src/lib/components/locations/LocationDetails.svelte @@ -61,6 +61,7 @@ longitude: number | null; location: string; tags: string[]; + collections?: string[]; } = { name: '', category: null, @@ -71,7 +72,8 @@ latitude: null, longitude: null, location: '', - tags: [] + tags: [], + collections: [] }; let user: User | null = null; @@ -84,6 +86,7 @@ export let initialLocation: any = null; export let currentUser: any = null; export let editingLocation: any = null; + export let collection: Collection | null = null; $: user = currentUser; $: locationToEdit = editingLocation; @@ -317,6 +320,9 @@ if (location.longitude !== null && typeof location.longitude === 'number') { location.longitude = parseFloat(location.longitude.toFixed(6)); } + if (collection && collection.id) { + location.collections = [collection.id]; + } // either a post or a patch depending on whether we're editing or creating if (locationToEdit && locationToEdit.id) { diff --git a/frontend/src/lib/components/locations/LocationMedia.svelte b/frontend/src/lib/components/locations/LocationMedia.svelte index e1037e7..910cd90 100644 --- a/frontend/src/lib/components/locations/LocationMedia.svelte +++ b/frontend/src/lib/components/locations/LocationMedia.svelte @@ -1,5 +1,5 @@