gradescopeapi 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ # empty on purpose
File without changes
@@ -0,0 +1,65 @@
1
+ """config.py
2
+ Configuration file for FastAPI. Specifies the specific objects and data models used in our api
3
+ """
4
+
5
+ import io
6
+ from datetime import datetime
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class UserSession(BaseModel):
12
+ user_email: str
13
+ session_token: str
14
+
15
+
16
+ class LoginRequestModel(BaseModel):
17
+ email: str
18
+ password: str
19
+
20
+
21
+ class CourseID(BaseModel):
22
+ course_id: str
23
+
24
+
25
+ class AssignmentID(BaseModel):
26
+ course_id: str
27
+ assignment_id: str
28
+
29
+
30
+ class StudentSubmission(BaseModel):
31
+ student_email: str
32
+ course_id: str
33
+ assignment_id: str
34
+
35
+
36
+ class ExtensionData(BaseModel):
37
+ course_id: str
38
+ assignment_id: str
39
+
40
+
41
+ class UpdateExtensionData(BaseModel):
42
+ course_id: str
43
+ assignment_id: str
44
+ user_id: str
45
+ release_date: datetime | None = None
46
+ due_date: datetime | None = None
47
+ late_due_date: datetime | None = None
48
+
49
+
50
+ class AssignmentDates(BaseModel):
51
+ course_id: str
52
+ assignment_id: str
53
+ release_date: datetime | None = None
54
+ due_date: datetime | None = None
55
+ late_due_date: datetime | None = None
56
+
57
+
58
+ class FileUploadModel(BaseModel, arbitrary_types_allowed=True):
59
+ file: io.TextIOWrapper
60
+
61
+
62
+ class AssignmentUpload(BaseModel):
63
+ course_id: str
64
+ assignment_id: str
65
+ leaderboard_name: str | None = None
File without changes
@@ -0,0 +1,373 @@
1
+ from datetime import datetime
2
+
3
+ from fastapi import Depends, FastAPI, HTTPException, status
4
+
5
+ from gradescopeapi._config.config import FileUploadModel, LoginRequestModel
6
+ from gradescopeapi.classes.account import Account
7
+ from gradescopeapi.classes.assignments import Assignment, update_assignment_date
8
+ from gradescopeapi.classes.connection import GSConnection
9
+ from gradescopeapi.classes.courses import Course
10
+ from gradescopeapi.classes.extensions import get_extensions, update_student_extension
11
+ from gradescopeapi.classes.member import Member
12
+ from gradescopeapi.classes.upload import upload_assignment
13
+
14
+ app = FastAPI()
15
+
16
+ # Create instance of GSConnection, to be used where needed
17
+ connection = GSConnection()
18
+
19
+
20
+ def get_gs_connection():
21
+ """
22
+ Returns the GSConnection instance
23
+
24
+ Returns:
25
+ connection (GSConnection): an instance of the GSConnection class,
26
+ containing the session object used to make HTTP requests,
27
+ a boolean defining True/False if the user is logged in, and
28
+ the user's Account object.
29
+ """
30
+ return connection
31
+
32
+
33
+ def get_gs_connection_session():
34
+ """
35
+ Returns session of the the GSConnection instance
36
+
37
+ Returns:
38
+ connection.session (GSConnection.session): an instance of the GSConnection class' session object used to make HTTP requests
39
+ """
40
+ return connection.session
41
+
42
+
43
+ def get_account():
44
+ """
45
+ Returns the user's Account object
46
+
47
+ Returns:
48
+ Account (Account): an instance of the Account class, containing
49
+ methods for interacting with the user's courses and assignments.
50
+ """
51
+ return Account(session=get_gs_connection_session)
52
+
53
+
54
+ # Create instance of GSConnection, to be used where needed
55
+ connection = GSConnection()
56
+
57
+ account = None
58
+
59
+
60
+ @app.get("/")
61
+ def root():
62
+ return {"message": "Hello World"}
63
+
64
+
65
+ @app.post("/login", name="login")
66
+ def login(
67
+ login_data: LoginRequestModel,
68
+ gs_connection: GSConnection = Depends(get_gs_connection),
69
+ ):
70
+ """Login to Gradescope, with correct credentials
71
+
72
+ Args:
73
+ username (str): email address of user attempting to log in
74
+ password (str): password of user attempting to log in
75
+
76
+ Raises:
77
+ HTTPException: If the request to login fails, with a 404 Unauthorized Error status code and the error message "Account not found".
78
+ """
79
+ user_email = login_data.email
80
+ password = login_data.password
81
+
82
+ try:
83
+ connection.login(user_email, password)
84
+ global account
85
+ account = connection.account
86
+ return {"message": "Login successful", "status_code": status.HTTP_200_OK}
87
+ except ValueError as e:
88
+ raise HTTPException(status_code=404, detail=f"Account not found. Error {e}")
89
+
90
+
91
+ @app.post("/courses", response_model=dict[str, dict[str, Course]])
92
+ def get_courses():
93
+ """Get all courses for the user
94
+
95
+ Args:
96
+ account (Account): Account object containing the user's courses
97
+
98
+ Returns:
99
+ dict: dictionary of dictionaries
100
+
101
+ Raises:
102
+ HTTPException: If the request to get courses fails, with a 500 Internal Server Error status code and the error message.
103
+ """
104
+ try:
105
+ course_list = account.get_courses()
106
+ return course_list
107
+ except RuntimeError as e:
108
+ raise HTTPException(status_code=500, detail=str(e))
109
+
110
+
111
+ @app.post("/course_users", response_model=list[Member])
112
+ def get_course_users(course_id: str):
113
+ """Get all users for a course. ONLY FOR INSTRUCTORS.
114
+
115
+ Args:
116
+ course_id (str): The ID of the course.
117
+
118
+ Returns:
119
+ dict: dictionary of dictionaries
120
+
121
+ Raises:
122
+ HTTPException: If the request to get courses fails, with a 500 Internal Server Error status code and the error message.
123
+ """
124
+ try:
125
+ course_list = connection.account.get_course_users(course_id)
126
+ print(course_list)
127
+ return course_list
128
+ except RuntimeError as e:
129
+ raise HTTPException(status_code=500, detail=str(e))
130
+
131
+
132
+ @app.post("/assignments", response_model=list[Assignment])
133
+ def get_assignments(course_id: str):
134
+ """Get all assignments for a course. ONLY FOR INSTRUCTORS.
135
+ list: list of user emails
136
+
137
+ Raises:
138
+ HTTPException: If the request to get course users fails, with a 500 Internal Server Error status code and the error message.
139
+ """
140
+ try:
141
+ course_users = connection.account.get_assignments(course_id)
142
+ return course_users
143
+ except RuntimeError as e:
144
+ raise HTTPException(
145
+ status_code=500, detail=f"Failed to get course users. Error {e}"
146
+ )
147
+
148
+
149
+ @app.post("/assignment_submissions", response_model=dict[str, list[str]])
150
+ def get_assignment_submissions(
151
+ course_id: str,
152
+ assignment_id: str,
153
+ ):
154
+ """Get all assignment submissions for an assignment. ONLY FOR INSTRUCTORS.
155
+
156
+ Args:
157
+ course_id (str): The ID of the course.
158
+ assignment_id (str): The ID of the assignment.
159
+
160
+ Returns:
161
+ list: list of Assignment objects
162
+
163
+ Raises:
164
+ HTTPException: If the request to get assignments fails, with a 500 Internal Server Error status code and the error message.
165
+ """
166
+ try:
167
+ assignment_list = connection.account.get_assignment_submissions(
168
+ course_id=course_id, assignment_id=assignment_id
169
+ )
170
+ return assignment_list
171
+ except RuntimeError as e:
172
+ raise HTTPException(
173
+ status_code=500, detail=f"Failed to get assignments. Error: {e}"
174
+ )
175
+
176
+
177
+ @app.post("/single_assignment_submission", response_model=list[str])
178
+ def get_student_assignment_submission(
179
+ student_email: str, course_id: str, assignment_id: str
180
+ ):
181
+ """Get a student's assignment submission. ONLY FOR INSTRUCTORS.
182
+
183
+ Args:
184
+ student_email (str): The email address of the student.
185
+ course_id (str): The ID of the course.
186
+ assignment_id (str): The ID of the assignment.
187
+
188
+ Returns:
189
+ dict: dictionary containing a list of student emails and their corresponding submission IDs
190
+
191
+ Raises:
192
+ HTTPException: If the request to get assignment submissions fails, with a 500 Internal Server Error status code and the error message.
193
+ """
194
+ try:
195
+ assignment_submissions = connection.account.get_assignment_submission(
196
+ student_email=student_email,
197
+ course_id=course_id,
198
+ assignment_id=assignment_id,
199
+ )
200
+ return assignment_submissions
201
+ except RuntimeError as e:
202
+ raise HTTPException(
203
+ status_code=500, detail=f"Failed to get assignment submissions. Error: {e}"
204
+ )
205
+
206
+
207
+ @app.post("/assignments/update_dates")
208
+ def update_assignment_dates(
209
+ course_id: str,
210
+ assignment_id: str,
211
+ release_date: datetime,
212
+ due_date: datetime,
213
+ late_due_date: datetime,
214
+ ):
215
+ """
216
+ Update the release and due dates for an assignment. ONLY FOR INSTRUCTORS.
217
+
218
+ Args:
219
+ course_id (str): The ID of the course.
220
+ assignment_id (str): The ID of the assignment.
221
+ release_date (datetime): The release date of the assignment.
222
+ due_date (datetime): The due date of the assignment.
223
+ late_due_date (datetime): The late due date of the assignment.
224
+
225
+ Notes:
226
+ The timezone for dates used in Gradescope is specific to an institution. For example, for NYU, the timezone is America/New_York.
227
+ For datetime objects passed to this function, the timezone should be set to the institution's timezone.
228
+
229
+ Returns:
230
+ dict: A dictionary with a "message" key indicating if the assignment dates were updated successfully.
231
+
232
+ Raises:
233
+ HTTPException: If the assignment dates update fails, with a 400 Bad Request status code and the error message "Failed to update assignment dates".
234
+ """
235
+ try:
236
+ print(f"late due date {late_due_date}")
237
+ success = update_assignment_date(
238
+ session=connection.session,
239
+ course_id=course_id,
240
+ assignment_id=assignment_id,
241
+ release_date=release_date,
242
+ due_date=due_date,
243
+ late_due_date=late_due_date,
244
+ )
245
+ if success:
246
+ return {
247
+ "message": "Assignment dates updated successfully",
248
+ "status_code": status.HTTP_200_OK,
249
+ }
250
+ else:
251
+ raise HTTPException(
252
+ status_code=400, detail="Failed to update assignment dates"
253
+ )
254
+ except Exception as e:
255
+ raise HTTPException(status_code=500, detail=str(e))
256
+
257
+
258
+ @app.post("/assignments/extensions", response_model=dict)
259
+ def get_assignment_extensions(course_id: str, assignment_id: str):
260
+ """
261
+ Get all extensions for an assignment.
262
+
263
+ Args:
264
+ course_id (str): The ID of the course.
265
+ assignment_id (str): The ID of the assignment.
266
+
267
+ Returns:
268
+ dict: A dictionary containing the extensions, where the keys are user IDs and the values are Extension objects.
269
+
270
+ Raises:
271
+ HTTPException: If the request to get extensions fails, with a 500 Internal Server Error status code and the error message.
272
+ """
273
+ try:
274
+ extensions = get_extensions(
275
+ session=connection.session,
276
+ course_id=course_id,
277
+ assignment_id=assignment_id,
278
+ )
279
+ return extensions
280
+ except RuntimeError as e:
281
+ raise HTTPException(status_code=500, detail=str(e))
282
+
283
+
284
+ @app.post("/assignments/extensions/update")
285
+ def update_extension(
286
+ course_id: str,
287
+ assignment_id: str,
288
+ user_id: str,
289
+ release_date: datetime,
290
+ due_date: datetime,
291
+ late_due_date: datetime,
292
+ ):
293
+ """
294
+ Update the extension for a student on an assignment. ONLY FOR INSTRUCTORS.
295
+
296
+ Args:
297
+ course_id (str): The ID of the course.
298
+ assignment_id (str): The ID of the assignment.
299
+ user_id (str): The ID of the student.
300
+ release_date (datetime): The release date of the extension.
301
+ due_date (datetime): The due date of the extension.
302
+ late_due_date (datetime): The late due date of the extension.
303
+
304
+ Returns:
305
+ dict: A dictionary with a "message" key indicating if the extension was updated successfully.
306
+
307
+ Raises:
308
+ HTTPException: If the extension update fails, with a 400 Bad Request status code and the error message.
309
+ HTTPException: If a ValueError is raised (e.g., invalid date order), with a 400 Bad Request status code and the error message.
310
+ HTTPException: If any other exception occurs, with a 500 Internal Server Error status code and the error message.
311
+ """
312
+ try:
313
+ success = update_student_extension(
314
+ session=connection.session,
315
+ course_id=course_id,
316
+ assignment_id=assignment_id,
317
+ user_id=user_id,
318
+ release_date=release_date,
319
+ due_date=due_date,
320
+ late_due_date=late_due_date,
321
+ )
322
+ if success:
323
+ return {
324
+ "message": "Extension updated successfully",
325
+ "status_code": status.HTTP_200_OK,
326
+ }
327
+ else:
328
+ raise HTTPException(status_code=400, detail="Failed to update extension")
329
+ except ValueError as e:
330
+ raise HTTPException(status_code=400, detail=str(e))
331
+ except Exception as e:
332
+ raise HTTPException(status_code=500, detail=str(e))
333
+
334
+
335
+ @app.post("/assignments/upload")
336
+ def upload_assignment_files(
337
+ course_id: str, assignment_id: str, leaderboard_name: str, file: FileUploadModel
338
+ ):
339
+ """
340
+ Upload files for an assignment.
341
+
342
+ NOTE: This function within FastAPI is currently nonfunctional, as we did not
343
+ find the datatype for file, which would allow us to upload a file via
344
+ Postman. However, this functionality works correctly if a user
345
+ runs this as a Python package.
346
+
347
+ Args:
348
+ course_id (str): The ID of the course on Gradescope.
349
+ assignment_id (str): The ID of the assignment on Gradescope.
350
+ leaderboard_name (str): The name of the leaderboard.
351
+ file (FileUploadModel): The file object to upload.
352
+
353
+ Returns:
354
+ dict: A dictionary containing the submission link for the uploaded files.
355
+
356
+ Raises:
357
+ HTTPException: If the upload fails, with a 400 Bad Request status code and the error message "Upload unsuccessful".
358
+ HTTPException: If any other exception occurs, with a 500 Internal Server Error status code and the error message.
359
+ """
360
+ try:
361
+ submission_link = upload_assignment(
362
+ session=connection.session,
363
+ course_id=course_id,
364
+ assignment_id=assignment_id,
365
+ files=file,
366
+ leaderboard_name=leaderboard_name,
367
+ )
368
+ if submission_link:
369
+ return {"submission_link": submission_link}
370
+ else:
371
+ raise HTTPException(status_code=400, detail="Upload unsuccessful")
372
+ except Exception as e:
373
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,5 @@
1
+ """constants.py
2
+ Constants file for FastAPI. Specifies any variable or other object which should remain the same across all environments.
3
+ """
4
+
5
+ BASE_URL = "https://www.gradescope.com"
File without changes
@@ -0,0 +1,178 @@
1
+ import json
2
+
3
+ import dateutil.parser
4
+ import requests
5
+
6
+ from gradescopeapi.classes.assignments import Assignment
7
+
8
+
9
+ def check_page_auth(session, endpoint):
10
+ """
11
+ raises Exception if user not logged in or doesn't have appropriate authorities
12
+ Returns response if otherwise good
13
+ """
14
+ submissions_resp = session.get(endpoint)
15
+ # check if page is valid, raise exception if not
16
+ if submissions_resp.status_code == requests.codes.unauthorized:
17
+ # check error type
18
+ # TODO: how should we handle errors so that our API can read them?
19
+ error_msg = [*json.loads(submissions_resp.text).values()][0]
20
+ if error_msg == "You are not authorized to access this page.":
21
+ raise Exception("You are not authorized to access this page.")
22
+ elif error_msg == "You must be logged in to access this page.":
23
+ raise Exception("You must be logged in to access this page.")
24
+ elif submissions_resp.status_code == requests.codes.not_found:
25
+ raise Exception("Page not Found")
26
+ elif submissions_resp.status_code == requests.codes.ok:
27
+ return submissions_resp
28
+
29
+
30
+ def get_assignments_instructor_view(coursepage_soup):
31
+ assignments_list = []
32
+ element_with_props = coursepage_soup.find(
33
+ "div", {"data-react-class": "AssignmentsTable"}
34
+ )
35
+ if element_with_props:
36
+ # Extract the value of the data-react-props attribute
37
+ props_str = element_with_props["data-react-props"]
38
+ # Parse the JSON data
39
+ assignment_json = json.loads(props_str)
40
+
41
+ # Extract information for each assignment
42
+ for assignment in assignment_json["table_data"]:
43
+ assignment_obj = Assignment(
44
+ assignment_id=assignment["url"].split("/")[-1],
45
+ name=assignment["title"],
46
+ release_date=assignment["submission_window"]["release_date"],
47
+ due_date=assignment["submission_window"]["due_date"],
48
+ late_due_date=assignment["submission_window"].get("hard_due_date"),
49
+ submissions_status=None,
50
+ grade=None,
51
+ max_grade=str(float(assignment["total_points"])),
52
+ )
53
+
54
+ # convert to datetime objects
55
+ assignment_obj.release_date = (
56
+ dateutil.parser.parse(assignment_obj.release_date)
57
+ if assignment_obj.release_date
58
+ else assignment_obj.release_date
59
+ )
60
+
61
+ assignment_obj.due_date = (
62
+ dateutil.parser.parse(assignment_obj.due_date)
63
+ if assignment_obj.due_date
64
+ else assignment_obj.due_date
65
+ )
66
+
67
+ assignment_obj.late_due_date = (
68
+ dateutil.parser.parse(assignment_obj.late_due_date)
69
+ if assignment_obj.late_due_date
70
+ else assignment_obj.late_due_date
71
+ )
72
+
73
+ # Add the assignment dictionary to the list
74
+ assignments_list.append(assignment_obj)
75
+ return assignments_list
76
+
77
+
78
+ def get_assignments_student_view(coursepage_soup):
79
+ # parse into list of lists: Assignments[row_elements[]]
80
+ assignment_table = []
81
+ for assignment_row in coursepage_soup.findAll("tr", role="row")[
82
+ 1:-1
83
+ ]: # Skip header row and tail row (dropzonePreview--fileNameHeader)
84
+ row = []
85
+ for th in assignment_row.findAll("th"):
86
+ row.append(th)
87
+ for td in assignment_row.findAll("td"):
88
+ row.append(td)
89
+ assignment_table.append(row)
90
+ assignment_info_list = []
91
+
92
+ # Iterate over the list of Tag objects
93
+ for assignment in assignment_table:
94
+ # Extract assignment ID and name
95
+ name = assignment[0].text
96
+ # 3 cases: 1. submitted -> href element, 2. not submitted, submittable -> button element, 3. not submitted, cant submit -> only text
97
+ assignment_a_href = assignment[0].find("a", href=True)
98
+ assignment_button = assignment[0].find("button", class_="js-submitAssignment")
99
+ if assignment_a_href:
100
+ assignment_id = assignment_a_href["href"].split("/")[4]
101
+ elif assignment_button:
102
+ assignment_id = assignment_button["data-assignment-id"]
103
+ else:
104
+ assignment_id = None
105
+
106
+ # Extract submission status, grade, max_grade
107
+ try: # Points not guaranteed
108
+ points = assignment[1].text.split(" / ")
109
+ grade = float(points[0])
110
+ max_grade = float(points[1])
111
+ submission_status = "Submitted"
112
+ except (IndexError, ValueError):
113
+ grade = None
114
+ max_grade = None
115
+ submission_status = assignment[1].text
116
+
117
+ # Extract release date, due date, and late due date
118
+ release_date = due_date = late_due_date = None
119
+ try: # release date, due date, and late due date not guaranteed to be available
120
+ release_obj = assignment[2].find(class_="submissionTimeChart--releaseDate")
121
+ release_date = release_obj["datetime"] if release_obj else None
122
+ # both due data and late due date have the same class
123
+ due_dates_obj = assignment[2].find_all(
124
+ class_="submissionTimeChart--dueDate"
125
+ )
126
+ if due_dates_obj:
127
+ due_date = due_dates_obj[0]["datetime"] if due_dates_obj else None
128
+ if len(due_dates_obj) > 1:
129
+ late_due_date = (
130
+ due_dates_obj[1]["datetime"] if due_dates_obj else None
131
+ )
132
+ except IndexError:
133
+ pass
134
+
135
+ # convert to datetime objects
136
+ release_date = (
137
+ dateutil.parser.parse(release_date) if release_date else release_date
138
+ )
139
+ due_date = dateutil.parser.parse(due_date) if due_date else due_date
140
+ late_due_date = (
141
+ dateutil.parser.parse(late_due_date) if late_due_date else late_due_date
142
+ )
143
+
144
+ # Store the extracted information in a dictionary
145
+ assignment_obj = Assignment(
146
+ assignment_id=assignment_id,
147
+ name=name,
148
+ release_date=release_date,
149
+ due_date=due_date,
150
+ late_due_date=late_due_date,
151
+ submissions_status=submission_status,
152
+ grade=grade,
153
+ max_grade=max_grade,
154
+ )
155
+
156
+ # Append the dictionary to the list
157
+ assignment_info_list.append(assignment_obj)
158
+
159
+ return assignment_info_list
160
+
161
+
162
+ def get_submission_files(session, course_id, assignment_id, submission_id):
163
+ ASSIGNMENT_ENDPOINT = (
164
+ f"https://www.gradescope.com/courses/{course_id}/assignments/{assignment_id}"
165
+ )
166
+
167
+ file_info_link = f"{ASSIGNMENT_ENDPOINT}/submissions/{submission_id}.json?content=react&only_keys[]=text_files&only_keys[]=file_comments"
168
+ file_info_resp = session.get(file_info_link)
169
+ if file_info_resp.status_code == requests.codes.ok:
170
+ file_info_json = json.loads(file_info_resp.text)
171
+ if file_info_json.get("text_files"):
172
+ aws_links = []
173
+ for file_data in file_info_json["text_files"]:
174
+ aws_links.append(file_data["file"]["url"])
175
+ else:
176
+ raise NotImplementedError("Image only submissions not yet supported")
177
+ # TODO add support for image questions
178
+ return aws_links