diff --git a/backend/server/adventures/managers.py b/backend/server/adventures/managers.py new file mode 100644 index 0000000..6d8d43c --- /dev/null +++ b/backend/server/adventures/managers.py @@ -0,0 +1,22 @@ +from django.db import models +from django.db.models import Q + +class AdventureManager(models.Manager): + def retrieve_adventures(self, user, include_owned=False, include_shared=False, include_public=False): + # Initialize the query with an empty Q object + query = Q() + + # Add owned adventures to the query if included + if include_owned: + query |= Q(user_id=user.id) + + # Add shared adventures to the query if included + if include_shared: + query |= Q(collection__shared_with=user.id) + + # Add public adventures to the query if included + if include_public: + query |= Q(is_public=True) + + # Perform the query with the final Q object and remove duplicates + return self.filter(query).distinct() diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index c77bc4d..779d7f6 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -4,12 +4,13 @@ from typing import Iterable import uuid from django.db import models from django.utils.deconstruct import deconstructible - +from adventures.managers import AdventureManager from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField from django.forms import ValidationError from django_resized import ResizedImageField + ADVENTURE_TYPES = [ ('general', 'General 🌍'), ('outdoor', 'Outdoor 🏞️'), @@ -88,6 +89,8 @@ class Adventure(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + objects = AdventureManager() + # DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS # Migrations performed in this version will remove these fields # image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/') diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index c28478e..cc59670 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -16,6 +16,7 @@ router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geo router.register(r'categories', CategoryViewSet, basename='categories') router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar') router.register(r'overpass', OverpassViewSet, basename='overpass') +router.register(r'search', GlobalSearchView, basename='search') urlpatterns = [ diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 7b0d335..5920619 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -10,4 +10,5 @@ from .note_view import * from .overpass_view import * from .reverse_geocode_view import * from .stats_view import * -from .transportation_view import * \ No newline at end of file +from .transportation_view import * +from .global_search_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/adventure_image_view.py b/backend/server/adventures/views/adventure_image_view.py index b4a3ce4..d76f6a5 100644 --- a/backend/server/adventures/views/adventure_image_view.py +++ b/backend/server/adventures/views/adventure_image_view.py @@ -11,10 +11,6 @@ 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) diff --git a/backend/server/adventures/views/adventure_view.py b/backend/server/adventures/views/adventure_view.py index be86ff3..5249e00 100644 --- a/backend/server/adventures/views/adventure_view.py +++ b/backend/server/adventures/views/adventure_view.py @@ -1,15 +1,14 @@ 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 django.db.models import Q, Max +from django.db.models.functions import Lower +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from adventures.models import Adventure, Category from adventures.permissions import IsOwnerOrSharedWithFullAccess -from django.shortcuts import get_object_or_404 -from django.db.models import Max +from adventures.serializers import AdventureSerializer from adventures.utils import pagination class AdventureViewSet(viewsets.ModelViewSet): @@ -30,11 +29,8 @@ class AdventureViewSet(viewsets.ModelViewSet): 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) + queryset = queryset.annotate(latest_visit=Max('visits__start_date')).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' @@ -47,262 +43,138 @@ class AdventureViewSet(viewsets.ModelViewSet): 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 + ordering = '-updated_at' if order_direction == 'asc' else 'updated_at' if include_collections == 'false': - queryset = queryset.filter(collection = None) + 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: + """ + Returns the queryset for the AdventureViewSet. Unauthenticated users can only + retrieve public adventures, while authenticated users can access their own, + shared, and public adventures depending on the action. + """ + user = self.request.user + + if not user.is_authenticated: + # Unauthenticated users can only access public adventures for retrieval if self.action == 'retrieve': - return Adventure.objects.filter(is_public=True).distinct().order_by('-updated_at') + return Adventure.objects.retrieve_adventures(user, include_public=True).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) + # Authenticated users: Handle retrieval separately + include_public = self.action == 'retrieve' + return Adventure.objects.retrieve_adventures( + user, + include_public=include_public, + include_owned=True, + include_shared=True + ).order_by('-updated_at') 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) + if not types or not all( + Category.objects.filter(user_id=request.user, name=type).exists() for type in types + ): + return Response({"error": "Invalid category or no types 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 + if is_visited.lower() in ['true', 'false']: + is_visited_bool = is_visited.lower() == 'true' + queryset = queryset.filter(is_visited=is_visited_bool) - # Apply sorting queryset = self.apply_sorting(queryset) + return self.paginate_and_respond(queryset, request) - # 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 - ) + include_collections = request.query_params.get('include_collections', 'false') == 'true' queryset = Adventure.objects.filter( - Q(user_id=request.user.id) + Q(is_public=True) | Q(user_id=request.user.id), + collection=None if not include_collections else Q() ) + 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') + query = request.query_params.get('query', '') + property = 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']: + + valid_properties = ['name', 'location', 'description', 'activity_types'] + if property not in valid_properties: property = 'all' - queryset = Adventure.objects.none() + filters = { + 'name': Q(name__icontains=query), + 'location': Q(location__icontains=query), + 'description': Q(description__icontains=query), + 'activity_types': Q(activity_types__icontains=query), + 'all': Q(name__icontains=query) | Q(description__icontains=query) | + Q(location__icontains=query) | Q(activity_types__icontains=query) + } - 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 = Adventure.objects.filter( + filters[property] & (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: + if new_collection and new_collection!=instance.collection: + if new_collection.user_id != request.user or instance.user_id != request.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 + elif new_collection is None and instance.collection and instance.collection.user_id != request.user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + 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 + + if collection and not (collection.user_id == self.request.user or collection.shared_with.filter(id=self.request.user.id).exists()): + raise PermissionDenied("You do not have permission to use this collection.") + elif 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) + serializer.save(user_id=self.request.user, is_public=collection.is_public if collection else False) def paginate_and_respond(self, queryset, request): paginator = self.pagination_class() @@ -310,5 +182,6 @@ class AdventureViewSet(viewsets.ModelViewSet): 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) \ No newline at end of file + return Response(serializer.data) diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index 7e35d64..f0529ee 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -216,4 +216,3 @@ class CollectionViewSet(viewsets.ModelViewSet): return paginator.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) - diff --git a/backend/server/adventures/views/generate_description_view.py b/backend/server/adventures/views/generate_description_view.py index 154bdc1..988773a 100644 --- a/backend/server/adventures/views/generate_description_view.py +++ b/backend/server/adventures/views/generate_description_view.py @@ -12,7 +12,7 @@ class GenerateDescription(viewsets.ViewSet): name = self.request.query_params.get('name', '') # un url encode the name name = name.replace('%20', ' ') - print(name) + name = self.get_search_term(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() @@ -27,6 +27,7 @@ class GenerateDescription(viewsets.ViewSet): name = self.request.query_params.get('name', '') # un url encode the name name = name.replace('%20', ' ') + name = self.get_search_term(name) 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() @@ -34,4 +35,10 @@ class GenerateDescription(viewsets.ViewSet): extract = data["query"]["pages"][page_id] if extract.get('original') is None: return Response({"error": "No image found"}, status=400) - return Response(extract["original"]) \ No newline at end of file + return Response(extract["original"]) + + def get_search_term(self, term): + response = requests.get(f'https://en.wikipedia.org/w/api.php?action=opensearch&search={term}&limit=10&namespace=0&format=json') + data = response.json() + if data[1] and len(data[1]) > 0: + return data[1][0] \ No newline at end of file diff --git a/backend/server/adventures/views/global_search_view.py b/backend/server/adventures/views/global_search_view.py new file mode 100644 index 0000000..3780629 --- /dev/null +++ b/backend/server/adventures/views/global_search_view.py @@ -0,0 +1,71 @@ + +from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from adventures.models import Adventure, Collection +from adventures.serializers import AdventureSerializer, CollectionSerializer +from django.db.models import Q +from adventures.utils import pagination +from worldtravel.models import Country, Region, City +from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer +from users.models import CustomUser as User +from users.serializers import CustomUserDetailsSerializer as UserSerializer + +class GlobalSearchView(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + pagination_class = pagination.StandardResultsSetPagination + + def list(self, request): + search_term = request.query_params.get('query', '') + # print(f"Searching for: {search_term}") # For debugging + + if not search_term: + return Response({"error": "Search query is required"}, status=400) + + # Search for adventures + adventures = Adventure.objects.filter( + (Q(name__icontains=search_term) | Q(description__icontains=search_term) | Q(location__icontains=search_term)) & Q(user_id=request.user.id) + ) + + # Search for collections + collections = Collection.objects.filter( + Q(name__icontains=search_term) & Q(user_id=request.user.id) + ) + + # Search for users + users = User.objects.filter( + (Q(username__icontains=search_term) | Q(first_name__icontains=search_term) | Q(last_name__icontains=search_term)) & Q(public_profile=True) + ) + + # Search for countries + countries = Country.objects.filter( + Q(name__icontains=search_term) | Q(country_code__icontains=search_term) + ) + + # Search for regions + regions = Region.objects.filter( + Q(name__icontains=search_term) | Q(country__name__icontains=search_term) + ) + + # Search for cities + cities = City.objects.filter( + Q(name__icontains=search_term) | Q(region__name__icontains=search_term) | Q(region__country__name__icontains=search_term) + ) + + # Serialize the results + adventure_serializer = AdventureSerializer(adventures, many=True) + collection_serializer = CollectionSerializer(collections, many=True) + user_serializer = UserSerializer(users, many=True) + country_serializer = CountrySerializer(countries, many=True) + region_serializer = RegionSerializer(regions, many=True) + city_serializer = CitySerializer(cities, many=True) + + return Response({ + "adventures": adventure_serializer.data, + "collections": collection_serializer.data, + "users": user_serializer.data, + "countries": country_serializer.data, + "regions": region_serializer.data, + "cities": city_serializer.data + }) + \ No newline at end of file diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index 1e9b1b4..5b0ce94 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -289,6 +289,7 @@ let res = await fetch(imageUrl); let blob = await res.blob(); let file = new File([blob], `${imageSearch}.jpg`, { type: 'image/jpeg' }); + wikiImageError = ''; let formData = new FormData(); formData.append('image', file); formData.append('adventure', adventure.id); @@ -1097,6 +1098,9 @@ it would also work to just use on:click on the MapLibre component itself. --> {$t('adventures.fetch_image')} + {#if wikiImageError} +
{$t('adventures.wiki_image_error')}
+ {/if} {#if immichIntegration}