diff --git a/mainapp/api.py b/mainapp/api.py index 70461c2..40401c3 100644 --- a/mainapp/api.py +++ b/mainapp/api.py @@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404 from django.http import JsonResponse, HttpResponse, Http404, HttpResponseBadRequest, HttpResponseServerError from django.core.paginator import Paginator, EmptyPage +from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank, TrigramSimilarity +from django.db.models import Q from .models import Model from .utils import get_kv, admin from django.views.decorators.csrf import csrf_exempt @@ -15,6 +17,8 @@ # returns a paginated json response def api_paginate(models, page_id): + if hasattr(models, 'ordered') and not models.ordered: + models = models.order_by('model_id') paginator = Paginator(models, RESULTS_PER_API_CALL) try: @@ -190,11 +194,18 @@ def search_range(request, latitude, longitude, distance, page_id=1): @any_origin def search_title(request, title, page_id=1): - models = Model.objects.filter(latest=True, title__icontains=title) + vector = SearchVector('title', weight='A') + search_query = SearchQuery(title) + models = Model.objects.filter(latest=True).annotate( + rank=SearchRank(vector, search_query), + similarity=TrigramSimilarity('title', title) + ).filter(Q(rank__gte=0.01) | Q(similarity__gt=0.1)) if not admin(request): models = models.filter(is_hidden=False) + models = models.order_by('-rank', 'model_id') + return api_paginate(models, page_id) @csrf_exempt # there's no need for this, since no data is modified @@ -229,9 +240,23 @@ def search_full(request): except (ValueError, TypeError): return HttpResponseBadRequest('Invalid range parameters') + query = data.get('query') + if query: + vector = SearchVector('title', weight='A') + SearchVector('description', weight='B') + search_query = SearchQuery(query) + models = models.annotate( + rank_query=SearchRank(vector, search_query), + similarity_query=TrigramSimilarity('title', query) + TrigramSimilarity('description', query) + ).filter(Q(rank_query__gte=0.01) | Q(similarity_query__gt=0.1)) + title = data.get('title') if title: - models = models.filter(title__icontains=title) + vector = SearchVector('title', weight='A') + search_query = SearchQuery(title) + models = models.annotate( + rank_title=SearchRank(vector, search_query), + similarity_title=TrigramSimilarity('title', title) + ).filter(Q(rank_title__gte=0.01) | Q(similarity_title__gt=0.1)) tags = data.get('tags') if tags: @@ -243,7 +268,14 @@ def search_full(request): for category in categories: models = models.filter(categories__name=category) - models = models.order_by('model_id') + if query and title: + models = models.order_by('-rank_query', '-similarity_query', '-rank_title', '-similarity_title', 'model_id') + elif query: + models = models.order_by('-rank_query', '-similarity_query', 'model_id') + elif title: + models = models.order_by('-rank_title', '-similarity_title', 'model_id') + else: + models = models.order_by('model_id') try: page_id = int(data.get('page', 1)) @@ -255,6 +287,8 @@ def search_full(request): if not fmt: return api_paginate(models, page_id) + if hasattr(models, 'ordered') and not models.ordered: + models = models.order_by('model_id') paginator = Paginator(models, RESULTS_PER_API_CALL) try: diff --git a/mainapp/migrations/0008_trgm_ext.py b/mainapp/migrations/0008_trgm_ext.py new file mode 100644 index 0000000..a6af69d --- /dev/null +++ b/mainapp/migrations/0008_trgm_ext.py @@ -0,0 +1,14 @@ +# Generated by Django 6.0 on 2026-03-27 18:00 + +from django.db import migrations +from django.contrib.postgres.operations import TrigramExtension + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0007_update_osm_oauth2_provider'), + ] + + operations = [ + TrigramExtension(), + ] diff --git a/mainapp/tests/api/test_search_full.py b/mainapp/tests/api/test_search_full.py index 403c763..29de592 100644 --- a/mainapp/tests/api/test_search_full.py +++ b/mainapp/tests/api/test_search_full.py @@ -49,6 +49,18 @@ def test_search_full_title_filter(self): self.assertEqual(len(results), 3) self.assertSetEqual({result[1] for result in results}, {"Model 1", "Model 3", "Model 4 No Loc"}) + def test_search_full_query_filter(self): + payload = {"query": "Model", "format": ["id", "title"]} + response = self.client.post( + reverse("search_full"), + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + results = json.loads(response.content) + self.assertEqual(len(results), 3) + self.assertSetEqual({result[1] for result in results}, {"Model 1", "Model 3", "Model 4 No Loc"}) + def test_search_full_tags_filter(self): payload = {"tags": {"color": "red"}, "format": ["id", "title"]} response = self.client.post( diff --git a/mainapp/tests/views/test_search.py b/mainapp/tests/views/test_search.py index ec6435c..1e0b20c 100644 --- a/mainapp/tests/views/test_search.py +++ b/mainapp/tests/views/test_search.py @@ -16,9 +16,17 @@ def test_search_by_title(self): response = self.client.get(reverse("search"), {"query": "Model 1"}) self.assertEqual(response.status_code, 200) self.assertContains(response, "Model 1") - self.assertNotContains(response, "Model 2") - self.assertNotContains(response, "Model 3") - self.assertEqual(len(response.context["models"]), 1) + # Typo tolerance will match "Model 2" and "Model 3" as well, but "Model 1" should be ranked first. + self.assertEqual(response.context["models"][0].model_id, self.model1.model_id) + self.assertGreaterEqual(len(response.context["models"]), 1) + + def test_search_typo_tolerance(self): + # Searching for "Modl 1" (typ0) should still find "Model 1" + response = self.client.get(reverse("search"), {"query": "Modl 1"}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Model 1") + # Should rank the most similar one first + self.assertEqual(response.context["models"][0].model_id, self.model1.model_id) def test_search_by_tag_filter(self): response = self.client.get(reverse("search"), {"tag": "color=red"}) diff --git a/mainapp/views.py b/mainapp/views.py index 8ec6d98..26b9e9d 100644 --- a/mainapp/views.py +++ b/mainapp/views.py @@ -9,6 +9,8 @@ from django.contrib.auth import logout from django.contrib import messages from django.db import transaction +from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank, TrigramSimilarity +from django.db.models import Q from .models import Model, Category, Change, Ban, Location from .forms import UploadFileForm, UploadFileMetadataForm, MetadataForm, UserDescriptionForm from .utils import get_kv, update_last_page, get_last_page, CHANGES, admin, LICENSES_DISPLAY @@ -114,32 +116,35 @@ def search(request): url_params += 'query=' + query if tag: url_params += 'tag=' + tag + if not query and not tag and not category: + return redirect(index) + if category: url_params += 'category=' + category - models = Model.objects.filter(latest=True) + filtered_models = Model.objects.filter(latest=True) if tag: try: key, value = get_kv(tag) except ValueError: return redirect(index) - filtered_models = models.filter(tags__contains={key: value}) - elif category: - filtered_models = models.filter(categories__name=category) - elif query: - filtered_models = \ - models.filter(title__icontains=query) | \ - models.filter(description__icontains=query) - - try: - if not admin(request): - filtered_models = filtered_models.filter(is_hidden=False) + filtered_models = filtered_models.filter(tags__contains={key: value}) + if category: + filtered_models = filtered_models.filter(categories__name=category) + + if query: + vector = SearchVector('title', weight='A') + SearchVector('description', weight='B') + search_query = SearchQuery(query) + filtered_models = filtered_models.annotate( + rank=SearchRank(vector, search_query), + similarity=TrigramSimilarity('title', query) + TrigramSimilarity('description', query) + ).filter(Q(rank__gte=0.01) | Q(similarity__gt=0.4)) + if query: + ordered_models = filtered_models.order_by('-rank', '-similarity', '-pk') + else: ordered_models = filtered_models.order_by('-pk') - except UnboundLocalError: - # filtered_models isn't set, redirect to homepage - return redirect(index) if not ordered_models: results = None