ed-api-client 0.1.0__tar.gz → 0.1.2__tar.gz

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.
@@ -1,26 +1,23 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: ed-api-client
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: A client for interacting with the Ed LMS
5
5
  License: MIT
6
6
  Author: David Milne
7
7
  Author-email: d.n.milne@gmail.com
8
- Requires-Python: >=3.6,<4.0
8
+ Requires-Python: >=3.10,<4.0
9
9
  Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.6
12
- Classifier: Programming Language :: Python :: 3.7
13
- Classifier: Programming Language :: Python :: 3.8
14
- Classifier: Programming Language :: Python :: 3.9
15
11
  Classifier: Programming Language :: Python :: 3.10
16
12
  Classifier: Programming Language :: Python :: 3.11
17
13
  Classifier: Programming Language :: Python :: 3.12
18
14
  Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
19
16
  Requires-Dist: DateTime (>=5.1,<6.0)
20
- Requires-Dist: dataclasses (==0.6)
21
17
  Requires-Dist: pylcs (>=0.1.1,<0.2.0)
22
18
  Requires-Dist: python-dateutil (>=2.9.0,<3.0.0)
23
19
  Requires-Dist: requests (>=2.31.0,<3.0.0)
20
+ Requires-Dist: websocket-client (>=1.6,<2.0)
24
21
  Description-Content-Type: text/markdown
25
22
 
26
23
  # ed-api-client
@@ -1,9 +1,10 @@
1
1
  from ed_api_client.slides import *
2
2
  from ed_api_client.users import *
3
+ from ed_api_client.threads import *
3
4
  from ed_api_client.challenges import *
4
5
  from ed_api_client.quizzes import *
5
6
  from ed_api_client.assignments import *
6
7
  from ed_api_client.workspaces import *
7
8
  from ed_api_client.websockets import *
8
9
  from ed_api_client.client import *
9
- from ed_api_client.canvas import *
10
+ from ed_api_client.canvas import *
@@ -0,0 +1,382 @@
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 : str
18
+ The name of the assignment.
19
+ max_marks : int
20
+ The total available marks for this assignment
21
+ marker : ChallengeMarker
22
+ The approach used for calculating grades
23
+ deadline : datetime
24
+ The default deadline beyond which submissions will be penalised.
25
+ challenges : List[Challenge]
26
+ The challenges involved in this assignment
27
+ quizzes : List[Quiz]
28
+ The quizzes involved in this assignment
29
+ extensions_by_student_id : Dict[int, datetime]
30
+ Associates ids of students with deadlines that have been given to them (i.e. as extensions)
31
+ max_days_late : int
32
+ The maximum number of days late that will be considered for marking
33
+ daily_penalty : float
34
+ The fraction of max_marks deducted per day late
35
+
36
+ Methods
37
+ ----------
38
+ add_challenge_slide(slide: Slide)
39
+ Adds a slide (usually a code challenge) to this assignment
40
+ add_extension(student: User, deadline: datetime)
41
+ Provides the given student with an extension, until the given deadline
42
+ get_deadline(student: User) -> datetime
43
+ Returns the deadline for the given student
44
+ get_max_score() -> int
45
+ Returns the maximum possible score across all challenges and quizzes
46
+ get_canvas_col() -> str
47
+ Returns the name of the column that would match this assignment in a gradebook exported from Canvas.
48
+
49
+ """
50
+
51
+ name: str
52
+ max_marks: int
53
+ marker: ChallengeMarker
54
+ deadline: datetime.datetime
55
+ challenges: List[Challenge] = field(default_factory=list)
56
+ quizzes: List[Quiz] = field(default_factory=list)
57
+ extensions_by_student_id: Dict[int, datetime.datetime] = field(default_factory=dict)
58
+ max_days_late: int = 7
59
+ daily_penalty: float = 0.05
60
+
61
+ def add_challenge_slide(self, slide: Slide):
62
+ """
63
+ Add a code challenge slide to this assignment.
64
+
65
+ Parameters
66
+ ----------
67
+ slide : Slide
68
+ The slide to add. Must be a code challenge (``slide.challenge_id`` must not be None).
69
+
70
+ Raises
71
+ ------
72
+ ValueError
73
+ If the slide is not a code challenge.
74
+ """
75
+ if slide.challenge_id is None:
76
+ raise ValueError("This slide is not a code challenge")
77
+
78
+ self.challenges.append(Challenge(slide.challenge_id, slide.id, slide.title))
79
+
80
+ def add_quiz_slide(self, slide: Slide, questions: list):
81
+ """
82
+ Add a quiz slide to this assignment.
83
+
84
+ Parameters
85
+ ----------
86
+ slide : Slide
87
+ The quiz slide to add.
88
+ questions : list
89
+ The questions for this quiz, typically fetched via ``EdClient.get_questions``.
90
+ """
91
+ self.quizzes.append(Quiz(slide.id, slide.title, questions))
92
+
93
+ def add_multichoice_solution(self, question_id: int, solution: Set[int]):
94
+ """
95
+ Add an accepted answer to a multiple-choice question across all quizzes in this assignment.
96
+
97
+ Parameters
98
+ ----------
99
+ question_id : int
100
+ The id of the question to update.
101
+ solution : Set[int]
102
+ A set of option indices that constitute a correct answer.
103
+ """
104
+
105
+ for quiz in self.quizzes:
106
+
107
+ if quiz.add_multichoice_solution(question_id, solution):
108
+ return
109
+
110
+ print(f"could not locate multichoice question {question_id}")
111
+
112
+ def add_parsons_solution(self, question_id: int, solution: str):
113
+ """
114
+ Add an accepted arrangement to a Parsons question across all quizzes in this assignment.
115
+
116
+ Parameters
117
+ ----------
118
+ question_id : int
119
+ The id of the question to update.
120
+ solution : str
121
+ The accepted code arrangement as a string.
122
+ """
123
+
124
+ for quiz in self.quizzes:
125
+
126
+ if quiz.add_parsons_solution(question_id, solution):
127
+ return
128
+
129
+ print(f"could not locate parsons question {question_id}")
130
+
131
+ def add_extension(self, student, deadline):
132
+ """
133
+ Grant a deadline extension to a single student.
134
+
135
+ Parameters
136
+ ----------
137
+ student : User
138
+ The student receiving the extension.
139
+ deadline : datetime
140
+ The new deadline for this student.
141
+ """
142
+ self.extensions_by_student_id[student.id] = deadline
143
+
144
+ def add_extensions(self, students, deadline):
145
+ """
146
+ Grant the same deadline extension to multiple students.
147
+
148
+ Parameters
149
+ ----------
150
+ students : List[User]
151
+ The students receiving the extension.
152
+ deadline : datetime
153
+ The new deadline for all listed students.
154
+ """
155
+ for student in students:
156
+ self.extensions_by_student_id[student.id] = deadline
157
+
158
+ def get_deadline(self, student):
159
+ """
160
+ Return the effective deadline for a student, accounting for any extension.
161
+
162
+ Parameters
163
+ ----------
164
+ student : User
165
+ The student to look up.
166
+
167
+ Returns
168
+ -------
169
+ datetime
170
+ The student's personal extension deadline if one exists, otherwise
171
+ the assignment's default deadline.
172
+ """
173
+ if student.id in self.extensions_by_student_id:
174
+ return self.extensions_by_student_id[student.id]
175
+
176
+ return self.deadline
177
+
178
+ def get_max_score(self):
179
+ """
180
+ Return the maximum possible score across all challenges and quizzes.
181
+
182
+ Returns
183
+ -------
184
+ int
185
+ The total points available in this assignment.
186
+ """
187
+ max_score = 0
188
+ for quiz in self.quizzes:
189
+ max_score = max_score + quiz.get_max_score()
190
+
191
+ if self.marker.mark_type == MarkType.BY_CHALLENGE:
192
+ max_score = max_score + (
193
+ len(self.challenges) * self.marker.points_per_challenge
194
+ )
195
+ else:
196
+ max_score = max_score + self.marker.max_test_case_score
197
+
198
+ return max_score
199
+
200
+ def get_canvas_col(self):
201
+ """
202
+ Return the column header that matches this assignment in a Canvas gradebook export.
203
+
204
+ Returns
205
+ -------
206
+ str
207
+ """
208
+ return "{} ({})".format(self.name, self.id)
209
+
210
+
211
+ @dataclass
212
+ class AssignmentResult:
213
+ """
214
+ Records the results of a student for an assignment.
215
+
216
+ Marks for the assignment include a 10% penalty per day if work is received late.
217
+ Late submissions are only considered if they improve the student's marks after the late penalty is applied.
218
+
219
+ Attributes
220
+ ----------
221
+ student_id : int
222
+ The id of the student
223
+ completed : bool
224
+ True if the student successfully completed this assignment, otherwise false.
225
+ score : int
226
+ The score received by this student
227
+ mark : float
228
+ The mark received by this student.
229
+ days_late : int
230
+ The number of days past the deadline that work was received and considered.
231
+ challenge_results : Dict[int, ChallengeResult]
232
+ Associates ids of challenges with their individual results
233
+
234
+ Methods
235
+ ----------
236
+ add_challenge_submission(submission, student, assignment)
237
+ Updates this result based on a single challenge submission
238
+ add_quiz_result(quiz_result)
239
+ Records the result for a quiz
240
+ finalize(assignment, crawler)
241
+ To be called after all submissions have been added.
242
+ print_summary(student, assignment)
243
+ Print a human-readable summary of the student's mark
244
+ """
245
+
246
+ student_id: int
247
+ score: int = None
248
+ mark: float = None
249
+ days_late: int = None
250
+ penalty: float = None
251
+ challenge_results: Dict[int, ChallengeResult] = field(default_factory=dict)
252
+ quiz_results: Dict[int, QuizResult] = field(default_factory=dict)
253
+
254
+ def add_challenge_submission(self, submission, student, assignment):
255
+ """
256
+ Record a challenge submission for this student.
257
+
258
+ Calculates days late relative to the student's effective deadline and
259
+ updates the per-challenge result. Call this for every submission before
260
+ calling ``finalize``.
261
+
262
+ Parameters
263
+ ----------
264
+ submission : Submission
265
+ The submission to record.
266
+ student : User
267
+ The student who made the submission.
268
+ assignment : Assignment
269
+ The assignment being graded (used to look up the student's deadline).
270
+ """
271
+
272
+ deadline = assignment.get_deadline(student)
273
+
274
+ days_late = 0
275
+ if submission.marked_at > deadline:
276
+ diff = submission.marked_at - deadline
277
+ days_late = math.ceil(diff.total_seconds() / (60 * 60 * 24))
278
+
279
+ if submission.challenge_id not in self.challenge_results:
280
+ self.challenge_results[submission.challenge_id] = ChallengeResult()
281
+
282
+ self.challenge_results[submission.challenge_id].add(submission, days_late)
283
+
284
+ def add_quiz_result(self, quiz_result: QuizResult):
285
+ """
286
+ Record the result for a quiz.
287
+
288
+ Parameters
289
+ ----------
290
+ quiz_result : QuizResult
291
+ The graded quiz result to store.
292
+ """
293
+ self.quiz_results[quiz_result.quiz_id] = quiz_result
294
+
295
+ def finalize(self, assignment: Assignment, crawler):
296
+ """
297
+ Calculate the student's final mark. Call after all submissions and quiz results have been added.
298
+
299
+ Finds the number of days late that maximises the student's mark after
300
+ applying the late penalty, and stores the result in ``mark``, ``score``,
301
+ ``days_late``, and ``penalty``.
302
+
303
+ Parameters
304
+ ----------
305
+ assignment : Assignment
306
+ The assignment being graded.
307
+ crawler : EdClient
308
+ Used to fetch full submission detail when the marker type is
309
+ ``BY_TESTCASE_SCORE``. May be None for other mark types.
310
+ """
311
+
312
+ self.score = 0
313
+ self.mark = 0
314
+ self.days_late = 0
315
+ self.penalty = 0
316
+
317
+ quiz_score = 0
318
+ for quiz_result in self.quiz_results.values():
319
+ quiz_score = quiz_score + quiz_result.score
320
+
321
+ if self.challenge_results is not None:
322
+ for challenge in self.challenge_results.values():
323
+ challenge.finalize(assignment.marker, crawler)
324
+
325
+ daily_mark_penalty = assignment.daily_penalty * assignment.max_marks
326
+
327
+ for days_late in range(0, assignment.max_days_late + 1):
328
+ score = quiz_score
329
+
330
+ for challenge in self.challenge_results.values():
331
+ score = score + challenge.scores_by_calendar_days_late[days_late]
332
+
333
+ mark = assignment.max_marks * (score / assignment.get_max_score())
334
+ penalty = days_late * daily_mark_penalty
335
+ mark = mark - penalty
336
+ if mark < 0:
337
+ mark = 0
338
+
339
+ if mark > self.mark:
340
+ self.score = score
341
+ self.mark = mark
342
+ self.days_late = days_late
343
+ self.penalty = penalty
344
+
345
+ def print_summary(self, student: User, assignment: Assignment):
346
+ """
347
+ Print a human-readable summary of the student's mark and per-challenge breakdown.
348
+
349
+ Parameters
350
+ ----------
351
+ student : User
352
+ The student whose result is being summarised.
353
+ assignment : Assignment
354
+ The assignment being summarised.
355
+ """
356
+
357
+ msg = f"{student.name} got {self.mark:.1f}/{assignment.max_marks} marks from {self.score}/{assignment.get_max_score()} points"
358
+
359
+ if self.days_late > 0:
360
+ msg = f"{msg} and a penalty of {self.penalty:.1f} marks for submitting {self.days_late} days late"
361
+
362
+ print(msg)
363
+
364
+ for quiz in assignment.quizzes:
365
+ quiz_result = self.quiz_results.get(quiz.id)
366
+
367
+ if quiz.id is None:
368
+ print(
369
+ f" 0/{quiz.get_max_score()} points for "
370
+ + quiz.name
371
+ + " due to no attempt"
372
+ )
373
+ else:
374
+ quiz_result.print_summary(quiz)
375
+
376
+ for challenge in assignment.challenges:
377
+
378
+ challenge_result = self.challenge_results.get(challenge.id)
379
+ if challenge_result is None:
380
+ print(f" 0 points for " + challenge.name + " due to no attempt")
381
+ else:
382
+ challenge_result.print_summary(challenge, self.days_late)
@@ -0,0 +1,124 @@
1
+ import csv
2
+ from typing import Dict
3
+
4
+ from ed_api_client import Assignment, Users, AssignmentResult
5
+
6
+
7
+ class CanvasExporter:
8
+ """
9
+ Builds a Canvas-compatible gradebook CSV from Ed assignment results.
10
+
11
+ Takes an exported Canvas gradebook as a template and fills in marks from Ed,
12
+ matching students by their SIS Login ID (email address).
13
+
14
+ Parameters
15
+ ----------
16
+ students : Users
17
+ The full collection of Ed users for the course, used to match Canvas
18
+ rows to Ed student ids.
19
+ """
20
+
21
+ def __init__(self, students: Users):
22
+ self.__students = students
23
+ self.__assignments_by_col = {}
24
+ self.__results_by_col = {}
25
+ self.__field_names = ["Student", "ID", "SIS User ID", "SIS Login ID", "Section"]
26
+
27
+ def add_assignment(
28
+ self,
29
+ assignment: Assignment,
30
+ column_name: str,
31
+ results: Dict[int, AssignmentResult],
32
+ ):
33
+ """
34
+ Register an assignment and its results to be included in the export.
35
+
36
+ Parameters
37
+ ----------
38
+ assignment : Assignment
39
+ The assignment to include.
40
+ column_name : str
41
+ The column header to use in the output CSV. Should match the
42
+ corresponding column in the source Canvas gradebook export.
43
+ results : Dict[int, AssignmentResult]
44
+ Maps Ed student ids to their graded results for this assignment.
45
+
46
+ Returns
47
+ -------
48
+ CanvasExporter
49
+ Returns self to allow method chaining.
50
+ """
51
+ self.__assignments_by_col[column_name] = assignment
52
+ self.__results_by_col[column_name] = results
53
+ self.__field_names.append(column_name)
54
+ return self
55
+
56
+ def generate_csv(self, source_csv, target_csv):
57
+ """
58
+ Generate the output gradebook CSV.
59
+
60
+ Reads the Canvas gradebook export at ``source_csv``, looks up each
61
+ student's Ed mark, and writes the result to ``target_csv``. Students
62
+ not found in the Ed user collection are skipped with a warning.
63
+
64
+ Parameters
65
+ ----------
66
+ source_csv : str
67
+ Path to the Canvas gradebook export CSV.
68
+ target_csv : str
69
+ Path to write the output CSV to.
70
+ """
71
+ with open(source_csv) as input:
72
+ reader = csv.DictReader(input)
73
+
74
+ with open(target_csv, "w") as output:
75
+ writer = csv.DictWriter(output, fieldnames=self.__field_names)
76
+ writer.writeheader()
77
+
78
+ for input_row in reader:
79
+
80
+ output_row = self.handle_row(input_row)
81
+
82
+ if output_row is None:
83
+ continue
84
+
85
+ writer.writerow(output_row)
86
+
87
+ def handle_row(self, input_row: dict):
88
+ """
89
+ Convert a single Canvas gradebook row into an output row with Ed marks.
90
+
91
+ Parameters
92
+ ----------
93
+ input_row : dict
94
+ A row from the Canvas gradebook CSV, keyed by column header.
95
+
96
+ Returns
97
+ -------
98
+ dict or None
99
+ The output row with Ed marks filled in, or None if the student
100
+ could not be matched (row is skipped).
101
+ """
102
+
103
+ student_email = input_row.get("SIS Login ID")
104
+ if student_email is None:
105
+ return None
106
+
107
+ student = self.__students.get_by_email(student_email.lower())
108
+ if student is None:
109
+ print(f"Could not locate student {student_email}")
110
+ return None
111
+
112
+ output_row = {}
113
+ for field in ["Student", "ID", "SIS User ID", "SIS Login ID", "Section"]:
114
+ output_row[field] = input_row[field]
115
+
116
+ for col, assignment in self.__assignments_by_col.items():
117
+
118
+ results = self.__results_by_col.get(col, {}).get(student.id)
119
+ if results is None:
120
+ output_row[col] = 0
121
+ else:
122
+ output_row[col] = round(results.mark, 3)
123
+
124
+ return output_row