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
74
for semester in req.store.find(Semester).order_by(Desc(Semester.year),
75
Desc(Semester.semester)):
77
# For admins, show all subjects in the system
78
offerings = list(semester.offerings.find())
80
offerings = [enrolment.offering for enrolment in
81
semester.enrolments.find(user=req.user)]
83
ctx['semesters'].append((semester, offerings))
86
class SubjectsManage(XHTMLView):
87
'''Subject management view.'''
88
template = 'templates/subjects-manage.html'
91
def authorize(self, req):
92
return req.user is not None and req.user.admin
94
def populate(self, req, ctx):
96
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
97
ctx['SubjectEdit'] = SubjectEdit
98
ctx['SemesterEdit'] = SemesterEdit
100
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
101
ctx['semesters'] = req.store.find(Semester).order_by(
102
Semester.year, Semester.semester)
105
class SubjectShortNameUniquenessValidator(formencode.FancyValidator):
106
"""A FormEncode validator that checks that a subject name is unused.
108
The subject referenced by state.existing_subject is permitted
109
to hold that name. If any other object holds it, the input is rejected.
111
def __init__(self, matching=None):
112
self.matching = matching
114
def _to_python(self, value, state):
115
if (state.store.find(
116
Subject, short_name=value).one() not in
117
(None, state.existing_subject)):
118
raise formencode.Invalid(
119
'Short name already taken', value, state)
123
class SubjectSchema(formencode.Schema):
124
short_name = formencode.All(
125
SubjectShortNameUniquenessValidator(),
126
formencode.validators.UnicodeString(not_empty=True))
127
name = formencode.validators.UnicodeString(not_empty=True)
128
code = formencode.validators.UnicodeString(not_empty=True)
131
class SubjectFormView(BaseFormView):
132
"""An abstract form to add or edit a subject."""
135
def authorize(self, req):
136
return req.user is not None and req.user.admin
138
def populate_state(self, state):
139
state.existing_subject = None
143
return SubjectSchema()
145
def get_return_url(self, obj):
149
class SubjectNew(SubjectFormView):
150
"""A form to create a subject."""
151
template = 'templates/subject-new.html'
153
def get_default_data(self, req):
156
def save_object(self, req, data):
157
new_subject = Subject()
158
new_subject.short_name = data['short_name']
159
new_subject.name = data['name']
160
new_subject.code = data['code']
162
req.store.add(new_subject)
166
class SubjectEdit(SubjectFormView):
167
"""A form to edit a subject."""
168
template = 'templates/subject-edit.html'
170
def populate_state(self, state):
171
state.existing_subject = self.context
173
def get_default_data(self, req):
175
'short_name': self.context.short_name,
176
'name': self.context.name,
177
'code': self.context.code,
180
def save_object(self, req, data):
181
self.context.short_name = data['short_name']
182
self.context.name = data['name']
183
self.context.code = data['code']
188
class SemesterUniquenessValidator(formencode.FancyValidator):
189
"""A FormEncode validator that checks that a semester is unique.
191
There cannot be more than one semester for the same year and semester.
193
def _to_python(self, value, state):
194
if (state.store.find(
195
Semester, year=value['year'], semester=value['semester']
196
).one() not in (None, state.existing_semester)):
197
raise formencode.Invalid(
198
'Semester already exists', value, state)
202
class SemesterSchema(formencode.Schema):
203
year = formencode.validators.UnicodeString()
204
semester = formencode.validators.UnicodeString()
205
state = formencode.All(
206
formencode.validators.OneOf(["past", "current", "future"]),
207
formencode.validators.UnicodeString())
208
chained_validators = [SemesterUniquenessValidator()]
211
class SemesterFormView(BaseFormView):
214
def authorize(self, req):
215
return req.user is not None and req.user.admin
219
return SemesterSchema()
221
def get_return_url(self, obj):
222
return '/subjects/+manage'
225
class SemesterNew(SemesterFormView):
226
"""A form to create a semester."""
227
template = 'templates/semester-new.html'
230
def populate_state(self, state):
231
state.existing_semester = None
233
def get_default_data(self, req):
236
def save_object(self, req, data):
237
new_semester = Semester()
238
new_semester.year = data['year']
239
new_semester.semester = data['semester']
240
new_semester.state = data['state']
242
req.store.add(new_semester)
246
class SemesterEdit(SemesterFormView):
247
"""A form to edit a semester."""
248
template = 'templates/semester-edit.html'
250
def populate_state(self, state):
251
state.existing_semester = self.context
253
def get_default_data(self, req):
255
'year': self.context.year,
256
'semester': self.context.semester,
257
'state': self.context.state,
260
def save_object(self, req, data):
261
self.context.year = data['year']
262
self.context.semester = data['semester']
263
self.context.state = data['state']
268
class OfferingView(XHTMLView):
269
"""The home page of an offering."""
270
template = 'templates/offering.html'
274
def populate(self, req, ctx):
275
# Need the worksheet result styles.
276
self.plugin_styles[TutorialPlugin] = ['tutorial.css']
277
ctx['context'] = self.context
279
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
280
ctx['format_submission_principal'] = util.format_submission_principal
281
ctx['format_datetime'] = ivle.date.make_date_nice
282
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
283
ctx['OfferingEdit'] = OfferingEdit
284
ctx['GroupsView'] = GroupsView
286
# As we go, calculate the total score for this subject
287
# (Assessable worksheets only, mandatory problems only)
289
ctx['worksheets'], problems_total, problems_done = (
290
ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
291
req.store, req.user, self.context))
293
ctx['exercises_total'] = problems_total
294
ctx['exercises_done'] = problems_done
295
if problems_total > 0:
296
if problems_done >= problems_total:
297
ctx['worksheets_complete_class'] = "complete"
298
elif problems_done > 0:
299
ctx['worksheets_complete_class'] = "semicomplete"
301
ctx['worksheets_complete_class'] = "incomplete"
302
# Calculate the final percentage and mark for the subject
303
(ctx['exercises_pct'], ctx['worksheet_mark'],
304
ctx['worksheet_max_mark']) = (
305
ivle.worksheet.utils.calculate_mark(
306
problems_done, problems_total))
309
class SubjectValidator(formencode.FancyValidator):
310
"""A FormEncode validator that turns a subject name into a subject.
312
The state must have a 'store' attribute, which is the Storm store
315
def _to_python(self, value, state):
316
subject = state.store.find(Subject, short_name=value).one()
320
raise formencode.Invalid('Subject does not exist', value, state)
323
class SemesterValidator(formencode.FancyValidator):
324
"""A FormEncode validator that turns a string into a semester.
326
The string should be of the form 'year/semester', eg. '2009/1'.
328
The state must have a 'store' attribute, which is the Storm store
331
def _to_python(self, value, state):
333
year, semester = value.split('/')
335
year = semester = None
337
semester = state.store.find(
338
Semester, year=year, semester=semester).one()
342
raise formencode.Invalid('Semester does not exist', value, state)
345
class OfferingUniquenessValidator(formencode.FancyValidator):
346
"""A FormEncode validator that checks that an offering is unique.
348
There cannot be more than one offering in the same year and semester.
350
The offering referenced by state.existing_offering is permitted to
351
hold that year and semester tuple. If any other object holds it, the
354
def _to_python(self, value, state):
355
if (state.store.find(
356
Offering, subject=value['subject'],
357
semester=value['semester']).one() not in
358
(None, state.existing_offering)):
359
raise formencode.Invalid(
360
'Offering already exists', value, state)
364
class OfferingSchema(formencode.Schema):
365
description = formencode.validators.UnicodeString(
366
if_missing=None, not_empty=False)
367
url = formencode.validators.URL(if_missing=None, not_empty=False)
370
class OfferingAdminSchema(OfferingSchema):
371
subject = formencode.All(
372
SubjectValidator(), formencode.validators.UnicodeString())
373
semester = formencode.All(
374
SemesterValidator(), formencode.validators.UnicodeString())
375
chained_validators = [OfferingUniquenessValidator()]
378
class OfferingEdit(BaseFormView):
379
"""A form to edit an offering's details."""
380
template = 'templates/offering-edit.html'
386
if self.req.user.admin:
387
return OfferingAdminSchema()
389
return OfferingSchema()
391
def populate(self, req, ctx):
392
super(OfferingEdit, self).populate(req, ctx)
393
ctx['subjects'] = req.store.find(Subject)
394
ctx['semesters'] = req.store.find(Semester)
396
def populate_state(self, state):
397
state.existing_offering = self.context
399
def get_default_data(self, req):
401
'subject': self.context.subject.short_name,
402
'semester': self.context.semester.year + '/' +
403
self.context.semester.semester,
404
'url': self.context.url,
405
'description': self.context.description,
408
def save_object(self, req, data):
410
self.context.subject = data['subject']
411
self.context.semester = data['semester']
412
self.context.description = data['description']
413
self.context.url = unicode(data['url']) if data['url'] else None
417
class OfferingNew(BaseFormView):
418
"""A form to create an offering."""
419
template = 'templates/offering-new.html'
422
def authorize(self, req):
423
return req.user is not None and req.user.admin
427
return OfferingAdminSchema()
429
def populate(self, req, ctx):
430
super(OfferingNew, self).populate(req, ctx)
431
ctx['subjects'] = req.store.find(Subject)
432
ctx['semesters'] = req.store.find(Semester)
434
def populate_state(self, state):
435
state.existing_offering = None
437
def get_default_data(self, req):
440
def save_object(self, req, data):
441
new_offering = Offering()
442
new_offering.subject = data['subject']
443
new_offering.semester = data['semester']
444
new_offering.description = data['description']
445
new_offering.url = unicode(data['url']) if data['url'] else None
447
req.store.add(new_offering)
451
class UserValidator(formencode.FancyValidator):
452
"""A FormEncode validator that turns a username into a user.
454
The state must have a 'store' attribute, which is the Storm store
456
def _to_python(self, value, state):
457
user = User.get_by_login(state.store, value)
461
raise formencode.Invalid('User does not exist', value, state)
464
class NoEnrolmentValidator(formencode.FancyValidator):
465
"""A FormEncode validator that ensures absence of an enrolment.
467
The state must have an 'offering' attribute.
469
def _to_python(self, value, state):
470
if state.offering.get_enrolment(value):
471
raise formencode.Invalid('User already enrolled', value, state)
475
class RoleEnrolmentValidator(formencode.FancyValidator):
476
"""A FormEncode validator that checks permission to enrol users with a
479
The state must have an 'offering' attribute.
481
def _to_python(self, value, state):
482
if (("enrol_" + value) not in
483
state.offering.get_permissions(state.user, state.config)):
484
raise formencode.Invalid('Not allowed to assign users that role',
489
class EnrolSchema(formencode.Schema):
490
user = formencode.All(NoEnrolmentValidator(), UserValidator())
491
role = formencode.All(formencode.validators.OneOf(
492
["lecturer", "tutor", "student"]),
493
RoleEnrolmentValidator(),
494
formencode.validators.UnicodeString())
497
class EnrolmentsView(XHTMLView):
498
"""A page which displays all users enrolled in an offering."""
499
template = 'templates/enrolments.html'
503
def populate(self, req, ctx):
504
ctx['offering'] = self.context
506
class EnrolView(XHTMLView):
507
"""A form to enrol a user in an offering."""
508
template = 'templates/enrol.html'
512
def filter(self, stream, ctx):
513
return stream | HTMLFormFiller(data=ctx['data'])
515
def populate(self, req, ctx):
516
if req.method == 'POST':
517
data = dict(req.get_fieldstorage())
519
validator = EnrolSchema()
520
req.offering = self.context # XXX: Getting into state.
521
data = validator.to_python(data, state=req)
522
self.context.enrol(data['user'], data['role'])
524
req.throw_redirect(req.uri)
525
except formencode.Invalid, e:
526
errors = e.unpack_errors()
531
ctx['data'] = data or {}
532
ctx['offering'] = self.context
533
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
534
ctx['errors'] = errors
536
class OfferingProjectsView(XHTMLView):
537
"""View the projects for an offering."""
538
template = 'templates/offering_projects.html'
542
def populate(self, req, ctx):
543
self.plugin_styles[Plugin] = ["project.css"]
544
self.plugin_scripts[Plugin] = ["project.js"]
546
ctx['offering'] = self.context
547
ctx['projectsets'] = []
548
ctx['OfferingRESTView'] = OfferingRESTView
550
#Open the projectset Fragment, and render it for inclusion
551
#into the ProjectSets page
552
#XXX: This could be a lot cleaner
553
loader = genshi.template.TemplateLoader(".", auto_reload=True)
555
set_fragment = os.path.join(os.path.dirname(__file__),
556
"templates/projectset_fragment.html")
557
project_fragment = os.path.join(os.path.dirname(__file__),
558
"templates/project_fragment.html")
560
for projectset in self.context.project_sets:
561
settmpl = loader.load(set_fragment)
564
setCtx['projectset'] = projectset
565
setCtx['projects'] = []
566
setCtx['GroupsView'] = GroupsView
567
setCtx['ProjectSetRESTView'] = ProjectSetRESTView
569
for project in projectset.projects:
570
projecttmpl = loader.load(project_fragment)
571
projectCtx = Context()
572
projectCtx['req'] = req
573
projectCtx['project'] = project
575
setCtx['projects'].append(
576
projecttmpl.generate(projectCtx))
578
ctx['projectsets'].append(settmpl.generate(setCtx))
581
class ProjectView(XHTMLView):
582
"""View the submissions for a ProjectSet"""
583
template = "templates/project.html"
584
permission = "view_project_submissions"
587
def build_subversion_url(self, svnroot, submission):
588
princ = submission.assessed.principal
590
if isinstance(princ, User):
591
path = 'users/%s' % princ.login
593
path = 'groups/%s_%s_%s_%s' % (
594
princ.project_set.offering.subject.short_name,
595
princ.project_set.offering.semester.year,
596
princ.project_set.offering.semester.semester,
599
return urlparse.urljoin(
601
os.path.join(path, submission.path[1:] if
602
submission.path.startswith(os.sep) else
605
def populate(self, req, ctx):
606
self.plugin_styles[Plugin] = ["project.css"]
609
ctx['GroupsView'] = GroupsView
610
ctx['EnrolView'] = EnrolView
611
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
612
ctx['build_subversion_url'] = self.build_subversion_url
613
ctx['svn_addr'] = req.config['urls']['svn_addr']
614
ctx['project'] = self.context
615
ctx['user'] = req.user
617
class Plugin(ViewPlugin, MediaPlugin):
618
forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
619
offering_to_project, offering_to_projectset)
621
subject_url, semester_url, offering_url, projectset_url, project_url)
623
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
624
(ApplicationRoot, ('subjects', '+manage'), SubjectsManage),
625
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
626
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
627
(ApplicationRoot, ('+semesters', '+new'), SemesterNew),
628
(Subject, '+edit', SubjectEdit),
629
(Semester, '+edit', SemesterEdit),
630
(Offering, '+index', OfferingView),
631
(Offering, '+edit', OfferingEdit),
632
(Offering, ('+enrolments', '+index'), EnrolmentsView),
633
(Offering, ('+enrolments', '+new'), EnrolView),
634
(Offering, ('+projects', '+index'), OfferingProjectsView),
635
(Project, '+index', ProjectView),
637
(Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
638
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
641
breadcrumbs = {Subject: SubjectBreadcrumb,
642
Offering: OfferingBreadcrumb,
643
User: UserBreadcrumb,
644
Project: ProjectBreadcrumb,
648
('subjects', 'Subjects',
649
'View subject content and complete worksheets',
650
'subjects.png', 'subjects', 5)
653
media = 'subject-media'