ed-api-client 0.1.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,9 @@
1
+ from ed_api_client.slides import *
2
+ from ed_api_client.users import *
3
+ from ed_api_client.challenges import *
4
+ from ed_api_client.quizzes import *
5
+ from ed_api_client.assignments import *
6
+ from ed_api_client.workspaces import *
7
+ from ed_api_client.websockets import *
8
+ from ed_api_client.client import *
9
+ from ed_api_client.canvas import *
@@ -0,0 +1,241 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Dict, Set
3
+ import datetime
4
+ import math
5
+
6
+ from ed_api_client import Slide, ChallengeResult, User, QuizResult, Quiz
7
+ from ed_api_client.challenges import ChallengeMarker, MarkType, Challenge
8
+
9
+
10
+ @dataclass
11
+ class Assignment:
12
+ """
13
+ An assignment against which students are graded
14
+
15
+ Attributes
16
+ ----------
17
+ name : int
18
+ The name of the assignment.
19
+ marks : int
20
+ The total available marks for this assignment
21
+ maxScore : int
22
+ The maximum score that users might achieve on this assignment. This depends on the gradeType:
23
+ For BY_CHALLENGE this should be the number of challenges.
24
+ For BY_TESTCASE this should be the number of test cases accross all challenges.
25
+ For BY_TESTCASE_SCORE this should be the sum of all scores for all test cases across all challenges
26
+ gradeType: GradeType
27
+ The approach used for calculating grades
28
+ deadline: datetime
29
+ The default deadline beyond which submissions will be penalised.
30
+ lessonIds: Set[int]
31
+ The ids of all lessons involved in this assignment (note this is populated automatically as slides are added)
32
+ slideIds: Set[int]
33
+ The ids of all slides involved in this assignment (note this is populated automatically as slides are added)
34
+ challengeIds: List[int]
35
+ The ids of all challenges involved in this assignment (note this is populated automatically as slides are added)
36
+ challengeNames: List[str]
37
+ The names of all challenges involved in this assignment (note this is populated automatically as slides are added)
38
+ extensionsByStudentId: Dict[int,datetime]
39
+ Associates ids of students with deadlines that have been given to them (i.e. as extensions)
40
+
41
+ Methods
42
+ ----------
43
+ add_slide (slide:Slide)
44
+ Adds a slide (usually a code challenge) to this assignment
45
+ add_extension (student: User, deadline:datetime)
46
+ Provides the given student with an extension, until the given deadline
47
+ get_deadline (student: Student) -> datetime
48
+ Returns the deadline for the given student
49
+ get_canvas_col ( ) -> str
50
+ Returns the name of the column that would match this assignment in an gradebook exported from canvas.
51
+
52
+ """
53
+
54
+ name: str
55
+ maxMarks: int
56
+ marker: ChallengeMarker
57
+ deadline: datetime.datetime
58
+ challenges: List[Challenge] = field(default_factory=list)
59
+ quizzes: List[Quiz] = field(default_factory=list)
60
+ extensionsByStudentId: Dict[int, datetime.datetime] = field(default_factory=dict)
61
+ dailyPenalty: float = 0.05
62
+
63
+ def add_challenge_slide(self, slide: Slide):
64
+
65
+ if slide.challengeId is None:
66
+ raise ValueError("This slide is not a code challenge")
67
+
68
+ self.challenges.append(Challenge(slide.challengeId, slide.id, slide.title))
69
+
70
+ def add_quiz_slide(self, slide: Slide, questions: list):
71
+
72
+ self.quizzes.append(Quiz(slide.id, slide.title, questions))
73
+
74
+ def add_multichoice_solution(self, questionId: int, solution: Set[int]):
75
+
76
+ for quiz in self.quizzes:
77
+
78
+ if quiz.add_multichoice_solution(questionId, solution):
79
+ return
80
+
81
+ print(f"could not locate multichoice question {questionId}")
82
+
83
+ def add_parsons_solution(self, questionId: int, solution: str):
84
+
85
+ for quiz in self.quizzes:
86
+
87
+ if quiz.add_parsons_solution(questionId, solution):
88
+ return
89
+
90
+ print(f"could not locate parsons question {questionId}")
91
+
92
+ def add_extension(self, student, deadline):
93
+ self.extensionsByStudentId[student.id] = deadline
94
+
95
+ def add_extensions(self, students, deadline):
96
+ for student in students:
97
+ self.extensionsByStudentId[student.id] = deadline
98
+
99
+ def get_deadline(self, student):
100
+ if student.id in self.extensionsByStudentId:
101
+ return self.extensionsByStudentId[student.id]
102
+
103
+ return self.deadline
104
+
105
+ def get_max_score(self):
106
+ maxScore = 0
107
+ for quiz in self.quizzes:
108
+ maxScore = maxScore + quiz.getMaxScore()
109
+
110
+ if self.marker.markType == MarkType.BY_CHALLENGE:
111
+ maxScore = maxScore + (
112
+ len(self.challenges) * self.marker.pointsPerChallenge
113
+ )
114
+ else:
115
+ maxScore = maxScore + self.marker.maxTestCaseScore
116
+
117
+ return maxScore
118
+
119
+ def get_canvas_col(self):
120
+ return "{} ({})".format(self.name, self.id)
121
+
122
+
123
+ @dataclass
124
+ class AssignmentResult:
125
+ """
126
+ Records the results of a student for an assignment.
127
+
128
+ Marks for the assignment include a 10% penalty per day if work is received late.
129
+ Late submissions are only considered if they improve the student's marks after the late penalty is applied.
130
+
131
+ Attributes
132
+ ----------
133
+ studentId : int
134
+ The id of the student
135
+ completed : bool
136
+ True if the student successfully completed this assignment, otherwise false.
137
+ score : int
138
+ The score recieved by this student
139
+ mark : float
140
+ The mark recieved by this student.
141
+ daysLate: int
142
+ The number of days past the deadline that work was received and considered.
143
+ challengeResults: Dict[int, ChallengeResult]
144
+ Associates ids of challenges with their individual results
145
+
146
+ Methods
147
+ ----------
148
+ add (submission:Submission, student:Student, assignment:Assignment)
149
+ Updates this result based on a single submission
150
+ finalize (assignment:Assignment, crawler: EdCrawler)
151
+ To be called after all submissions have been added.
152
+ """
153
+
154
+ studentId: int
155
+ score: int = None
156
+ mark: float = None
157
+ daysLate: int = None
158
+ penalty: float = None
159
+ challengeResults: Dict[int, ChallengeResult] = field(default_factory=dict)
160
+ quizResults: Dict[int, QuizResult] = field(default_factory=dict)
161
+
162
+ def add_challenge_submission(self, submission, student, assignment):
163
+
164
+ deadline = assignment.get_deadline(student)
165
+
166
+ daysLate = 0
167
+ if submission.markedAt > deadline:
168
+ diff = submission.markedAt - deadline
169
+ daysLate = math.ceil(diff.total_seconds() / (60 * 60 * 24))
170
+
171
+ if submission.challengeId not in self.challengeResults:
172
+ self.challengeResults[submission.challengeId] = ChallengeResult()
173
+
174
+ self.challengeResults[submission.challengeId].add(submission, daysLate)
175
+
176
+ def add_quiz_result(self, quizResult: QuizResult):
177
+ self.quizResults[quizResult.quizId] = quizResult
178
+
179
+ def finalize(self, assignment: Assignment, crawler):
180
+
181
+ self.score = 0
182
+ self.mark = 0
183
+ self.daysLate = 0
184
+ self.penalty = 0
185
+
186
+ quizScore = 0
187
+ for quizResult in self.quizResults.values():
188
+ quizScore = quizScore + quizResult.score
189
+
190
+ if self.challengeResults is not None:
191
+ for challengeId, challenge in self.challengeResults.items():
192
+ challenge.finalize(assignment.marker, crawler)
193
+
194
+ dailyMarkPenalty = assignment.dailyPenalty * assignment.maxMarks
195
+
196
+ for daysLate in range(0, 6):
197
+ score = quizScore
198
+
199
+ for challengeId, challenge in self.challengeResults.items():
200
+ score = score + challenge.scoresByCalendarDaysLate[daysLate]
201
+
202
+ mark = assignment.maxMarks * (score / assignment.get_max_score())
203
+ penalty = daysLate * dailyMarkPenalty
204
+ mark = mark - penalty
205
+ if mark < 0:
206
+ mark = 0
207
+
208
+ if mark > self.mark:
209
+ self.score = score
210
+ self.mark = mark
211
+ self.daysLate = daysLate
212
+ self.penalty = penalty
213
+
214
+ def printSummary(self, student: User, assignment: Assignment):
215
+
216
+ msg = f"{student.name} got {self.mark:.1f}/{assignment.maxMarks} marks from {self.score}/{assignment.get_max_score()} points"
217
+
218
+ if self.daysLate > 0:
219
+ msg = f"{msg} and a penalty of {self.penalty:.1f} marks for submitting {self.daysLate} days late"
220
+
221
+ print(msg)
222
+
223
+ for quiz in assignment.quizzes:
224
+ quizResult = self.quizResults.get(quiz.id)
225
+
226
+ if quiz.id is None:
227
+ print(
228
+ f" 0/{quiz.getMaxScore()} points for "
229
+ + quiz.name
230
+ + " due to no attempt"
231
+ )
232
+ else:
233
+ quizResult.print_summary(quiz)
234
+
235
+ for challenge in assignment.challenges:
236
+
237
+ challengeResult = self.challengeResults.get(challenge.id)
238
+ if challengeResult is None:
239
+ print(f" 0 points for " + challenge.name + " due to no attempt")
240
+ else:
241
+ challengeResult.print_summary(challenge, self.daysLate)
@@ -0,0 +1,67 @@
1
+ import csv
2
+ from typing import Dict
3
+
4
+ from ed_api_client import Assignment, Users, AssignmentResult
5
+
6
+
7
+ class CanvasExporter:
8
+ def __init__(self, students: Users):
9
+ self.__students = students
10
+ self.__assignmentsByCol = {}
11
+ self.__resultsByCol = {}
12
+ self.__fieldNames = ["Student", "ID", "SIS User ID", "SIS Login ID", "Section"]
13
+
14
+ def addAssignment(
15
+ self,
16
+ assignment: Assignment,
17
+ columnName: str,
18
+ results: Dict[int, AssignmentResult],
19
+ ):
20
+
21
+ self.__assignmentsByCol[columnName] = assignment
22
+ self.__resultsByCol[columnName] = results
23
+ self.__fieldNames.append(columnName)
24
+ return self
25
+
26
+ def generateCsv(self, sourceCsv, targetCsv):
27
+
28
+ with open(sourceCsv) as input:
29
+ reader = csv.DictReader(input)
30
+
31
+ with open(targetCsv, "w") as output:
32
+ writer = csv.DictWriter(output, fieldnames=self.__fieldNames)
33
+ writer.writeheader()
34
+
35
+ for inputRow in reader:
36
+
37
+ outputRow = self.handleRow(inputRow)
38
+
39
+ if outputRow is None:
40
+ continue
41
+
42
+ writer.writerow(outputRow)
43
+
44
+ def handleRow(self, inputRow: dict):
45
+
46
+ studentEmail = inputRow.get("SIS Login ID")
47
+ if studentEmail is None:
48
+ return None
49
+
50
+ student = self.__students.getByEmail(studentEmail.lower())
51
+ if student is None:
52
+ print(f"Could not locate student {studentEmail}")
53
+ return None
54
+
55
+ outputRow = {}
56
+ for field in ["Student", "ID", "SIS User ID", "SIS Login ID", "Section"]:
57
+ outputRow[field] = inputRow[field]
58
+
59
+ for col, assignment in self.__assignmentsByCol.items():
60
+
61
+ results = self.__resultsByCol.get(col, {}).get(student.id)
62
+ if results is None:
63
+ outputRow[col] = 0
64
+ else:
65
+ outputRow[col] = round(results.mark, 3)
66
+
67
+ return outputRow
@@ -0,0 +1,279 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import List
3
+ from enum import Enum
4
+ import datetime
5
+ import dateutil.parser
6
+
7
+
8
+ @dataclass
9
+ class Challenge:
10
+ id: int
11
+ slideId: int
12
+ name: str
13
+
14
+
15
+ class MarkType(Enum):
16
+ """
17
+ Different approaches used for grading assignments
18
+
19
+ BY_CHALLENGE
20
+ user recieves a point for each challenge they complete
21
+ BY_TESTCASE
22
+ user recieves a point for each testcase they pass, across all challenges
23
+ BY_TESTCASE_SCORE
24
+ user recieves points for each testcase they pass, across all challenges.
25
+ The points recieved for each testcase is whatever score value was assigned
26
+ to the testcase
27
+ """
28
+
29
+ BY_CHALLENGE = 1
30
+ BY_TESTCASE = 2
31
+ BY_TESTCASE_SCORE = 3
32
+
33
+
34
+ class ChallengeMarker:
35
+ def __init__(self, markType: MarkType):
36
+ self.markType = markType
37
+
38
+ def passFail(pointsPerChallenge: int = 1):
39
+ marker = ChallengeMarker(MarkType.BY_CHALLENGE)
40
+ marker.pointsPerChallenge = pointsPerChallenge
41
+ return marker
42
+
43
+ def perTestCase(maxTestCaseScore: int):
44
+ marker = ChallengeMarker(MarkType.BY_TESTCASE)
45
+ marker.maxTestCaseScore = maxTestCaseScore
46
+ return marker
47
+
48
+ def perScoredTestCase(maxTestCaseScore: int):
49
+ marker = ChallengeMarker(MarkType.BY_TESTCASE_SCORE)
50
+ marker.maxTestCaseScore = maxTestCaseScore
51
+ return marker
52
+
53
+
54
+ @dataclass
55
+ class TestCase:
56
+ """
57
+ A test case, that a user may or may not have passed
58
+
59
+ ...
60
+
61
+ Attributes
62
+ ----------
63
+ name : str
64
+ The name of the test case
65
+ score : int
66
+ The points assigned to this test case
67
+ passed : bool
68
+ True if the user has passed the test case, otherwise false
69
+
70
+ """
71
+
72
+ name: str
73
+ score: int
74
+ passed: bool
75
+
76
+ def from_json(j: dict):
77
+ return TestCase(j["name"], j["score"], j["passed"])
78
+
79
+
80
+ @dataclass
81
+ class Submission:
82
+ """
83
+ A submission that a user has made towards a challenge
84
+
85
+ ...
86
+
87
+ Attributes
88
+ ----------
89
+ id : int
90
+ The unique id of the submission
91
+ userId : int
92
+ The id of the user who made the submission
93
+ challengeId : int
94
+ The id of the challenge this submission was made toward
95
+ status : bool
96
+ One of "passed", "failed", etc.
97
+ testCasesPassed: int
98
+ The number of test cases that this submission has passed
99
+ testCasesTotal: int
100
+ The number of test cases that this submission was tested against
101
+ (Note this may be smaller than the number of test cases provided by the
102
+ challenge, if the submission has compilation errors)
103
+ markedAt: datetime
104
+ A timestamp of when this submission was recieved and assessed
105
+ testcases: List[TestCase]
106
+ A list of details for each test case (Note this is only populated if you request
107
+ a specific submission from the api. It will be left unpopulated by any requests that
108
+ return multiple submissions)
109
+
110
+ """
111
+
112
+ id: int
113
+ userId: int
114
+ challengeId: int
115
+ workspaceId: str
116
+ status: str
117
+ testCasesPassed: int
118
+ testCasesTotal: int
119
+ markedAt: datetime.datetime
120
+ testcases: List[TestCase] = field(default_factory=list)
121
+
122
+ def from_json(j: dict):
123
+ submission = Submission(
124
+ j["id"],
125
+ j["user_id"],
126
+ j["challenge_id"],
127
+ j["workspace_id"],
128
+ j["status"],
129
+ j["testcase_pass_count"],
130
+ j["testcase_total_count"],
131
+ dateutil.parser.isoparse(j["marked_at"]),
132
+ )
133
+
134
+ for tc in j.get("result", {}).get("testcases", []):
135
+ submission.testcases.append(TestCase.from_json(tc))
136
+
137
+ return submission
138
+
139
+
140
+ @dataclass
141
+ class ChallengeResult:
142
+ """
143
+ Records the results of a student for a challenge within an assignment
144
+
145
+ Attributes
146
+ ----------
147
+ attempts : int
148
+ The number of attempts that the student submitted for this assignment.
149
+ completed : bool
150
+ True if the student successfully completed this challenge, otherwise false.
151
+ bestSubmissionByCalendarDaysLate : List[Submission]
152
+ The best submissions recieved by this student before and after the deadline.
153
+ The first element contains the best submission recieved before the deadline.
154
+ The second element contains the best submission recieved within one day after the deadline.
155
+ The third element contains the best submission recieved between one and two days after the deadline. etc.
156
+ scoresByCalendarDaysLate : List[int]
157
+ The best scores recieved by this student before and after the deadline.
158
+ The first element contains the best score recieved before the deadline.
159
+ The second element contains the best score recieved within one day after the deadline.
160
+ The third element contains the best score recieved between one and two days after the deadline. etc.
161
+ firstAttempt: datetime
162
+ A timestamp of this students first submission towards the assignment.
163
+ lastAttempt: datetime
164
+ A timestamp of this student's last submission towards the assignment.
165
+
166
+ Methods
167
+ ----------
168
+ add (submission:Submission, daysLate: int)
169
+ Updates this result based on a single submission
170
+ finalize (assignment:Assignment, crawler: EdCrawler)
171
+ To be called after all submissions have been added.
172
+ """
173
+ maxDaysLate: int = 7
174
+ attempts: int = 0
175
+ completed: bool = False
176
+ bestSubmissionByCalendarDaysLate: List[Submission] = None
177
+ scoresByCalendarDaysLate: List[int] = None
178
+ firstAttempt: datetime.datetime = None
179
+ lastAttempt: datetime.datetime = None
180
+
181
+ def add(self, submission: Submission, daysLate: int):
182
+
183
+ if self.bestSubmissionByCalendarDaysLate is None:
184
+ self.bestSubmissionByCalendarDaysLate = [None] * (self.maxDaysLate + 1)
185
+ self.scoresByCalendarDaysLate = [0] * (self.maxDaysLate + 1)
186
+
187
+ self.attempts += 1
188
+ if (
189
+ submission.status == "passed"
190
+ and submission.testCasesPassed == submission.testCasesTotal
191
+ ):
192
+ self.completed = True
193
+
194
+ if self.firstAttempt is None or self.firstAttempt > submission.markedAt:
195
+ self.firstAttempt = submission.markedAt
196
+ if self.lastAttempt is None or self.lastAttempt < submission.markedAt:
197
+ self.lastAttempt = submission.markedAt
198
+
199
+ if daysLate <= self.maxDaysLate:
200
+ if (
201
+ self.bestSubmissionByCalendarDaysLate[daysLate] is None
202
+ or self.bestSubmissionByCalendarDaysLate[daysLate].testCasesPassed
203
+ < submission.testCasesPassed
204
+ ):
205
+ self.bestSubmissionByCalendarDaysLate[daysLate] = submission
206
+
207
+ def finalize(self, marker: ChallengeMarker, crawler):
208
+ for daysLate in range(0, self.maxDaysLate+1):
209
+ submission = self.bestSubmissionByCalendarDaysLate[daysLate]
210
+ if submission is None:
211
+ continue
212
+
213
+ if marker.markType is MarkType.BY_CHALLENGE:
214
+ if (
215
+ submission.status == "passed"
216
+ and submission.testCasesPassed == submission.testCasesTotal
217
+ ):
218
+ self.scoresByCalendarDaysLate[daysLate] = marker.pointsPerChallenge
219
+ elif marker.markType is MarkType.BY_TESTCASE:
220
+ self.scoresByCalendarDaysLate[daysLate] += submission.testCasesPassed
221
+ elif marker.markType is MarkType.BY_TESTCASE_SCORE:
222
+ # we have to fetch full submission details
223
+ s = crawler.get_submission(submission.id)
224
+ for test in s.testcases:
225
+ # print(test)
226
+ if test.passed:
227
+ self.scoresByCalendarDaysLate[daysLate] = (
228
+ self.scoresByCalendarDaysLate[daysLate] + test.score
229
+ )
230
+ else:
231
+ raise Exception(f"Invalid marker type {marker.markType}")
232
+
233
+ # make sure scores can only go up
234
+ for daysLate in range(1, self.maxDaysLate+1):
235
+ if (
236
+ self.scoresByCalendarDaysLate[daysLate]
237
+ < self.scoresByCalendarDaysLate[daysLate - 1]
238
+ ):
239
+ self.scoresByCalendarDaysLate[daysLate] = self.scoresByCalendarDaysLate[
240
+ daysLate - 1
241
+ ]
242
+
243
+ def __get_best_day(self, daysLate: int):
244
+
245
+ bestScore = 0
246
+ bestDay = 0
247
+
248
+ for day in range(0, daysLate + 1):
249
+ if self.scoresByCalendarDaysLate[day] > bestScore:
250
+ bestScore = self.scoresByCalendarDaysLate[day]
251
+ bestDay = day
252
+
253
+ return bestDay
254
+
255
+ def print_summary(self, challenge: Challenge, daysLate: int):
256
+
257
+ bestDay = self.__get_best_day(daysLate)
258
+ bestScore = self.scoresByCalendarDaysLate[bestDay]
259
+
260
+ print(
261
+ f" {bestScore} points for {challenge.name} from the following submissions:"
262
+ )
263
+
264
+ for day in range(0, len(self.scoresByCalendarDaysLate)):
265
+ score = self.scoresByCalendarDaysLate[day]
266
+ submission = self.bestSubmissionByCalendarDaysLate[day]
267
+ if submission is None:
268
+ continue
269
+
270
+ submittedAt = submission.markedAt.strftime("%m/%d/%Y, %H:%M:%S")
271
+
272
+ msg = f" {score} points from {submission.testCasesPassed}/{submission.testCasesTotal} test cases @ {day} days overdue ({submittedAt})"
273
+
274
+ if day < bestDay:
275
+ msg = f"{msg} - superseded by later submission"
276
+ elif day > bestDay:
277
+ msg = f"{msg} - ignored for not overcoming late penalty"
278
+
279
+ print(msg)