Skip to content

Commit aaaa13a

Browse files
authored
Advanced search filter backend routes (#744)
Create new POST route for filtered search with more extensible API
1 parent c133fd3 commit aaaa13a

File tree

20 files changed

+1132
-1319
lines changed

20 files changed

+1132
-1319
lines changed

backend/courses/filters.py

Lines changed: 172 additions & 445 deletions
Large diffs are not rendered by default.

backend/courses/migrations/0067_alter_meeting_associated_break_and_more.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ class Migration(migrations.Migration):
2727
model_name="meeting",
2828
constraint=models.UniqueConstraint(
2929
condition=models.Q(
30-
("section__isnull", True), ("associated_break__isnull", True), _connector="OR"
30+
("section__isnull", True),
31+
("associated_break__isnull", True),
32+
_connector="OR",
3133
),
3234
fields=("section", "associated_break"),
3335
name="unique_meeting_either_section_or_break",
@@ -37,7 +39,9 @@ class Migration(migrations.Migration):
3739
model_name="meeting",
3840
constraint=models.UniqueConstraint(
3941
condition=models.Q(
40-
("section__isnull", True), ("associated_break__isnull", True), _negated=True
42+
("section__isnull", True),
43+
("associated_break__isnull", True),
44+
_negated=True,
4145
),
4246
fields=("section", "associated_break"),
4347
name="meeting_must_have_section_or_break",

backend/courses/serializers.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from decimal import Decimal
12
from textwrap import dedent
23

34
from django.contrib.auth import get_user_model
@@ -517,3 +518,91 @@ class FriendshipRequestSerializer(serializers.Serializer):
517518

518519
def to_representation(self, instance):
519520
return super().to_representation(instance)
521+
522+
523+
class AdvancedSearchEnumSerializer(serializers.Serializer):
524+
type = serializers.ChoiceField(choices=["enum"])
525+
field = serializers.CharField()
526+
op = serializers.ChoiceField(choices=["is", "is_not", "is_any_of", "is_none_of"])
527+
value = serializers.ListField(
528+
child=serializers.CharField(),
529+
allow_empty=True,
530+
)
531+
532+
533+
class AdvancedSearchNumericSerializer(serializers.Serializer):
534+
type = serializers.ChoiceField(choices=["numeric"])
535+
field = serializers.CharField()
536+
op = serializers.ChoiceField(choices=["lt", "lte", "gt", "gte", "eq", "neq"])
537+
value = serializers.DecimalField(max_digits=4, decimal_places=2)
538+
539+
540+
class AdvancedSearchStartTimeSerializer(AdvancedSearchNumericSerializer):
541+
field = serializers.ChoiceField(choices=["start_time"])
542+
value = serializers.DecimalField(
543+
min_value=Decimal(0.0), max_value=Decimal(23.99), max_digits=4, decimal_places=2
544+
)
545+
546+
547+
class AdvancedSearchBooleanSerializer(serializers.Serializer):
548+
type = serializers.ChoiceField(choices=["boolean"])
549+
field = serializers.CharField()
550+
value = serializers.BooleanField()
551+
552+
553+
class AdvancedSearchValueSerializer(serializers.Serializer):
554+
type = serializers.ChoiceField(choices=["value"])
555+
field = serializers.CharField()
556+
value = serializers.Field()
557+
558+
559+
class AdvancedSearchConditionSerializer(serializers.Serializer):
560+
def to_internal_value(self, data):
561+
field_map = {
562+
"days": AdvancedSearchEnumSerializer,
563+
"activity": AdvancedSearchEnumSerializer,
564+
"cu": AdvancedSearchEnumSerializer,
565+
"start_time": AdvancedSearchNumericSerializer,
566+
"end_time": AdvancedSearchNumericSerializer,
567+
"difficulty": AdvancedSearchNumericSerializer,
568+
"course_quality": AdvancedSearchNumericSerializer,
569+
"instructor_quality": AdvancedSearchNumericSerializer,
570+
"is_open": AdvancedSearchBooleanSerializer,
571+
"fit_schedule": AdvancedSearchValueSerializer,
572+
}
573+
serializer_class = field_map.get(data.get("field"))
574+
if serializer_class is None:
575+
raise serializers.ValidationError({"type": "Invalid type"})
576+
577+
serializer = serializer_class(data=data)
578+
serializer.is_valid(raise_exception=True)
579+
return serializer.validated_data
580+
581+
582+
class AdvancedSearchGroupSerializer(serializers.Serializer):
583+
type = serializers.ChoiceField(choices=["group"])
584+
op = serializers.ChoiceField(choices=["AND", "OR"])
585+
children = serializers.ListField(
586+
child=AdvancedSearchConditionSerializer(),
587+
allow_empty=False,
588+
max_length=5,
589+
)
590+
591+
592+
class AdvancedSearchDataSerializer(serializers.Serializer):
593+
op = serializers.ChoiceField(choices=["AND", "OR"])
594+
children = serializers.ListField(
595+
max_length=10,
596+
allow_empty=True,
597+
)
598+
599+
def validate_children(self, children):
600+
validated_children = []
601+
for f in children:
602+
if f.get("type") == "group":
603+
serializer = AdvancedSearchGroupSerializer(data=f)
604+
else:
605+
serializer = AdvancedSearchConditionSerializer(data=f)
606+
serializer.is_valid(raise_exception=True)
607+
validated_children.append(serializer.validated_data)
608+
return validated_children

backend/courses/urls.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
from django.urls import path
22

33
from courses import views
4-
from courses.views import CourseListSearch, Health
54

65

76
urlpatterns = [
8-
path("health/", Health.as_view(), name="health"),
7+
path("health/", views.Health.as_view(), name="health"),
98
path("<slug:semester>/courses/", views.CourseList.as_view(), name="courses-list"),
109
path(
1110
"<slug:semester>/search/courses/",
12-
CourseListSearch.as_view(),
11+
views.CourseListSearch.as_view(),
1312
name="courses-search",
1413
),
1514
path(

backend/courses/views.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from rest_framework.response import Response
1212
from rest_framework.views import APIView
1313

14-
from courses.filters import CourseSearchFilterBackend
14+
from courses.filters import CourseSearchAdvancedFilterBackend
1515
from courses.models import (
1616
Attribute,
1717
Course,
@@ -175,14 +175,19 @@ def get_queryset(self):
175175
class CourseListSearch(CourseList):
176176
"""
177177
This route allows you to list courses by certain search terms and/or filters.
178-
Without any GET parameters, this route simply returns all courses
178+
- **GET**: (Deprecated) Without any GET parameters, this route simply returns all courses
179179
for a given semester. There are a few filter query parameters which constitute ranges of
180180
floating-point numbers. The values for these are <min>-<max> , with minimum excluded.
181181
For example, looking for classes in the range of 0-2.5 in difficulty, you would add the
182182
parameter difficulty=0-2.5. If you are a backend developer, you can find these filters in
183183
backend/plan/filters.py/CourseSearchFilterBackend. If you are reading the frontend docs,
184184
these filters are listed below in the query parameters list (with description starting with
185185
"Filter").
186+
- **POST**: This route also accepts POST requests, where the body is a JSON object
187+
containing a "filters" key, which maps to an object containing the same filters as
188+
described above. This API will allow for a more extensible filtering system.
189+
If you are a backend or frontenddeveloper, you can find these filters and request
190+
body schema in backend/plan/filters.py/CourseSearchAdvancedFilterBackend.
186191
"""
187192

188193
schema = PcxAutoSchema(
@@ -191,11 +196,18 @@ class CourseListSearch(CourseList):
191196
"GET": {
192197
200: "[DESCRIBE_RESPONSE_SCHEMA]Courses listed successfully.",
193198
400: "Bad request (invalid query).",
194-
}
199+
},
200+
"POST": {
201+
200: "[DESCRIBE_RESPONSE_SCHEMA]Courses listed successfully.",
202+
400: "Bad request (invalid query).",
203+
},
195204
}
196205
},
197206
custom_path_parameter_desc={
198-
"courses-search": {"GET": {"semester": SEMESTER_PARAM_DESCRIPTION}}
207+
"courses-search": {
208+
"GET": {"semester": SEMESTER_PARAM_DESCRIPTION},
209+
"POST": {"semester": SEMESTER_PARAM_DESCRIPTION},
210+
}
199211
},
200212
)
201213

@@ -221,7 +233,12 @@ def get_serializer_context(self):
221233
if self.request is None or not self.request.user or not self.request.user.is_authenticated:
222234
return context
223235

224-
_, _, curr_course_vectors_dict, past_course_vectors_dict = retrieve_course_clusters()
236+
(
237+
_,
238+
_,
239+
curr_course_vectors_dict,
240+
past_course_vectors_dict,
241+
) = retrieve_course_clusters()
225242
user_vector, _ = vectorize_user(
226243
self.request.user, curr_course_vectors_dict, past_course_vectors_dict
227244
)
@@ -234,9 +251,23 @@ def get_serializer_context(self):
234251

235252
return context
236253

237-
filter_backends = [TypedCourseSearchBackend, CourseSearchFilterBackend]
238254
search_fields = ("full_code", "title", "sections__instructors__name")
239255

256+
def get(self, request, *args, **kwargs):
257+
queryset = super().get_queryset()
258+
queryset = TypedCourseSearchBackend().filter_queryset(request, queryset, self)
259+
260+
serializer = self.get_serializer(queryset, many=True)
261+
return Response(serializer.data)
262+
263+
def post(self, request, *args, **kwargs):
264+
queryset = super().get_queryset()
265+
queryset = TypedCourseSearchBackend().filter_queryset(request, queryset, self)
266+
queryset = CourseSearchAdvancedFilterBackend().filter_queryset(request, queryset, self)
267+
268+
serializer = self.get_serializer(queryset, many=True)
269+
return Response(serializer.data)
270+
240271

241272
class CourseDetail(generics.RetrieveAPIView, BaseCourseMixin):
242273
"""

backend/plan/migrations/0010_break.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ class Migration(migrations.Migration):
1919
(
2020
"id",
2121
models.AutoField(
22-
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
22+
auto_created=True,
23+
primary_key=True,
24+
serialize=False,
25+
verbose_name="ID",
2326
),
2427
),
2528
(

backend/plan/migrations/0014_break_unique_break_meeting_times_per_person.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ class Migration(migrations.Migration):
1616
model_name="break",
1717
constraint=models.UniqueConstraint(
1818
condition=models.Q(
19-
("meeting_times__isnull", False), models.Q(("meeting_times", ""), _negated=True)
19+
("meeting_times__isnull", False),
20+
models.Q(("meeting_times", ""), _negated=True),
2021
),
2122
fields=("person", "meeting_times"),
2223
name="unique_break_meeting_times_per_person",

backend/plan/serializers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ class ScheduleSerializer(serializers.ModelSerializer):
4848
required=True,
4949
)
5050
breaks = BreakSerializer(
51-
many=True, read_only=False, help_text="The breaks in the schedule.", required=False
51+
many=True,
52+
read_only=False,
53+
help_text="The breaks in the schedule.",
54+
required=False,
5255
)
5356
id = serializers.IntegerField(
5457
read_only=False, required=False, help_text="The id of the schedule."

backend/plan/util.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@
22

33

44
def get_first_matching_date(start_date_str, days):
5-
day_map = {"MO": 0, "TU": 1, "WE": 2,
6-
"TH": 3, "FR": 4, "SA": 5, "SU": 6}
7-
start_date = datetime.datetime.strptime(
8-
start_date_str, "%Y-%m-%d").date()
5+
day_map = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
6+
start_date = datetime.datetime.strptime(start_date_str, "%Y-%m-%d").date()
97
weekdays = [day_map[code] for code in days]
108

119
for i in range(7):

0 commit comments

Comments
 (0)