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
29
40
from ivle.webapp.base.xhtml import XHTMLView
30
from ivle.webapp.base.plugins import ViewPlugin, MediaPlugin
31
from ivle.webapp.errors import NotFound
32
from ivle.database import Subject
41
from ivle.webapp import ApplicationRoot
43
from ivle.database import Subject, Semester, Offering, Enrolment, User,\
44
ProjectSet, Project, ProjectSubmission
33
45
from ivle import util
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
36
60
class SubjectsView(XHTMLView):
37
61
'''The view of the list of subjects.'''
38
template = 'subjects.html'
39
appname = 'subjects' # XXX
62
template = 'templates/subjects.html'
41
65
def authorize(self, req):
42
66
return req.user is not None
44
68
def populate(self, req, ctx):
45
enrolled_subjects = req.user.subjects
46
unenrolled_subjects = [subject for subject in
47
req.store.find(Subject)
48
if subject not in enrolled_subjects]
50
ctx['enrolled_subjects'] = []
51
ctx['other_subjects'] = []
53
for subject in enrolled_subjects:
55
new_subj['name'] = subject.name
56
new_subj['url'] = subject.url
57
ctx['enrolled_subjects'].append(new_subj)
59
if len(unenrolled_subjects) > 0:
60
for subject in unenrolled_subjects:
62
new_subj['name'] = subject.name
63
new_subj['url'] = subject.url
64
ctx['other_subjects'].append(new_subj)
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(BaseFormView):
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 populate_state(self, state):
125
state.existing_subject = None
129
return SubjectSchema()
131
def get_return_url(self, obj):
135
class SubjectNew(SubjectFormView):
136
"""A form to create a subject."""
137
template = 'templates/subject-new.html'
139
def get_default_data(self, req):
142
def save_object(self, req, data):
143
new_subject = Subject()
144
new_subject.short_name = data['short_name']
145
new_subject.name = data['name']
146
new_subject.code = data['code']
148
req.store.add(new_subject)
152
class SubjectEdit(SubjectFormView):
153
"""A form to edit a subject."""
154
template = 'templates/subject-edit.html'
156
def populate_state(self, state):
157
state.existing_subject = self.context
159
def get_default_data(self, req):
161
'short_name': self.context.short_name,
162
'name': self.context.name,
163
'code': self.context.code,
166
def save_object(self, req, data):
167
self.context.short_name = data['short_name']
168
self.context.name = data['name']
169
self.context.code = data['code']
174
class SemesterUniquenessValidator(formencode.FancyValidator):
175
"""A FormEncode validator that checks that a semester is unique.
177
There cannot be more than one semester for the same year and semester.
179
def _to_python(self, value, state):
180
if (state.store.find(
181
Semester, year=value['year'], semester=value['semester']
183
raise formencode.Invalid(
184
'Semester already exists', value, state)
188
class SemesterSchema(formencode.Schema):
189
year = formencode.validators.UnicodeString()
190
semester = formencode.validators.UnicodeString()
191
chained_validators = [SemesterUniquenessValidator()]
194
class SemesterNew(BaseFormView):
195
"""A form to create a semester."""
196
template = 'templates/semester-new.html'
199
def authorize(self, req):
200
return req.user is not None and req.user.admin
204
return SemesterSchema()
206
def get_default_data(self, req):
209
def save_object(self, req, data):
210
new_semester = Semester()
211
new_semester.year = data['year']
212
new_semester.semester = data['semester']
214
req.store.add(new_semester)
217
def get_return_url(self, obj):
221
class OfferingView(XHTMLView):
222
"""The home page of an offering."""
223
template = 'templates/offering.html'
227
def populate(self, req, ctx):
228
# Need the worksheet result styles.
229
self.plugin_styles[TutorialPlugin] = ['tutorial.css']
230
ctx['context'] = self.context
232
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
233
ctx['format_submission_principal'] = util.format_submission_principal
234
ctx['format_datetime'] = ivle.date.make_date_nice
235
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
236
ctx['OfferingEdit'] = OfferingEdit
238
# As we go, calculate the total score for this subject
239
# (Assessable worksheets only, mandatory problems only)
241
ctx['worksheets'], problems_total, problems_done = (
242
ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
243
req.store, req.user, self.context))
245
ctx['exercises_total'] = problems_total
246
ctx['exercises_done'] = problems_done
247
if problems_total > 0:
248
if problems_done >= problems_total:
249
ctx['worksheets_complete_class'] = "complete"
250
elif problems_done > 0:
251
ctx['worksheets_complete_class'] = "semicomplete"
253
ctx['worksheets_complete_class'] = "incomplete"
254
# Calculate the final percentage and mark for the subject
255
(ctx['exercises_pct'], ctx['worksheet_mark'],
256
ctx['worksheet_max_mark']) = (
257
ivle.worksheet.utils.calculate_mark(
258
problems_done, problems_total))
261
class SubjectValidator(formencode.FancyValidator):
262
"""A FormEncode validator that turns a subject name into a subject.
264
The state must have a 'store' attribute, which is the Storm store
267
def _to_python(self, value, state):
268
subject = state.store.find(Subject, short_name=value).one()
272
raise formencode.Invalid('Subject does not exist', value, state)
275
class SemesterValidator(formencode.FancyValidator):
276
"""A FormEncode validator that turns a string into a semester.
278
The string should be of the form 'year/semester', eg. '2009/1'.
280
The state must have a 'store' attribute, which is the Storm store
283
def _to_python(self, value, state):
285
year, semester = value.split('/')
287
year = semester = None
289
semester = state.store.find(
290
Semester, year=year, semester=semester).one()
294
raise formencode.Invalid('Semester does not exist', value, state)
297
class OfferingUniquenessValidator(formencode.FancyValidator):
298
"""A FormEncode validator that checks that an offering is unique.
300
There cannot be more than one offering in the same year and semester.
302
The offering referenced by state.existing_offering is permitted to
303
hold that year and semester tuple. If any other object holds it, the
306
def _to_python(self, value, state):
307
if (state.store.find(
308
Offering, subject=value['subject'],
309
semester=value['semester']).one() not in
310
(None, state.existing_offering)):
311
raise formencode.Invalid(
312
'Offering already exists', value, state)
316
class OfferingSchema(formencode.Schema):
317
description = formencode.validators.UnicodeString(
318
if_missing=None, not_empty=False)
319
url = formencode.validators.URL(if_missing=None, not_empty=False)
322
class OfferingAdminSchema(OfferingSchema):
323
subject = formencode.All(
324
SubjectValidator(), formencode.validators.UnicodeString())
325
semester = formencode.All(
326
SemesterValidator(), formencode.validators.UnicodeString())
327
chained_validators = [OfferingUniquenessValidator()]
330
class OfferingEdit(BaseFormView):
331
"""A form to edit an offering's details."""
332
template = 'templates/offering-edit.html'
338
if self.req.user.admin:
339
return OfferingAdminSchema()
341
return OfferingSchema()
343
def populate(self, req, ctx):
344
super(OfferingEdit, self).populate(req, ctx)
345
ctx['subjects'] = req.store.find(Subject)
346
ctx['semesters'] = req.store.find(Semester)
348
def populate_state(self, state):
349
state.existing_offering = self.context
351
def get_default_data(self, req):
353
'subject': self.context.subject.short_name,
354
'semester': self.context.semester.year + '/' +
355
self.context.semester.semester,
356
'url': self.context.url,
357
'description': self.context.description,
360
def save_object(self, req, data):
362
self.context.subject = data['subject']
363
self.context.semester = data['semester']
364
self.context.description = data['description']
365
self.context.url = unicode(data['url']) if data['url'] else None
369
class OfferingNew(BaseFormView):
370
"""A form to create an offering."""
371
template = 'templates/offering-new.html'
374
def authorize(self, req):
375
return req.user is not None and req.user.admin
379
return OfferingAdminSchema()
381
def populate(self, req, ctx):
382
super(OfferingNew, self).populate(req, ctx)
383
ctx['subjects'] = req.store.find(Subject)
384
ctx['semesters'] = req.store.find(Semester)
386
def populate_state(self, state):
387
state.existing_offering = None
389
def get_default_data(self, req):
392
def save_object(self, req, data):
393
new_offering = Offering()
394
new_offering.subject = data['subject']
395
new_offering.semester = data['semester']
396
new_offering.description = data['description']
397
new_offering.url = unicode(data['url']) if data['url'] else None
399
req.store.add(new_offering)
403
class UserValidator(formencode.FancyValidator):
404
"""A FormEncode validator that turns a username into a user.
406
The state must have a 'store' attribute, which is the Storm store
408
def _to_python(self, value, state):
409
user = User.get_by_login(state.store, value)
413
raise formencode.Invalid('User does not exist', value, state)
416
class NoEnrolmentValidator(formencode.FancyValidator):
417
"""A FormEncode validator that ensures absence of an enrolment.
419
The state must have an 'offering' attribute.
421
def _to_python(self, value, state):
422
if state.offering.get_enrolment(value):
423
raise formencode.Invalid('User already enrolled', value, state)
427
class RoleEnrolmentValidator(formencode.FancyValidator):
428
"""A FormEncode validator that checks permission to enrol users with a
431
The state must have an 'offering' attribute.
433
def _to_python(self, value, state):
434
if (("enrol_" + value) not in
435
state.offering.get_permissions(state.user, state.config)):
436
raise formencode.Invalid('Not allowed to assign users that role',
441
class EnrolSchema(formencode.Schema):
442
user = formencode.All(NoEnrolmentValidator(), UserValidator())
443
role = formencode.All(formencode.validators.OneOf(
444
["lecturer", "tutor", "student"]),
445
RoleEnrolmentValidator(),
446
formencode.validators.UnicodeString())
449
class EnrolmentsView(XHTMLView):
450
"""A page which displays all users enrolled in an offering."""
451
template = 'templates/enrolments.html'
455
def populate(self, req, ctx):
456
ctx['offering'] = self.context
458
class EnrolView(XHTMLView):
459
"""A form to enrol a user in an offering."""
460
template = 'templates/enrol.html'
464
def filter(self, stream, ctx):
465
return stream | HTMLFormFiller(data=ctx['data'])
467
def populate(self, req, ctx):
468
if req.method == 'POST':
469
data = dict(req.get_fieldstorage())
471
validator = EnrolSchema()
472
req.offering = self.context # XXX: Getting into state.
473
data = validator.to_python(data, state=req)
474
self.context.enrol(data['user'], data['role'])
476
req.throw_redirect(req.uri)
477
except formencode.Invalid, e:
478
errors = e.unpack_errors()
483
ctx['data'] = data or {}
484
ctx['offering'] = self.context
485
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
486
ctx['errors'] = errors
488
class OfferingProjectsView(XHTMLView):
489
"""View the projects for an offering."""
490
template = 'templates/offering_projects.html'
494
def populate(self, req, ctx):
495
self.plugin_styles[Plugin] = ["project.css"]
496
self.plugin_scripts[Plugin] = ["project.js"]
498
ctx['offering'] = self.context
499
ctx['projectsets'] = []
500
ctx['OfferingRESTView'] = OfferingRESTView
502
#Open the projectset Fragment, and render it for inclusion
503
#into the ProjectSets page
504
#XXX: This could be a lot cleaner
505
loader = genshi.template.TemplateLoader(".", auto_reload=True)
507
set_fragment = os.path.join(os.path.dirname(__file__),
508
"templates/projectset_fragment.html")
509
project_fragment = os.path.join(os.path.dirname(__file__),
510
"templates/project_fragment.html")
512
for projectset in self.context.project_sets:
513
settmpl = loader.load(set_fragment)
516
setCtx['projectset'] = projectset
517
setCtx['projects'] = []
518
setCtx['GroupsView'] = GroupsView
519
setCtx['ProjectSetRESTView'] = ProjectSetRESTView
521
for project in projectset.projects:
522
projecttmpl = loader.load(project_fragment)
523
projectCtx = Context()
524
projectCtx['req'] = req
525
projectCtx['project'] = project
527
setCtx['projects'].append(
528
projecttmpl.generate(projectCtx))
530
ctx['projectsets'].append(settmpl.generate(setCtx))
533
class ProjectView(XHTMLView):
534
"""View the submissions for a ProjectSet"""
535
template = "templates/project.html"
539
def build_subversion_url(self, svnroot, submission):
540
princ = submission.assessed.principal
542
if isinstance(princ, User):
543
path = 'users/%s' % princ.login
545
path = 'groups/%s_%s_%s_%s' % (
546
princ.project_set.offering.subject.short_name,
547
princ.project_set.offering.semester.year,
548
princ.project_set.offering.semester.semester,
551
return urlparse.urljoin(
553
os.path.join(path, submission.path[1:] if
554
submission.path.startswith(os.sep) else
557
def populate(self, req, ctx):
558
self.plugin_styles[Plugin] = ["project.css"]
561
ctx['GroupsView'] = GroupsView
562
ctx['EnrolView'] = EnrolView
563
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
564
ctx['build_subversion_url'] = self.build_subversion_url
565
ctx['svn_addr'] = req.config['urls']['svn_addr']
566
ctx['project'] = self.context
567
ctx['user'] = req.user
67
569
class Plugin(ViewPlugin, MediaPlugin):
69
('subjects/', SubjectsView),
570
forward_routes = (root_to_subject, subject_to_offering,
571
offering_to_project, offering_to_projectset)
572
reverse_routes = (subject_url, offering_url, projectset_url, project_url)
574
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
575
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
576
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
577
(ApplicationRoot, ('subjects', '+new-semester'), SemesterNew),
578
(Subject, '+edit', SubjectEdit),
579
(Offering, '+index', OfferingView),
580
(Offering, '+edit', OfferingEdit),
581
(Offering, ('+enrolments', '+index'), EnrolmentsView),
582
(Offering, ('+enrolments', '+new'), EnrolView),
583
(Offering, ('+projects', '+index'), OfferingProjectsView),
584
(Project, '+index', ProjectView),
586
(Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
587
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
590
breadcrumbs = {Subject: SubjectBreadcrumb,
591
Offering: OfferingBreadcrumb,
592
User: UserBreadcrumb,
593
Project: ProjectBreadcrumb,
73
('subjects', 'Subjects', 'Announcements and information about the '
74
'subjects you are enrolled in.', 'subjects.png', 'subjects', 5)
597
('subjects', 'Subjects',
598
'View subject content and complete worksheets',
599
'subjects.png', 'subjects', 5)
77
602
media = 'subject-media'