1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-05 13:15:18 +02:00

Refactor adventure-related views and components to use "Location" terminology

- Updated GlobalSearchView to replace AdventureSerializer with LocationSerializer.
- Modified IcsCalendarGeneratorViewSet to use LocationSerializer instead of AdventureSerializer.
- Created new LocationImageViewSet for managing location images, including primary image toggling and image deletion.
- Introduced LocationViewSet for managing locations with enhanced filtering, sorting, and sharing capabilities.
- Updated ReverseGeocodeViewSet to utilize LocationSerializer.
- Added ActivityTypesView to retrieve distinct activity types from locations.
- Refactored user views to replace AdventureSerializer with LocationSerializer.
- Updated frontend components to reflect changes from "adventure" to "location", including AdventureCard, AdventureLink, AdventureModal, and others.
- Adjusted API endpoints in frontend routes to align with new location-based structure.
- Ensured all references to adventures are replaced with locations across the codebase.
This commit is contained in:
Sean Morley 2025-06-20 12:10:14 -04:00
parent 241d27a1a6
commit 84b01b9749
38 changed files with 215 additions and 216 deletions

View file

@ -11,17 +11,17 @@ admin.site.login = secure_admin_login(admin.site.login)
@admin.action(description="Trigger geocoding")
def trigger_geocoding(modeladmin, request, queryset):
count = 0
for adventure in queryset:
for location in queryset:
try:
adventure.save() # Triggers geocoding logic in your model
location.save() # Triggers geocoding logic in your model
count += 1
except Exception as e:
modeladmin.message_user(request, f"Error geocoding {adventure}: {e}", level='error')
modeladmin.message_user(request, f"Geocoding triggered for {count} adventures.", level='success')
modeladmin.message_user(request, f"Error geocoding {location}: {e}", level='error')
modeladmin.message_user(request, f"Geocoding triggered for {count} locations.", level='success')
class AdventureAdmin(admin.ModelAdmin):
class LocationAdmin(admin.ModelAdmin):
list_display = ('name', 'get_category', 'get_visit_count', 'user', 'is_public')
list_filter = ( 'user', 'is_public')
search_fields = ('name',)
@ -96,7 +96,7 @@ class CustomUserAdmin(UserAdmin):
else:
return
class AdventureImageAdmin(admin.ModelAdmin):
class LocationImageAdmin(admin.ModelAdmin):
list_display = ('user', 'image_display')
def image_display(self, obj):
@ -137,7 +137,7 @@ admin.site.register(CustomUser, CustomUserAdmin)
admin.site.register(Location, AdventureAdmin)
admin.site.register(Location, LocationAdmin)
admin.site.register(Collection, CollectionAdmin)
admin.site.register(Visit, VisitAdmin)
admin.site.register(Country, CountryAdmin)
@ -147,7 +147,7 @@ admin.site.register(Transportation)
admin.site.register(Note)
admin.site.register(Checklist)
admin.site.register(ChecklistItem)
admin.site.register(LocationImage, AdventureImageAdmin)
admin.site.register(LocationImage, LocationImageAdmin)
admin.site.register(Category, CategoryAdmin)
admin.site.register(City, CityAdmin)
admin.site.register(VisitedCity)

View file

@ -2,7 +2,7 @@ from django.db import models
from django.db.models import Q
class LocationManager(models.Manager):
def retrieve_adventures(self, user, include_owned=False, include_shared=False, include_public=False):
def retrieve_locations(self, user, include_owned=False, include_shared=False, include_public=False):
query = Q()
if include_owned:

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.1 on 2025-06-20 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0036_rename_adventure_location_squashed_0050_rename_user_id_lodging_user'),
]
operations = [
migrations.RenameField(
model_name='location',
old_name='activity_types',
new_name='tags',
),
migrations.AlterField(
model_name='location',
name='collections',
field=models.ManyToManyField(blank=True, related_name='locations', to='adventures.collection'),
),
]

View file

@ -48,8 +48,6 @@ def background_geocode_and_assign(location_id: str):
# Save updated location info, skip geocode threading
location.save(update_fields=["region", "city", "country"], _skip_geocode=True)
# print(f"[Adventure Geocode Thread] Successfully processed {adventure_id}: {adventure.name} - {adventure.latitude}, {adventure.longitude}")
except Exception as e:
# Optional: log or print the error
print(f"[Location Geocode Thread] Error processing {location_id}: {e}")
@ -144,7 +142,7 @@ class Location(models.Model):
category = models.ForeignKey('Category', on_delete=models.SET_NULL, blank=True, null=True)
name = models.CharField(max_length=200)
location = models.CharField(max_length=200, blank=True, null=True)
activity_types = ArrayField(models.CharField(
tags = ArrayField(models.CharField(
max_length=100), blank=True, null=True)
description = models.TextField(blank=True, null=True)
rating = models.FloatField(blank=True, null=True)
@ -159,7 +157,7 @@ class Location(models.Model):
country = models.ForeignKey(Country, on_delete=models.SET_NULL, blank=True, null=True)
# Changed from ForeignKey to ManyToManyField
collections = models.ManyToManyField('Collection', blank=True, related_name='adventures')
collections = models.ManyToManyField('Collection', blank=True, related_name='locations')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@ -256,13 +254,13 @@ class Collection(models.Model):
shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True)
link = models.URLField(blank=True, null=True, max_length=2083)
# if connected adventures are private and collection is public, raise an error
# if connected locations are private and collection is public, raise an error
def clean(self):
if self.is_public and self.pk: # Only check if the instance has a primary key
# Updated to use the new related_name 'adventures'
for adventure in self.adventures.all():
if not adventure.is_public:
raise ValidationError(f'Public collections cannot be associated with private locations. Collection: {self.name} Adventure: {adventure.name}')
# Updated to use the new related_name 'locations'
for location in self.locations.all():
if not location.is_public:
raise ValidationError(f'Public collections cannot be associated with private locations. Collection: {self.name} Location: {location.name}')
def __str__(self):
return self.name

View file

@ -39,7 +39,7 @@ class CollectionShared(permissions.BasePermission):
if obj.shared_with.filter(id=user.id).exists():
return True
# If obj is an Adventure (has collections M2M)
# If obj is a Location (has collections M2M)
if hasattr(obj, 'collections'):
# Check if user is in shared_with of any related collection
shared_collections = obj.collections.filter(shared_with=user)

View file

@ -9,7 +9,7 @@ from geopy.distance import geodesic
from integrations.models import ImmichIntegration
class AdventureImageSerializer(CustomModelSerializer):
class LocationImageSerializer(CustomModelSerializer):
class Meta:
model = LocationImage
fields = ['id', 'image', 'location', 'is_primary', 'user', 'immich_id']
@ -59,11 +59,11 @@ class AttachmentSerializer(CustomModelSerializer):
return representation
class CategorySerializer(serializers.ModelSerializer):
num_adventures = serializers.SerializerMethodField()
num_locations = serializers.SerializerMethodField()
class Meta:
model = Category
fields = ['id', 'name', 'display_name', 'icon', 'num_adventures']
read_only_fields = ['id', 'num_adventures']
fields = ['id', 'name', 'display_name', 'icon', 'num_locations']
read_only_fields = ['id', 'num_locations']
def validate_name(self, value):
return value.lower()
@ -81,7 +81,7 @@ class CategorySerializer(serializers.ModelSerializer):
instance.save()
return instance
def get_num_adventures(self, obj):
def get_num_locations(self, obj):
return Location.objects.filter(category=obj, user=obj.user).count()
class VisitSerializer(serializers.ModelSerializer):
@ -91,13 +91,12 @@ class VisitSerializer(serializers.ModelSerializer):
fields = ['id', 'start_date', 'end_date', 'timezone', 'notes']
read_only_fields = ['id']
class AdventureSerializer(CustomModelSerializer):
class LocationSerializer(CustomModelSerializer):
images = serializers.SerializerMethodField()
visits = VisitSerializer(many=True, read_only=False, required=False)
attachments = AttachmentSerializer(many=True, read_only=True)
category = CategorySerializer(read_only=False, required=False)
is_visited = serializers.SerializerMethodField()
user = serializers.SerializerMethodField()
country = CountrySerializer(read_only=True)
region = RegionSerializer(read_only=True)
city = CitySerializer(read_only=True)
@ -110,14 +109,20 @@ class AdventureSerializer(CustomModelSerializer):
class Meta:
model = Location
fields = [
'id', 'name', 'description', 'rating', 'activity_types', 'location',
'id', 'name', 'description', 'rating', 'tags', 'location',
'is_public', 'collections', 'created_at', 'updated_at', 'images', 'link', 'longitude',
'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user', 'city', 'country', 'region'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'is_visited']
# Makes it so the whole user object is returned in the serializer instead of just the user uuid
def to_representation(self, instance):
representation = super().to_representation(instance)
representation['user'] = CustomUserDetailsSerializer(instance.user, context=self.context).data
return representation
def get_images(self, obj):
serializer = AdventureImageSerializer(obj.images.all(), many=True, context=self.context)
serializer = LocationImageSerializer(obj.images.all(), many=True, context=self.context)
# Filter out None values from the serialized data
return [image for image in serializer.data if image is not None]
@ -171,10 +176,6 @@ class AdventureSerializer(CustomModelSerializer):
)
return category
def get_user(self, obj):
user = obj.user
return CustomUserDetailsSerializer(user).data
def get_is_visited(self, obj):
return obj.is_visited_status()
@ -184,24 +185,24 @@ class AdventureSerializer(CustomModelSerializer):
collections_data = validated_data.pop('collections', [])
print(category_data)
adventure = Location.objects.create(**validated_data)
location = Location.objects.create(**validated_data)
# Handle visits
for visit_data in visits_data:
Visit.objects.create(location=adventure, **visit_data)
Visit.objects.create(location=location, **visit_data)
# Handle category
if category_data:
category = self.get_or_create_category(category_data)
adventure.category = category
location.category = category
# Handle collections - set after adventure is saved
# Handle collections - set after location is saved
if collections_data:
adventure.collections.set(collections_data)
location.collections.set(collections_data)
adventure.save()
location.save()
return adventure
return location
def update(self, instance, validated_data):
has_visits = 'visits' in validated_data
@ -214,7 +215,7 @@ class AdventureSerializer(CustomModelSerializer):
for attr, value in validated_data.items():
setattr(instance, attr, value)
# Handle category - ONLY allow the adventure owner to change categories
# Handle category - ONLY allow the location owner to change categories
user = self.context['request'].user
if category_data and instance.user == user:
# Only the owner can set categories
@ -247,7 +248,7 @@ class AdventureSerializer(CustomModelSerializer):
visits_to_delete = current_visit_ids - updated_visit_ids
instance.visits.filter(id__in=visits_to_delete).delete()
# call save on the adventure to update the updated_at field and trigger any geocoding
# call save on the location to update the updated_at field and trigger any geocoding
instance.save()
return instance
@ -391,7 +392,7 @@ class ChecklistSerializer(CustomModelSerializer):
return data
class CollectionSerializer(CustomModelSerializer):
adventures = AdventureSerializer(many=True, read_only=True)
locations = LocationSerializer(many=True, read_only=True)
transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set')
notes = NoteSerializer(many=True, read_only=True, source='note_set')
checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set')
@ -399,7 +400,7 @@ class CollectionSerializer(CustomModelSerializer):
class Meta:
model = Collection
fields = ['id', 'description', 'user', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'lodging']
fields = ['id', 'description', 'user', 'name', 'is_public', 'locations', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'lodging']
read_only_fields = ['id', 'created_at', 'updated_at', 'user']
def to_representation(self, instance):

View file

@ -3,11 +3,11 @@ from rest_framework.routers import DefaultRouter
from adventures.views import *
router = DefaultRouter()
router.register(r'adventures', AdventureViewSet, basename='adventures')
router.register(r'locations', LocationViewSet, basename='locations')
router.register(r'collections', CollectionViewSet, basename='collections')
router.register(r'stats', StatsViewSet, basename='stats')
router.register(r'generate', GenerateDescription, basename='generate')
router.register(r'activity-types', ActivityTypesView, basename='activity-types')
router.register(r'tags', ActivityTypesView, basename='tags')
router.register(r'transportations', TransportationViewSet, basename='transportations')
router.register(r'notes', NoteViewSet, basename='notes')
router.register(r'checklists', ChecklistViewSet, basename='checklists')

View file

@ -10,14 +10,14 @@ def checkFilePermission(fileId, user, mediaType):
# Construct the full relative path to match the database field
image_path = f"images/{fileId}"
# Fetch the AdventureImage object
adventure = LocationImage.objects.get(image=image_path).location
if adventure.is_public:
location = LocationImage.objects.get(image=image_path).location
if location.is_public:
return True
elif adventure.user == user:
elif location.user == user:
return True
elif adventure.collections.exists():
elif location.collections.exists():
# Check if the user is in any collection's shared_with list
for collection in adventure.collections.all():
for collection in location.collections.all():
if collection.shared_with.filter(id=user.id).exists():
return True
return False
@ -31,14 +31,14 @@ def checkFilePermission(fileId, user, mediaType):
attachment_path = f"attachments/{fileId}"
# Fetch the Attachment object
attachment = Attachment.objects.get(file=attachment_path)
adventure = attachment.location
if adventure.is_public:
location = attachment.location
if location.is_public:
return True
elif adventure.user == user:
elif location.user == user:
return True
elif adventure.collections.exists():
elif location.collections.exists():
# Check if the user is in any collection's shared_with list
for collection in adventure.collections.all():
for collection in location.collections.all():
if collection.shared_with.filter(id=user.id).exists():
return True
return False

View file

@ -1,6 +1,6 @@
from .activity_types_view import *
from .adventure_image_view import *
from .adventure_view import *
from .tags_view import *
from .location_image_view import *
from .location_view import *
from .category_view import *
from .checklist_view import *
from .collection_view import *

View file

@ -12,10 +12,6 @@ class AttachmentViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Attachment.objects.filter(user=self.request.user)
@action(detail=True, methods=['post'])
def attachment_delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)

View file

@ -6,15 +6,13 @@ from adventures.models import Category, Location
from adventures.serializers import CategorySerializer
class CategoryViewSet(viewsets.ModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Category.objects.filter(user=self.request.user)
@action(detail=False, methods=['get'])
def categories(self, request):
def list(self, request, *args, **kwargs):
"""
Retrieve a list of distinct categories for adventures associated with the current user.
"""

View file

@ -6,32 +6,22 @@ from adventures.models import Checklist
from adventures.serializers import ChecklistSerializer
from rest_framework.exceptions import PermissionDenied
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from rest_framework.permissions import IsAuthenticated
class ChecklistViewSet(viewsets.ModelViewSet):
queryset = Checklist.objects.all()
serializer_class = ChecklistSerializer
permission_classes = [IsOwnerOrSharedWithFullAccess]
permission_classes = [IsAuthenticated, IsOwnerOrSharedWithFullAccess]
filterset_fields = ['is_public', 'collection']
# return error message if user is not authenticated on the root endpoint
def list(self, request, *args, **kwargs):
# Prevent listing all adventures
return Response({"detail": "Listing all checklists is not allowed."},
status=status.HTTP_403_FORBIDDEN)
@action(detail=False, methods=['get'])
def all(self, request):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = Checklist.objects.filter(
Q(user=request.user.id)
Q(user=request.user)
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def get_queryset(self):
# if the user is not authenticated return only public transportations for retrieve action
# if the user is not authenticated return only public checklists for retrieve action
if not self.request.user.is_authenticated:
if self.action == 'retrieve':
return Checklist.objects.filter(is_public=True).distinct().order_by('-updated_at')
@ -41,12 +31,12 @@ class ChecklistViewSet(viewsets.ModelViewSet):
if self.action == 'retrieve':
# For individual adventure retrieval, include public adventures
return Checklist.objects.filter(
Q(is_public=True) | Q(user=self.request.user.id) | Q(collection__shared_with=self.request.user)
Q(is_public=True) | Q(user=self.request.user) | Q(collection__shared_with=self.request.user)
).distinct().order_by('-updated_at')
else:
# For other actions, include user's own adventures and shared adventures
return Checklist.objects.filter(
Q(user=self.request.user.id) | Q(collection__shared_with=self.request.user)
Q(user=self.request.user) | Q(collection__shared_with=self.request.user)
).distinct().order_by('-updated_at')
def partial_update(self, request, *args, **kwargs):

View file

@ -15,8 +15,6 @@ class CollectionViewSet(viewsets.ModelViewSet):
permission_classes = [CollectionShared]
pagination_class = pagination.StandardResultsSetPagination
# def get_queryset(self):
# return Collection.objects.filter(Q(user=self.request.user.id) & Q(is_archived=False))
def apply_sorting(self, queryset):
order_by = self.request.query_params.get('order_by', 'name')
@ -47,15 +45,13 @@ class CollectionViewSet(viewsets.ModelViewSet):
if order_direction == 'asc':
ordering = '-updated_at'
#print(f"Ordering by: {ordering}") # For debugging
return queryset.order_by(ordering)
def list(self, request, *args, **kwargs):
# make sure the user is authenticated
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = Collection.objects.filter(user=request.user.id, is_archived=False)
queryset = Collection.objects.filter(user=request.user, is_archived=False)
queryset = self.apply_sorting(queryset)
collections = self.paginate_and_respond(queryset, request)
return collections
@ -66,7 +62,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
return Response({"error": "User is not authenticated"}, status=400)
queryset = Collection.objects.filter(
Q(user=request.user.id)
Q(user=request.user)
)
queryset = self.apply_sorting(queryset)
@ -88,7 +84,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
# this make the is_public field of the collection cascade to the adventures
# this make the is_public field of the collection cascade to the locations
@transaction.atomic
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
@ -111,25 +107,25 @@ class CollectionViewSet(viewsets.ModelViewSet):
print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user}")
return Response({"error": "User does not own the collection"}, status=400)
# Get all adventures in this collection
adventures_in_collection = Location.objects.filter(collections=instance)
# Get all locations in this collection
locations_in_collection = Location.objects.filter(collections=instance)
if new_public_status:
# If collection becomes public, make all adventures public
adventures_in_collection.update(is_public=True)
# If collection becomes public, make all locations public
locations_in_collection.update(is_public=True)
else:
# If collection becomes private, check each adventure
# Only set an adventure to private if ALL of its collections are private
# Collect adventures that do NOT belong to any other public collection (excluding the current one)
adventure_ids_to_set_private = []
# If collection becomes private, check each location
# Only set a location to private if ALL of its collections are private
# Collect locations that do NOT belong to any other public collection (excluding the current one)
location_ids_to_set_private = []
for adventure in adventures_in_collection:
has_public_collection = adventure.collections.filter(is_public=True).exclude(id=instance.id).exists()
for location in locations_in_collection:
has_public_collection = location.collections.filter(is_public=True).exclude(id=instance.id).exists()
if not has_public_collection:
adventure_ids_to_set_private.append(adventure.id)
location_ids_to_set_private.append(location.id)
# Bulk update those adventures
Location.objects.filter(id__in=adventure_ids_to_set_private).update(is_public=False)
# Bulk update those locations
Location.objects.filter(id__in=location_ids_to_set_private).update(is_public=False)
# Update transportations, notes, and checklists related to this collection
# These still use direct ForeignKey relationships
@ -150,7 +146,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
# make an action to retreive all adventures that are shared with the user
# make an action to retreive all locations that are shared with the user
@action(detail=False, methods=['get'])
def shared(self, request):
if not request.user.is_authenticated:
@ -162,7 +158,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
# Adds a new user to the shared_with field of an adventure
# Adds a new user to the shared_with field of a location
@action(detail=True, methods=['post'], url_path='share/(?P<uuid>[^/.]+)')
def share(self, request, pk=None, uuid=None):
collection = self.get_object()
@ -177,7 +173,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
return Response({"error": "Cannot share with yourself"}, status=400)
if collection.shared_with.filter(id=user.id).exists():
return Response({"error": "Adventure is already shared with this user"}, status=400)
return Response({"error": "Location is already shared with this user"}, status=400)
collection.shared_with.add(user)
collection.save()

View file

@ -4,7 +4,7 @@ from rest_framework.permissions import IsAuthenticated
from django.db.models import Q
from django.contrib.postgres.search import SearchVector, SearchQuery
from adventures.models import Location, Collection
from adventures.serializers import AdventureSerializer, CollectionSerializer
from adventures.serializers import LocationSerializer, CollectionSerializer
from worldtravel.models import Country, Region, City, VisitedCity, VisitedRegion
from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer, VisitedCitySerializer, VisitedRegionSerializer
from users.models import CustomUser as User
@ -34,7 +34,7 @@ class GlobalSearchView(viewsets.ViewSet):
adventures = Location.objects.annotate(
search=SearchVector('name', 'description', 'location')
).filter(search=SearchQuery(search_term), user=request.user)
results["adventures"] = AdventureSerializer(adventures, many=True).data
results["adventures"] = LocationSerializer(adventures, many=True).data
# Collections: Partial Match Search
collections = Collection.objects.filter(

View file

@ -5,7 +5,7 @@ from rest_framework.permissions import IsAuthenticated
from icalendar import Calendar, Event, vText, vCalAddress
from datetime import datetime, timedelta
from adventures.models import Location
from adventures.serializers import AdventureSerializer
from adventures.serializers import LocationSerializer
class IcsCalendarGeneratorViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@ -13,7 +13,7 @@ class IcsCalendarGeneratorViewSet(viewsets.ViewSet):
@action(detail=False, methods=['get'])
def generate(self, request):
adventures = Location.objects.filter(user=request.user)
serializer = AdventureSerializer(adventures, many=True)
serializer = LocationSerializer(adventures, many=True)
user = request.user
name = f"{user.first_name} {user.last_name}"

View file

@ -5,13 +5,13 @@ from rest_framework.response import Response
from django.db.models import Q
from django.core.files.base import ContentFile
from adventures.models import Location, LocationImage
from adventures.serializers import AdventureImageSerializer
from adventures.serializers import LocationImageSerializer
from integrations.models import ImmichIntegration
import uuid
import requests
class AdventureImageViewSet(viewsets.ModelViewSet):
serializer_class = AdventureImageSerializer
serializer_class = LocationImageSerializer
permission_classes = [IsAuthenticated]
@action(detail=True, methods=['post'])
@ -20,21 +20,21 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['post'])
def toggle_primary(self, request, *args, **kwargs):
# Makes the image the primary image for the adventure, if there is already a primary image linked to the adventure, it is set to false and the new image is set to true. make sure that the permission is set to the owner of the adventure
# Makes the image the primary image for the location, if there is already a primary image linked to the location, it is set to false and the new image is set to true. make sure that the permission is set to the owner of the location
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object()
adventure = instance.adventure
if adventure.user != request.user:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
location = instance.location
if location.user != request.user:
return Response({"error": "User does not own this location"}, status=status.HTTP_403_FORBIDDEN)
# Check if the image is already the primary image
if instance.is_primary:
return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST)
# Set the current primary image to false
LocationImage.objects.filter(adventure=adventure, is_primary=True).update(is_primary=False)
LocationImage.objects.filter(location=location, is_primary=True).update(is_primary=False)
# Set the new image to true
instance.is_primary = True
@ -44,29 +44,29 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
def create(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
adventure_id = request.data.get('adventure')
location_id = request.data.get('location')
try:
adventure = Location.objects.get(id=adventure_id)
location = Location.objects.get(id=location_id)
except Location.DoesNotExist:
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
return Response({"error": "location not found"}, status=status.HTTP_404_NOT_FOUND)
if adventure.user != request.user:
# Check if the adventure has any collections
if adventure.collections.exists():
# Check if the user is in the shared_with list of any of the adventure's collections
if location.user != request.user:
# Check if the location has any collections
if location.collections.exists():
# Check if the user is in the shared_with list of any of the location's collections
user_has_access = False
for collection in adventure.collections.all():
for collection in location.collections.all():
if collection.shared_with.filter(id=request.user.id).exists():
user_has_access = True
break
if not user_has_access:
return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
return Response({"error": "User does not have permission to access this location"}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
return Response({"error": "User does not own this location"}, status=status.HTTP_403_FORBIDDEN)
# Handle Immich ID for shared users by downloading the image
if (request.user != adventure.user and
if (request.user != location.user and
'immich_id' in request.data and
request.data.get('immich_id')):
@ -121,8 +121,8 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True)
# Save with the downloaded image
adventure = serializer.validated_data['adventure']
serializer.save(user=adventure.user, image=image_file)
location = serializer.validated_data['location']
serializer.save(user=location.user, image=image_file)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@ -143,30 +143,28 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
adventure_id = request.data.get('adventure')
location_id = request.data.get('location')
try:
adventure = Location.objects.get(id=adventure_id)
location = Location.objects.get(id=location_id)
except Location.DoesNotExist:
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
return Response({"error": "location not found"}, status=status.HTTP_404_NOT_FOUND)
if adventure.user != request.user:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
if location.user != request.user:
return Response({"error": "User does not own this location"}, status=status.HTTP_403_FORBIDDEN)
return super().update(request, *args, **kwargs)
def perform_destroy(self, instance):
print("perform_destroy")
return super().perform_destroy(instance)
def destroy(self, request, *args, **kwargs):
print("destroy")
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object()
adventure = instance.adventure
if adventure.user != request.user:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
location = instance.location
if location.user != request.user:
return Response({"error": "User does not own this location"}, status=status.HTTP_403_FORBIDDEN)
return super().destroy(request, *args, **kwargs)
@ -175,27 +173,27 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object()
adventure = instance.adventure
if adventure.user != request.user:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
location = instance.location
if location.user != request.user:
return Response({"error": "User does not own this location"}, status=status.HTTP_403_FORBIDDEN)
return super().partial_update(request, *args, **kwargs)
@action(detail=False, methods=['GET'], url_path='(?P<adventure_id>[0-9a-f-]+)')
def adventure_images(self, request, adventure_id=None, *args, **kwargs):
@action(detail=False, methods=['GET'], url_path='(?P<location_id>[0-9a-f-]+)')
def location_images(self, request, location_id=None, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
try:
adventure_uuid = uuid.UUID(adventure_id)
location_uuid = uuid.UUID(location_id)
except ValueError:
return Response({"error": "Invalid adventure ID"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"error": "Invalid location ID"}, status=status.HTTP_400_BAD_REQUEST)
# Updated queryset to include images from adventures the user owns OR has shared access to
# Updated queryset to include images from locations the user owns OR has shared access to
queryset = LocationImage.objects.filter(
Q(adventure__id=adventure_uuid) & (
Q(adventure__user=request.user) | # User owns the adventure
Q(adventure__collections__shared_with=request.user) # User has shared access via collection
Q(location__id=location_uuid) & (
Q(location__user=request.user) | # User owns the location
Q(location__collections__shared_with=request.user) # User has shared access via collection
)
).distinct()
@ -203,13 +201,13 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
def get_queryset(self):
# Updated to include images from adventures the user owns OR has shared access to
# Updated to include images from locations the user owns OR has shared access to
return LocationImage.objects.filter(
Q(adventure__user=self.request.user) | # User owns the adventure
Q(adventure__collections__shared_with=self.request.user) # User has shared access via collection
Q(location__user=self.request.user) | # User owns the location
Q(location__collections__shared_with=self.request.user) # User has shared access via collection
).distinct()
def perform_create(self, serializer):
# Always set the image owner to the adventure owner, not the current user
adventure = serializer.validated_data['adventure']
serializer.save(user=adventure.user)
# Always set the image owner to the location owner, not the current user
location = serializer.validated_data['location']
serializer.save(user=location.user)

View file

@ -10,16 +10,16 @@ import requests
from adventures.models import Location, Category, Transportation, Lodging
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.serializers import AdventureSerializer, TransportationSerializer, LodgingSerializer
from adventures.serializers import LocationSerializer, TransportationSerializer, LodgingSerializer
from adventures.utils import pagination
class AdventureViewSet(viewsets.ModelViewSet):
class LocationViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing Adventure objects with support for filtering, sorting,
and sharing functionality.
"""
serializer_class = AdventureSerializer
serializer_class = LocationSerializer
permission_classes = [IsOwnerOrSharedWithFullAccess]
pagination_class = pagination.StandardResultsSetPagination
@ -35,13 +35,13 @@ class AdventureViewSet(viewsets.ModelViewSet):
if not user.is_authenticated:
if self.action in public_allowed_actions:
return Location.objects.retrieve_adventures(
return Location.objects.retrieve_locations(
user, include_public=True
).order_by('-updated_at')
return Location.objects.none()
include_public = self.action in public_allowed_actions
return Location.objects.retrieve_adventures(
return Location.objects.retrieve_locations(
user,
include_public=include_public,
include_owned=True,

View file

@ -4,7 +4,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from worldtravel.models import Region, City, VisitedRegion, VisitedCity
from adventures.models import Location
from adventures.serializers import AdventureSerializer
from adventures.serializers import LocationSerializer
import requests
from adventures.geocoding import reverse_geocode
from adventures.geocoding import extractIsoCode
@ -53,7 +53,7 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
new_city_count = 0
new_cities = {}
adventures = Location.objects.filter(user=self.request.user)
serializer = AdventureSerializer(adventures, many=True)
serializer = LocationSerializer(adventures, many=True)
for adventure, serialized_adventure in zip(adventures, serializer.data):
if serialized_adventure['is_visited'] == True:
lat = adventure.latitude

View file

@ -18,7 +18,7 @@ class ActivityTypesView(viewsets.ViewSet):
Returns:
Response: A response containing a list of distinct activity types.
"""
types = Location.objects.filter(user=request.user.id).values_list('activity_types', flat=True).distinct()
types = Location.objects.filter(user=request.user).values_list('tags', flat=True).distinct()
allTypes = []

View file

@ -11,7 +11,7 @@ from django.shortcuts import get_object_or_404
from django.contrib.auth import get_user_model
from .serializers import CustomUserDetailsSerializer as PublicUserSerializer
from allauth.socialaccount.models import SocialApp
from adventures.serializers import AdventureSerializer, CollectionSerializer
from adventures.serializers import LocationSerializer, CollectionSerializer
from adventures.models import Location, Collection
from allauth.socialaccount.models import SocialAccount
@ -101,7 +101,7 @@ class PublicUserDetailView(APIView):
# Get the users adventures and collections to include in the response
adventures = Location.objects.filter(user=user, is_public=True)
collections = Collection.objects.filter(user=user, is_public=True)
adventure_serializer = AdventureSerializer(adventures, many=True)
adventure_serializer = LocationSerializer(adventures, many=True)
collection_serializer = CollectionSerializer(collections, many=True)
return Response({

View file

@ -37,13 +37,13 @@
// Process activity types for display
$: {
if (adventure.activity_types) {
if (adventure.activity_types.length <= 3) {
displayActivityTypes = adventure.activity_types;
if (adventure.tags) {
if (adventure.tags.length <= 3) {
displayActivityTypes = adventure.tags;
remainingCount = 0;
} else {
displayActivityTypes = adventure.activity_types.slice(0, 3);
remainingCount = adventure.activity_types.length - 3;
displayActivityTypes = adventure.tags.slice(0, 3);
remainingCount = adventure.tags.length - 3;
}
}
}
@ -77,7 +77,7 @@
}
async function deleteAdventure() {
let res = await fetch(`/api/adventures/${adventure.id}`, {
let res = await fetch(`/api/locations/${adventure.id}`, {
method: 'DELETE'
});
if (res.ok) {
@ -98,7 +98,7 @@
updatedCollections.push(collectionId);
}
let res = await fetch(`/api/adventures/${adventure.id}`, {
let res = await fetch(`/api/locations/${adventure.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
@ -128,7 +128,7 @@
(c) => String(c) !== String(collectionId)
);
let res = await fetch(`/api/adventures/${adventure.id}`, {
let res = await fetch(`/api/locations/${adventure.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
@ -280,7 +280,7 @@
{$t('adventures.open_details')}
</button>
{#if adventure.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
{#if (adventure.user && adventure.user.uuid == user?.uuid) || (collection && user && collection.shared_with?.includes(user.uuid))}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm btn-base-300">
<DotsHorizontal class="w-5 h-5" />
@ -297,7 +297,7 @@
</button>
</li>
{#if user?.uuid == adventure.user}
{#if user?.uuid == adventure.user?.uuid}
<li>
<button
on:click={() => (isCollectionModalOpen = true)}

View file

@ -69,7 +69,7 @@
modal.showModal();
}
let res = await fetch(`/api/adventures/all/?include_collections=true`, {
let res = await fetch(`/api/locations/all/?include_collections=true`, {
method: 'GET'
});

View file

@ -103,7 +103,7 @@
visits: [],
link: null,
description: null,
activity_types: [],
tags: [],
rating: NaN,
is_public: false,
latitude: NaN,
@ -128,7 +128,7 @@
name: adventureToEdit?.name || '',
link: adventureToEdit?.link || null,
description: adventureToEdit?.description || null,
activity_types: adventureToEdit?.activity_types || [],
tags: adventureToEdit?.tags || [],
rating: adventureToEdit?.rating || NaN,
is_public: adventureToEdit?.is_public || false,
latitude: adventureToEdit?.latitude || NaN,
@ -152,7 +152,7 @@
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal.showModal();
let categoryFetch = await fetch('/api/categories/categories');
let categoryFetch = await fetch('/api/categories');
if (categoryFetch.ok) {
categories = await categoryFetch.json();
} else {
@ -461,7 +461,7 @@
adventure.collections = [collection.id];
}
let res = await fetch('/api/adventures', {
let res = await fetch('/api/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@ -480,7 +480,7 @@
addToast('error', $t('adventures.adventure_create_error'));
}
} else {
let res = await fetch(`/api/adventures/${adventure.id}`, {
let res = await fetch(`/api/locations/${adventure.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
@ -654,18 +654,18 @@
<div class="collapse collapse-plus bg-base-200 mb-4 overflow-visible">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
{$t('adventures.tags')} ({adventure.activity_types?.length || 0})
{$t('adventures.tags')} ({adventure.tags?.length || 0})
</div>
<div class="collapse-content">
<input
type="text"
id="activity_types"
name="activity_types"
id="tags"
name="tags"
hidden
bind:value={adventure.activity_types}
bind:value={adventure.tags}
class="input input-bordered w-full"
/>
<ActivityComplete bind:activities={adventure.activity_types} />
<ActivityComplete bind:activities={adventure.tags} />
</div>
</div>

View file

@ -11,7 +11,7 @@
icon: '',
id: '',
user: '',
num_adventures: 0
num_locations: 0
};
let isOpen: boolean = false;
@ -44,7 +44,7 @@
let dropdownRef: HTMLDivElement;
onMount(() => {
categories = categories.sort((a, b) => (b.num_adventures || 0) - (a.num_adventures || 0));
categories = categories.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0));
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
isOpen = false;
@ -105,7 +105,7 @@
<!-- Sort the categories dynamically before rendering -->
{#each categories
.slice()
.sort((a, b) => (b.num_adventures || 0) - (a.num_adventures || 0)) as category}
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0)) as category}
<button
type="button"
class="btn btn-neutral flex items-center space-x-2"
@ -113,7 +113,7 @@
role="option"
aria-selected={selected_category && selected_category.id === category.id}
>
<span>{category.display_name} {category.icon} ({category.num_adventures})</span>
<span>{category.display_name} {category.icon} ({category.num_locations})</span>
</button>
{/each}
</div>

View file

@ -8,7 +8,7 @@
let adventure_types: Category[] = [];
onMount(async () => {
let categoryFetch = await fetch('/api/categories/categories');
let categoryFetch = await fetch('/api/categories');
let categoryData = await categoryFetch.json();
adventure_types = categoryData;
console.log(categoryData);
@ -60,7 +60,7 @@
/>
<span>
{type.display_name}
{type.icon} ({type.num_adventures})
{type.icon} ({type.num_locations})
</span>
</label>
</li>

View file

@ -26,7 +26,7 @@
async function loadCategories() {
try {
const res = await fetch('/api/categories/categories');
const res = await fetch('/api/categories');
if (res.ok) {
categories = await res.json();
}

View file

@ -91,7 +91,7 @@
>
<!-- Image Carousel -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel adventures={collection.adventures} />
<CardCarousel adventures={collection.locations} />
<!-- Badge Overlay -->
<div class="absolute top-4 left-4 flex flex-col gap-2">
@ -119,7 +119,7 @@
<!-- Adventure Count -->
<p class="text-sm text-base-content/70">
{collection.adventures.length}
{collection.locations.length}
{$t('navbar.adventures')}
</p>

View file

@ -83,7 +83,7 @@
adventure.name = markers[0].name;
}
if (adventure.type == 'visited' || adventure.type == 'planned') {
adventure.activity_types = [...adventure.activity_types, markers[0].activity_type];
adventure.tags = [...adventure.tags, markers[0].activity_type];
}
dispatch('submit', adventure);
close();

View file

@ -35,8 +35,8 @@ export function checkLink(link: string) {
}
export async function exportData() {
let res = await fetch('/api/adventures/all');
let adventures = (await res.json()) as Adventure[];
let res = await fetch('/api/locations/all');
let adventures = (await res.json()) as Location[];
res = await fetch('/api/collections/all');
let collections = (await res.json()) as Collection[];
@ -78,7 +78,7 @@ export function groupAdventuresByDate(
}
adventures.forEach((adventure) => {
adventure.visits.forEach((visit) => {
adventure.visits.forEach((visit: { start_date: string; end_date: string; timezone: any }) => {
if (visit.start_date) {
// Check if it's all-day: start has 00:00:00 AND (no end OR end also has 00:00:00)
const startHasZeros = isAllDay(visit.start_date);

View file

@ -16,10 +16,9 @@ export type User = {
export type Adventure = {
id: string;
user: string | null;
name: string;
location?: string | null;
activity_types?: string[] | null;
tags?: string[] | null;
description?: string | null;
rating?: number | null;
link?: string | null;
@ -45,7 +44,7 @@ export type Adventure = {
is_visited?: boolean;
category: Category | null;
attachments: Attachment[];
user?: User | null;
user: User | null;
city?: City | null;
region?: Region | null;
country?: Country | null;
@ -127,7 +126,7 @@ export type Collection = {
name: string;
description: string;
is_public: boolean;
adventures: Adventure[];
locations: Adventure[];
created_at?: string | null;
start_date: string | null;
end_date: string | null;
@ -236,7 +235,7 @@ export type Category = {
display_name: string;
icon: string;
user: string;
num_adventures?: number | null;
num_locations?: number | null;
};
export type ImmichIntegration = {

View file

@ -8,7 +8,7 @@ export const POST: RequestHandler = async (event) => {
let allActivities: string[] = [];
let csrfToken = await fetchCSRFToken();
let sessionId = event.cookies.get('sessionid');
let res = await event.fetch(`${endpoint}/api/activity-types/types/`, {
let res = await event.fetch(`${endpoint}/api/tags/types/`, {
headers: {
'X-CSRFToken': csrfToken,
Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`

View file

@ -29,7 +29,7 @@ export const load = (async (event) => {
const is_visited = event.url.searchParams.get('is_visited') || 'all';
let initialFetch = await event.fetch(
`${serverEndpoint}/api/adventures/filtered?types=${typeString}&order_by=${order_by}&order_direction=${order_direction}&include_collections=${include_collections}&page=${page}&is_visited=${is_visited}`,
`${serverEndpoint}/api/locations/filtered?types=${typeString}&order_by=${order_by}&order_direction=${order_direction}&include_collections=${include_collections}&page=${page}&is_visited=${is_visited}`,
{
headers: {
Cookie: `sessionid=${event.cookies.get('sessionid')}`

View file

@ -5,7 +5,7 @@ const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
const id = event.params as { id: string };
let request = await fetch(`${endpoint}/api/adventures/${id.id}/additional-info/`, {
let request = await fetch(`${endpoint}/api/locations/${id.id}/additional-info/`, {
headers: {
Cookie: `sessionid=${event.cookies.get('sessionid')}`
},
@ -51,7 +51,7 @@ export const actions: Actions = {
let csrfToken = await fetchCSRFToken();
let res = await fetch(`${serverEndpoint}/api/adventures/${event.params.id}`, {
let res = await fetch(`${serverEndpoint}/api/locations/${event.params.id}`, {
method: 'DELETE',
headers: {
Referer: event.url.origin, // Include Referer header

View file

@ -150,7 +150,7 @@
{/if}
{#if adventure}
{#if data.user && data.user.uuid == adventure.user}
{#if data.user?.uuid && adventure.user?.uuid && data.user.uuid === adventure.user.uuid}
<div class="fixed bottom-6 right-6 z-50">
<button
class="btn btn-primary btn-circle w-16 h-16 shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-110"
@ -649,11 +649,11 @@
<div class="card-body">
<h3 class="card-title text-lg mb-4"> {$t('adventures.basic_information')}</h3>
<div class="space-y-3">
{#if adventure.activity_types && adventure.activity_types?.length > 0}
{#if adventure.tags && adventure.tags?.length > 0}
<div>
<div class="text-sm opacity-70 mb-1">{$t('adventures.tags')}</div>
<div class="flex flex-wrap gap-1">
{#each adventure.activity_types as activity}
{#each adventure.tags as activity}
<span class="badge badge-sm badge-outline">{activity}</span>
{/each}
</div>

View file

@ -8,7 +8,7 @@ const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
let sessionId = event.cookies.get('sessionid');
let visitedFetch = await fetch(`${endpoint}/api/adventures/all/?include_collections=true`, {
let visitedFetch = await fetch(`${endpoint}/api/locations/all/?include_collections=true`, {
headers: {
Cookie: `sessionid=${sessionId}`
}

View file

@ -417,7 +417,7 @@
onMount(() => {
if (data.props.adventure) {
collection = data.props.adventure;
adventures = collection.adventures as Adventure[];
adventures = collection.locations as Adventure[];
} else {
notFound = true;
}
@ -477,7 +477,7 @@
}
}
let res = await fetch(`/api/adventures/${adventure.id}/`, {
let res = await fetch(`/api/locations/${adventure.id}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'

View file

@ -11,7 +11,7 @@ export const load = (async (event) => {
} else {
let adventures: Adventure[] = [];
let initialFetch = await event.fetch(`${serverEndpoint}/api/adventures/`, {
let initialFetch = await event.fetch(`${serverEndpoint}/api/locations/`, {
headers: {
Cookie: `sessionid=${event.cookies.get('sessionid')}`
},

View file

@ -9,7 +9,7 @@ export const load = (async (event) => {
return redirect(302, '/login');
} else {
let sessionId = event.cookies.get('sessionid');
let visitedFetch = await fetch(`${endpoint}/api/adventures/all/?include_collections=true`, {
let visitedFetch = await fetch(`${endpoint}/api/locations/all/?include_collections=true`, {
headers: {
Cookie: `sessionid=${sessionId}`
}