23
23
# A sample / testing application for IVLE.
31
from storm.locals import Desc, Store
33
from genshi.filters import HTMLFormFiller
34
from genshi.template import Context, TemplateLoader
36
import formencode.validators
38
from ivle.webapp.base.forms import BaseFormView
39
from ivle.webapp.base.plugins import ViewPlugin, MediaPlugin
40
from ivle.webapp.base.xhtml import XHTMLView
41
from ivle.webapp import ApplicationRoot
43
from ivle.database import Subject, Semester, Offering, Enrolment, User,\
44
ProjectSet, Project, ProjectSubmission
30
45
from ivle import util
33
import genshi.template
36
"""Handler for the Subjects application. Links to subject home pages."""
38
req.styles = ["media/subjects/subjects.css"]
39
ctx = genshi.template.Context()
41
# This is represented as a directory. Redirect and add a slash if it is
43
if req.uri[-1] != '/':
44
req.throw_redirect(req.uri + '/')
45
ctx['whichpage'] = "toplevel"
46
handle_toplevel_menu(req, ctx)
48
ctx['whichpage'] = "subject"
49
handle_subject_page(req, req.path, ctx)
51
loader = genshi.template.TemplateLoader(".", auto_reload=True)
52
tmpl = loader.load(util.make_local_path("apps/subjects/template.html"))
53
req.write(tmpl.generate(ctx).render('html')) #'xhtml', doctype='xhtml'))
55
def handle_toplevel_menu(req, ctx):
57
enrolled_subjects = req.user.subjects
58
unenrolled_subjects = [subject for subject in
59
req.store.find(ivle.database.Subject)
60
if subject not in enrolled_subjects]
62
ctx['enrolled_subjects'] = []
63
ctx['other_subjects'] = []
65
req.content_type = "text/html"
66
req.write_html_head_foot = True
68
for subject in enrolled_subjects:
70
new_subj['name'] = subject.name
71
new_subj['url'] = subject.url
72
ctx['enrolled_subjects'].append(new_subj)
74
if len(unenrolled_subjects) > 0:
75
for subject in unenrolled_subjects:
77
new_subj['name'] = subject.name
78
new_subj['url'] = subject.url
79
ctx['other_subjects'].append(new_subj)
82
def handle_subject_page(req, path, ctx):
83
req.content_type = "text/html"
84
req.write_html_head_foot = True # Have dispatch print head and foot
86
# Just make the iframe pointing to media/subjects
87
ctx['serve_loc'] = urllib.quote(util.make_path(os.path.join('media', 'subjects', path)))
48
from ivle.webapp.admin.projectservice import ProjectSetRESTView
49
from ivle.webapp.admin.offeringservice import OfferingRESTView
50
from ivle.webapp.admin.publishing import (root_to_subject,
51
subject_to_offering, offering_to_projectset, offering_to_project,
52
subject_url, offering_url, projectset_url, project_url)
53
from ivle.webapp.admin.breadcrumbs import (SubjectBreadcrumb,
54
OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb)
55
from ivle.webapp.core import Plugin as CorePlugin
56
from ivle.webapp.groups import GroupsView
57
from ivle.webapp.media import media_url
58
from ivle.webapp.tutorial import Plugin as TutorialPlugin
60
class SubjectsView(XHTMLView):
61
'''The view of the list of subjects.'''
62
template = 'templates/subjects.html'
65
def authorize(self, req):
66
return req.user is not None
68
def populate(self, req, ctx):
70
ctx['user'] = req.user
72
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
73
ctx['SubjectEdit'] = SubjectEdit
75
for semester in req.store.find(Semester).order_by(Desc(Semester.year),
76
Desc(Semester.semester)):
78
# For admins, show all subjects in the system
79
offerings = list(semester.offerings.find())
81
offerings = [enrolment.offering for enrolment in
82
semester.enrolments.find(user=req.user)]
84
ctx['semesters'].append((semester, offerings))
86
# Admins get a separate list of subjects so they can add/edit.
88
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
91
class SubjectShortNameUniquenessValidator(formencode.FancyValidator):
92
"""A FormEncode validator that checks that a subject name is unused.
94
The subject referenced by state.existing_subject is permitted
95
to hold that name. If any other object holds it, the input is rejected.
97
def __init__(self, matching=None):
98
self.matching = matching
100
def _to_python(self, value, state):
101
if (state.store.find(
102
Subject, short_name=value).one() not in
103
(None, state.existing_subject)):
104
raise formencode.Invalid(
105
'Short name already taken', value, state)
109
class SubjectSchema(formencode.Schema):
110
short_name = formencode.All(
111
SubjectShortNameUniquenessValidator(),
112
formencode.validators.UnicodeString(not_empty=True))
113
name = formencode.validators.UnicodeString(not_empty=True)
114
code = formencode.validators.UnicodeString(not_empty=True)
117
class SubjectFormView(XHTMLView):
118
"""An abstract form to add or edit a subject."""
121
def authorize(self, req):
122
return req.user is not None and req.user.admin
124
def filter(self, stream, ctx):
125
return stream | HTMLFormFiller(data=ctx['data'])
127
def populate_state(self, state):
128
state.existing_subject = None
130
def populate(self, req, ctx):
131
if req.method == 'POST':
132
data = dict(req.get_fieldstorage())
134
validator = SubjectSchema()
135
self.populate_state(req)
136
data = validator.to_python(data, state=req)
138
subject = self.update_subject_object(req, data)
141
req.throw_redirect(req.publisher.generate(subject))
142
except formencode.Invalid, e:
143
errors = e.unpack_errors()
145
data = self.get_default_data(req)
151
ctx['context'] = self.context
152
ctx['data'] = data or {}
153
ctx['errors'] = errors
156
class SubjectNew(SubjectFormView):
157
"""A form to create a subject."""
158
template = 'templates/subject-new.html'
160
def populate_state(self, state):
161
state.existing_subject = self.context
163
def get_default_data(self, req):
166
def update_subject_object(self, req, data):
167
new_subject = Subject()
168
new_subject.short_name = data['short_name']
169
new_subject.name = data['name']
170
new_subject.code = data['code']
172
req.store.add(new_subject)
176
class SubjectEdit(SubjectFormView):
177
"""A form to edit a subject."""
178
template = 'templates/subject-edit.html'
180
def populate_state(self, state):
181
state.existing_subject = self.context
183
def get_default_data(self, req):
185
'short_name': self.context.short_name,
186
'name': self.context.name,
187
'code': self.context.code,
190
def update_subject_object(self, req, data):
191
self.context.short_name = data['short_name']
192
self.context.name = data['name']
193
self.context.code = data['code']
198
class OfferingView(XHTMLView):
199
"""The home page of an offering."""
200
template = 'templates/offering.html'
204
def populate(self, req, ctx):
205
# Need the worksheet result styles.
206
self.plugin_styles[TutorialPlugin] = ['tutorial.css']
207
ctx['context'] = self.context
209
ctx['permissions'] = self.context.get_permissions(req.user)
210
ctx['format_submission_principal'] = util.format_submission_principal
211
ctx['format_datetime'] = ivle.date.make_date_nice
212
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
213
ctx['OfferingEdit'] = OfferingEdit
215
# As we go, calculate the total score for this subject
216
# (Assessable worksheets only, mandatory problems only)
218
ctx['worksheets'], problems_total, problems_done = (
219
ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
220
req.store, req.user, self.context))
222
ctx['exercises_total'] = problems_total
223
ctx['exercises_done'] = problems_done
224
if problems_total > 0:
225
if problems_done >= problems_total:
226
ctx['worksheets_complete_class'] = "complete"
227
elif problems_done > 0:
228
ctx['worksheets_complete_class'] = "semicomplete"
230
ctx['worksheets_complete_class'] = "incomplete"
231
# Calculate the final percentage and mark for the subject
232
(ctx['exercises_pct'], ctx['worksheet_mark'],
233
ctx['worksheet_max_mark']) = (
234
ivle.worksheet.utils.calculate_mark(
235
problems_done, problems_total))
238
class SubjectValidator(formencode.FancyValidator):
239
"""A FormEncode validator that turns a subject name into a subject.
241
The state must have a 'store' attribute, which is the Storm store
244
def _to_python(self, value, state):
245
subject = state.store.find(Subject, short_name=value).one()
249
raise formencode.Invalid('Subject does not exist', value, state)
252
class SemesterValidator(formencode.FancyValidator):
253
"""A FormEncode validator that turns a string into a semester.
255
The string should be of the form 'year/semester', eg. '2009/1'.
257
The state must have a 'store' attribute, which is the Storm store
260
def _to_python(self, value, state):
262
year, semester = value.split('/')
264
year = semester = None
266
semester = state.store.find(
267
Semester, year=year, semester=semester).one()
271
raise formencode.Invalid('Semester does not exist', value, state)
274
class OfferingUniquenessValidator(formencode.FancyValidator):
275
"""A FormEncode validator that checks that an offering is unique.
277
There cannot be more than one offering in the same year and semester.
279
The offering referenced by state.existing_offering is permitted to
280
hold that year and semester tuple. If any other object holds it, the
283
def _to_python(self, value, state):
284
if (state.store.find(
285
Offering, subject=value['subject'],
286
semester=value['semester']).one() not in
287
(None, state.existing_offering)):
288
raise formencode.Invalid(
289
'Offering already exists', value, state)
293
class OfferingSchema(formencode.Schema):
294
description = formencode.validators.UnicodeString(
295
if_missing=None, not_empty=False)
296
url = formencode.validators.URL(if_missing=None, not_empty=False)
299
class OfferingAdminSchema(OfferingSchema):
300
subject = formencode.All(
301
SubjectValidator(), formencode.validators.UnicodeString())
302
semester = formencode.All(
303
SemesterValidator(), formencode.validators.UnicodeString())
304
chained_validators = [OfferingUniquenessValidator()]
307
class OfferingEdit(BaseFormView):
308
"""A form to edit an offering's details."""
309
template = 'templates/offering-edit.html'
315
if self.req.user.admin:
316
return OfferingAdminSchema()
318
return OfferingSchema()
320
def populate(self, req, ctx):
321
super(OfferingEdit, self).populate(req, ctx)
322
ctx['subjects'] = req.store.find(Subject)
323
ctx['semesters'] = req.store.find(Semester)
325
def populate_state(self, state):
326
state.existing_offering = self.context
328
def get_default_data(self, req):
330
'subject': self.context.subject.short_name,
331
'semester': self.context.semester.year + '/' +
332
self.context.semester.semester,
333
'url': self.context.url,
334
'description': self.context.description,
337
def save_object(self, req, data):
339
self.context.subject = data['subject']
340
self.context.semester = data['semester']
341
self.context.description = data['description']
342
self.context.url = unicode(data['url']) if data['url'] else None
346
class OfferingNew(BaseFormView):
347
"""A form to create an offering."""
348
template = 'templates/offering-new.html'
351
def authorize(self, req):
352
return req.user is not None and req.user.admin
356
return OfferingAdminSchema()
358
def populate(self, req, ctx):
359
super(OfferingNew, self).populate(req, ctx)
360
ctx['subjects'] = req.store.find(Subject)
361
ctx['semesters'] = req.store.find(Semester)
363
def populate_state(self, state):
364
state.existing_offering = None
366
def get_default_data(self, req):
369
def save_object(self, req, data):
370
new_offering = Offering()
371
new_offering.subject = data['subject']
372
new_offering.semester = data['semester']
373
new_offering.description = data['description']
374
new_offering.url = unicode(data['url']) if data['url'] else None
376
req.store.add(new_offering)
380
class UserValidator(formencode.FancyValidator):
381
"""A FormEncode validator that turns a username into a user.
383
The state must have a 'store' attribute, which is the Storm store
385
def _to_python(self, value, state):
386
user = User.get_by_login(state.store, value)
390
raise formencode.Invalid('User does not exist', value, state)
393
class NoEnrolmentValidator(formencode.FancyValidator):
394
"""A FormEncode validator that ensures absence of an enrolment.
396
The state must have an 'offering' attribute.
398
def _to_python(self, value, state):
399
if state.offering.get_enrolment(value):
400
raise formencode.Invalid('User already enrolled', value, state)
404
class RoleEnrolmentValidator(formencode.FancyValidator):
405
"""A FormEncode validator that checks permission to enrol users with a
408
The state must have an 'offering' attribute.
410
def _to_python(self, value, state):
411
if ("enrol_" + value) not in state.offering.get_permissions(state.user):
412
raise formencode.Invalid('Not allowed to assign users that role',
417
class EnrolSchema(formencode.Schema):
418
user = formencode.All(NoEnrolmentValidator(), UserValidator())
419
role = formencode.All(formencode.validators.OneOf(
420
["lecturer", "tutor", "student"]),
421
RoleEnrolmentValidator(),
422
formencode.validators.UnicodeString())
425
class EnrolmentsView(XHTMLView):
426
"""A page which displays all users enrolled in an offering."""
427
template = 'templates/enrolments.html'
431
def populate(self, req, ctx):
432
ctx['offering'] = self.context
434
class EnrolView(XHTMLView):
435
"""A form to enrol a user in an offering."""
436
template = 'templates/enrol.html'
440
def filter(self, stream, ctx):
441
return stream | HTMLFormFiller(data=ctx['data'])
443
def populate(self, req, ctx):
444
if req.method == 'POST':
445
data = dict(req.get_fieldstorage())
447
validator = EnrolSchema()
448
req.offering = self.context # XXX: Getting into state.
449
data = validator.to_python(data, state=req)
450
self.context.enrol(data['user'], data['role'])
452
req.throw_redirect(req.uri)
453
except formencode.Invalid, e:
454
errors = e.unpack_errors()
459
ctx['data'] = data or {}
460
ctx['offering'] = self.context
461
ctx['roles_auth'] = self.context.get_permissions(req.user)
462
ctx['errors'] = errors
464
class OfferingProjectsView(XHTMLView):
465
"""View the projects for an offering."""
466
template = 'templates/offering_projects.html'
470
def populate(self, req, ctx):
471
self.plugin_styles[Plugin] = ["project.css"]
472
self.plugin_scripts[Plugin] = ["project.js"]
474
ctx['offering'] = self.context
475
ctx['projectsets'] = []
476
ctx['OfferingRESTView'] = OfferingRESTView
478
#Open the projectset Fragment, and render it for inclusion
479
#into the ProjectSets page
480
#XXX: This could be a lot cleaner
481
loader = genshi.template.TemplateLoader(".", auto_reload=True)
483
set_fragment = os.path.join(os.path.dirname(__file__),
484
"templates/projectset_fragment.html")
485
project_fragment = os.path.join(os.path.dirname(__file__),
486
"templates/project_fragment.html")
488
for projectset in self.context.project_sets:
489
settmpl = loader.load(set_fragment)
492
setCtx['projectset'] = projectset
493
setCtx['projects'] = []
494
setCtx['GroupsView'] = GroupsView
495
setCtx['ProjectSetRESTView'] = ProjectSetRESTView
497
for project in projectset.projects:
498
projecttmpl = loader.load(project_fragment)
499
projectCtx = Context()
500
projectCtx['req'] = req
501
projectCtx['project'] = project
503
setCtx['projects'].append(
504
projecttmpl.generate(projectCtx))
506
ctx['projectsets'].append(settmpl.generate(setCtx))
509
class ProjectView(XHTMLView):
510
"""View the submissions for a ProjectSet"""
511
template = "templates/project.html"
515
def build_subversion_url(self, svnroot, submission):
516
princ = submission.assessed.principal
518
if isinstance(princ, User):
519
path = 'users/%s' % princ.login
521
path = 'groups/%s_%s_%s_%s' % (
522
princ.project_set.offering.subject.short_name,
523
princ.project_set.offering.semester.year,
524
princ.project_set.offering.semester.semester,
527
return urlparse.urljoin(
529
os.path.join(path, submission.path[1:] if
530
submission.path.startswith(os.sep) else
533
def populate(self, req, ctx):
534
self.plugin_styles[Plugin] = ["project.css"]
537
ctx['GroupsView'] = GroupsView
538
ctx['EnrolView'] = EnrolView
539
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
540
ctx['build_subversion_url'] = self.build_subversion_url
541
ctx['svn_addr'] = req.config['urls']['svn_addr']
542
ctx['project'] = self.context
543
ctx['user'] = req.user
545
class Plugin(ViewPlugin, MediaPlugin):
546
forward_routes = (root_to_subject, subject_to_offering,
547
offering_to_project, offering_to_projectset)
548
reverse_routes = (subject_url, offering_url, projectset_url, project_url)
550
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
551
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
552
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
553
(Subject, '+edit', SubjectEdit),
554
(Offering, '+index', OfferingView),
555
(Offering, '+edit', OfferingEdit),
556
(Offering, ('+enrolments', '+index'), EnrolmentsView),
557
(Offering, ('+enrolments', '+new'), EnrolView),
558
(Offering, ('+projects', '+index'), OfferingProjectsView),
559
(Project, '+index', ProjectView),
561
(Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
562
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
565
breadcrumbs = {Subject: SubjectBreadcrumb,
566
Offering: OfferingBreadcrumb,
567
User: UserBreadcrumb,
568
Project: ProjectBreadcrumb,
572
('subjects', 'Subjects',
573
'View subject content and complete worksheets',
574
'subjects.png', 'subjects', 5)
577
media = 'subject-media'