15
15
# along with this program; if not, write to the Free Software
16
16
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
22
# Tutorial application.
23
# Displays tutorial content with editable exercises, allowing students to test
24
# and submit their solutions to exercises and have them auto-tested.
27
# All path segments are optional (omitted path segments will show menus).
28
# The first path segment is the subject code.
29
# The second path segment is the worksheet name.
18
# Author: Matt Giuca, Will Grant
20
'''Tutorial/worksheet/exercise application.
22
Displays tutorial content with editable exercises, allowing students to test
23
and submit their solutions to exercises and have them auto-tested.
33
from datetime import datetime
30
from datetime import datetime
37
31
from xml.dom import minidom
44
37
import ivle.database
38
from ivle.database import Subject, Offering, Semester, Exercise, \
39
ExerciseSave, WorksheetExercise
40
from ivle.database import Worksheet as DBWorksheet
45
41
import ivle.worksheet
51
import genshi.template
55
# Regex for valid identifiers (subject/worksheet names)
56
re_ident = re.compile("[0-9A-Za-z_]+")
42
from ivle.webapp.base.views import BaseView
43
from ivle.webapp.base.xhtml import XHTMLView
44
from ivle.webapp.base.plugins import ViewPlugin, MediaPlugin
45
from ivle.webapp.media import BaseMediaFileView, media_url
46
from ivle.webapp.errors import NotFound, Forbidden
47
from ivle.webapp.tutorial.rst import rst as rstfunc
48
from ivle.webapp.tutorial.service import AttemptsRESTView, AttemptRESTView, \
49
ExerciseRESTView, WorksheetRESTView, WorksheetsRESTView
52
"""This class represents a worksheet and a particular students progress
55
Do not confuse this with a worksheet in the database. This worksheet
56
has extra information for use in the output, such as marks."""
59
57
def __init__(self, id, name, assessable):
62
60
self.assessable = assessable
63
self.loc = urllib.quote(id)
64
61
self.complete_class = ''
65
62
self.optional_message = ''
69
66
return ("Worksheet(id=%s, name=%s, assessable=%s)"
70
67
% (repr(self.id), repr(self.name), repr(self.assessable)))
72
def make_tutorial_path(subject=None, worksheet=None):
73
"""Creates an absolute (site-relative) path to a tutorial sheet.
74
Subject or worksheet can be None.
75
Ensures that top-level or subject-level URLs end in a '/', because they
76
are represented as directories.
79
return util.make_path(THIS_APP + '/')
82
return util.make_path(os.path.join(THIS_APP, subject + '/'))
84
return util.make_path(os.path.join(THIS_APP, subject, worksheet))
87
"""Handler for the Tutorial application."""
89
# TODO: Take this as an argument instead (refactor dispatch)
90
ctx = genshi.template.Context()
92
# Set request attributes
93
req.content_type = "text/html"
95
"media/common/util.js",
96
"media/common/json2.js",
97
"media/tutorial/tutorial.js",
100
"media/tutorial/tutorial.css",
102
# Note: Don't print write_html_head_foot just yet
103
# If we encounter errors later we do not want this
105
path_segs = req.path.split('/')
108
if len(req.path) > 0:
109
subject = path_segs[0]
110
if subject == "media":
111
# Special case: "tutorial/media" will plainly serve any path
112
# relative to "subjects/media".
113
handle_media_path(req)
115
if len(path_segs) > 2:
116
req.throw_error(req.HTTP_NOT_FOUND,
117
"Invalid tutorial path.")
118
if len(path_segs) == 2:
119
worksheet = path_segs[1]
122
ctx['whichmenu'] = 'toplevel'
123
handle_toplevel_menu(req, ctx)
124
elif worksheet == None:
125
ctx['whichmenu'] = 'subjectmenu'
126
handle_subject_menu(req, ctx, subject)
128
ctx['whichmenu'] = 'worksheet'
129
handle_worksheet(req, ctx, subject, worksheet)
131
# Use Genshi to render out the template
132
# TODO: Dispatch should do this instead
133
loader = genshi.template.TemplateLoader(".", auto_reload=True)
134
tmpl = loader.load(util.make_local_path("apps/tutorial/template.html"))
135
req.write(tmpl.generate(ctx).render('html')) #'xhtml', doctype='xhtml'))
137
def handle_media_path(req):
139
Urls in "tutorial/media" will just be served directly, relative to
140
subjects. So if we came here, we just want to serve a file relative to the
141
subjects directory on the local file system.
143
# First normalise the path
144
urlpath = os.path.normpath(req.path)
145
# Now if it begins with ".." or separator, then it's illegal
146
if urlpath.startswith("..") or urlpath.startswith('/'):
147
req.throw_error(req.HTTP_FORBIDDEN,
149
filename = os.path.join(ivle.conf.subjects_base, urlpath)
150
(type, _) = mimetypes.guess_type(filename)
152
type = ivle.conf.mimetypes.default_mimetype
153
## THIS CODE taken from apps/server/__init__.py
154
if not os.access(filename, os.R_OK):
155
req.throw_error(req.HTTP_NOT_FOUND,
156
"The requested file does not exist.")
157
if os.path.isdir(filename):
158
req.throw_error(req.HTTP_FORBIDDEN,
159
"The requested file is a directory.")
160
req.content_type = type
161
req.sendfile(filename)
163
def handle_toplevel_menu(req, ctx):
164
# This is represented as a directory. Redirect and add a slash if it is
166
if req.uri[-1] != '/':
167
req.throw_redirect(make_tutorial_path())
168
req.write_html_head_foot = True
170
ctx['enrolled_subjects'] = req.user.subjects
171
ctx['unenrolled_subjects'] = [subject for subject in
172
req.store.find(ivle.database.Subject)
173
if subject not in ctx['enrolled_subjects']]
175
def is_valid_subjname(subject):
176
m = re_ident.match(subject)
177
return m is not None and m.end() == len(subject)
179
def handle_subject_menu(req, ctx, subject):
180
# This is represented as a directory. Redirect and add a slash if it is
182
if req.uri[-1] != '/':
183
req.throw_redirect(make_tutorial_path(subject))
184
# Subject names must be valid identifiers
185
if not is_valid_subjname(subject):
186
req.throw_error(req.HTTP_NOT_FOUND,
187
"Invalid subject name: %s." % repr(subject))
188
# Parse the subject description file
189
# The subject directory must have a file "subject.xml" in it,
190
# or it does not exist (404 error).
192
ctx['subject'] = subject
194
subjectfile = open(os.path.join(ivle.conf.subjects_base, subject,
195
"subject.xml")).read()
197
req.throw_error(req.HTTP_NOT_FOUND,
198
"Subject %s not found." % repr(subject))
200
subjectfile = genshi.Stream(list(genshi.XML(subjectfile)))
202
ctx['worksheets'] = get_worksheets(subjectfile)
204
# Now all the errors are out the way, we can begin writing
206
req.write_html_head_foot = True
207
# As we go, calculate the total score for this subject
208
# (Assessable worksheets only, mandatory problems only)
211
for worksheet in ctx['worksheets']:
212
stored_worksheet = ivle.database.Worksheet.get_by_name(req.store,
213
subject, worksheet.id)
214
# If worksheet is not in database yet, we'll simply not display
215
# data about it yet (it should be added as soon as anyone visits
216
# the worksheet itself).
217
if stored_worksheet is not None:
218
# If the assessable status of this worksheet has changed,
220
# (Note: This fails the try block if the worksheet is not yet
221
# in the DB, which is fine. The author should visit the
222
# worksheet page to get it into the DB).
223
if worksheet.assessable != stored_worksheet.assessable:
224
# XXX If statement to avoid unnecessary database writes.
225
# Is this necessary, or will Storm check for us?
226
stored_worksheet.assessable = worksheet.assessable
228
if worksheet.assessable:
69
class OfferingView(XHTMLView):
70
'''The view of the index of worksheets for an offering.'''
71
template = 'templates/subjectmenu.html'
72
appname = 'tutorial' # XXX
75
def __init__(self, req, subject, year, semester):
76
"""Find the given offering by subject, year and semester."""
77
self.context = req.store.find(Offering,
78
Offering.subject_id == Subject.id,
79
Subject.code == subject,
80
Offering.semester_id == Semester.id,
81
Semester.year == year,
82
Semester.semester == semester).one()
88
def populate(self, req, ctx):
89
"""Create the context for the given offering."""
90
self.plugin_styles[Plugin] = ['tutorial.css']
92
ctx['subject'] = self.context.subject
93
ctx['year'] = self.context.semester.year
94
ctx['semester'] = self.context.semester.semester
96
# As we go, calculate the total score for this subject
97
# (Assessable worksheets only, mandatory problems only)
99
ctx['worksheets'] = []
102
# Offering.worksheets is ordered by the worksheets seq_no
103
for worksheet in self.context.worksheets:
104
new_worksheet = Worksheet(worksheet.identifier, worksheet.name,
105
worksheet.assessable)
106
if new_worksheet.assessable:
229
107
# Calculate the user's score for this worksheet
230
108
mand_done, mand_total, opt_done, opt_total = (
231
109
ivle.worksheet.calculate_score(req.store, req.user,
233
111
if opt_total > 0:
234
112
optional_message = " (excluding optional exercises)"
236
114
optional_message = ""
237
115
if mand_done >= mand_total:
238
worksheet.complete_class = "complete"
116
new_worksheet.complete_class = "complete"
239
117
elif mand_done > 0:
240
worksheet.complete_class = "semicomplete"
118
new_worksheet.complete_class = "semicomplete"
242
worksheet.complete_class = "incomplete"
120
new_worksheet.complete_class = "incomplete"
243
121
problems_done += mand_done
244
122
problems_total += mand_total
245
worksheet.mand_done = mand_done
246
worksheet.total = mand_total
247
worksheet.optional_message = optional_message
250
ctx['problems_total'] = problems_total
251
ctx['problems_done'] = problems_done
252
if problems_total > 0:
253
if problems_done >= problems_total:
254
ctx['complete_class'] = "complete"
255
elif problems_done > 0:
256
ctx['complete_class'] = "semicomplete"
258
ctx['complete_class'] = "incomplete"
259
ctx['problems_pct'] = (100 * problems_done) / problems_total
260
# TODO: Put this somewhere else! What is this on about? Why 16?
261
# XXX Marks calculation (should be abstracted out of here!)
262
# percent / 16, rounded down, with a maximum mark of 5
264
ctx['mark'] = min(ctx['problems_pct'] / 16, ctx['max_mark'])
123
new_worksheet.mand_done = mand_done
124
new_worksheet.total = mand_total
125
new_worksheet.optional_message = optional_message
126
ctx['worksheets'].append(new_worksheet)
128
ctx['problems_total'] = problems_total
129
ctx['problems_done'] = problems_done
130
if problems_total > 0:
131
if problems_done >= problems_total:
132
ctx['complete_class'] = "complete"
133
elif problems_done > 0:
134
ctx['complete_class'] = "semicomplete"
136
ctx['complete_class'] = "incomplete"
137
ctx['problems_pct'] = (100 * problems_done) / problems_total
139
# We want to display a students mark out of 5. However, they are
140
# allowed to skip 1 in 5 questions and still get 'full marks'.
141
# Hence we divide by 16, essentially making 16 percent worth
142
# 1 star, and 80 or above worth 5.
144
ctx['mark'] = min(ctx['problems_pct'] / 16, ctx['max_mark'])
146
class WorksheetView(XHTMLView):
147
'''The view of a worksheet with exercises.'''
148
template = 'templates/worksheet.html'
149
appname = 'tutorial' # XXX
152
def __init__(self, req, subject, year, semester, worksheet):
153
self.context = req.store.find(DBWorksheet,
154
DBWorksheet.offering_id == Offering.id,
155
Offering.subject_id == Subject.id,
156
Subject.code == subject,
157
Offering.semester_id == Semester.id,
158
Semester.year == year,
159
Semester.semester == semester,
160
DBWorksheet.identifier == worksheet).one()
162
if self.context is None:
163
raise NotFound(str(worksheet) + " was not found.")
166
self.semester = semester
168
def populate(self, req, ctx):
169
self.plugin_scripts[Plugin] = ['tutorial.js']
170
self.plugin_styles[Plugin] = ['tutorial.css']
175
ctx['subject'] = self.context.offering.subject
176
ctx['worksheet'] = self.context
177
ctx['semester'] = self.semester
178
ctx['year'] = self.year
179
ctx['worksheetstream'] = genshi.Stream(list(genshi.XML(self.context.data)))
181
generate_worksheet_data(ctx, req, self.context)
183
ctx['worksheetstream'] = add_exercises(ctx['worksheetstream'], ctx, req)
185
class SubjectMediaView(BaseMediaFileView):
186
'''The view of subject media files.
188
URIs pointing here will just be served directly, from the subject's
193
def __init__(self, req, subject, path):
194
self.context = req.store.find(Subject, code=subject).one()
195
self.path = os.path.normpath(path)
197
def _make_filename(self, req):
198
# If the subject doesn't exist, self.subject will be None. Die.
202
subjectdir = os.path.join(ivle.conf.subjects_base,
203
self.context.code, 'media')
204
return os.path.join(subjectdir, self.path)
266
206
def get_worksheets(subjectfile):
267
207
'''Given a subject stream, get all the worksheets and put them in ctx'''
284
224
return worksheets
286
def handle_worksheet(req, ctx, subject, worksheet):
287
# Subject and worksheet names must be valid identifiers
288
if not is_valid_subjname(subject) or not is_valid_subjname(worksheet):
289
req.throw_error(req.HTTP_NOT_FOUND,
290
"Invalid subject name %s or worksheet name %s."
291
% (repr(subject), repr(worksheet)))
293
# Read in worksheet data
294
worksheetfilename = os.path.join(ivle.conf.subjects_base, subject,
297
worksheetfile = open(worksheetfilename)
298
worksheetmtime = os.path.getmtime(worksheetfilename)
300
req.throw_error(req.HTTP_NOT_FOUND,
301
"Worksheet file not found.")
302
worksheetmtime = datetime.fromtimestamp(worksheetmtime)
303
worksheetfile = worksheetfile.read()
305
ctx['worksheetstream'] = genshi.Stream(list(genshi.XML(worksheetfile)))
307
req.write_html_head_foot = True
309
ctx['subject'] = subject
311
#TODO: Replace this with a nice way, possibly a match template
312
generate_worksheet_data(ctx, req)
314
update_db_worksheet(req.store, subject, worksheet, worksheetmtime,
317
ctx['worksheetstream'] = add_exercises(ctx['worksheetstream'], ctx, req)
319
# This generator adds in the exercises as they are required. This is returned
226
# This generator adds in the exercises as they are required. This is returned.
320
227
def add_exercises(stream, ctx, req):
321
"""A filter adds exercises into the stream."""
228
"""A filter which adds exercises into the stream."""
323
230
for kind, data, pos in stream:
324
231
if kind is genshi.core.START:
325
if data[0] == 'exercise':
232
# Remove the worksheet tags, as they are not xhtml valid.
233
if data[0] == 'worksheet':
235
# If we have an exercise node, replace it with the content of the
237
elif data[0] == 'exercise':
238
# XXX: Note that we presume ctx['exercises'] has a correct list
239
# of exercises. If it doesn't, something has gone wrong.
326
240
new_stream = ctx['exercises'][exid]['stream']
328
242
for item in new_stream:
331
245
yield kind, data, pos
246
# Remove the end tags for exercises and worksheets
247
elif kind is genshi.core.END:
248
if data == 'exercise':
250
elif data == 'worksheet':
253
yield kind, data, pos
333
255
yield kind, data, pos
335
257
# This function runs through the worksheet, to get data on the exercises to
336
258
# build a Table of Contents, as well as fill in details in ctx
337
def generate_worksheet_data(ctx, req):
259
def generate_worksheet_data(ctx, req, worksheet):
338
260
"""Runs through the worksheetstream, generating the exericises"""
340
261
ctx['exercises'] = []
341
262
ctx['exerciselist'] = []
342
263
for kind, data, pos in ctx['worksheetstream']:
343
264
if kind is genshi.core.START:
344
265
if data[0] == 'exercise':
348
268
for attr in data[1]:
392
311
# Exercise-specific context is used here, as we already have all the data
394
313
curctx = genshi.template.Context()
395
curctx['filename'] = exercisesrc
396
curctx['exerciseid'] = exerciseid
315
worksheet_exercise = req.store.find(WorksheetExercise,
316
WorksheetExercise.worksheet_id == worksheet.id,
317
WorksheetExercise.exercise_id == src).one()
319
if worksheet_exercise is None:
398
322
# Retrieve the exercise details from the database
399
exercise = ivle.database.Exercise.get_by_name(req.store, exercisesrc)
400
#Open the exercise, and double-check that it exists
401
exercisefile = util.open_exercise_file(exercisesrc)
402
if exercisefile is None:
403
req.throw_error(req.HTTP_EXPECTATION_FAILED, \
404
"Exercise file could not be opened")
323
exercise = req.store.find(Exercise,
324
Exercise.id == worksheet_exercise.exercise_id).one()
327
raise NotFound(exercisesrc)
406
329
# Read exercise file and present the exercise
407
330
# Note: We do not use the testing framework because it does a lot more
408
331
# work than we need. We just need to get the exercise name and a few other
409
332
# fields from the XML.
411
334
#TODO: Replace calls to minidom with calls to the database directly
412
exercisedom = minidom.parse(exercisefile)
414
exercisedom = exercisedom.documentElement
415
if exercisedom.tagName != "exercise":
416
req.throw_error(req.HTTP_INTERNAL_SERVER_ERROR,
417
"The exercise XML file's top-level element must be <exercise>.")
418
curctx['exercisename'] = exercisedom.getAttribute("name")
420
curctx['rows'] = exercisedom.getAttribute("rows")
421
if not curctx['rows']:
422
curctx['rows'] = "12"
423
# Look for some other fields we need, which are elements:
426
curctx['exercisedesc'] = None
427
curctx['exercisepartial'] = ""
428
for elem in exercisedom.childNodes:
429
if elem.nodeType == elem.ELEMENT_NODE:
430
if elem.tagName == "desc":
431
curctx['exercisedesc'] = genshi.XML(rst(innerXML(elem).strip()))
432
if elem.tagName == "partial":
433
curctx['exercisepartial'] = getTextData(elem) + '\n'
434
curctx['exercisepartial_backup'] = curctx['exercisepartial']
335
curctx['exercise'] = exercise
336
if exercise.description is not None:
337
desc = rstfunc(exercise.description)
338
curctx['description'] = genshi.XML('<div id="description">' + desc +
341
curctx['description'] = None
436
343
# If the user has already saved some text for this problem, or submitted
437
344
# an attempt, then use that text instead of the supplied "partial".
438
saved_text = ivle.worksheet.get_exercise_stored_text(req.store,
345
# Get exercise stored text will return a save, or the most recent attempt,
346
# whichever is more recent
347
save = ivle.worksheet.get_exercise_stored_text(
348
req.store, req.user, worksheet_exercise)
440
350
# Also get the number of attempts taken and whether this is complete.
441
351
complete, curctx['attempts'] = \
442
ivle.worksheet.get_exercise_status(req.store, req.user, exercise)
443
if saved_text is not None:
444
curctx['exercisepartial'] = saved_text.text
446
curctx['complete'] = 'complete'
352
ivle.worksheet.get_exercise_status(req.store, req.user,
355
curctx['exercisesave'] = save.text
448
curctx['complete'] = 'incomplete'
357
curctx['exercisesave']= exercise.partial
358
curctx['complete'] = 'Complete' if complete else 'Incomplete'
359
curctx['complete_class'] = curctx['complete'].lower()
450
361
#Save the exercise details to the Table of Contents
452
363
loader = genshi.template.TemplateLoader(".", auto_reload=True)
453
tmpl = loader.load(util.make_local_path("apps/tutorial/exercise.html"))
364
tmpl = loader.load(os.path.join(os.path.dirname(__file__), "exercise.html"))
454
365
ex_stream = tmpl.generate(curctx)
455
return {'name': curctx['exercisename'], 'complete': curctx['complete'], \
456
'stream': ex_stream, 'exid': exerciseid}
459
def update_db_worksheet(store, subject, worksheetname, file_mtime,
460
exercise_list=None, assessable=None):
462
Determines if the database is missing this worksheet or out of date,
463
and inserts or updates its details about the worksheet.
464
file_mtime is a datetime.datetime with the modification time of the XML
465
file. The database will not be updated unless worksheetmtime is newer than
466
the mtime in the database.
467
exercise_list is a list of (filename, optional) pairs as returned by
468
present_table_of_contents.
469
assessable is boolean.
470
exercise_list and assessable are optional, and if omitted, will not change
471
the existing data. If the worksheet does not yet exist, and assessable
472
is omitted, it defaults to False.
474
worksheet = ivle.database.Worksheet.get_by_name(store, subject,
477
updated_database = False
478
if worksheet is None:
479
# If assessable is not supplied, default to False.
480
if assessable is None:
482
# Create a new Worksheet
483
worksheet = ivle.database.Worksheet(subject=unicode(subject),
484
name=unicode(worksheetname), assessable=assessable,
485
mtime=datetime.now())
487
updated_database = True
489
if file_mtime > worksheet.mtime:
490
# File on disk is newer than database. Need to update.
491
worksheet.mtime = datetime.now()
492
if exercise_list is not None:
493
# exercise_list is supplied, so delete any existing problems
494
worksheet.remove_all_exercises(store)
495
if assessable is not None:
496
worksheet.assessable = assessable
497
updated_database = True
499
if updated_database and exercise_list is not None:
500
# Insert each exercise into the worksheet
501
for exercise_name, optional in exercise_list:
502
# Get the Exercise from the DB
503
exercise = ivle.database.Exercise.get_by_name(store,exercise_name)
504
# Create a new binding between the worksheet and the exercise
505
worksheetexercise = ivle.database.WorksheetExercise(
506
worksheet=worksheet, exercise=exercise, optional=optional)
366
return {'name': exercise.name,
367
'complete': curctx['complete_class'],
371
class OfferingAdminView(XHTMLView):
372
"""The admin view for an Offering.
374
This class is designed to check the user has admin privileges, and
375
then allow them to edit the RST for the offering, which controls which
376
worksheets are actually displayed on the page."""
379
class WorksheetEditView(XHTMLView):
380
"""The admin view for an offering.
382
This view is designed to replace worksheets.xml, turning them instead
383
into XML directly from RST."""
385
template = "templates/worksheet_edit.html"
386
appname = "Edit Worksheet"
388
def __init__(self, req, **kwargs):
390
subject = kwargs['subject']
391
year = kwargs['year']
392
semester = kwargs['semester']
393
worksheet = kwargs['worksheet']
394
self.context = req.store.find(DBWorksheet,
395
DBWorksheet.identifier == worksheet,
396
DBWorksheet.offering_id == Offering.id,
397
Offering.semester_id == Semester.id,
398
Semester.year == year,
399
Semester.semester == semester,
400
Offering.subject_id == Subject.id,
401
Subject.code == subject
404
if self.context is None:
407
self.subject = subject
409
self.semester = semester
410
self.worksheet = worksheet
413
def populate(self, req, ctx):
414
self.plugin_styles[Plugin] = ["tutorial_admin.css"]
415
self.plugin_scripts[Plugin] = ['tutorial_admin.js']
417
ctx['worksheet'] = self.context
418
ctx['worksheetname'] = self.worksheet
419
ctx['subject'] = self.context.offering.subject
420
ctx['year'] = self.year
421
ctx['semester'] = self.semester
422
#XXX: Get the list of formats from somewhere else
423
ctx['formats'] = ['xml', 'rst']
426
class WorksheetAddView(XHTMLView):
427
"""This view allows a user to add a worksheet"""
429
template = "templates/worksheet_add.html"
431
def __init__(self, req, subject, year, semester):
432
self.context = req.store.find(Offering,
433
Offering.semester_id == Semester.id,
434
Semester.year == year,
435
Semester.semester == semester,
436
Offering.subject_id == Subject.id,
437
Subject.code == subject
440
self.subject = subject
442
self.semester = semester
444
if self.context is None:
447
def populate(self, req, ctx):
448
self.plugin_styles[Plugin] = ["tutorial_admin.css"]
449
self.plugin_scripts[Plugin] = ['tutorial_admin.js']
451
ctx['subject'] = self.context.subject
452
ctx['year'] = self.year
453
ctx['semester'] = self.semester
455
#XXX: Get the list of formats from somewhere else
456
ctx['formats'] = ['xml', 'rst']
458
class WorksheetsEditView(XHTMLView):
459
"""View for arranging worksheets."""
462
template = 'templates/worksheets_edit.html'
464
def __init__(self, req, subject, year, semester):
465
self.context = req.store.find(Offering,
466
Offering.semester_id == Semester.id,
467
Semester.year == year,
468
Semester.semester == semester,
469
Offering.subject_id == Subject.id,
470
Subject.code == subject
473
self.subject = subject
475
self.semester = semester
477
if self.context is None:
480
def populate(self, req, ctx):
481
self.plugin_styles[Plugin] = ['tutorial_admin.css']
482
self.plugin_scripts[Plugin] = ['tutorial_admin.js']
484
ctx['subject'] = self.context.subject
485
ctx['year'] = self.year
486
ctx['semester'] = self.semester
488
ctx['worksheets'] = self.context.worksheets
490
ctx['mediapath'] = media_url(req, Plugin, 'images/')
494
class Plugin(ViewPlugin, MediaPlugin):
496
('subjects/:subject/+worksheets/+media/*(path)', SubjectMediaView),
497
('subjects/:subject/:year/:semester/+worksheets', OfferingView),
498
('subjects/:subject/:year/:semester/+worksheets/+new', WorksheetAddView),
499
('subjects/:subject/:year/:semester/+worksheets/+edit', WorksheetsEditView),
500
('subjects/:subject/:year/:semester/+worksheets/:worksheet', WorksheetView),
501
('subjects/:subject/:year/:semester/+worksheets/:worksheet/+edit', WorksheetEditView),
502
('api/subjects/:subject/:year/:semester/+worksheets', WorksheetsRESTView),
503
('api/subjects/:subject/:year/:semester/+worksheets/:worksheet/*exercise/'
504
'+attempts/:username', AttemptsRESTView),
505
('api/subjects/:subject/:year/:semester/+worksheets/:worksheet/*exercise/'
506
'+attempts/:username/:date', AttemptRESTView),
507
('api/subjects/:subject/:year/:semester/+worksheets/:worksheet', WorksheetRESTView),
508
('api/subjects/:subject/:year/:semester/+worksheets/:worksheet/*exercise', ExerciseRESTView),
512
help = {'Tutorial': 'help.html'}