Status: | Merged |
---|---|
Approved by: | William Grant |
Approved revision: | 1214 |
Merged at revision: | 1196 |
Proposed branch: | lp:~ivle-dev/ivle/marks-fix |
Merge into: | lp:ivle |
Diff against target: | None lines |
To merge this branch: | bzr merge lp:~ivle-dev/ivle/marks-fix |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | Approve | ||
Review via email:
|
Commit message
Description of the change
To post a comment you must log in.
Revision history for this message

Matt Giuca (mgiuca) wrote : | # |
lp:~ivle-dev/ivle/marks-fix
updated
- 1214. By Matt Giuca
-
ivle.database: Removed Worksheet.
get_by_ name.
This method wasn't used anywhere and was invalid (it took a subject name
which isn't sufficient now that we have offerings). Just getting rid of it
should do no harm.
Revision history for this message

William Grant (wgrant) wrote : | # |
Looks fine to me.
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'bin/ivle-marks' |
2 | --- bin/ivle-marks 2009-02-26 01:22:17 +0000 |
3 | +++ bin/ivle-marks 2009-04-24 14:29:12 +0000 |
4 | @@ -1,6 +1,6 @@ |
5 | #!/usr/bin/env python |
6 | # IVLE - Informatics Virtual Learning Environment |
7 | -# Copyright (C) 2007-2008 The University of Melbourne |
8 | +# Copyright (C) 2007-2009 The University of Melbourne |
9 | # |
10 | # This program is free software; you can redistribute it and/or modify |
11 | # it under the terms of the GNU General Public License as published by |
12 | @@ -18,44 +18,25 @@ |
13 | |
14 | # Program: Marks |
15 | # Author: Matt Giuca |
16 | -# Date: 17/4/2008 |
17 | |
18 | # Script to calculate the marks for all students for a particular subject. |
19 | # Requires root to run. |
20 | |
21 | import sys |
22 | import os |
23 | -import re |
24 | import csv |
25 | +import datetime |
26 | +import optparse |
27 | from xml.dom import minidom |
28 | |
29 | +if os.getuid() != 0: |
30 | + print >>sys.stderr, "Must run %s as root." % os.path.basename(sys.argv[0]) |
31 | + sys.exit() |
32 | + |
33 | import ivle.database |
34 | import ivle.worksheet.utils |
35 | import ivle.conf |
36 | |
37 | -if os.getuid() != 0: |
38 | - print >>sys.stderr, "Must run %s as root." % os.path.basename(sys.argv[0]) |
39 | - sys.exit() |
40 | - |
41 | -if len(sys.argv) <= 1: |
42 | - print >>sys.stderr, "Usage: %s subject" % os.path.basename(sys.argv[0]) |
43 | - sys.exit() |
44 | - |
45 | -# Regex for valid identifiers (subject/worksheet names) |
46 | -re_ident = re.compile("[0-9A-Za-z_]+") |
47 | - |
48 | -# This code copy/edited from www/apps/tutorial/__init__.py |
49 | -def is_valid_subjname(subject): |
50 | - m = re_ident.match(subject) |
51 | - return m is not None and m.end() == len(subject) |
52 | - |
53 | -subject = sys.argv[1] |
54 | - |
55 | -# Subject names must be valid identifiers |
56 | -if not is_valid_subjname(subject): |
57 | - print >>sys.stderr, "Invalid subject name: %s." % repr(subject) |
58 | - sys.exit() |
59 | - |
60 | def get_userdata(user): |
61 | """ |
62 | Given a User object, returns a list of strings for the user data which |
63 | @@ -65,102 +46,155 @@ |
64 | last_login = (None if user.last_login is None else |
65 | user.last_login.strftime("%d/%m/%y")) |
66 | return [user.studentid, user.login, user.fullname, last_login] |
67 | + |
68 | userdata_header = ["Student ID", "Login", "Full name", "Last login"] |
69 | - |
70 | -def get_assessable_worksheets(subject): |
71 | - """ |
72 | - Given a subject name, returns a list of strings - the worksheet names (not |
73 | - primary key IDs) for all assessable worksheets for that subject. |
74 | - May raise Exceptions, which are fatal. |
75 | - """ |
76 | - # NOTE: This code is copy/edited from |
77 | - # www/apps/tutorial/__init__.py:handle_subject_menu |
78 | - # Should be factored out of there. |
79 | - |
80 | - # Parse the subject description file |
81 | - # The subject directory must have a file "subject.xml" in it, |
82 | - # or it does not exist (raise exception). |
83 | - try: |
84 | - subjectfile = open(os.path.join(ivle.conf.subjects_base, subject, |
85 | - "subject.xml")) |
86 | - except: |
87 | - raise Exception("Subject %s not found." % repr(subject)) |
88 | - |
89 | - assessable_worksheets = [] |
90 | - # Read in data about the subject |
91 | - subjectdom = minidom.parse(subjectfile) |
92 | - subjectfile.close() |
93 | - # TEMP: All of this is for a temporary XML format, which will later |
94 | - # change. |
95 | - worksheetsdom = subjectdom.documentElement |
96 | - worksheets = [] # List of string IDs |
97 | - for worksheetdom in worksheetsdom.childNodes: |
98 | - if worksheetdom.nodeType == worksheetdom.ELEMENT_NODE: |
99 | - # (Note: assessable will default to False, unless it is explicitly |
100 | - # set to "true"). |
101 | - if worksheetdom.getAttribute("assessable") == "true": |
102 | - assessable_worksheets.append(worksheetdom.getAttribute("id")) |
103 | - |
104 | - return assessable_worksheets |
105 | - |
106 | -def get_marks_header(worksheets): |
107 | - """ |
108 | - Given a list of strings - the assessable worksheets - returns a new list |
109 | - of strings - the column headings for the marks section of the CSV output. |
110 | - """ |
111 | - return worksheets + ["Total %", "Mark"] |
112 | - |
113 | -def get_marks_user(subject, worksheet_names, user): |
114 | - """ |
115 | - Given a subject, a list of strings (the assessable worksheets), and a user |
116 | - object, returns the user's percentage for each worksheet, overall, and |
117 | +def get_header(worksheets): |
118 | + """ |
119 | + Given a list of Worksheet objects (the assessable worksheets), returns a |
120 | + list of strings -- the column headings for the marks section of the CSV |
121 | + output. |
122 | + """ |
123 | + return (userdata_header + [ws.name for ws in worksheets] |
124 | + + ["Total %", "Mark"]) |
125 | + |
126 | +def get_marks_user(worksheets, user, as_of=None): |
127 | + """Gets marks for a particular user for a particular set of worksheets. |
128 | + @param worksheets: List of Worksheet objects to get marks for. |
129 | + @param user: User to get marks for. |
130 | + @param as_of: Optional datetime. If supplied, gets the marks as of as_of. |
131 | + @returns: The user's percentage for each worksheet, overall, and |
132 | their final mark, as a list of strings, in a manner which corresponds to |
133 | the headings produced by get_marks_header. |
134 | """ |
135 | - # NOTE: This code is copy/edited from |
136 | - # www/apps/tutorial/__init__.py:handle_subject_menu |
137 | - # Should be factored out of there. |
138 | - |
139 | worksheet_pcts = [] |
140 | # As we go, calculate the total score for this subject |
141 | # (Assessable worksheets only, mandatory problems only) |
142 | problems_done = 0 |
143 | problems_total = 0 |
144 | |
145 | - for worksheet_name in worksheet_names: |
146 | - worksheet = ivle.database.Worksheet.get_by_name(store, |
147 | - subject, worksheet_name) |
148 | + for worksheet in worksheets: |
149 | # We simply ignore optional exercises here |
150 | mand_done, mand_total, _, _ = ( |
151 | - ivle.worksheet.utils.calculate_score(store, user, worksheet)) |
152 | - worksheet_pcts.append(float(mand_done) / mand_total) |
153 | + ivle.worksheet.utils.calculate_score(store, user, worksheet, |
154 | + as_of)) |
155 | + if mand_total > 0: |
156 | + worksheet_pcts.append(float(mand_done) / mand_total) |
157 | + else: |
158 | + # Avoid Div0, just give everyone 0 marks if there are none |
159 | + worksheet_pcts.append(0.0) |
160 | problems_done += mand_done |
161 | problems_total += mand_total |
162 | - problems_pct = float(problems_done) / problems_total |
163 | - problems_pct_int = (100 * problems_done) / problems_total |
164 | - # XXX Marks calculation (should be abstracted out of here!) |
165 | - # percent / 16, rounded down, with a maximum mark of 5 |
166 | - max_mark = 5 |
167 | - mark = min(problems_pct_int / 16, max_mark) |
168 | - return worksheet_pcts + [problems_pct, mark] |
169 | + percent, mark, _ = ( |
170 | + ivle.worksheet.utils.calculate_mark(problems_done, problems_total)) |
171 | + return worksheet_pcts + [float(percent)/100, mark] |
172 | |
173 | -def writeuser(subject, worksheets, user, csvfile): |
174 | +def writeuser(worksheets, user, csvfile, cutoff=None): |
175 | userdata = get_userdata(user) |
176 | - marksdata = get_marks_user(subject, worksheets, user) |
177 | - csvfile.writerow(userdata + marksdata) |
178 | - |
179 | -try: |
180 | - # Get the list of assessable worksheets from the subject.xml file. |
181 | - worksheets = get_assessable_worksheets(subject) |
182 | + marksdata = get_marks_user(worksheets, user, cutoff) |
183 | + data = userdata + marksdata |
184 | + # CSV writer can't handle non-ASCII characters. Encode to UTF-8. |
185 | + data = [unicode(x).encode('utf-8') for x in data] |
186 | + csvfile.writerow(data) |
187 | + |
188 | +def main(argv=None): |
189 | + global store |
190 | + if argv is None: |
191 | + argv = sys.argv |
192 | + |
193 | + usage = """usage: %prog [options] subject |
194 | + (requires root) |
195 | + Reports each student's marks for a given subject offering.""" |
196 | + |
197 | + # Parse arguments |
198 | + parser = optparse.OptionParser(usage) |
199 | + parser.add_option("-s", "--semester", |
200 | + action="store", dest="semester", metavar="YEAR/SEMESTER", |
201 | + help="Semester of the subject's offering (eg. 2009/1). " |
202 | + "Defaults to the currently active semester.", |
203 | + default=None) |
204 | + parser.add_option("-c", "--cutoff", |
205 | + action="store", dest="cutoff", metavar="DATE", |
206 | + help="Cutoff date (calculate the marks as of this date). " |
207 | + "YYYY-MM-DD H:M:S.", |
208 | + default=None) |
209 | + (options, args) = parser.parse_args(argv[1:]) |
210 | + |
211 | + if len(args) < 1: |
212 | + parser.print_help() |
213 | + parser.exit() |
214 | + |
215 | + subject_name = unicode(args[0]) |
216 | + |
217 | + if options.semester is None: |
218 | + year, semester = None, None |
219 | + else: |
220 | + try: |
221 | + year, semester = options.semester.split('/') |
222 | + if len(year) == 0 or len(semester) == 0: |
223 | + raise ValueError() |
224 | + except ValueError: |
225 | + parser.error('Invalid semester (must have form "year/semester")') |
226 | + |
227 | + if options.cutoff is not None: |
228 | + try: |
229 | + cutoff = datetime.datetime.strptime(options.cutoff, |
230 | + "%Y-%m-%d %H:%M:%S") |
231 | + except ValueError: |
232 | + parser.error("Invalid date format: '%s' " |
233 | + "(must be YYYY-MM-DD H:M:S)." % options.cutoff) |
234 | + else: |
235 | + cutoff = None |
236 | + |
237 | store = ivle.database.get_store() |
238 | -except Exception, message: |
239 | - print >>sys.stderr, "Error: " + str(message) |
240 | - sys.exit(1) |
241 | - |
242 | -# Start writing the CSV file - header |
243 | -csvfile = csv.writer(sys.stdout) |
244 | -csvfile.writerow(userdata_header + get_marks_header(worksheets)) |
245 | - |
246 | -for user in store.find(ivle.database.User).order_by(ivle.database.User.login): |
247 | - writeuser(subject, worksheets, user, csvfile) |
248 | - |
249 | + |
250 | + # Get the subject from the DB |
251 | + subject = store.find(ivle.database.Subject, |
252 | + ivle.database.Subject.short_name == subject_name).one() |
253 | + if subject is None: |
254 | + print >>sys.stderr, "No subject with short name '%s'" % subject_name |
255 | + return 1 |
256 | + |
257 | + # Get the offering from the DB |
258 | + if semester is None: |
259 | + # None specified - get the current offering from the DB |
260 | + offerings = list(subject.active_offerings()) |
261 | + if len(offerings) == 0: |
262 | + print >>sys.stderr, ("No active offering for subject '%s'" |
263 | + % subject_name) |
264 | + return 1 |
265 | + elif len(offerings) > 1: |
266 | + print >>sys.stderr, ("Multiple active offerings for subject '%s':" |
267 | + % subject_name) |
268 | + print >>sys.stderr, "Please use one of:" |
269 | + for offering in offerings: |
270 | + print >>sys.stderr, (" --semester=%s/%s" |
271 | + % (offering.semester.year, offering.semester.semester)) |
272 | + return 1 |
273 | + else: |
274 | + offering = offerings[0] |
275 | + else: |
276 | + # Get the offering for the specified semester |
277 | + offering = subject.offering_for_semester(year, semester) |
278 | + if offering is None: |
279 | + print >>sys.stderr, ( |
280 | + "No offering for subject '%s' in semester %s/%s" |
281 | + % (subject_name, year, semester)) |
282 | + return 1 |
283 | + |
284 | + # Get the list of assessable worksheets |
285 | + worksheets = offering.worksheets.find(assessable=True) |
286 | + |
287 | + # Start writing the CSV file - header |
288 | + csvfile = csv.writer(sys.stdout) |
289 | + csvfile.writerow(get_header(worksheets)) |
290 | + |
291 | + # Get all users enrolled in this offering |
292 | + users = store.find(ivle.database.User, |
293 | + ivle.database.User.id == ivle.database.Enrolment.user_id, |
294 | + offering.id == ivle.database.Enrolment.offering).order_by( |
295 | + ivle.database.User.login) |
296 | + for user in users: |
297 | + writeuser(worksheets, user, csvfile, cutoff) |
298 | + |
299 | +if __name__ == "__main__": |
300 | + sys.exit(main(sys.argv)) |
301 | |
302 | === modified file 'ivle/database.py' |
303 | --- ivle/database.py 2009-04-07 03:44:43 +0000 |
304 | +++ ivle/database.py 2009-04-24 12:43:55 +0000 |
305 | @@ -256,6 +256,20 @@ |
306 | perms.add('edit') |
307 | return perms |
308 | |
309 | + def active_offerings(self): |
310 | + """Return a sequence of currently active offerings for this subject |
311 | + (offerings whose semester.state is "current"). There should be 0 or 1 |
312 | + elements in this sequence, but it's possible there are more. |
313 | + """ |
314 | + return self.offerings.find(Offering.semester_id == Semester.id, |
315 | + Semester.state == u'current') |
316 | + |
317 | + def offering_for_semester(self, year, semester): |
318 | + """Get the offering for the given year/semester, or None.""" |
319 | + return self.offerings.find(Offering.semester_id == Semester.id, |
320 | + Semester.year == unicode(year), |
321 | + Semester.semester == unicode(semester)).one() |
322 | + |
323 | class Semester(Storm): |
324 | __storm_table__ = "semester" |
325 | |
326 | |
327 | === modified file 'ivle/webapp/tutorial/__init__.py' |
328 | --- ivle/webapp/tutorial/__init__.py 2009-03-17 01:42:19 +0000 |
329 | +++ ivle/webapp/tutorial/__init__.py 2009-04-24 13:44:39 +0000 |
330 | @@ -137,14 +137,10 @@ |
331 | ctx['complete_class'] = "semicomplete" |
332 | else: |
333 | ctx['complete_class'] = "incomplete" |
334 | - ctx['problems_pct'] = (100 * problems_done) / problems_total |
335 | - |
336 | - # We want to display a students mark out of 5. However, they are |
337 | - # allowed to skip 1 in 5 questions and still get 'full marks'. |
338 | - # Hence we divide by 16, essentially making 16 percent worth |
339 | - # 1 star, and 80 or above worth 5. |
340 | - ctx['max_mark'] = 5 |
341 | - ctx['mark'] = min(ctx['problems_pct'] / 16, ctx['max_mark']) |
342 | + # Calculate the final percentage and mark for the subject |
343 | + ctx['problems_pct'], ctx['mark'], ctx['max_mark'] = ( |
344 | + ivle.worksheet.utils.calculate_mark( |
345 | + problems_done, problems_total)) |
346 | |
347 | class WorksheetView(XHTMLView): |
348 | '''The view of a worksheet with exercises.''' |
349 | |
350 | === modified file 'ivle/worksheet/utils.py' |
351 | --- ivle/worksheet/utils.py 2009-02-26 01:22:17 +0000 |
352 | +++ ivle/worksheet/utils.py 2009-04-24 14:29:12 +0000 |
353 | @@ -34,9 +34,13 @@ |
354 | 'get_exercise_attempts', 'get_exercise_attempt', |
355 | ] |
356 | |
357 | -def get_exercise_status(store, user, worksheet_exercise): |
358 | +def get_exercise_status(store, user, worksheet_exercise, as_of=None): |
359 | """Given a storm.store, User and Exercise, returns information about |
360 | the user's performance on that problem. |
361 | + @param store: A storm.store |
362 | + @param user: A User. |
363 | + @param worksheet_exercise: An Exercise. |
364 | + @param as_of: Optional datetime. If supplied, gets the status as of as_of. |
365 | Returns a tuple of: |
366 | - A boolean, whether they have successfully passed this exercise. |
367 | - An int, the number of attempts they have made up to and |
368 | @@ -48,6 +52,8 @@ |
369 | is_relevant = ((ExerciseAttempt.user_id == user.id) & |
370 | (ExerciseAttempt.ws_ex_id == worksheet_exercise.id) & |
371 | (ExerciseAttempt.active == True)) |
372 | + if as_of is not None: |
373 | + is_relevant &= ExerciseAttempt.date <= as_of |
374 | |
375 | # Get the first successful active attempt, or None if no success yet. |
376 | # (For this user, for this exercise). |
377 | @@ -162,10 +168,14 @@ |
378 | saved.date = date |
379 | saved.text = text |
380 | |
381 | -def calculate_score(store, user, worksheet): |
382 | +def calculate_score(store, user, worksheet, as_of=None): |
383 | """ |
384 | Given a storm.store, User, Exercise and Worksheet, calculates a score for |
385 | the user on the given worksheet. |
386 | + @param store: A storm.store |
387 | + @param user: A User. |
388 | + @param worksheet: A Worksheet. |
389 | + @param as_of: Optional datetime. If supplied, gets the score as of as_of. |
390 | Returns a 4-tuple of ints, consisting of: |
391 | (No. mandatory exercises completed, |
392 | Total no. mandatory exercises, |
393 | @@ -183,7 +193,7 @@ |
394 | worksheet = worksheet_exercise.worksheet |
395 | optional = worksheet_exercise.optional |
396 | |
397 | - done, _ = get_exercise_status(store, user, worksheet_exercise) |
398 | + done, _ = get_exercise_status(store, user, worksheet_exercise, as_of) |
399 | # done is a bool, whether this student has completed that problem |
400 | if optional: |
401 | opt_total += 1 |
402 | @@ -194,6 +204,33 @@ |
403 | |
404 | return mand_done, mand_total, opt_done, opt_total |
405 | |
406 | +def calculate_mark(mand_done, mand_total): |
407 | + """Calculate a subject mark, given the result of all worksheets. |
408 | + @param mand_done: The total number of mandatory exercises completed by |
409 | + some student, across all worksheets. |
410 | + @param mand_total: The total number of mandatory exercises across all |
411 | + worksheets in the offering. |
412 | + @return: (percent, mark, mark_total) |
413 | + percent: The percentage of exercises the student has completed, as an |
414 | + integer between 0 and 100 inclusive. |
415 | + mark: The mark the student has received, based on the percentage. |
416 | + mark_total: The total number of marks available (currently hard-coded |
417 | + as 5). |
418 | + """ |
419 | + # We want to display a students mark out of 5. However, they are |
420 | + # allowed to skip 1 in 5 questions and still get 'full marks'. |
421 | + # Hence we divide by 16, essentially making 16 percent worth |
422 | + # 1 star, and 80 or above worth 5. |
423 | + if mand_total > 0: |
424 | + percent_int = (100 * mand_done) // mand_total |
425 | + else: |
426 | + # Avoid Div0, just give everyone 0 marks if there are none available |
427 | + percent_int = 0 |
428 | + # percent / 16, rounded down, with a maximum mark of 5 |
429 | + max_mark = 5 |
430 | + mark = min(percent_int // 16, max_mark) |
431 | + return (percent_int, mark, max_mark) |
432 | + |
433 | def update_exerciselist(worksheet): |
434 | """Runs through the worksheetstream, generating the appropriate |
435 | WorksheetExercises, and de-activating the old ones.""" |
Ready to merge ivle-marks into trunk, completely rewritten. Please review. (I even did your --cutoff feature, Will! Bonus marks for me!)
Commit log as follows:
bin/ivle-marks: Completely rewritten. (Was hopelessly out-of-date, and didn't work at all. Now works on the current database model, and has new features to boot - able to select the semester and cutoff date).
ivle.worksheet. utils: Added calculate_mark, which is from the duplicated code in both bin/ivle-marks and ivle.webapp. tutorial. OfferingView. populate. Also added some extra optional arguments for calculating exercise/worksheet scores from a particular date.
ivle.webapp. tutorial: Call ivle.worksheet. utils.calculate _mark instead of manually calculating (avoid code duplication).
ivle.database: A few new methods on the Subject class, for retrieving offerings.