1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-23 14:59:36 +02:00

feat: implement global search functionality for adventures, collections, users, and locations

This commit is contained in:
Sean Morley 2025-01-18 12:28:14 -05:00
parent 9132ef39ec
commit d60945d5b7
10 changed files with 186 additions and 209 deletions

View file

@ -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()

View file

@ -4,12 +4,13 @@ from typing import Iterable
import uuid import uuid
from django.db import models from django.db import models
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from adventures.managers import AdventureManager
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.forms import ValidationError from django.forms import ValidationError
from django_resized import ResizedImageField from django_resized import ResizedImageField
ADVENTURE_TYPES = [ ADVENTURE_TYPES = [
('general', 'General 🌍'), ('general', 'General 🌍'),
('outdoor', 'Outdoor 🏞️'), ('outdoor', 'Outdoor 🏞️'),
@ -88,6 +89,8 @@ class Adventure(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
objects = AdventureManager()
# DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS # DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS
# Migrations performed in this version will remove these fields # Migrations performed in this version will remove these fields
# image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/') # image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/')

View file

@ -16,6 +16,7 @@ router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geo
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') router.register(r'overpass', OverpassViewSet, basename='overpass')
router.register(r'search', GlobalSearchView, basename='search')
urlpatterns = [ urlpatterns = [

View file

@ -11,3 +11,4 @@ from .overpass_view import *
from .reverse_geocode_view import * from .reverse_geocode_view import *
from .stats_view import * from .stats_view import *
from .transportation_view import * from .transportation_view import *
from .global_search_view import *

View file

@ -11,10 +11,6 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
serializer_class = AdventureImageSerializer serializer_class = AdventureImageSerializer
permission_classes = [IsAuthenticated] 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']) @action(detail=True, methods=['post'])
def image_delete(self, request, *args, **kwargs): def image_delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs) return self.destroy(request, *args, **kwargs)

View file

@ -1,15 +1,14 @@
from django.db import transaction 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 django.core.exceptions import PermissionDenied
from adventures.serializers import AdventureSerializer from django.db.models import Q, Max
from django.db.models import Q 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 adventures.permissions import IsOwnerOrSharedWithFullAccess
from django.shortcuts import get_object_or_404 from adventures.serializers import AdventureSerializer
from django.db.models import Max
from adventures.utils import pagination from adventures.utils import pagination
class AdventureViewSet(viewsets.ModelViewSet): class AdventureViewSet(viewsets.ModelViewSet):
@ -30,11 +29,8 @@ class AdventureViewSet(viewsets.ModelViewSet):
order_direction = 'asc' order_direction = 'asc'
if order_by == 'date': if order_by == 'date':
# order by the earliest visit object associated with the adventure queryset = queryset.annotate(latest_visit=Max('visits__start_date')).filter(latest_visit__isnull=False)
queryset = queryset.annotate(latest_visit=Max('visits__start_date'))
queryset = queryset.filter(latest_visit__isnull=False)
ordering = 'latest_visit' ordering = 'latest_visit'
# Apply case-insensitive sorting for the 'name' field
elif order_by == 'name': elif order_by == 'name':
queryset = queryset.annotate(lower_name=Lower('name')) queryset = queryset.annotate(lower_name=Lower('name'))
ordering = 'lower_name' ordering = 'lower_name'
@ -47,44 +43,36 @@ class AdventureViewSet(viewsets.ModelViewSet):
if order_direction == 'desc': if order_direction == 'desc':
ordering = f'-{ordering}' ordering = f'-{ordering}'
# reverse ordering for updated_at field
if order_by == 'updated_at': if order_by == 'updated_at':
if order_direction == 'asc': ordering = '-updated_at' if order_direction == 'asc' else 'updated_at'
ordering = '-updated_at'
else:
ordering = 'updated_at'
print(f"Ordering by: {ordering}") # For debugging
if include_collections == 'false': if include_collections == 'false':
queryset = queryset.filter(collection = None) queryset = queryset.filter(collection=None)
return queryset.order_by(ordering) return queryset.order_by(ordering)
def get_queryset(self): def get_queryset(self):
print(self.request.user) """
# if the user is not authenticated return only public adventures for retrieve action Returns the queryset for the AdventureViewSet. Unauthenticated users can only
if not self.request.user.is_authenticated: 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': 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() return Adventure.objects.none()
if self.action == 'retrieve': # Authenticated users: Handle retrieval separately
# For individual adventure retrieval, include public adventures include_public = self.action == 'retrieve'
return Adventure.objects.filter( return Adventure.objects.retrieve_adventures(
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) user,
).distinct().order_by('-updated_at') include_public=include_public,
else: include_owned=True,
# For other actions, include user's own adventures and shared adventures include_shared=True
return Adventure.objects.filter( ).order_by('-updated_at')
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): def perform_update(self, serializer):
adventure = serializer.save() adventure = serializer.save()
@ -97,112 +85,64 @@ class AdventureViewSet(viewsets.ModelViewSet):
types = request.query_params.get('types', '').split(',') types = request.query_params.get('types', '').split(',')
is_visited = request.query_params.get('is_visited', 'all') is_visited = request.query_params.get('is_visited', 'all')
# Handle case where types is all
if 'all' in types: if 'all' in types:
types = Category.objects.filter(user_id=request.user).values_list('name', flat=True) types = Category.objects.filter(user_id=request.user).values_list('name', flat=True)
else: else:
for type in types: if not types or not all(
if not Category.objects.filter(user_id=request.user, name=type).exists(): Category.objects.filter(user_id=request.user, name=type).exists() for type in types
return Response({"error": f"Category {type} does not exist"}, status=400) ):
return Response({"error": "Invalid category or no types provided"}, status=400)
if not types:
return Response({"error": "At least one type must be provided"}, status=400)
queryset = Adventure.objects.filter( queryset = Adventure.objects.filter(
category__in=Category.objects.filter(name__in=types, user_id=request.user), category__in=Category.objects.filter(name__in=types, user_id=request.user),
user_id=request.user.id user_id=request.user.id
) )
# Handle is_visited filtering if is_visited.lower() in ['true', 'false']:
if is_visited.lower() == 'true': is_visited_bool = is_visited.lower() == 'true'
serializer = self.get_serializer(queryset, many=True) queryset = queryset.filter(is_visited=is_visited_bool)
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) 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']) @action(detail=False, methods=['get'])
def all(self, request): def all(self, request):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400) 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': include_collections = request.query_params.get('include_collections', 'false') == 'true'
queryset = Adventure.objects.filter( queryset = Adventure.objects.filter(
Q(is_public=True) | Q(user_id=request.user.id) Q(is_public=True) | Q(user_id=request.user.id),
) collection=None if not include_collections else Q()
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) queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) return Response(serializer.data)
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])
def search(self, request): def search(self, request):
query = self.request.query_params.get('query', '') query = request.query_params.get('query', '')
property = self.request.query_params.get('property', 'all') property = request.query_params.get('property', 'all')
if len(query) < 2: if len(query) < 2:
return Response({"error": "Query must be at least 2 characters long"}, status=400) 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' 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( queryset = Adventure.objects.filter(
(Q(name__icontains=query)) & filters[property] & (Q(user_id=request.user.id) | Q(is_public=True))
(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) queryset = self.apply_sorting(queryset)
@ -210,99 +150,31 @@ class AdventureViewSet(viewsets.ModelViewSet):
return Response(serializer.data) return Response(serializer.data)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
# Retrieve the current object
instance = self.get_object() instance = self.get_object()
# Partially update the instance with the request data
serializer = self.get_serializer(instance, data=request.data, partial=True) serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=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') new_collection = serializer.validated_data.get('collection')
if new_collection and new_collection!=instance.collection:
user = request.user if new_collection.user_id != request.user or instance.user_id != 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.") raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None: elif new_collection is None and instance.collection and instance.collection.user_id != request.user:
# 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.") raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
self.perform_update(serializer) self.perform_update(serializer)
# Return the updated instance
return Response(serializer.data) 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 @transaction.atomic
def perform_create(self, serializer): def perform_create(self, serializer):
# Retrieve the collection from the validated data
collection = serializer.validated_data.get('collection') collection = serializer.validated_data.get('collection')
# Check if a collection is provided if collection and not (collection.user_id == self.request.user or collection.shared_with.filter(id=self.request.user.id).exists()):
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.") raise PermissionDenied("You do not have permission to use this collection.")
# if collection the owner of the adventure is the owner of the collection elif 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) serializer.save(user_id=collection.user_id, is_public=collection.is_public)
return return
# Save the adventure with the current user as the owner serializer.save(user_id=self.request.user, is_public=collection.is_public if collection else False)
serializer.save(user_id=self.request.user)
def paginate_and_respond(self, queryset, request): def paginate_and_respond(self, queryset, request):
paginator = self.pagination_class() paginator = self.pagination_class()
@ -310,5 +182,6 @@ class AdventureViewSet(viewsets.ModelViewSet):
if page is not None: if page is not None:
serializer = self.get_serializer(page, many=True) serializer = self.get_serializer(page, many=True)
return paginator.get_paginated_response(serializer.data) return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) return Response(serializer.data)

View file

@ -216,4 +216,3 @@ class CollectionViewSet(viewsets.ModelViewSet):
return paginator.get_paginated_response(serializer.data) return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) return Response(serializer.data)

View file

@ -12,7 +12,7 @@ class GenerateDescription(viewsets.ViewSet):
name = self.request.query_params.get('name', '') name = self.request.query_params.get('name', '')
# un url encode the name # un url encode the name
name = name.replace('%20', ' ') 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 url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=extracts&exintro&explaintext&format=json&titles=%s' % name
response = requests.get(url) response = requests.get(url)
data = response.json() data = response.json()
@ -27,6 +27,7 @@ class GenerateDescription(viewsets.ViewSet):
name = self.request.query_params.get('name', '') name = self.request.query_params.get('name', '')
# un url encode the name # un url encode the name
name = name.replace('%20', ' ') 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 url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=pageimages&format=json&piprop=original&titles=%s' % name
response = requests.get(url) response = requests.get(url)
data = response.json() data = response.json()
@ -35,3 +36,9 @@ class GenerateDescription(viewsets.ViewSet):
if extract.get('original') is None: if extract.get('original') is None:
return Response({"error": "No image found"}, status=400) return Response({"error": "No image found"}, status=400)
return Response(extract["original"]) 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]

View file

@ -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
})

View file

@ -289,6 +289,7 @@
let res = await fetch(imageUrl); let res = await fetch(imageUrl);
let blob = await res.blob(); let blob = await res.blob();
let file = new File([blob], `${imageSearch}.jpg`, { type: 'image/jpeg' }); let file = new File([blob], `${imageSearch}.jpg`, { type: 'image/jpeg' });
wikiImageError = '';
let formData = new FormData(); let formData = new FormData();
formData.append('image', file); formData.append('image', file);
formData.append('adventure', adventure.id); 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')} {$t('adventures.fetch_image')}
</button> </button>
</div> </div>
{#if wikiImageError}
<p class="text-red-500">{$t('adventures.wiki_image_error')}</p>
{/if}
</div> </div>
{#if immichIntegration} {#if immichIntegration}