Skip to content
This repository has been archived by the owner on Oct 18, 2024. It is now read-only.

feat: ✨ add enrollment history endpoint & scraper support #108

Merged
merged 14 commits into from
Nov 16, 2023
Merged
1 change: 1 addition & 0 deletions apps/api/src/routes/v1/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const resolvers: ApolloServerOptions<BaseContext>["resolvers"] = {
course: proxyRestApi("/v1/rest/courses", { pathArg: "courseId" }),
courses: proxyRestApi("/v1/rest/courses", { argsTransform: geTransform }),
allCourses: proxyRestApi("/v1/rest/courses/all"),
enrollmentHistory: proxyRestApi("/v1/rest/enrollmentHistory"),
rawGrades: proxyRestApi("/v1/rest/grades/raw"),
aggregateGrades: proxyRestApi("/v1/rest/grades/aggregate"),
gradesOptions: proxyRestApi("/v1/rest/grades/options"),
Expand Down
33 changes: 33 additions & 0 deletions apps/api/src/routes/v1/graphql/schema/enrollmentHistory.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
type EnrollmentHistory {
year: String!
quarter: Quarter!
sectionCode: String!
department: String!
courseNumber: String!
sectionType: SectionType!
sectionNum: String!
units: String
instructors: [String!]!
meetings: [String!]!
finalExam: String
dates: [String!]!
maxCapacityHistory: [String!]!
totalEnrolledHistory: [String!]!
waitlistHistory: [String!]!
waitlistCapHistory: [String!]!
requestedHistory: [String!]!
newOnlyReservedHistory: [String!]!
statusHistory: [String!]!
}

extend type Query {
enrollmentHistory(
year: String
quarter: Quarter
instructor: String
department: String
courseNumber: String
sectionCode: String
sectionType: SectionType
): [EnrollmentHistory!]!
}
34 changes: 34 additions & 0 deletions apps/api/src/routes/v1/rest/enrollmentHistory/+endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { PrismaClient } from "@libs/db";
import { createErrorResult, createOKResult } from "@libs/lambda";
import type { EnrollmentHistory } from "@peterportal-api/types";
import type { APIGatewayProxyHandler } from "aws-lambda";

import { QuerySchema } from "./schema";

const prisma = new PrismaClient();

export const GET: APIGatewayProxyHandler = async (event, context) => {
const { headers, queryStringParameters: query } = event;
const { awsRequestId: requestId } = context;

const maybeParsed = QuerySchema.safeParse(query);
if (!maybeParsed.success) {
return createErrorResult(400, maybeParsed.error, requestId);
}
const {
data: { instructor, ...data },
} = maybeParsed;

return createOKResult<EnrollmentHistory[]>(
(
await prisma.websocEnrollmentHistory.findMany({
where: { ...data, instructors: { array_contains: instructor } },
})
).map((x) => {
const { timestamp: _, ...obj } = x;
return obj as unknown as EnrollmentHistory;
}),
headers,
requestId,
);
};
23 changes: 23 additions & 0 deletions apps/api/src/routes/v1/rest/enrollmentHistory/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { quarters, sectionTypes } from "@peterportal-api/types";
import { z } from "zod";

export const QuerySchema = z.object({
year: z
.string()
.regex(/^\d{4}$/, { message: "Invalid year provided" })
.optional(),
quarter: z.enum(quarters, { invalid_type_error: "Invalid quarter provided" }).optional(),
instructor: z.string().optional(),
department: z.string().optional(),
courseNumber: z.string().optional(),
sectionCode: z
.string()
.regex(/^\d{5}$/, { message: "Invalid sectionCode provided" })
.transform((x) => Number.parseInt(x, 10))
.optional(),
sectionType: z
.enum(sectionTypes, { invalid_type_error: "Invalid sectionType provided" })
.optional(),
});

export type Query = z.infer<typeof QuerySchema>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
pagination_prev: null
pagination_next: null
---

import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";

# Enrollment History

The enrollment history endpoint allows users to
ecxyzzy marked this conversation as resolved.
Show resolved Hide resolved

## Query parameters for all endpoints

#### `year` string

The year to include.

#### `quarter` Fall | Winter | Spring | Summer1 | Summer10wk | Summer2

The quarter to include. Case-sensitive.

#### `instructor` string

The shortened name of the instructor to include. (Ex.: `SHINDLER, M.`)

#### `courseNumber` string

The course number to include. (Ex.: 161)

#### `sectionCode` string

The five-digit section code to include.

#### `sectionType` | Act | Col | Dis | Fld | Lab | Lec | Qiz | Res | Sem | Stu | Tap | Tut

The section type code. Case-sensitive.

### Code sample

<Tabs>
<TabItem value="bash" label="cURL">

```bash
curl "https://api-next.peterportal.org/v1/rest/enrollmentHistory?year=2022&quarter=Fall&department=I%26C%20SCI&courseNumber=46"
```

</TabItem>
</Tabs>

### Response

<Tabs>
<TabItem value="json" label="Example response">

```json
[
{
"year": "2022",
"quarter": "Fall",
"sectionCode": 35730,
"department": "I&C SCI",
"courseNumber": "46",
"sectionType": "Lec",
"sectionNum": "A",
"units": "4",
"instructors": ["SHINDLER, M.", "GARZA RODRIGUE, A.", "GILA, O."],
"meetings": [{ "days": "MWF", "time": "8:00- 8:50", "bldg": ["EH 1200"] }],
"finalExam": "Mon, Dec 5, 8:00-10:00am",
"dates": ["2022-05-17", "2022-05-18", "..."],
"maxCapacityHistory": ["220", "220", "220", "..."],
"totalEnrolledHistory": ["5", "5", "7", "..."],
"waitlistHistory": ["n/a", "n/a", "n/a", "..."],
"waitlistCapHistory": ["0", "0", "0", "..."],
"requestedHistory": ["7", "8", "11", "..."],
"newOnlyReservedHistory": ["0", "0", "0", "..."],
"statusHistory": ["OPEN", "OPEN", "OPEN", "..."]
},
{
"year": "2022",
"quarter": "Fall",
"sectionCode": 35740,
"department": "I&C SCI",
"courseNumber": "46",
"sectionType": "Lec",
"sectionNum": "B",
"units": "4",
"instructors": ["SHINDLER, M.", "DICKERSON, M."],
"meetings": [{ "days": "MWF", "time": "10:00-10:50", "bldg": ["SSLH 100"] }],
"finalExam": "Mon, Dec 5, 10:30-12:30pm",
"dates": ["2022-05-17", "2022-05-18", "..."],
"maxCapacityHistory": ["220", "220", "220", "..."],
"totalEnrolledHistory": ["38", "44", "58", "..."],
"waitlistHistory": ["n/a", "n/a", "n/a", "..."],
"waitlistCapHistory": ["0", "0", "0", "..."],
"requestedHistory": ["41", "49", "66", "..."],
"newOnlyReservedHistory": ["0", "0", "0", "..."],
"statusHistory": ["OPEN", "OPEN", "OPEN", "..."]
}
]
```

</TabItem>
<TabItem value="ts" label="Payload schema">

```typescript
// https://github.com/icssc/peterportal-api-next/blob/main/packages/peterportal-api-next-types/types/calendar.ts
type EnrollmentHistory = {
year: string;
quarter: Quarter;
sectionCode: string;
department: string;
courseNumber: string;
sectionType: string;
sectionNum: string;
units: string;
instructors: string[];
meetings: string[];
finalExam: string;
dates: string[];
maxCapacityHistory: string[];
totalEnrolledHistory: string[];
waitlistHistory: string[];
waitlistCapHistory: string[];
requestedHistory: string[];
newOnlyReservedHistory: string[];
statusHistory: string[];
};
```

</TabItem>
</Tabs>
26 changes: 26 additions & 0 deletions libs/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,32 @@ model GradesSection {
@@unique([year, quarter, sectionCode], name: "idx")
}

model WebsocEnrollmentHistory {
year String
quarter Quarter
sectionCode Int
timestamp DateTime
department String
courseNumber String
sectionType WebsocSectionType
sectionNum String
units String
instructors Json
meetings Json
finalExam String
dates Json
maxCapacityHistory Json
totalEnrolledHistory Json
waitlistHistory Json
waitlistCapHistory Json
requestedHistory Json
newOnlyReservedHistory Json
statusHistory Json

@@id([year, quarter, sectionCode, timestamp])
@@unique([year, quarter, sectionCode, timestamp], name: "idx")
}

model WebsocSectionInstructor {
id Int @id @default(autoincrement())
year String
Expand Down
1 change: 1 addition & 0 deletions packages/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from "./types/calendar";
export * from "./types/constants";
export * from "./types/courses";
export * from "./types/enrollmentHistory";
export * from "./types/grades";
export * from "./types/instructor";
export * from "./types/larc";
Expand Down
23 changes: 23 additions & 0 deletions packages/types/types/enrollmentHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Quarter, SectionType } from "./constants";

export type EnrollmentHistory = {
year: string;
quarter: Quarter;
sectionCode: string;
department: string;
courseNumber: string;
sectionType: SectionType;
sectionNum: string;
units: string;
instructors: string[];
meetings: string[];
finalExam: string;
dates: string[];
maxCapacityHistory: string[];
totalEnrolledHistory: string[];
waitlistHistory: string[];
waitlistCapHistory: string[];
requestedHistory: string[];
newOnlyReservedHistory: string[];
statusHistory: string[];
};
84 changes: 83 additions & 1 deletion services/websoc-scraper-v2/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PrismaClient } from "@libs/db";
import { Prisma, PrismaClient } from "@libs/db";
import { getTermDateData } from "@libs/uc-irvine-api/registrar";
import type {
GE,
Expand Down Expand Up @@ -473,6 +473,88 @@ async function scrape(name: string, term: Term) {
},
};

const enrollmentHistory = Object.fromEntries(
(await prisma.websocEnrollmentHistory.findMany(params)).map((x) => [
`${x.year}-${x.quarter}-${x.sectionCode}`,
x,
]),
);

for (const { data } of Object.values(res)) {
const key = `${data.year}-${data.quarter}-${data.sectionCode}`;
if (key in enrollmentHistory) {
const rawData = (data.data as WebsocAPIResponse).schools[0].departments[0].courses[0]
.sections[0];
enrollmentHistory[key].timestamp = timestamp;
(enrollmentHistory[key].dates as string[]).push(
`${timestamp.getFullYear()}-${timestamp.getMonth() + 1}-${timestamp.getDate()}`,
);
(enrollmentHistory[key].maxCapacityHistory as string[]).push(data.maxCapacity.toString(10));
(enrollmentHistory[key].totalEnrolledHistory as string[]).push(
rawData.numCurrentlyEnrolled.totalEnrolled,
);
(enrollmentHistory[key].waitlistHistory as string[]).push(rawData.numOnWaitlist);
(enrollmentHistory[key].waitlistCapHistory as string[]).push(rawData.numWaitlistCap);
(enrollmentHistory[key].requestedHistory as string[]).push(rawData.numRequested);
(enrollmentHistory[key].newOnlyReservedHistory as string[]).push(rawData.numNewOnlyReserved);
(enrollmentHistory[key].statusHistory as string[]).push(rawData.status);
} else {
const {
year,
quarter,
sectionCode,
timestamp,
department,
courseNumber,
sectionType,
units,
} = data;
const {
sectionNum,
instructors,
meetings,
finalExam,
maxCapacity,
numCurrentlyEnrolled,
numOnWaitlist,
numWaitlistCap,
numRequested,
numNewOnlyReserved,
status,
} = (data.data as WebsocAPIResponse).schools[0].departments[0].courses[0].sections[0];
enrollmentHistory[key] = {
year,
quarter,
sectionCode,
timestamp,
department,
courseNumber,
sectionType,
sectionNum,
units,
instructors,
meetings,
finalExam,
dates: [`${timestamp.getFullYear()}-${timestamp.getMonth() + 1}-${timestamp.getDate()}`],
maxCapacityHistory: [maxCapacity],
totalEnrolledHistory: [numCurrentlyEnrolled.totalEnrolled],
waitlistHistory: [numOnWaitlist],
waitlistCapHistory: [numWaitlistCap],
requestedHistory: [numRequested],
newOnlyReservedHistory: [numNewOnlyReserved],
statusHistory: [status],
};
}
}

await prisma.$transaction([
prisma.websocEnrollmentHistory.createMany({
data: Object.values(enrollmentHistory) as Prisma.WebsocEnrollmentHistoryCreateManyInput[],
skipDuplicates: true,
}),
prisma.websocEnrollmentHistory.deleteMany(params),
]);

const [instructorsDeleted, buildingsDeleted, meetingsDeleted, sectionsDeleted] =
await prisma.$transaction([
prisma.websocSectionInstructor.deleteMany(params),
Expand Down