mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-19 04:49:37 +02:00
AdventureLog Smart Recommendations
This commit is contained in:
commit
8f6e655378
57 changed files with 2177 additions and 1431 deletions
|
@ -61,6 +61,10 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
|
|
||||||
|
# Allow GET only for a public object
|
||||||
|
if request.method in permissions.SAFE_METHODS and obj.is_public:
|
||||||
|
return True
|
||||||
# Check if the object has a collection
|
# Check if the object has a collection
|
||||||
if hasattr(obj, 'collection') and obj.collection:
|
if hasattr(obj, 'collection') and obj.collection:
|
||||||
# Allow all actions for shared users
|
# Allow all actions for shared users
|
||||||
|
@ -71,27 +75,5 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
||||||
if request.method in permissions.SAFE_METHODS:
|
if request.method in permissions.SAFE_METHODS:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Allow all actions for the owner
|
|
||||||
return obj.user_id == request.user
|
|
||||||
|
|
||||||
class IsPublicOrOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
|
||||||
"""
|
|
||||||
Custom permission to allow:
|
|
||||||
- Read-only access for public objects
|
|
||||||
- Full access for shared users
|
|
||||||
- Full access for owners
|
|
||||||
"""
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
# Allow read-only access for public objects
|
|
||||||
if obj.is_public and request.method in permissions.SAFE_METHODS:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check if the object has a collection
|
|
||||||
if hasattr(obj, 'collection') and obj.collection:
|
|
||||||
# Allow all actions for shared users
|
|
||||||
if request.user in obj.collection.shared_with.all():
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Allow all actions for the owner
|
# Allow all actions for the owner
|
||||||
return obj.user_id == request.user
|
return obj.user_id == request.user
|
|
@ -1,6 +1,6 @@
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import AdventureViewSet, ChecklistViewSet, CollectionViewSet, NoteViewSet, StatsViewSet, GenerateDescription, ActivityTypesView, TransportationViewSet, AdventureImageViewSet, ReverseGeocodeViewSet, CategoryViewSet, IcsCalendarGeneratorViewSet
|
from adventures.views import *
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'adventures', AdventureViewSet, basename='adventures')
|
router.register(r'adventures', AdventureViewSet, basename='adventures')
|
||||||
|
@ -15,6 +15,7 @@ router.register(r'images', AdventureImageViewSet, basename='images')
|
||||||
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
|
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
|
||||||
router.register(r'categories', CategoryViewSet, basename='categories')
|
router.register(r'categories', CategoryViewSet, basename='categories')
|
||||||
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
|
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
|
||||||
|
router.register(r'overpass', OverpassViewSet, basename='overpass')
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
6
backend/server/adventures/utils/pagination.py
Normal file
6
backend/server/adventures/utils/pagination.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
class StandardResultsSetPagination(PageNumberPagination):
|
||||||
|
page_size = 25
|
||||||
|
page_size_query_param = 'page_size'
|
||||||
|
max_page_size = 1000
|
File diff suppressed because it is too large
Load diff
13
backend/server/adventures/views/__init__.py
Normal file
13
backend/server/adventures/views/__init__.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from .activity_types_view import *
|
||||||
|
from .adventure_image_view import *
|
||||||
|
from .adventure_view import *
|
||||||
|
from .category_view import *
|
||||||
|
from .checklist_view import *
|
||||||
|
from .collection_view import *
|
||||||
|
from .generate_description_view import *
|
||||||
|
from .ics_calendar_view import *
|
||||||
|
from .note_view import *
|
||||||
|
from .overpass_view import *
|
||||||
|
from .reverse_geocode_view import *
|
||||||
|
from .stats_view import *
|
||||||
|
from .transportation_view import *
|
32
backend/server/adventures/views/activity_types_view.py
Normal file
32
backend/server/adventures/views/activity_types_view.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from adventures.models import Adventure
|
||||||
|
|
||||||
|
class ActivityTypesView(viewsets.ViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def types(self, request):
|
||||||
|
"""
|
||||||
|
Retrieve a list of distinct activity types for adventures associated with the current user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (HttpRequest): The HTTP request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: A response containing a list of distinct activity types.
|
||||||
|
"""
|
||||||
|
types = Adventure.objects.filter(user_id=request.user.id).values_list('activity_types', flat=True).distinct()
|
||||||
|
|
||||||
|
allTypes = []
|
||||||
|
|
||||||
|
for i in types:
|
||||||
|
if not i:
|
||||||
|
continue
|
||||||
|
for x in i:
|
||||||
|
if x and x not in allTypes:
|
||||||
|
allTypes.append(x)
|
||||||
|
|
||||||
|
return Response(allTypes)
|
128
backend/server/adventures/views/adventure_image_view.py
Normal file
128
backend/server/adventures/views/adventure_image_view.py
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.db.models import Q
|
||||||
|
from adventures.models import Adventure, AdventureImage
|
||||||
|
from adventures.serializers import AdventureImageSerializer
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = AdventureImageSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
print(f"Method: {request.method}")
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def image_delete(self, request, *args, **kwargs):
|
||||||
|
return self.destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@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
|
||||||
|
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_id != request.user:
|
||||||
|
return Response({"error": "User does not own this adventure"}, 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
|
||||||
|
AdventureImage.objects.filter(adventure=adventure, is_primary=True).update(is_primary=False)
|
||||||
|
|
||||||
|
# Set the new image to true
|
||||||
|
instance.is_primary = True
|
||||||
|
instance.save()
|
||||||
|
return Response({"success": "Image set as primary image"})
|
||||||
|
|
||||||
|
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')
|
||||||
|
try:
|
||||||
|
adventure = Adventure.objects.get(id=adventure_id)
|
||||||
|
except Adventure.DoesNotExist:
|
||||||
|
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if adventure.user_id != request.user:
|
||||||
|
# Check if the adventure has a collection
|
||||||
|
if adventure.collection:
|
||||||
|
# Check if the user is in the collection's shared_with list
|
||||||
|
if not adventure.collection.shared_with.filter(id=request.user.id).exists():
|
||||||
|
return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
else:
|
||||||
|
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def update(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')
|
||||||
|
try:
|
||||||
|
adventure = Adventure.objects.get(id=adventure_id)
|
||||||
|
except Adventure.DoesNotExist:
|
||||||
|
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if adventure.user_id != request.user:
|
||||||
|
return Response({"error": "User does not own this adventure"}, 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_id != request.user:
|
||||||
|
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
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_id != request.user:
|
||||||
|
return Response({"error": "User does not own this adventure"}, 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):
|
||||||
|
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)
|
||||||
|
except ValueError:
|
||||||
|
return Response({"error": "Invalid adventure ID"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
queryset = AdventureImage.objects.filter(
|
||||||
|
Q(adventure__id=adventure_uuid) & Q(user_id=request.user)
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(queryset, many=True, context={'request': request})
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return AdventureImage.objects.filter(user_id=self.request.user)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user_id=self.request.user)
|
314
backend/server/adventures/views/adventure_view.py
Normal file
314
backend/server/adventures/views/adventure_view.py
Normal file
|
@ -0,0 +1,314 @@
|
||||||
|
from django.db import transaction
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from django.db.models.functions import Lower
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from adventures.models import Adventure, Category
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from adventures.serializers import AdventureSerializer
|
||||||
|
from django.db.models import Q
|
||||||
|
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.db.models import Max
|
||||||
|
from adventures.utils import pagination
|
||||||
|
|
||||||
|
class AdventureViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = AdventureSerializer
|
||||||
|
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||||
|
pagination_class = pagination.StandardResultsSetPagination
|
||||||
|
|
||||||
|
def apply_sorting(self, queryset):
|
||||||
|
order_by = self.request.query_params.get('order_by', 'updated_at')
|
||||||
|
order_direction = self.request.query_params.get('order_direction', 'asc')
|
||||||
|
include_collections = self.request.query_params.get('include_collections', 'true')
|
||||||
|
|
||||||
|
valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at']
|
||||||
|
if order_by not in valid_order_by:
|
||||||
|
order_by = 'name'
|
||||||
|
|
||||||
|
if order_direction not in ['asc', 'desc']:
|
||||||
|
order_direction = 'asc'
|
||||||
|
|
||||||
|
if order_by == 'date':
|
||||||
|
# order by the earliest visit object associated with the adventure
|
||||||
|
queryset = queryset.annotate(latest_visit=Max('visits__start_date'))
|
||||||
|
queryset = queryset.filter(latest_visit__isnull=False)
|
||||||
|
ordering = 'latest_visit'
|
||||||
|
# Apply case-insensitive sorting for the 'name' field
|
||||||
|
elif order_by == 'name':
|
||||||
|
queryset = queryset.annotate(lower_name=Lower('name'))
|
||||||
|
ordering = 'lower_name'
|
||||||
|
elif order_by == 'rating':
|
||||||
|
queryset = queryset.filter(rating__isnull=False)
|
||||||
|
ordering = 'rating'
|
||||||
|
else:
|
||||||
|
ordering = order_by
|
||||||
|
|
||||||
|
if order_direction == 'desc':
|
||||||
|
ordering = f'-{ordering}'
|
||||||
|
|
||||||
|
# reverse ordering for updated_at field
|
||||||
|
if order_by == 'updated_at':
|
||||||
|
if order_direction == 'asc':
|
||||||
|
ordering = '-updated_at'
|
||||||
|
else:
|
||||||
|
ordering = 'updated_at'
|
||||||
|
|
||||||
|
print(f"Ordering by: {ordering}") # For debugging
|
||||||
|
|
||||||
|
if include_collections == 'false':
|
||||||
|
queryset = queryset.filter(collection = None)
|
||||||
|
|
||||||
|
return queryset.order_by(ordering)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
print(self.request.user)
|
||||||
|
# if the user is not authenticated return only public adventures for retrieve action
|
||||||
|
if not self.request.user.is_authenticated:
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
return Adventure.objects.filter(is_public=True).distinct().order_by('-updated_at')
|
||||||
|
return Adventure.objects.none()
|
||||||
|
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
# For individual adventure retrieval, include public adventures
|
||||||
|
return Adventure.objects.filter(
|
||||||
|
Q(is_public=True) | Q(user_id=self.request.user.id) | 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 Adventure.objects.filter(
|
||||||
|
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
|
||||||
|
).distinct().order_by('-updated_at')
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
adventure = get_object_or_404(queryset, pk=kwargs['pk'])
|
||||||
|
serializer = self.get_serializer(adventure)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
adventure = serializer.save()
|
||||||
|
if adventure.collection:
|
||||||
|
adventure.is_public = adventure.collection.is_public
|
||||||
|
adventure.save()
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def filtered(self, request):
|
||||||
|
types = request.query_params.get('types', '').split(',')
|
||||||
|
is_visited = request.query_params.get('is_visited', 'all')
|
||||||
|
|
||||||
|
# Handle case where types is all
|
||||||
|
if 'all' in types:
|
||||||
|
types = Category.objects.filter(user_id=request.user).values_list('name', flat=True)
|
||||||
|
|
||||||
|
else:
|
||||||
|
for type in types:
|
||||||
|
if not Category.objects.filter(user_id=request.user, name=type).exists():
|
||||||
|
return Response({"error": f"Category {type} does not exist"}, status=400)
|
||||||
|
|
||||||
|
if not types:
|
||||||
|
return Response({"error": "At least one type must be provided"}, status=400)
|
||||||
|
|
||||||
|
queryset = Adventure.objects.filter(
|
||||||
|
category__in=Category.objects.filter(name__in=types, user_id=request.user),
|
||||||
|
user_id=request.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle is_visited filtering
|
||||||
|
if is_visited.lower() == 'true':
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
filtered_ids = [
|
||||||
|
adventure.id for adventure, serialized_adventure in zip(queryset, serializer.data)
|
||||||
|
if serialized_adventure['is_visited']
|
||||||
|
]
|
||||||
|
queryset = queryset.filter(id__in=filtered_ids)
|
||||||
|
elif is_visited.lower() == 'false':
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
filtered_ids = [
|
||||||
|
adventure.id for adventure, serialized_adventure in zip(queryset, serializer.data)
|
||||||
|
if not serialized_adventure['is_visited']
|
||||||
|
]
|
||||||
|
queryset = queryset.filter(id__in=filtered_ids)
|
||||||
|
# If is_visited is 'all' or any other value, we don't apply additional filtering
|
||||||
|
|
||||||
|
# Apply sorting
|
||||||
|
queryset = self.apply_sorting(queryset)
|
||||||
|
|
||||||
|
# Paginate and respond
|
||||||
|
adventures = self.paginate_and_respond(queryset, request)
|
||||||
|
return adventures
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def all(self, request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return Response({"error": "User is not authenticated"}, status=400)
|
||||||
|
include_collections = request.query_params.get('include_collections', 'false')
|
||||||
|
if include_collections not in ['true', 'false']:
|
||||||
|
include_collections = 'false'
|
||||||
|
|
||||||
|
if include_collections == 'true':
|
||||||
|
queryset = Adventure.objects.filter(
|
||||||
|
Q(is_public=True) | Q(user_id=request.user.id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
queryset = Adventure.objects.filter(
|
||||||
|
Q(is_public=True) | Q(user_id=request.user.id), collection=None
|
||||||
|
)
|
||||||
|
queryset = Adventure.objects.filter(
|
||||||
|
Q(user_id=request.user.id)
|
||||||
|
)
|
||||||
|
queryset = self.apply_sorting(queryset)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def search(self, request):
|
||||||
|
query = self.request.query_params.get('query', '')
|
||||||
|
property = self.request.query_params.get('property', 'all')
|
||||||
|
if len(query) < 2:
|
||||||
|
return Response({"error": "Query must be at least 2 characters long"}, status=400)
|
||||||
|
|
||||||
|
if property not in ['name', 'type', 'location', 'description', 'activity_types']:
|
||||||
|
property = 'all'
|
||||||
|
|
||||||
|
queryset = Adventure.objects.none()
|
||||||
|
|
||||||
|
if property == 'name':
|
||||||
|
queryset = Adventure.objects.filter(
|
||||||
|
(Q(name__icontains=query)) &
|
||||||
|
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||||
|
)
|
||||||
|
elif property == 'type':
|
||||||
|
queryset = Adventure.objects.filter(
|
||||||
|
(Q(type__icontains=query)) &
|
||||||
|
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||||
|
)
|
||||||
|
elif property == 'location':
|
||||||
|
queryset = Adventure.objects.filter(
|
||||||
|
(Q(location__icontains=query)) &
|
||||||
|
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||||
|
)
|
||||||
|
elif property == 'description':
|
||||||
|
queryset = Adventure.objects.filter(
|
||||||
|
(Q(description__icontains=query)) &
|
||||||
|
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||||
|
)
|
||||||
|
elif property == 'activity_types':
|
||||||
|
queryset = Adventure.objects.filter(
|
||||||
|
(Q(activity_types__icontains=query)) &
|
||||||
|
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
queryset = Adventure.objects.filter(
|
||||||
|
(Q(name__icontains=query) | Q(description__icontains=query) | Q(location__icontains=query) | Q(activity_types__icontains=query)) &
|
||||||
|
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = self.apply_sorting(queryset)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
# Retrieve the current object
|
||||||
|
instance = self.get_object()
|
||||||
|
|
||||||
|
# Partially update the instance with the request data
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# if the adventure is trying to have is_public changed and its part of a collection return an error
|
||||||
|
if new_collection is not None:
|
||||||
|
serializer.validated_data['is_public'] = new_collection.is_public
|
||||||
|
elif instance.collection:
|
||||||
|
serializer.validated_data['is_public'] = instance.collection.is_public
|
||||||
|
|
||||||
|
|
||||||
|
# Retrieve the collection from the validated data
|
||||||
|
new_collection = serializer.validated_data.get('collection')
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
print(new_collection)
|
||||||
|
|
||||||
|
if new_collection is not None and new_collection!=instance.collection:
|
||||||
|
# Check if the user is the owner of the new collection
|
||||||
|
if new_collection.user_id != user or instance.user_id != user:
|
||||||
|
raise PermissionDenied("You do not have permission to use this collection.")
|
||||||
|
elif new_collection is None:
|
||||||
|
# Handle the case where the user is trying to set the collection to None
|
||||||
|
if instance.collection is not None and instance.collection.user_id != user:
|
||||||
|
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||||
|
|
||||||
|
# Perform the update
|
||||||
|
self.perform_update(serializer)
|
||||||
|
|
||||||
|
# Return the updated instance
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
# Retrieve the current object
|
||||||
|
instance = self.get_object()
|
||||||
|
|
||||||
|
# Partially update the instance with the request data
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# Retrieve the collection from the validated data
|
||||||
|
new_collection = serializer.validated_data.get('collection')
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
print(new_collection)
|
||||||
|
|
||||||
|
# if the adventure is trying to have is_public changed and its part of a collection return an error
|
||||||
|
if new_collection is not None:
|
||||||
|
serializer.validated_data['is_public'] = new_collection.is_public
|
||||||
|
elif instance.collection:
|
||||||
|
serializer.validated_data['is_public'] = instance.collection.is_public
|
||||||
|
|
||||||
|
if new_collection is not None and new_collection!=instance.collection:
|
||||||
|
# Check if the user is the owner of the new collection
|
||||||
|
if new_collection.user_id != user or instance.user_id != user:
|
||||||
|
raise PermissionDenied("You do not have permission to use this collection.")
|
||||||
|
elif new_collection is None:
|
||||||
|
# Handle the case where the user is trying to set the collection to None
|
||||||
|
if instance.collection is not None and instance.collection.user_id != user:
|
||||||
|
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||||
|
|
||||||
|
# Perform the update
|
||||||
|
self.perform_update(serializer)
|
||||||
|
|
||||||
|
# Return the updated instance
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
|
||||||
|
@transaction.atomic
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
# Retrieve the collection from the validated data
|
||||||
|
collection = serializer.validated_data.get('collection')
|
||||||
|
|
||||||
|
# Check if a collection is provided
|
||||||
|
if collection:
|
||||||
|
user = self.request.user
|
||||||
|
# Check if the user is the owner or is in the shared_with list
|
||||||
|
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
|
||||||
|
# Return an error response if the user does not have permission
|
||||||
|
raise PermissionDenied("You do not have permission to use this collection.")
|
||||||
|
# if collection the owner of the adventure is the owner of the collection
|
||||||
|
# set the is_public field of the adventure to the is_public field of the collection
|
||||||
|
serializer.save(user_id=collection.user_id, is_public=collection.is_public)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save the adventure with the current user as the owner
|
||||||
|
serializer.save(user_id=self.request.user)
|
||||||
|
|
||||||
|
def paginate_and_respond(self, queryset, request):
|
||||||
|
paginator = self.pagination_class()
|
||||||
|
page = paginator.paginate_queryset(queryset, request)
|
||||||
|
if page is not None:
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
return paginator.get_paginated_response(serializer.data)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
42
backend/server/adventures/views/category_view.py
Normal file
42
backend/server/adventures/views/category_view.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from adventures.models import Category, Adventure
|
||||||
|
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_id=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def categories(self, request):
|
||||||
|
"""
|
||||||
|
Retrieve a list of distinct categories for adventures associated with the current user.
|
||||||
|
"""
|
||||||
|
categories = self.get_queryset().distinct()
|
||||||
|
serializer = self.get_serializer(categories, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
if instance.user_id != request.user:
|
||||||
|
return Response({"error": "User does not own this category"}, status
|
||||||
|
=400)
|
||||||
|
|
||||||
|
if instance.name == 'general':
|
||||||
|
return Response({"error": "Cannot delete the general category"}, status=400)
|
||||||
|
|
||||||
|
# set any adventures with this category to a default category called general before deleting the category, if general does not exist create it for the user
|
||||||
|
general_category = Category.objects.filter(user_id=request.user, name='general').first()
|
||||||
|
|
||||||
|
if not general_category:
|
||||||
|
general_category = Category.objects.create(user_id=request.user, name='general', icon='🌍', display_name='General')
|
||||||
|
|
||||||
|
Adventure.objects.filter(category=instance).update(category=general_category)
|
||||||
|
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
130
backend/server/adventures/views/checklist_view.py
Normal file
130
backend/server/adventures/views/checklist_view.py
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.db.models import Q
|
||||||
|
from adventures.models import Checklist
|
||||||
|
from adventures.serializers import ChecklistSerializer
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||||
|
|
||||||
|
class ChecklistViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Checklist.objects.all()
|
||||||
|
serializer_class = ChecklistSerializer
|
||||||
|
permission_classes = [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_id=request.user.id)
|
||||||
|
)
|
||||||
|
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 not self.request.user.is_authenticated:
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
return Checklist.objects.filter(is_public=True).distinct().order_by('-updated_at')
|
||||||
|
return Checklist.objects.none()
|
||||||
|
|
||||||
|
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
# For individual adventure retrieval, include public adventures
|
||||||
|
return Checklist.objects.filter(
|
||||||
|
Q(is_public=True) | Q(user_id=self.request.user.id) | 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_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
|
||||||
|
).distinct().order_by('-updated_at')
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
# Retrieve the current object
|
||||||
|
instance = self.get_object()
|
||||||
|
|
||||||
|
# Partially update the instance with the request data
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# Retrieve the collection from the validated data
|
||||||
|
new_collection = serializer.validated_data.get('collection')
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
print(new_collection)
|
||||||
|
|
||||||
|
if new_collection is not None and new_collection!=instance.collection:
|
||||||
|
# Check if the user is the owner of the new collection
|
||||||
|
if new_collection.user_id != user or instance.user_id != user:
|
||||||
|
raise PermissionDenied("You do not have permission to use this collection.")
|
||||||
|
elif new_collection is None:
|
||||||
|
# Handle the case where the user is trying to set the collection to None
|
||||||
|
if instance.collection is not None and instance.collection.user_id != user:
|
||||||
|
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||||
|
|
||||||
|
# Perform the update
|
||||||
|
self.perform_update(serializer)
|
||||||
|
|
||||||
|
# Return the updated instance
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
# Retrieve the current object
|
||||||
|
instance = self.get_object()
|
||||||
|
|
||||||
|
# Partially update the instance with the request data
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# Retrieve the collection from the validated data
|
||||||
|
new_collection = serializer.validated_data.get('collection')
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
print(new_collection)
|
||||||
|
|
||||||
|
if new_collection is not None and new_collection!=instance.collection:
|
||||||
|
# Check if the user is the owner of the new collection
|
||||||
|
if new_collection.user_id != user or instance.user_id != user:
|
||||||
|
raise PermissionDenied("You do not have permission to use this collection.")
|
||||||
|
elif new_collection is None:
|
||||||
|
# Handle the case where the user is trying to set the collection to None
|
||||||
|
if instance.collection is not None and instance.collection.user_id != user:
|
||||||
|
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||||
|
|
||||||
|
# Perform the update
|
||||||
|
self.perform_update(serializer)
|
||||||
|
|
||||||
|
# Return the updated instance
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
# Retrieve the collection from the validated data
|
||||||
|
collection = serializer.validated_data.get('collection')
|
||||||
|
|
||||||
|
# Check if a collection is provided
|
||||||
|
if collection:
|
||||||
|
user = self.request.user
|
||||||
|
# Check if the user is the owner or is in the shared_with list
|
||||||
|
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
|
||||||
|
# Return an error response if the user does not have permission
|
||||||
|
raise PermissionDenied("You do not have permission to use this collection.")
|
||||||
|
# if collection the owner of the adventure is the owner of the collection
|
||||||
|
serializer.save(user_id=collection.user_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save the adventure with the current user as the owner
|
||||||
|
serializer.save(user_id=self.request.user)
|
219
backend/server/adventures/views/collection_view.py
Normal file
219
backend/server/adventures/views/collection_view.py
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.db.models.functions import Lower
|
||||||
|
from django.db import transaction
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from adventures.models import Collection, Adventure, Transportation, Note
|
||||||
|
from adventures.permissions import CollectionShared
|
||||||
|
from adventures.serializers import CollectionSerializer
|
||||||
|
from users.models import CustomUser as User
|
||||||
|
from adventures.utils import pagination
|
||||||
|
|
||||||
|
class CollectionViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = CollectionSerializer
|
||||||
|
permission_classes = [CollectionShared]
|
||||||
|
pagination_class = pagination.StandardResultsSetPagination
|
||||||
|
|
||||||
|
# def get_queryset(self):
|
||||||
|
# return Collection.objects.filter(Q(user_id=self.request.user.id) & Q(is_archived=False))
|
||||||
|
|
||||||
|
def apply_sorting(self, queryset):
|
||||||
|
order_by = self.request.query_params.get('order_by', 'name')
|
||||||
|
order_direction = self.request.query_params.get('order_direction', 'asc')
|
||||||
|
|
||||||
|
valid_order_by = ['name', 'upated_at']
|
||||||
|
if order_by not in valid_order_by:
|
||||||
|
order_by = 'updated_at'
|
||||||
|
|
||||||
|
if order_direction not in ['asc', 'desc']:
|
||||||
|
order_direction = 'asc'
|
||||||
|
|
||||||
|
# Apply case-insensitive sorting for the 'name' field
|
||||||
|
if order_by == 'name':
|
||||||
|
queryset = queryset.annotate(lower_name=Lower('name'))
|
||||||
|
ordering = 'lower_name'
|
||||||
|
if order_direction == 'desc':
|
||||||
|
ordering = f'-{ordering}'
|
||||||
|
else:
|
||||||
|
order_by == 'updated_at'
|
||||||
|
ordering = 'updated_at'
|
||||||
|
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_id=request.user.id)
|
||||||
|
queryset = self.apply_sorting(queryset)
|
||||||
|
collections = self.paginate_and_respond(queryset, request)
|
||||||
|
return collections
|
||||||
|
|
||||||
|
@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 = Collection.objects.filter(
|
||||||
|
Q(user_id=request.user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = self.apply_sorting(queryset)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def archived(self, request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return Response({"error": "User is not authenticated"}, status=400)
|
||||||
|
|
||||||
|
queryset = Collection.objects.filter(
|
||||||
|
Q(user_id=request.user.id) & Q(is_archived=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = self.apply_sorting(queryset)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
# this make the is_public field of the collection cascade to the adventures
|
||||||
|
@transaction.atomic
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
partial = kwargs.pop('partial', False)
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
if 'collection' in serializer.validated_data:
|
||||||
|
new_collection = serializer.validated_data['collection']
|
||||||
|
# if the new collection is different from the old one and the user making the request is not the owner of the new collection return an error
|
||||||
|
if new_collection != instance.collection and new_collection.user_id != request.user:
|
||||||
|
return Response({"error": "User does not own the new collection"}, status=400)
|
||||||
|
|
||||||
|
# Check if the 'is_public' field is present in the update data
|
||||||
|
if 'is_public' in serializer.validated_data:
|
||||||
|
new_public_status = serializer.validated_data['is_public']
|
||||||
|
|
||||||
|
# if is_publuc has changed and the user is not the owner of the collection return an error
|
||||||
|
if new_public_status != instance.is_public and instance.user_id != request.user:
|
||||||
|
print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user_id}")
|
||||||
|
return Response({"error": "User does not own the collection"}, status=400)
|
||||||
|
|
||||||
|
# Update associated adventures to match the collection's is_public status
|
||||||
|
Adventure.objects.filter(collection=instance).update(is_public=new_public_status)
|
||||||
|
|
||||||
|
# do the same for transportations
|
||||||
|
Transportation.objects.filter(collection=instance).update(is_public=new_public_status)
|
||||||
|
|
||||||
|
# do the same for notes
|
||||||
|
Note.objects.filter(collection=instance).update(is_public=new_public_status)
|
||||||
|
|
||||||
|
# Log the action (optional)
|
||||||
|
action = "public" if new_public_status else "private"
|
||||||
|
print(f"Collection {instance.id} and its adventures were set to {action}")
|
||||||
|
|
||||||
|
self.perform_update(serializer)
|
||||||
|
|
||||||
|
if getattr(instance, '_prefetched_objects_cache', None):
|
||||||
|
# If 'prefetch_related' has been applied to a queryset, we need to
|
||||||
|
# forcibly invalidate the prefetch cache on the instance.
|
||||||
|
instance._prefetched_objects_cache = {}
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
# make an action to retreive all adventures that are shared with the user
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def shared(self, request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return Response({"error": "User is not authenticated"}, status=400)
|
||||||
|
queryset = Collection.objects.filter(
|
||||||
|
shared_with=request.user
|
||||||
|
)
|
||||||
|
queryset = self.apply_sorting(queryset)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
# Adds a new user to the shared_with field of an adventure
|
||||||
|
@action(detail=True, methods=['post'], url_path='share/(?P<uuid>[^/.]+)')
|
||||||
|
def share(self, request, pk=None, uuid=None):
|
||||||
|
collection = self.get_object()
|
||||||
|
if not uuid:
|
||||||
|
return Response({"error": "User UUID is required"}, status=400)
|
||||||
|
try:
|
||||||
|
user = User.objects.get(uuid=uuid, public_profile=True)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response({"error": "User not found"}, status=404)
|
||||||
|
|
||||||
|
if user == request.user:
|
||||||
|
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)
|
||||||
|
|
||||||
|
collection.shared_with.add(user)
|
||||||
|
collection.save()
|
||||||
|
return Response({"success": f"Shared with {user.username}"})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'], url_path='unshare/(?P<uuid>[^/.]+)')
|
||||||
|
def unshare(self, request, pk=None, uuid=None):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return Response({"error": "User is not authenticated"}, status=400)
|
||||||
|
collection = self.get_object()
|
||||||
|
if not uuid:
|
||||||
|
return Response({"error": "User UUID is required"}, status=400)
|
||||||
|
try:
|
||||||
|
user = User.objects.get(uuid=uuid, public_profile=True)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response({"error": "User not found"}, status=404)
|
||||||
|
|
||||||
|
if user == request.user:
|
||||||
|
return Response({"error": "Cannot unshare with yourself"}, status=400)
|
||||||
|
|
||||||
|
if not collection.shared_with.filter(id=user.id).exists():
|
||||||
|
return Response({"error": "Collection is not shared with this user"}, status=400)
|
||||||
|
|
||||||
|
collection.shared_with.remove(user)
|
||||||
|
collection.save()
|
||||||
|
return Response({"success": f"Unshared with {user.username}"})
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if self.action == 'destroy':
|
||||||
|
return Collection.objects.filter(user_id=self.request.user.id)
|
||||||
|
|
||||||
|
if self.action in ['update', 'partial_update']:
|
||||||
|
return Collection.objects.filter(
|
||||||
|
Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
if not self.request.user.is_authenticated:
|
||||||
|
return Collection.objects.filter(is_public=True)
|
||||||
|
return Collection.objects.filter(
|
||||||
|
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# For list action, include collections owned by the user or shared with the user, that are not archived
|
||||||
|
return Collection.objects.filter(
|
||||||
|
(Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)) & Q(is_archived=False)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
# This is ok because you cannot share a collection when creating it
|
||||||
|
serializer.save(user_id=self.request.user)
|
||||||
|
|
||||||
|
def paginate_and_respond(self, queryset, request):
|
||||||
|
paginator = self.pagination_class()
|
||||||
|
page = paginator.paginate_queryset(queryset, request)
|
||||||
|
if page is not None:
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
return paginator.get_paginated_response(serializer.data)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
37
backend/server/adventures/views/generate_description_view.py
Normal file
37
backend/server/adventures/views/generate_description_view.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
import requests
|
||||||
|
|
||||||
|
class GenerateDescription(viewsets.ViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'],)
|
||||||
|
def desc(self, request):
|
||||||
|
name = self.request.query_params.get('name', '')
|
||||||
|
# un url encode the name
|
||||||
|
name = name.replace('%20', ' ')
|
||||||
|
print(name)
|
||||||
|
url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=extracts&exintro&explaintext&format=json&titles=%s' % name
|
||||||
|
response = requests.get(url)
|
||||||
|
data = response.json()
|
||||||
|
data = response.json()
|
||||||
|
page_id = next(iter(data["query"]["pages"]))
|
||||||
|
extract = data["query"]["pages"][page_id]
|
||||||
|
if extract.get('extract') is None:
|
||||||
|
return Response({"error": "No description found"}, status=400)
|
||||||
|
return Response(extract)
|
||||||
|
@action(detail=False, methods=['get'],)
|
||||||
|
def img(self, request):
|
||||||
|
name = self.request.query_params.get('name', '')
|
||||||
|
# un url encode the name
|
||||||
|
name = name.replace('%20', ' ')
|
||||||
|
url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=pageimages&format=json&piprop=original&titles=%s' % name
|
||||||
|
response = requests.get(url)
|
||||||
|
data = response.json()
|
||||||
|
page_id = next(iter(data["query"]["pages"]))
|
||||||
|
extract = data["query"]["pages"][page_id]
|
||||||
|
if extract.get('original') is None:
|
||||||
|
return Response({"error": "No image found"}, status=400)
|
||||||
|
return Response(extract["original"])
|
67
backend/server/adventures/views/ics_calendar_view.py
Normal file
67
backend/server/adventures/views/ics_calendar_view.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from icalendar import Calendar, Event, vText, vCalAddress
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from adventures.models import Adventure
|
||||||
|
from adventures.serializers import AdventureSerializer
|
||||||
|
|
||||||
|
class IcsCalendarGeneratorViewSet(viewsets.ViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def generate(self, request):
|
||||||
|
adventures = Adventure.objects.filter(user_id=request.user)
|
||||||
|
serializer = AdventureSerializer(adventures, many=True)
|
||||||
|
user = request.user
|
||||||
|
name = f"{user.first_name} {user.last_name}"
|
||||||
|
print(serializer.data)
|
||||||
|
|
||||||
|
cal = Calendar()
|
||||||
|
cal.add('prodid', '-//My Adventure Calendar//example.com//')
|
||||||
|
cal.add('version', '2.0')
|
||||||
|
|
||||||
|
for adventure in serializer.data:
|
||||||
|
if adventure['visits']:
|
||||||
|
for visit in adventure['visits']:
|
||||||
|
# Skip if start_date is missing
|
||||||
|
if not visit.get('start_date'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse start_date and handle end_date
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(visit['start_date'], '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
continue # Skip if the start_date is invalid
|
||||||
|
|
||||||
|
end_date = (
|
||||||
|
datetime.strptime(visit['end_date'], '%Y-%m-%d').date() + timedelta(days=1)
|
||||||
|
if visit.get('end_date') else start_date + timedelta(days=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create event
|
||||||
|
event = Event()
|
||||||
|
event.add('summary', adventure['name'])
|
||||||
|
event.add('dtstart', start_date)
|
||||||
|
event.add('dtend', end_date)
|
||||||
|
event.add('dtstamp', datetime.now())
|
||||||
|
event.add('transp', 'TRANSPARENT')
|
||||||
|
event.add('class', 'PUBLIC')
|
||||||
|
event.add('created', datetime.now())
|
||||||
|
event.add('last-modified', datetime.now())
|
||||||
|
event.add('description', adventure['description'])
|
||||||
|
if adventure.get('location'):
|
||||||
|
event.add('location', adventure['location'])
|
||||||
|
if adventure.get('link'):
|
||||||
|
event.add('url', adventure['link'])
|
||||||
|
|
||||||
|
organizer = vCalAddress(f'MAILTO:{user.email}')
|
||||||
|
organizer.params['cn'] = vText(name)
|
||||||
|
event.add('organizer', organizer)
|
||||||
|
|
||||||
|
cal.add_component(event)
|
||||||
|
|
||||||
|
response = HttpResponse(cal.to_ical(), content_type='text/calendar')
|
||||||
|
response['Content-Disposition'] = 'attachment; filename=adventures.ics'
|
||||||
|
return response
|
130
backend/server/adventures/views/note_view.py
Normal file
130
backend/server/adventures/views/note_view.py
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.db.models import Q
|
||||||
|
from adventures.models import Note
|
||||||
|
from adventures.serializers import NoteSerializer
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
|
||||||
|
class NoteViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Note.objects.all()
|
||||||
|
serializer_class = NoteSerializer
|
||||||
|
permission_classes = [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 notes 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 = Note.objects.filter(
|
||||||
|
Q(user_id=request.user.id)
|
||||||
|
)
|
||||||
|
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 not self.request.user.is_authenticated:
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
return Note.objects.filter(is_public=True).distinct().order_by('-updated_at')
|
||||||
|
return Note.objects.none()
|
||||||
|
|
||||||
|
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
# For individual adventure retrieval, include public adventures
|
||||||
|
return Note.objects.filter(
|
||||||
|
Q(is_public=True) | Q(user_id=self.request.user.id) | 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 Note.objects.filter(
|
||||||
|
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
|
||||||
|
).distinct().order_by('-updated_at')
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
# Retrieve the current object
|
||||||
|
instance = self.get_object()
|
||||||
|
|
||||||
|
# Partially update the instance with the request data
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# Retrieve the collection from the validated data
|
||||||
|
new_collection = serializer.validated_data.get('collection')
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
print(new_collection)
|
||||||
|
|
||||||
|
if new_collection is not None and new_collection!=instance.collection:
|
||||||
|
# Check if the user is the owner of the new collection
|
||||||
|
if new_collection.user_id != user or instance.user_id != user:
|
||||||
|
raise PermissionDenied("You do not have permission to use this collection.")
|
||||||
|
elif new_collection is None:
|
||||||
|
# Handle the case where the user is trying to set the collection to None
|
||||||
|
if instance.collection is not None and instance.collection.user_id != user:
|
||||||
|
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||||
|
|
||||||
|
# Perform the update
|
||||||
|
self.perform_update(serializer)
|
||||||
|
|
||||||
|
# Return the updated instance
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
# Retrieve the current object
|
||||||
|
instance = self.get_object()
|
||||||
|
|
||||||
|
# Partially update the instance with the request data
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# Retrieve the collection from the validated data
|
||||||
|
new_collection = serializer.validated_data.get('collection')
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
print(new_collection)
|
||||||
|
|
||||||
|
if new_collection is not None and new_collection!=instance.collection:
|
||||||
|
# Check if the user is the owner of the new collection
|
||||||
|
if new_collection.user_id != user or instance.user_id != user:
|
||||||
|
raise PermissionDenied("You do not have permission to use this collection.")
|
||||||
|
elif new_collection is None:
|
||||||
|
# Handle the case where the user is trying to set the collection to None
|
||||||
|
if instance.collection is not None and instance.collection.user_id != user:
|
||||||
|
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||||
|
|
||||||
|
# Perform the update
|
||||||
|
self.perform_update(serializer)
|
||||||
|
|
||||||
|
# Return the updated instance
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
# Retrieve the collection from the validated data
|
||||||
|
collection = serializer.validated_data.get('collection')
|
||||||
|
|
||||||
|
# Check if a collection is provided
|
||||||
|
if collection:
|
||||||
|
user = self.request.user
|
||||||
|
# Check if the user is the owner or is in the shared_with list
|
||||||
|
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
|
||||||
|
# Return an error response if the user does not have permission
|
||||||
|
raise PermissionDenied("You do not have permission to use this collection.")
|
||||||
|
# if collection the owner of the adventure is the owner of the collection
|
||||||
|
serializer.save(user_id=collection.user_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save the adventure with the current user as the owner
|
||||||
|
serializer.save(user_id=self.request.user)
|
183
backend/server/adventures/views/overpass_view.py
Normal file
183
backend/server/adventures/views/overpass_view.py
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
import requests
|
||||||
|
|
||||||
|
class OverpassViewSet(viewsets.ViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
BASE_URL = "https://overpass-api.de/api/interpreter"
|
||||||
|
HEADERS = {'User-Agent': 'AdventureLog Server'}
|
||||||
|
|
||||||
|
def make_overpass_query(self, query):
|
||||||
|
"""
|
||||||
|
Sends a query to the Overpass API and returns the response data.
|
||||||
|
Args:
|
||||||
|
query (str): The Overpass QL query string.
|
||||||
|
Returns:
|
||||||
|
dict: Parsed JSON response from the Overpass API.
|
||||||
|
Raises:
|
||||||
|
Response: DRF Response object with an error message in case of failure.
|
||||||
|
"""
|
||||||
|
url = f"{self.BASE_URL}?data={query}"
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=self.HEADERS)
|
||||||
|
response.raise_for_status() # Raise an exception for HTTP errors
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
return Response({"error": "Failed to connect to Overpass API"}, status=500)
|
||||||
|
except requests.exceptions.JSONDecodeError:
|
||||||
|
return Response({"error": "Invalid response from Overpass API"}, status=400)
|
||||||
|
|
||||||
|
def parse_overpass_response(self, data, request):
|
||||||
|
"""
|
||||||
|
Parses the JSON response from the Overpass API and extracts relevant data,
|
||||||
|
turning it into an adventure-structured object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response (dict): The JSON response from the Overpass API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: A list of adventure objects with structured data.
|
||||||
|
"""
|
||||||
|
# Extract elements (nodes/ways/relations) from the response
|
||||||
|
nodes = data.get('elements', [])
|
||||||
|
adventures = []
|
||||||
|
|
||||||
|
# include all entries, even the ones that do not have lat long
|
||||||
|
all = request.query_params.get('all', False)
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
# Ensure we are working with a "node" type (can also handle "way" or "relation" if needed)
|
||||||
|
if node.get('type') not in ['node', 'way', 'relation']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract tags and general data
|
||||||
|
tags = node.get('tags', {})
|
||||||
|
adventure = {
|
||||||
|
"id": node.get('id'), # Include the unique OSM ID
|
||||||
|
"type": node.get('type'), # Type of element (node, way, relation)
|
||||||
|
"name": tags.get('name', tags.get('official_name', '')), # Fallback to 'official_name'
|
||||||
|
"description": tags.get('description', None), # Additional descriptive information
|
||||||
|
"latitude": node.get('lat', None), # Use None for consistency with missing values
|
||||||
|
"longitude": node.get('lon', None),
|
||||||
|
"address": {
|
||||||
|
"city": tags.get('addr:city', None),
|
||||||
|
"housenumber": tags.get('addr:housenumber', None),
|
||||||
|
"postcode": tags.get('addr:postcode', None),
|
||||||
|
"state": tags.get('addr:state', None),
|
||||||
|
"street": tags.get('addr:street', None),
|
||||||
|
"country": tags.get('addr:country', None), # Add 'country' if available
|
||||||
|
"suburb": tags.get('addr:suburb', None), # Add 'suburb' for more granularity
|
||||||
|
},
|
||||||
|
"feature_id": tags.get('gnis:feature_id', None),
|
||||||
|
"tag": next((tags.get(key, None) for key in ['leisure', 'tourism', 'natural', 'historic', 'amenity'] if key in tags), None),
|
||||||
|
"contact": {
|
||||||
|
"phone": tags.get('phone', None),
|
||||||
|
"email": tags.get('contact:email', None),
|
||||||
|
"website": tags.get('website', None),
|
||||||
|
"facebook": tags.get('contact:facebook', None), # Social media links
|
||||||
|
"twitter": tags.get('contact:twitter', None),
|
||||||
|
},
|
||||||
|
# "tags": tags, # Include all raw tags for future use
|
||||||
|
}
|
||||||
|
|
||||||
|
# Filter out adventures with no name, latitude, or longitude
|
||||||
|
if (adventure["name"] and
|
||||||
|
adventure["latitude"] is not None and -90 <= adventure["latitude"] <= 90 and
|
||||||
|
adventure["longitude"] is not None and -180 <= adventure["longitude"] <= 180) or all:
|
||||||
|
adventures.append(adventure)
|
||||||
|
|
||||||
|
return adventures
|
||||||
|
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def query(self, request):
|
||||||
|
"""
|
||||||
|
Radius-based search for tourism-related locations around given coordinates.
|
||||||
|
"""
|
||||||
|
lat = request.query_params.get('lat')
|
||||||
|
lon = request.query_params.get('lon')
|
||||||
|
radius = request.query_params.get('radius', '1000') # Default radius: 1000 meters
|
||||||
|
|
||||||
|
valid_categories = ['lodging', 'food', 'tourism']
|
||||||
|
category = request.query_params.get('category', 'all')
|
||||||
|
if category not in valid_categories:
|
||||||
|
return Response({"error": f"Invalid category. Valid categories: {', '.join(valid_categories)}"}, status=400)
|
||||||
|
|
||||||
|
if category == 'tourism':
|
||||||
|
query = f"""
|
||||||
|
[out:json];
|
||||||
|
(
|
||||||
|
node(around:{radius},{lat},{lon})["tourism"];
|
||||||
|
node(around:{radius},{lat},{lon})["leisure"];
|
||||||
|
node(around:{radius},{lat},{lon})["historic"];
|
||||||
|
node(around:{radius},{lat},{lon})["sport"];
|
||||||
|
node(around:{radius},{lat},{lon})["natural"];
|
||||||
|
node(around:{radius},{lat},{lon})["attraction"];
|
||||||
|
node(around:{radius},{lat},{lon})["museum"];
|
||||||
|
node(around:{radius},{lat},{lon})["zoo"];
|
||||||
|
node(around:{radius},{lat},{lon})["aquarium"];
|
||||||
|
);
|
||||||
|
out;
|
||||||
|
"""
|
||||||
|
if category == 'lodging':
|
||||||
|
query = f"""
|
||||||
|
[out:json];
|
||||||
|
(
|
||||||
|
node(around:{radius},{lat},{lon})["tourism"="hotel"];
|
||||||
|
node(around:{radius},{lat},{lon})["tourism"="motel"];
|
||||||
|
node(around:{radius},{lat},{lon})["tourism"="guest_house"];
|
||||||
|
node(around:{radius},{lat},{lon})["tourism"="hostel"];
|
||||||
|
node(around:{radius},{lat},{lon})["tourism"="camp_site"];
|
||||||
|
node(around:{radius},{lat},{lon})["tourism"="caravan_site"];
|
||||||
|
node(around:{radius},{lat},{lon})["tourism"="chalet"];
|
||||||
|
node(around:{radius},{lat},{lon})["tourism"="alpine_hut"];
|
||||||
|
node(around:{radius},{lat},{lon})["tourism"="apartment"];
|
||||||
|
);
|
||||||
|
out;
|
||||||
|
"""
|
||||||
|
if category == 'food':
|
||||||
|
query = f"""
|
||||||
|
[out:json];
|
||||||
|
(
|
||||||
|
node(around:{radius},{lat},{lon})["amenity"="restaurant"];
|
||||||
|
node(around:{radius},{lat},{lon})["amenity"="cafe"];
|
||||||
|
node(around:{radius},{lat},{lon})["amenity"="fast_food"];
|
||||||
|
node(around:{radius},{lat},{lon})["amenity"="pub"];
|
||||||
|
node(around:{radius},{lat},{lon})["amenity"="bar"];
|
||||||
|
node(around:{radius},{lat},{lon})["amenity"="food_court"];
|
||||||
|
node(around:{radius},{lat},{lon})["amenity"="ice_cream"];
|
||||||
|
node(around:{radius},{lat},{lon})["amenity"="bakery"];
|
||||||
|
node(around:{radius},{lat},{lon})["amenity"="confectionery"];
|
||||||
|
);
|
||||||
|
out;
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Validate required parameters
|
||||||
|
if not lat or not lon:
|
||||||
|
return Response(
|
||||||
|
{"error": "Latitude and longitude parameters are required."}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
data = self.make_overpass_query(query)
|
||||||
|
adventures = self.parse_overpass_response(data, request)
|
||||||
|
return Response(adventures)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='search')
|
||||||
|
def search(self, request):
|
||||||
|
"""
|
||||||
|
Name-based search for nodes with the specified name.
|
||||||
|
"""
|
||||||
|
name = request.query_params.get('name')
|
||||||
|
|
||||||
|
# Validate required parameter
|
||||||
|
if not name:
|
||||||
|
return Response({"error": "Name parameter is required."}, status=400)
|
||||||
|
|
||||||
|
# Construct Overpass API query
|
||||||
|
query = f'[out:json];node["name"~"{name}",i];out;'
|
||||||
|
data = self.make_overpass_query(query)
|
||||||
|
|
||||||
|
adventures = self.parse_overpass_response(data, request)
|
||||||
|
return Response(adventures)
|
117
backend/server/adventures/views/reverse_geocode_view.py
Normal file
117
backend/server/adventures/views/reverse_geocode_view.py
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from worldtravel.models import Region, City, VisitedRegion, VisitedCity
|
||||||
|
from adventures.models import Adventure
|
||||||
|
from adventures.serializers import AdventureSerializer
|
||||||
|
import requests
|
||||||
|
|
||||||
|
class ReverseGeocodeViewSet(viewsets.ViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def extractIsoCode(self, data):
|
||||||
|
"""
|
||||||
|
Extract the ISO code from the response data.
|
||||||
|
Returns a dictionary containing the region name, country name, and ISO code if found.
|
||||||
|
"""
|
||||||
|
iso_code = None
|
||||||
|
town_city_or_county = None
|
||||||
|
display_name = None
|
||||||
|
country_code = None
|
||||||
|
city = None
|
||||||
|
visited_city = None
|
||||||
|
|
||||||
|
# town = None
|
||||||
|
# city = None
|
||||||
|
# county = None
|
||||||
|
|
||||||
|
if 'address' in data.keys():
|
||||||
|
keys = data['address'].keys()
|
||||||
|
for key in keys:
|
||||||
|
if key.find("ISO") != -1:
|
||||||
|
iso_code = data['address'][key]
|
||||||
|
if 'town' in keys:
|
||||||
|
town_city_or_county = data['address']['town']
|
||||||
|
if 'county' in keys:
|
||||||
|
town_city_or_county = data['address']['county']
|
||||||
|
if 'city' in keys:
|
||||||
|
town_city_or_county = data['address']['city']
|
||||||
|
if not iso_code:
|
||||||
|
return {"error": "No region found"}
|
||||||
|
|
||||||
|
region = Region.objects.filter(id=iso_code).first()
|
||||||
|
visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first()
|
||||||
|
|
||||||
|
region_visited = False
|
||||||
|
city_visited = False
|
||||||
|
country_code = iso_code[:2]
|
||||||
|
|
||||||
|
if region:
|
||||||
|
if town_city_or_county:
|
||||||
|
display_name = f"{town_city_or_county}, {region.name}, {country_code}"
|
||||||
|
city = City.objects.filter(name__contains=town_city_or_county, region=region).first()
|
||||||
|
visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
|
||||||
|
|
||||||
|
if visited_region:
|
||||||
|
region_visited = True
|
||||||
|
if visited_city:
|
||||||
|
city_visited = True
|
||||||
|
if region:
|
||||||
|
return {"region_id": iso_code, "region": region.name, "country": region.country.name, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited}
|
||||||
|
return {"error": "No region found"}
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def reverse_geocode(self, request):
|
||||||
|
lat = request.query_params.get('lat', '')
|
||||||
|
lon = request.query_params.get('lon', '')
|
||||||
|
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}"
|
||||||
|
headers = {'User-Agent': 'AdventureLog Server'}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except requests.exceptions.JSONDecodeError:
|
||||||
|
return Response({"error": "Invalid response from geocoding service"}, status=400)
|
||||||
|
return Response(self.extractIsoCode(data))
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def mark_visited_region(self, request):
|
||||||
|
# searches through all of the users adventures, if the serialized data is_visited, is true, runs reverse geocode on the adventures and if a region is found, marks it as visited. Use the extractIsoCode function to get the region
|
||||||
|
new_region_count = 0
|
||||||
|
new_regions = {}
|
||||||
|
new_city_count = 0
|
||||||
|
new_cities = {}
|
||||||
|
adventures = Adventure.objects.filter(user_id=self.request.user)
|
||||||
|
serializer = AdventureSerializer(adventures, many=True)
|
||||||
|
for adventure, serialized_adventure in zip(adventures, serializer.data):
|
||||||
|
if serialized_adventure['is_visited'] == True:
|
||||||
|
lat = adventure.latitude
|
||||||
|
lon = adventure.longitude
|
||||||
|
if not lat or not lon:
|
||||||
|
continue
|
||||||
|
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}"
|
||||||
|
headers = {'User-Agent': 'AdventureLog Server'}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except requests.exceptions.JSONDecodeError:
|
||||||
|
return Response({"error": "Invalid response from geocoding service"}, status=400)
|
||||||
|
extracted_region = self.extractIsoCode(data)
|
||||||
|
if 'error' not in extracted_region:
|
||||||
|
region = Region.objects.filter(id=extracted_region['region_id']).first()
|
||||||
|
visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first()
|
||||||
|
if not visited_region:
|
||||||
|
visited_region = VisitedRegion(region=region, user_id=self.request.user)
|
||||||
|
visited_region.save()
|
||||||
|
new_region_count += 1
|
||||||
|
new_regions[region.id] = region.name
|
||||||
|
|
||||||
|
if extracted_region['city_id'] is not None:
|
||||||
|
city = City.objects.filter(id=extracted_region['city_id']).first()
|
||||||
|
visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
|
||||||
|
if not visited_city:
|
||||||
|
visited_city = VisitedCity(city=city, user_id=self.request.user)
|
||||||
|
visited_city.save()
|
||||||
|
new_city_count += 1
|
||||||
|
new_cities[city.id] = city.name
|
||||||
|
return Response({"new_regions": new_region_count, "regions": new_regions, "new_cities": new_city_count, "cities": new_cities})
|
38
backend/server/adventures/views/stats_view.py
Normal file
38
backend/server/adventures/views/stats_view.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from worldtravel.models import City, Region, Country, VisitedCity, VisitedRegion
|
||||||
|
from adventures.models import Adventure, Collection
|
||||||
|
|
||||||
|
class StatsViewSet(viewsets.ViewSet):
|
||||||
|
"""
|
||||||
|
A simple ViewSet for listing the stats of a user.
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def counts(self, request):
|
||||||
|
adventure_count = Adventure.objects.filter(
|
||||||
|
user_id=request.user.id).count()
|
||||||
|
trips_count = Collection.objects.filter(
|
||||||
|
user_id=request.user.id).count()
|
||||||
|
visited_city_count = VisitedCity.objects.filter(
|
||||||
|
user_id=request.user.id).count()
|
||||||
|
total_cities = City.objects.count()
|
||||||
|
visited_region_count = VisitedRegion.objects.filter(
|
||||||
|
user_id=request.user.id).count()
|
||||||
|
total_regions = Region.objects.count()
|
||||||
|
visited_country_count = VisitedRegion.objects.filter(
|
||||||
|
user_id=request.user.id).values('region__country').distinct().count()
|
||||||
|
total_countries = Country.objects.count()
|
||||||
|
return Response({
|
||||||
|
'adventure_count': adventure_count,
|
||||||
|
'trips_count': trips_count,
|
||||||
|
'visited_city_count': visited_city_count,
|
||||||
|
'total_cities': total_cities,
|
||||||
|
'visited_region_count': visited_region_count,
|
||||||
|
'total_regions': total_regions,
|
||||||
|
'visited_country_count': visited_country_count,
|
||||||
|
'total_countries': total_countries
|
||||||
|
})
|
84
backend/server/adventures/views/transportation_view.py
Normal file
84
backend/server/adventures/views/transportation_view.py
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.db.models import Q
|
||||||
|
from adventures.models import Transportation
|
||||||
|
from adventures.serializers import TransportationSerializer
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
|
class TransportationViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Transportation.objects.all()
|
||||||
|
serializer_class = TransportationSerializer
|
||||||
|
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||||
|
queryset = Transportation.objects.filter(
|
||||||
|
Q(user_id=request.user.id)
|
||||||
|
)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
# For individual adventure retrieval, include public adventures, user's own adventures and shared adventures
|
||||||
|
return Transportation.objects.filter(
|
||||||
|
Q(is_public=True) | Q(user_id=user.id) | Q(collection__shared_with=user.id)
|
||||||
|
).distinct().order_by('-updated_at')
|
||||||
|
# For other actions, include user's own adventures and shared adventures
|
||||||
|
return Transportation.objects.filter(
|
||||||
|
Q(user_id=user.id) | Q(collection__shared_with=user.id)
|
||||||
|
).distinct().order_by('-updated_at')
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
# Retrieve the current object
|
||||||
|
instance = self.get_object()
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Partially update the instance with the request data
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# Retrieve the collection from the validated data
|
||||||
|
new_collection = serializer.validated_data.get('collection')
|
||||||
|
|
||||||
|
if new_collection is not None and new_collection != instance.collection:
|
||||||
|
# Check if the user is the owner of the new collection
|
||||||
|
if new_collection.user_id != user or instance.user_id != user:
|
||||||
|
raise PermissionDenied("You do not have permission to use this collection.")
|
||||||
|
elif new_collection is None:
|
||||||
|
# Handle the case where the user is trying to set the collection to None
|
||||||
|
if instance.collection is not None and instance.collection.user_id != user:
|
||||||
|
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||||
|
|
||||||
|
# Perform the update
|
||||||
|
self.perform_update(serializer)
|
||||||
|
|
||||||
|
# Return the updated instance
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
# Retrieve the collection from the validated data
|
||||||
|
collection = serializer.validated_data.get('collection')
|
||||||
|
|
||||||
|
# Check if a collection is provided
|
||||||
|
if collection:
|
||||||
|
user = self.request.user
|
||||||
|
# Check if the user is the owner or is in the shared_with list
|
||||||
|
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
|
||||||
|
# Return an error response if the user does not have permission
|
||||||
|
raise PermissionDenied("You do not have permission to use this collection.")
|
||||||
|
# if collection the owner of the adventure is the owner of the collection
|
||||||
|
serializer.save(user_id=collection.user_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save the adventure with the current user as the owner
|
||||||
|
serializer.save(user_id=self.request.user)
|
|
@ -1,4 +1,4 @@
|
||||||
Django==5.0.8
|
Django==5.0.11
|
||||||
djangorestframework>=3.15.2
|
djangorestframework>=3.15.2
|
||||||
django-allauth==0.63.3
|
django-allauth==0.63.3
|
||||||
drf-yasg==1.21.4
|
drf-yasg==1.21.4
|
||||||
|
@ -19,4 +19,5 @@ django-widget-tweaks==1.5.0
|
||||||
django-ical==1.9.2
|
django-ical==1.9.2
|
||||||
icalendar==6.1.0
|
icalendar==6.1.0
|
||||||
ijson==3.3.0
|
ijson==3.3.0
|
||||||
tqdm==4.67.1
|
tqdm==4.67.1
|
||||||
|
overpy==0.7
|
|
@ -1,17 +0,0 @@
|
||||||
from django import forms
|
|
||||||
|
|
||||||
class CustomSignupForm(forms.Form):
|
|
||||||
first_name = forms.CharField(max_length=30, required=True)
|
|
||||||
last_name = forms.CharField(max_length=30, required=True)
|
|
||||||
|
|
||||||
def signup(self, request, user):
|
|
||||||
# Delay the import to avoid circular import
|
|
||||||
from allauth.account.forms import SignupForm
|
|
||||||
|
|
||||||
# No need to call super() from CustomSignupForm; use the SignupForm directly if needed
|
|
||||||
user.first_name = self.cleaned_data['first_name']
|
|
||||||
user.last_name = self.cleaned_data['last_name']
|
|
||||||
|
|
||||||
# Save the user instance
|
|
||||||
user.save()
|
|
||||||
return user
|
|
|
@ -1,3 +1,82 @@
|
||||||
from django.test import TestCase
|
from rest_framework.test import APITestCase
|
||||||
|
from .models import CustomUser
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
# Create your tests here.
|
from allauth.account.models import EmailAddress
|
||||||
|
|
||||||
|
class UserAPITestCase(APITestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Signup a new user
|
||||||
|
response = self.client.post('/_allauth/browser/v1/auth/signup', {
|
||||||
|
'username': 'testuser',
|
||||||
|
'email': 'testuser@example.com',
|
||||||
|
'password': 'testpassword',
|
||||||
|
'first_name': 'Test',
|
||||||
|
'last_name': 'User',
|
||||||
|
}, format='json')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_001_user(self):
|
||||||
|
# Fetch user metadata
|
||||||
|
response = self.client.get('/auth/user-metadata/', format='json')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data['username'], 'testuser')
|
||||||
|
self.assertEqual(data['email'], 'testuser@example.com')
|
||||||
|
self.assertEqual(data['first_name'], 'Test')
|
||||||
|
self.assertEqual(data['last_name'], 'User')
|
||||||
|
self.assertEqual(data['public_profile'], False)
|
||||||
|
self.assertEqual(data['profile_pic'], None)
|
||||||
|
self.assertEqual(UUID(data['uuid']), CustomUser.objects.get(username='testuser').uuid)
|
||||||
|
self.assertEqual(data['is_staff'], False)
|
||||||
|
self.assertEqual(data['has_password'], True)
|
||||||
|
|
||||||
|
def test_002_user_update(self):
|
||||||
|
try:
|
||||||
|
userModel = CustomUser.objects.get(username='testuser2')
|
||||||
|
except:
|
||||||
|
userModel = None
|
||||||
|
|
||||||
|
self.assertEqual(userModel, None)
|
||||||
|
# Update user metadata
|
||||||
|
response = self.client.patch('/auth/update-user/', {
|
||||||
|
'username': 'testuser2',
|
||||||
|
'first_name': 'Test2',
|
||||||
|
'last_name': 'User2',
|
||||||
|
'public_profile': True,
|
||||||
|
}, format='json')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
# Note that the email field is not updated because that is a seperate endpoint
|
||||||
|
userModel = CustomUser.objects.get(username='testuser2')
|
||||||
|
self.assertEqual(data['username'], 'testuser2')
|
||||||
|
self.assertEqual(data['email'], 'testuser@example.com')
|
||||||
|
self.assertEqual(data['first_name'], 'Test2')
|
||||||
|
self.assertEqual(data['last_name'], 'User2')
|
||||||
|
self.assertEqual(data['public_profile'], True)
|
||||||
|
self.assertEqual(data['profile_pic'], None)
|
||||||
|
self.assertEqual(UUID(data['uuid']), CustomUser.objects.get(username='testuser2').uuid)
|
||||||
|
self.assertEqual(data['is_staff'], False)
|
||||||
|
self.assertEqual(data['has_password'], True)
|
||||||
|
|
||||||
|
def test_003_user_add_email(self):
|
||||||
|
# Update user email
|
||||||
|
response = self.client.post('/_allauth/browser/v1/account/email', {
|
||||||
|
'email': 'testuser2@example.com',
|
||||||
|
}, format='json')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
email_data = data['data'][0]
|
||||||
|
|
||||||
|
self.assertEqual(email_data['email'], 'testuser2@example.com')
|
||||||
|
self.assertEqual(email_data['primary'], False)
|
||||||
|
self.assertEqual(email_data['verified'], False)
|
||||||
|
|
||||||
|
emails = EmailAddress.objects.filter(user=CustomUser.objects.get(username='testuser'))
|
||||||
|
self.assertEqual(emails.count(), 2)
|
||||||
|
# assert email are testuser@example and testuser2@example.com
|
||||||
|
self.assertEqual(emails[1].email, 'testuser@example.com')
|
||||||
|
self.assertEqual(emails[0].email, 'testuser2@example.com')
|
||||||
|
|
|
@ -13,19 +13,17 @@ It is recommended to install applications in this order.
|
||||||
- To find the Database Application, search for `PostGIS` on the Unraid App Store and fill out the fields as follows:
|
- To find the Database Application, search for `PostGIS` on the Unraid App Store and fill out the fields as follows:
|
||||||
- Ensure that the POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB are set in the PostGIS container if not add them custom variables
|
- Ensure that the POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB are set in the PostGIS container if not add them custom variables
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
- Cache Configuration: This option is useful only if your appdata share is stored on a cache drive, which is used to speed up read/write operations for your containerized applications.
|
- Cache Configuration: This option is useful only if your appdata share is stored on a cache drive, which is used to speed up read/write operations for your containerized applications.
|
||||||
- Note: if your running the server in a docker network that is other than "host" (for example "bridge") than you need to add the IP of the host machine in the CSRF Trusted Origins variable.
|
- Note: if your running the server in a docker network that is other than "host" (for example "bridge") than you need to add the IP of the host machine in the CSRF Trusted Origins variable.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- By default, the frontend connects to the backend using `http://server:8000`. This will work if both the frontend and backend are on the same network. Otherwise, you’ll need to configure it to use the exposed port (default: 8016).
|
- By default, the frontend connects to the backend using `http://server:8000`. This will work if both the frontend and backend are on the same network. Otherwise, you’ll need to configure it to use the exposed port (default: 8016).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -11,4 +11,4 @@
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
BIN
documentation/public/unraid-config-1.png
Normal file
BIN
documentation/public/unraid-config-1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 337 KiB |
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 224 KiB |
BIN
documentation/public/unraid-config-3.png
Normal file
BIN
documentation/public/unraid-config-3.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 335 KiB |
Binary file not shown.
Before Width: | Height: | Size: 409 KiB |
Binary file not shown.
Before Width: | Height: | Size: 262 KiB |
|
@ -60,11 +60,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAdventure() {
|
async function deleteAdventure() {
|
||||||
let res = await fetch(`/adventures/${adventure.id}?/delete`, {
|
let res = await fetch(`/api/adventures/${adventure.id}`, {
|
||||||
method: 'POST',
|
method: 'DELETE'
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
addToast('info', $t('adventures.adventure_delete_success'));
|
addToast('info', $t('adventures.adventure_delete_success'));
|
||||||
|
|
|
@ -48,7 +48,6 @@
|
||||||
let adventure: Adventure = {
|
let adventure: Adventure = {
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
type: 'visited',
|
|
||||||
visits: [],
|
visits: [],
|
||||||
link: null,
|
link: null,
|
||||||
description: null,
|
description: null,
|
||||||
|
@ -75,7 +74,6 @@
|
||||||
adventure = {
|
adventure = {
|
||||||
id: adventureToEdit?.id || '',
|
id: adventureToEdit?.id || '',
|
||||||
name: adventureToEdit?.name || '',
|
name: adventureToEdit?.name || '',
|
||||||
type: adventureToEdit?.type || 'general',
|
|
||||||
link: adventureToEdit?.link || null,
|
link: adventureToEdit?.link || null,
|
||||||
description: adventureToEdit?.description || null,
|
description: adventureToEdit?.description || null,
|
||||||
activity_types: adventureToEdit?.activity_types || [],
|
activity_types: adventureToEdit?.activity_types || [],
|
||||||
|
@ -1045,13 +1043,7 @@ it would also work to just use on:click on the MapLibre component itself. -->
|
||||||
<label for="image" class="block font-medium mb-2">
|
<label for="image" class="block font-medium mb-2">
|
||||||
{$t('adventures.image')}
|
{$t('adventures.image')}
|
||||||
</label>
|
</label>
|
||||||
<form
|
<form class="flex flex-col items-start gap-2">
|
||||||
method="POST"
|
|
||||||
action="/adventures?/image"
|
|
||||||
use:enhance={imageSubmit}
|
|
||||||
enctype="multipart/form-data"
|
|
||||||
class="flex flex-col items-start gap-2"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
name="image"
|
name="image"
|
||||||
|
|
|
@ -347,3 +347,120 @@ export let themes = [
|
||||||
{ name: 'aestheticDark', label: 'Aesthetic Dark' },
|
{ name: 'aestheticDark', label: 'Aesthetic Dark' },
|
||||||
{ name: 'northernLights', label: 'Northern Lights' }
|
{ name: 'northernLights', label: 'Northern Lights' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function osmTagToEmoji(tag: string) {
|
||||||
|
switch (tag) {
|
||||||
|
case 'camp_site':
|
||||||
|
return '🏕️';
|
||||||
|
case 'slipway':
|
||||||
|
return '🛳️';
|
||||||
|
case 'playground':
|
||||||
|
return '🛝';
|
||||||
|
case 'viewpoint':
|
||||||
|
return '👀';
|
||||||
|
case 'cape':
|
||||||
|
return '🏞️';
|
||||||
|
case 'beach':
|
||||||
|
return '🏖️';
|
||||||
|
case 'park':
|
||||||
|
return '🌳';
|
||||||
|
case 'museum':
|
||||||
|
return '🏛️';
|
||||||
|
case 'theme_park':
|
||||||
|
return '🎢';
|
||||||
|
case 'nature_reserve':
|
||||||
|
return '🌲';
|
||||||
|
case 'memorial':
|
||||||
|
return '🕊️';
|
||||||
|
case 'monument':
|
||||||
|
return '🗿';
|
||||||
|
case 'wood':
|
||||||
|
return '🌲';
|
||||||
|
case 'zoo':
|
||||||
|
return '🦁';
|
||||||
|
case 'attraction':
|
||||||
|
return '🎡';
|
||||||
|
case 'ruins':
|
||||||
|
return '🏚️';
|
||||||
|
case 'bay':
|
||||||
|
return '🌊';
|
||||||
|
case 'hotel':
|
||||||
|
return '🏨';
|
||||||
|
case 'motel':
|
||||||
|
return '🏩';
|
||||||
|
case 'pub':
|
||||||
|
return '🍺';
|
||||||
|
case 'restaurant':
|
||||||
|
return '🍽️';
|
||||||
|
case 'cafe':
|
||||||
|
return '☕';
|
||||||
|
case 'bakery':
|
||||||
|
return '🥐';
|
||||||
|
case 'archaeological_site':
|
||||||
|
return '🏺';
|
||||||
|
case 'lighthouse':
|
||||||
|
return '🗼';
|
||||||
|
case 'tree':
|
||||||
|
return '🌳';
|
||||||
|
case 'cliff':
|
||||||
|
return '⛰️';
|
||||||
|
case 'water':
|
||||||
|
return '💧';
|
||||||
|
case 'fishing':
|
||||||
|
return '🎣';
|
||||||
|
case 'golf_course':
|
||||||
|
return '⛳';
|
||||||
|
case 'swimming_pool':
|
||||||
|
return '🏊';
|
||||||
|
case 'stadium':
|
||||||
|
return '🏟️';
|
||||||
|
case 'cave_entrance':
|
||||||
|
return '🕳️';
|
||||||
|
case 'anchor':
|
||||||
|
return '⚓';
|
||||||
|
case 'garden':
|
||||||
|
return '🌼';
|
||||||
|
case 'disc_golf_course':
|
||||||
|
return '🥏';
|
||||||
|
case 'natural':
|
||||||
|
return '🌿';
|
||||||
|
case 'ice_rink':
|
||||||
|
return '⛸️';
|
||||||
|
case 'horse_riding':
|
||||||
|
return '🐎';
|
||||||
|
case 'wreck':
|
||||||
|
return '🚢';
|
||||||
|
case 'water_park':
|
||||||
|
return '💦';
|
||||||
|
case 'picnic_site':
|
||||||
|
return '🧺';
|
||||||
|
case 'axe_throwing':
|
||||||
|
return '🪓';
|
||||||
|
case 'fort':
|
||||||
|
return '🏰';
|
||||||
|
case 'amusement_arcade':
|
||||||
|
return '🕹️';
|
||||||
|
case 'tepee':
|
||||||
|
return '🏕️';
|
||||||
|
case 'track':
|
||||||
|
return '🏃';
|
||||||
|
case 'trampoline_park':
|
||||||
|
return '🤸';
|
||||||
|
case 'dojo':
|
||||||
|
return '🥋';
|
||||||
|
case 'tree_stump':
|
||||||
|
return '🪵';
|
||||||
|
case 'peak':
|
||||||
|
return '🏔️';
|
||||||
|
case 'fitness_centre':
|
||||||
|
return '🏋️';
|
||||||
|
case 'artwork':
|
||||||
|
return '🎨';
|
||||||
|
case 'fast_food':
|
||||||
|
return '🍔';
|
||||||
|
case 'ice_cream':
|
||||||
|
return '🍦';
|
||||||
|
default:
|
||||||
|
return '📍'; // Default placeholder emoji for unknown tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -15,7 +15,6 @@ export type User = {
|
||||||
export type Adventure = {
|
export type Adventure = {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
type: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
activity_types?: string[] | null;
|
activity_types?: string[] | null;
|
||||||
|
|
|
@ -217,7 +217,10 @@
|
||||||
"to": "Zu",
|
"to": "Zu",
|
||||||
"transportation_delete_confirm": "Sind Sie sicher, dass Sie diesen Transport löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.",
|
"transportation_delete_confirm": "Sind Sie sicher, dass Sie diesen Transport löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
"show_map": "Karte anzeigen",
|
"show_map": "Karte anzeigen",
|
||||||
"will_be_marked": "wird als besucht markiert, sobald das Abenteuer gespeichert ist."
|
"will_be_marked": "wird als besucht markiert, sobald das Abenteuer gespeichert ist.",
|
||||||
|
"cities_updated": "Städte aktualisiert",
|
||||||
|
"create_adventure": "Erstelle Abenteuer",
|
||||||
|
"no_adventures_to_recommendations": "Keine Abenteuer gefunden. \nFügen Sie mindestens ein Abenteuer hinzu, um Empfehlungen zu erhalten."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit",
|
"desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit",
|
||||||
|
@ -497,7 +500,8 @@
|
||||||
"member_since": "Mitglied seit",
|
"member_since": "Mitglied seit",
|
||||||
"user_stats": "Benutzerstatistiken",
|
"user_stats": "Benutzerstatistiken",
|
||||||
"visited_countries": "Besuchte Länder",
|
"visited_countries": "Besuchte Länder",
|
||||||
"visited_regions": "Besuchte Regionen"
|
"visited_regions": "Besuchte Regionen",
|
||||||
|
"visited_cities": "Besuchte Städte"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"category_name": "Kategoriename",
|
"category_name": "Kategoriename",
|
||||||
|
@ -515,7 +519,8 @@
|
||||||
"recent_adventures": "Aktuelle Abenteuer",
|
"recent_adventures": "Aktuelle Abenteuer",
|
||||||
"total_adventures": "Totale Abenteuer",
|
"total_adventures": "Totale Abenteuer",
|
||||||
"total_visited_regions": "Insgesamt besuchte Regionen",
|
"total_visited_regions": "Insgesamt besuchte Regionen",
|
||||||
"welcome_back": "Willkommen zurück"
|
"welcome_back": "Willkommen zurück",
|
||||||
|
"total_visited_cities": "Insgesamt besuchte Städte"
|
||||||
},
|
},
|
||||||
"immich": {
|
"immich": {
|
||||||
"api_key": "Immich-API-Schlüssel",
|
"api_key": "Immich-API-Schlüssel",
|
||||||
|
|
|
@ -185,18 +185,21 @@
|
||||||
"no_description_found": "No description found",
|
"no_description_found": "No description found",
|
||||||
"adventure_created": "Adventure created",
|
"adventure_created": "Adventure created",
|
||||||
"adventure_create_error": "Failed to create adventure",
|
"adventure_create_error": "Failed to create adventure",
|
||||||
|
"create_adventure": "Create Adventure",
|
||||||
"adventure_updated": "Adventure updated",
|
"adventure_updated": "Adventure updated",
|
||||||
"adventure_update_error": "Failed to update adventure",
|
"adventure_update_error": "Failed to update adventure",
|
||||||
"set_to_pin": "Set to Pin",
|
"set_to_pin": "Set to Pin",
|
||||||
"category_fetch_error": "Error fetching categories",
|
"category_fetch_error": "Error fetching categories",
|
||||||
"new_adventure": "New Adventure",
|
"new_adventure": "New Adventure",
|
||||||
"basic_information": "Basic Information",
|
"basic_information": "Basic Information",
|
||||||
|
"no_adventures_to_recommendations": "No adventures found. Add at leat one adventure to get recommendations.",
|
||||||
"adventure_not_found": "There are no adventures to display. Add some using the plus button at the bottom right or try changing filters!",
|
"adventure_not_found": "There are no adventures to display. Add some using the plus button at the bottom right or try changing filters!",
|
||||||
"no_adventures_found": "No adventures found",
|
"no_adventures_found": "No adventures found",
|
||||||
"mark_region_as_visited": "Mark region {region}, {country} as visited?",
|
"mark_region_as_visited": "Mark region {region}, {country} as visited?",
|
||||||
"mark_visited": "Mark Visited",
|
"mark_visited": "Mark Visited",
|
||||||
"error_updating_regions": "Error updating regions",
|
"error_updating_regions": "Error updating regions",
|
||||||
"regions_updated": "regions updated",
|
"regions_updated": "regions updated",
|
||||||
|
"cities_updated": "cities updated",
|
||||||
"visited_region_check": "Visited Region Check",
|
"visited_region_check": "Visited Region Check",
|
||||||
"visited_region_check_desc": "By selecting this, the server will check all of your visited adventures and mark the regions they are located in as visited in world travel.",
|
"visited_region_check_desc": "By selecting this, the server will check all of your visited adventures and mark the regions they are located in as visited in world travel.",
|
||||||
"update_visited_regions": "Update Visited Regions",
|
"update_visited_regions": "Update Visited Regions",
|
||||||
|
@ -497,7 +500,8 @@
|
||||||
"member_since": "Member since",
|
"member_since": "Member since",
|
||||||
"user_stats": "User Stats",
|
"user_stats": "User Stats",
|
||||||
"visited_countries": "Visited Countries",
|
"visited_countries": "Visited Countries",
|
||||||
"visited_regions": "Visited Regions"
|
"visited_regions": "Visited Regions",
|
||||||
|
"visited_cities": "Visited Cities"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"manage_categories": "Manage Categories",
|
"manage_categories": "Manage Categories",
|
||||||
|
@ -513,6 +517,7 @@
|
||||||
"countries_visited": "Countries Visited",
|
"countries_visited": "Countries Visited",
|
||||||
"total_adventures": "Total Adventures",
|
"total_adventures": "Total Adventures",
|
||||||
"total_visited_regions": "Total Visited Regions",
|
"total_visited_regions": "Total Visited Regions",
|
||||||
|
"total_visited_cities": "Total Visited Cities",
|
||||||
"recent_adventures": "Recent Adventures",
|
"recent_adventures": "Recent Adventures",
|
||||||
"no_recent_adventures": "No recent adventures?",
|
"no_recent_adventures": "No recent adventures?",
|
||||||
"add_some": "Why not start planning your next adventure? You can add a new adventure by clicking the button below."
|
"add_some": "Why not start planning your next adventure? You can add a new adventure by clicking the button below."
|
||||||
|
|
|
@ -264,7 +264,10 @@
|
||||||
"to": "A",
|
"to": "A",
|
||||||
"transportation_delete_confirm": "¿Está seguro de que desea eliminar este transporte? \nEsta acción no se puede deshacer.",
|
"transportation_delete_confirm": "¿Está seguro de que desea eliminar este transporte? \nEsta acción no se puede deshacer.",
|
||||||
"show_map": "Mostrar mapa",
|
"show_map": "Mostrar mapa",
|
||||||
"will_be_marked": "se marcará como visitado una vez guardada la aventura."
|
"will_be_marked": "se marcará como visitado una vez guardada la aventura.",
|
||||||
|
"cities_updated": "ciudades actualizadas",
|
||||||
|
"create_adventure": "Crear aventura",
|
||||||
|
"no_adventures_to_recommendations": "No se encontraron aventuras. \nAñade al menos una aventura para obtener recomendaciones."
|
||||||
},
|
},
|
||||||
"worldtravel": {
|
"worldtravel": {
|
||||||
"all": "Todo",
|
"all": "Todo",
|
||||||
|
@ -497,7 +500,8 @@
|
||||||
"member_since": "Miembro desde",
|
"member_since": "Miembro desde",
|
||||||
"user_stats": "Estadísticas de usuario",
|
"user_stats": "Estadísticas de usuario",
|
||||||
"visited_countries": "Países visitados",
|
"visited_countries": "Países visitados",
|
||||||
"visited_regions": "Regiones visitadas"
|
"visited_regions": "Regiones visitadas",
|
||||||
|
"visited_cities": "Ciudades Visitadas"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"category_name": "Nombre de categoría",
|
"category_name": "Nombre de categoría",
|
||||||
|
@ -515,7 +519,8 @@
|
||||||
"recent_adventures": "Aventuras recientes",
|
"recent_adventures": "Aventuras recientes",
|
||||||
"total_adventures": "Aventuras totales",
|
"total_adventures": "Aventuras totales",
|
||||||
"total_visited_regions": "Total de regiones visitadas",
|
"total_visited_regions": "Total de regiones visitadas",
|
||||||
"welcome_back": "Bienvenido de nuevo"
|
"welcome_back": "Bienvenido de nuevo",
|
||||||
|
"total_visited_cities": "Total de ciudades visitadas"
|
||||||
},
|
},
|
||||||
"immich": {
|
"immich": {
|
||||||
"api_key": "Clave API de Immich",
|
"api_key": "Clave API de Immich",
|
||||||
|
|
|
@ -217,7 +217,10 @@
|
||||||
"to": "À",
|
"to": "À",
|
||||||
"transportation_delete_confirm": "Etes-vous sûr de vouloir supprimer ce transport ? \nCette action ne peut pas être annulée.",
|
"transportation_delete_confirm": "Etes-vous sûr de vouloir supprimer ce transport ? \nCette action ne peut pas être annulée.",
|
||||||
"show_map": "Afficher la carte",
|
"show_map": "Afficher la carte",
|
||||||
"will_be_marked": "sera marqué comme visité une fois l’aventure sauvegardée."
|
"will_be_marked": "sera marqué comme visité une fois l’aventure sauvegardée.",
|
||||||
|
"cities_updated": "villes mises à jour",
|
||||||
|
"create_adventure": "Créer une aventure",
|
||||||
|
"no_adventures_to_recommendations": "Aucune aventure trouvée. \nAjoutez au moins une aventure pour obtenir des recommandations."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"desc_1": "Découvrez, planifiez et explorez en toute simplicité",
|
"desc_1": "Découvrez, planifiez et explorez en toute simplicité",
|
||||||
|
@ -497,7 +500,8 @@
|
||||||
"member_since": "Membre depuis",
|
"member_since": "Membre depuis",
|
||||||
"user_stats": "Statistiques des utilisateurs",
|
"user_stats": "Statistiques des utilisateurs",
|
||||||
"visited_countries": "Pays visités",
|
"visited_countries": "Pays visités",
|
||||||
"visited_regions": "Régions visitées"
|
"visited_regions": "Régions visitées",
|
||||||
|
"visited_cities": "Villes visitées"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"category_name": "Nom de la catégorie",
|
"category_name": "Nom de la catégorie",
|
||||||
|
@ -515,7 +519,8 @@
|
||||||
"recent_adventures": "Aventures récentes",
|
"recent_adventures": "Aventures récentes",
|
||||||
"total_adventures": "Aventures totales",
|
"total_adventures": "Aventures totales",
|
||||||
"total_visited_regions": "Total des régions visitées",
|
"total_visited_regions": "Total des régions visitées",
|
||||||
"welcome_back": "Content de te revoir"
|
"welcome_back": "Content de te revoir",
|
||||||
|
"total_visited_cities": "Total des villes visitées"
|
||||||
},
|
},
|
||||||
"immich": {
|
"immich": {
|
||||||
"api_key": "Clé API Immich",
|
"api_key": "Clé API Immich",
|
||||||
|
|
|
@ -217,7 +217,10 @@
|
||||||
"to": "A",
|
"to": "A",
|
||||||
"transportation_delete_confirm": "Sei sicuro di voler eliminare questo trasporto? \nQuesta azione non può essere annullata.",
|
"transportation_delete_confirm": "Sei sicuro di voler eliminare questo trasporto? \nQuesta azione non può essere annullata.",
|
||||||
"show_map": "Mostra mappa",
|
"show_map": "Mostra mappa",
|
||||||
"will_be_marked": "verrà contrassegnato come visitato una volta salvata l'avventura."
|
"will_be_marked": "verrà contrassegnato come visitato una volta salvata l'avventura.",
|
||||||
|
"cities_updated": "città aggiornate",
|
||||||
|
"create_adventure": "Crea Avventura",
|
||||||
|
"no_adventures_to_recommendations": "Nessuna avventura trovata. \nAggiungi almeno un'avventura per ricevere consigli."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"desc_1": "Scopri, pianifica ed esplora con facilità",
|
"desc_1": "Scopri, pianifica ed esplora con facilità",
|
||||||
|
@ -497,7 +500,8 @@
|
||||||
"member_since": "Membro da allora",
|
"member_since": "Membro da allora",
|
||||||
"user_stats": "Statistiche utente",
|
"user_stats": "Statistiche utente",
|
||||||
"visited_countries": "Paesi visitati",
|
"visited_countries": "Paesi visitati",
|
||||||
"visited_regions": "Regioni visitate"
|
"visited_regions": "Regioni visitate",
|
||||||
|
"visited_cities": "Città visitate"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"category_name": "Nome della categoria",
|
"category_name": "Nome della categoria",
|
||||||
|
@ -515,7 +519,8 @@
|
||||||
"recent_adventures": "Avventure recenti",
|
"recent_adventures": "Avventure recenti",
|
||||||
"total_adventures": "Avventure totali",
|
"total_adventures": "Avventure totali",
|
||||||
"total_visited_regions": "Totale regioni visitate",
|
"total_visited_regions": "Totale regioni visitate",
|
||||||
"welcome_back": "Bentornato"
|
"welcome_back": "Bentornato",
|
||||||
|
"total_visited_cities": "Totale città visitate"
|
||||||
},
|
},
|
||||||
"immich": {
|
"immich": {
|
||||||
"api_key": "Chiave API Immich",
|
"api_key": "Chiave API Immich",
|
||||||
|
|
|
@ -217,7 +217,10 @@
|
||||||
"transportation_delete_confirm": "Weet u zeker dat u dit transport wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.",
|
"transportation_delete_confirm": "Weet u zeker dat u dit transport wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.",
|
||||||
"ending_airport": "Einde luchthaven",
|
"ending_airport": "Einde luchthaven",
|
||||||
"show_map": "Toon kaart",
|
"show_map": "Toon kaart",
|
||||||
"will_be_marked": "wordt gemarkeerd als bezocht zodra het avontuur is opgeslagen."
|
"will_be_marked": "wordt gemarkeerd als bezocht zodra het avontuur is opgeslagen.",
|
||||||
|
"cities_updated": "steden bijgewerkt",
|
||||||
|
"create_adventure": "Creëer avontuur",
|
||||||
|
"no_adventures_to_recommendations": "Geen avonturen gevonden. \nVoeg ten minste één avontuur toe om aanbevelingen te krijgen."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"desc_1": "Ontdek, plan en verken met gemak",
|
"desc_1": "Ontdek, plan en verken met gemak",
|
||||||
|
@ -497,7 +500,8 @@
|
||||||
"member_since": "Lid sinds",
|
"member_since": "Lid sinds",
|
||||||
"user_stats": "Gebruikersstatistieken",
|
"user_stats": "Gebruikersstatistieken",
|
||||||
"visited_countries": "Bezochte landen",
|
"visited_countries": "Bezochte landen",
|
||||||
"visited_regions": "Bezochte regio's"
|
"visited_regions": "Bezochte regio's",
|
||||||
|
"visited_cities": "Steden bezocht"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"category_name": "Categorienaam",
|
"category_name": "Categorienaam",
|
||||||
|
@ -515,7 +519,8 @@
|
||||||
"recent_adventures": "Recente avonturen",
|
"recent_adventures": "Recente avonturen",
|
||||||
"total_adventures": "Totale avonturen",
|
"total_adventures": "Totale avonturen",
|
||||||
"total_visited_regions": "Totaal bezochte regio's",
|
"total_visited_regions": "Totaal bezochte regio's",
|
||||||
"welcome_back": "Welkom terug"
|
"welcome_back": "Welkom terug",
|
||||||
|
"total_visited_cities": "Totaal bezochte steden"
|
||||||
},
|
},
|
||||||
"immich": {
|
"immich": {
|
||||||
"api_key": "Immich API-sleutel",
|
"api_key": "Immich API-sleutel",
|
||||||
|
|
|
@ -264,7 +264,10 @@
|
||||||
"to": "Do",
|
"to": "Do",
|
||||||
"transportation_delete_confirm": "Czy na pewno chcesz usunąć ten transport? \nTej akcji nie można cofnąć.",
|
"transportation_delete_confirm": "Czy na pewno chcesz usunąć ten transport? \nTej akcji nie można cofnąć.",
|
||||||
"show_map": "Pokaż mapę",
|
"show_map": "Pokaż mapę",
|
||||||
"will_be_marked": "zostanie oznaczona jako odwiedzona po zapisaniu przygody."
|
"will_be_marked": "zostanie oznaczona jako odwiedzona po zapisaniu przygody.",
|
||||||
|
"cities_updated": "miasta zaktualizowane",
|
||||||
|
"create_adventure": "Stwórz przygodę",
|
||||||
|
"no_adventures_to_recommendations": "Nie znaleziono żadnych przygód. \nDodaj co najmniej jedną przygodę, aby uzyskać rekomendacje."
|
||||||
},
|
},
|
||||||
"worldtravel": {
|
"worldtravel": {
|
||||||
"country_list": "Lista krajów",
|
"country_list": "Lista krajów",
|
||||||
|
@ -497,7 +500,8 @@
|
||||||
"member_since": "Użytkownik od",
|
"member_since": "Użytkownik od",
|
||||||
"user_stats": "Statystyki użytkownika",
|
"user_stats": "Statystyki użytkownika",
|
||||||
"visited_countries": "Odwiedzone kraje",
|
"visited_countries": "Odwiedzone kraje",
|
||||||
"visited_regions": "Odwiedzone regiony"
|
"visited_regions": "Odwiedzone regiony",
|
||||||
|
"visited_cities": "Odwiedzone miasta"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"manage_categories": "Zarządzaj kategoriami",
|
"manage_categories": "Zarządzaj kategoriami",
|
||||||
|
@ -515,7 +519,8 @@
|
||||||
"recent_adventures": "Ostatnie przygody",
|
"recent_adventures": "Ostatnie przygody",
|
||||||
"total_adventures": "Totalne przygody",
|
"total_adventures": "Totalne przygody",
|
||||||
"total_visited_regions": "Łączna liczba odwiedzonych regionów",
|
"total_visited_regions": "Łączna liczba odwiedzonych regionów",
|
||||||
"welcome_back": "Witamy z powrotem"
|
"welcome_back": "Witamy z powrotem",
|
||||||
|
"total_visited_cities": "Łączna liczba odwiedzonych miast"
|
||||||
},
|
},
|
||||||
"immich": {
|
"immich": {
|
||||||
"api_key": "Klucz API Immicha",
|
"api_key": "Klucz API Immicha",
|
||||||
|
|
|
@ -217,7 +217,10 @@
|
||||||
"to": "Till",
|
"to": "Till",
|
||||||
"transportation_delete_confirm": "Är du säker på att du vill ta bort denna transport? \nDenna åtgärd kan inte ångras.",
|
"transportation_delete_confirm": "Är du säker på att du vill ta bort denna transport? \nDenna åtgärd kan inte ångras.",
|
||||||
"show_map": "Visa karta",
|
"show_map": "Visa karta",
|
||||||
"will_be_marked": "kommer att markeras som besökt när äventyret har sparats."
|
"will_be_marked": "kommer att markeras som besökt när äventyret har sparats.",
|
||||||
|
"cities_updated": "städer uppdaterade",
|
||||||
|
"create_adventure": "Skapa äventyr",
|
||||||
|
"no_adventures_to_recommendations": "Inga äventyr hittades. \nLägg till minst ett äventyr för att få rekommendationer."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"desc_1": "Upptäck, planera och utforska med lätthet",
|
"desc_1": "Upptäck, planera och utforska med lätthet",
|
||||||
|
@ -497,7 +500,8 @@
|
||||||
"member_since": "Medlem sedan",
|
"member_since": "Medlem sedan",
|
||||||
"user_stats": "Användarstatistik",
|
"user_stats": "Användarstatistik",
|
||||||
"visited_countries": "Besökta länder",
|
"visited_countries": "Besökta länder",
|
||||||
"visited_regions": "Besökta regioner"
|
"visited_regions": "Besökta regioner",
|
||||||
|
"visited_cities": "Besökte städer"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"category_name": "Kategorinamn",
|
"category_name": "Kategorinamn",
|
||||||
|
@ -515,7 +519,8 @@
|
||||||
"recent_adventures": "Senaste äventyr",
|
"recent_adventures": "Senaste äventyr",
|
||||||
"total_adventures": "Totala äventyr",
|
"total_adventures": "Totala äventyr",
|
||||||
"total_visited_regions": "Totalt antal besökta regioner",
|
"total_visited_regions": "Totalt antal besökta regioner",
|
||||||
"welcome_back": "Välkommen tillbaka"
|
"welcome_back": "Välkommen tillbaka",
|
||||||
|
"total_visited_cities": "Totalt antal besökta städer"
|
||||||
},
|
},
|
||||||
"immich": {
|
"immich": {
|
||||||
"api_key": "Immich API-nyckel",
|
"api_key": "Immich API-nyckel",
|
||||||
|
|
|
@ -217,7 +217,10 @@
|
||||||
"to": "到",
|
"to": "到",
|
||||||
"transportation_delete_confirm": "您确定要删除此交通工具吗?\n此操作无法撤消。",
|
"transportation_delete_confirm": "您确定要删除此交通工具吗?\n此操作无法撤消。",
|
||||||
"show_map": "显示地图",
|
"show_map": "显示地图",
|
||||||
"will_be_marked": "保存冒险后将被标记为已访问。"
|
"will_be_marked": "保存冒险后将被标记为已访问。",
|
||||||
|
"cities_updated": "城市已更新",
|
||||||
|
"create_adventure": "创造冒险",
|
||||||
|
"no_adventures_to_recommendations": "没有发现任何冒险。\n至少添加一次冒险以获得推荐。"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"desc_1": "轻松发现、规划和探索",
|
"desc_1": "轻松发现、规划和探索",
|
||||||
|
@ -497,7 +500,8 @@
|
||||||
"member_since": "会员自",
|
"member_since": "会员自",
|
||||||
"user_stats": "用户统计",
|
"user_stats": "用户统计",
|
||||||
"visited_countries": "访问过的国家",
|
"visited_countries": "访问过的国家",
|
||||||
"visited_regions": "访问地区"
|
"visited_regions": "访问地区",
|
||||||
|
"visited_cities": "访问城市"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"category_name": "类别名称",
|
"category_name": "类别名称",
|
||||||
|
@ -515,7 +519,8 @@
|
||||||
"recent_adventures": "最近的冒险",
|
"recent_adventures": "最近的冒险",
|
||||||
"total_adventures": "全面冒险",
|
"total_adventures": "全面冒险",
|
||||||
"total_visited_regions": "总访问地区",
|
"total_visited_regions": "总访问地区",
|
||||||
"welcome_back": "欢迎回来"
|
"welcome_back": "欢迎回来",
|
||||||
|
"total_visited_cities": "访问城市总数"
|
||||||
},
|
},
|
||||||
"immich": {
|
"immich": {
|
||||||
"api_key": "伊米奇 API 密钥",
|
"api_key": "伊米奇 API 密钥",
|
||||||
|
|
|
@ -37,7 +37,8 @@ export const actions: Actions = {
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRFToken': csrfToken,
|
'X-CSRFToken': csrfToken,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Cookie: `csrftoken=${csrfToken}`
|
Cookie: `csrftoken=${csrfToken}`,
|
||||||
|
Referer: event.url.origin // Include Referer header
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(res);
|
console.log(res);
|
||||||
|
|
|
@ -69,7 +69,8 @@ export const actions: Actions = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`,
|
Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`,
|
||||||
'X-CSRFToken': csrfToken
|
'X-CSRFToken': csrfToken,
|
||||||
|
Referer: event.url.origin // Include Referer header
|
||||||
},
|
},
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
|
@ -66,7 +66,9 @@ export const actions: Actions = {
|
||||||
let res = await fetch(`${serverEndpoint}/api/adventures/${event.params.id}`, {
|
let res = await fetch(`${serverEndpoint}/api/adventures/${event.params.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `sessionid=${event.cookies.get('sessionid')}; csrftoken=${csrfToken}`,
|
Referer: event.url.origin, // Include Referer header
|
||||||
|
Cookie: `sessionid=${event.cookies.get('sessionid')};
|
||||||
|
csrftoken=${csrfToken}`,
|
||||||
'X-CSRFToken': csrfToken
|
'X-CSRFToken': csrfToken
|
||||||
},
|
},
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
|
|
|
@ -96,6 +96,7 @@ export const actions: Actions = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRFToken': csrfToken,
|
'X-CSRFToken': csrfToken,
|
||||||
|
Referer: event.url.origin, // Include Referer header
|
||||||
Cookie: `sessionid=${sessionid}; csrftoken=${csrfToken}`
|
Cookie: `sessionid=${sessionid}; csrftoken=${csrfToken}`
|
||||||
},
|
},
|
||||||
body: formDataToSend
|
body: formDataToSend
|
||||||
|
@ -174,9 +175,11 @@ export const actions: Actions = {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRFToken': csrfToken,
|
'X-CSRFToken': csrfToken,
|
||||||
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`
|
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
||||||
|
Referer: event.url.origin // Include Referer header
|
||||||
},
|
},
|
||||||
body: formDataToSend,
|
body: formDataToSend,
|
||||||
|
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -63,7 +63,8 @@ export const actions: Actions = {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRFToken': csrfToken
|
'X-CSRFToken': csrfToken,
|
||||||
|
Referer: event.url.origin // Include Referer header
|
||||||
},
|
},
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
|
@ -26,7 +26,8 @@
|
||||||
groupAdventuresByDate,
|
groupAdventuresByDate,
|
||||||
groupNotesByDate,
|
groupNotesByDate,
|
||||||
groupTransportationsByDate,
|
groupTransportationsByDate,
|
||||||
groupChecklistsByDate
|
groupChecklistsByDate,
|
||||||
|
osmTagToEmoji
|
||||||
} from '$lib';
|
} from '$lib';
|
||||||
import ChecklistCard from '$lib/components/ChecklistCard.svelte';
|
import ChecklistCard from '$lib/components/ChecklistCard.svelte';
|
||||||
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
|
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
|
||||||
|
@ -207,6 +208,30 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recomendationToAdventure(recomendation: any) {
|
||||||
|
adventureToEdit = {
|
||||||
|
id: '',
|
||||||
|
user_id: null,
|
||||||
|
name: recomendation.name,
|
||||||
|
latitude: recomendation.latitude,
|
||||||
|
longitude: recomendation.longitude,
|
||||||
|
images: [],
|
||||||
|
is_visited: false,
|
||||||
|
is_public: false,
|
||||||
|
visits: [],
|
||||||
|
category: {
|
||||||
|
display_name: recomendation.tag
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (char: string) => char.toUpperCase()),
|
||||||
|
icon: osmTagToEmoji(recomendation.tag),
|
||||||
|
id: '',
|
||||||
|
name: recomendation.tag,
|
||||||
|
user_id: ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
isAdventureModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
let adventureToEdit: Adventure | null = null;
|
let adventureToEdit: Adventure | null = null;
|
||||||
let transportationToEdit: Transportation | null = null;
|
let transportationToEdit: Transportation | null = null;
|
||||||
let isAdventureModalOpen: boolean = false;
|
let isAdventureModalOpen: boolean = false;
|
||||||
|
@ -240,6 +265,72 @@
|
||||||
isAdventureModalOpen = false;
|
isAdventureModalOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isPopupOpen = false;
|
||||||
|
|
||||||
|
function togglePopup() {
|
||||||
|
isPopupOpen = !isPopupOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recomendationsData: any;
|
||||||
|
let loadingRecomendations: boolean = false;
|
||||||
|
let recomendationsRange: number = 1600;
|
||||||
|
let recomendationType: string = 'tourism';
|
||||||
|
let recomendationTags: { name: string; display_name: string }[] = [];
|
||||||
|
let selectedRecomendationTag: string = '';
|
||||||
|
let filteredRecomendations: any[] = [];
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (recomendationsData && selectedRecomendationTag) {
|
||||||
|
filteredRecomendations = recomendationsData.filter(
|
||||||
|
(r: any) => r.tag === selectedRecomendationTag
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
filteredRecomendations = recomendationsData;
|
||||||
|
}
|
||||||
|
console.log(filteredRecomendations);
|
||||||
|
console.log(selectedRecomendationTag);
|
||||||
|
}
|
||||||
|
async function getRecomendations(adventure: Adventure) {
|
||||||
|
recomendationsData = null;
|
||||||
|
selectedRecomendationTag = '';
|
||||||
|
loadingRecomendations = true;
|
||||||
|
let res = await fetch(
|
||||||
|
`/api/overpass/query/?lat=${adventure.latitude}&lon=${adventure.longitude}&radius=${recomendationsRange}&category=${recomendationType}`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.log('Error fetching recommendations');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = await res.json();
|
||||||
|
recomendationsData = data;
|
||||||
|
|
||||||
|
if (recomendationsData && recomendationsData.some((r: any) => r.longitude && r.latitude)) {
|
||||||
|
const tagMap = new Map();
|
||||||
|
recomendationsData.forEach((r: any) => {
|
||||||
|
const tag = formatTag(r.tag);
|
||||||
|
if (tag) {
|
||||||
|
tagMap.set(r.tag, { name: r.tag, display_name: tag });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
recomendationTags = Array.from(tagMap.values());
|
||||||
|
|
||||||
|
function formatTag(tag: string): string {
|
||||||
|
if (tag) {
|
||||||
|
return (
|
||||||
|
tag
|
||||||
|
.split('_')
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ') + osmTagToEmoji(tag)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadingRecomendations = false;
|
||||||
|
console.log(recomendationTags);
|
||||||
|
}
|
||||||
|
|
||||||
function saveOrCreateTransportation(event: CustomEvent<Transportation>) {
|
function saveOrCreateTransportation(event: CustomEvent<Transportation>) {
|
||||||
if (transportations.find((transportation) => transportation.id === event.detail.id)) {
|
if (transportations.find((transportation) => transportation.id === event.detail.id)) {
|
||||||
// Update existing transportation
|
// Update existing transportation
|
||||||
|
@ -476,7 +567,7 @@
|
||||||
{#if collection.id}
|
{#if collection.id}
|
||||||
<div class="flex justify-center mx-auto">
|
<div class="flex justify-center mx-auto">
|
||||||
<!-- svelte-ignore a11y-missing-attribute -->
|
<!-- svelte-ignore a11y-missing-attribute -->
|
||||||
<div role="tablist" class="tabs tabs-boxed tabs-lg max-w-xl">
|
<div role="tablist" class="tabs tabs-boxed tabs-lg max-w-full">
|
||||||
<!-- svelte-ignore a11y-missing-attribute -->
|
<!-- svelte-ignore a11y-missing-attribute -->
|
||||||
{#if collection.start_date}
|
{#if collection.start_date}
|
||||||
<a
|
<a
|
||||||
|
@ -508,6 +599,14 @@
|
||||||
on:click={() => (currentView = 'map')}
|
on:click={() => (currentView = 'map')}
|
||||||
on:keydown={(e) => e.key === 'Enter' && (currentView = 'map')}>Map</a
|
on:keydown={(e) => e.key === 'Enter' && (currentView = 'map')}>Map</a
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
role="tab"
|
||||||
|
class="tab {currentView === 'recommendations' ? 'tab-active' : ''}"
|
||||||
|
tabindex="0"
|
||||||
|
on:click={() => (currentView = 'recommendations')}
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && (currentView = 'recommendations')}
|
||||||
|
>Recommendations</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -800,6 +899,188 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if currentView == 'recommendations'}
|
||||||
|
<div class="card bg-base-200 shadow-xl my-8 mx-auto w-10/12">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-3xl justify-center mb-4">Adventure Recommendations</h2>
|
||||||
|
{#each adventures as adventure}
|
||||||
|
{#if adventure.longitude && adventure.latitude}
|
||||||
|
<button on:click={() => getRecomendations(adventure)} class="btn btn-neutral"
|
||||||
|
>{adventure.name}</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if adventures.length == 0}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<p class="text-center text-lg">{$t('adventures.no_adventures_to_recommendations')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1600"
|
||||||
|
max="80467"
|
||||||
|
class="range"
|
||||||
|
step="1600"
|
||||||
|
bind:value={recomendationsRange}
|
||||||
|
/>
|
||||||
|
<div class="flex w-full justify-between px-2">
|
||||||
|
<span class="text-lg"
|
||||||
|
>{Math.round(recomendationsRange / 1600)} mile ({(
|
||||||
|
(recomendationsRange / 1600) *
|
||||||
|
1.6
|
||||||
|
).toFixed(1)} km)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="join flex items-center justify-center mt-4">
|
||||||
|
<input
|
||||||
|
class="join-item btn"
|
||||||
|
type="radio"
|
||||||
|
name="options"
|
||||||
|
aria-label="Tourism"
|
||||||
|
checked={recomendationType == 'tourism'}
|
||||||
|
on:click={() => (recomendationType = 'tourism')}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="join-item btn"
|
||||||
|
type="radio"
|
||||||
|
name="options"
|
||||||
|
aria-label="Food"
|
||||||
|
checked={recomendationType == 'food'}
|
||||||
|
on:click={() => (recomendationType = 'food')}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="join-item btn"
|
||||||
|
type="radio"
|
||||||
|
name="options"
|
||||||
|
aria-label="Lodging"
|
||||||
|
checked={recomendationType == 'lodging'}
|
||||||
|
on:click={() => (recomendationType = 'lodging')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if recomendationTags.length > 0}
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full max-w-xs"
|
||||||
|
bind:value={selectedRecomendationTag}
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
{#each recomendationTags as tag}
|
||||||
|
<option value={tag.name}>{tag.display_name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recomendationsData}
|
||||||
|
<MapLibre
|
||||||
|
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
|
||||||
|
class="aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max
|
||||||
|
-h-full w-full rounded-lg"
|
||||||
|
standardControls
|
||||||
|
center={{ lng: recomendationsData[0].longitude, lat: recomendationsData[0].latitude }}
|
||||||
|
zoom={12}
|
||||||
|
>
|
||||||
|
{#each filteredRecomendations as recomendation}
|
||||||
|
{#if recomendation.longitude && recomendation.latitude && recomendation.name}
|
||||||
|
<Marker
|
||||||
|
lngLat={[recomendation.longitude, recomendation.latitude]}
|
||||||
|
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 bg-blue-300 text-black focus:outline-6 focus:outline-black"
|
||||||
|
on:click={togglePopup}
|
||||||
|
>
|
||||||
|
<span class="text-xl">
|
||||||
|
{osmTagToEmoji(recomendation.tag)}
|
||||||
|
</span>
|
||||||
|
{#if isPopupOpen}
|
||||||
|
<Popup openOn="click" offset={[0, -10]} on:close={() => (isPopupOpen = false)}>
|
||||||
|
<div class="text-lg text-black font-bold">{recomendation.name}</div>
|
||||||
|
|
||||||
|
<p class="font-semibold text-black text-md">
|
||||||
|
{`${recomendation.tag} ${osmTagToEmoji(recomendation.tag)}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-neutral btn-wide btn-sm mt-4"
|
||||||
|
on:click={() =>
|
||||||
|
window.open(
|
||||||
|
`https://www.openstreetmap.org/node/${recomendation.id}`,
|
||||||
|
'_blank'
|
||||||
|
)}>{$t('map.view_details')}</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-neutral btn-wide btn-sm mt-4"
|
||||||
|
on:click={() => recomendationToAdventure(recomendation)}
|
||||||
|
>{$t('adventures.create_adventure')}</button
|
||||||
|
>
|
||||||
|
</Popup>
|
||||||
|
{/if}
|
||||||
|
</Marker>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</MapLibre>
|
||||||
|
{#each filteredRecomendations as recomendation}
|
||||||
|
{#if recomendation.name && recomendation.longitude && recomendation.latitude}
|
||||||
|
<div class="card bg-base-100 shadow-xl my-4 w-full">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-xl font-bold">
|
||||||
|
{recomendation.name || 'Recommendation'}
|
||||||
|
</h2>
|
||||||
|
<div class="badge badge-primary">{recomendation.tag}</div>
|
||||||
|
<p class="text-md">{recomendation.description || 'No description available.'}</p>
|
||||||
|
{#if recomendation.address}
|
||||||
|
<p class="text-md">
|
||||||
|
<strong>Address:</strong>
|
||||||
|
{recomendation.address.housenumber}
|
||||||
|
{recomendation.address.street}, {recomendation.address.city}, {recomendation
|
||||||
|
.address.state}
|
||||||
|
{recomendation.address.postcode}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if recomendation.contact}
|
||||||
|
<p class="text-md">
|
||||||
|
<strong>Contact:</strong>
|
||||||
|
{#if recomendation.contact.phone}
|
||||||
|
Phone: {recomendation.contact.phone}
|
||||||
|
{/if}
|
||||||
|
{#if recomendation.contact.email}
|
||||||
|
Email: {recomendation.contact.email}
|
||||||
|
{/if}
|
||||||
|
{#if recomendation.contact.website}
|
||||||
|
Website: <a
|
||||||
|
href={recomendation.contact.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">{recomendation.contact.website}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
on:click={() => recomendationToAdventure(recomendation)}
|
||||||
|
>
|
||||||
|
{$t('adventures.create_adventure')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{#if loadingRecomendations}
|
||||||
|
<div class="card bg-base-100 shadow-xl my-4 w-full">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col items-center justify-center">
|
||||||
|
<span class="loading loading-ring loading-lg"></span>
|
||||||
|
<div class="mt-2">
|
||||||
|
<p class="text-center text-lg">
|
||||||
|
Discovering hidden gems for your next adventure...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
import FlagCheckeredVariantIcon from '~icons/mdi/flag-checkered-variant';
|
import FlagCheckeredVariantIcon from '~icons/mdi/flag-checkered-variant';
|
||||||
import Airplane from '~icons/mdi/airplane';
|
import Airplane from '~icons/mdi/airplane';
|
||||||
import CityVariantOutline from '~icons/mdi/city-variant-outline';
|
import CityVariantOutline from '~icons/mdi/city-variant-outline';
|
||||||
|
import MapMarkerStarOutline from '~icons/mdi/map-marker-star-outline';
|
||||||
|
|
||||||
const user = data.user;
|
const user = data.user;
|
||||||
const recentAdventures = data.props.adventures;
|
const recentAdventures = data.props.adventures;
|
||||||
|
@ -26,13 +27,6 @@
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="stats shadow mb-8 w-full bg-neutral">
|
<div class="stats shadow mb-8 w-full bg-neutral">
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-figure text-primary">
|
|
||||||
<FlagCheckeredVariantIcon class="w-10 h-10 inline-block" />
|
|
||||||
</div>
|
|
||||||
<div class="stat-title text-neutral-content">{$t('dashboard.countries_visited')}</div>
|
|
||||||
<div class="stat-value text-primary">{stats.country_count}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-figure text-secondary">
|
<div class="stat-figure text-secondary">
|
||||||
<Airplane class="w-10 h-10 inline-block" />
|
<Airplane class="w-10 h-10 inline-block" />
|
||||||
|
@ -40,13 +34,27 @@
|
||||||
<div class="stat-title text-neutral-content">{$t('dashboard.total_adventures')}</div>
|
<div class="stat-title text-neutral-content">{$t('dashboard.total_adventures')}</div>
|
||||||
<div class="stat-value text-secondary">{stats.adventure_count}</div>
|
<div class="stat-value text-secondary">{stats.adventure_count}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-figure text-primary">
|
||||||
|
<FlagCheckeredVariantIcon class="w-10 h-10 inline-block" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-title text-neutral-content">{$t('dashboard.countries_visited')}</div>
|
||||||
|
<div class="stat-value text-primary">{stats.visited_country_count}</div>
|
||||||
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-figure text-success">
|
<div class="stat-figure text-success">
|
||||||
<CityVariantOutline class="w-10 h-10 inline-block" />
|
<MapMarkerStarOutline class="w-10 h-10 inline-block" />
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-title text-neutral-content">{$t('dashboard.total_visited_regions')}</div>
|
<div class="stat-title text-neutral-content">{$t('dashboard.total_visited_regions')}</div>
|
||||||
<div class="stat-value text-success">{stats.visited_region_count}</div>
|
<div class="stat-value text-success">{stats.visited_region_count}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-figure text-info">
|
||||||
|
<CityVariantOutline class="w-10 h-10 inline-block" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-title text-neutral-content">{$t('dashboard.total_visited_cities')}</div>
|
||||||
|
<div class="stat-value text-info">{stats.visited_city_count}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Adventures -->
|
<!-- Recent Adventures -->
|
||||||
|
|
|
@ -46,7 +46,8 @@ export const actions: Actions = {
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRFToken': csrfToken,
|
'X-CSRFToken': csrfToken,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Cookie: `csrftoken=${csrfToken}`
|
Cookie: `csrftoken=${csrfToken}`,
|
||||||
|
Referer: event.url.origin // Include Referer header
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
|
@ -73,7 +74,8 @@ export const actions: Actions = {
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRFToken': csrfToken,
|
'X-CSRFToken': csrfToken,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`
|
Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`,
|
||||||
|
Referer: event.url.origin // Include Referer header
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ code: totp }),
|
body: JSON.stringify({ code: totp }),
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
|
|
|
@ -57,11 +57,14 @@
|
||||||
class="block input input-bordered w-full max-w-xs"
|
class="block input input-bordered w-full max-w-xs"
|
||||||
/><br />
|
/><br />
|
||||||
{#if $page.form?.mfa_required}
|
{#if $page.form?.mfa_required}
|
||||||
<label for="password">TOTP</label>
|
<label for="totp">TOTP</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="text"
|
||||||
name="totp"
|
name="totp"
|
||||||
id="totp"
|
id="totp"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autocomplete="one-time-code"
|
||||||
class="block input input-bordered w-full max-w-xs"
|
class="block input input-bordered w-full max-w-xs"
|
||||||
/><br />
|
/><br />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -3,12 +3,14 @@
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
let stats: {
|
let stats: {
|
||||||
country_count: number;
|
visited_country_count: number;
|
||||||
total_regions: number;
|
total_regions: number;
|
||||||
trips_count: number;
|
trips_count: number;
|
||||||
adventure_count: number;
|
adventure_count: number;
|
||||||
visited_region_count: number;
|
visited_region_count: number;
|
||||||
total_countries: number;
|
total_countries: number;
|
||||||
|
visited_city_count: number;
|
||||||
|
total_cities: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
stats = data.stats || null;
|
stats = data.stats || null;
|
||||||
|
@ -73,10 +75,10 @@
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">{$t('profile.visited_countries')}</div>
|
<div class="stat-title">{$t('profile.visited_countries')}</div>
|
||||||
<div class="stat-value text-center">
|
<div class="stat-value text-center">
|
||||||
{Math.round((stats.country_count / stats.total_countries) * 100)}%
|
{Math.round((stats.visited_country_count / stats.total_countries) * 100)}%
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-desc text-center">
|
<div class="stat-desc text-center">
|
||||||
{stats.country_count}/{stats.total_countries}
|
{stats.visited_country_count}/{stats.total_countries}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -89,6 +91,16 @@
|
||||||
{stats.visited_region_count}/{stats.total_regions}
|
{stats.visited_region_count}/{stats.total_regions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">{$t('profile.visited_cities')}</div>
|
||||||
|
<div class="stat-value text-center">
|
||||||
|
{Math.round((stats.visited_city_count / stats.total_cities) * 100)}%
|
||||||
|
</div>
|
||||||
|
<div class="stat-desc text-center">
|
||||||
|
{stats.visited_city_count}/{stats.total_cities}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -130,7 +130,7 @@
|
||||||
class="join-item btn"
|
class="join-item btn"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="filter"
|
name="filter"
|
||||||
aria-label={$t('adventures.activity_types')}
|
aria-label={$t('adventures.tags')}
|
||||||
id="activity_types"
|
id="activity_types"
|
||||||
on:change={() => (property = 'activity_types')}
|
on:change={() => (property = 'activity_types')}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -107,7 +107,8 @@ export const actions: Actions = {
|
||||||
|
|
||||||
const resCurrent = await fetch(`${endpoint}/auth/user-metadata/`, {
|
const resCurrent = await fetch(`${endpoint}/auth/user-metadata/`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `sessionid=${sessionId}`
|
Cookie: `sessionid=${sessionId}`,
|
||||||
|
Referer: event.url.origin // Include Referer header
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -158,6 +159,7 @@ export const actions: Actions = {
|
||||||
let res = await fetch(`${endpoint}/auth/update-user/`, {
|
let res = await fetch(`${endpoint}/auth/update-user/`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
|
Referer: event.url.origin, // Include Referer header
|
||||||
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
||||||
'X-CSRFToken': csrfToken
|
'X-CSRFToken': csrfToken
|
||||||
},
|
},
|
||||||
|
@ -209,6 +211,7 @@ export const actions: Actions = {
|
||||||
let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, {
|
let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
Referer: event.url.origin, // Include Referer header
|
||||||
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
||||||
'X-CSRFToken': csrfToken,
|
'X-CSRFToken': csrfToken,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
|
@ -226,6 +229,7 @@ export const actions: Actions = {
|
||||||
let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, {
|
let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
Referer: event.url.origin, // Include Referer header
|
||||||
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
||||||
'X-CSRFToken': csrfToken,
|
'X-CSRFToken': csrfToken,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
|
@ -258,6 +262,7 @@ export const actions: Actions = {
|
||||||
let res = await fetch(`${endpoint}/auth/change-email/`, {
|
let res = await fetch(`${endpoint}/auth/change-email/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
Referer: event.url.origin, // Include Referer header
|
||||||
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRFToken': csrfToken
|
'X-CSRFToken': csrfToken
|
||||||
|
|
|
@ -62,7 +62,10 @@
|
||||||
});
|
});
|
||||||
let data = await res.json();
|
let data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
addToast('success', `${data.new_regions} ${$t('adventures.regions_updated')}`);
|
addToast(
|
||||||
|
'success',
|
||||||
|
`${data.new_regions} ${$t('adventures.regions_updated')}. ${data.new_cities} ${$t('adventures.cities_updated')}.`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
addToast('error', $t('adventures.error_updating_regions'));
|
addToast('error', $t('adventures.error_updating_regions'));
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,7 +56,8 @@ export const actions: Actions = {
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRFToken': csrfToken,
|
'X-CSRFToken': csrfToken,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Cookie: `csrftoken=${csrfToken}`
|
Cookie: `csrftoken=${csrfToken}`,
|
||||||
|
Referer: event.url.origin // Include Referer header
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: username,
|
username: username,
|
||||||
|
|
|
@ -21,7 +21,8 @@ export const actions: Actions = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRFToken': csrfToken,
|
'X-CSRFToken': csrfToken,
|
||||||
Cookie: `csrftoken=${csrfToken}`
|
Cookie: `csrftoken=${csrfToken}`,
|
||||||
|
Referer: event.url.origin // Include Referer header
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email
|
email
|
||||||
|
|
|
@ -35,7 +35,8 @@ export const actions: Actions = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Cookie: `csrftoken=${csrfToken}`,
|
Cookie: `csrftoken=${csrfToken}`,
|
||||||
'X-CSRFToken': csrfToken
|
'X-CSRFToken': csrfToken,
|
||||||
|
Referer: event.url.origin // Include Referer header
|
||||||
},
|
},
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue