Skip to content
Merged
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
617 changes: 172 additions & 445 deletions backend/courses/filters.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ class Migration(migrations.Migration):
model_name="meeting",
constraint=models.UniqueConstraint(
condition=models.Q(
("section__isnull", True), ("associated_break__isnull", True), _connector="OR"
("section__isnull", True),
("associated_break__isnull", True),
_connector="OR",
),
fields=("section", "associated_break"),
name="unique_meeting_either_section_or_break",
Expand All @@ -37,7 +39,9 @@ class Migration(migrations.Migration):
model_name="meeting",
constraint=models.UniqueConstraint(
condition=models.Q(
("section__isnull", True), ("associated_break__isnull", True), _negated=True
("section__isnull", True),
("associated_break__isnull", True),
_negated=True,
),
fields=("section", "associated_break"),
name="meeting_must_have_section_or_break",
Expand Down
89 changes: 89 additions & 0 deletions backend/courses/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from decimal import Decimal
from textwrap import dedent

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

def to_representation(self, instance):
return super().to_representation(instance)


class AdvancedSearchEnumSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=["enum"])
field = serializers.CharField()
op = serializers.ChoiceField(choices=["is", "is_not", "is_any_of", "is_none_of"])
value = serializers.ListField(
child=serializers.CharField(),
allow_empty=True,
)


class AdvancedSearchNumericSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=["numeric"])
field = serializers.CharField()
op = serializers.ChoiceField(choices=["lt", "lte", "gt", "gte", "eq", "neq"])
value = serializers.DecimalField(max_digits=4, decimal_places=2)


class AdvancedSearchStartTimeSerializer(AdvancedSearchNumericSerializer):
field = serializers.ChoiceField(choices=["start_time"])
value = serializers.DecimalField(
min_value=Decimal(0.0), max_value=Decimal(23.99), max_digits=4, decimal_places=2
)


class AdvancedSearchBooleanSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=["boolean"])
field = serializers.CharField()
value = serializers.BooleanField()


class AdvancedSearchValueSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=["value"])
field = serializers.CharField()
value = serializers.Field()


class AdvancedSearchConditionSerializer(serializers.Serializer):
def to_internal_value(self, data):
field_map = {
"days": AdvancedSearchEnumSerializer,
"activity": AdvancedSearchEnumSerializer,
"cu": AdvancedSearchEnumSerializer,
"start_time": AdvancedSearchNumericSerializer,
"end_time": AdvancedSearchNumericSerializer,
"difficulty": AdvancedSearchNumericSerializer,
"course_quality": AdvancedSearchNumericSerializer,
"instructor_quality": AdvancedSearchNumericSerializer,
"is_open": AdvancedSearchBooleanSerializer,
"fit_schedule": AdvancedSearchValueSerializer,
}
serializer_class = field_map.get(data.get("field"))
if serializer_class is None:
raise serializers.ValidationError({"type": "Invalid type"})

serializer = serializer_class(data=data)
serializer.is_valid(raise_exception=True)
return serializer.validated_data


class AdvancedSearchGroupSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=["group"])
op = serializers.ChoiceField(choices=["AND", "OR"])
children = serializers.ListField(
child=AdvancedSearchConditionSerializer(),
allow_empty=False,
max_length=5,
)


class AdvancedSearchDataSerializer(serializers.Serializer):
op = serializers.ChoiceField(choices=["AND", "OR"])
children = serializers.ListField(
max_length=10,
allow_empty=True,
)

def validate_children(self, children):
validated_children = []
for f in children:
if f.get("type") == "group":
serializer = AdvancedSearchGroupSerializer(data=f)
else:
serializer = AdvancedSearchConditionSerializer(data=f)
serializer.is_valid(raise_exception=True)
validated_children.append(serializer.validated_data)
return validated_children
5 changes: 2 additions & 3 deletions backend/courses/urls.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
from django.urls import path

from courses import views
from courses.views import CourseListSearch, Health


urlpatterns = [
path("health/", Health.as_view(), name="health"),
path("health/", views.Health.as_view(), name="health"),
path("<slug:semester>/courses/", views.CourseList.as_view(), name="courses-list"),
path(
"<slug:semester>/search/courses/",
CourseListSearch.as_view(),
views.CourseListSearch.as_view(),
name="courses-search",
),
path(
Expand Down
43 changes: 37 additions & 6 deletions backend/courses/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from rest_framework.response import Response
from rest_framework.views import APIView

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

schema = PcxAutoSchema(
Expand All @@ -191,11 +196,18 @@ class CourseListSearch(CourseList):
"GET": {
200: "[DESCRIBE_RESPONSE_SCHEMA]Courses listed successfully.",
400: "Bad request (invalid query).",
}
},
"POST": {
200: "[DESCRIBE_RESPONSE_SCHEMA]Courses listed successfully.",
400: "Bad request (invalid query).",
},
}
},
custom_path_parameter_desc={
"courses-search": {"GET": {"semester": SEMESTER_PARAM_DESCRIPTION}}
"courses-search": {
"GET": {"semester": SEMESTER_PARAM_DESCRIPTION},
"POST": {"semester": SEMESTER_PARAM_DESCRIPTION},
}
},
)

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

_, _, curr_course_vectors_dict, past_course_vectors_dict = retrieve_course_clusters()
(
_,
_,
curr_course_vectors_dict,
past_course_vectors_dict,
) = retrieve_course_clusters()
user_vector, _ = vectorize_user(
self.request.user, curr_course_vectors_dict, past_course_vectors_dict
)
Expand All @@ -234,9 +251,23 @@ def get_serializer_context(self):

return context

filter_backends = [TypedCourseSearchBackend, CourseSearchFilterBackend]
search_fields = ("full_code", "title", "sections__instructors__name")

def get(self, request, *args, **kwargs):
queryset = super().get_queryset()
queryset = TypedCourseSearchBackend().filter_queryset(request, queryset, self)

serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)

def post(self, request, *args, **kwargs):
queryset = super().get_queryset()
queryset = TypedCourseSearchBackend().filter_queryset(request, queryset, self)
queryset = CourseSearchAdvancedFilterBackend().filter_queryset(request, queryset, self)

serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)


class CourseDetail(generics.RetrieveAPIView, BaseCourseMixin):
"""
Expand Down
5 changes: 4 additions & 1 deletion backend/plan/migrations/0010_break.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ class Migration(migrations.Migration):
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ class Migration(migrations.Migration):
model_name="break",
constraint=models.UniqueConstraint(
condition=models.Q(
("meeting_times__isnull", False), models.Q(("meeting_times", ""), _negated=True)
("meeting_times__isnull", False),
models.Q(("meeting_times", ""), _negated=True),
),
fields=("person", "meeting_times"),
name="unique_break_meeting_times_per_person",
Expand Down
5 changes: 4 additions & 1 deletion backend/plan/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ class ScheduleSerializer(serializers.ModelSerializer):
required=True,
)
breaks = BreakSerializer(
many=True, read_only=False, help_text="The breaks in the schedule.", required=False
many=True,
read_only=False,
help_text="The breaks in the schedule.",
required=False,
)
id = serializers.IntegerField(
read_only=False, required=False, help_text="The id of the schedule."
Expand Down
6 changes: 2 additions & 4 deletions backend/plan/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@


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

for i in range(7):
Expand Down
Loading
Loading