-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathdocs_settings.py
More file actions
1476 lines (1318 loc) · 74.3 KB
/
docs_settings.py
File metadata and controls
1476 lines (1318 loc) · 74.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
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import inspect
import json
import re
from copy import deepcopy
from inspect import getdoc
from textwrap import dedent
import jsonref
from django.urls import reverse
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONOpenAPIRenderer
from rest_framework.schemas.openapi import AutoSchema
from rest_framework.schemas.utils import is_list_view
"""
This file includes code and settings for our PCx autodocumentation
(based on a Django-generated OpenAPI schema and Redoc, which formats that schema into a
readable documentation web page). Some useful links:
https://github.com/Redocly/redoc
https://github.com/Redocly/redoc/blob/master/docs/redoc-vendor-extensions.md#tagGroupObject
https://www.django-rest-framework.org/api-guide/schemas
https://www.django-rest-framework.org/topics/documenting-your-api/
A Redoc example from which many of the concepts in this file were taken from:
https://redocly.github.io/redoc/
https://github.com/Redocly/redoc/blob/master/demo/openapi.yaml
TERMINOLOGY:
Each route (e.g. GET /api/plan/schedules/{id}/) has an "operation ID" that is (by default)
automatically parsed from the codebase (e.g. "Retrieve Schedule"). The base "name" underlying this
operation ID is also automatically parsed by default (e.g. "Schedule"). The operation ID shows up
as the title of the route on our Redoc documentation page. You can customize the
name and/or the operation ID of a route by modifying the custom_name and custom_operation_id
dicts in this file. Customizing the name will change the operation ID and the tag of the route
(see below). You can customize the name with the custom_name dict below, and the operation ID
with the custom_operation_id dict.
Tags are groupings of routes by name. For instance, all the routes
GET, POST /api/plan/schedules/ and GET, PUT, DELETE /api/plan/schedules/{id}
are organized under the shared tag "[PCP] Schedule", since they all share the base name "Schedule".
You can click on a tag in the table of contents of our Redoc documenation, and the section will
expand to show all the underlying routes (each titled by its operation ID). You can change tag
names using the custom_tag_names dict below, but the default tag names are usually sensible
(derived from the base name of the underlying routes). What's more useful is to give a tag a
description, which you can do with the custom_tag_descriptions dict.
Tag groups organize tags into groups; they show up in the left sidebar of our Redoc page and divide
the categories of routes into meta categories. We are using them to separate our tags by app.
For instance, "Penn Course Plan" is a tag group. Each tag group has an abbreviation, specified by
the tag_group_abbreviations dict. For instance, the "Penn Course Plan" tag group is
abbreviated "[PCP]". Each tag in a tag group is prefixed by the tag group abbreviation in
square brackets (for instance "[PCP] Schedule"). The subpath_abbreviations dict below takes Django
app names (e.g. "plan"), and maps them to the corresponding tag group abbreviation (this is how
tags are automatically organized into tag groups). You shouldn't need to modify this dict unless
you change an app name or add a new app). Then, the tag_group_abbreviations dict maps the
abbreviation to the full name of the tag group.
MAINTENENCE:
You can update the introductory sections / readme of the auto-docs page by editing the
markdown-formatted openapi_description text below.
You should include docstrings in views (the proper format of docstrings are specified here
https://www.django-rest-framework.org/coreapi/from-documenting-your-api/#documenting-your-views)
explaining the function of the view (or the function of each method if there are multiple supported
methods). These docstrings will be parsed by the auto-docs and included in the documentation page.
When writing any class-based views where you specify a queryset (such as ViewSets), even if you
override get_queryset(), you should also specify the queryset field with something like
queryset = ExampleModel.objects.none() (using .none() to prevent accidental data breach), or
alternatively a sensible queryset default (e.g. queryset = Course.with_reviews.all() for the
CourseDetail ViewSet). Basically, just make sure the queryset parameter is always pointing to the
relevant model (if you are using queryset or get_queryset()). This will allow the
auto-documentation to access the model underlying the queryset (it cannot call get_queryset()
since it cannot generate a request object which the get_queryset() method might try to access).
If the meaning of a model or serializer field is not clear, you should include the string help_text
as a parameter when initializing the field, explaining what that field stores. This will show up
in the documentation such that parameter descriptions are inferred from model or serializer field
help text. For properties, the docstring will be used since there is no
way to define help_text for a property; so even if a property's use is clear based on the code,
keep in mind that describing its purpose in the docstring will be helpful to frontend engineers
who are unfamiliar with the backend code (also, don't include a :return: tag as you might normally
do for functions; a property is to be treated as a dynamic field, not a method, so just state
what the method returns as the only text in the docstring).
Including help_text/docstring when a field/property's purpose is unclear will also
make the model/serializer code more understandable for future Labs developers.
And furthermore, all help_text and descriptive docstrings show up in the backend
documentation (accessible at /admin/doc/).
PcxAutoSchema (defined below) is a subclass of Django's AutoSchema, and it makes some improvements
on that class for use with Redoc as well as some customizations specific to our use-cases. You can
customize auto-docs for a specific view by setting
schema = PcxAutoSchema(...)
in class-based views, or using the decorator
@schema(PcxAutoSchema(...))
in functional views, and passing kwargs (...) into the PcxAutoSchema constructor (search
PcxAutoSchema in our codebase for some examples of this, and keep reading for a comprehensive
description of how you can customize PcxAutoSchema using these kwargs).
There are a number of dictionaries you can use to customize these auto-docs; some are passed into
PcxAutoSchema as initialization kwargs, and some are predefined in this file (in the
"Customizable Settings" section below). Often, these dictionaries will contain layers of nested
dictionaries with a schema of path/method/... However, you will notice in example code snippets
in this README and in our codebase, these paths are not hardcoded but instead are indicated using
reverse_func(...). Whenever you see a reverse_func(...) function in these docs or in the code,
think of that as the url corresponding to the passed-in name with path parameters
replaced in order by the arguments in the args=[...] kwarg list. It works just like Django's
reverse function:
https://docs.djangoproject.com/en/3.1/ref/urlresolvers/#reverse
The only reason we don't just use reverse is to avoid circular imports (reverse_func is lazily
evaluated; for more details on this see the docstring of reverse_func, defined below).
To determine the name of a certain url, run `python manage.py show_urls` which will print
a list of urls and their corresponding names.
Note that the name of the url here is not to be confused with the base name of the route
as defined above in the TERMINOLOGY section; the name of the url is specified in the urls.py file,
whereas the name of the route is auto-generated from the code, and may or may not be derived from
the url name. For instance "courses-detail" is a url name, and "Course" is the base name of the
corresponding route for documentation generation.
Sorry for the confusion / overloading of terms here.
Your args list should always contain the string names of each of the
path parameters of the url, in the order they appear in the url. For instance,
reverse_func("courses-detail", args=["semester", "full_code"])
would be used to reference /api/base/{semester}/courses/{full_code}/.
We use reverse_func rather than hardcoding urls so that the only places with hardcoded
urls are urls.py files (so urls can easily be changed).
Unimportant implementation detail: in reality, reverse_func doesn't return a string but actually
returns a function which returns a string (see the reverse_func docstring if you are curious
as to why this is necessary to avoid circular imports). You don't need to keep this in mind when
documenting your code however, since it is handled under the hood by our auto-docs code.
Effectively you can think of reverse_func as Django's reverse function.
By default, response codes will be assumed to be 204 (for delete) or 200 (in all other cases).
To set custom response codes for a path/method (with a custom description), include a
response_codes kwarg in your PcxAutoSchema instantiation. You should input
a dict mapping paths (indicated by reverse_func) to dicts, where each subdict maps string methods
to dicts, and each further subdict maps int response codes to string descriptions. An example:
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).",
}
},
...
}
Note that if you include "[DESCRIBE_RESPONSE_SCHEMA]" in your string description, that will
not show up in the description text (it will automatically be removed) but instead will indicate
that that response should have a response body schema show up in the documentation (the schema will
be automatically generated by default, but can be customized using the override_response_schema
kwarg; see below). You should generally enable a response schema for responses which will contain
useful data for the frontend beyond the response code. Note that in the example above, the only
such response is the listing of schedules (the GET 200 response).
If you include "[UNDOCUMENTED]" in your string description, that will
remove that response status code from the schema/docs. This is useful if you want to remove
a code that is included by default from the schema.
If you want to make manual changes to a request schema, include an override_request_schema kwarg
in your PcxAutoSchema instantiation. You should input a dict mapping paths (indicated by
reverse_func) to dicts, where each subdict maps string methods to objects specifying the
desired response schema for that path/method.
The format of these objects is governed by the OpenAPI specification
(for more on the syntax of how to specify a schema, see this link:
http://spec.openapis.org/oas/v3.0.3.html#schema-object [section 4.7.24]
you are specifying the dicts mapped to by "schema" keys in the examples at the following link:
http://spec.openapis.org/oas/v3.0.3.html#request-body-object). An example:
override_request_schema={
reverse_func("recommend-courses"): {
"POST": {
"type": "object",
"properties": {
"past_courses": {
"type": "array",
"description": (
"An array of courses the user has previously taken."
),
"items": {
"type": "string",
"description": "A course code of the form DEPT-XXX, e.g. CIS-120"
}
}
}
}
}
}
If you want to make manual changes to a response schema, include an override_response_schema kwarg
in your PcxAutoSchema instantiation. You should input a dict mapping paths (indicated by
reverse_func) to dicts, where each subdict maps string methods to dicts, and each further subdict
maps int response codes to the objects specifying the desired response schema.
The format of these objects is governed by the OpenAPI specification
(for more on the syntax of how to specify a schema, see this link:
http://spec.openapis.org/oas/v3.0.3.html#schema-object [section 4.7.24]
you are specifying the dicts mapped to by "schema" keys in the examples at the following link:
http://spec.openapis.org/oas/v3.0.3.html#response-object). You can reference existing schemas
generated by the docs using the notation {"$ref": "#/components/schemas/VeryComplexType"}.
Download the existing OpenAPI schema using the button at the top of the docs page to inspect
what existing schemas exist, and what the path to them is.
An example:
override_response_schema={
reverse_func("recommend-courses"): {
"POST": {
200: {
"type": "array",
"description": "An array of courses that we recommend.",
"items": {
"type": "string",
"description": "The full code of the recommended course, in the form "
"DEPT-XXX, e.g. CIS-120"
}
}
}
}
}
If you want to manually set the description of a path parameter for a certain path/method,
you can do so by including a custom_path_parameter_desc kwarg in your PcxAutoSchema instantiation,
with keys of the form path > method > variable_name pointing to a string description. Example:
custom_path_parameter_desc={
reverse_func("statusupdate", args=["full_code"]): {
"GET": {
"full_code": (
"The code of the section which this status update applies to, in the "
"form '{dept code}-{course code}-{section code}', e.g. 'CIS-120-001' for the "
"001 section of CIS-120."
)
}
}
}
If you want to manually specify parameters (query, path, header, or cookie) for a certain
path/method, you can do so by including a custom_parameters kwarg in your PcxAutoSchema
instantiation, passing a dict of the form path > method > [list of query param schema objects].
This kwarg will override custom_path_parameter_desc if they conflict.
The format of these objects is described by
https://spec.openapis.org/oas/v3.0.3.html#parameter-object [section 4.7.12]
Example:
custom_parameters={
reverse_func("course-plots", args=["course_code"]): {
"GET": [
{
"name": "course_code",
"in": "path",
"description": "The dash-joined department and code of the course you want plots for, e.g. `CIS-120` for CIS-120.", # noqa E501
"schema": {"type": "string"},
"required": True,
},
{
"name": "instructor_ids",
"in": "query",
"description": "A comma-separated list of instructor IDs with which to filter the sections underlying the returned plots.", # noqa E501
"schema": {"type": "string"},
"required": False,
},
]
},
},
Finally, if you still need to further customize your API schema, you can do this in the
make_manual_schema_changes function below. This is applied to the final JSON schema after all
automatic changes / customizations are applied. For more about the format of an OpenAPI
schema (which you would need to know a bit about to make further customizations), see this
documentation:
http://spec.openapis.org/oas/v3.0.3.html
To explore our JSON schema (which can help when trying to figure out how to modify it in
make_manual_schema_changes if you need to), you can download it from the /api/openapi/ route.
"""
def reverse_func(*pargs, args=None, **kwargs):
"""
This function returns a function which, when called, will return the string url associated
with the given args and kwargs, just like the reverse function would:
https://docs.djangoproject.com/en/3.1/ref/urlresolvers/#reverse
Importantly, it allows for evaluation of the string to
occur later when the docs are generated, rather than during the creation of the views
(which causes an unavoidable circular import problem).
"""
if args is None:
args = []
def get_url():
# replace args with unique pattern which won't be found in the rest of the url
# (DRF throws an error if we include curly braces in a string in args, so this hack
# allows us to identify each path parameter in the url and replace it).
if "hopefully_unique_str_path_parameter" in reverse(
*pargs, args=["0" for _ in args], **kwargs
):
raise ValueError(
"Please remove the string 'hopefully_unique_str_path_parameter' from all urls. Wtf."
)
new_args = [f"hopefully_unique_str_path_parameter_{i}" for i in range(len(args))]
url = reverse(*pargs, args=new_args, **kwargs)
for i, pretend_param in enumerate(new_args):
# Surround given path parameters with curly braces (can't be used in the args
# list of reverse, but is required by the OpenAPI specification:
# https://swagger.io/docs/specification/describing-parameters/)
url = url.replace(pretend_param, "{" + args[i] + "}")
return url
return get_url
# ============================= Begin Customizable Settings ========================================
# The following is the description which shows up at the top of the documentation site
openapi_description = """
# Introduction
Penn Courses ([GitHub](https://github.com/pennlabs/penn-courses)) is the umbrella
categorization for [Penn Labs](https://pennlabs.org/)
products designed to help students navigate the course registration process. It currently
includes three products, each with their own API documented on this page:
Penn Course Alert, Penn Course Plan, and Penn Course Review.
See `Penn Labs Notion > Penn Courses` for more details on each of our (currently) three apps.
For instructions on how to maintain this documentation while writing code,
see the comments in `backend/PennCourses/docs_settings.py` (it is easy, and will be helpful
for maintaining Labs knowledge in spite of our high member turnover rate).
See our
[GitHub](https://github.com/pennlabs/penn-courses) repo for instructions on
installation, running in development, and loading in course data for development. Visit
the `/admin/doc/` route ([link](/admin/doc/)) for the backend documentation (admin account required,
which can be made by running `python manage.py createsuperuser` in terminal/CLI).
# Unified Penn Courses
By virtue of the fact that all Penn Courses products deal with, well, courses,
it would make sense for all three products to share the same backend.
We realized the necessity of a unified backend when attempting to design a new Django backend
for Penn Course Plan. We like to live by the philosophy of keeping it
[DRY](https://en.wikipedia.org/wiki/Don't_repeat_yourself), and
PCA and PCP's data models both need to reference course and
section information. We could have simply copied over code (a bad idea)
or created a shared reusable Django app (a better idea) for course data,
but each app would still need to download copies of the same data.
Additionally, this will help us build integrations between our Courses products.
See `Penn Labs Notion > Penn Courses > Unified Penn Courses` for more details on our
codebase file structure, data models, and multi-site devops scheme.
# Authentication
Currently, PCx user authentication is taken care of by platform's Penn Labs Accounts system.
See `Penn Labs Notion > Platform > The Accounts Engine` for extensive documentation and
links to repositories for this system. When tags or routes are described as requiring user
authentication, they are referring to this system. See the Django docs for more on Django's
[User Authentication system](https://docs.djangoproject.com/en/3.0/topics/auth/) which
underlies PLA.
"""
# This dictionary takes app names (the string just after /api/ in the path or just after /
# if /api/ does not come at the beginning of the path)
# as values and abbreviated versions of those names as values. It is used to
# add an abbreviated app prefix designating app membership to each route's tag name.
# For instance the Registration tag is prepended with [PCA] to get "[PCA] Registration" since
# its routes start with /api/alert/, and "alert": "PCA" is a key/value pair in the following dict.
subpath_abbreviations = {
"plan": "PCP",
"alert": "PCA",
"review": "PCR",
"base": "PCx",
"accounts": "Accounts",
}
assert all(
[isinstance(key, str) and isinstance(val, str) for key, val in subpath_abbreviations.items()]
)
# This dictionary should map abbreviated app names (values from the dict above) to
# longer form names which will show up as the tag group name in the documentation.
tag_group_abbreviations = {
"PCP": "Penn Course Plan",
"PCA": "Penn Course Alert",
"PCR": "Penn Course Review",
"PCx": "Penn Courses (Base)",
"Accounts": "Penn Labs Accounts",
"": "Other" # Catches all other tags (this should normally be an empty tag group and if so
# it will not show up in the documentation, but is left as a debugging safeguard).
# If routes are showing up in a "Misc" tag in this group, make sure you set the schema for
# those views to be PcxAutoSchema, as is instructed in the meta docs above.
}
assert all(
[isinstance(key, str) and isinstance(val, str) for key, val in tag_group_abbreviations.items()]
)
# "operation ids" are the unique titles of routes within a tag (if you click on a tag you see
# a list of operation ids, each corresponding to a certain route).
# name here refers to the name underlying the operation id of the view
# this is NOT the full name that you see on the API, it is the base name underlying it,
# and is used in construction of that name
# For instance, for POST /api/plan/schedules/, the name is "Schedule" and the operation_id is
# "Create Schedule" (see below get_name and _get_operation_id methods in PcxAutoSchema for
# a more in-depth explanation of the difference).
# IMPORTANT: The name also defines what the automatically-set tag name will be.
# That's why this custom_name is provided separately from custom_operation_id below;
# you can use it if you want to change the operation_id AND the tag name at once.
custom_name = { # keys are (path, method) tuples, values are custom names
# method is one of ("GET", "POST", "PUT", "PATCH", "DELETE")
(reverse_func("registrationhistory-list"), "GET"): "Registration History",
(reverse_func("registrationhistory-detail", args=["id"]), "GET"): "Registration History",
(reverse_func("statusupdate", args=["full_code"]), "GET"): "Status Update",
(reverse_func("recommend-schedule"), "POST"): "Schedule Recommendation",
(reverse_func("recommend-courses"), "POST"): "Course Recommendations",
(reverse_func("course-reviews", args=["course_code"]), "GET"): "Course Reviews",
(reverse_func("course-plots", args=["course_code"]), "GET"): "Plots",
(reverse_func("review-autocomplete"), "GET"): "Autocomplete Dump",
(reverse_func("instructor-reviews", args=["instructor_id"]), "GET"): "Instructor Reviews",
(reverse_func("department-reviews", args=["department_code"]), "GET"): "Department Reviews",
(
reverse_func("course-history", args=["course_code", "instructor_id"]),
"GET",
): "Section-Specific Reviews",
(reverse_func("requirements-list", args=["semester"]), "GET"): "Pre-NGSS Requirement",
(reverse_func("restrictions-list"), "GET"): "NGSS Restriction",
}
assert all(
[isinstance(k, tuple) and len(k) == 2 and isinstance(k[1], str) for k in custom_name.keys()]
)
custom_operation_id = { # keys are (path, method) tuples, values are custom names
# method is one of ("GET", "POST", "PUT", "PATCH", "DELETE")
(reverse_func("registrationhistory-list"), "GET"): "List Registration History",
(
reverse_func("registrationhistory-detail", args=["id"]),
"GET",
): "Retrieve Historic Registration",
(reverse_func("statusupdate", args=["full_code"]), "GET"): "List Status Updates",
(reverse_func("courses-search", args=["semester"]), "GET"): "Course Search",
(reverse_func("section-search", args=["semester"]), "GET"): "Section Search",
(reverse_func("review-autocomplete"), "GET"): "Retrieve Autocomplete Dump",
}
assert all(
[
isinstance(k, tuple) and len(k) == 2 and isinstance(k[1], str)
for k in custom_operation_id.keys()
]
)
# Use this dictionary to rename tags, if you wish to do so
# keys are old tag names (seen on docs), values are new tag names
custom_tag_names = {}
assert all([isinstance(key, str) and isinstance(val, str) for key, val in custom_tag_names.items()])
# Note that you can customize the tag for all routes from a certain view by passing in a
# list containing only that tag into the tags kwarg of PcxAutoSchema instantiation
# (inherited behavior from Django AutoSchema:
# https://www.django-rest-framework.org/api-guide/schemas/#autoschema)
# tag descriptions show up in the documentation body below the tag name
custom_tag_descriptions = {
# keys are tag names (after any name changes from above dicts), vals are descriptions
"[PCP] Schedule": dedent(
"""
These routes allow interfacing with the user's PCP Schedules for the current semester,
stored on the backend. Ever since we integrated Penn Labs Accounts into PCP so that users
can store their schedules across devices and browsers, we have stored users' schedules on
our backend (rather than local storage).
"""
),
"[PCP] Pre-NGSS Requirements": dedent(
"""
These routes expose the pre-NGSS (deprecated since 2022C) academic requirements for the
current semester which are stored on our backend (hopefully comprehensive).
"""
),
"[PCP] Course": dedent(
"""
These routes expose course information for PCP for the current semester.
"""
),
"[PCA] Registration History": dedent(
"""
These routes expose a user's registration history (including
inactive and obsolete registrations) for the current semester. Inactive registrations are
registrations which would not trigger a notification to be sent if their section opened,
and obsolete registrations are registrations which are not at the head of their resubscribe
chain.
"""
),
"[PCA] Registration": dedent(
"""
As the main API endpoints for PCA, these routes allow interaction with the user's
PCA registrations. An important concept which is referenced throughout the documentation
for these routes is that of the "resubscribe chain". A resubscribe chain is a chain
of PCA registrations where the tail of the chain was an original registration created
through a POST request to `/api/alert/registrations/` specifying a new section (one that
the user wasn't already registered to receive alerts for). Each next element in the chain
is a registration created by resubscribing to the previous registration (once that
registration had triggered an alert to be sent), either manually by the user or
automatically if auto_resubscribe was set to true. Then, it follows that the head of the
resubscribe chain is the most relevant Registration for that user/section combo; if any
of the registrations in the chain are active, it would be the head. And if the head
is active, none of the other registrations in the chain are active.
Note that a registration will send an alert when the section it is watching opens, if and
only if it hasn't sent one before, it isn't cancelled, and it isn't deleted. If a
registration would send an alert when the section it is watching opens, we call it
"active". See the Create Registration docs for an explanation of how to create a new
registration, and the Update Registration docs for an explanation of how you can modify
a registration after it is created.
In addition to sending alerts for when a class opens up, we have also implemented
an optionally user-enabled feature called "close notifications".
If a registration has close_notification enabled, it will act normally when the watched
section opens up for the first time (triggering an alert to be sent). However, once the
watched section closes, it will send another alert (the email alert will be in the same
chain as the original alert) to let the user know that the section has closed. Thus,
if a user sees a PCA notification on their phone during a class for instance, they won't
need to frantically open up their laptop and check PennInTouch to see if the class is still
open just to find that it is already closed. To avoid spam and wasted money, we DO NOT
send any close notifications over text. So the user must have an email saved or use
push notifications in order to be able to enable close notifications on a registration.
Note that the close_notification setting carries over across resubscriptions, but can be
disabled at any time using Update Registration.
After the PCA backend refactor in 2019C/2020A, all PCA Registrations have a `user` field
pointing to the user's Penn Labs Accounts User object. In other words, we implemented a
user/accounts system for PCA which required that
people log in to use the website. Thus, the contact information used in PCA alerts
is taken from the user's User Profile. You can edit this contact information using
Update User or Partial Update User. If push_notifications is set to True, then
a push notification will be sent when the user is alerted, but no text notifications will
be sent (as that would be a redundant alert to the user's phone). Otherwise, an email
or a text alert is sent if and only if contact information for that medium exists in
the user's profile.
"""
),
"[PCA] User": dedent(
"""
These routes expose a user's saved settings (from their Penn Labs Accounts user object).
For PCA, the profile object is of particular importance; it stores the email and
phone of the user (with a null value for either indicating the user doesn't want to be
notified using that medium).
"""
),
"[PCA] Sections": dedent(
"""
This route is used by PCA to get data about sections.
"""
),
"[Accounts] User": dedent(
"""
These routes allow interaction with the User object of a Penn Labs Accounts user.
"""
),
"Miscs": dedent(
"""
<span style="color:red;">WARNING</span>: This tag should not be used, and its existence
indicates you may have forgotten to set a view's schema to PcxAutoSchema for the views
under this tag. See the meta documentation in backend/PennCourses/docs_settings.py of our
codebase for instructions on how to properly set a view's schema to PcxAutoSchema.
"""
),
}
assert all(
[isinstance(key, str) and isinstance(val, str) for key, val in custom_tag_descriptions.items()]
)
labs_logo_url = "https://i.imgur.com/tVsRNxJ.png"
def make_manual_schema_changes(data):
"""
Use this space to make manual modifications to the schema before it is
presented to the user. Only make manual changes as a last resort, and try
to use built-in functionality whenever possible.
These modifications were written by referencing the existing schema at /api/openapi
and also an example schema (written in YAML instead of JSON, but still
easily interpretable as JSON) from a Redoc example:
https://github.com/Redocly/redoc/blob/master/demo/openapi.yaml
"""
data["info"]["x-logo"] = {"url": labs_logo_url, "altText": "Labs Logo"}
data["info"]["contact"] = {"email": "contact@pennlabs.org"}
# Remove ID from the documented PUT request body for /api/plan/schedules/
# (the id field in the request body is ignored in favor of the id path parameter)
data["paths"][reverse_func("schedules-detail", args=["id"])()]["put"] = deepcopy(
data["paths"][reverse_func("schedules-detail", args=["id"])()]["put"]
)
for content_ob in data["paths"][reverse_func("schedules-detail", args=["id"])()]["put"][
"requestBody"
]["content"].values():
content_ob["schema"]["properties"].pop("id", None)
# Make the name and sections fields of the PCP schedule request body required,
# make the id field optionally show up. Also, make the id and semester fields
# show up under the sections field, and make id required.
for path, path_ob in data["paths"].items():
if reverse_func("schedules-list")() not in path:
continue
for method_ob in path_ob.values():
if "requestBody" not in method_ob.keys():
continue
for content_ob in method_ob["requestBody"]["content"].values():
properties_ob = content_ob["schema"]["properties"]
if "sections" in properties_ob.keys():
section_ob = properties_ob["sections"]
if "required" not in section_ob["items"].keys():
section_ob["items"]["required"] = []
required = section_ob["items"]["required"]
section_ob["items"]["required"] = list(set(required + ["id", "semester"]))
for field, field_ob in section_ob["items"]["properties"].items():
if field == "id" or field == "semester":
field_ob["readOnly"] = False
if "semester" in properties_ob.keys():
properties_ob["semester"]["description"] = dedent(
"""
The semester of the course (of the form YYYYx where x is A [for spring],
B [summer], or C [fall]), e.g. `2019C` for fall 2019. You can omit this
field and the semester of the first section in the sections list will be
used instead (or if the sections list is empty, the current semester will
be used). If this field differs from any of the semesters of the sections
in the sections list, a 400 will be returned.
"""
)
if "id" in properties_ob.keys():
properties_ob["id"]["description"] = (
"The id of the schedule, if you want to explicitly set this (on create) "
"or update an existing schedule by id (optional)."
)
# Make application/json the only content type
def delete_other_content_types_dfs(dictionary):
if not isinstance(dictionary, dict):
return None
dictionary.pop("application/x-www-form-urlencoded", None)
dictionary.pop("multipart/form-data", None)
for value in dictionary.values():
delete_other_content_types_dfs(value)
delete_other_content_types_dfs(data)
# ============================== End Customizable Settings =========================================
def not_using_reverse_func(dictionary_name, key, PcxAutoSchema=False, traceback=None):
"""
This function should be called when it is detected that a user did not use the reverse_func
function to generate a url (and instead hardcoded the url as a string or otherwise
messed up). It raises an error to let the user know about their mistake.
"""
if not PcxAutoSchema:
# Error occurred in a dictionary in docs_settings.py, not in PcxAutoSchema initialization.
raise ValueError(
f"Check your {dictionary_name} dictionary in PennCourses/docs_settings.py "
f"for an invalid key: {str(key)}. You should be calling the reverse_func function "
"for all your keys. Reverse_func returns a function which returns a string."
)
else:
assert traceback is not None # indicates autodoc code error, not user error
raise ValueError(
f"Check your {dictionary_name} dictionary in PcxAutoSchema initialization at "
f"{traceback} for an invalid key: {str(key)}. You should be calling the reverse_func "
"function for all your keys. Reverse_func returns a function which returns a string."
)
def split_camel(w):
return re.sub("([a-z0-9])([A-Z])", lambda x: x.groups()[0] + " " + x.groups()[1], w)
def pluralize_word(s):
return s + "s" # naive solution because this is how it is done in DRF
# Customization dicts populated by PcxAutoSchema __init__ method calls
# A cumulative version of the response_codes parameter to PcxAutoSchema:
cumulative_response_codes = dict()
# A cumulative version of the override_request_schema parameter to PcxAutoSchema:
cumulative_override_request_schema = dict()
# A cumulative version of the override_response_schema parameter to PcxAutoSchema:
cumulative_override_response_schema = dict()
# A cumulative version of the custom_path_parameter_desc parameter to PcxAutoSchema:
cumulative_cppd = dict()
# A cumulative version of the custom_parameters parameter to PcxAutoSchema:
cumulative_cp = dict()
class JSONOpenAPICustomTagGroupsRenderer(JSONOpenAPIRenderer):
def render(self, data_raw, media_type=None, renderer_context=None):
"""
This overridden method modifies the JSON OpenAPI schema generated by Django
to add tag groups, and most of the other customization specified above.
It was written by referencing the existing schema at /api/openapi
and also an example schema (written in YAML instead of JSON, but still
easily interpretable as JSON) from a Redoc example:
https://github.com/Redocly/redoc/blob/master/demo/openapi.yaml
"""
# The following resolves JSON refs which are not handled automatically in Python dicts
# https://swagger.io/docs/specification/using-ref/
data = jsonref.loads(json.dumps(data_raw))
# Determine existing tags and create a map from tag to a list of the corresponding dicts
# of nested schema objects at paths/{path}/{method} in the OpenAPI schema (for all
# the paths/methods which have that tag).
# If any routes do not have tags, add the 'Misc' tag to them, which will be put in
# the 'Other' tag group automatically, below.
tags = set()
tag_to_dicts = dict()
for x in data["paths"].values():
for v in x.values():
if "tags" in v.keys():
tags.update(v["tags"])
for t in v["tags"]:
if t not in tag_to_dicts.keys():
tag_to_dicts[t] = []
tag_to_dicts[t].append(v)
else:
v["tags"] = ["Misc"]
tags.add("Misc")
if "Misc" not in tag_to_dicts.keys():
tag_to_dicts["Misc"] = []
tag_to_dicts["Misc"].append(v)
# A function to change tag names (adds requested changes to a dict which will be
# cleared after the for tag in tags loop below finishes; it is done this way since
# the tags set cannot be modified while it is being iterated over).
changes = dict()
def update_tag(old_tag, new_tag):
for val in tag_to_dicts[old_tag]:
val["tags"] = [(t if t != old_tag else new_tag) for t in val["tags"]]
lst = tag_to_dicts.pop(old_tag)
tag_to_dicts[new_tag] = lst
changes[old_tag] = new_tag # since tags cannot be updated while iterating through tags
return new_tag
# Pluralize tag name if all views in tag are lists, and apply custom tag names from
# custom_tag_names dict defined above.
for tag in tags:
tag = update_tag(tag, split_camel(tag))
all_list = all([("list" in v["operationId"].lower()) for v in tag_to_dicts[tag]])
if all_list: # if all views in tag are lists, pluralize tag name
tag = update_tag(
tag, " ".join(tag.split(" ")[:-1] + [pluralize_word(tag.split(" ")[-1])])
)
if tag in custom_tag_names.keys(): # rename custom tags
tag = update_tag(tag, custom_tag_names[tag])
# Remove 'required' flags from responses (it doesn't make sense for a response
# item to be 'required').
def delete_required_dfs(dictionary):
if not isinstance(dictionary, dict):
return None
dictionary.pop("required", None)
for value in dictionary.values():
delete_required_dfs(value)
for path_name, val in data["paths"].items():
for method_name, v in val.items():
v["responses"] = deepcopy(v["responses"])
delete_required_dfs(v["responses"])
# Since tags could not be updated while we were iterating through tags above,
# we update them now.
for k, v in changes.items():
tags.remove(k)
tags.add(v)
# Add custom tag descriptions from the custom_tag_descriptions dict defined above
data["tags"] = [
{"name": tag, "description": custom_tag_descriptions.get(tag, "")} for tag in tags
]
# Add tags to tag groups based on the tag group abbreviation in the name of the tag
# (these abbreviations are added as prefixes of the tag names automatically in the
# get_tags method of PcxAutoSchema).
tags_to_tag_groups = dict()
for t in tags:
for k in tag_group_abbreviations.keys():
# Assigning the tag groups like this prevents tag abbreviations being substrings
# of each other from being problematic; the longest matching abbreviation is
# used (so even if another tag group abbreviation is a substring, it won't be
# mistakenly used for the tag group).
if k in t and (
t not in tags_to_tag_groups.keys() or len(k) > len(tags_to_tag_groups[t])
):
tags_to_tag_groups[t] = k
data["x-tagGroups"] = [
{"name": v, "tags": [t for t in tags if tags_to_tag_groups[t] == k]}
for k, v in tag_group_abbreviations.items()
]
# Remove empty tag groups
data["x-tagGroups"] = [g for g in data["x-tagGroups"] if len(g["tags"]) != 0]
# This code ensures that no path/methods in optional dictionary kwargs passed to
# PcxAutoSchema __init__ methods are invalid (indicating user error)
for original_kwarg, parameter_name, parameter_dict in [
("response_codes", "cumulative_response_codes", cumulative_response_codes),
(
"override_request_schema",
"cumulative_override_request_schema",
cumulative_override_request_schema,
),
(
"override_response_schema",
"cumulative_override_response_schema",
cumulative_override_response_schema,
),
("custom_path_parameter_desc", "cumulative_cppd", cumulative_cppd),
("custom_parameters", "cumulative_cp", cumulative_cp),
]:
for path_func in parameter_dict:
traceback = parameter_dict[path_func]["traceback"]
if not callable(path_func) or not isinstance(path_func(), str):
not_using_reverse_func(
original_kwarg, path_func, PcxAutoSchema=True, traceback=traceback
)
path = path_func()
if path not in data["paths"].keys():
raise ValueError(
f"Check the {original_kwarg} input to PcxAutoSchema instantiation at "
f"{traceback}; invalid path found: '{path}'."
+ (
"If 'id' is in your args list, check if you set primary_key=True for "
"some field in the relevant model, and if so change 'id' "
"in your args list to the name of that field."
if "id" in path
else ""
)
)
for method in parameter_dict[path_func]:
if method == "traceback":
continue
if method.lower() not in data["paths"][path].keys():
raise ValueError(
f"Check the {original_kwarg} input to PcxAutoSchema instantiation at "
f"{traceback}; invalid method '{method}' for path '{path}'"
)
# Convert cumulative_cp keys to strings (necessary since keys are paths indicated
# by reverse_func).
new_cumulative_cp = dict()
for key, value in cumulative_cp.items():
if not callable(key) or not isinstance(key(), str):
not_using_reverse_func(
"custom_parameters",
key,
PcxAutoSchema=True,
traceback=cumulative_cp[key]["traceback"],
)
new_cumulative_cp[key()] = value
# Update query parameter documentation
for path_name, val in data["paths"].items():
if path_name not in new_cumulative_cp:
continue
for method_name, v in val.items():
method_name = method_name.upper()
if method_name.upper() not in new_cumulative_cp[path_name]:
continue
custom_query_params = new_cumulative_cp[path_name][method_name]
custom_query_params_names = {param_ob["name"] for param_ob in custom_query_params}
v["parameters"] = [
param_ob
for param_ob in v["parameters"]
if param_ob["name"] not in custom_query_params_names
] + custom_query_params
# Make any additional manual changes to the schema programmed by the user
make_manual_schema_changes(data)
return jsonref.dumps(data, indent=2).encode("utf-8")
class PcxAutoSchema(AutoSchema):
"""
This custom subclass serves to improve AutoSchema in terms of customizability, and
quality of inference in some non-customized cases.
https://www.django-rest-framework.org/api-guide/schemas/#autoschema
"""
def __new__(
cls,
*args,
response_codes=None,
override_request_schema=None,
override_response_schema=None,
custom_path_parameter_desc=None,
custom_parameters=None,
**kwargs,
):
"""
An overridden __new__ method which adds a created_at property to each PcxAutoSchema
instance indicating the file/line from which it was instantiated (useful for debugging).
"""
new_instance = super(PcxAutoSchema, cls).__new__(cls, *args, **kwargs)
stack_trace = inspect.stack()
created_at = "%s:%d" % (stack_trace[1][1], stack_trace[1][2])
new_instance.created_at = created_at
return new_instance
# Overrides, uses overridden method
# https://www.django-rest-framework.org/api-guide/schemas/#autoschema__init__-kwargs
def __init__(
self,
*args,
response_codes=None,
override_request_schema=None,
override_response_schema=None,
custom_path_parameter_desc=None,
custom_parameters=None,
**kwargs,
):
"""
This custom __init__ method deals with optional passed-in kwargs such as
response_codes, override_response_schema, and custom_path_parameter_desc.
"""
def fail(param, hint):
"""
A function to generate an error message if validation of one of the passed-in
kwargs fails.
"""
raise ValueError(
f"Invalid {param} kwarg passed into PcxAutoSchema at {self.created_at}; please "
f"check the meta docs in PennCourses/docs_settings.py for an explanation of "
f"the proper format of this kwarg. Hint:\n{hint}"
)
# Validate that each of the passed-in kwargs are nested dictionaries of the correct depth
for param_name, param_dict in [
("response_codes", response_codes),
("override_request_schema", override_request_schema),
("override_response_schema", override_response_schema),
("custom_path_parameter_desc", custom_path_parameter_desc),
("custom_parameters", custom_parameters),
]:
if param_dict is not None:
if not isinstance(param_dict, dict):
fail(param_name, f"The {param_name} kwarg must be a dict.")
for dictionary in param_dict.values():
if not isinstance(dictionary, dict):
fail(param_name, f"All values of the {param_name} dict must be dicts.")
for nested_dictionary in dictionary.values():
if param_name == "custom_parameters":
if not isinstance(nested_dictionary, list):
fail(
param_name,
f"All values of the dict values of {param_name} must be lists.",
)
continue
if not isinstance(nested_dictionary, dict):
fail(
param_name,
f"All values of the dict values of {param_name} must be dicts.",
)
if param_name in [
"override_request_schema",
"override_response_schema",
]:
continue
for value in nested_dictionary.values():
if isinstance(value, dict):
fail(
param_name,
f"Too deep nested dictionaries found in {param_name}.",
)
# Handle passed-in custom response codes
global cumulative_response_codes
if response_codes is None:
self.response_codes = dict()
else:
response_codes = deepcopy(response_codes)
for key, d in response_codes.items():
response_codes[key] = {k.upper(): v for k, v in d.items()}
self.response_codes = response_codes
for_cumulative_response_codes = deepcopy(response_codes)
for dictionary in for_cumulative_response_codes.values():
dictionary["traceback"] = self.created_at
cumulative_response_codes = {
**cumulative_response_codes,
**for_cumulative_response_codes,
}
# Handle passed-in customized request schemas