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,
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(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
237
ctx['GroupsView'] = GroupsView
239
# As we go, calculate the total score for this subject
240
# (Assessable worksheets only, mandatory problems only)
242
ctx['worksheets'], problems_total, problems_done = (
243
ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
244
req.store, req.user, self.context))
246
ctx['exercises_total'] = problems_total
247
ctx['exercises_done'] = problems_done
248
if problems_total > 0:
249
if problems_done >= problems_total:
250
ctx['worksheets_complete_class'] = "complete"
251
elif problems_done > 0:
252
ctx['worksheets_complete_class'] = "semicomplete"
254
ctx['worksheets_complete_class'] = "incomplete"
255
# Calculate the final percentage and mark for the subject
256
(ctx['exercises_pct'], ctx['worksheet_mark'],
257
ctx['worksheet_max_mark']) = (
258
ivle.worksheet.utils.calculate_mark(
259
problems_done, problems_total))
262
class SubjectValidator(formencode.FancyValidator):
263
"""A FormEncode validator that turns a subject name into a subject.
265
The state must have a 'store' attribute, which is the Storm store
268
def _to_python(self, value, state):
269
subject = state.store.find(Subject, short_name=value).one()
273
raise formencode.Invalid('Subject does not exist', value, state)
276
class SemesterValidator(formencode.FancyValidator):
277
"""A FormEncode validator that turns a string into a semester.
279
The string should be of the form 'year/semester', eg. '2009/1'.
281
The state must have a 'store' attribute, which is the Storm store
284
def _to_python(self, value, state):
286
year, semester = value.split('/')
288
year = semester = None
290
semester = state.store.find(
291
Semester, year=year, semester=semester).one()
295
raise formencode.Invalid('Semester does not exist', value, state)
298
class OfferingUniquenessValidator(formencode.FancyValidator):
299
"""A FormEncode validator that checks that an offering is unique.
301
There cannot be more than one offering in the same year and semester.
303
The offering referenced by state.existing_offering is permitted to
304
hold that year and semester tuple. If any other object holds it, the
307
def _to_python(self, value, state):
308
if (state.store.find(
309
Offering, subject=value['subject'],
310
semester=value['semester']).one() not in
311
(None, state.existing_offering)):
312
raise formencode.Invalid(
313
'Offering already exists', value, state)
317
class OfferingSchema(formencode.Schema):
318
description = formencode.validators.UnicodeString(
319
if_missing=None, not_empty=False)
320
url = formencode.validators.URL(if_missing=None, not_empty=False)
323
class OfferingAdminSchema(OfferingSchema):
324
subject = formencode.All(
325
SubjectValidator(), formencode.validators.UnicodeString())
326
semester = formencode.All(
327
SemesterValidator(), formencode.validators.UnicodeString())
328
chained_validators = [OfferingUniquenessValidator()]
331
class OfferingEdit(BaseFormView):
332
"""A form to edit an offering's details."""
333
template = 'templates/offering-edit.html'
339
if self.req.user.admin:
340
return OfferingAdminSchema()
342
return OfferingSchema()
344
def populate(self, req, ctx):
345
super(OfferingEdit, self).populate(req, ctx)
346
ctx['subjects'] = req.store.find(Subject)
347
ctx['semesters'] = req.store.find(Semester)
349
def populate_state(self, state):
350
state.existing_offering = self.context
352
def get_default_data(self, req):
354
'subject': self.context.subject.short_name,
355
'semester': self.context.semester.year + '/' +
356
self.context.semester.semester,
357
'url': self.context.url,
358
'description': self.context.description,
361
def save_object(self, req, data):
363
self.context.subject = data['subject']
364
self.context.semester = data['semester']
365
self.context.description = data['description']
366
self.context.url = unicode(data['url']) if data['url'] else None
370
class OfferingNew(BaseFormView):
371
"""A form to create an offering."""
372
template = 'templates/offering-new.html'
375
def authorize(self, req):
376
return req.user is not None and req.user.admin
380
return OfferingAdminSchema()
382
def populate(self, req, ctx):
383
super(OfferingNew, self).populate(req, ctx)
384
ctx['subjects'] = req.store.find(Subject)
385
ctx['semesters'] = req.store.find(Semester)
387
def populate_state(self, state):
388
state.existing_offering = None
390
def get_default_data(self, req):
393
def save_object(self, req, data):
394
new_offering = Offering()
395
new_offering.subject = data['subject']
396
new_offering.semester = data['semester']
397
new_offering.description = data['description']
398
new_offering.url = unicode(data['url']) if data['url'] else None
400
req.store.add(new_offering)
404
class UserValidator(formencode.FancyValidator):
405
"""A FormEncode validator that turns a username into a user.
407
The state must have a 'store' attribute, which is the Storm store
409
def _to_python(self, value, state):
410
user = User.get_by_login(state.store, value)
414
raise formencode.Invalid('User does not exist', value, state)
417
class NoEnrolmentValidator(formencode.FancyValidator):
418
"""A FormEncode validator that ensures absence of an enrolment.
420
The state must have an 'offering' attribute.
422
def _to_python(self, value, state):
423
if state.offering.get_enrolment(value):
424
raise formencode.Invalid('User already enrolled', value, state)
428
class RoleEnrolmentValidator(formencode.FancyValidator):
429
"""A FormEncode validator that checks permission to enrol users with a
432
The state must have an 'offering' attribute.
434
def _to_python(self, value, state):
435
if (("enrol_" + value) not in
436
state.offering.get_permissions(state.user, state.config)):
437
raise formencode.Invalid('Not allowed to assign users that role',
442
class EnrolSchema(formencode.Schema):
443
user = formencode.All(NoEnrolmentValidator(), UserValidator())
444
role = formencode.All(formencode.validators.OneOf(
445
["lecturer", "tutor", "student"]),
446
RoleEnrolmentValidator(),
447
formencode.validators.UnicodeString())
450
class EnrolmentsView(XHTMLView):
451
"""A page which displays all users enrolled in an offering."""
452
template = 'templates/enrolments.html'
456
def populate(self, req, ctx):
457
ctx['offering'] = self.context
459
class EnrolView(XHTMLView):
460
"""A form to enrol a user in an offering."""
461
template = 'templates/enrol.html'
465
def filter(self, stream, ctx):
466
return stream | HTMLFormFiller(data=ctx['data'])
468
def populate(self, req, ctx):
469
if req.method == 'POST':
470
data = dict(req.get_fieldstorage())
472
validator = EnrolSchema()
473
req.offering = self.context # XXX: Getting into state.
474
data = validator.to_python(data, state=req)
475
self.context.enrol(data['user'], data['role'])
477
req.throw_redirect(req.uri)
478
except formencode.Invalid, e:
479
errors = e.unpack_errors()
484
ctx['data'] = data or {}
485
ctx['offering'] = self.context
486
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
487
ctx['errors'] = errors
489
class OfferingProjectsView(XHTMLView):
490
"""View the projects for an offering."""
491
template = 'templates/offering_projects.html'
495
def populate(self, req, ctx):
496
self.plugin_styles[Plugin] = ["project.css"]
497
self.plugin_scripts[Plugin] = ["project.js"]
499
ctx['offering'] = self.context
500
ctx['projectsets'] = []
501
ctx['OfferingRESTView'] = OfferingRESTView
503
#Open the projectset Fragment, and render it for inclusion
504
#into the ProjectSets page
505
#XXX: This could be a lot cleaner
506
loader = genshi.template.TemplateLoader(".", auto_reload=True)
508
set_fragment = os.path.join(os.path.dirname(__file__),
509
"templates/projectset_fragment.html")
510
project_fragment = os.path.join(os.path.dirname(__file__),
511
"templates/project_fragment.html")
513
for projectset in self.context.project_sets:
514
settmpl = loader.load(set_fragment)
517
setCtx['projectset'] = projectset
518
setCtx['projects'] = []
519
setCtx['GroupsView'] = GroupsView
520
setCtx['ProjectSetRESTView'] = ProjectSetRESTView
522
for project in projectset.projects:
523
projecttmpl = loader.load(project_fragment)
524
projectCtx = Context()
525
projectCtx['req'] = req
526
projectCtx['project'] = project
528
setCtx['projects'].append(
529
projecttmpl.generate(projectCtx))
531
ctx['projectsets'].append(settmpl.generate(setCtx))
534
class ProjectView(XHTMLView):
535
"""View the submissions for a ProjectSet"""
536
template = "templates/project.html"
537
permission = "view_project_submissions"
540
def build_subversion_url(self, svnroot, submission):
541
princ = submission.assessed.principal
543
if isinstance(princ, User):
544
path = 'users/%s' % princ.login
546
path = 'groups/%s_%s_%s_%s' % (
547
princ.project_set.offering.subject.short_name,
548
princ.project_set.offering.semester.year,
549
princ.project_set.offering.semester.semester,
552
return urlparse.urljoin(
554
os.path.join(path, submission.path[1:] if
555
submission.path.startswith(os.sep) else
558
def populate(self, req, ctx):
559
self.plugin_styles[Plugin] = ["project.css"]
562
ctx['GroupsView'] = GroupsView
563
ctx['EnrolView'] = EnrolView
564
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
565
ctx['build_subversion_url'] = self.build_subversion_url
566
ctx['svn_addr'] = req.config['urls']['svn_addr']
567
ctx['project'] = self.context
568
ctx['user'] = req.user
570
class Plugin(ViewPlugin, MediaPlugin):
571
forward_routes = (root_to_subject, subject_to_offering,
572
offering_to_project, offering_to_projectset)
573
reverse_routes = (subject_url, offering_url, projectset_url, project_url)
575
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
576
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
577
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
578
(ApplicationRoot, ('subjects', '+new-semester'), SemesterNew),
579
(Subject, '+edit', SubjectEdit),
580
(Offering, '+index', OfferingView),
581
(Offering, '+edit', OfferingEdit),
582
(Offering, ('+enrolments', '+index'), EnrolmentsView),
583
(Offering, ('+enrolments', '+new'), EnrolView),
584
(Offering, ('+projects', '+index'), OfferingProjectsView),
585
(Project, '+index', ProjectView),
587
(Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
588
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
591
breadcrumbs = {Subject: SubjectBreadcrumb,
592
Offering: OfferingBreadcrumb,
593
User: UserBreadcrumb,
594
Project: ProjectBreadcrumb,
598
('subjects', 'Subjects',
599
'View subject content and complete worksheets',
600
'subjects.png', 'subjects', 5)
603
media = 'subject-media'