Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/courses/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,3 +545,11 @@ def get_schema_operation_parameters(self, view):
"example": "true",
},
]


class CourseSearchAdvancedFilterBackend(CourseSearchFilterBackend):
def filter_queryset(self, request, queryset, view):
pass

def get_schema_operation_parameters(self, view):
pass
5 changes: 2 additions & 3 deletions backend/courses/urls.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
from django.urls import path

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


urlpatterns = [
path("health/", Health.as_view(), name="health"),
path("health/", views.Health.as_view(), name="health"),
path("<slug:semester>/courses/", views.CourseList.as_view(), name="courses-list"),
path(
"<slug:semester>/search/courses/",
CourseListSearch.as_view(),
views.CourseListSearch.as_view(),
name="courses-search",
),
path(
Expand Down
64 changes: 59 additions & 5 deletions backend/courses/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404
from django_auto_prefetching import AutoPrefetchViewSetMixin
from lark import ParseError
from rest_framework import generics, status
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

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

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

Expand Down Expand Up @@ -234,8 +245,51 @@ def get_serializer_context(self):

return context

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

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

# Apply text-based search first
queryset = TypedCourseSearchBackend().filter_queryset(request, queryset, self)

# Apply simple filters (query params)
queryset = CourseSearchFilterBackend().filter_queryset(request, queryset, self)

# Handle pagination (preserve DRF behavior)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)

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

def post(self, request, *args, **kwargs):
if not isinstance(request.data, dict):
raise ParseError("Expected JSON body with 'query' and 'filters' fields.")

queryset = super().get_queryset()

# Apply text-based search
queryset = TypedCourseSearchBackend().filter_queryset(request, queryset, self)

# Apply advanced structured filters
if request.data.get("filters"):
queryset = CourseSearchAdvancedFilterBackend().filter_queryset_from_json(
request, queryset, request.data["filters"]
)

# Apply pagination again for consistency
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)

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


class CourseDetail(generics.RetrieveAPIView, BaseCourseMixin):
Expand Down
6 changes: 2 additions & 4 deletions backend/plan/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@


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

for i in range(7):
Expand Down
36 changes: 12 additions & 24 deletions backend/plan/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 "
Expand All @@ -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)
Expand All @@ -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."},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -682,8 +676,7 @@ 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):
Expand Down Expand Up @@ -880,8 +873,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()
Expand All @@ -908,10 +900,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"
Expand All @@ -921,11 +911,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:
Expand Down
4 changes: 3 additions & 1 deletion backend/review/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
),
)
Expand Down
1 change: 0 additions & 1 deletion backend/tests/alert/test_alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
44 changes: 44 additions & 0 deletions frontend/plan/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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";
Expand Down Expand Up @@ -402,6 +403,42 @@ export function fetchCourseSearch(filterData) {
};
}

const advancedCourseSearch = (_, searchData) =>
doAPIRequest("/base/current/search/courses/v2", {
method: "GET",
credentials: "include",
mode: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-CSRFToken": getCsrf(),
},
body: JSON.stringify(searchData),
});

const debouncedAdvancedCourseSearch = AwesomeDebouncePromise(
advancedCourseSearch,
500
);

export function fetchAdvancedCourseSearch(searchData) {
return (dispatch) => {
dispatch(updateSearchRequest());
debouncedAdvancedCourseSearch(dispatch, searchData)
.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)));
};
}

export function updateSearchText(s) {
return {
type: UPDATE_SEARCH_TEXT,
Expand Down Expand Up @@ -473,6 +510,13 @@ export function clearFilter(propertyName) {
};
}

export function updateSearchFilter(path, filters) {
return {
type: UPDATE_SEARCH_FILTER,
filters,
};
}

export const deletionAttempted = (scheduleName) => ({
type: DELETION_ATTEMPTED,
scheduleName,
Expand Down
Empty file.
12 changes: 12 additions & 0 deletions frontend/plan/components/search/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,18 @@ SearchBarProps) {
Clear all
</ClearButton>
</LevelItem>
<LevelItem
onClick={() => {

}}
>
<button>
<i
className="fas fa-filter"
style={{ color: "#c6c6c6" }}
/>
</button>
</LevelItem>
</LevelRight>
</SearchBarFilters>
<LevelRight>
Expand Down
2 changes: 2 additions & 0 deletions frontend/plan/reducers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +18,7 @@ const coursePlanApp = combineReducers({
friendships,
alerts,
breaks,
search,
});

export default coursePlanApp;
2 changes: 1 addition & 1 deletion frontend/plan/reducers/schedule.js
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ export const schedule = (state = initialState, action) => {
),
};
}
return state;
case TOGGLE_BREAK:
if (!state.readOnly) {
const oldBreakSections =
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading