2
# Copyright (C) 2007-2008 The University of Melbourne
4
# This program is free software; you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License as published by
6
# the Free Software Foundation; either version 2 of the License, or
7
# (at your option) any later version.
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU General Public License for more details.
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
22
# This is an IVLE application.
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.errors import BadRequest
42
from ivle.webapp import ApplicationRoot
44
from ivle.database import Subject, Semester, Offering, Enrolment, User,\
45
ProjectSet, Project, ProjectSubmission
49
from ivle.webapp.admin.projectservice import ProjectSetRESTView
50
from ivle.webapp.admin.offeringservice import OfferingRESTView
51
from ivle.webapp.admin.publishing import (root_to_subject, root_to_semester,
52
subject_to_offering, offering_to_projectset, offering_to_project,
53
offering_to_enrolment, subject_url, semester_url, offering_url,
54
projectset_url, project_url, enrolment_url)
55
from ivle.webapp.admin.breadcrumbs import (SubjectBreadcrumb,
56
OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb,
58
from ivle.webapp.core import Plugin as CorePlugin
59
from ivle.webapp.groups import GroupsView
60
from ivle.webapp.media import media_url
61
from ivle.webapp.tutorial import Plugin as TutorialPlugin
63
class SubjectsView(XHTMLView):
64
'''The view of the list of subjects.'''
65
template = 'templates/subjects.html'
68
def authorize(self, req):
69
return req.user is not None
71
def populate(self, req, ctx):
73
ctx['user'] = req.user
76
for semester in req.store.find(Semester).order_by(Desc(Semester.year),
77
Desc(Semester.semester)):
79
# For admins, show all subjects in the system
80
offerings = list(semester.offerings.find())
82
offerings = [enrolment.offering for enrolment in
83
semester.enrolments.find(user=req.user)]
85
ctx['semesters'].append((semester, offerings))
88
class SubjectsManage(XHTMLView):
89
'''Subject management view.'''
90
template = 'templates/subjects-manage.html'
93
def authorize(self, req):
94
return req.user is not None and req.user.admin
96
def populate(self, req, ctx):
98
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
99
ctx['SubjectEdit'] = SubjectEdit
100
ctx['SemesterEdit'] = SemesterEdit
102
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
103
ctx['semesters'] = req.store.find(Semester).order_by(
104
Semester.year, Semester.semester)
107
class SubjectShortNameUniquenessValidator(formencode.FancyValidator):
108
"""A FormEncode validator that checks that a subject name is unused.
110
The subject referenced by state.existing_subject is permitted
111
to hold that name. If any other object holds it, the input is rejected.
113
def __init__(self, matching=None):
114
self.matching = matching
116
def _to_python(self, value, state):
117
if (state.store.find(
118
Subject, short_name=value).one() not in
119
(None, state.existing_subject)):
120
raise formencode.Invalid(
121
'Short name already taken', value, state)
125
class SubjectSchema(formencode.Schema):
126
short_name = formencode.All(
127
SubjectShortNameUniquenessValidator(),
128
formencode.validators.UnicodeString(not_empty=True))
129
name = formencode.validators.UnicodeString(not_empty=True)
130
code = formencode.validators.UnicodeString(not_empty=True)
133
class SubjectFormView(BaseFormView):
134
"""An abstract form to add or edit a subject."""
137
def authorize(self, req):
138
return req.user is not None and req.user.admin
140
def populate_state(self, state):
141
state.existing_subject = None
145
return SubjectSchema()
147
def get_return_url(self, obj):
151
class SubjectNew(SubjectFormView):
152
"""A form to create a subject."""
153
template = 'templates/subject-new.html'
155
def get_default_data(self, req):
158
def save_object(self, req, data):
159
new_subject = Subject()
160
new_subject.short_name = data['short_name']
161
new_subject.name = data['name']
162
new_subject.code = data['code']
164
req.store.add(new_subject)
168
class SubjectEdit(SubjectFormView):
169
"""A form to edit a subject."""
170
template = 'templates/subject-edit.html'
172
def populate_state(self, state):
173
state.existing_subject = self.context
175
def get_default_data(self, req):
177
'short_name': self.context.short_name,
178
'name': self.context.name,
179
'code': self.context.code,
182
def save_object(self, req, data):
183
self.context.short_name = data['short_name']
184
self.context.name = data['name']
185
self.context.code = data['code']
190
class SemesterUniquenessValidator(formencode.FancyValidator):
191
"""A FormEncode validator that checks that a semester is unique.
193
There cannot be more than one semester for the same year and semester.
195
def _to_python(self, value, state):
196
if (state.store.find(
197
Semester, year=value['year'], semester=value['semester']
198
).one() not in (None, state.existing_semester)):
199
raise formencode.Invalid(
200
'Semester already exists', value, state)
204
class SemesterSchema(formencode.Schema):
205
year = formencode.validators.UnicodeString()
206
semester = formencode.validators.UnicodeString()
207
state = formencode.All(
208
formencode.validators.OneOf(["past", "current", "future"]),
209
formencode.validators.UnicodeString())
210
chained_validators = [SemesterUniquenessValidator()]
213
class SemesterFormView(BaseFormView):
216
def authorize(self, req):
217
return req.user is not None and req.user.admin
221
return SemesterSchema()
223
def get_return_url(self, obj):
224
return '/subjects/+manage'
227
class SemesterNew(SemesterFormView):
228
"""A form to create a semester."""
229
template = 'templates/semester-new.html'
232
def populate_state(self, state):
233
state.existing_semester = None
235
def get_default_data(self, req):
238
def save_object(self, req, data):
239
new_semester = Semester()
240
new_semester.year = data['year']
241
new_semester.semester = data['semester']
242
new_semester.state = data['state']
244
req.store.add(new_semester)
248
class SemesterEdit(SemesterFormView):
249
"""A form to edit a semester."""
250
template = 'templates/semester-edit.html'
252
def populate_state(self, state):
253
state.existing_semester = self.context
255
def get_default_data(self, req):
257
'year': self.context.year,
258
'semester': self.context.semester,
259
'state': self.context.state,
262
def save_object(self, req, data):
263
self.context.year = data['year']
264
self.context.semester = data['semester']
265
self.context.state = data['state']
270
class OfferingView(XHTMLView):
271
"""The home page of an offering."""
272
template = 'templates/offering.html'
276
def populate(self, req, ctx):
277
# Need the worksheet result styles.
278
self.plugin_styles[TutorialPlugin] = ['tutorial.css']
279
ctx['context'] = self.context
281
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
282
ctx['format_submission_principal'] = util.format_submission_principal
283
ctx['format_datetime'] = ivle.date.make_date_nice
284
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
285
ctx['OfferingEdit'] = OfferingEdit
286
ctx['OfferingCloneWorksheets'] = OfferingCloneWorksheets
287
ctx['GroupsView'] = GroupsView
288
ctx['EnrolmentsView'] = EnrolmentsView
290
# As we go, calculate the total score for this subject
291
# (Assessable worksheets only, mandatory problems only)
293
ctx['worksheets'], problems_total, problems_done = (
294
ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
295
req.store, req.user, self.context))
297
ctx['exercises_total'] = problems_total
298
ctx['exercises_done'] = problems_done
299
if problems_total > 0:
300
if problems_done >= problems_total:
301
ctx['worksheets_complete_class'] = "complete"
302
elif problems_done > 0:
303
ctx['worksheets_complete_class'] = "semicomplete"
305
ctx['worksheets_complete_class'] = "incomplete"
306
# Calculate the final percentage and mark for the subject
307
(ctx['exercises_pct'], ctx['worksheet_mark'],
308
ctx['worksheet_max_mark']) = (
309
ivle.worksheet.utils.calculate_mark(
310
problems_done, problems_total))
313
class SubjectValidator(formencode.FancyValidator):
314
"""A FormEncode validator that turns a subject name into a subject.
316
The state must have a 'store' attribute, which is the Storm store
319
def _to_python(self, value, state):
320
subject = state.store.find(Subject, short_name=value).one()
324
raise formencode.Invalid('Subject does not exist', value, state)
327
class SemesterValidator(formencode.FancyValidator):
328
"""A FormEncode validator that turns a string into a semester.
330
The string should be of the form 'year/semester', eg. '2009/1'.
332
The state must have a 'store' attribute, which is the Storm store
335
def _to_python(self, value, state):
337
year, semester = value.split('/')
339
year = semester = None
341
semester = state.store.find(
342
Semester, year=year, semester=semester).one()
346
raise formencode.Invalid('Semester does not exist', value, state)
349
class OfferingUniquenessValidator(formencode.FancyValidator):
350
"""A FormEncode validator that checks that an offering is unique.
352
There cannot be more than one offering in the same year and semester.
354
The offering referenced by state.existing_offering is permitted to
355
hold that year and semester tuple. If any other object holds it, the
358
def _to_python(self, value, state):
359
if (state.store.find(
360
Offering, subject=value['subject'],
361
semester=value['semester']).one() not in
362
(None, state.existing_offering)):
363
raise formencode.Invalid(
364
'Offering already exists', value, state)
368
class OfferingSchema(formencode.Schema):
369
description = formencode.validators.UnicodeString(
370
if_missing=None, not_empty=False)
371
url = formencode.validators.URL(if_missing=None, not_empty=False)
374
class OfferingAdminSchema(OfferingSchema):
375
subject = formencode.All(
376
SubjectValidator(), formencode.validators.UnicodeString())
377
semester = formencode.All(
378
SemesterValidator(), formencode.validators.UnicodeString())
379
chained_validators = [OfferingUniquenessValidator()]
382
class OfferingEdit(BaseFormView):
383
"""A form to edit an offering's details."""
384
template = 'templates/offering-edit.html'
390
if self.req.user.admin:
391
return OfferingAdminSchema()
393
return OfferingSchema()
395
def populate(self, req, ctx):
396
super(OfferingEdit, self).populate(req, ctx)
397
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
398
ctx['semesters'] = req.store.find(Semester).order_by(
399
Semester.year, Semester.semester)
401
def populate_state(self, state):
402
state.existing_offering = self.context
404
def get_default_data(self, req):
406
'subject': self.context.subject.short_name,
407
'semester': self.context.semester.year + '/' +
408
self.context.semester.semester,
409
'url': self.context.url,
410
'description': self.context.description,
413
def save_object(self, req, data):
415
self.context.subject = data['subject']
416
self.context.semester = data['semester']
417
self.context.description = data['description']
418
self.context.url = unicode(data['url']) if data['url'] else None
422
class OfferingNew(BaseFormView):
423
"""A form to create an offering."""
424
template = 'templates/offering-new.html'
427
def authorize(self, req):
428
return req.user is not None and req.user.admin
432
return OfferingAdminSchema()
434
def populate(self, req, ctx):
435
super(OfferingNew, self).populate(req, ctx)
436
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
437
ctx['semesters'] = req.store.find(Semester).order_by(
438
Semester.year, Semester.semester)
440
def populate_state(self, state):
441
state.existing_offering = None
443
def get_default_data(self, req):
446
def save_object(self, req, data):
447
new_offering = Offering()
448
new_offering.subject = data['subject']
449
new_offering.semester = data['semester']
450
new_offering.description = data['description']
451
new_offering.url = unicode(data['url']) if data['url'] else None
453
req.store.add(new_offering)
457
class OfferingCloneWorksheetsSchema(formencode.Schema):
458
subject = formencode.All(
459
SubjectValidator(), formencode.validators.UnicodeString())
460
semester = formencode.All(
461
SemesterValidator(), formencode.validators.UnicodeString())
464
class OfferingCloneWorksheets(BaseFormView):
465
"""A form to clone worksheets from one offering to another."""
466
template = 'templates/offering-clone-worksheets.html'
469
def authorize(self, req):
470
return req.user is not None and req.user.admin
474
return OfferingCloneWorksheetsSchema()
476
def populate(self, req, ctx):
477
super(OfferingCloneWorksheets, self).populate(req, ctx)
478
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
479
ctx['semesters'] = req.store.find(Semester).order_by(
480
Semester.year, Semester.semester)
482
def get_default_data(self, req):
485
def save_object(self, req, data):
486
if self.context.worksheets.count() > 0:
488
"Cannot clone to target with existing worksheets.")
489
offering = req.store.find(
490
Offering, subject=data['subject'], semester=data['semester']).one()
492
raise BadRequest("No such offering.")
493
if offering.worksheets.count() == 0:
494
raise BadRequest("Source offering has no worksheets.")
496
self.context.clone_worksheets(offering)
500
class UserValidator(formencode.FancyValidator):
501
"""A FormEncode validator that turns a username into a user.
503
The state must have a 'store' attribute, which is the Storm store
505
def _to_python(self, value, state):
506
user = User.get_by_login(state.store, value)
510
raise formencode.Invalid('User does not exist', value, state)
513
class NoEnrolmentValidator(formencode.FancyValidator):
514
"""A FormEncode validator that ensures absence of an enrolment.
516
The state must have an 'offering' attribute.
518
def _to_python(self, value, state):
519
if state.offering.get_enrolment(value):
520
raise formencode.Invalid('User already enrolled', value, state)
524
class RoleEnrolmentValidator(formencode.FancyValidator):
525
"""A FormEncode validator that checks permission to enrol users with a
528
The state must have an 'offering' attribute.
530
def _to_python(self, value, state):
531
if (("enrol_" + value) not in
532
state.offering.get_permissions(state.user, state.config)):
533
raise formencode.Invalid('Not allowed to assign users that role',
538
class EnrolSchema(formencode.Schema):
539
user = formencode.All(NoEnrolmentValidator(), UserValidator())
540
role = formencode.All(formencode.validators.OneOf(
541
["lecturer", "tutor", "student"]),
542
RoleEnrolmentValidator(),
543
formencode.validators.UnicodeString())
546
class EnrolmentsView(XHTMLView):
547
"""A page which displays all users enrolled in an offering."""
548
template = 'templates/enrolments.html'
551
breadcrumb_text = 'Enrolments'
553
def populate(self, req, ctx):
555
ctx['offering'] = self.context
556
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
557
ctx['offering_perms'] = self.context.get_permissions(
558
req.user, req.config)
559
ctx['EnrolView'] = EnrolView
560
ctx['EnrolmentEdit'] = EnrolmentEdit
561
ctx['EnrolmentDelete'] = EnrolmentDelete
564
class EnrolView(XHTMLView):
565
"""A form to enrol a user in an offering."""
566
template = 'templates/enrol.html'
570
def filter(self, stream, ctx):
571
return stream | HTMLFormFiller(data=ctx['data'])
573
def populate(self, req, ctx):
574
if req.method == 'POST':
575
data = dict(req.get_fieldstorage())
577
validator = EnrolSchema()
578
req.offering = self.context # XXX: Getting into state.
579
data = validator.to_python(data, state=req)
580
self.context.enrol(data['user'], data['role'])
582
req.throw_redirect(req.uri)
583
except formencode.Invalid, e:
584
errors = e.unpack_errors()
589
ctx['data'] = data or {}
590
ctx['offering'] = self.context
591
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
592
ctx['errors'] = errors
595
class EnrolmentEditSchema(formencode.Schema):
596
role = formencode.All(formencode.validators.OneOf(
597
["lecturer", "tutor", "student"]),
598
RoleEnrolmentValidator(),
599
formencode.validators.UnicodeString())
602
class EnrolmentEdit(BaseFormView):
603
"""A form to alter an enrolment's role."""
604
template = 'templates/enrolment-edit.html'
608
def populate_state(self, state):
609
state.offering = self.context.offering
611
def get_default_data(self, req):
612
return {'role': self.context.role}
616
return EnrolmentEditSchema()
618
def save_object(self, req, data):
619
self.context.role = data['role']
621
def get_return_url(self, obj):
622
return self.req.publisher.generate(
623
self.context.offering, EnrolmentsView)
625
def populate(self, req, ctx):
626
super(EnrolmentEdit, self).populate(req, ctx)
627
ctx['offering_perms'] = self.context.offering.get_permissions(
628
req.user, req.config)
631
class EnrolmentDelete(XHTMLView):
632
"""A form to alter an enrolment's role."""
633
template = 'templates/enrolment-delete.html'
637
def populate(self, req, ctx):
638
# If POSTing, delete delete delete.
639
if req.method == 'POST':
640
self.context.delete()
642
req.throw_redirect(req.publisher.generate(
643
self.context.offering, EnrolmentsView))
645
ctx['enrolment'] = self.context
648
class OfferingProjectsView(XHTMLView):
649
"""View the projects for an offering."""
650
template = 'templates/offering_projects.html'
653
breadcrumb_text = 'Projects'
655
def populate(self, req, ctx):
656
self.plugin_styles[Plugin] = ["project.css"]
657
self.plugin_scripts[Plugin] = ["project.js"]
659
ctx['offering'] = self.context
660
ctx['projectsets'] = []
661
ctx['OfferingRESTView'] = OfferingRESTView
663
#Open the projectset Fragment, and render it for inclusion
664
#into the ProjectSets page
665
#XXX: This could be a lot cleaner
666
loader = genshi.template.TemplateLoader(".", auto_reload=True)
668
set_fragment = os.path.join(os.path.dirname(__file__),
669
"templates/projectset_fragment.html")
670
project_fragment = os.path.join(os.path.dirname(__file__),
671
"templates/project_fragment.html")
673
for projectset in self.context.project_sets:
674
settmpl = loader.load(set_fragment)
677
setCtx['projectset'] = projectset
678
setCtx['projects'] = []
679
setCtx['GroupsView'] = GroupsView
680
setCtx['ProjectSetRESTView'] = ProjectSetRESTView
682
for project in projectset.projects:
683
projecttmpl = loader.load(project_fragment)
684
projectCtx = Context()
685
projectCtx['req'] = req
686
projectCtx['project'] = project
688
setCtx['projects'].append(
689
projecttmpl.generate(projectCtx))
691
ctx['projectsets'].append(settmpl.generate(setCtx))
694
class ProjectView(XHTMLView):
695
"""View the submissions for a ProjectSet"""
696
template = "templates/project.html"
697
permission = "view_project_submissions"
700
def build_subversion_url(self, svnroot, submission):
701
princ = submission.assessed.principal
703
if isinstance(princ, User):
704
path = 'users/%s' % princ.login
706
path = 'groups/%s_%s_%s_%s' % (
707
princ.project_set.offering.subject.short_name,
708
princ.project_set.offering.semester.year,
709
princ.project_set.offering.semester.semester,
712
return urlparse.urljoin(
714
os.path.join(path, submission.path[1:] if
715
submission.path.startswith(os.sep) else
718
def populate(self, req, ctx):
719
self.plugin_styles[Plugin] = ["project.css"]
722
ctx['GroupsView'] = GroupsView
723
ctx['EnrolView'] = EnrolView
724
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
725
ctx['build_subversion_url'] = self.build_subversion_url
726
ctx['svn_addr'] = req.config['urls']['svn_addr']
727
ctx['project'] = self.context
728
ctx['user'] = req.user
730
class Plugin(ViewPlugin, MediaPlugin):
731
forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
732
offering_to_project, offering_to_projectset,
733
offering_to_enrolment)
735
subject_url, semester_url, offering_url, projectset_url, project_url,
738
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
739
(ApplicationRoot, ('subjects', '+manage'), SubjectsManage),
740
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
741
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
742
(ApplicationRoot, ('+semesters', '+new'), SemesterNew),
743
(Subject, '+edit', SubjectEdit),
744
(Semester, '+edit', SemesterEdit),
745
(Offering, '+index', OfferingView),
746
(Offering, '+edit', OfferingEdit),
747
(Offering, '+clone-worksheets', OfferingCloneWorksheets),
748
(Offering, ('+enrolments', '+index'), EnrolmentsView),
749
(Offering, ('+enrolments', '+new'), EnrolView),
750
(Enrolment, '+edit', EnrolmentEdit),
751
(Enrolment, '+delete', EnrolmentDelete),
752
(Offering, ('+projects', '+index'), OfferingProjectsView),
753
(Project, '+index', ProjectView),
755
(Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
756
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
759
breadcrumbs = {Subject: SubjectBreadcrumb,
760
Offering: OfferingBreadcrumb,
761
User: UserBreadcrumb,
762
Project: ProjectBreadcrumb,
763
Enrolment: EnrolmentBreadcrumb,
767
('subjects', 'Subjects',
768
'View subject content and complete worksheets',
769
'subjects.png', 'subjects', 5)
772
media = 'subject-media'