1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-23 06:49:37 +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
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/')

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'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
router.register(r'overpass', OverpassViewSet, basename='overpass')
router.register(r'search', GlobalSearchView, basename='search')
urlpatterns = [

View file

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

View file

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

View file

@ -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,14 +43,8 @@ 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)
@ -62,29 +52,27 @@ class AdventureViewSet(viewsets.ModelViewSet):
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()
@ -97,112 +85,64 @@ class AdventureViewSet(viewsets.ModelViewSet):
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)
# Paginate and respond
adventures = self.paginate_and_respond(queryset, request)
return adventures
return self.paginate_and_respond(queryset, request)
@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':
include_collections = request.query_params.get('include_collections', 'false') == '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)
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))
filters[property] & (Q(user_id=request.user.id) | Q(is_public=True))
)
queryset = self.apply_sorting(queryset)
@ -210,99 +150,31 @@ class AdventureViewSet(viewsets.ModelViewSet):
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:
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.")
# 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
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.")
# 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
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)

View file

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

View file

@ -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()
@ -35,3 +36,9 @@ class GenerateDescription(viewsets.ViewSet):
if extract.get('original') is None:
return Response({"error": "No image found"}, status=400)
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 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')}
</button>
</div>
{#if wikiImageError}
<p class="text-red-500">{$t('adventures.wiki_image_error')}</p>
{/if}
</div>
{#if immichIntegration}