diff --git a/backend/courses/filters.py b/backend/courses/filters.py index 32218fe21..d8cfdc61f 100644 --- a/backend/courses/filters.py +++ b/backend/courses/filters.py @@ -1,15 +1,12 @@ from decimal import Decimal from django.core.exceptions import BadRequest -from django.db.models import Count, Exists, OuterRef, Q +from django.db.models import Count, Q from django.db.models.expressions import F, Subquery -from lark import Lark, Transformer, Tree -from lark.exceptions import UnexpectedInput from rest_framework import filters -from courses.models import Course, Meeting, PreNGSSRequirement, Section -from courses.util import get_current_semester -from degree.models import Rule +from courses.models import Meeting, Section +from courses.serializers import AdvancedSearchDataSerializer from plan.models import Schedule @@ -66,63 +63,90 @@ def meeting_filter(queryset, meeting_query): ) -def is_open_filter(queryset, *args): +def _enum(field): """ - Filters the given queryset of courses by the following condition: - include a course only if filtering its sections by `status="O"` does - not does not limit the set of section activities we can participate in for the course. - In other words, include only courses for which all activities have open sections. - Note that for compatibility, this function can take additional positional - arguments, but these are ignored. + Constructs an enum filter function for the given field, operators, and values """ - return queryset.filter(id__in=course_ids_by_section_query(Q(status="O"))) + + def filter_enum(filter_condition): + op = filter_condition["op"] + values = filter_condition["value"] + + match op: + case "is": + return Q(**{field: values[0]}) + case "is_not": + return ~Q(**{field: values[0]}) + case "is_any_of": + return Q(**{f"{field}__in": set(values)}) + case "is_none_of": + return ~Q(**{f"{field}__in": set(values)}) + return Q() + + return filter_enum -def day_filter(days): +def _numeric(field): """ - Constructs a Q() query object for filtering meetings by day, - based on the given days filter string. + Constructs a numeric filter function for the given field, operators, and values """ - days = set(days) - if not days.issubset({"M", "T", "W", "R", "F", "S", "U"}): + + def filter_numeric(filter_condition): + op = filter_condition["op"] + value = Decimal(filter_condition["value"]) + + q = Q(**{f"{field}__isnull": True}) + + match op: + case "eq": + return q | Q(**{field: value}) + case "neq": + return q | ~Q(**{field: value}) + case _: + return q | Q(**{f"{field}__{op}": value}) + return Q() - return Q(day__isnull=True) | Q(day__in=set(days)) + return filter_numeric + + +def _boolean(field): + def filter_boolean(filter_condition): + value = filter_condition["value"] + return Q(**{field: value}) + + return filter_boolean + + +def _combine(op, q1, q2): + match op: + case "AND": + return q1 & q2 + case "OR": + return q1 | q2 + raise BadRequest(f"Invalid group operator: {op}") -def time_filter(time_range): + +def _is_open(filter_condition): """ - Constructs a Q() query object for filtering meetings by start/end time, - based on the given time_range filter string. + Filters the given queryset of courses by the following condition: + include a course only if filtering its sections by `status="O"` does + not does not limit the set of section activities we can participate in for the course. + In other words, include only courses for which all activities have open sections. + Note that for compatibility, this function can take additional positional + arguments, but these are ignored. """ - if not time_range: - return Q() - times = time_range.split("-") - if len(times) != 2: - return Q() - times = [t.strip() for t in times] - for time in times: - if time and not time.replace(".", "", 1).isdigit(): - return Q() - start_time, end_time = times - query = Q() - if start_time: - query &= Q(start__isnull=True) | Q(start__gte=Decimal(start_time)) - if end_time: - query &= Q(end__isnull=True) | Q(end__lte=Decimal(end_time)) - return query + return Q(id__in=course_ids_by_section_query(Q(status="O"))) -def gen_schedule_filter(request): +def _fit_schedule(request): """ Generates a schedule filter function that checks for proper authentication in the given request. """ - def schedule_filter(schedule_id): - """ - Constructs a Q() query object for filtering meetings by - whether they fit into the specified schedule. - """ + def filter_fit_schedule(filter_condition): + schedule_id = filter_condition["value"] if not schedule_id: return Q() if not schedule_id.isdigit(): @@ -136,412 +160,115 @@ def schedule_filter(schedule_id): ) ) ) - query = Q() + q = Q() for meeting in meetings: - query &= meeting.no_conflict_query - return query - - return schedule_filter - - -def pre_ngss_requirement_filter(queryset, req_ids): - if not req_ids: - return queryset - query = Q() - for req_id in req_ids.split(","): - code, school = req_id.split("@") - try: - requirement = PreNGSSRequirement.objects.get( - code=code, school=school, semester=get_current_semester() - ) - except PreNGSSRequirement.DoesNotExist: - continue - query &= Q(id__in=requirement.satisfying_courses.all()) - - return queryset.filter(query) - - -# See the attribute_filter docstring for an explanation of this grammar -# https://lark-parser.readthedocs.io/en/latest/examples/calc.html -attribute_query_parser = Lark( - r""" - ?expr : or_expr + q &= meeting.no_conflict_query + return q + + return filter_fit_schedule + + +class CourseSearchAdvancedFilterBackend(filters.BaseFilterBackend): + field_map = { + "cu": _enum("sections__credits"), + "activity": _enum("sections__activity"), + "days": _enum("day"), + "difficulty": _numeric("difficulty"), + "course_quality": _numeric("course_quality"), + "instructor_quality": _numeric("instructor_quality"), + "start_time": _numeric("start"), + "end_time": _numeric("end"), + "is_open": _is_open, + "fit_schedule": _fit_schedule(request=None), + "attribute": _enum("attributes__code"), + } + + meeting_fields = {"days", "start_time", "end_time", "fit_schedule"} + + def _apply_filters(self, request, queryset, filter_group): + op = filter_group.get("op") + children = filter_group.get("children", []) + q = Q() + meeting_q = Q() + for child in children: + child_q = Q() + child_meeting_q = Q() + if child["type"] == "group": + child_q, child_meeting_q = self._apply_filters(request, queryset, child) + q = _combine(op, q, child_q) + meeting_q = _combine(op, meeting_q, child_meeting_q) + else: + field = child["field"] + filter_func = self.field_map[field] + + if field == "fit_schedule": + filter_func = _fit_schedule(request) + + if field not in self.meeting_fields: + child_q = filter_func(child) + q = _combine(op, q, child_q) + else: + child_meeting_q = filter_func(child) + meeting_q = _combine(op, meeting_q, child_meeting_q) + + return q, meeting_q - ?or_expr : and_expr - | and_expr "|" or_expr -> disjunction - - ?and_expr : atom - | atom "*" and_expr -> conjunction - - ?atom : attribute - | "~" atom -> negation - | "(" or_expr ")" - - attribute : WORD - - %import common.WORD - %import common.WS - %ignore WS - """, - start="expr", -) - - -class AttributeQueryTreeToCourseQ(Transformer): - """ - Each transformation step returns a tuple of the form `(is_leaf, q)`, - where `is_leaf` is a boolean indicating if that query expression - is a leaf-level attribute code filter, and `q` is the query expression. - """ - - def attribute(self, children): - (code,) = children - return True, Q(attributes__code=code.upper()) - - def disjunction(self, children): - (c1_leaf, c1), (c2_leaf, c2) = children - return (c1_leaf or c2_leaf), c1 | c2 - - def lift_exists(self, q): - """ - 'Lifts' the given `q` query object from a leaf-level attribute - filter (e.g. `Q(attributes__code="WUOM")`) to an 'exists' subquery, - e.g. `Q(Exists(Course.objects.filter(attributes__code="WUOM", id=OuterRef("id"))))`. - This is required for conjunction and negation operations, as `Q(attributes__code="WUOM")` - simply performs a join between the `Course` and `Attribute` tables and filters the joined - rows, so `Q(attributes__code="WUOM") & Q(attributes__code="EMCI")` - would filter out all rows, (as no row can have code equal to both "WUOM" and "EMCI"), and - `~Q(attributes__code="WUOM")` would filter for courses that contain some attribute - other than WUOM (not the desired behavior). Lifing these conditions with an exists subquery - before combining with the relevant logical connectives fixes this issue. - """ - return Q(Exists(Course.objects.filter(q, id=OuterRef("id")))) - - def conjunction(self, children): - children = [self.lift_exists(c) if c_leaf else c for c_leaf, c in children] - c1, c2 = children - return False, c1 & c2 - - def negation(self, children): - ((c_leaf, c),) = children - if c_leaf: - c = self.lift_exists(c) - return False, ~c - - -def attribute_filter(queryset, attr_query): - """ - :param queryset: initial Course object queryset - :param attr_query: the attribute query string; see the description - of the attributes query param below for an explanation of the - syntax/semantics of this filter - :return: filtered queryset - """ - if not attr_query: - return queryset - - expr = None - try: - expr = attribute_query_parser.parse(attr_query) - except UnexpectedInput as e: - raise BadRequest(e) - - def lift_demorgan(t): - """ - Optimization: Given a Lark parse tree t, tries to - convert `*` to leaf-level `|` operators as much as possible, - using DeMorgan's laws (for query performance). - """ - if t.data == "attribute": - return t - t.children = [lift_demorgan(c) for c in t.children] - if t.data == "conjunction": - c1, c2 = t.children - if c1.data == "negation" and c2.data == "negation": - (c1c,) = c1.children - (c2c,) = c2.children - return Tree( - data="negation", - children=[Tree(data="disjunction", children=[c1c, c2c])], - ) - return t - - expr = lift_demorgan(expr) - - _, query = AttributeQueryTreeToCourseQ().transform(expr) - - return queryset.filter(query).distinct() - - -def bound_filter(field): - def filter_bounds(queryset, bounds): - if not bounds: - return queryset - bound_arr = bounds.split("-") - if len(bound_arr) != 2: - return queryset - bound_arr = [b.strip() for b in bound_arr] - for bound in bound_arr: - if bound and not bound.replace(".", "", 1).isdigit(): - return queryset - lower_bound, upper_bound = bound_arr - lower_bound = Decimal(lower_bound) - upper_bound = Decimal(upper_bound) - - return queryset.filter( - Q(**{f"{field}__isnull": True}) - | Q( - **{ - f"{field}__gte": lower_bound, - f"{field}__lte": upper_bound, - } - ) - ) - - return filter_bounds - - -def choice_filter(field): - def filter_choices(queryset, choices): - if not choices: - return queryset - query = Q() - for choice in choices.split(","): - query = query | Q(**{field: choice}) - - return queryset.filter(query) - - return filter_choices - - -def degree_rules_filter(queryset, rule_ids): - """ - :param queryset: initial Course object queryset - :param rule_ids: Comma separated string of of Rule ids to filter by. If the rule does not - have a q object, it does not filter the queryset. - """ - if not rule_ids: - return queryset - query = Q() - for rule_id in rule_ids.split(","): - try: - rule = Rule.objects.get(id=int(rule_id)) - except Rule.DoesNotExist | ValueError: - continue - q = rule.get_q_object() - if not q: - continue - query &= q - return queryset.filter(query) - - -class CourseSearchFilterBackend(filters.BaseFilterBackend): def filter_queryset(self, request, queryset, view): - filters = { - "attributes": attribute_filter, - "pre_ngss_requirements": pre_ngss_requirement_filter, - "cu": choice_filter("sections__credits"), - "activity": choice_filter("sections__activity"), - "course_quality": bound_filter("course_quality"), - "instructor_quality": bound_filter("instructor_quality"), - "difficulty": bound_filter("difficulty"), - "is_open": is_open_filter, - "rule_ids": degree_rules_filter, - } - for field, filter_func in filters.items(): - param = request.query_params.get(field) - if param is not None: - queryset = filter_func(queryset, param) - - # Combine meeting filter queries for efficiency - meeting_filters = { - "days": day_filter, - "time": time_filter, - "schedule-fit": gen_schedule_filter(request), - } - meeting_query = Q() - for field, filter_func in meeting_filters.items(): - param = request.query_params.get(field) - if param is not None: - meeting_query &= filter_func(param) - if len(meeting_query) > 0: - queryset = meeting_filter(queryset, meeting_query) - - return queryset.distinct("full_code") # TODO: THIS COULD BE A BREAKING CHANGE FOR PCX + q, meeting_q = self._apply_filters(request, queryset, request.data) + queryset = queryset.filter(q) + # Separate meeting filter for optimization + if len(meeting_q) > 0: + queryset = meeting_filter(queryset, meeting_q) + + return queryset.distinct("full_code") def get_schema_operation_parameters(self, view): return [ { - "name": "degree_rules", - "required": False, - "in": "query", - "description": ( - "Filter to courses that satisfy certain degree Rules. Accepts " - "a string of comma-separated Rule ids. If multiple Rule ids " - "are passed then filtered courses satisfy all the rules." - ), - "schema": {"type": "string"}, - }, - { - "name": "type", - "required": False, - "in": "query", - "description": ( - "Can specify what kind of query to run. Course queries are faster, " - "keyword queries look against professor name and course title." - ), - "schema": { - "type": "string", - "default": "auto", - "enum": ["auto", "course", "keyword"], + "name": "search_data", + "in": "body", + "required": True, + "description": "Advanced search parameters with query string and filters.", + "schema": AdvancedSearchDataSerializer().data, + "example": { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "activity", + "op": "is_any_of", + "value": ["LEC", "REC"], + }, + { + "type": "numeric", + "field": "difficulty", + "op": "lte", + "value": 3, + }, + { + "type": "enum", + "field": "attribute", + "op": "is_any_of", + "value": ["WUOM", "EMCI"], + }, + { + "type": "group", + "op": "OR", + "children": [ + { + "type": "boolean", + "field": "is_open", + "value": True, + }, + { + "type": "enum", + "field": "credit_units", + "op": "is", + "value": "1.0", + }, + ], + }, + ], }, - }, - { - "name": "pre_ngss_requirements", - "required": False, - "in": "query", - "description": ( - "Deprecated since 2022B. Filter courses by comma-separated pre " - "ngss requirements, ANDed together. Use the " - "[List Requirements](/api/documentation/#operation/List%20Pre-Ngss%20Requirements) " # noqa: E501 - "endpoint to get requirement IDs." - ), - "schema": {"type": "string"}, - "example": "SS@SEAS,H@SEAS", - }, - { - "name": "attributes", - "required": False, - "in": "query", - "description": ( - "This query parameter accepts a logical expression of attribute codes " - "separated by `*` (AND) or `|` (OR) connectives, optionally grouped " - "into clauses by parentheses and arbitrarily nested (we avoid using " - "`&` for the AND connective so the query string doesn't have to be escaped). " - "You can negate an individual attribute code or a clause with the `~` operator " - "(this will filter for courses that do NOT have that attribute or do not " - "satisfy that clause). Binary operators are left-associative, " - "and operator precedence is as follows: `~ > * > |`. " - "Whitespace is ignored. " - "A syntax error will cause a 400 response to be returned. " - "Example: `(EUHS|EUSS)*(QP|QS)` would filter for courses that " - "satisfy the EAS humanities or social science requirements " - "and also have a standard grade type or a pass/fail grade type. Use the " - "[List Attributes](/api/documentation/#operation/List%20Attributes) endpoint " - "to get a list of valid attribute codes and descriptions." - ), - "schema": {"type": "string"}, - "example": "WUOM|WUGA", - }, - { - "name": "cu", - "required": False, - "in": "query", - "description": "Filter course units to be within the given range.", - "schema": {"type": "string"}, - "example": "0-0.5", - }, - { - "name": "difficulty", - "required": False, - "in": "query", - "description": ( - "Filter course difficulty (average across all reviews) to be within " - "the given range." - ), - "schema": {"type": "string"}, - "example": "1-2.5", - }, - { - "name": "course_quality", - "required": False, - "in": "query", - "description": ( - "Filter course quality (average across all reviews) to be within " - "the given range." - ), - "schema": {"type": "string"}, - "example": "2.5-4", - }, - { - "name": "instructor_quality", - "required": False, - "in": "query", - "description": ( - "Filter instructor quality (average across all reviews) to be " - "within the given range." - ), - "schema": {"type": "string"}, - "example": "2.5-4", - }, - { - "name": "days", - "required": False, - "in": "query", - "description": ( - "Filter meetings to be within the specified set of days. " - "The set of days should be specified as a string containing some " - "combination of the characters [M, T, W, R, F, S, U]. " - "This filters courses by the following condition: " - "include a course only if the specified day filter " - "does not limit the set of section activities we can participate in " - "for the course. " - "Passing an empty string will return only asynchronous classes " - "or classes with meeting days TBD." - ), - "schema": {"type": "string"}, - "example": "TWR", - }, - { - "name": "time", - "required": False, - "in": "query", - "description": ( - "Filter meeting times to be within the specified range. " - "The start and end time of the filter should be dash-separated. " - "Times should be specified as decimal numbers of the form `h+mm/100` " - "where h is the hour `[0..23]` and mm is the minute `[0,60)`, in ET. " - "You can omit either the start or end time to leave that side unbounded, " - "e.g. '11.30-'. " - "This filters courses by the following condition: " - "include a course only if the specified time filter " - "does not limit the set of section activities we can participate in " - "for the course." - ), - "schema": {"type": "string"}, - "example": "11.30-18", - }, - { - "name": "schedule-fit", - "required": False, - "in": "query", - "description": ( - "Filter meeting times to fit into the schedule with the specified integer id. " - "You must be authenticated with the account owning the specified schedule, " - "or this filter will be ignored. " - "This filters courses by the following condition: " - "include a course only if the specified schedule-fit filter " - "does not limit the set of section activities we can participate in " - "for the course." - ), - "schema": {"type": "integer"}, - "example": "242", - }, - { - "name": "is_open", - "required": False, - "in": "query", - "description": ( - "Filter courses to only those that are open. " - "A boolean of true should be included if you want to apply the filter. " - "By default (ie when the `is_open` is not supplied, the filter is not applied. " - "This filters courses by the following condition: " - "include a course only if the specification that a section is open " - "does not limit the set of section activities we can participate in " - "for the course." - "In other words, filter to courses for which all activities have open sections." - ), - "schema": {"type": "boolean"}, - "example": "true", - }, + } ] diff --git a/backend/courses/migrations/0067_alter_meeting_associated_break_and_more.py b/backend/courses/migrations/0067_alter_meeting_associated_break_and_more.py index cd8f70e67..e37ee128f 100644 --- a/backend/courses/migrations/0067_alter_meeting_associated_break_and_more.py +++ b/backend/courses/migrations/0067_alter_meeting_associated_break_and_more.py @@ -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", @@ -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", diff --git a/backend/courses/serializers.py b/backend/courses/serializers.py index 313397cab..78d20efdc 100644 --- a/backend/courses/serializers.py +++ b/backend/courses/serializers.py @@ -1,3 +1,4 @@ +from decimal import Decimal from textwrap import dedent from django.contrib.auth import get_user_model @@ -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 diff --git a/backend/courses/urls.py b/backend/courses/urls.py index ae8b22b35..cf3277e02 100644 --- a/backend/courses/urls.py +++ b/backend/courses/urls.py @@ -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("/courses/", views.CourseList.as_view(), name="courses-list"), path( "/search/courses/", - CourseListSearch.as_view(), + views.CourseListSearch.as_view(), name="courses-search", ), path( diff --git a/backend/courses/views.py b/backend/courses/views.py index ce241ed09..9a9397f8f 100644 --- a/backend/courses/views.py +++ b/backend/courses/views.py @@ -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, @@ -175,7 +175,7 @@ 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 - , with minimum excluded. For example, looking for classes in the range of 0-2.5 in difficulty, you would add the @@ -183,6 +183,11 @@ class CourseListSearch(CourseList): 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( @@ -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}, + } }, ) @@ -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 ) @@ -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): """ diff --git a/backend/plan/migrations/0010_break.py b/backend/plan/migrations/0010_break.py index 03659ca99..0a509bdf0 100644 --- a/backend/plan/migrations/0010_break.py +++ b/backend/plan/migrations/0010_break.py @@ -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", ), ), ( diff --git a/backend/plan/migrations/0014_break_unique_break_meeting_times_per_person.py b/backend/plan/migrations/0014_break_unique_break_meeting_times_per_person.py index 1bd74eb9a..6fffac1b3 100644 --- a/backend/plan/migrations/0014_break_unique_break_meeting_times_per_person.py +++ b/backend/plan/migrations/0014_break_unique_break_meeting_times_per_person.py @@ -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", diff --git a/backend/plan/serializers.py b/backend/plan/serializers.py index 0f9618732..e979c289f 100644 --- a/backend/plan/serializers.py +++ b/backend/plan/serializers.py @@ -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." diff --git a/backend/plan/util.py b/backend/plan/util.py index 2ff9ce996..207d1aaa3 100644 --- a/backend/plan/util.py +++ b/backend/plan/util.py @@ -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): diff --git a/backend/plan/views.py b/backend/plan/views.py index 7f715046d..d65d04c10 100644 --- a/backend/plan/views.py +++ b/backend/plan/views.py @@ -273,8 +273,7 @@ def create(self, request): res["message"] = "Primary schedule successfully unset" res["message"] = "Primary schedule was already unset" else: - schedule = Schedule.objects.filter( - person_id=user.id, id=schedule_id).first() + schedule = Schedule.objects.filter(person_id=user.id, id=schedule_id).first() if not schedule: res["message"] = "Schedule does not exist" return JsonResponse(res, status=status.HTTP_400_BAD_REQUEST) @@ -522,8 +521,7 @@ def validate_name(self, request, existing_schedule=None, allow_path=False): name, existing_schedule and existing_schedule.name, ] and not ( - allow_path and isinstance( - request.successful_authenticator, PlatformAuthentication) + allow_path and isinstance(request.successful_authenticator, PlatformAuthentication) ): raise PermissionDenied( "You cannot create/update/delete a schedule with the name " @@ -549,8 +547,7 @@ def update(self, request, pk=None): if from_path: schedule, _ = self.get_queryset(semester).get_or_create( name=PATH_REGISTRATION_SCHEDULE_NAME, - defaults={"person": self.request.user, - "semester": semester}, + defaults={"person": self.request.user, "semester": semester}, ) else: schedule = self.get_queryset(semester).get(id=pk) @@ -560,12 +557,10 @@ def update(self, request, pk=None): status=status.HTTP_403_FORBIDDEN, ) - name = self.validate_name( - request, existing_schedule=schedule, allow_path=from_path) + name = self.validate_name(request, existing_schedule=schedule, allow_path=from_path) try: - sections = self.get_sections( - request.data, semester, skip_missing=from_path) + sections = self.get_sections(request.data, semester, skip_missing=from_path) except ObjectDoesNotExist: return Response( {"detail": "One or more sections not found in database."}, @@ -650,8 +645,7 @@ def create(self, request, *args, **kwargs): def get_queryset(self, semester=None): if not semester: semester = get_current_semester() - queryset = Schedule.objects.filter( - person=self.request.user, semester=semester) + queryset = Schedule.objects.filter(person=self.request.user, semester=semester) queryset = queryset.prefetch_related( Prefetch("sections", Section.with_reviews.all()), "sections__associated_sections", @@ -682,15 +676,15 @@ def get(self): Get all breaks for the current user. """ breaks = self.get_queryset() - serializer = BreakSerializer( - breaks, many=True, context=self.get_serializer_context()) + serializer = BreakSerializer(breaks, many=True, context=self.get_serializer_context()) return Response(serializer.data, status=status.HTTP_200_OK) def update(self, request, *args, **kwargs): break_id = kwargs["pk"] if not break_id: return Response( - {"detail": "Break id is required for update."}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Break id is required for update."}, + status=status.HTTP_400_BAD_REQUEST, ) try: @@ -699,13 +693,15 @@ def update(self, request, *args, **kwargs): return Response({"detail": "Break not found."}, status=status.HTTP_404_NOT_FOUND) except Exception as e: return Response( - {"detail": "Error retrieving break: " + str(e)}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Error retrieving break: " + str(e)}, + status=status.HTTP_400_BAD_REQUEST, ) name = request.data.get("name") if not name: return Response( - {"detail": "Break name is required."}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Break name is required."}, + status=status.HTTP_400_BAD_REQUEST, ) location_string = request.data.get("location_string") @@ -725,7 +721,8 @@ def update(self, request, *args, **kwargs): set_meetings(current_break, meetings_with_codes) except Exception as e: return Response( - {"detail": "Error setting meetings: " + str(e)}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Error setting meetings: " + str(e)}, + status=status.HTTP_400_BAD_REQUEST, ) checked = request.data.get("checked") @@ -736,11 +733,13 @@ def update(self, request, *args, **kwargs): except Exception as e: return Response( - {"detail": "Error saving break: " + str(e)}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Error saving break: " + str(e)}, + status=status.HTTP_400_BAD_REQUEST, ) return Response( - {"message": "success", "break_id": current_break.id}, status=status.HTTP_200_OK + {"message": "success", "break_id": current_break.id}, + status=status.HTTP_200_OK, ) def create(self, request, *args, **kwargs): @@ -752,13 +751,15 @@ def create(self, request, *args, **kwargs): if Break.objects.filter(person=request.user).count() >= 10: print(Break.objects.filter(person=request.user)) return Response( - {"detail": "You can only have up to 10 breaks."}, status=status.HTTP_400_BAD_REQUEST + {"detail": "You can only have up to 10 breaks."}, + status=status.HTTP_400_BAD_REQUEST, ) name = request.data.get("name") if not name: return Response( - {"detail": "Break name is required."}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Break name is required."}, + status=status.HTTP_400_BAD_REQUEST, ) location_string = request.data.get("location_string") try: @@ -802,7 +803,8 @@ def create(self, request, *args, **kwargs): set_meetings(new_break, meetings_with_codes) except Exception as e: return Response( - {"detail": "Error setting meetings: " + str(e)}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Error setting meetings: " + str(e)}, + status=status.HTTP_400_BAD_REQUEST, ) return Response( @@ -814,7 +816,8 @@ def destroy(self, request, *args, **kwargs): break_id = kwargs["pk"] if not break_id: return Response( - {"detail": "Break id is required for delete."}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Break id is required for delete."}, + status=status.HTTP_400_BAD_REQUEST, ) try: @@ -825,7 +828,8 @@ def destroy(self, request, *args, **kwargs): return Response({"detail": "Break not found."}, status=status.HTTP_404_NOT_FOUND) except Exception as e: return Response( - {"detail": "Error deleting break: " + str(e)}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Error deleting break: " + str(e)}, + status=status.HTTP_400_BAD_REQUEST, ) def get_queryset(self): @@ -880,8 +884,7 @@ def get(self, *args, **kwargs): day_mapping = {"M": "MO", "T": "TU", "W": "WE", "R": "TH", "F": "FR"} calendar = ICSCal(creator="Penn Labs") - calendar.extra.append(ContentLine( - name="X-WR-CALNAME", value=f"{schedule.name} Schedule")) + calendar.extra.append(ContentLine(name="X-WR-CALNAME", value=f"{schedule.name} Schedule")) for section in schedule.sections.all(): e = ICSEvent() @@ -908,10 +911,8 @@ def get(self, *args, **kwargs): start_datetime = "" end_datetime = "" else: - start_datetime = get_first_matching_date( - first_meeting.start_date, days) + " " - end_datetime = get_first_matching_date( - first_meeting.start_date, days) + " " + start_datetime = get_first_matching_date(first_meeting.start_date, days) + " " + end_datetime = get_first_matching_date(first_meeting.start_date, days) + " " if int(first_meeting.start) < 10: start_datetime += "0" @@ -921,11 +922,9 @@ def get(self, *args, **kwargs): start_datetime += start_time end_datetime += end_time - e.begin = arrow.get(start_datetime, "YYYY-MM-DD h:mm A", - tzinfo="America/New_York") + e.begin = arrow.get(start_datetime, "YYYY-MM-DD h:mm A", tzinfo="America/New_York") - e.end = arrow.get(end_datetime, "YYYY-MM-DD h:mm A", - tzinfo="America/New York") + e.end = arrow.get(end_datetime, "YYYY-MM-DD h:mm A", tzinfo="America/New York") location = None if hasattr(first_meeting, "room") and first_meeting.room: diff --git a/backend/review/util.py b/backend/review/util.py index 3947043da..127734f9d 100644 --- a/backend/review/util.py +++ b/backend/review/util.py @@ -417,7 +417,9 @@ def avg_and_recent_demand_plots(section_map, status_updates_map, bin_size=0.01): ( 1 if x["type"] == "status_update" - else 2 if x["type"] == "distribution_estimate_change" else 3 + else 2 + if x["type"] == "distribution_estimate_change" + else 3 ), ), ) diff --git a/backend/tests/alert/test_alert.py b/backend/tests/alert/test_alert.py index a936eda21..e786c6508 100644 --- a/backend/tests/alert/test_alert.py +++ b/backend/tests/alert/test_alert.py @@ -2955,7 +2955,6 @@ def test_last_notification_sent_at(self): class TestAlertMeetingString(TestCase): - def setUp(self): set_semester() user = User.objects.create_user(username="jacob", password="top_secret") diff --git a/backend/tests/courses/test_api.py b/backend/tests/courses/test_api.py index 082313dd7..5dafed262 100644 --- a/backend/tests/courses/test_api.py +++ b/backend/tests/courses/test_api.py @@ -536,219 +536,6 @@ def test_attribute_route(self): self.assertEqual({res["code"] for res in response.data}, {"EMCI", "WUFN"}) -class AttributeFilterTestCase(TestCase): - def setUp(self): - set_semester() - # Courses (4) - self.cis_120, _ = create_mock_data("CIS-120-001", TEST_SEMESTER) - self.mgmt_117, _ = create_mock_data("MGMT-117-001", TEST_SEMESTER) - self.econ_001, _ = create_mock_data("ECON-001-001", TEST_SEMESTER) - self.anth_001, _ = create_mock_data("ANTH-001-001", TEST_SEMESTER) - - # Attributes - self.wuom = Attribute.objects.create( - code="WUOM", description="Wharton OIDD Operation", school="WH" - ) - self.emci = Attribute.objects.create( - code="EMCI", description="SEAS CIS NonCIS Elective", school="SEAS" - ) - - # Attach courses to attributes - self.wuom.courses.add(self.mgmt_117) - self.wuom.courses.add(self.econ_001) - self.emci.courses.add(self.cis_120) - self.emci.courses.add(self.econ_001) - - self.all_codes = {"CIS-120", "MGMT-117", "ECON-001", "ANTH-001"} - - self.client = APIClient() - - def test_no_attributes(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": ""} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, self.all_codes) - - def test_single_attribute(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "WUOM"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, {"MGMT-117", "ECON-001"}) - - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "ECON-001"}) - - def test_multiple_overlapping_attributes(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "WUOM|EMCI"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, {"MGMT-117", "ECON-001", "CIS-120"}) - - def test_nonexistent_attribute(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "LLLL"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 0) - self.assertEqual(len(response.data), 0) - - def test_existent_and_nonexistent_attributes(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI|LLLL"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "ECON-001"}) - - def test_and(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI*WUOM"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, {"ECON-001"}) - - def test_and_nonexistent(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI*LLLL"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 0) - - def test_and_or(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"attributes": "(EMCI*WUOM)|EMCI"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "ECON-001"}) - - def test_not(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "~EMCI"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, {"MGMT-117", "ANTH-001"}) - - def test_not_nonexistent(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "~LLLL"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, self.all_codes) - - def test_and_not(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"attributes": "~EMCI*WUOM"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, {"MGMT-117"}) - - def test_and_or_not(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"attributes": "(EMCI*WUOM)|~EMCI"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, {"ECON-001", "MGMT-117", "ANTH-001"}) - - def test_and_or_nots(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"attributes": "(~EMCI*WUOM)|~WUOM"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "MGMT-117", "ANTH-001"}) - - def test_demorgan(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"attributes": "~EMCI*~WUOM"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual({res["id"] for res in response.data}, {"ANTH-001"}) - - def test_empty_parens(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "()"} - ) - self.assertEqual(response.status_code, 400) - - def test_partial_binary_op(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI|"} - ) - self.assertEqual(response.status_code, 400) - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "|EMCI"} - ) - self.assertEqual(response.status_code, 400) - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI*"} - ) - self.assertEqual(response.status_code, 400) - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "*EMCI"} - ) - self.assertEqual(response.status_code, 400) - - def test_partial_negation(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "~"} - ) - self.assertEqual(response.status_code, 400) - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI|~"} - ) - self.assertEqual(response.status_code, 400) - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI~"} - ) - self.assertEqual(response.status_code, 400) - - def test_unmatched_parens(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "(EMCI"} - ) - self.assertEqual(response.status_code, 400) - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI)"} - ) - self.assertEqual(response.status_code, 400) - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": ")EMCI("} - ) - self.assertEqual(response.status_code, 400) - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"attributes": "(EMCI*(WUOM|LLLL)"}, - ) - self.assertEqual(response.status_code, 400) - - def test_invalid_chars(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI,LLLL"} - ) - self.assertEqual(response.status_code, 400) - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI&LLLL"} - ) - self.assertEqual(response.status_code, 400) - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI^LLLL"} - ) - self.assertEqual(response.status_code, 400) - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"attributes": "EMCI+LLLL"} - ) - self.assertEqual(response.status_code, 400) - - class SectionListTestCase(TestCase): def setUp(self): set_semester() diff --git a/backend/tests/courses/util.py b/backend/tests/courses/util.py index f007cd216..05440a805 100644 --- a/backend/tests/courses/util.py +++ b/backend/tests/courses/util.py @@ -3,7 +3,7 @@ recompute_precomputed_fields, recompute_topics, ) -from courses.models import Instructor +from courses.models import Attribute, Instructor from courses.util import all_semesters, get_or_create_course_and_section, set_meetings from review.models import Review @@ -19,9 +19,16 @@ def fill_course_soft_state(): recompute_topics(min_semester=min(semesters), verbose=False) -def create_mock_data(code, semester, meeting_days="MWF", start=1100, end=1200): +def get_or_create_attribute(code, description=""): + return Attribute.objects.get_or_create(code=code, defaults={"description": description}) + + +def create_mock_data(code, semester, meeting_days="MWF", start=1100, end=1200, attributes=[]): course, section, _, _ = get_or_create_course_and_section(code, semester) course.description = "This is a fake class." + for attr in attributes: + attribute_obj, _ = get_or_create_attribute(attr) + attribute_obj.courses.add(course) course.save() section.credits = 1 section.status = "O" diff --git a/backend/tests/plan/test_api.py b/backend/tests/plan/test_filter.py similarity index 60% rename from backend/tests/plan/test_api.py rename to backend/tests/plan/test_filter.py index 8451ff86b..c152d2ee1 100644 --- a/backend/tests/plan/test_api.py +++ b/backend/tests/plan/test_filter.py @@ -1,4 +1,5 @@ -from django.contrib.auth.models import User +import json + from django.db.models.signals import post_save from django.test import TestCase from django.urls import reverse @@ -7,7 +8,7 @@ from alert.models import AddDropPeriod from courses.management.commands.recompute_soft_state import recompute_precomputed_fields -from courses.models import Instructor, PreNGSSRequirement, Section +from courses.models import Instructor, Section, User from courses.util import invalidate_current_semester_cache, set_meetings from plan.models import Schedule from review.models import Review @@ -28,7 +29,7 @@ def set_semester(): AddDropPeriod(semester=TEST_SEMESTER).save() -class CreditUnitFilterTestCase(TestCase): +class CuFilterTestCase(TestCase): def setUp(self): self.course, self.section = create_mock_data("CIS-120-001", TEST_SEMESTER) _, self.section2 = create_mock_data("CIS-120-201", TEST_SEMESTER) @@ -40,189 +41,77 @@ def setUp(self): set_semester() def test_include_course(self): - response = self.client.get(reverse("courses-search", args=["current"]), {"cu": "1.0"}) + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "cu", + "op": "is", + "value": ["1.0"], + } + ], + } + ), + content_type="application/json", + ) self.assertEqual(200, response.status_code) self.assertEqual(1, len(response.data)) def test_include_multiple(self): - response = self.client.get(reverse("courses-search", args=["current"]), {"cu": "0.5,1.0"}) - self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) - - def test_exclude_course(self): - response = self.client.get(reverse("courses-search", args=["current"]), {"cu": ".5,1.5"}) - self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - - -class PreNGSSRequirementFilterTestCase(TestCase): - def setUp(self): - self.course, self.section = create_mock_data("CIS-120-001", TEST_SEMESTER) - self.math, self.math1 = create_mock_data("MATH-114-001", TEST_SEMESTER) - self.different_math, self.different_math1 = create_mock_data( - "MATH-116-001", ("2019A" if TEST_SEMESTER == "2019C" else "2019C") - ) - self.req = PreNGSSRequirement(semester=TEST_SEMESTER, code="REQ", school="SAS") - self.req.save() - self.req.courses.add(self.math) - self.client = APIClient() - set_semester() - - def test_return_all_courses(self): - response = self.client.get(reverse("courses-search", args=["current"])) - self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data)) - - def test_filter_for_req(self): - response = self.client.get( - reverse("courses-search", args=["current"]), - {"pre_ngss_requirements": "REQ@SAS"}, + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "op": "is_any_of", + "field": "cu", + "value": ["0.5", "1.0"], + } + ], + } + ), + content_type="application/json", ) self.assertEqual(200, response.status_code) self.assertEqual(1, len(response.data)) - self.assertEqual("MATH-114", response.data[0]["id"]) - def test_filter_for_req_dif_sem(self): - req2 = PreNGSSRequirement( - semester=("2019A" if TEST_SEMESTER == "2019C" else "2019C"), - code="REQ", - school="SAS", - ) - req2.save() - req2.courses.add(self.different_math) - response = self.client.get( - reverse("courses-search", args=["current"]), - {"pre_ngss_requirements": "REQ@SAS"}, + def test_exclude_course(self): + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "op": "is_none_of", + "field": "cu", + "value": ["0.0", "1.0"], + } + ], + } + ), + content_type="application/json", ) self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) - self.assertEqual("MATH-114", response.data[0]["id"]) - self.assertEqual(TEST_SEMESTER, response.data[0]["semester"]) - - def test_multi_req(self): - course3, section3 = create_mock_data("CIS-240-001", TEST_SEMESTER) - req2 = PreNGSSRequirement(semester=TEST_SEMESTER, code="REQ2", school="SEAS") - req2.save() - req2.courses.add(course3) - - response = self.client.get( - reverse("courses-search", args=["current"]), - {"pre_ngss_requirements": "REQ@SAS,REQ2@SEAS"}, - ) self.assertEqual(0, len(response.data)) - def test_double_count_req(self): - req2 = PreNGSSRequirement(semester=TEST_SEMESTER, code="REQ2", school="SEAS") - req2.save() - req2.courses.add(self.math) - response = self.client.get( - reverse("courses-search", args=["current"]), - {"pre_ngss_requirements": "REQ@SAS,REQ2@SEAS"}, - ) - self.assertEqual(1, len(response.data)) - self.assertEqual("MATH-114", response.data[0]["id"]) - -class IsOpenFilterTestCase(TestCase): +class NumericFilterTestCase(TestCase): def setUp(self): - _, self.cis_160_001 = create_mock_data( - code="CIS-160-001", semester=TEST_SEMESTER, meeting_days="TR" - ) - - _, self.cis_160_201 = create_mock_data( - code="CIS-160-201", semester=TEST_SEMESTER, meeting_days="M" - ) - self.cis_160_201.activity = "REC" - self.cis_160_201.save() - - _, self.cis_160_202 = create_mock_data( - code="CIS-160-202", semester=TEST_SEMESTER, meeting_days="W" - ) - self.cis_160_202.activity = "REC" - self.cis_160_202.save() - - def save_all(): - for section in [self.cis_160_001, self.cis_160_201, self.cis_160_202]: - section.save() - - self.save_all = save_all - self.all_codes = {"CIS-160"} - self.non_open_statuses = [ - status[0] for status in Section.STATUS_CHOICES if status[0] not in ["O"] - ] - - recompute_precomputed_fields() - - self.client = APIClient() - set_semester() - - def test_lec_open_all_rec_open(self): - response = self.client.get(reverse("courses-search", args=[TEST_SEMESTER]), {"is-open": ""}) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1) - self.assertEqual({res["id"] for res in response.data}, self.all_codes) - - def test_lec_open_one_rec_not_open(self): - for status in self.non_open_statuses: - self.cis_160_202.status = status - self.save_all() - - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"is_open": ""} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1) - self.assertEqual({res["id"] for res in response.data}, self.all_codes) - - def test_lec_open_all_rec_not_open(self): - for status in self.non_open_statuses: - self.cis_160_202.status = status - self.cis_160_201.status = status - self.save_all() - - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"is_open": ""} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 0) - self.assertEqual({res["id"] for res in response.data}, set()) - - def test_rec_open_lec_not_open(self): - for status in self.non_open_statuses: - self.cis_160_001.status = status - self.save_all() - - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"is_open": ""} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 0) - self.assertEqual({res["id"] for res in response.data}, set()) - - def test_lec_not_open_all_rec_not_open(self): - for status in self.non_open_statuses: - self.cis_160_202.status = status - self.cis_160_201.status = status - self.cis_160_001.status = status - self.save_all() - - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"is_open": ""} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 0) - self.assertEqual({res["id"] for res in response.data}, set()) - - -class CourseReviewAverageTestCase(TestCase): - def setUp(self): - self.course, self.section = create_mock_data("CIS-120-001", TEST_SEMESTER) - _, self.section2 = create_mock_data("CIS-120-002", TEST_SEMESTER) - self.instructor = Instructor(name="Person1") - self.instructor.save() + self.course1, self.section1 = create_mock_data("CIS-120-001", TEST_SEMESTER) + self.instructor1 = Instructor(name="Person1") + self.instructor1.save() self.rev1 = Review( - section=create_mock_data("CIS-120-003", "2005C")[1], - instructor=self.instructor, + section=create_mock_data("CIS-120-002", "2005C")[1], + instructor=self.instructor1, responses=100, ) self.rev1.save() @@ -233,14 +122,16 @@ def setUp(self): "difficulty": 4, } ) + self.section1.instructors.add(self.instructor1) + + self.course2, self.section2 = create_mock_data("CIS-160-001", TEST_SEMESTER) self.instructor2 = Instructor(name="Person2") self.instructor2.save() self.rev2 = Review( - section=create_mock_data("CIS-120-002", "2015A")[1], + section=create_mock_data("CIS-160-002", "2005C")[1], instructor=self.instructor2, responses=100, ) - self.rev2.instructor = self.instructor2 self.rev2.save() self.rev2.set_averages( { @@ -249,63 +140,106 @@ def setUp(self): "difficulty": 2, } ) - - self.section.instructors.add(self.instructor) self.section2.instructors.add(self.instructor2) + self.client = APIClient() set_semester() - def test_course_average(self): - response = self.client.get(reverse("courses-detail", args=["current", "CIS-120"])) + def test_lt_exclude(self): + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "numeric", + "op": "lt", + "field": "difficulty", + "value": 2.0, + } + ], + } + ), + content_type="application/json", + ) self.assertEqual(200, response.status_code) - self.assertEqual(3, response.data["course_quality"]) - self.assertEqual(3, response.data["instructor_quality"]) - self.assertEqual(3, response.data["difficulty"]) + self.assertEqual(0, len(response.data)) - def test_section_reviews(self): - response = self.client.get(reverse("courses-detail", args=["current", "CIS-120"])) - self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data["sections"])) - - def test_section_no_duplicates(self): - instructor3 = Instructor(name="person3") - instructor3.save() - rev3 = Review( - section=self.rev2.section, - instructor=instructor3, - responses=100, - ) - rev3.save() - rev3.set_averages( - { - "course_quality": 1, - "instructor_quality": 1, - "difficulty": 1, - } + def test_gte_include(self): + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "numeric", + "op": "gte", + "field": "course_quality", + "value": 2.0, + } + ], + } + ), + content_type="application/json", ) - self.section2.instructors.add(instructor3) - response = self.client.get(reverse("courses-detail", args=["current", "CIS-120"])) self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data["sections"])) - self.assertEqual( - 1.5, - response.data["sections"][1]["course_quality"], - response.data["sections"][1], - ) + self.assertEqual(2, len(response.data)) - def test_filter_courses_by_review_included(self): - response = self.client.get( - reverse("courses-search", args=["current"]), {"difficulty": "2.5-3.5"} + def test_and(self): + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "numeric", + "op": "gt", + "field": "course_quality", + "value": 1.0, + }, + { + "type": "numeric", + "op": "lt", + "field": "course_quality", + "value": 2.1, + }, + ], + } + ), + content_type="application/json", ) self.assertEqual(200, response.status_code) self.assertEqual(1, len(response.data)) - def test_filter_courses_by_review_excluded(self): - response = self.client.get( - reverse("courses-search", args=["current"]), {"difficulty": "0-2"} + def test_or(self): + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "OR", + "children": [ + { + "type": "numeric", + "op": "gt", + "field": "course_quality", + "value": 3.0, + }, + { + "type": "numeric", + "op": "lt", + "field": "difficulty", + "value": 3.0, + }, + ], + } + ), + content_type="application/json", ) self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) + self.assertEqual(2, len(response.data)) class DayFilterTestCase(TestCase): @@ -367,74 +301,220 @@ def setUp(self): set_semester() def test_only_async(self): - response = self.client.get(reverse("courses-search", args=[TEST_SEMESTER]), {"days": ""}) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1) - self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) # only async + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "days", + "op": "is_any_of", + "value": [], + } + ], + } + ), + content_type="application/json", + ) + self.assertEqual(200, response.status_code) + self.assertEqual(1, len(response.data)) + self.assertEqual({"CIS-262"}, {res["id"] for res in response.data}) # only async def test_all_days(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"days": "MTWRFSU"} + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "days", + "op": "is_any_of", + "value": ["M", "T", "W", "R", "F", "S", "U"], + } + ], + } + ), + content_type="application/json", ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), len(self.all_codes)) self.assertEqual({res["id"] for res in response.data}, self.all_codes) def test_illegal_characters(self): - response = self.client.get(reverse("courses-search", args=[TEST_SEMESTER]), {"days": "M-R"}) + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "days", + "op": "is_any_of", + "value": ["T", "R", "X", 2], + } + ], + } + ), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), len(self.all_codes)) - self.assertEqual({res["id"] for res in response.data}, self.all_codes) + self.assertEqual(len(response.data), 2) + self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "CIS-262"}) def test_lec_no_rec(self): - response = self.client.get(reverse("courses-search", args=[TEST_SEMESTER]), {"days": "TR"}) + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "days", + "op": "is_any_of", + "value": ["T", "R"], + } + ], + } + ), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 2) self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "CIS-262"}) def test_rec_no_lec(self): - response = self.client.get(reverse("courses-search", args=[TEST_SEMESTER]), {"days": "MW"}) + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "days", + "op": "is_any_of", + "value": ["M", "W"], + } + ], + } + ), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) def test_lec_and_rec(self): - response = self.client.get(reverse("courses-search", args=[TEST_SEMESTER]), {"days": "TWR"}) + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "days", + "op": "is_any_of", + "value": ["T", "W", "R"], + } + ], + } + ), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 3) self.assertEqual({res["id"] for res in response.data}, {"CIS-160", "CIS-120", "CIS-262"}) def test_partial_match(self): - response = self.client.get( + response = self.client.post( reverse("courses-search", args=[TEST_SEMESTER]), - {"days": "T"}, + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "days", + "op": "is", + "value": ["T"], + } + ], + } + ), + content_type="application/json", ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) def test_contains_rec_no_sec(self): - response = self.client.get( + response = self.client.post( reverse("courses-search", args=[TEST_SEMESTER]), - {"days": "W"}, + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "days", + "op": "is", + "value": ["W"], + } + ], + } + ), + content_type="application/json", ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) def test_partial_multi_meeting_match(self): - response = self.client.get( + response = self.client.post( reverse("courses-search", args=[TEST_SEMESTER]), - {"days": "MT"}, + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "days", + "op": "is_any_of", + "value": ["M", "W"], + } + ], + } + ), + content_type="application/json", ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) def test_full_multi_meeting_match(self): - response = self.client.get( + response = self.client.post( reverse("courses-search", args=[TEST_SEMESTER]), - {"days": "MTWR"}, + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "days", + "op": "is_any_of", + "value": ["M", "T", "W", "R"], + } + ], + } + ), + content_type="application/json", ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 4) @@ -443,6 +523,129 @@ def test_full_multi_meeting_match(self): {"CIS-120", "CIS-121", "CIS-160", "CIS-262"}, ) + def test_none_of(self): + response = self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "days", + "op": "is_none_of", + "value": ["M", "W", "F"], + } + ], + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "CIS-262"}) + + +class IsOpenFilterTestCase(TestCase): + def setUp(self): + _, self.cis_160_001 = create_mock_data( + code="CIS-160-001", semester=TEST_SEMESTER, meeting_days="TR" + ) + + _, self.cis_160_201 = create_mock_data( + code="CIS-160-201", semester=TEST_SEMESTER, meeting_days="M" + ) + self.cis_160_201.activity = "REC" + self.cis_160_201.save() + + _, self.cis_160_202 = create_mock_data( + code="CIS-160-202", semester=TEST_SEMESTER, meeting_days="W" + ) + self.cis_160_202.activity = "REC" + self.cis_160_202.save() + + def save_all(): + for section in [self.cis_160_001, self.cis_160_201, self.cis_160_202]: + section.save() + + self.save_all = save_all + self.all_codes = {"CIS-160"} + self.non_open_statuses = [ + status[0] for status in Section.STATUS_CHOICES if status[0] not in ["O"] + ] + + recompute_precomputed_fields() + + self.client = APIClient() + set_semester() + + def _post_is_open(self): + return self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "boolean", + "field": "is_open", + "value": True, + } + ], + } + ), + content_type="application/json", + ) + + def test_lec_open_all_rec_open(self): + response = self._post_is_open() + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual({res["id"] for res in response.data}, self.all_codes) + + def test_lec_open_one_rec_not_open(self): + for status in self.non_open_statuses: + self.cis_160_202.status = status + self.save_all() + + response = self._post_is_open() + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual({res["id"] for res in response.data}, self.all_codes) + + def test_lec_open_all_rec_not_open(self): + for status in self.non_open_statuses: + self.cis_160_202.status = status + self.cis_160_201.status = status + self.save_all() + + response = self._post_is_open() + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 0) + self.assertEqual({res["id"] for res in response.data}, set()) + + def test_rec_open_lec_not_open(self): + for status in self.non_open_statuses: + self.cis_160_001.status = status + self.save_all() + + response = self._post_is_open() + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 0) + self.assertEqual({res["id"] for res in response.data}, set()) + + def test_lec_not_open_all_rec_not_open(self): + for status in self.non_open_statuses: + self.cis_160_202.status = status + self.cis_160_201.status = status + self.cis_160_001.status = status + self.save_all() + + response = self._post_is_open() + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 0) + self.assertEqual({res["id"] for res in response.data}, set()) + class TimeFilterTestCase(TestCase): def setUp(self): @@ -502,256 +705,102 @@ def setUp(self): self.client = APIClient() set_semester() - def test_empty_time_all(self): - response = self.client.get(reverse("courses-search", args=[TEST_SEMESTER]), {"time": ""}) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), len(self.all_codes)) - self.assertEqual({res["id"] for res in response.data}, self.all_codes) - - def test_whole_day(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"time": "0.0-23.59"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), len(self.all_codes)) - self.assertEqual({res["id"] for res in response.data}, self.all_codes) - - def test_no_dashes(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"time": "11.00"} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), len(self.all_codes)) - self.assertEqual({res["id"] for res in response.data}, self.all_codes) + def _post_time(self, start_time, end_time): + children = [] + if start_time is not None: + children.append( + { + "type": "numeric", + "field": "start_time", + "op": "gte", + "value": start_time, + } + ) + if end_time is not None: + children.append( + { + "type": "numeric", + "field": "end_time", + "op": "lte", + "value": end_time, + } + ) - def test_too_many_dashes(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"time": "-1.00-3.00"} + return self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": children, + } + ), + content_type="application/json", ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), len(self.all_codes)) - self.assertEqual({res["id"] for res in response.data}, self.all_codes) - def test_non_numeric(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"time": "11.00am-3.00pm"} - ) + def test_whole_day(self): + response = self._post_time(0, 23.59) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), len(self.all_codes)) self.assertEqual({res["id"] for res in response.data}, self.all_codes) def test_crossover_times(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"time": "15.0-2.0"} - ) + response = self._post_time(15.0, 2.0) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) # only async def test_start_end_same(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"time": "5.5-5.5"} - ) + + response = self._post_time(5.5, 5.5) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) # only async def test_lec_no_rec(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"time": "4.59-6.30"} - ) + response = self._post_time(4.59, 6.30) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) # only async def test_one_match(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"time": "11.30-13.30"} - ) + response = self._post_time(11.30, 13.30) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 2) self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "CIS-262"}) def test_lec_and_rec(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), {"time": "5.0-12.0"} - ) + response = self._post_time(5.0, 12.0) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 3) self.assertEqual({res["id"] for res in response.data}, {"CIS-160", "CIS-120", "CIS-262"}) def test_contains_parts_of_two_sec(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"time": "11.30-13.0"}, - ) + response = self._post_time(11.30, 13.0) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) def test_contains_rec_no_sec(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"time": "11.30-16"}, - ) + response = self._post_time(11.30, 16.0) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 2) self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "CIS-262"}) def test_unbounded_right(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"time": "11.30-"}, - ) + response = self._post_time(11.30, None) + self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 2) self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "CIS-262"}) def test_unbounded_left(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"time": "-12.00"}, - ) + response = self._post_time(None, 12.0) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 3) self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "CIS-160", "CIS-262"}) def test_multi_meeting_match(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"time": "9.00-15.00"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 3) - self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "CIS-121", "CIS-262"}) - - -class DayTimeFilterTestCase(TestCase): - def setUp(self): - _, self.cis_120_001 = create_mock_data( - "CIS-120-001", TEST_SEMESTER - ) # time 11.0-12.0, days MWF - - _, self.cis_120_002 = create_mock_data( - code="CIS-120-002", - semester=TEST_SEMESTER, - start=1200, - end=1330, - meeting_days="TR", - ) - - _, self.cis_160_001 = create_mock_data( - code="CIS-160-001", - semester=TEST_SEMESTER, - start=500, - end=630, - meeting_days="TR", - ) - - _, self.cis_160_201 = create_mock_data( - code="CIS-160-201", - semester=TEST_SEMESTER, - start=1100, - end=1200, - meeting_days="M", - ) - self.cis_160_201.activity = "REC" - self.cis_160_201.save() - - _, self.cis_160_202 = create_mock_data( - code="CIS-160-202", - semester=TEST_SEMESTER, - start=1400, - end=1500, - meeting_days="W", - ) - self.cis_160_202.activity = "REC" - self.cis_160_202.save() - - _, self.cis_121_001 = create_mock_data(code="CIS-121-001", semester=TEST_SEMESTER) - set_meetings( - self.cis_121_001, - [ - { - "building_code": "LLAB", - "room_code": "10", - "days": "MT", - "begin_time_24": 900, - "begin_time": "9:00 AM", - "end_time_24": 1000, - "end_time": "10:00 AM", - }, - { - "building_code": "LLAB", - "room_code": "10", - "days": "WR", - "begin_time_24": 1330, - "begin_time": "1:30 PM", - "end_time_24": 1430, - "end_time": "2:30 PM", - }, - ], - ) - - _, self.cis_262_001 = create_mock_async_class(code="CIS-262-001", semester=TEST_SEMESTER) - - recompute_precomputed_fields() - - self.all_codes = {"CIS-120", "CIS-160", "CIS-121", "CIS-262"} - - self.client = APIClient() - set_semester() - - def test_all_match(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"time": "0-23.59", "days": "MTWRFSU"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), len(self.all_codes)) - self.assertEqual({res["id"] for res in response.data}, self.all_codes) - - def test_days_match_not_time(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"time": "1.00-2.00", "days": "MTWRFSU"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1) - self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) - - def test_time_matches_not_days(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"time": "1.00-", "days": "F"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1) - self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) - - def test_days_time_partial_match(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"time": "12.0-15.0", "days": "TWR"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 2) - self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "CIS-262"}) - - def test_multi_meeting_partial_match(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"time": "9.00-10.00", "days": "MTWR"}, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1) - self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) - - def test_multi_meeting_full_match(self): - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"time": "9.00-14.30", "days": "MTWR"}, - ) + response = self._post_time(9.0, 15.0) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 3) self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "CIS-121", "CIS-262"}) @@ -870,11 +919,26 @@ def setUp(self): self.client = APIClient() set_semester() - def test_not_authenticated(self): - response = self.client.get( + def _post_fit_schedule(self, schedule_id): + return self.client.post( reverse("courses-search", args=[TEST_SEMESTER]), - {"schedule-fit": str(self.only_262_available_schedule.id)}, + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "value", + "field": "fit_schedule", + "value": str(schedule_id), + } + ], + } + ), + content_type="application/json", ) + + def test_not_authenticated(self): + response = self._post_fit_schedule(self.only_262_available_schedule.id) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), len(self.all_codes)) self.assertEqual({res["id"] for res in response.data}, self.all_codes) @@ -885,60 +949,79 @@ def test_different_authenticated(self): ) client2 = APIClient() client2.login(username="charley", password="top_secret") - response = client2.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"schedule-fit": str(self.only_262_available_schedule.id)}, - ) + response = self._post_fit_schedule(self.only_262_available_schedule.id) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), len(self.all_codes)) self.assertEqual({res["id"] for res in response.data}, self.all_codes) def test_invalid_schedule(self): self.client.login(username="jacob", password="top_secret") - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"schedule-fit": "invalid"}, - ) + response = self._post_fit_schedule(-1) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), len(self.all_codes)) self.assertEqual({res["id"] for res in response.data}, self.all_codes) def test_empty_schedule(self): self.client.login(username="jacob", password="top_secret") - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"schedule-fit": str(self.empty_schedule.id)}, - ) + response = self._post_fit_schedule(self.empty_schedule.id) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), len(self.all_codes)) self.assertEqual({res["id"] for res in response.data}, self.all_codes) def test_all_available_schedule(self): self.client.login(username="jacob", password="top_secret") - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"schedule-fit": str(self.all_available_schedule.id)}, - ) + response = self._post_fit_schedule(self.all_available_schedule.id) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), len(self.all_codes)) self.assertEqual({res["id"] for res in response.data}, self.all_codes) def test_only_120_262_available_schedule(self): self.client.login(username="jacob", password="top_secret") - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"schedule-fit": str(self.only_120_262_available_schedule.id)}, - ) + response = self._post_fit_schedule(self.only_120_262_available_schedule.id) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 2) self.assertEqual({res["id"] for res in response.data}, {"CIS-120", "CIS-262"}) def test_only_262_available_schedule(self): self.client.login(username="jacob", password="top_secret") - response = self.client.get( - reverse("courses-search", args=[TEST_SEMESTER]), - {"schedule-fit": str(self.only_262_available_schedule.id)}, - ) + response = self._post_fit_schedule(self.only_262_available_schedule.id) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) self.assertEqual({res["id"] for res in response.data}, {"CIS-262"}) + + +class AttributeFilterTestCase(TestCase): + def setUp(self): + self.course1, self.section1 = create_mock_data( + "CIS-120-001", TEST_SEMESTER, attributes=["EUNG", "NURS"] + ) + self.course2, self.section2 = create_mock_data( + "CIS-160-001", TEST_SEMESTER, attributes=["EUNG", "EUMA", "WUNM"] + ) + + self.client = APIClient() + set_semester() + + def _post_attribute_filter(self, op, values): + return self.client.post( + reverse("courses-search", args=[TEST_SEMESTER]), + data=json.dumps( + { + "op": "AND", + "children": [ + { + "type": "enum", + "field": "attribute", + "op": op, + "value": values, + } + ], + } + ), + content_type="application/json", + ) + + def test_attribute_included(self): + response = self._post_attribute_filter("is_any_of", ["EUNG", "EUMA"]) + self.assertEqual(200, response.status_code) + self.assertEqual(2, len(response.data)) diff --git a/frontend/plan/actions/index.js b/frontend/plan/actions/index.js index d6e91d7a6..0e98b0cf8 100644 --- a/frontend/plan/actions/index.js +++ b/frontend/plan/actions/index.js @@ -5,6 +5,7 @@ import { parsePhoneNumberFromString } from "libphonenumber-js"; import getCsrf from "../components/csrf"; import { MIN_FETCH_INTERVAL } from "../constants/sync_constants"; import { PATH_REGISTRATION_SCHEDULE_NAME } from "../constants/constants"; +import { buildCourseSearchRequest } from "../util.ts"; export const UPDATE_SEARCH = "UPDATE_SEARCH"; export const UPDATE_SEARCH_REQUEST = "UPDATE_SEARCH_REQUEST"; @@ -34,6 +35,7 @@ export const UPDATE_CHECKBOX_FILTER = "UPDATE_CHECKBOX_FILTER"; export const UPDATE_BUTTON_FILTER = "UPDATE_BUTTON_FILTER"; export const CLEAR_FILTER = "CLEAR_FILTER"; export const CLEAR_ALL = "CLEAR_ALL"; +export const UPDATE_SEARCH_FILTER = "UPDATE_SEARCH_FILTER"; export const SECTION_INFO_SEARCH_ERROR = "SECTION_INFO_SEARCH_ERROR"; export const SECTION_INFO_SEARCH_LOADING = "SECTION_INFO_SEARCH_LOADING"; @@ -250,144 +252,50 @@ export const loadRequirements = () => (dispatch) => } ); -function buildCourseSearchUrl(filterData) { - let queryString = `/base/current/search/courses/?search=${filterData.searchString}`; - - // Requirements filter - const reqs = []; - if (filterData.selectedReq) { - for (const key of Object.keys(filterData.selectedReq)) { - if (filterData.selectedReq[key]) { - reqs.push(key); - } - } - - if (reqs.length > 0) { - queryString += `&requirements=${reqs[0]}`; - for (let i = 1; i < reqs.length; i += 1) { - queryString += `,${reqs[i]}`; - } - } - } - - // Range filters - const filterFields = [ - "difficulty", - "course_quality", - "instructor_quality", - "time", - ]; - const defaultFilters = [ - [0, 4], - [0, 4], - [0, 4], - [1.5, 17], - ]; - const decimalToTime = (t) => { - const hour = Math.floor(t); - const mins = parseFloat(((t % 1) * 0.6).toFixed(2)); - return Math.min(23.59, hour + mins); +export function fetchCourseSearch(filterData) { + return (dispatch) => { + dispatch(updateSearchRequest()); + debouncedAdvancedCourseSearch( + dispatch, + buildCourseSearchRequest(filterData) + ) + // debouncedCourseSearch(dispatch, filterData) + .then((res) => res.json()) + .then((res) => res.filter((course) => course.num_sections > 0)) + .then((res) => + batch(() => { + dispatch(updateScrollPos()); + dispatch(updateSearch(res)); + if (res.length === 1) + dispatch(fetchCourseDetails(res[0].id)); + }) + ) + .catch((error) => dispatch(courseSearchError(error))); }; - for (let i = 0; i < filterFields.length; i += 1) { - if ( - filterData[filterFields[i]] && - JSON.stringify(filterData[filterFields[i]]) !== - JSON.stringify(defaultFilters[i]) - ) { - const filterRange = filterData[filterFields[i]]; - if (filterFields[i] === "time") { - const start = decimalToTime(24 - filterRange[1]); - const end = decimalToTime(24 - filterRange[0]); - queryString += `&${filterFields[i]}=${ - start === 7 ? "" : start - }-${end === 10.3 ? "" : end}`; - } else { - queryString += `&${filterFields[i]}=${filterRange[0]}-${filterRange[1]}`; - } - } - } - - // Checkbox Filters - const checkboxFields = ["cu", "activity", "days"]; - const checkboxDefaultFields = [ - { - 0.5: 0, - 1: 0, - 1.5: 0, - }, - { - LAB: 0, - REC: 0, - SEM: 0, - STU: 0, - }, - { - M: 1, - T: 1, - W: 1, - R: 1, - F: 1, - S: 1, - U: 1, - }, - ]; - for (let i = 0; i < checkboxFields.length; i += 1) { - if ( - filterData[checkboxFields[i]] && - JSON.stringify(filterData[checkboxFields[i]]) !== - JSON.stringify(checkboxDefaultFields[i]) - ) { - const applied = []; - Object.keys(filterData[checkboxFields[i]]).forEach((item) => { - // eslint-disable-line - if (filterData[checkboxFields[i]][item]) { - applied.push(item); - } - }); - if (applied.length > 0) { - if (checkboxFields[i] === "days") { - queryString += - applied.length < 7 ? `&days=${applied.join("")}` : ""; - } else { - queryString += `&${checkboxFields[i]}=${applied[0]}`; - for (let j = 1; j < applied.length; j += 1) { - queryString += `,${applied[j]}`; - } - } - } - } - } - - // toggle button filters - const buttonFields = ["schedule-fit", "is_open"]; - const buttonDefaultFields = [-1, 0]; - - for (let i = 0; i < buttonFields.length; i += 1) { - if ( - filterData[buttonFields[i]] && - JSON.stringify(filterData[buttonFields[i]]) !== - JSON.stringify(buttonDefaultFields[i]) - ) { - // get each filter's value - const applied = filterData[buttonFields[i]]; - if (applied !== undefined && applied !== "" && applied !== null) { - queryString += `&${buttonFields[i]}=${applied}`; - } - } - } - - return queryString; } -const courseSearch = (_, filterData) => - doAPIRequest(buildCourseSearchUrl(filterData)); +const advancedCourseSearch = (_, searchData) => + doAPIRequest(`/base/current/search/courses/?search=${searchData.query}`, { + method: "POST", + credentials: "include", + mode: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-CSRFToken": getCsrf(), + }, + body: JSON.stringify(searchData.filters), + }); -const debouncedCourseSearch = AwesomeDebouncePromise(courseSearch, 500); +const debouncedAdvancedCourseSearch = AwesomeDebouncePromise( + advancedCourseSearch, + 500 +); -export function fetchCourseSearch(filterData) { +export function fetchAdvancedCourseSearch(searchData) { return (dispatch) => { dispatch(updateSearchRequest()); - debouncedCourseSearch(dispatch, filterData) + debouncedAdvancedCourseSearch(dispatch, searchData) .then((res) => res.json()) .then((res) => res.filter((course) => course.num_sections > 0)) .then((res) => @@ -473,6 +381,13 @@ export function clearFilter(propertyName) { }; } +export function updateSearchFilter(path, filters) { + return { + type: UPDATE_SEARCH_FILTER, + filters, + }; +} + export const deletionAttempted = (scheduleName) => ({ type: DELETION_ATTEMPTED, scheduleName, diff --git a/frontend/plan/reducers/index.js b/frontend/plan/reducers/index.js index e881ac2fe..6c025f757 100644 --- a/frontend/plan/reducers/index.js +++ b/frontend/plan/reducers/index.js @@ -7,6 +7,7 @@ import { login } from "./login"; import { friendships } from "./friendships"; import { alerts } from "./alerts"; import { breaks } from "./breaks"; +import { search } from "./search"; const coursePlanApp = combineReducers({ schedule, @@ -17,6 +18,7 @@ const coursePlanApp = combineReducers({ friendships, alerts, breaks, + search, }); export default coursePlanApp; diff --git a/frontend/plan/reducers/schedule.js b/frontend/plan/reducers/schedule.js index 5566e431d..4eb7c3377 100644 --- a/frontend/plan/reducers/schedule.js +++ b/frontend/plan/reducers/schedule.js @@ -490,6 +490,7 @@ export const schedule = (state = initialState, action) => { ), }; } + return state; case TOGGLE_BREAK: if (!state.readOnly) { const oldBreakSections = @@ -526,7 +527,6 @@ export const schedule = (state = initialState, action) => { } showToast("Cannot remove breaks from a friend's schedule!", true); return { ...state }; - case ADD_CART_ITEM: return { ...state, diff --git a/frontend/plan/reducers/search.ts b/frontend/plan/reducers/search.ts new file mode 100644 index 000000000..4f2399ea0 --- /dev/null +++ b/frontend/plan/reducers/search.ts @@ -0,0 +1,29 @@ +import { UPDATE_SEARCH_FILTER, CLEAR_ALL, UPDATE_SEARCH_TEXT, CLEAR_FILTER } from "../actions"; +import { AdvancedSearchData } from "../types"; + +export const initialState = { + query: "", + filters: { + op: "AND", + children: [], + } +} as AdvancedSearchData; + +export const search = (state = initialState, action: any): AdvancedSearchData => { + switch (action.type) { + case UPDATE_SEARCH_TEXT: + return { + ...state, + query: action.s, + }; + case UPDATE_SEARCH_FILTER: + return { + ...state, + filters: action.filters, + } + case CLEAR_ALL: + return initialState; + default: + return state; + } +} \ No newline at end of file diff --git a/frontend/plan/types.ts b/frontend/plan/types.ts index 74ada5076..32d21bed9 100644 --- a/frontend/plan/types.ts +++ b/frontend/plan/types.ts @@ -246,21 +246,63 @@ export type FilterType = } | number; - export interface FriendshipState { - activeFriend: User; - activeFriendSchedule: { found: boolean; sections: Section[], breaks: Break[] }; - acceptedFriends: User[]; - requestsReceived: Friendship[]; - requestsSent: Friendship[]; - } +export type AdvancedSearchEnum = { + type: "enum"; + field: string; + op: "is" | "is_not" | "is_any_of" | "is_none_of"; + value: string[]; +} - export interface ColorsMap { - [key: string]: Color - } +export type AdvancedSearchNumeric = { + type: "numeric"; + field: string; + op: "lt" | "lte" | "gt" | "gte" | "eq" | "neq"; + value: number; +} - export type Location = { - lat: number; - lng: number; - color?: string; +export type AdvancedSearchBoolean = { + type: "boolean"; + field: string; + value: boolean; +} + +export type AdvancedSearchValue = { + type: "value"; + field: string; + value: number; +} + +export type AdvancedSearchGroup = { + type: "group"; + op: "AND" | "OR"; + children: AdvancedSearchCondition[]; +} + +export type AdvancedSearchCondition = AdvancedSearchEnum | AdvancedSearchNumeric | AdvancedSearchBoolean | AdvancedSearchValue; + +export type AdvancedSearchData = { + query: string; + filters: { + op: "AND" | "OR"; + children: (AdvancedSearchCondition | AdvancedSearchGroup)[]; } + +} + +export interface FriendshipState { + activeFriend: User; + activeFriendSchedule: { found: boolean; sections: Section[], breaks: Break[] }; + acceptedFriends: User[]; + requestsReceived: Friendship[]; + requestsSent: Friendship[]; +} + +export interface ColorsMap { + [key: string]: Color +} +export type Location = { + lat: number; + lng: number; + color?: string; +} diff --git a/frontend/plan/util.ts b/frontend/plan/util.ts new file mode 100644 index 000000000..beb8aaf32 --- /dev/null +++ b/frontend/plan/util.ts @@ -0,0 +1,92 @@ +import { AdvancedSearchBoolean, AdvancedSearchData, AdvancedSearchEnum, AdvancedSearchNumeric, AdvancedSearchValue, FilterData } from "./types"; + +function decimalToTimeString(decimalTime: number): number { + const hour = Math.floor(decimalTime); + const mins = parseFloat(((decimalTime % 1) * 0.6).toFixed(2)); + return Math.min(23.59, hour + mins); +} + +export function buildCourseSearchRequest(filterData: FilterData): AdvancedSearchData { + const children = [] + + const numerics = ["difficulty", "course_quality", "instructor_quality"]; + for (const field of numerics) { + const [lb, ub] = filterData[field as keyof FilterData] as [number, number]; + if (lb !== 0) { + children.push({ + type: "numeric", + field: field, + op: "gte", + value: lb, + } as AdvancedSearchNumeric); + } + + if (ub !== 4) { + children.push({ + type: "numeric", + field: field, + op: "lte", + value: ub, + } as AdvancedSearchNumeric); + } + } + + const startTime = decimalToTimeString(24 - filterData.time[1]); + const endTime = decimalToTimeString(24 - filterData.time[0]); + if (startTime !== 7) { + children.push({ + type: "numeric", + field: "start_time", + op: "gte", + value: startTime, + } as AdvancedSearchNumeric); + } + if (endTime !== 22.3) { + children.push({ + type: "numeric", + field: "end_time", + op: "lte", + value: endTime, + } as AdvancedSearchNumeric); + } + + const enums = [["cu", 3], ["activity", 4], ["days", 7]] as [keyof FilterData, number][]; + for (const [field, defaultLen] of enums) { + const selections = + Object.entries(filterData[field]) + .filter(([_, isSelected]) => isSelected) + .map(([key]) => key.toUpperCase()); + if (selections.length < defaultLen && selections.length > 0) { + children.push({ + type: "enum", + field: field, + op: "is_any_of", + value: selections, + } as AdvancedSearchEnum); + } + } + + if (typeof filterData["schedule-fit"] === 'number' && filterData["schedule-fit"] >= 0) { + children.push({ + type: "value", + field: "fit_schedule", + value: filterData["schedule-fit"], + } as AdvancedSearchValue); + } + + if (filterData["is_open"] === 1) { + children.push({ + type: "boolean", + field: "is_open", + value: true, + } as AdvancedSearchBoolean); + } + + return { + query: filterData.searchString, + filters: { + op: "AND", + children: children, + } + } +} \ No newline at end of file