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 import ApplicationRoot
43
from ivle.database import Subject, Semester, Offering, Enrolment, User,\
44
ProjectSet, Project, ProjectSubmission
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, root_to_semester,
51
subject_to_offering, offering_to_projectset, offering_to_project,
52
subject_url, semester_url, offering_url, projectset_url,
54
from ivle.webapp.admin.breadcrumbs import (SubjectBreadcrumb,
55
OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb)
56
from ivle.webapp.core import Plugin as CorePlugin
57
from ivle.webapp.groups import GroupsView
58
from ivle.webapp.media import media_url
59
from ivle.webapp.tutorial import Plugin as TutorialPlugin
61
class SubjectsView(XHTMLView):
62
'''The view of the list of subjects.'''
63
template = 'templates/subjects.html'
66
def authorize(self, req):
67
return req.user is not None
69
def populate(self, req, ctx):
71
ctx['user'] = req.user
73
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
74
ctx['SubjectEdit'] = SubjectEdit
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))
87
# Admins get a separate list of subjects so they can add/edit.
89
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
92
class SubjectShortNameUniquenessValidator(formencode.FancyValidator):
93
"""A FormEncode validator that checks that a subject name is unused.
95
The subject referenced by state.existing_subject is permitted
96
to hold that name. If any other object holds it, the input is rejected.
98
def __init__(self, matching=None):
99
self.matching = matching
101
def _to_python(self, value, state):
102
if (state.store.find(
103
Subject, short_name=value).one() not in
104
(None, state.existing_subject)):
105
raise formencode.Invalid(
106
'Short name already taken', value, state)
110
class SubjectSchema(formencode.Schema):
111
short_name = formencode.All(
112
SubjectShortNameUniquenessValidator(),
113
formencode.validators.UnicodeString(not_empty=True))
114
name = formencode.validators.UnicodeString(not_empty=True)
115
code = formencode.validators.UnicodeString(not_empty=True)
118
class SubjectFormView(BaseFormView):
119
"""An abstract form to add or edit a subject."""
122
def authorize(self, req):
123
return req.user is not None and req.user.admin
125
def populate_state(self, state):
126
state.existing_subject = None
130
return SubjectSchema()
132
def get_return_url(self, obj):
136
class SubjectNew(SubjectFormView):
137
"""A form to create a subject."""
138
template = 'templates/subject-new.html'
140
def get_default_data(self, req):
143
def save_object(self, req, data):
144
new_subject = Subject()
145
new_subject.short_name = data['short_name']
146
new_subject.name = data['name']
147
new_subject.code = data['code']
149
req.store.add(new_subject)
153
class SubjectEdit(SubjectFormView):
154
"""A form to edit a subject."""
155
template = 'templates/subject-edit.html'
157
def populate_state(self, state):
158
state.existing_subject = self.context
160
def get_default_data(self, req):
162
'short_name': self.context.short_name,
163
'name': self.context.name,
164
'code': self.context.code,
167
def save_object(self, req, data):
168
self.context.short_name = data['short_name']
169
self.context.name = data['name']
170
self.context.code = data['code']
175
class SemesterUniquenessValidator(formencode.FancyValidator):
176
"""A FormEncode validator that checks that a semester is unique.
178
There cannot be more than one semester for the same year and semester.
180
def _to_python(self, value, state):
181
if (state.store.find(
182
Semester, year=value['year'], semester=value['semester']
183
).one() not in (None, state.existing_semester)):
184
raise formencode.Invalid(
185
'Semester already exists', value, state)
189
class SemesterSchema(formencode.Schema):
190
year = formencode.validators.UnicodeString()
191
semester = formencode.validators.UnicodeString()
192
state = formencode.All(
193
formencode.validators.OneOf(["past", "current", "future"]),
194
formencode.validators.UnicodeString())
195
chained_validators = [SemesterUniquenessValidator()]
198
class SemesterFormView(BaseFormView):
201
def authorize(self, req):
202
return req.user is not None and req.user.admin
206
return SemesterSchema()
208
def get_return_url(self, obj):
209
return '/subjects/+manage'
212
class SemesterNew(SemesterFormView):
213
"""A form to create a semester."""
214
template = 'templates/semester-new.html'
217
def populate_state(self, state):
218
state.existing_semester = None
220
def get_default_data(self, req):
223
def save_object(self, req, data):
224
new_semester = Semester()
225
new_semester.year = data['year']
226
new_semester.semester = data['semester']
227
new_semester.state = data['state']
229
req.store.add(new_semester)
233
class SemesterEdit(SemesterFormView):
234
"""A form to edit a semester."""
235
template = 'templates/semester-edit.html'
237
def populate_state(self, state):
238
state.existing_semester = self.context
240
def get_default_data(self, req):
242
'year': self.context.year,
243
'semester': self.context.semester,
244
'state': self.context.state,
247
def save_object(self, req, data):
248
self.context.year = data['year']
249
self.context.semester = data['semester']
250
self.context.state = data['state']
255
class OfferingView(XHTMLView):
256
"""The home page of an offering."""
257
template = 'templates/offering.html'
261
def populate(self, req, ctx):
262
# Need the worksheet result styles.
263
self.plugin_styles[TutorialPlugin] = ['tutorial.css']
264
ctx['context'] = self.context
266
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
267
ctx['format_submission_principal'] = util.format_submission_principal
268
ctx['format_datetime'] = ivle.date.make_date_nice
269
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
270
ctx['OfferingEdit'] = OfferingEdit
271
ctx['GroupsView'] = GroupsView
273
# As we go, calculate the total score for this subject
274
# (Assessable worksheets only, mandatory problems only)
276
ctx['worksheets'], problems_total, problems_done = (
277
ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
278
req.store, req.user, self.context))
280
ctx['exercises_total'] = problems_total
281
ctx['exercises_done'] = problems_done
282
if problems_total > 0:
283
if problems_done >= problems_total:
284
ctx['worksheets_complete_class'] = "complete"
285
elif problems_done > 0:
286
ctx['worksheets_complete_class'] = "semicomplete"
288
ctx['worksheets_complete_class'] = "incomplete"
289
# Calculate the final percentage and mark for the subject
290
(ctx['exercises_pct'], ctx['worksheet_mark'],
291
ctx['worksheet_max_mark']) = (
292
ivle.worksheet.utils.calculate_mark(
293
problems_done, problems_total))
296
class SubjectValidator(formencode.FancyValidator):
297
"""A FormEncode validator that turns a subject name into a subject.
299
The state must have a 'store' attribute, which is the Storm store
302
def _to_python(self, value, state):
303
subject = state.store.find(Subject, short_name=value).one()
307
raise formencode.Invalid('Subject does not exist', value, state)
310
class SemesterValidator(formencode.FancyValidator):
311
"""A FormEncode validator that turns a string into a semester.
313
The string should be of the form 'year/semester', eg. '2009/1'.
315
The state must have a 'store' attribute, which is the Storm store
318
def _to_python(self, value, state):
320
year, semester = value.split('/')
322
year = semester = None
324
semester = state.store.find(
325
Semester, year=year, semester=semester).one()
329
raise formencode.Invalid('Semester does not exist', value, state)
332
class OfferingUniquenessValidator(formencode.FancyValidator):
333
"""A FormEncode validator that checks that an offering is unique.
335
There cannot be more than one offering in the same year and semester.
337
The offering referenced by state.existing_offering is permitted to
338
hold that year and semester tuple. If any other object holds it, the
341
def _to_python(self, value, state):
342
if (state.store.find(
343
Offering, subject=value['subject'],
344
semester=value['semester']).one() not in
345
(None, state.existing_offering)):
346
raise formencode.Invalid(
347
'Offering already exists', value, state)
351
class OfferingSchema(formencode.Schema):
352
description = formencode.validators.UnicodeString(
353
if_missing=None, not_empty=False)
354
url = formencode.validators.URL(if_missing=None, not_empty=False)
357
class OfferingAdminSchema(OfferingSchema):
358
subject = formencode.All(
359
SubjectValidator(), formencode.validators.UnicodeString())
360
semester = formencode.All(
361
SemesterValidator(), formencode.validators.UnicodeString())
362
chained_validators = [OfferingUniquenessValidator()]
365
class OfferingEdit(BaseFormView):
366
"""A form to edit an offering's details."""
367
template = 'templates/offering-edit.html'
373
if self.req.user.admin:
374
return OfferingAdminSchema()
376
return OfferingSchema()
378
def populate(self, req, ctx):
379
super(OfferingEdit, self).populate(req, ctx)
380
ctx['subjects'] = req.store.find(Subject)
381
ctx['semesters'] = req.store.find(Semester)
383
def populate_state(self, state):
384
state.existing_offering = self.context
386
def get_default_data(self, req):
388
'subject': self.context.subject.short_name,
389
'semester': self.context.semester.year + '/' +
390
self.context.semester.semester,
391
'url': self.context.url,
392
'description': self.context.description,
395
def save_object(self, req, data):
397
self.context.subject = data['subject']
398
self.context.semester = data['semester']
399
self.context.description = data['description']
400
self.context.url = unicode(data['url']) if data['url'] else None
404
class OfferingNew(BaseFormView):
405
"""A form to create an offering."""
406
template = 'templates/offering-new.html'
409
def authorize(self, req):
410
return req.user is not None and req.user.admin
414
return OfferingAdminSchema()
416
def populate(self, req, ctx):
417
super(OfferingNew, self).populate(req, ctx)
418
ctx['subjects'] = req.store.find(Subject)
419
ctx['semesters'] = req.store.find(Semester)
421
def populate_state(self, state):
422
state.existing_offering = None
424
def get_default_data(self, req):
427
def save_object(self, req, data):
428
new_offering = Offering()
429
new_offering.subject = data['subject']
430
new_offering.semester = data['semester']
431
new_offering.description = data['description']
432
new_offering.url = unicode(data['url']) if data['url'] else None
434
req.store.add(new_offering)
438
class UserValidator(formencode.FancyValidator):
439
"""A FormEncode validator that turns a username into a user.
441
The state must have a 'store' attribute, which is the Storm store
443
def _to_python(self, value, state):
444
user = User.get_by_login(state.store, value)
448
raise formencode.Invalid('User does not exist', value, state)
451
class NoEnrolmentValidator(formencode.FancyValidator):
452
"""A FormEncode validator that ensures absence of an enrolment.
454
The state must have an 'offering' attribute.
456
def _to_python(self, value, state):
457
if state.offering.get_enrolment(value):
458
raise formencode.Invalid('User already enrolled', value, state)
462
class RoleEnrolmentValidator(formencode.FancyValidator):
463
"""A FormEncode validator that checks permission to enrol users with a
466
The state must have an 'offering' attribute.
468
def _to_python(self, value, state):
469
if (("enrol_" + value) not in
470
state.offering.get_permissions(state.user, state.config)):
471
raise formencode.Invalid('Not allowed to assign users that role',
476
class EnrolSchema(formencode.Schema):
477
user = formencode.All(NoEnrolmentValidator(), UserValidator())
478
role = formencode.All(formencode.validators.OneOf(
479
["lecturer", "tutor", "student"]),
480
RoleEnrolmentValidator(),
481
formencode.validators.UnicodeString())
484
class EnrolmentsView(XHTMLView):
485
"""A page which displays all users enrolled in an offering."""
486
template = 'templates/enrolments.html'
490
def populate(self, req, ctx):
491
ctx['offering'] = self.context
493
class EnrolView(XHTMLView):
494
"""A form to enrol a user in an offering."""
495
template = 'templates/enrol.html'
499
def filter(self, stream, ctx):
500
return stream | HTMLFormFiller(data=ctx['data'])
502
def populate(self, req, ctx):
503
if req.method == 'POST':
504
data = dict(req.get_fieldstorage())
506
validator = EnrolSchema()
507
req.offering = self.context # XXX: Getting into state.
508
data = validator.to_python(data, state=req)
509
self.context.enrol(data['user'], data['role'])
511
req.throw_redirect(req.uri)
512
except formencode.Invalid, e:
513
errors = e.unpack_errors()
518
ctx['data'] = data or {}
519
ctx['offering'] = self.context
520
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
521
ctx['errors'] = errors
523
class OfferingProjectsView(XHTMLView):
524
"""View the projects for an offering."""
525
template = 'templates/offering_projects.html'
529
def populate(self, req, ctx):
530
self.plugin_styles[Plugin] = ["project.css"]
531
self.plugin_scripts[Plugin] = ["project.js"]
533
ctx['offering'] = self.context
534
ctx['projectsets'] = []
535
ctx['OfferingRESTView'] = OfferingRESTView
537
#Open the projectset Fragment, and render it for inclusion
538
#into the ProjectSets page
539
#XXX: This could be a lot cleaner
540
loader = genshi.template.TemplateLoader(".", auto_reload=True)
542
set_fragment = os.path.join(os.path.dirname(__file__),
543
"templates/projectset_fragment.html")
544
project_fragment = os.path.join(os.path.dirname(__file__),
545
"templates/project_fragment.html")
547
for projectset in self.context.project_sets:
548
settmpl = loader.load(set_fragment)
551
setCtx['projectset'] = projectset
552
setCtx['projects'] = []
553
setCtx['GroupsView'] = GroupsView
554
setCtx['ProjectSetRESTView'] = ProjectSetRESTView
556
for project in projectset.projects:
557
projecttmpl = loader.load(project_fragment)
558
projectCtx = Context()
559
projectCtx['req'] = req
560
projectCtx['project'] = project
562
setCtx['projects'].append(
563
projecttmpl.generate(projectCtx))
565
ctx['projectsets'].append(settmpl.generate(setCtx))
568
class ProjectView(XHTMLView):
569
"""View the submissions for a ProjectSet"""
570
template = "templates/project.html"
571
permission = "view_project_submissions"
574
def build_subversion_url(self, svnroot, submission):
575
princ = submission.assessed.principal
577
if isinstance(princ, User):
578
path = 'users/%s' % princ.login
580
path = 'groups/%s_%s_%s_%s' % (
581
princ.project_set.offering.subject.short_name,
582
princ.project_set.offering.semester.year,
583
princ.project_set.offering.semester.semester,
586
return urlparse.urljoin(
588
os.path.join(path, submission.path[1:] if
589
submission.path.startswith(os.sep) else
592
def populate(self, req, ctx):
593
self.plugin_styles[Plugin] = ["project.css"]
596
ctx['GroupsView'] = GroupsView
597
ctx['EnrolView'] = EnrolView
598
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
599
ctx['build_subversion_url'] = self.build_subversion_url
600
ctx['svn_addr'] = req.config['urls']['svn_addr']
601
ctx['project'] = self.context
602
ctx['user'] = req.user
604
class Plugin(ViewPlugin, MediaPlugin):
605
forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
606
offering_to_project, offering_to_projectset)
608
subject_url, semester_url, offering_url, projectset_url, project_url)
610
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
611
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
612
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
613
(ApplicationRoot, ('+semesters', '+new'), SemesterNew),
614
(Subject, '+edit', SubjectEdit),
615
(Semester, '+edit', SemesterEdit),
616
(Offering, '+index', OfferingView),
617
(Offering, '+edit', OfferingEdit),
618
(Offering, ('+enrolments', '+index'), EnrolmentsView),
619
(Offering, ('+enrolments', '+new'), EnrolView),
620
(Offering, ('+projects', '+index'), OfferingProjectsView),
621
(Project, '+index', ProjectView),
623
(Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
624
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
627
breadcrumbs = {Subject: SubjectBreadcrumb,
628
Offering: OfferingBreadcrumb,
629
User: UserBreadcrumb,
630
Project: ProjectBreadcrumb,
634
('subjects', 'Subjects',
635
'View subject content and complete worksheets',
636
'subjects.png', 'subjects', 5)
639
media = 'subject-media'