Skip to content

Commit c6aecf1

Browse files
authored
197 | Enhance events/all API Search Filter by enabling Fuzzy Matching with Priority Based Inclusion (#61)
* Update Event search filter to enable fuzzy search with priority-based inclusion * Update unittests for event filters
1 parent 39266d2 commit c6aecf1

File tree

8 files changed

+267
-26
lines changed

8 files changed

+267
-26
lines changed

arbisoft_sessions_portal/settings/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
'django.contrib.contenttypes',
5959
'django.contrib.sessions',
6060
'django.contrib.messages',
61+
'django.contrib.postgres',
6162
'django.contrib.staticfiles',
6263
'rest_framework',
6364
'django_filters',

events/factories.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.contrib.auth import get_user_model
55
from django.utils import timezone
66

7-
from events.models import Event, Playlist, Tag, VideoAsset
7+
from events.models import Event, EventPresenter, Playlist, Tag, VideoAsset
88

99
fake = Faker()
1010
User = get_user_model()
@@ -61,3 +61,13 @@ class Meta:
6161
status = VideoAsset.VideoStatus.READY
6262
duration = factory.Faker("random_int", min=60, max=3600) # Duration in seconds
6363
file_size = factory.Faker("random_int", min=1024, max=10485760) # File size in bytes
64+
65+
66+
class EventPresenterFactory(factory.django.DjangoModelFactory):
67+
"""Factory for EventPresenter model"""
68+
69+
class Meta:
70+
model = EventPresenter
71+
72+
event = factory.SubFactory(EventFactory)
73+
user = factory.SubFactory(UserFactory)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Generated by Django 4.2.21 on 2025-06-14 03:23
2+
3+
from django.contrib.postgres.operations import CreateExtension
4+
from django.db import migrations
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('events', '0010_populate_event_slugs'),
11+
]
12+
13+
operations = [
14+
CreateExtension('pg_trgm'),
15+
]

events/signals.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77

88
@receiver(post_save, sender=Event)
9-
def set_slug_on_create(_sender, instance, created, **kwargs):
9+
def set_slug_on_create(sender, instance, created, **kwargs): # pylint: disable=unused-argument
1010
"""
1111
Set a slug for the event instance if it is created and does not have a slug.
1212
The slug is generated from the title and includes the event ID to ensure uniqueness.

events/tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22

3+
from django.db import connection
34
from django.db.models.signals import post_save
45

56
from events.models import Event
@@ -12,3 +13,11 @@ def disable_event_signals():
1213
post_save.disconnect(set_slug_on_create, sender=Event)
1314
yield
1415
post_save.connect(set_slug_on_create, sender=Event)
16+
17+
18+
@pytest.fixture(scope='session', autouse=True)
19+
def setup_test_database(django_db_setup, django_db_blocker): # pylint: disable=unused-argument
20+
"""Ensure test database has trigram extension"""
21+
with django_db_blocker.unblock():
22+
with connection.cursor() as cursor:
23+
cursor.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")

events/tests/test_filters.py

Lines changed: 171 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import datetime, timezone
2+
13
import pytest
24
from rest_framework import status
35
from rest_framework.test import APIClient
@@ -6,7 +8,14 @@
68
from django.urls import reverse
79

810
# pylint: disable=duplicate-code
9-
from events.factories import EventFactory, PlaylistFactory, TagFactory, UserFactory, VideoAssetFactory
11+
from events.factories import (
12+
EventFactory,
13+
EventPresenterFactory,
14+
PlaylistFactory,
15+
TagFactory,
16+
UserFactory,
17+
VideoAssetFactory,
18+
)
1019

1120
User = get_user_model()
1221

@@ -49,20 +58,6 @@ def test_events_search_filter_by_description(self, api_client):
4958
assert len(response.data["results"]) == 1
5059
assert response.data["results"][0]["title"] == "Tech Event 1"
5160

52-
def test_events_search_filter_by_creator_name(self, api_client):
53-
""" Test filtering events by creator's first or last name """
54-
creator1 = UserFactory(first_name="John", last_name="Doe")
55-
creator2 = UserFactory(first_name="Jane", last_name="Smith")
56-
57-
self._create_event_with_video(title="Event 1", creator=creator1)
58-
self._create_event_with_video(title="Event 2", creator=creator2)
59-
60-
response = api_client.get(reverse("events-list"), {'search': 'John'})
61-
62-
assert response.status_code == status.HTTP_200_OK
63-
assert len(response.data["results"]) == 1
64-
assert response.data["results"][0]["title"] == "Event 1"
65-
6661
def test_events_tag_filter(self, api_client):
6762
""" Test filtering events by tag """
6863
python_tag = TagFactory(name="Python")
@@ -136,3 +131,164 @@ def test_events_ordering_by_featured(self, api_client):
136131
assert response.status_code == status.HTTP_200_OK
137132
assert len(response.data["results"]) == 3
138133
assert response.data["results"][0]["title"] != "Non-Featured Event"
134+
135+
def test_events_search_filter_fuzzy_matching_title(self, api_client):
136+
""" Test fuzzy matching in title search """
137+
self._create_event_with_video(title="All-Hands Meeting 2024")
138+
self._create_event_with_video(title="Machine Learning Summit")
139+
140+
response = api_client.get(reverse("events-list"), {'search': 'All Hands'})
141+
142+
assert response.status_code == status.HTTP_200_OK
143+
assert len(response.data["results"]) == 1
144+
assert response.data["results"][0]["title"] == "All-Hands Meeting 2024"
145+
146+
def test_events_search_filter_fuzzy_matching_description(self, api_client):
147+
""" Test fuzzy matching in description search """
148+
self._create_event_with_video(title="Tech Event", description="Learn about Python programming")
149+
self._create_event_with_video(title="Other Event", description="Data science basics")
150+
151+
response = api_client.get(reverse("events-list"), {'search': 'Python'})
152+
153+
assert response.status_code == status.HTTP_200_OK
154+
assert len(response.data["results"]) == 1
155+
assert response.data["results"][0]["title"] == "Tech Event"
156+
157+
def test_events_search_filter_by_playlist_name(self, api_client):
158+
""" Test filtering events by search term matching playlist name """
159+
demo_playlist = PlaylistFactory(name="Demo Hour")
160+
tech_playlist = PlaylistFactory(name="Tech Talks")
161+
162+
event1 = self._create_event_with_video(title="Weekly Demo Session", description="Demo of new features")
163+
event1.playlists.add(demo_playlist)
164+
165+
event2 = self._create_event_with_video(title="Architecture Discussion", description="Tech stack overview")
166+
event2.playlists.add(tech_playlist)
167+
168+
response = api_client.get(reverse("events-list"), {'search': 'Demo'})
169+
170+
assert response.status_code == status.HTTP_200_OK
171+
assert len(response.data["results"]) == 1
172+
assert response.data["results"][0]["title"] == "Weekly Demo Session"
173+
174+
def test_events_search_filter_by_presenter_name(self, api_client):
175+
""" Test filtering events by presenter name """
176+
presenter_user = UserFactory(first_name="Alice", last_name="Johnson")
177+
other_user = UserFactory(first_name="Bob", last_name="Wilson")
178+
179+
event1 = self._create_event_with_video(title="Python Workshop")
180+
event2 = self._create_event_with_video(title="Data Science Talk")
181+
182+
EventPresenterFactory(event=event1, user=presenter_user)
183+
EventPresenterFactory(event=event2, user=other_user)
184+
185+
response = api_client.get(reverse("events-list"), {'search': 'Alice'})
186+
187+
assert response.status_code == status.HTTP_200_OK
188+
assert len(response.data["results"]) == 1
189+
assert response.data["results"][0]["title"] == "Python Workshop"
190+
191+
response = api_client.get(reverse("events-list"), {'search': 'Johnson'})
192+
193+
assert response.status_code == status.HTTP_200_OK
194+
assert len(response.data["results"]) == 1
195+
assert response.data["results"][0]["title"] == "Python Workshop"
196+
197+
def test_events_search_multiple_presenters(self, api_client):
198+
""" Test event with multiple presenters """
199+
presenter1 = UserFactory(first_name="Alice", last_name="Johnson")
200+
presenter2 = UserFactory(first_name="Bob", last_name="Smith")
201+
other_presenter = UserFactory(first_name="Carol", last_name="Davis")
202+
203+
event1 = self._create_event_with_video(title="Team Presentation")
204+
event2 = self._create_event_with_video(title="Solo Talk")
205+
206+
EventPresenterFactory(event=event1, user=presenter1)
207+
EventPresenterFactory(event=event1, user=presenter2)
208+
209+
EventPresenterFactory(event=event2, user=other_presenter)
210+
211+
response = api_client.get(reverse("events-list"), {'search': 'Alice'})
212+
assert response.status_code == status.HTTP_200_OK
213+
assert len(response.data["results"]) == 1
214+
assert response.data["results"][0]["title"] == "Team Presentation"
215+
216+
response = api_client.get(reverse("events-list"), {'search': 'Bob'})
217+
assert response.status_code == status.HTTP_200_OK
218+
assert len(response.data["results"]) == 1
219+
assert response.data["results"][0]["title"] == "Team Presentation"
220+
221+
response = api_client.get(reverse("events-list"), {'search': 'Carol'})
222+
assert response.status_code == status.HTTP_200_OK
223+
assert len(response.data["results"]) == 1
224+
assert response.data["results"][0]["title"] == "Solo Talk"
225+
226+
def test_events_search_filter_by_tag_name(self, api_client):
227+
""" Test filtering events by search term matching tag name """
228+
python_tag = TagFactory(name="Python")
229+
javascript_tag = TagFactory(name="JavaScript")
230+
231+
event1 = self._create_event_with_video(title="Backend Development")
232+
event1.tags.add(python_tag)
233+
234+
event2 = self._create_event_with_video(title="Frontend Development")
235+
event2.tags.add(javascript_tag)
236+
237+
response = api_client.get(reverse("events-list"), {'search': 'Python'})
238+
239+
assert response.status_code == status.HTTP_200_OK
240+
assert len(response.data["results"]) == 1
241+
assert response.data["results"][0]["title"] == "Backend Development"
242+
243+
def test_events_search_no_results(self, api_client):
244+
""" Test search with no matching results """
245+
self._create_event_with_video(title="Python Conference")
246+
self._create_event_with_video(title="Machine Learning Summit")
247+
248+
response = api_client.get(reverse("events-list"), {'search': 'NonExistentTerm'})
249+
250+
assert response.status_code == status.HTTP_200_OK
251+
assert len(response.data["results"]) == 0
252+
253+
def test_events_search_ordering_by_event_time(self, api_client):
254+
""" Test that search results are ordered by event_time (newest first) """
255+
# pylint: disable=unused-variable
256+
old_event = self._create_event_with_video(
257+
title="Python Old Event",
258+
event_time=datetime(2024, 1, 1, tzinfo=timezone.utc)
259+
)
260+
new_event = self._create_event_with_video(
261+
title="Python New Event",
262+
event_time=datetime(2024, 12, 1, tzinfo=timezone.utc)
263+
)
264+
middle_event = self._create_event_with_video(
265+
title="Python Middle Event",
266+
event_time=datetime(2024, 6, 1, tzinfo=timezone.utc)
267+
)
268+
269+
response = api_client.get(reverse("events-list"), {'search': 'Python'})
270+
271+
assert response.status_code == status.HTTP_200_OK
272+
assert len(response.data["results"]) == 3
273+
274+
assert response.data["results"][0]["title"] == "Python New Event"
275+
assert response.data["results"][1]["title"] == "Python Middle Event"
276+
assert response.data["results"][2]["title"] == "Python Old Event"
277+
278+
def test_events_search_multiple_criteria_match(self, api_client):
279+
""" Test event matching multiple search criteria """
280+
python_tag = TagFactory(name="Python")
281+
demo_playlist = PlaylistFactory(name="Demo Hour")
282+
283+
event = self._create_event_with_video(
284+
title="Python Programming Demo",
285+
description="Advanced Python techniques"
286+
)
287+
event.tags.add(python_tag)
288+
event.playlists.add(demo_playlist)
289+
290+
response = api_client.get(reverse("events-list"), {'search': 'Python'})
291+
292+
assert response.status_code == status.HTTP_200_OK
293+
assert len(response.data["results"]) == 1
294+
assert response.data["results"][0]["title"] == "Python Programming Demo"

events/v1/filters.py

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import django_filters
22

3-
from django.db.models import Q
3+
from django.contrib.postgres.search import TrigramSimilarity
4+
from django.db.models import Exists, OuterRef, Q
45

5-
from events.models import Event, Playlist, Tag
6+
from events.models import Event, EventPresenter, Playlist, Tag
67

78

89
class EventFilter(django_filters.rest_framework.FilterSet):
@@ -19,14 +20,63 @@ class Meta:
1920
fields = ('event_type', 'is_featured', 'status')
2021

2122
def filter_search(self, queryset, _, value):
22-
""" Filter the queryset based on the search value """
23-
return queryset.filter(
24-
Q(title__icontains=value) |
25-
Q(description__icontains=value) |
26-
Q(creator__first_name__icontains=value) |
27-
Q(creator__last_name__icontains=value)
23+
"""
24+
Filter the queryset based on fuzzy search with priority-based inclusion
25+
Priority order for inclusion:
26+
1. Title match
27+
2. Description match
28+
3. Playlist match
29+
4. Presenter match
30+
5. Tag match
31+
32+
Final results ordered by event_time (newest to oldest)
33+
"""
34+
if not value:
35+
return queryset
36+
37+
search_term = value.strip()
38+
similarity_threshold = 0.3
39+
40+
playlist_match = Playlist.objects.filter(
41+
events=OuterRef('pk'),
42+
name__icontains=search_term
43+
)
44+
45+
tag_match = Tag.objects.filter(
46+
events=OuterRef('pk'),
47+
name__icontains=search_term
2848
)
2949

50+
presenter_match = EventPresenter.objects.filter(
51+
event=OuterRef('pk')
52+
).filter(
53+
Q(user__first_name__icontains=search_term) |
54+
Q(user__last_name__icontains=search_term)
55+
)
56+
57+
queryset = queryset.annotate(
58+
title_similarity=TrigramSimilarity('title', search_term),
59+
desc_similarity=TrigramSimilarity('description', search_term),
60+
has_playlist_match=Exists(playlist_match),
61+
has_tag_match=Exists(tag_match),
62+
has_presenter_match=Exists(presenter_match)
63+
)
64+
65+
filtered_queryset = queryset.filter(
66+
Q(title__icontains=search_term) |
67+
Q(title_similarity__gt=similarity_threshold) |
68+
Q(description__icontains=search_term) |
69+
Q(desc_similarity__gt=similarity_threshold) |
70+
Q(has_playlist_match=True) |
71+
Q(has_presenter_match=True) |
72+
Q(has_tag_match=True)
73+
)
74+
75+
if not filtered_queryset.exists():
76+
return queryset.none()
77+
78+
return filtered_queryset.order_by('-event_time')
79+
3080
def filter_tag(self, queryset, _, value):
3181
""" Filter the queryset based on the tag value """
3282
return queryset.filter(

events/v1/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
class EventsListView(ListAPIView):
1616
""" View for listing the events """
1717

18-
queryset = Event.objects.filter(videos__isnull=False).order_by("-status")
18+
queryset = Event.objects.filter(videos__isnull=False)
1919
serializer_class = EventSerializer
2020
pagination_class = CustomPageNumberPagination
2121
filterset_class = EventFilter

0 commit comments

Comments
 (0)