Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions mainapp/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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))
Expand All @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions mainapp/migrations/0008_trgm_ext.py
Original file line number Diff line number Diff line change
@@ -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(),
]
12 changes: 12 additions & 0 deletions mainapp/tests/api/test_search_full.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 11 additions & 3 deletions mainapp/tests/views/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
35 changes: 20 additions & 15 deletions mainapp/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading