From 801b94a7250995e68a9cecc498a8a20a40da58c6 Mon Sep 17 00:00:00 2001 From: cfournier Date: Fri, 24 Jul 2026 13:42:45 -0400 Subject: [PATCH] API changes - Added Grade Data endpoints --- docs.html | 439 ++++++++++++++++++++++++++++++++++++++++++++++++++++ docs.md | 235 +++++++++++++++++++++++++++- handlers.go | 232 +++++++++++++++++++++++++++ main.go | 4 + supabase.go | 97 ++++++++++++ 5 files changed, 1006 insertions(+), 1 deletion(-) diff --git a/docs.html b/docs.html index d1f1c8c..cd52928 100644 --- a/docs.html +++ b/docs.html @@ -61,6 +61,21 @@

Endpoints

Get a list of 4-letter department codes jump + + /v0/grades + Get grade distributions for individual sections + jump + + + /v0/grades/summary + Get grade distributions aggregated by course, term, or instructor + jump + + + /v0/grades/terms + Get the terms for which grade data is available + jump +

/v0/

@@ -789,6 +804,430 @@

Output

+

/v0/grades

+

(back to endpoints)

+

Gets grade distributions for individual course sections, as released by the University Registrar under the Maryland Public Information Act. Data covers fall and spring terms from Fall 2010 through Spring 2026; winter and summer terms were not released.

+

For counts aggregated across sections, terms, or instructors, use the summary endpoint below.

+

Query parameters

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
paramdescriptionexample
courseCodes (optional)A string of one or multiple comma-separated course codes; cannot be combined with prefix or number.courseCodes=CMSC132,MATH141
prefix (optional)The course prefix to match records to; for instance, CMSC1 would match all CMSC1XX courses.prefix=CMSC1
number (optional)The course number to search for across multiple departments.number=433
term (optional)A string of equalities/inequalities to filter by term code. Possible expressions are: eq, lte, lt, gt, gte, neq, and in for a specific set. For multiple conditions, use multiple term arguments.term=gte.202008 or term=in.(202408,202501)
instructor (optional)Return only sections taught by the given instructor, written in "First Last" order. This field is case-sensitive.instructor=Larry%20Herman
instructorSource (optional)A comma-separated list of instructor_source values to include. Defaults to all. Use reported,lead to exclude attributions carried across lecture groups.instructorSource=reported
gpa (optional)A string of equalities/inequalities to filter by computed GPA.gpa=gte.3.5
graded (optional)A string of equalities/inequalities to filter by how many students received a letter grade. Useful for excluding sections too small to draw conclusions from.graded=gte.30
limit (optional)Maximum number of records to return; defaults to 100, maximum of 500.limit=10
offset (optional)How many records to skip when returning results; defaults to 0.offset=10
sortBy (optional)A comma-separated list of which columns to sort by, ascending (.asc) or descending (.desc).sortBy=term.desc,sec_code.asc
+

Output

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
fieldtypedescription
termintSix-digit term code: the four-digit year followed by the month the term begins (01 spring, 08 fall). Fall 2024 is 202408.
course_codestringThe course code, matching course_code elsewhere in this API. A course that has since been retired will have grade records but no entry in /v0/courses.
sec_codestringThe section code, matching sec_code on /v0/sections.
instructorstring or nullThe instructor exactly as the Registrar printed them, in "Last, First Middle" order. Null where the release left the field blank.
instructor_namestring or nullThe effective instructor in "First Last" order, suitable for matching against /v0/instructors. May be populated where instructor is null; see instructor_source.
instructor_sourcestring or nullHow instructor_name was determined. reported means the Registrar named them on this row. lead means the name was carried from the lead section of the same lecture, which is how the release records discussion and lab sections. course means it was carried from a different lecture group or a differently-coded offering, and is materially less reliable. Null where no section of the course was named.
totalintStudents enrolled, as reported. From Fall 2017 this equals the sum of the fifteen grade buckets; in earlier terms it can exceed that sum by a few students whose outcome the older report did not categorize. Prefer graded as a denominator when comparing across that boundary.
a_plus, a, a_minusd_minus, fintStudents receiving each letter grade.
wintStudents who withdrew.
otherintStudents receiving a non-letter outcome (pass/fail, incomplete, audit, and similar).
gradedintStudents who received a letter grade; the denominator used for gpa.
gpanumber or nullMean GPA on the UMD 4.0 scale over graded students. Withdrawals and non-letter outcomes are excluded from both the numerator and the denominator. Null where nobody received a letter grade.
+

Examples

+
Getting every section of a course in one term
+

Request: GET http://api.jupiterp.com/v0/grades?courseCodes=CMSC132&term=eq.202408&limit=2

+

Response:

+
[
+          {
+            "term": 202408,
+            "course_code": "CMSC132",
+            "sec_code": "0101",
+            "instructor": "Herman, Larry",
+            "instructor_name": "Larry Herman",
+            "instructor_source": "reported",
+            "total": 32,
+            "a_plus": 0,
+            "a": 1,
+            "a_minus": 4,
+            "graded": 30,
+            "gpa": 2.583
+          },
+          {
+            "term": 202408,
+            "course_code": "CMSC132",
+            "sec_code": "0102",
+            "instructor": null,
+            "instructor_name": "Larry Herman",
+            "instructor_source": "lead",
+            "total": 34,
+            "a_plus": 2,
+            "a": 7,
+            "a_minus": 5,
+            "graded": 34,
+            "gpa": 3.118
+          }
+        ]
+        
+

Note the second record: the release lists the instructor once against the lecture and leaves the discussion sections blank, so instructor is null while instructor_name carries the lecturer's name and instructor_source records that it was inferred.

+

/v0/grades/summary

+

(back to endpoints)

+

Gets grade distributions with the individual sections summed together. This is usually the endpoint you want: groupBy=course answers "how hard is this course", groupBy=term answers "has it changed", and groupBy=instructor answers "who should I take it with".

+

Query parameters

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
paramdescriptionexample
groupBy (optional)One of course (default), term, or instructor. course returns one record per course across every term on file; term returns one record per course per term; instructor returns one record per course per instructor.groupBy=instructor
includeCarried (optional)Only meaningful with groupBy=instructor. When true, also counts sections whose instructor was carried across lecture groups (instructor_source of course). Wider coverage, lower confidence. Defaults to false.includeCarried=true
courseCodes (optional)A string of one or multiple comma-separated course codes; cannot be combined with prefix or number.courseCodes=CMSC132
prefix (optional)The course prefix to match records to.prefix=CMSC3
number (optional)The course number to search for across multiple departments.number=433
term (optional)Equalities/inequalities to filter by term code. Only applied when groupBy=term, since the other groupings are aggregated across every term on file.term=gte.202008
instructor (optional)Return only the given instructor, in "First Last" order. Only applied when groupBy=instructor. Case-sensitive.instructor=Anwar%20Mamat
gpa (optional)Equalities/inequalities to filter by the aggregated GPA.gpa=gte.3.0
minStudents (optional)Exclude groups totalling fewer than this many students.minStudents=100
limit (optional)Maximum number of records to return; defaults to 100, maximum of 500.limit=10
offset (optional)How many records to skip; defaults to 0.offset=10
sortBy (optional)A comma-separated list of which columns to sort by.sortBy=gpa.desc
+

Output

+

All groupings return the summed grade buckets (a_plus through other), total, graded, and gpa, defined exactly as on /v0/grades. In addition:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
fieldtypedescription
course_codestringThe course these counts are for.
termintOnly present when groupBy=term.
instructorstringOnly present when groupBy=instructor; the instructor in "First Last" order.
section_countintHow many individual sections were summed.
term_countintHow many distinct terms are represented. Not present when groupBy=term.
first_term, last_termintThe earliest and latest term represented. Not present when groupBy=term.
+

Examples

+
How hard is a course, over its whole history
+

Request: GET http://api.jupiterp.com/v0/grades/summary?courseCodes=CMSC351

+

Response:

+
[
+          {
+            "course_code": "CMSC351",
+            "section_count": 97,
+            "term_count": 32,
+            "first_term": 201008,
+            "last_term": 202601,
+            "total": 14969,
+            "graded": 13346,
+            "a_plus": 450,
+            "a": 1578,
+            "a_minus": 1153,
+            "b_plus": 1313,
+            "b": 2169,
+            "b_minus": 1441,
+            "c_plus": 1263,
+            "c": 1583,
+            "c_minus": 1002,
+            "d_plus": 185,
+            "d": 846,
+            "d_minus": 70,
+            "f": 293,
+            "w": 738,
+            "other": 791,
+            "gpa": 2.699
+          }
+        ]
+        
+
Comparing instructors for a course
+

Request: GET http://api.jupiterp.com/v0/grades/summary?groupBy=instructor&courseCodes=CMSC330&minStudents=1000&sortBy=gpa.desc

+

Response:

+
[
+          {
+            "course_code": "CMSC330",
+            "instructor": "Michael W. Hicks",
+            "section_count": 37,
+            "term_count": 7,
+            "first_term": 201301,
+            "last_term": 202101,
+            "total": 1205,
+            "graded": 1076,
+            "gpa": 3.123
+          },
+          {
+            "course_code": "CMSC330",
+            "instructor": "Roger D. Eastman",
+            "section_count": 39,
+            "term_count": 5,
+            "first_term": 201808,
+            "last_term": 202108,
+            "total": 1258,
+            "graded": 1045,
+            "gpa": 3.047
+          }
+        ]
+        
+

/v0/grades/terms

+

(back to endpoints)

+

Gets every term for which grade data has been loaded, newest first. Takes no parameters. Useful for discovering coverage before querying, since the released data covers fall and spring only.

+

Output

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
fieldtypedescription
termintSix-digit term code.
section_countintSections with grade data in this term.
course_countintDistinct courses with grade data in this term.
totalintStudents enrolled across every section.
gradedintStudents who received a letter grade.
gpanumberMean GPA across the whole university for the term.
+

Example

+

Request: GET http://api.jupiterp.com/v0/grades/terms

+

Response:

+
[
+          {
+            "term": 202601,
+            "section_count": 6543,
+            "course_count": 3213,
+            "total": 168366,
+            "graded": 161397,
+            "gpa": 3.488
+          },
+          {
+            "term": 202508,
+            "section_count": 7056,
+            "course_count": 3263,
+            "total": 186608,
+            "graded": 176931,
+            "gpa": 3.503
+          }
+        ]
+        
\ No newline at end of file diff --git a/docs.md b/docs.md index fdce91f..8c0b84c 100644 --- a/docs.md +++ b/docs.md @@ -20,6 +20,9 @@ Feel free to view or contribute to the project [on GitHub](https://www.github.co | `/v0/instructors` | Get a list of instructors and their ratings | [jump](#-v0-instructors-) | | `/v0/instructors/active` | Get a list of instructors actively teaching a course | [jump](#-v0-instructors-active-) | | `/v0/deptList` | Get a list of 4-letter department codes | [jump](#-v0-deptlist-) | +| `/v0/grades` | Get grade distributions for individual sections | [jump](#-v0-grades-) | +| `/v0/grades/summary` | Get grade distributions aggregated by course, term, or instructor | [jump](#-v0-grades-summary-) | +| `/v0/grades/terms` | Get the terms for which grade data is available | [jump](#-v0-grades-terms-) | ### `/v0/` @@ -496,4 +499,234 @@ None | field | type | description | | :-- | :--: | :-- | | `dept_code` | string | A unique 4-letter department code | -| `name` | string | The name of the department | \ No newline at end of file +| `name` | string | The name of the department | +### `/v0/grades` + +[(back to endpoints)](#endpoints) + +Gets grade distributions for individual course sections, as released by the University Registrar under the Maryland Public Information Act. Data covers fall and spring terms from Fall 2010 through Spring 2026; winter and summer terms were not released. + +For counts aggregated across sections, terms, or instructors, use the `summary` endpoint below. + +#### Query parameters + +| param | description | example | +|:--|:--|:--| +| `courseCodes` (optional) | A string of one or multiple comma-separated course codes; cannot be combined with `prefix` or `number`. | `courseCodes=CMSC132,MATH141` | +| `prefix` (optional) | The course prefix to match records to; for instance, `CMSC1` would match all CMSC1XX courses. | `prefix=CMSC1` | +| `number` (optional) | The course number to search for across multiple departments. | `number=433` | +| `term` (optional) | A string of equalities/inequalities to filter by term code. Possible expressions are: `eq`, `lte`, `lt`, `gt`, `gte`, `neq`, and `in` for a specific set. For multiple conditions, use multiple `term` arguments. | `term=gte.202008` or `term=in.(202408,202501)` | +| `instructor` (optional) | Return only sections taught by the given instructor, written in "First Last" order. This field is case-sensitive. | `instructor=Larry%20Herman` | +| `instructorSource` (optional) | A comma-separated list of `instructor_source` values to include. Defaults to all. Use `reported,lead` to exclude attributions carried across lecture groups. | `instructorSource=reported` | +| `gpa` (optional) | A string of equalities/inequalities to filter by computed GPA. | `gpa=gte.3.5` | +| `graded` (optional) | A string of equalities/inequalities to filter by how many students received a letter grade. Useful for excluding sections too small to draw conclusions from. | `graded=gte.30` | +| `limit` (optional) | Maximum number of records to return; defaults to 100, maximum of 500. | `limit=10` | +| `offset` (optional) | How many records to skip when returning results; defaults to 0. | `offset=10` | +| `sortBy` (optional) | A comma-separated list of which columns to sort by, ascending (`.asc`) or descending (`.desc`). | `sortBy=term.desc,sec_code.asc` | + +#### Output + +| field | type | description | +| :-- | :--: | :-- | +| `term` | int | Six-digit term code: the four-digit year followed by the month the term begins (`01` spring, `08` fall). Fall 2024 is `202408`. | +| `course_code` | string | The course code, matching `course_code` elsewhere in this API. A course that has since been retired will have grade records but no entry in `/v0/courses`. | +| `sec_code` | string | The section code, matching `sec_code` on `/v0/sections`. | +| `instructor` | string or null | The instructor exactly as the Registrar printed them, in "Last, First Middle" order. Null where the release left the field blank. | +| `instructor_name` | string or null | The effective instructor in "First Last" order, suitable for matching against `/v0/instructors`. May be populated where `instructor` is null; see `instructor_source`. | +| `instructor_source` | string or null | How `instructor_name` was determined. `reported` means the Registrar named them on this row. `lead` means the name was carried from the lead section of the same lecture, which is how the release records discussion and lab sections. `course` means it was carried from a different lecture group or a differently-coded offering, and is materially less reliable. Null where no section of the course was named. | +| `total` | int | Students enrolled, as reported. From Fall 2017 this equals the sum of the fifteen grade buckets; in earlier terms it can exceed that sum by a few students whose outcome the older report did not categorize. Prefer `graded` as a denominator when comparing across that boundary. | +| `a_plus`, `a`, `a_minus` … `d_minus`, `f` | int | Students receiving each letter grade. | +| `w` | int | Students who withdrew. | +| `other` | int | Students receiving a non-letter outcome (pass/fail, incomplete, audit, and similar). | +| `graded` | int | Students who received a letter grade; the denominator used for `gpa`. | +| `gpa` | number or null | Mean GPA on the UMD 4.0 scale over `graded` students. Withdrawals and non-letter outcomes are excluded from both the numerator and the denominator. Null where nobody received a letter grade. | + +#### Examples + +##### Getting every section of a course in one term + +Request: `GET http://api.jupiterp.com/v0/grades?courseCodes=CMSC132&term=eq.202408&limit=2` + +Response: +``` +[ + { + "term": 202408, + "course_code": "CMSC132", + "sec_code": "0101", + "instructor": "Herman, Larry", + "instructor_name": "Larry Herman", + "instructor_source": "reported", + "total": 32, + "a_plus": 0, + "a": 1, + "a_minus": 4, + "graded": 30, + "gpa": 2.583 + }, + { + "term": 202408, + "course_code": "CMSC132", + "sec_code": "0102", + "instructor": null, + "instructor_name": "Larry Herman", + "instructor_source": "lead", + "total": 34, + "a_plus": 2, + "a": 7, + "a_minus": 5, + "graded": 34, + "gpa": 3.118 + } +] +``` + +Note the second record: the release lists the instructor once against the lecture and leaves the discussion sections blank, so `instructor` is null while `instructor_name` carries the lecturer's name and `instructor_source` records that it was inferred. + +### `/v0/grades/summary` + +[(back to endpoints)](#endpoints) + +Gets grade distributions with the individual sections summed together. This is usually the endpoint you want: `groupBy=course` answers "how hard is this course", `groupBy=term` answers "has it changed", and `groupBy=instructor` answers "who should I take it with". + +#### Query parameters + +| param | description | example | +|:--|:--|:--| +| `groupBy` (optional) | One of `course` (default), `term`, or `instructor`. `course` returns one record per course across every term on file; `term` returns one record per course per term; `instructor` returns one record per course per instructor. | `groupBy=instructor` | +| `includeCarried` (optional) | Only meaningful with `groupBy=instructor`. When true, also counts sections whose instructor was carried across lecture groups (`instructor_source` of `course`). Wider coverage, lower confidence. Defaults to false. | `includeCarried=true` | +| `courseCodes` (optional) | A string of one or multiple comma-separated course codes; cannot be combined with `prefix` or `number`. | `courseCodes=CMSC132` | +| `prefix` (optional) | The course prefix to match records to. | `prefix=CMSC3` | +| `number` (optional) | The course number to search for across multiple departments. | `number=433` | +| `term` (optional) | Equalities/inequalities to filter by term code. Only applied when `groupBy=term`, since the other groupings are aggregated across every term on file. | `term=gte.202008` | +| `instructor` (optional) | Return only the given instructor, in "First Last" order. Only applied when `groupBy=instructor`. Case-sensitive. | `instructor=Anwar%20Mamat` | +| `gpa` (optional) | Equalities/inequalities to filter by the aggregated GPA. | `gpa=gte.3.0` | +| `minStudents` (optional) | Exclude groups totalling fewer than this many students. | `minStudents=100` | +| `limit` (optional) | Maximum number of records to return; defaults to 100, maximum of 500. | `limit=10` | +| `offset` (optional) | How many records to skip; defaults to 0. | `offset=10` | +| `sortBy` (optional) | A comma-separated list of which columns to sort by. | `sortBy=gpa.desc` | + +#### Output + +All groupings return the summed grade buckets (`a_plus` through `other`), `total`, `graded`, and `gpa`, defined exactly as on `/v0/grades`. In addition: + +| field | type | description | +| :-- | :--: | :-- | +| `course_code` | string | The course these counts are for. | +| `term` | int | Only present when `groupBy=term`. | +| `instructor` | string | Only present when `groupBy=instructor`; the instructor in "First Last" order. | +| `section_count` | int | How many individual sections were summed. | +| `term_count` | int | How many distinct terms are represented. Not present when `groupBy=term`. | +| `first_term`, `last_term` | int | The earliest and latest term represented. Not present when `groupBy=term`. | + +#### Examples + +##### How hard is a course, over its whole history + +Request: `GET http://api.jupiterp.com/v0/grades/summary?courseCodes=CMSC351` + +Response: +``` +[ + { + "course_code": "CMSC351", + "section_count": 97, + "term_count": 32, + "first_term": 201008, + "last_term": 202601, + "total": 14969, + "graded": 13346, + "a_plus": 450, + "a": 1578, + "a_minus": 1153, + "b_plus": 1313, + "b": 2169, + "b_minus": 1441, + "c_plus": 1263, + "c": 1583, + "c_minus": 1002, + "d_plus": 185, + "d": 846, + "d_minus": 70, + "f": 293, + "w": 738, + "other": 791, + "gpa": 2.699 + } +] +``` + +##### Comparing instructors for a course + +Request: `GET http://api.jupiterp.com/v0/grades/summary?groupBy=instructor&courseCodes=CMSC330&minStudents=1000&sortBy=gpa.desc` + +Response: +``` +[ + { + "course_code": "CMSC330", + "instructor": "Michael W. Hicks", + "section_count": 37, + "term_count": 7, + "first_term": 201301, + "last_term": 202101, + "total": 1205, + "graded": 1076, + "gpa": 3.123 + }, + { + "course_code": "CMSC330", + "instructor": "Roger D. Eastman", + "section_count": 39, + "term_count": 5, + "first_term": 201808, + "last_term": 202108, + "total": 1258, + "graded": 1045, + "gpa": 3.047 + } +] +``` + +### `/v0/grades/terms` + +[(back to endpoints)](#endpoints) + +Gets every term for which grade data has been loaded, newest first. Takes no parameters. Useful for discovering coverage before querying, since the released data covers fall and spring only. + +#### Output + +| field | type | description | +| :-- | :--: | :-- | +| `term` | int | Six-digit term code. | +| `section_count` | int | Sections with grade data in this term. | +| `course_count` | int | Distinct courses with grade data in this term. | +| `total` | int | Students enrolled across every section. | +| `graded` | int | Students who received a letter grade. | +| `gpa` | number | Mean GPA across the whole university for the term. | + +#### Example + +Request: `GET http://api.jupiterp.com/v0/grades/terms` + +Response: +``` +[ + { + "term": 202601, + "section_count": 6543, + "course_count": 3213, + "total": 168366, + "graded": 161397, + "gpa": 3.488 + }, + { + "term": 202508, + "section_count": 7056, + "course_count": 3263, + "total": 186608, + "graded": 176931, + "gpa": 3.503 + } +] +``` \ No newline at end of file diff --git a/handlers.go b/handlers.go index 3dda22a..4de03cd 100644 --- a/handlers.go +++ b/handlers.go @@ -20,8 +20,25 @@ const ( instructorsTTL time.Duration = 12 * time.Hour departmentsTTL time.Duration = 2 * time.Hour sectionsTTL time.Duration = 15 * time.Minute + // Grade data changes once a term, when a new records request is fulfilled. + gradesTTL time.Duration = 12 * time.Hour ) +// Views backing the /v0/grades/summary endpoint, selected by `groupBy`. +const ( + gradeSummaryByCourseTable = "course_grades" + gradeSummaryByTermTable = "course_term_grades" + gradeSummaryByInstructorTable = "course_instructor_grades" + // As above, but also counting sections whose instructor was carried across + // lecture groups rather than within one. See `instructor_source`. + gradeSummaryByInstructorAllTable = "course_instructor_grades_all" +) + +func isInstructorSummary(table string) bool { + return table == gradeSummaryByInstructorTable || + table == gradeSummaryByInstructorAllTable +} + /* ================================= ARGS ================================== */ // For all argument structs, the first character of a field must be upper-case // so it can be written to when parsing query args. @@ -174,8 +191,141 @@ func (i *InstructorArgs) setDefaults() { } } +// Arguments for getting section-level grade distributions. +type GradesArgs struct { + // A string of one or multiple comma-separated course codes. + CourseCodes string `form:"courseCodes"` + + // The course prefix to filter by (ex. CMSC1 for all CMSC1XX courses). + Prefix string `form:"prefix"` + + // The number to filter by (ex. 132 for CMSC132). + Number string `form:"number"` + + // Conditions for the term code; for example, gte.202008. For a specific + // set of terms, in.(202408,202501). + Terms []string `form:"term"` + + // Instructor name filter, in "First Last" order (case sensitive, exact). + Instructor string `form:"instructor"` + + // A comma-separated list of instructor_source values to include; defaults + // to every row. Use reported,lead to exclude attributions carried across + // lecture groups. + InstructorSource string `form:"instructorSource"` + + // Conditions for GPA; for example, gte.3.5 + Gpa []string `form:"gpa"` + + // Conditions for the number of students who received a letter grade; for + // example, gte.30. Useful for excluding sections too small to read + // anything into. + Graded []string `form:"graded"` + + // Number of records to return per page. + // Default value: 100; Maximum value: 500 + Limit uint16 `form:"limit" binding:"omitempty,min=1,max=500"` + + // The offset of records to view. + // Default value: 0 + Offset uint16 `form:"offset"` + + // String of columns to sort by + SortBy string `form:"sortBy"` +} + +func (g *GradesArgs) setDefaults() { + if g.Limit == 0 { + g.Limit = 100 + } +} + +// Arguments for getting aggregated grade distributions. +type GradeSummaryArgs struct { + // How to group the results: course (default), term, or instructor. + GroupBy string `form:"groupBy" binding:"omitempty,oneof=course term instructor"` + + // When grouping by instructor, also count sections whose instructor was + // carried from a different lecture group or a differently-coded offering. + // Wider coverage, lower confidence. + IncludeCarried bool `form:"includeCarried"` + + // A string of one or multiple comma-separated course codes. + CourseCodes string `form:"courseCodes"` + + // The course prefix to filter by (ex. CMSC1 for all CMSC1XX courses). + Prefix string `form:"prefix"` + + // The number to filter by (ex. 132 for CMSC132). + Number string `form:"number"` + + // Conditions for the term code; only applied when groupBy=term, since the + // other groupings are aggregated across every term on file. + Terms []string `form:"term"` + + // Instructor name filter, in "First Last" order (case sensitive, exact). + // Only applied when groupBy=instructor. + Instructor string `form:"instructor"` + + // Conditions for GPA; for example, gte.3.5 + Gpa []string `form:"gpa"` + + // Exclude groups totalling fewer than this many students. + MinStudents uint16 `form:"minStudents"` + + // Number of records to return per page. + // Default value: 100; Maximum value: 500 + Limit uint16 `form:"limit" binding:"omitempty,min=1,max=500"` + + // The offset of records to view. + // Default value: 0 + Offset uint16 `form:"offset"` + + // String of columns to sort by + SortBy string `form:"sortBy"` +} + +func (g *GradeSummaryArgs) setDefaults() { + if g.Limit == 0 { + g.Limit = 100 + } + if g.GroupBy == "" { + g.GroupBy = "course" + } +} + +// Resolve `groupBy` and `includeCarried` to the view that serves them. +func (g GradeSummaryArgs) summaryTable() string { + switch g.GroupBy { + case "term": + return gradeSummaryByTermTable + case "instructor": + if g.IncludeCarried { + return gradeSummaryByInstructorAllTable + } + return gradeSummaryByInstructorTable + default: + return gradeSummaryByCourseTable + } +} + /* =============================== UTILITIES =============================== */ +// Reject requests that set more than one of the mutually exclusive course +// filters, all of which target the same column. +func checkCourseFilters(courseCodes, prefix, number string) error { + set := 0 + for _, arg := range []string{courseCodes, prefix, number} { + if arg != "" { + set++ + } + } + if set > 1 { + return errors.New("cannot specify more than one of courseCodes, prefix, and number") + } + return nil +} + // Takes the error from a failed query argument validation/binding and sends a // message to the caller listing any missing or invalid args. func sendInvalidArgsError(ctx *gin.Context, argsType reflect.Type, path string, err error) { @@ -516,3 +666,85 @@ func (client SupabaseClient) handleGetDepartments(ctx *gin.Context) { client.writeAndCacheResponse(ctx, res, path, key, departmentsTTL) } + +// Get section-level grade distributions. +// Example: /v0/grades?courseCodes=CMSC132&term=gte.202008&sortBy=term.desc +func (client SupabaseClient) handleGetGrades(ctx *gin.Context) { + path := "v0/grades" + + var args GradesArgs + if err := ctx.ShouldBindQuery(&args); err != nil { + sendInvalidArgsError(ctx, reflect.TypeOf(args), path, err) + return + } + if err := checkCourseFilters(args.CourseCodes, args.Prefix, args.Number); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + args.setDefaults() + + key := buildCacheKey(ctx.Request) + if client.serveFromCache(ctx, path, key) { + return + } + + // Get data from DB + res, err := client.getGrades(args) + if err != nil { + sendInternalError(ctx, path, err) + return + } + + client.writeAndCacheResponse(ctx, res, path, key, gradesTTL) +} + +// Get grade distributions aggregated by course, by course and term, or by +// course and instructor. +// Example: /v0/grades/summary?groupBy=instructor&courseCodes=CMSC330&minStudents=100 +func (client SupabaseClient) handleGetGradeSummary(ctx *gin.Context) { + path := "v0/grades/summary" + + var args GradeSummaryArgs + if err := ctx.ShouldBindQuery(&args); err != nil { + sendInvalidArgsError(ctx, reflect.TypeOf(args), path, err) + return + } + if err := checkCourseFilters(args.CourseCodes, args.Prefix, args.Number); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + args.setDefaults() + + key := buildCacheKey(ctx.Request) + if client.serveFromCache(ctx, path, key) { + return + } + + // Get data from DB + res, err := client.getGradeSummary(args, args.summaryTable()) + if err != nil { + sendInternalError(ctx, path, err) + return + } + + client.writeAndCacheResponse(ctx, res, path, key, gradesTTL) +} + +// Get every term for which grade data is available. +func (client SupabaseClient) handleGetGradeTerms(ctx *gin.Context) { + path := "v0/grades/terms" + + key := buildCacheKey(ctx.Request) + if client.serveFromCache(ctx, path, key) { + return + } + + // Get data from DB + res, err := client.getGradeTerms() + if err != nil { + sendInternalError(ctx, path, err) + return + } + + client.writeAndCacheResponse(ctx, res, path, key, gradesTTL) +} diff --git a/main.go b/main.go index 825eced..b03248a 100644 --- a/main.go +++ b/main.go @@ -79,6 +79,10 @@ func main() { v0.GET("/instructors", client.handleGetInstructors) // all instructors with ratings v0.GET("/instructors/active", client.handleGetActiveInstructors) // all instructors currently teaching + v0.GET("/grades", client.handleGetGrades) // section-level grade distributions + v0.GET("/grades/summary", client.handleGetGradeSummary) // grades aggregated by course, term, or instructor + v0.GET("/grades/terms", client.handleGetGradeTerms) // terms for which grade data exists + // Listen and serve on defined port log.Printf("Listening on port %s", port) r.Run(":" + port) diff --git a/supabase.go b/supabase.go index 4c8afcd..ce61663 100644 --- a/supabase.go +++ b/supabase.go @@ -199,3 +199,100 @@ func (s SupabaseClient) getDepartments() (*http.Response, error) { params.Set("order", "dept_code") return s.request("departments", params.Encode()) } + +// Apply the shared course-matching filters used by both grade endpoints. Only +// one of `courseCodes`, `prefix`, or `number` is honored; handlers reject +// requests that set more than one. +func applyCourseFilter(params url.Values, courseCodes, prefix, number string) { + if courseCodes != "" { + params.Set("course_code", fmt.Sprintf("in.(%s)", courseCodes)) + } else if prefix != "" { + params.Set("course_code", fmt.Sprintf("like.%s*", prefix)) + } else if number != "" { + params.Set("course_code", fmt.Sprintf("like.____%s*", number)) + } +} + +// Get section-level grade distributions. +func (s SupabaseClient) getGrades(args GradesArgs) (*http.Response, error) { + // SELECT * FROM grades + // WHERE course_code IN `args.CourseCodes` + // / WHERE course_code LIKE `args.Prefix`* + // / WHERE course_code LIKE ____`args.Number`* + // AND term `args.Terms` + // AND instructor_name = `args.Instructor` + // AND instructor_source IN `args.InstructorSource` + // AND gpa `args.Gpa` + // AND graded `args.Graded` + // OFFSET `args.Offset` LIMIT `args.Limit` + // SORT BY `args.SortBy` + params := url.Values{} + params.Set("select", "*") + applyCourseFilter(params, args.CourseCodes, args.Prefix, args.Number) + for _, cond := range args.Terms { + params.Add("term", cond) + } + for _, cond := range args.Gpa { + params.Add("gpa", cond) + } + for _, cond := range args.Graded { + params.Add("graded", cond) + } + if args.Instructor != "" { + params.Set("instructor_name", fmt.Sprintf("eq.%s", args.Instructor)) + } + if args.InstructorSource != "" { + params.Set("instructor_source", fmt.Sprintf("in.(%s)", args.InstructorSource)) + } + params.Set("offset", fmt.Sprintf("%d", args.Offset)) + params.Set("limit", fmt.Sprintf("%d", args.Limit)) + if args.SortBy != "" { + params.Set("order", args.SortBy) + } + return s.request("grades", params.Encode()) +} + +// Get aggregated grade distributions from one of the summary views. The view is +// chosen by the caller's `groupBy`; see `summaryTable`. +func (s SupabaseClient) getGradeSummary(args GradeSummaryArgs, table string) (*http.Response, error) { + // SELECT * FROM `table` + // WHERE course_code IN `args.CourseCodes` / LIKE `args.Prefix`* / LIKE ____`args.Number`* + // AND term `args.Terms` (only when the view carries a term column) + // AND instructor = `args.Instructor` (only on the instructor views) + // AND gpa `args.Gpa` + // AND total `args.MinStudents` + // OFFSET `args.Offset` LIMIT `args.Limit` + // SORT BY `args.SortBy` + params := url.Values{} + params.Set("select", "*") + applyCourseFilter(params, args.CourseCodes, args.Prefix, args.Number) + if table == gradeSummaryByTermTable { + for _, cond := range args.Terms { + params.Add("term", cond) + } + } + if args.Instructor != "" && isInstructorSummary(table) { + params.Set("instructor", fmt.Sprintf("eq.%s", args.Instructor)) + } + for _, cond := range args.Gpa { + params.Add("gpa", cond) + } + if args.MinStudents > 0 { + params.Set("total", fmt.Sprintf("gte.%d", args.MinStudents)) + } + params.Set("offset", fmt.Sprintf("%d", args.Offset)) + params.Set("limit", fmt.Sprintf("%d", args.Limit)) + if args.SortBy != "" { + params.Set("order", args.SortBy) + } + return s.request(table, params.Encode()) +} + +// Get every term for which grade data has been loaded, newest first. +func (s SupabaseClient) getGradeTerms() (*http.Response, error) { + // SELECT * FROM grade_terms ORDER BY term DESC + params := url.Values{} + params.Set("select", "*") + params.Set("order", "term.desc") + return s.request("grade_terms", params.Encode()) +}