Merge lp:~ivle-dev/ivle/marks-fix into lp:ivle

Proposed by Matt Giuca
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
Reviewer Review Type Date Requested Status
William Grant Approve
Review via email: mp+5866@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Matt Giuca (mgiuca) wrote :

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.

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."""

Subscribers

People subscribed via source and target branches