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,272 @@
1
+ import operator
2
+ import requests
3
+ import time
4
+ import json
5
+
6
+ from ed_api_client import (
7
+ User,
8
+ Slide,
9
+ Lesson,
10
+ SlideResultSet,
11
+ SlideResult,
12
+ Module,
13
+ Submission,
14
+ Assignment,
15
+ AssignmentResult,
16
+ Question,
17
+ Answer,
18
+ Users,
19
+ QuizResult,
20
+ WorkspaceLog,
21
+ ScaffoldSession, Override,
22
+ )
23
+
24
+
25
+ class EdClient:
26
+ """
27
+ Provides access to the Ed API
28
+
29
+ Methods
30
+ ----------
31
+
32
+ get_user () -> User
33
+ Retrieves details of the authenticated user
34
+ get_users (courseId:int) -> List<User>
35
+ Retrieves details of all users involved in the given course
36
+ get_slides (lessonId:int) -> List<Slide>
37
+ Retrieves all slides for the given lesson
38
+ get_module_structure (courseId:int, fetchSlides:bool=False) -> List<Module>
39
+ Retrieves all modules and lessons for the given course, and optionally all slides within each lesson
40
+ get_slide_results (lessonId: int) -> SlideResultSet
41
+ Retrieves records of student interactions with slides within the given lesson
42
+ get_submission (submissionId: int) -> Submission
43
+ Retrieves details of a single submission (i.e. a student hitting Mark).
44
+ Note the returned submission will include details of indiviual test cases that are passed or failed.
45
+ get_submissions (userId: int, challengeId: int) -> List<Submission>
46
+ Retrieves details of all submissions made by the given student towards the given challenge.
47
+ Note the returned submissions will not include details of indiviual test cases that are passed or failed.
48
+ getResultsForStudent (assignment: Assignment, student: Student) -> AssignmentResult
49
+ Retrieves the marks and other details for the given student and given assignment
50
+ """
51
+
52
+ def __init__(self, token):
53
+ self.host = "https://edstem.org/api"
54
+ self.session = requests.Session()
55
+ # self.session.headers.update({"x-token": token})
56
+ self.session.headers.update({"Authorization": f"Bearer {token}"})
57
+ self.MAX_ATTEMPTS = 3
58
+ self.DELAY = 60
59
+
60
+ def __get_json(self, url, params=None):
61
+ r = None
62
+
63
+ for attempt in range(0, self.MAX_ATTEMPTS):
64
+ r = self.session.get(url, params=params)
65
+
66
+ if r.status_code >= 200 and r.status_code < 300:
67
+ return r.json()
68
+
69
+ if r.status_code == 429:
70
+ # getting rate limited
71
+ time.sleep(self.DELAY)
72
+
73
+ print(
74
+ "given up on request to {} after {} attempts, due to {}:{}".format(
75
+ url, self.MAX_ATTEMPTS, r.status_code, r.content
76
+ )
77
+ )
78
+ r.raise_for_status()
79
+
80
+ def get_user(self) -> User:
81
+ r = self.__get_json("{}/user".format(self.host))
82
+ u = r.get("user")
83
+ if u is None:
84
+ return None
85
+
86
+ return User.from_json(u)
87
+
88
+ def get_users(self, courseId: int, role: str = None) -> Users:
89
+ users = Users()
90
+
91
+ r = self.__get_json("{}/courses/{}/admin".format(self.host, courseId))
92
+
93
+ for u in r.get("users", []):
94
+ user = User.from_json(u)
95
+ if role is None or role == user.role:
96
+ users.add(user)
97
+
98
+ return users
99
+
100
+ def get_slides(self, lessonId):
101
+ slides = []
102
+
103
+ r = self.__get_json("{}/lessons/{}".format(self.host, lessonId))
104
+
105
+ for s in r.get("lesson", {}).get("slides", []):
106
+ slides.append(Slide.from_json(s))
107
+
108
+ return slides
109
+
110
+ def get_slide(self, slideId):
111
+ slides = []
112
+
113
+ r = self.__get_json(f"{self.host}/lessons/slides/{slideId}")
114
+ print(r)
115
+ return Slide.from_json(r["slide"])
116
+
117
+ def get_challenge(self, challengeId):
118
+ slides = []
119
+
120
+ r = self.__get_json(f"{self.host}/challenges/{challengeId}")
121
+ print(r)
122
+ # return Slide.from_json(r['slide'])
123
+
124
+ def get_module_structure(self, courseId, fetch_slides=False):
125
+ r = self.__get_json("{}/courses/{}/lessons".format(self.host, courseId))
126
+
127
+ modules = []
128
+ modulesById = {}
129
+ moduleIndex = 0
130
+ for m in r.get("modules", []):
131
+ module = Module(m["id"], m["name"], moduleIndex)
132
+ modules.append(module)
133
+ modulesById[module.id] = module
134
+ moduleIndex += 1
135
+
136
+ for l in r.get("lessons", []):
137
+ if l["is_hidden"]:
138
+ continue
139
+
140
+ moduleId = l.get("module_id")
141
+
142
+ if moduleId is None or moduleId not in modulesById:
143
+ continue
144
+
145
+ lesson = Lesson.from_json(l)
146
+ modulesById[moduleId].add_lesson(lesson)
147
+
148
+ if fetch_slides:
149
+ for slide in self.get_slides(lesson.id):
150
+ lesson.add_slide(slide)
151
+
152
+ modules.sort(key=operator.attrgetter("index"))
153
+ return modules
154
+
155
+ def get_slide_results(self, lessonId):
156
+ r = self.__get_json("{}/lessons/{}/results".format(self.host, lessonId))
157
+
158
+ results = SlideResultSet()
159
+
160
+ for user in r:
161
+ userId = user["user_id"]
162
+
163
+ for slide in user.get("slides", []):
164
+ slideId = slide["slide_id"]
165
+ results.add(userId, slideId, SlideResult(slide))
166
+
167
+ return results
168
+
169
+ def get_submission(self, submissionId):
170
+ r = self.__get_json(
171
+ "{}/challenges/submissions/{}".format(self.host, submissionId)
172
+ )
173
+ s = r.get("submission")
174
+
175
+ if s is None:
176
+ return None
177
+
178
+ return Submission.from_json(s)
179
+
180
+ def get_submissions(self, userId, challengeId):
181
+ submissions = []
182
+ r = self.__get_json(
183
+ "{}/users/{}/challenges/{}/submissions".format(
184
+ self.host, userId, challengeId
185
+ )
186
+ )
187
+
188
+ for s in r.get("submissions", []):
189
+ submissions.append(Submission.from_json(s))
190
+
191
+ return submissions
192
+
193
+ def get_scaffold(self, challengeId: str):
194
+
195
+ r = self.__get_json(f"{self.host}/challenges/{challengeId}/scaffold")
196
+ print(json.dumps(r))
197
+
198
+ def get_workspace_log(self, workspaceId: str) -> WorkspaceLog:
199
+
200
+ r = self.__get_json(f"{self.host}/workspaces/{workspaceId}/logs")
201
+ # print(r['challenge_attempts'], r['challenge_results'])
202
+
203
+ return WorkspaceLog.from_json(r)
204
+
205
+ def getResultsForStudent(
206
+ self, assignment: Assignment, student: User
207
+ ) -> AssignmentResult:
208
+ result = AssignmentResult(student.id)
209
+
210
+ for quiz in assignment.quizzes:
211
+ answers = self.getAnswers(quiz.id, student.id)
212
+ result.add_quiz_result(QuizResult.build(quiz.id, quiz.questions, answers))
213
+
214
+ for challenge in assignment.challenges:
215
+ submissions = self.get_submissions(student.id, challenge.id)
216
+
217
+ submissions.sort(key=lambda x: x.markedAt)
218
+
219
+ for submission in submissions:
220
+ result.add_challenge_submission(submission, student, assignment)
221
+
222
+ result.finalize(assignment, self)
223
+ return result
224
+
225
+ def getQuestions(self, slideId):
226
+
227
+ questions = []
228
+ r = self.__get_json(f"{self.host}/lessons/slides/{slideId}/questions")
229
+
230
+ for q in r.get("questions", []):
231
+ questions.append(Question.from_json(q))
232
+
233
+ return questions
234
+
235
+ def getAnswers(self, slideId, userId):
236
+
237
+ answers = []
238
+ r = self.__get_json(
239
+ f"{self.host}/lessons/slides/{slideId}/questions/responses?user_id={userId}"
240
+ )
241
+
242
+ for a in r.get("responses", []):
243
+
244
+ if a.get("data") is None:
245
+ continue
246
+
247
+ answers.append(Answer.from_json(a))
248
+
249
+ return answers
250
+
251
+ def get_scaffold_files(self, challengeId):
252
+
253
+ r = self.session.post(f"{self.host}/challenges/{challengeId}/connect/scaffold")
254
+ ticket = r.json().get("ticket")
255
+
256
+ session = ScaffoldSession(ticket)
257
+ session.start()
258
+
259
+ return list(session.filesById.values())
260
+
261
+ def get_overrides(self, courseId:int, lessonId:int):
262
+
263
+ r = self.__get_json(
264
+ f"{self.host}/courses/{courseId}/overrides",
265
+ {'lesson_id' : lessonId}
266
+ )
267
+
268
+ overrides = []
269
+ for o in r.get('overrides',[]):
270
+ overrides.append(Override.from_json(o))
271
+
272
+ return overrides
@@ -0,0 +1,282 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Set, Dict
3
+ import datetime
4
+ import dateutil.parser
5
+ import re
6
+
7
+
8
+ def getParsonsSolutionFromSource(source):
9
+ solution = ""
10
+ for line in re.split("-----\\s*\n", source):
11
+ line = line.rstrip()
12
+
13
+ if len(line) == 0:
14
+ continue
15
+
16
+ if line.endswith("#alt"):
17
+ continue
18
+
19
+ if line.endswith("#distractor"):
20
+ continue
21
+
22
+ solution = solution + line + "\n"
23
+
24
+ return solution
25
+
26
+
27
+ def getParsonsAnswer(items):
28
+ answer = ""
29
+ for item in items:
30
+ for i in range(0, item.get("indent", 0)):
31
+ answer = answer + " "
32
+
33
+ answer = answer + item.get("text", "") + "\n"
34
+
35
+ return answer
36
+
37
+
38
+ def doesCodeMatch(codeA: str, codeB: str, strictIndentation: bool = False):
39
+
40
+ codeA = re.sub("-----", "", codeA)
41
+ codeB = re.sub("-----", "", codeB)
42
+
43
+ if strictIndentation:
44
+ return codeA == codeB
45
+
46
+ normA = re.sub("\\s+", " ", codeA).strip()
47
+ normB = re.sub("\\s+", " ", codeB).strip()
48
+
49
+ # print("A", normA)
50
+ # print("B", normB)
51
+
52
+ return normA == normB
53
+
54
+
55
+ @dataclass
56
+ class Quiz:
57
+ id: int
58
+ name: str
59
+ questions: list
60
+
61
+ def getMaxScore(self):
62
+ score = 0
63
+ for question in self.questions:
64
+ score = score + question.points
65
+
66
+ return score
67
+
68
+ def add_multichoice_solution(self, questionId: int, solution: Set[int]) -> bool:
69
+
70
+ for question in self.questions:
71
+ if question.type != "multichoice":
72
+ continue
73
+
74
+ if question.id != questionId:
75
+ continue
76
+
77
+ question.add_solution(solution)
78
+ return True
79
+
80
+ return False
81
+
82
+ def add_parsons_solution(self, questionId: int, solution: str) -> bool:
83
+
84
+ for question in self.questions:
85
+ if question.type != "parsons":
86
+ continue
87
+
88
+ if question.id != questionId:
89
+ continue
90
+
91
+ question.add_solution(solution)
92
+ return True
93
+
94
+ return False
95
+
96
+
97
+ @dataclass
98
+ class Question:
99
+ id: int
100
+ points: int
101
+ content: str
102
+ type: str = field(default=None, init=False)
103
+
104
+ def from_json(json: dict):
105
+ question_type = json.get("data", {}).get("type", "unknown")
106
+ if question_type == "multiple-choice":
107
+ return MultichoiceQuestion.from_json(json)
108
+ if question_type == "parsons":
109
+ return ParsonsQuestion.from_json(json)
110
+ if question_type == "short-answer":
111
+ return ShortAnswerQuestion.from_json(json)
112
+
113
+
114
+ @dataclass
115
+ class ShortAnswerQuestion(Question):
116
+ type = "short-answer"
117
+
118
+ def from_json(json: dict):
119
+ return ShortAnswerQuestion(
120
+ json["id"], json["auto_points"], json.get("data", {}).get("content")
121
+ )
122
+
123
+
124
+ @dataclass
125
+ class MultichoiceQuestion(Question):
126
+ type = "multichoice"
127
+ multiple_selection: bool
128
+ options: List[str]
129
+ solutions: List[Set[int]]
130
+
131
+ def from_json(json: dict):
132
+ return MultichoiceQuestion(
133
+ json["id"],
134
+ json["auto_points"],
135
+ json.get("data", {}).get("content"),
136
+ json.get("data", {}).get("multiple_selection", False),
137
+ json.get("data", {}).get("answers", []),
138
+ [json.get("data", {}).get("solution", [])],
139
+ )
140
+
141
+ def add_solution(self, solution: Set[int]):
142
+ self.solutions.append(solution)
143
+
144
+
145
+ @dataclass
146
+ class ParsonsQuestion(Question):
147
+ type = "parsons"
148
+ solutions: List[str] = field(default_factory=list)
149
+
150
+ def from_json(json: dict):
151
+ return ParsonsQuestion(
152
+ json["id"],
153
+ json["auto_points"],
154
+ json.get("data", {}).get("content"),
155
+ [getParsonsSolutionFromSource(json.get("data", {}).get("source"))],
156
+ )
157
+
158
+ def add_solution(self, solution: str):
159
+ self.solutions.append(solution)
160
+
161
+
162
+ @dataclass
163
+ class Answer:
164
+ question_id: int
165
+ created_at: datetime.datetime
166
+ type: str = field(default=None, init=False)
167
+
168
+ def from_json(j: dict):
169
+
170
+ if type(j["data"]) == list:
171
+ return MultichoiceAnswer.from_json(j)
172
+ elif type(j["data"]) == str:
173
+ return ShortAnswer.from_json(j)
174
+ else:
175
+ return ParsonsAnswer.from_json(j)
176
+
177
+
178
+ @dataclass
179
+ class MultichoiceAnswer(Answer):
180
+ type = "multichoice"
181
+ choices: Set[int]
182
+
183
+ def from_json(j: dict):
184
+ return MultichoiceAnswer(
185
+ j["question_id"], dateutil.parser.isoparse(j["created_at"]), j["data"]
186
+ )
187
+
188
+
189
+ @dataclass
190
+ class ParsonsAnswer(Answer):
191
+ type = "parsons"
192
+ code: str
193
+
194
+ def from_json(j: dict):
195
+
196
+ return ParsonsAnswer(
197
+ j["question_id"],
198
+ dateutil.parser.isoparse(j["created_at"]),
199
+ getParsonsAnswer(j["data"]["items"]),
200
+ )
201
+
202
+
203
+ @dataclass
204
+ class ShortAnswer(Answer):
205
+ type = "shortanswer"
206
+ text: str
207
+
208
+ def from_json(j: dict):
209
+
210
+ return ShortAnswer(
211
+ j["question_id"], dateutil.parser.isoparse(j["created_at"]), j["data"]
212
+ )
213
+
214
+
215
+ @dataclass
216
+ class QuizResult:
217
+ quizId: int
218
+ score: int = 0
219
+ answersByQuestionId: Dict[int, Answer] = field(default_factory=dict)
220
+ pointsByQuestionId: Dict[int, int] = field(default_factory=dict)
221
+
222
+ def build(quizId: int, questions: List, answers: List):
223
+
224
+ result = QuizResult(quizId)
225
+
226
+ for answer in answers:
227
+ result.answersByQuestionId[answer.question_id] = answer
228
+
229
+ for question in questions:
230
+
231
+ answer = result.answersByQuestionId.get(question.id)
232
+ if answer is None:
233
+ # result.pointsByQuestionId[question.id] = None
234
+ continue
235
+
236
+ if question.type == "multichoice":
237
+ correct = False
238
+ for solution in question.solutions:
239
+
240
+ if solution == answer.choices:
241
+ correct = True
242
+ break
243
+
244
+ if correct:
245
+ result.score = result.score + question.points
246
+ result.pointsByQuestionId[question.id] = question.points
247
+ else:
248
+ result.pointsByQuestionId[question.id] = 0
249
+
250
+ if question.type == "parsons":
251
+ correct = False
252
+ for solution in question.solutions:
253
+ if doesCodeMatch(solution, answer.code, False):
254
+ correct = True
255
+ break
256
+
257
+ if correct:
258
+ result.score = result.score + question.points
259
+ result.pointsByQuestionId[question.id] = question.points
260
+ else:
261
+ result.pointsByQuestionId[question.id] = 0
262
+
263
+ return result
264
+
265
+ def print_summary(self, quiz: Quiz):
266
+
267
+ print(
268
+ f" {self.score}/{quiz.getMaxScore()} points for "
269
+ + quiz.name
270
+ + " from the following questions:"
271
+ )
272
+
273
+ for index in range(0, len(quiz.questions)):
274
+ question = quiz.questions[index]
275
+ points = self.pointsByQuestionId.get(question.id)
276
+
277
+ if points is None:
278
+ print(f" Q{index+1}: 0 points (unanswered)")
279
+ elif points == 0:
280
+ print(f" Q{index+1}: 0 points (incorrect)")
281
+ else:
282
+ print(f" Q{index + 1}: {points} points (correct)")