-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathviews.py
More file actions
487 lines (444 loc) · 20.3 KB
/
views.py
File metadata and controls
487 lines (444 loc) · 20.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
from django.core.exceptions import ObjectDoesNotExist
from django.db import IntegrityError
from django.db.models import Prefetch, Q
from django_auto_prefetching import AutoPrefetchViewSetMixin
from rest_framework import status, viewsets
from rest_framework.decorators import api_view, permission_classes, schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from courses.models import Course, Section
from courses.serializers import CourseListSerializer
from courses.util import get_course_and_section, get_current_semester
from PennCourses.docs_settings import PcxAutoSchema, reverse_func
from plan.management.commands.recommendcourses import (
clean_course_input,
recommend_courses,
retrieve_course_clusters,
vectorize_user,
vectorize_user_by_courses,
)
from plan.models import Schedule
from plan.serializers import ScheduleSerializer
@api_view(["POST"])
@schema(
PcxAutoSchema(
response_codes={
reverse_func("recommend-courses"): {
"POST": {
200: "[DESCRIBE_RESPONSE_SCHEMA]Response returned successfully.",
201: "[UNDOCUMENTED]",
400: "Invalid curr_courses, past_courses, or n_recommendations (see response).",
}
}
},
override_request_schema={
reverse_func("recommend-courses"): {
"POST": {
"type": "object",
"properties": {
"curr_courses": {
"type": "array",
"description": (
"An array of courses the user is currently planning to "
"take, each specified by its string full code, of the form "
"DEPT-XXX, e.g. CIS-120."
),
"items": {"type": "string"},
},
"past_courses": {
"type": "array",
"description": (
"An array of courses the user has previously taken, each "
"specified by its string full code, of the form DEPT-XXX, "
"e.g. CIS-120."
),
"items": {"type": "string"},
},
"n_recommendations": {
"type": "integer",
"description": (
"The number of course recommendations you want returned. "
"Defaults to 5."
),
},
},
}
}
},
override_response_schema={
reverse_func("recommend-courses"): {
"POST": {
200: {"type": "array", "items": {"$ref": "#/components/schemas/CourseList"}}
}
}
},
)
)
# PcxAutoSchema for the advanced registration view.
@api_view(["POST"])
@schema(
PcxAutoSchema(
response_codes={
reverse_func("advanced-registration-schedule"): {
"POST": {
200: "[DESCRIBE_RESPONSE_SCHEMA]Response returned successfully.",
201: "[UNDOCUMENTED]",
400: "Invalid advanced_reg_codes, advanced_reg_sections, schedule_user, or advanced_reg_schedule_name (see response).",
}
}
},
override_request_schema={
reverse_func("advanced-registration-schedule"): {
"POST": {
"type": "object",
"properties": {
"advanced_reg_codes": {
"type": "array",
"description": (
"An array of courses the user is currently planning to "
"take, each specified by its string full code, of the form "
"e.g. CIS-120-000."
),
"items": {"type": "string"},
},
"advanced_reg_schedule_name": {
"type": "string",
"description": (
"Name of the advanced registration schedule as "
"Specified by the user "
),
},
},
}
}
},
override_response_schema={
reverse_func("advanced-registration-schedule"): {
"POST": {
200: {"type": "object"}
}
}
},
)
)
@permission_classes([IsAuthenticated])
def advanced_registration_schedule_view(request):
"""
This route will takes in advanced registration codes and a schedule's name
to compose an advanced registration schedule and save it to the database. This view
also returns the created schedule in the response.
"""
schedule_user = request.user
advanced_reg_codes = request.data.get("advanced_reg_codes", None)
advanced_reg_codes = advanced_reg_codes if advanced_reg_codes is not None else []
advanced_reg_schedule_name = request.data.get("advanced_reg_schedule_name")
semester = request.data.get("semester", get_current_semester())
try:
advanced_reg_sections = []
for advanced_reg_code in advanced_reg_codes:
advanced_reg_sections.append(Section.objects.filter(code=advanced_reg_code))
if len(advanced_reg_sections) == 0:
return Response(
f"Empty Advanced Registration Schedule",
status=status.HTTP_400_BAD_REQUEST,
)
advanced_reg_schedule = Schedule.objects.create(person=schedule_user, sections=advanced_reg_sections,
semester=semester, name=advanced_reg_schedule_name, advanced_registration=True)
advanced_reg_schedule.save()
except ValueError as e:
return Response(
str(e),
status=status.HTTP_400_BAD_REQUEST,
)
return Response({"message": "success", "advanced_reg_schedule": advanced_reg_schedule}, status=status.HTTP_200_OK)
@permission_classes([IsAuthenticated])
def recommend_courses_view(request):
"""
This route will optionally take in current and past courses. In order to
make recommendations solely on the user's courses in past and current PCP schedules, simply
omit both the curr_courses and past_courses fields in your request.
Otherwise, in order to specify past and current courses,
include a "curr-courses" and/or "past_courses" attribute in the request that should each contain
an array of string course full codes of the form DEPT-XXX (e.g. CIS-120).
If successful, this route will return a list of recommended courses, with the same schema
as the List Courses route, starting with the most relevant course. The number of
recommended courses returned can be specified using the n_recommendations attribute in the
request body, but if this attribute is omitted, the default will be 5.
If n_recommendations is not an integer, or is <=0, a 400 will be returned.
If curr_courses contains repeated courses or invalid courses or non-current courses, a
400 will be returned.
If past_courses contains repeated courses or invalid courses, a 400 will be returned.
If curr_courses and past_courses contain overlapping courses, a 400 will be returned.
"""
user = request.user
curr_courses = request.data.get("curr_courses", None)
curr_courses = curr_courses if curr_courses is not None else []
past_courses = request.data.get("past_courses", None)
past_courses = past_courses if past_courses is not None else []
n_recommendations = request.data.get("n_recommendations", 5)
# input validation
try:
n_recommendations = int(n_recommendations)
except ValueError:
return Response(
f"n_recommendations: {n_recommendations} is not int",
status=status.HTTP_400_BAD_REQUEST,
)
if n_recommendations <= 0:
return Response(
f"n_recommendations: {n_recommendations} <= 0",
status=status.HTTP_400_BAD_REQUEST,
)
course_clusters = retrieve_course_clusters()
(
cluster_centroids,
clusters,
curr_course_vectors_dict,
past_course_vectors_dict,
) = course_clusters
if curr_courses or past_courses:
try:
user_vector, user_courses = vectorize_user_by_courses(
clean_course_input(curr_courses),
clean_course_input(past_courses),
curr_course_vectors_dict,
past_course_vectors_dict,
)
except ValueError as e:
return Response(
str(e),
status=status.HTTP_400_BAD_REQUEST,
)
else:
user_vector, user_courses = vectorize_user(
user, curr_course_vectors_dict, past_course_vectors_dict
)
recommended_course_codes = recommend_courses(
curr_course_vectors_dict,
cluster_centroids,
clusters,
user_vector,
user_courses,
n_recommendations,
)
queryset = Course.with_reviews.filter(
semester=get_current_semester(), full_code__in=recommended_course_codes
)
queryset = queryset.prefetch_related(
Prefetch(
"sections",
Section.with_reviews.all()
.filter(credits__isnull=False)
.filter(Q(status="O") | Q(status="C"))
.distinct()
.prefetch_related("course", "meetings__room"),
)
)
return Response(
CourseListSerializer(
queryset,
many=True,
).data,
status=status.HTTP_200_OK,
)
class ScheduleViewSet(AutoPrefetchViewSetMixin, viewsets.ModelViewSet):
"""
list:
Get a list of all the logged-in user's schedules for the current semester. Normally, the
response code is 200. Each object in the returned list is of the same form as the object
returned by Retrieve Schedule.
retrieve:
Get one of the logged-in user's schedules for the current semester, using the schedule's ID.
If a schedule with the specified ID exists, a 200 response code is returned, along with
the schedule object.
If the given id does not exist, a 404 is returned.
create:
Use this route to create a schedule for the authenticated user.
This route will return a 201 if it succeeds (or a 200 if the POST specifies an id which already
is associated with a schedule, causing that schedule to be updated), with a JSON in the same
format as if you were to get the schedule you just posted (the 200 response schema for Retrieve
Schedule). At a minimum, you must include the `name` and `sections` list (`meetings` can be
substituted for `sections`; if you don't know why, ignore this and just use `sections`,
or see below for an explanation... TLDR: it is grandfathered in from the old version of PCP).
The `name` is the name of the schedule (all names must be distinct for a single user in a
single semester; otherwise the response will be a 400). The sections list must be a list of
objects with minimum fields `id` (dash-separated, e.g. `CIS-121-001`) and `semester`
(5 character string, e.g. `2020A`). If any of the sections are invalid, a 404 is returned
with data `{"detail": "One or more sections not found in database."}`. If any two sections in
the `sections` list have differing semesters, a 400 is returned.
Optionally, you can also include a `semester` field (5 character string, e.g. `2020A`) in the
posted object, which will set the academic semester which the schedule is planning. If the
`semester` field is omitted, the semester of the first section in the `sections` list will be
used (or if the `sections` list is empty, the current semester will be used). If the
schedule's semester differs from any of the semesters of the sections in the `sections` list,
a 400 will be returned.
Optionally, you can also include an `id` field (an integer) in the posted object; if you
include it, it will update the schedule with the given id (if such a schedule exists),
or if the schedule does not exist, it will create a new schedule with that id.
Note that your posted object can include either a `sections` field or a `meetings` field to
list all sections you would like to be in the schedule (mentioned above).
If both fields exist in the object, only `meetings` will be considered. In all cases,
the field in question will be renamed to `sections`, so that will be the field name whenever
you GET from the server. (Sorry for this confusing behavior, it is grandfathered in
from when the PCP frontend was referring to sections as meetings, before schedules were
stored on the backend.)
update:
Send a put request to this route to update a specific schedule.
The `id` path parameter (an integer) specifies which schedule you want to update. If a
schedule with the specified id does not exist, a 404 is returned. In the body of the PUT,
use the same format as a POST request (see the create schedule docs).
This is an alternate way to update schedules (you can also just include the id field
in a schedule when you post and it will update that schedule if the id exists). Note that in a
put request the id field in the putted object is ignored; the id taken from the route
always takes precedence. If the request succeeds, it will return a 200 and a JSON in the same
format as if you were to get the schedule you just updated (in the same format as returned by
the GET /schedules/ route).
delete:
Send a delete request to this route to delete a specific schedule. The `id` path parameter
(an integer) specifies which schedule you want to update. If a schedule with the specified
id does not exist, a 404 is returned. If the delete is successful, a 204 is returned.
"""
schema = PcxAutoSchema(
response_codes={
reverse_func("schedules-list"): {
"GET": {
200: "[DESCRIBE_RESPONSE_SCHEMA]Schedules listed successfully.",
},
"POST": {
201: "Schedule successfully created.",
200: "Schedule successfully updated (a schedule with the "
"specified id already existed).",
400: "Bad request (see description above).",
},
},
reverse_func("schedules-detail", args=["id"]): {
"GET": {
200: "[DESCRIBE_RESPONSE_SCHEMA]Successful retrieve "
"(the specified schedule exists).",
404: "No schedule with the specified id exists.",
},
"PUT": {
200: "Successful update (the specified schedule was found and updated).",
400: "Bad request (see description above).",
404: "No schedule with the specified id exists.",
},
"DELETE": {
204: "Successful delete (the specified schedule was found and deleted).",
404: "No schedule with the specified id exists.",
},
},
},
)
serializer_class = ScheduleSerializer
http_method_names = ["get", "post", "delete", "put"]
permission_classes = [IsAuthenticated]
@staticmethod
def get_sections(data):
raw_sections = []
if "meetings" in data:
raw_sections = data.get("meetings")
elif "sections" in data:
raw_sections = data.get("sections")
sections = []
for s in raw_sections:
_, section = get_course_and_section(s.get("id"), s.get("semester"))
sections.append(section)
return sections
@staticmethod
def check_semester(data, sections):
for i, s in enumerate(sections):
if i == 0 and "semester" not in data:
data["semester"] = s.course.semester
elif s.course.semester != data.get("semester"):
return Response(
{"detail": "Semester uniformity invariant violated."},
status=status.HTTP_400_BAD_REQUEST,
)
def update(self, request, pk=None):
if not Schedule.objects.filter(id=pk).exists():
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
try:
schedule = self.get_queryset().get(id=pk)
except Schedule.DoesNotExist:
return Response(
{"detail": "You do not have access to the specified schedule."},
status=status.HTTP_403_FORBIDDEN,
)
try:
sections = self.get_sections(request.data)
except ObjectDoesNotExist:
return Response(
{"detail": "One or more sections not found in database."},
status=status.HTTP_400_BAD_REQUEST,
)
semester_check_response = self.check_semester(request.data, sections)
if semester_check_response is not None:
return semester_check_response
try:
schedule.person = request.user
schedule.semester = request.data.get("semester", get_current_semester())
schedule.name = request.data.get("name")
schedule.save()
schedule.sections.set(sections)
return Response({"message": "success", "id": schedule.id}, status=status.HTTP_200_OK)
except IntegrityError as e:
return Response(
{
"detail": "IntegrityError encountered while trying to update: "
+ str(e.__cause__)
},
status=status.HTTP_400_BAD_REQUEST,
)
def create(self, request, *args, **kwargs):
if Schedule.objects.filter(id=request.data.get("id")).exists():
return self.update(request, request.data.get("id"))
try:
sections = self.get_sections(request.data)
except ObjectDoesNotExist:
return Response(
{"detail": "One or more sections not found in database."},
status=status.HTTP_400_BAD_REQUEST,
)
semester_check_response = self.check_semester(request.data, sections)
if semester_check_response is not None:
return semester_check_response
try:
if (
"id" in request.data
): # Also from above we know that this id does not conflict with existing schedules.
schedule = self.get_queryset().create(
person=request.user,
semester=request.data.get("semester", get_current_semester()),
name=request.data.get("name"),
id=request.data.get("id"),
)
else:
schedule = self.get_queryset().create(
person=request.user,
semester=request.data.get("semester", get_current_semester()),
name=request.data.get("name"),
)
schedule.sections.set(sections)
return Response(
{"message": "success", "id": schedule.id}, status=status.HTTP_201_CREATED
)
except IntegrityError as e:
return Response(
{
"detail": "IntegrityError encountered while trying to create: "
+ str(e.__cause__)
},
status=status.HTTP_400_BAD_REQUEST,
)
queryset = Schedule.objects.none() # included redundantly for docs
def get_queryset(self):
sem = get_current_semester()
queryset = Schedule.objects.filter(person=self.request.user, semester=sem)
queryset = queryset.prefetch_related(
Prefetch("sections", Section.with_reviews.all()),
"sections__associated_sections",
"sections__instructors",
"sections__meetings",
"sections__meetings__room",
)
return queryset