diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py index 11aec80..a54e8dd 100644 --- a/backend/server/adventures/permissions.py +++ b/backend/server/adventures/permissions.py @@ -109,6 +109,9 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission): is_safe_method = request.method in permissions.SAFE_METHODS # If the object has a location field, get that location and continue checking with that object, basically from the location's perspective. I am very proud of this line of code and that's why I am writing this comment. + + print("Checking permissions for object", obj, "of type", type(obj).__name__) + if type(obj).__name__ == 'Trail': obj = obj.location @@ -117,6 +120,13 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission): if hasattr(obj, 'visit') and hasattr(obj.visit, 'location'): obj = obj.visit.location + + if type(obj).__name__ == 'Visit': + print("Checking permissions for Visit object", obj) + # If the object is a Visit, get its location + if hasattr(obj, 'location'): + obj = obj.location + # Anonymous users only get read access to public objects if not user or not user.is_authenticated: return is_safe_method and getattr(obj, 'is_public', False) diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 5f9073d..e2ba426 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -215,8 +215,13 @@ class VisitSerializer(serializers.ModelSerializer): class Meta: model = Visit - fields = ['id', 'start_date', 'end_date', 'timezone', 'notes', 'activities'] - read_only_fields = ['id'] + fields = ['id', 'start_date', 'end_date', 'timezone', 'notes', 'activities','location', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def create(self, validated_data): + if not validated_data.get('end_date') and validated_data.get('start_date'): + validated_data['end_date'] = validated_data['start_date'] + return super().create(validated_data) class LocationSerializer(CustomModelSerializer): images = serializers.SerializerMethodField() @@ -358,16 +363,11 @@ class LocationSerializer(CustomModelSerializer): return obj.is_visited_status() def create(self, validated_data): - visits_data = validated_data.pop('visits', []) category_data = validated_data.pop('category', None) collections_data = validated_data.pop('collections', []) print(category_data) location = Location.objects.create(**validated_data) - - # Handle visits - for visit_data in visits_data: - Visit.objects.create(location=location, **visit_data) # Handle category if category_data: @@ -384,7 +384,6 @@ class LocationSerializer(CustomModelSerializer): def update(self, instance, validated_data): has_visits = 'visits' in validated_data - visits_data = validated_data.pop('visits', []) category_data = validated_data.pop('category', None) collections_data = validated_data.pop('collections', None) @@ -405,27 +404,6 @@ class LocationSerializer(CustomModelSerializer): if collections_data is not None: instance.collections.set(collections_data) - # Handle visits - if has_visits: - current_visits = instance.visits.all() - current_visit_ids = set(current_visits.values_list('id', flat=True)) - - updated_visit_ids = set() - for visit_data in visits_data: - visit_id = visit_data.get('id') - if visit_id and visit_id in current_visit_ids: - visit = current_visits.get(id=visit_id) - for attr, value in visit_data.items(): - setattr(visit, attr, value) - visit.save() - updated_visit_ids.add(visit_id) - else: - new_visit = Visit.objects.create(location=instance, **visit_data) - updated_visit_ids.add(new_visit.id) - - visits_to_delete = current_visit_ids - updated_visit_ids - instance.visits.filter(id__in=visits_to_delete).delete() - # call save on the location to update the updated_at field and trigger any geocoding instance.save() diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 71a117b..fdd680d 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -22,6 +22,7 @@ router.register(r'recommendations', RecommendationsViewSet, basename='recommenda router.register(r'backup', BackupViewSet, basename='backup') router.register(r'trails', TrailViewSet, basename='trails') router.register(r'activities', ActivityViewSet, basename='activities') +router.register(r'visits', VisitViewSet, basename='visits') urlpatterns = [ # Include the router under the 'api/' prefix diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 8f5e4ab..c13d73d 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -16,4 +16,5 @@ from .lodging_view import * from .recommendations_view import * from .import_export_view import * from .trail_view import * -from .activity_view import * \ No newline at end of file +from .activity_view import * +from .visit_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/activity_view.py b/backend/server/adventures/views/activity_view.py index 24acedb..01a84ab 100644 --- a/backend/server/adventures/views/activity_view.py +++ b/backend/server/adventures/views/activity_view.py @@ -3,6 +3,7 @@ from django.db.models import Q from adventures.models import Location, Activity from adventures.serializers import ActivitySerializer from adventures.permissions import IsOwnerOrSharedWithFullAccess +from rest_framework.exceptions import PermissionDenied class ActivityViewSet(viewsets.ModelViewSet): serializer_class = ActivitySerializer @@ -37,4 +38,32 @@ class ActivityViewSet(viewsets.ModelViewSet): """ Set the user when creating an activity. """ - serializer.save(user=self.request.user) \ No newline at end of file + visit = serializer.validated_data.get('visit') + location = visit.location if visit else None + + 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) + + def perform_update(self, serializer): + instance = serializer.instance + new_visit = serializer.validated_data.get('visit') + + # Prevent changing visit/location after creation + if new_visit and new_visit != instance.visit: + raise PermissionDenied("Cannot change activity visit after creation. Create a new activity instead.") + + # Check permission for updates to the existing location + location = instance.visit.location if instance.visit else None + if location and not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location): + raise PermissionDenied("You do not have permission to update this activity.") + + serializer.save() + + def perform_destroy(self, instance): + location = instance.visit.location if instance.visit else None + if location and not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location): + raise PermissionDenied("You do not have permission to delete this activity.") + + instance.delete() \ No newline at end of file diff --git a/backend/server/adventures/views/trail_view.py b/backend/server/adventures/views/trail_view.py index c1d2383..65e573f 100644 --- a/backend/server/adventures/views/trail_view.py +++ b/backend/server/adventures/views/trail_view.py @@ -3,6 +3,7 @@ from django.db.models import Q from adventures.models import Location, Trail from adventures.serializers import TrailSerializer from adventures.permissions import IsOwnerOrSharedWithFullAccess +from rest_framework.exceptions import PermissionDenied class TrailViewSet(viewsets.ModelViewSet): serializer_class = TrailSerializer @@ -34,7 +35,32 @@ class TrailViewSet(viewsets.ModelViewSet): return Trail.objects.filter(location_filter).distinct() def perform_create(self, serializer): - """ - Set the user when creating a trail. - """ - serializer.save(user=self.request.user) \ No newline at end of file + 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) + + def perform_update(self, serializer): + instance = serializer.instance + new_location = serializer.validated_data.get('location') + + # Prevent changing location after creation + if new_location and new_location != instance.location: + raise PermissionDenied("Cannot change trail location after creation. Create a new trail instead.") + + # Check permission for updates to the existing location + if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location): + raise PermissionDenied("You do not have permission to update this trail.") + + serializer.save() + + def perform_destroy(self, instance): + if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location): + raise PermissionDenied("You do not have permission to delete this trail.") + + instance.delete() \ No newline at end of file diff --git a/backend/server/adventures/views/visit_view.py b/backend/server/adventures/views/visit_view.py new file mode 100644 index 0000000..c0ae171 --- /dev/null +++ b/backend/server/adventures/views/visit_view.py @@ -0,0 +1,66 @@ +from rest_framework import viewsets +from django.db.models import Q +from adventures.models import Location, Visit +from adventures.serializers import VisitSerializer +from adventures.permissions import IsOwnerOrSharedWithFullAccess +from rest_framework.exceptions import PermissionDenied + +class VisitViewSet(viewsets.ModelViewSet): + serializer_class = VisitSerializer + permission_classes = [IsOwnerOrSharedWithFullAccess] + + def get_queryset(self): + """ + Returns visits based on location permissions. + Users can only see visits in locations they have access to for editing/updating/deleting. + This means they are either: + - The owner of the location + - The location is in a collection that is shared with the user + - The location is in a collection that the user owns + """ + user = self.request.user + + if not user or not user.is_authenticated: + return Visit.objects.none() + + # Build the filter for accessible locations + location_filter = Q(location__user=user) # User owns the location + + # Location is in collections (many-to-many) that are shared with user + location_filter |= Q(location__collections__shared_with=user) + + # Location is in collections (many-to-many) that user owns + location_filter |= Q(location__collections__user=user) + + return Visit.objects.filter(location_filter).distinct() + + def perform_create(self, serializer): + """ + Set the user when creating a visit and check permissions. + """ + location = serializer.validated_data.get('location') + + if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, location): + raise PermissionDenied("You do not have permission to add a visit to this location.") + + serializer.save() + + def perform_update(self, serializer): + instance = serializer.instance + new_location = serializer.validated_data.get('location') + + # Prevent changing location after creation + if new_location and new_location != instance.location: + raise PermissionDenied("Cannot change visit location after creation. Create a new visit instead.") + + # Check permission for updates to the existing location + if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location): + raise PermissionDenied("You do not have permission to update this visit.") + + serializer.save() + + def perform_destroy(self, instance): + if not IsOwnerOrSharedWithFullAccess().has_object_permission(self.request, self, instance.location): + raise PermissionDenied("You do not have permission to delete this visit.") + + instance.delete() \ No newline at end of file diff --git a/frontend/src/lib/components/CategoryDropdown.svelte b/frontend/src/lib/components/CategoryDropdown.svelte index 381db3f..67a4db9 100644 --- a/frontend/src/lib/components/CategoryDropdown.svelte +++ b/frontend/src/lib/components/CategoryDropdown.svelte @@ -39,6 +39,9 @@ function custom_category() { new_category.name = new_category.display_name.toLowerCase().replace(/ /g, '_'); + if (!new_category.icon) { + new_category.icon = '🌎'; // Default icon if none selected + } selectCategory(new_category); } diff --git a/frontend/src/lib/components/LocationModal.svelte b/frontend/src/lib/components/LocationModal.svelte deleted file mode 100644 index 9be16e8..0000000 --- a/frontend/src/lib/components/LocationModal.svelte +++ /dev/null @@ -1,1219 +0,0 @@ - - - - diff --git a/frontend/src/lib/components/NewLocationModal.svelte b/frontend/src/lib/components/NewLocationModal.svelte index f83f4a9..d5a14a0 100644 --- a/frontend/src/lib/components/NewLocationModal.svelte +++ b/frontend/src/lib/components/NewLocationModal.svelte @@ -298,7 +298,6 @@ bind:visits={location.visits} bind:trails={location.trails} objectId={location.id} - type="location" on:back={() => { steps[3].selected = false; steps[2].selected = true; diff --git a/frontend/src/lib/components/locations/LocationVisits.svelte b/frontend/src/lib/components/locations/LocationVisits.svelte index f7e9711..1b699cb 100644 --- a/frontend/src/lib/components/locations/LocationVisits.svelte +++ b/frontend/src/lib/components/locations/LocationVisits.svelte @@ -36,13 +36,12 @@ // Props export let collection: Collection | null = null; - export let type: 'location' | 'transportation' | 'lodging' = 'location'; export let selectedStartTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone; export let selectedEndTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone; export let utcStartDate: string | null = null; export let utcEndDate: string | null = null; export let note: string | null = null; - export let visits: (Visit | TransportationVisit)[] | null = null; + export let visits: Visit[] | null = null; export let objectId: string; export let trails: Trail[] = []; export let measurementSystem: 'metric' | 'imperial' = 'metric'; @@ -59,6 +58,7 @@ let fullEndDate: string = ''; let constrainDates: boolean = false; let isEditing = false; + let visitIdEditing: string | null = null; // Activity management state let stravaEnabled: boolean = false; @@ -116,6 +116,15 @@ 'Other' ]; + function getTypeConfig() { + return { + startLabel: 'Start Date', + endLabel: 'End Date', + icon: CalendarIcon, + color: 'primary' + }; + } + // Reactive constraints $: constraintStartDate = allDay ? fullStartDate && fullStartDate.includes('T') @@ -147,7 +156,7 @@ const end = updateLocalDate({ utcDate: utcEndDate, - timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone + timezone: selectedEndTimezone }).localDate; localStartDate = start; @@ -193,32 +202,6 @@ return 0; } - function getTypeConfig() { - switch (type) { - case 'transportation': - return { - startLabel: 'Departure Date', - endLabel: 'Arrival Date', - icon: MapMarkerIcon, - color: 'accent' - }; - case 'lodging': - return { - startLabel: 'Check In', - endLabel: 'Check Out', - icon: CalendarIcon, - color: 'secondary' - }; - default: - return { - startLabel: 'Start Date', - endLabel: 'End Date', - icon: CalendarIcon, - color: 'primary' - }; - } - } - // Event handlers function handleLocalDateChange() { utcStartDate = updateUTCDate({ @@ -229,7 +212,7 @@ utcEndDate = updateUTCDate({ localDate: localEndDate, - timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone, + timezone: selectedEndTimezone, allDay }).utcDate; } @@ -251,7 +234,7 @@ utcEndDate = updateUTCDate({ localDate: localEndDate, - timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone, + timezone: selectedEndTimezone, allDay }).utcDate; @@ -262,74 +245,59 @@ localEndDate = updateLocalDate({ utcDate: utcEndDate, - timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone + timezone: selectedEndTimezone }).localDate; } - function createVisitObject(): Visit | TransportationVisit { - const uniqueId = Date.now().toString(36) + Math.random().toString(36).substring(2); - - if (type === 'transportation') { - const transportVisit: TransportationVisit = { - id: uniqueId, - start_date: utcStartDate ?? '', - end_date: utcEndDate ?? utcStartDate ?? '', - notes: note ?? '', - start_timezone: selectedStartTimezone, - end_timezone: selectedEndTimezone, - activities: [] - }; - return transportVisit; - } else { - const regularVisit: Visit = { - id: uniqueId, - start_date: utcStartDate ?? '', - end_date: utcEndDate ?? utcStartDate ?? '', - notes: note ?? '', - timezone: selectedStartTimezone, - activities: [] - }; - return regularVisit; - } - } - async function addVisit() { - const newVisit = createVisitObject(); + // If editing an existing visit, patch instead of creating new + if (visitIdEditing) { + const response = await fetch(`/api/visits/${visitIdEditing}/`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + start_date: utcStartDate, + end_date: utcEndDate, + notes: note, + timezone: selectedStartTimezone + }) + }); - // Patch updated visits array to location and get the response with actual IDs - console.log('Adding new visit:', newVisit); - console.log(objectId); - if (type === 'location' && objectId) { - try { - const updatedVisits = visits ? [...visits, newVisit] : [newVisit]; - console.log('Patching visits:', updatedVisits); - - const response = await fetch(`/api/locations/${objectId}/`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ visits: updatedVisits }) - }); - - if (response.ok) { - const updatedLocation = await response.json(); - // Update visits with the response data that contains actual IDs - visits = updatedLocation.visits; - } else { - console.error('Failed to patch visits:', await response.text()); - return; // Don't update local state if API call failed - } - } catch (error) { - console.error('Error patching visits:', error); - return; // Don't update local state if API call failed + if (response.ok) { + const updatedVisit: Visit = await response.json(); + visits = visits ? [...visits, updatedVisit] : [updatedVisit]; + dispatch('visitAdded', updatedVisit); + visitIdEditing = null; + } else { + const errorText = await response.text(); + alert(`Failed to update visit: ${errorText}`); } } else { - // Fallback for non-location types - add new visit to the visits array - if (visits) { - visits = [...visits, newVisit]; + // post to /api/visits for new visit + const response = await fetch('/api/visits/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + object_id: objectId, + start_date: utcStartDate, + end_date: utcEndDate, + notes: note, + timezone: selectedStartTimezone, + location: objectId + }) + }); + + if (response.ok) { + const newVisit: Visit = await response.json(); + visits = visits ? [...visits, newVisit] : [newVisit]; + dispatch('visitAdded', newVisit); } else { - visits = [newVisit]; + const errorText = await response.text(); + alert(`Failed to add visit: ${errorText}`); } } @@ -581,27 +549,13 @@ if (response.ok) { // Refetch the location data to get the updated visits with correct IDs - if (type === 'location' && objectId) { - const locationResponse = await fetch(`/api/locations/${objectId}/`); - if (locationResponse.ok) { - const updatedLocation = await locationResponse.json(); - visits = updatedLocation.visits; - } else { - console.error('Failed to refetch location data:', await locationResponse.text()); - } + + const locationResponse = await fetch(`/api/locations/${objectId}/`); + if (locationResponse.ok) { + const updatedLocation = await locationResponse.json(); + visits = updatedLocation.visits; } else { - // Fallback: Update the visit's activities array locally - if (visits) { - visits = visits.map((visit) => { - if (visit.id === visitId) { - return { - ...visit, - activities: (visit.activities || []).filter((a) => a.id !== activityId) - }; - } - return visit; - }); - } + console.error('Failed to refetch location data:', await locationResponse.text()); } } else { console.error('Failed to delete activity:', await response.text()); @@ -661,14 +615,17 @@ } } - function editVisit(visit: Visit | TransportationVisit) { + function editVisit(visit: Visit) { isEditing = true; + visitIdEditing = visit.id; const isAllDayEvent = isAllDay(visit.start_date); allDay = isAllDayEvent; - if ('start_timezone' in visit) { + if ('start_timezone' in visit && typeof visit.start_timezone === 'string') { selectedStartTimezone = visit.start_timezone; - selectedEndTimezone = visit.end_timezone; + if ('end_timezone' in visit && typeof visit.end_timezone === 'string') { + selectedEndTimezone = visit.end_timezone; + } } else if (visit.timezone) { selectedStartTimezone = visit.timezone; } @@ -684,10 +641,14 @@ localEndDate = updateLocalDate({ utcDate: visit.end_date, - timezone: 'end_timezone' in visit ? visit.end_timezone : selectedStartTimezone + timezone: + 'end_timezone' in visit && typeof visit.end_timezone === 'string' + ? visit.end_timezone + : selectedStartTimezone }).localDate; } + // Remove the visit from the array temporarily for editing if (visits) { visits = visits.filter((v) => v.id !== visit.id); } @@ -706,17 +667,6 @@ setTimeout(() => { isEditing = false; }, 0); - - // Update the visits array in the parent component - if (type === 'location' && objectId) { - fetch(`/api/locations/${objectId}/`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ visits }) - }); - } } function removeVisit(visitId: string) { @@ -730,16 +680,17 @@ delete loadingActivities[visitId]; delete showActivityUpload[visitId]; - // Patch updated visits array to location - if (type === 'location' && objectId) { - fetch(`/api/locations/${objectId}/`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ visits }) - }); - } + // make the DELETE request + fetch(`/api/visits/${visitId}/`, { + method: 'DELETE' + }).then((response) => { + if (!response.ok) { + alert('Failed to delete visit. Please try again.'); + } else { + // remove the visit from the local state + visits = visits?.filter((v) => v.id !== visitId) ?? null; + } + }); } function handleBack() { @@ -752,10 +703,6 @@ // Lifecycle onMount(async () => { - if ((type === 'transportation' || type === 'lodging') && utcStartDate) { - allDay = isAllDay(utcStartDate); - } - localStartDate = updateLocalDate({ utcDate: utcStartDate, timezone: selectedStartTimezone @@ -763,7 +710,7 @@ localEndDate = updateLocalDate({ utcDate: utcEndDate, - timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone + timezone: selectedEndTimezone }).localDate; if (!selectedStartTimezone) { @@ -787,8 +734,8 @@ } }); - $: typeConfig = getTypeConfig(); $: isDateValid = validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid; + $: typeConfig = getTypeConfig();
No visits added yet
-- Create your first visit by selecting dates above -
-No visits added yet
++ Create your first visit by selecting dates above +
+- "{visit.notes}" -
- {/if} - - {#if visit.activities && visit.activities.length > 0} -+ "{visit.notes}" +
+ {/if} - - - - - -Loading activities...
-No Strava activities found during this visit
-Loading activities...
+No Strava activities found during this visit
+