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