-
Notifications
You must be signed in to change notification settings - Fork 3.9k
/
course_grading.py
370 lines (310 loc) · 14 KB
/
course_grading.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
"""Grading policy"""
import json
import logging
from base64 import b64encode
from datetime import timedelta
from hashlib import sha1
from eventtracking import tracker
from cms.djangoapps.contentstore.signals.signals import GRADING_POLICY_CHANGED
from cms.djangoapps.models.settings.waffle import material_recompute_only
from common.djangoapps.track.event_transaction_utils import create_new_event_transaction_id
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
log = logging.getLogger(__name__)
GRADING_POLICY_CHANGED_EVENT_TYPE = 'edx.grades.grading_policy_changed'
class CourseGradingModel:
"""
Basically a DAO and Model combo for CRUD operations pertaining to grading policy.
"""
# Within this class, allow access to protected members of client classes.
# This comes up when accessing kvs data and caches during kvs saves and modulestore writes.
def __init__(self, course):
self.graders = [
CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course.raw_grader)
] # weights transformed to ints [0..100]
self.grade_cutoffs = course.grade_cutoffs
self.grace_period = CourseGradingModel.convert_set_grace_period(course)
self.minimum_grade_credit = course.minimum_grade_credit
@classmethod
def fetch(cls, course_key):
"""
Fetch the course grading policy for the given course from persistence and return a CourseGradingModel.
"""
course = modulestore().get_course(course_key)
model = cls(course)
return model
@staticmethod
def fetch_grader(course_key, index):
"""
Fetch the course's nth grader
Returns an empty dict if there's no such grader.
"""
course = modulestore().get_course(course_key)
index = int(index)
if len(course.raw_grader) > index:
return CourseGradingModel.jsonize_grader(index, course.raw_grader[index])
# return empty model
else:
return {"id": index,
"type": "",
"min_count": 0,
"drop_count": 0,
"short_label": None,
"weight": 0
}
@staticmethod
def update_from_json(course_key, jsondict, user):
"""
Decode the json into CourseGradingModel and save any changes. Returns the modified model.
Probably not the usual path for updates as it's too coarse grained.
"""
course = modulestore().get_course(course_key)
previous_grading_policy_hash = str(hash_grading_policy(course.grading_policy))
graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']]
fire_signal = CourseGradingModel.must_fire_grading_event_and_signal(
course_key,
graders_parsed,
course,
jsondict
)
course.raw_grader = graders_parsed
course.grade_cutoffs = jsondict['grade_cutoffs']
modulestore().update_item(course, user.id)
CourseGradingModel.update_grace_period_from_json(course_key, jsondict['grace_period'], user)
CourseGradingModel.update_minimum_grade_credit_from_json(course_key, jsondict['minimum_grade_credit'], user)
course = modulestore().get_course(course_key)
new_grading_policy_hash = str(hash_grading_policy(course.grading_policy))
log.info(
"Updated course grading policy for course %s from %s to %s. fire_signal = %s",
str(course_key),
previous_grading_policy_hash,
new_grading_policy_hash,
fire_signal
)
if fire_signal:
_grading_event_and_signal(course_key, user.id)
return CourseGradingModel.fetch(course_key)
@staticmethod
def must_fire_grading_event_and_signal(course_key, proposed_grader_settings, course_from_modulestore, jsondict):
"""
Detects if substantive enough changes were made to the proposed grader settings to warrant the firing of
_grading_event_and_sngal
Substantive changes mean the following values were changed:
drop_count, weight, min_count
An assignment type was added or removed
"""
if course_from_modulestore.grade_cutoffs != jsondict['grade_cutoffs'] or \
len(proposed_grader_settings) != len(course_from_modulestore.raw_grader):
return True
# because grading policy lists remain in the same order, we can do a single loop
for i in range(len(course_from_modulestore.raw_grader)):
if CourseGradingModel.must_fire_grading_event_and_signal_single_grader(
course_key,
proposed_grader_settings[i],
course_from_modulestore.raw_grader[i]
):
return True
return False
@staticmethod
def must_fire_grading_event_and_signal_single_grader(
course_key,
proposed_grader_settings,
existing_grader_settings
):
"""
Detects changes in an individual grader vs an entire grading policy
Detects if substantive enough changes were made to the proposed grader settings to warrant the firing of
_grading_event_and_sngal
Substantive changes mean the following values were changed:
drop_count, weight, min_count
"""
if not material_recompute_only(course_key):
return True
if existing_grader_settings['drop_count'] != proposed_grader_settings['drop_count'] or \
existing_grader_settings['weight'] != proposed_grader_settings['weight'] or \
existing_grader_settings['min_count'] != proposed_grader_settings['min_count']:
return True
return False
@staticmethod
def update_grader_from_json(course_key, grader, user):
"""
Create or update the grader of the given type (string key) for the given course. Returns the modified
grader which is a full model on the client but not on the server (just a dict)
"""
course = modulestore().get_course(course_key)
previous_grading_policy_hash = str(hash_grading_policy(course.grading_policy))
# parse removes the id; so, grab it before parse
index = int(grader.get('id', len(course.raw_grader)))
grader = CourseGradingModel.parse_grader(grader)
fire_signal = True
if index < len(course.raw_grader):
fire_signal = CourseGradingModel.must_fire_grading_event_and_signal_single_grader(
course_key,
grader,
course.raw_grader[index]
)
course.raw_grader[index] = grader
else:
course.raw_grader.append(grader)
modulestore().update_item(course, user.id)
course = modulestore().get_course(course_key)
new_grading_policy_hash = str(hash_grading_policy(course.grading_policy))
log.info(
"Updated grader for course %s. Grading policy has changed from %s to %s. fire_signal = %s",
str(course_key),
previous_grading_policy_hash,
new_grading_policy_hash,
fire_signal
)
if fire_signal:
_grading_event_and_signal(course_key, user.id)
return CourseGradingModel.jsonize_grader(index, course.raw_grader[index])
@staticmethod
def update_cutoffs_from_json(course_key, cutoffs, user):
"""
Create or update the grade cutoffs for the given course. Returns sent in cutoffs (ie., no extra
db fetch).
"""
course = modulestore().get_course(course_key)
course.grade_cutoffs = cutoffs
modulestore().update_item(course, user.id)
_grading_event_and_signal(course_key, user.id)
return cutoffs
@staticmethod
def update_grace_period_from_json(course_key, graceperiodjson, user):
"""
Update the course's default grace period. Incoming dict is {hours: h, minutes: m} possibly as a
grace_period entry in an enclosing dict. It is also safe to call this method with a value of
None for graceperiodjson.
"""
course = modulestore().get_course(course_key)
# Before a graceperiod has ever been created, it will be None (once it has been
# created, it cannot be set back to None).
if graceperiodjson is not None:
if 'grace_period' in graceperiodjson:
graceperiodjson = graceperiodjson['grace_period']
grace_timedelta = timedelta(**graceperiodjson)
course.graceperiod = grace_timedelta
modulestore().update_item(course, user.id)
@staticmethod
def update_minimum_grade_credit_from_json(course_key, minimum_grade_credit, user):
"""Update the course's default minimum grade requirement for credit.
Args:
course_key(CourseKey): The course identifier
minimum_grade_json(Float): Minimum grade value
user(User): The user object
"""
course = modulestore().get_course(course_key)
# 'minimum_grade_credit' cannot be set to None
if minimum_grade_credit is not None:
minimum_grade_credit = minimum_grade_credit # lint-amnesty, pylint: disable=self-assigning-variable
course.minimum_grade_credit = minimum_grade_credit
modulestore().update_item(course, user.id)
@staticmethod
def delete_grader(course_key, index, user):
"""
Delete the grader of the given type from the given course.
"""
course = modulestore().get_course(course_key)
index = int(index)
if index < len(course.raw_grader):
del course.raw_grader[index]
# force propagation to definition
course.raw_grader = course.raw_grader
modulestore().update_item(course, user.id)
_grading_event_and_signal(course_key, user.id)
@staticmethod
def delete_grace_period(course_key, user):
"""
Delete the course's grace period.
"""
course = modulestore().get_course(course_key)
del course.graceperiod
modulestore().update_item(course, user.id)
@staticmethod
def get_section_grader_type(location):
block = modulestore().get_item(location)
return {
"graderType": block.format if block.format is not None else 'notgraded',
"location": str(location),
}
@staticmethod
def update_section_grader_type(block, grader_type, user): # lint-amnesty, pylint: disable=missing-function-docstring
if grader_type is not None and grader_type != 'notgraded':
block.format = grader_type
block.graded = True
else:
del block.format
del block.graded
modulestore().update_item(block, user.id)
_grading_event_and_signal(block.location.course_key, user.id)
return {'graderType': grader_type}
@staticmethod
def convert_set_grace_period(course): # lint-amnesty, pylint: disable=missing-function-docstring
# 5 hours 59 minutes 59 seconds => converted to iso format
rawgrace = course.graceperiod
if rawgrace:
hours_from_days = rawgrace.days * 24
seconds = rawgrace.seconds
hours_from_seconds = int(seconds / 3600)
hours = hours_from_days + hours_from_seconds
seconds -= hours_from_seconds * 3600
minutes = int(seconds / 60)
seconds -= minutes * 60
graceperiod = {'hours': 0, 'minutes': 0, 'seconds': 0}
if hours > 0:
graceperiod['hours'] = hours
if minutes > 0:
graceperiod['minutes'] = minutes
if seconds > 0:
graceperiod['seconds'] = seconds
return graceperiod
else:
return None
@staticmethod
def parse_grader(json_grader): # lint-amnesty, pylint: disable=missing-function-docstring
# manual to clear out kruft
result = {"type": json_grader["type"],
"min_count": int(json_grader.get('min_count', 0)),
"drop_count": int(json_grader.get('drop_count', 0)),
"short_label": json_grader.get('short_label', None),
"weight": float(json_grader.get('weight', 0)) / 100.0
}
return result
@staticmethod
def jsonize_grader(i, grader): # lint-amnesty, pylint: disable=missing-function-docstring
# Warning: converting weight to integer might give unwanted results due
# to the reason how floating point arithmetic works
# e.g, "0.29 * 100 = 28.999999999999996"
return {
"id": i,
"type": grader["type"],
"min_count": grader.get('min_count', 0),
"drop_count": grader.get('drop_count', 0),
"short_label": grader.get('short_label', ""),
"weight": grader.get('weight', 0) * 100,
}
def _grading_event_and_signal(course_key, user_id): # lint-amnesty, pylint: disable=missing-function-docstring
name = GRADING_POLICY_CHANGED_EVENT_TYPE
course = modulestore().get_course(course_key)
grading_policy_hash = str(hash_grading_policy(course.grading_policy))
data = {
"course_id": str(course_key),
"user_id": str(user_id),
"grading_policy_hash": grading_policy_hash,
"event_transaction_id": str(create_new_event_transaction_id()),
"event_transaction_type": GRADING_POLICY_CHANGED_EVENT_TYPE,
}
tracker.emit(name, data)
GRADING_POLICY_CHANGED.send(
sender=CourseGradingModel,
user_id=user_id,
course_key=course_key,
grading_policy_hash=grading_policy_hash
)
def hash_grading_policy(grading_policy): # lint-amnesty, pylint: disable=missing-function-docstring
ordered_policy = json.dumps(
grading_policy,
separators=(',', ':'), # Remove spaces from separators for more compact representation
sort_keys=True,
)
return b64encode(sha1(ordered_policy.encode("utf-8")).digest()).decode('utf-8')