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, URLNameValidator
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
subject_url, semester_url, offering_url, projectset_url,
55
from ivle.webapp.admin.breadcrumbs import (SubjectBreadcrumb,
56
OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb)
57
from ivle.webapp.core import Plugin as CorePlugin
58
from ivle.webapp.groups import GroupsView
59
from ivle.webapp.media import media_url
60
from ivle.webapp.tutorial import Plugin as TutorialPlugin
62
class SubjectsView(XHTMLView):
63
'''The view of the list of subjects.'''
64
template = 'templates/subjects.html'
67
def authorize(self, req):
68
return req.user is not None
70
def populate(self, req, ctx):
72
ctx['user'] = req.user
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))
87
class SubjectsManage(XHTMLView):
88
'''Subject management view.'''
89
template = 'templates/subjects-manage.html'
92
def authorize(self, req):
93
return req.user is not None and req.user.admin
95
def populate(self, req, ctx):
97
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
98
ctx['SubjectEdit'] = SubjectEdit
99
ctx['SemesterEdit'] = SemesterEdit
101
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
102
ctx['semesters'] = req.store.find(Semester).order_by(
103
Semester.year, Semester.semester)
106
class SubjectShortNameUniquenessValidator(formencode.FancyValidator):
107
"""A FormEncode validator that checks that a subject name is unused.
109
The subject referenced by state.existing_subject is permitted
110
to hold that name. If any other object holds it, the input is rejected.
112
def __init__(self, matching=None):
113
self.matching = matching
115
def _to_python(self, value, state):
116
if (state.store.find(
117
Subject, short_name=value).one() not in
118
(None, state.existing_subject)):
119
raise formencode.Invalid(
120
'Short name already taken', value, state)
124
class SubjectSchema(formencode.Schema):
125
short_name = formencode.All(
126
SubjectShortNameUniquenessValidator(),
127
URLNameValidator(not_empty=True))
128
name = formencode.validators.UnicodeString(not_empty=True)
129
code = formencode.validators.UnicodeString(not_empty=True)
132
class SubjectFormView(BaseFormView):
133
"""An abstract form to add or edit a subject."""
136
def authorize(self, req):
137
return req.user is not None and req.user.admin
139
def populate_state(self, state):
140
state.existing_subject = None
144
return SubjectSchema()
146
def get_return_url(self, obj):
150
class SubjectNew(SubjectFormView):
151
"""A form to create a subject."""
152
template = 'templates/subject-new.html'
154
def get_default_data(self, req):
157
def save_object(self, req, data):
158
new_subject = Subject()
159
new_subject.short_name = data['short_name']
160
new_subject.name = data['name']
161
new_subject.code = data['code']
163
req.store.add(new_subject)
167
class SubjectEdit(SubjectFormView):
168
"""A form to edit a subject."""
169
template = 'templates/subject-edit.html'
171
def populate_state(self, state):
172
state.existing_subject = self.context
174
def get_default_data(self, req):
176
'short_name': self.context.short_name,
177
'name': self.context.name,
178
'code': self.context.code,
181
def save_object(self, req, data):
182
self.context.short_name = data['short_name']
183
self.context.name = data['name']
184
self.context.code = data['code']
189
class SemesterUniquenessValidator(formencode.FancyValidator):
190
"""A FormEncode validator that checks that a semester is unique.
192
There cannot be more than one semester for the same year and semester.
194
def _to_python(self, value, state):
195
if (state.store.find(
196
Semester, year=value['year'], semester=value['semester']
197
).one() not in (None, state.existing_semester)):
198
raise formencode.Invalid(
199
'Semester already exists', value, state)
203
class SemesterSchema(formencode.Schema):
204
year = URLNameValidator()
205
semester = URLNameValidator()
206
state = formencode.All(
207
formencode.validators.OneOf(["past", "current", "future"]),
208
formencode.validators.UnicodeString())
209
chained_validators = [SemesterUniquenessValidator()]
212
class SemesterFormView(BaseFormView):
215
def authorize(self, req):
216
return req.user is not None and req.user.admin
220
return SemesterSchema()
222
def get_return_url(self, obj):
223
return '/subjects/+manage'
226
class SemesterNew(SemesterFormView):
227
"""A form to create a semester."""
228
template = 'templates/semester-new.html'
231
def populate_state(self, state):
232
state.existing_semester = None
234
def get_default_data(self, req):
237
def save_object(self, req, data):
238
new_semester = Semester()
239
new_semester.year = data['year']
240
new_semester.semester = data['semester']
241
new_semester.state = data['state']
243
req.store.add(new_semester)
247
class SemesterEdit(SemesterFormView):
248
"""A form to edit a semester."""
249
template = 'templates/semester-edit.html'
251
def populate_state(self, state):
252
state.existing_semester = self.context
254
def get_default_data(self, req):
256
'year': self.context.year,
257
'semester': self.context.semester,
258
'state': self.context.state,
261
def save_object(self, req, data):
262
self.context.year = data['year']
263
self.context.semester = data['semester']
264
self.context.state = data['state']
269
class OfferingView(XHTMLView):
270
"""The home page of an offering."""
271
template = 'templates/offering.html'
275
def populate(self, req, ctx):
276
# Need the worksheet result styles.
277
self.plugin_styles[TutorialPlugin] = ['tutorial.css']
278
ctx['context'] = self.context
280
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
281
ctx['format_submission_principal'] = util.format_submission_principal
282
ctx['format_datetime'] = ivle.date.make_date_nice
283
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
284
ctx['OfferingEdit'] = OfferingEdit
285
ctx['OfferingCloneWorksheets'] = OfferingCloneWorksheets
286
ctx['GroupsView'] = GroupsView
288
# As we go, calculate the total score for this subject
289
# (Assessable worksheets only, mandatory problems only)
291
ctx['worksheets'], problems_total, problems_done = (
292
ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
293
req.store, req.user, self.context))
295
ctx['exercises_total'] = problems_total
296
ctx['exercises_done'] = problems_done
297
if problems_total > 0:
298
if problems_done >= problems_total:
299
ctx['worksheets_complete_class'] = "complete"
300
elif problems_done > 0:
301
ctx['worksheets_complete_class'] = "semicomplete"
303
ctx['worksheets_complete_class'] = "incomplete"
304
# Calculate the final percentage and mark for the subject
305
(ctx['exercises_pct'], ctx['worksheet_mark'],
306
ctx['worksheet_max_mark']) = (
307
ivle.worksheet.utils.calculate_mark(
308
problems_done, problems_total))
311
class SubjectValidator(formencode.FancyValidator):
312
"""A FormEncode validator that turns a subject name into a subject.
314
The state must have a 'store' attribute, which is the Storm store
317
def _to_python(self, value, state):
318
subject = state.store.find(Subject, short_name=value).one()
322
raise formencode.Invalid('Subject does not exist', value, state)
325
class SemesterValidator(formencode.FancyValidator):
326
"""A FormEncode validator that turns a string into a semester.
328
The string should be of the form 'year/semester', eg. '2009/1'.
330
The state must have a 'store' attribute, which is the Storm store
333
def _to_python(self, value, state):
335
year, semester = value.split('/')
337
year = semester = None
339
semester = state.store.find(
340
Semester, year=year, semester=semester).one()
344
raise formencode.Invalid('Semester does not exist', value, state)
347
class OfferingUniquenessValidator(formencode.FancyValidator):
348
"""A FormEncode validator that checks that an offering is unique.
350
There cannot be more than one offering in the same year and semester.
352
The offering referenced by state.existing_offering is permitted to
353
hold that year and semester tuple. If any other object holds it, the
356
def _to_python(self, value, state):
357
if (state.store.find(
358
Offering, subject=value['subject'],
359
semester=value['semester']).one() not in
360
(None, state.existing_offering)):
361
raise formencode.Invalid(
362
'Offering already exists', value, state)
366
class OfferingSchema(formencode.Schema):
367
description = formencode.validators.UnicodeString(
368
if_missing=None, not_empty=False)
369
url = formencode.validators.URL(if_missing=None, not_empty=False)
372
class OfferingAdminSchema(OfferingSchema):
373
subject = formencode.All(
374
SubjectValidator(), formencode.validators.UnicodeString())
375
semester = formencode.All(
376
SemesterValidator(), formencode.validators.UnicodeString())
377
chained_validators = [OfferingUniquenessValidator()]
380
class OfferingEdit(BaseFormView):
381
"""A form to edit an offering's details."""
382
template = 'templates/offering-edit.html'
388
if self.req.user.admin:
389
return OfferingAdminSchema()
391
return OfferingSchema()
393
def populate(self, req, ctx):
394
super(OfferingEdit, self).populate(req, ctx)
395
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
396
ctx['semesters'] = req.store.find(Semester).order_by(
397
Semester.year, Semester.semester)
399
def populate_state(self, state):
400
state.existing_offering = self.context
402
def get_default_data(self, req):
404
'subject': self.context.subject.short_name,
405
'semester': self.context.semester.year + '/' +
406
self.context.semester.semester,
407
'url': self.context.url,
408
'description': self.context.description,
411
def save_object(self, req, data):
413
self.context.subject = data['subject']
414
self.context.semester = data['semester']
415
self.context.description = data['description']
416
self.context.url = unicode(data['url']) if data['url'] else None
420
class OfferingNew(BaseFormView):
421
"""A form to create an offering."""
422
template = 'templates/offering-new.html'
425
def authorize(self, req):
426
return req.user is not None and req.user.admin
430
return OfferingAdminSchema()
432
def populate(self, req, ctx):
433
super(OfferingNew, self).populate(req, ctx)
434
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
435
ctx['semesters'] = req.store.find(Semester).order_by(
436
Semester.year, Semester.semester)
438
def populate_state(self, state):
439
state.existing_offering = None
441
def get_default_data(self, req):
444
def save_object(self, req, data):
445
new_offering = Offering()
446
new_offering.subject = data['subject']
447
new_offering.semester = data['semester']
448
new_offering.description = data['description']
449
new_offering.url = unicode(data['url']) if data['url'] else None
451
req.store.add(new_offering)
455
class OfferingCloneWorksheetsSchema(formencode.Schema):
456
subject = formencode.All(
457
SubjectValidator(), formencode.validators.UnicodeString())
458
semester = formencode.All(
459
SemesterValidator(), formencode.validators.UnicodeString())
462
class OfferingCloneWorksheets(BaseFormView):
463
"""A form to clone worksheets from one offering to another."""
464
template = 'templates/offering-clone-worksheets.html'
467
def authorize(self, req):
468
return req.user is not None and req.user.admin
472
return OfferingCloneWorksheetsSchema()
474
def populate(self, req, ctx):
475
super(OfferingCloneWorksheets, self).populate(req, ctx)
476
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
477
ctx['semesters'] = req.store.find(Semester).order_by(
478
Semester.year, Semester.semester)
480
def get_default_data(self, req):
483
def save_object(self, req, data):
484
if self.context.worksheets.count() > 0:
486
"Cannot clone to target with existing worksheets.")
487
offering = req.store.find(
488
Offering, subject=data['subject'], semester=data['semester']).one()
490
raise BadRequest("No such offering.")
491
if offering.worksheets.count() == 0:
492
raise BadRequest("Source offering has no worksheets.")
494
self.context.clone_worksheets(offering)
498
class UserValidator(formencode.FancyValidator):
499
"""A FormEncode validator that turns a username into a user.
501
The state must have a 'store' attribute, which is the Storm store
503
def _to_python(self, value, state):
504
user = User.get_by_login(state.store, value)
508
raise formencode.Invalid('User does not exist', value, state)
511
class NoEnrolmentValidator(formencode.FancyValidator):
512
"""A FormEncode validator that ensures absence of an enrolment.
514
The state must have an 'offering' attribute.
516
def _to_python(self, value, state):
517
if state.offering.get_enrolment(value):
518
raise formencode.Invalid('User already enrolled', value, state)
522
class RoleEnrolmentValidator(formencode.FancyValidator):
523
"""A FormEncode validator that checks permission to enrol users with a
526
The state must have an 'offering' attribute.
528
def _to_python(self, value, state):
529
if (("enrol_" + value) not in
530
state.offering.get_permissions(state.user, state.config)):
531
raise formencode.Invalid('Not allowed to assign users that role',
536
class EnrolSchema(formencode.Schema):
537
user = formencode.All(NoEnrolmentValidator(), UserValidator())
538
role = formencode.All(formencode.validators.OneOf(
539
["lecturer", "tutor", "student"]),
540
RoleEnrolmentValidator(),
541
formencode.validators.UnicodeString())
544
class EnrolmentsView(XHTMLView):
545
"""A page which displays all users enrolled in an offering."""
546
template = 'templates/enrolments.html'
550
def populate(self, req, ctx):
551
ctx['offering'] = self.context
553
class EnrolView(XHTMLView):
554
"""A form to enrol a user in an offering."""
555
template = 'templates/enrol.html'
559
def filter(self, stream, ctx):
560
return stream | HTMLFormFiller(data=ctx['data'])
562
def populate(self, req, ctx):
563
if req.method == 'POST':
564
data = dict(req.get_fieldstorage())
566
validator = EnrolSchema()
567
req.offering = self.context # XXX: Getting into state.
568
data = validator.to_python(data, state=req)
569
self.context.enrol(data['user'], data['role'])
571
req.throw_redirect(req.uri)
572
except formencode.Invalid, e:
573
errors = e.unpack_errors()
578
ctx['data'] = data or {}
579
ctx['offering'] = self.context
580
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
581
ctx['errors'] = errors
583
class OfferingProjectsView(XHTMLView):
584
"""View the projects for an offering."""
585
template = 'templates/offering_projects.html'
589
def populate(self, req, ctx):
590
self.plugin_styles[Plugin] = ["project.css"]
591
self.plugin_scripts[Plugin] = ["project.js"]
593
ctx['offering'] = self.context
594
ctx['projectsets'] = []
595
ctx['OfferingRESTView'] = OfferingRESTView
597
#Open the projectset Fragment, and render it for inclusion
598
#into the ProjectSets page
599
#XXX: This could be a lot cleaner
600
loader = genshi.template.TemplateLoader(".", auto_reload=True)
602
set_fragment = os.path.join(os.path.dirname(__file__),
603
"templates/projectset_fragment.html")
604
project_fragment = os.path.join(os.path.dirname(__file__),
605
"templates/project_fragment.html")
607
for projectset in self.context.project_sets:
608
settmpl = loader.load(set_fragment)
611
setCtx['projectset'] = projectset
612
setCtx['projects'] = []
613
setCtx['GroupsView'] = GroupsView
614
setCtx['ProjectSetRESTView'] = ProjectSetRESTView
616
for project in projectset.projects:
617
projecttmpl = loader.load(project_fragment)
618
projectCtx = Context()
619
projectCtx['req'] = req
620
projectCtx['project'] = project
622
setCtx['projects'].append(
623
projecttmpl.generate(projectCtx))
625
ctx['projectsets'].append(settmpl.generate(setCtx))
628
class ProjectView(XHTMLView):
629
"""View the submissions for a ProjectSet"""
630
template = "templates/project.html"
631
permission = "view_project_submissions"
634
def build_subversion_url(self, svnroot, submission):
635
princ = submission.assessed.principal
637
if isinstance(princ, User):
638
path = 'users/%s' % princ.login
640
path = 'groups/%s_%s_%s_%s' % (
641
princ.project_set.offering.subject.short_name,
642
princ.project_set.offering.semester.year,
643
princ.project_set.offering.semester.semester,
646
return urlparse.urljoin(
648
os.path.join(path, submission.path[1:] if
649
submission.path.startswith(os.sep) else
652
def populate(self, req, ctx):
653
self.plugin_styles[Plugin] = ["project.css"]
656
ctx['GroupsView'] = GroupsView
657
ctx['EnrolView'] = EnrolView
658
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
659
ctx['build_subversion_url'] = self.build_subversion_url
660
ctx['svn_addr'] = req.config['urls']['svn_addr']
661
ctx['project'] = self.context
662
ctx['user'] = req.user
664
class Plugin(ViewPlugin, MediaPlugin):
665
forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
666
offering_to_project, offering_to_projectset)
668
subject_url, semester_url, offering_url, projectset_url, project_url)
670
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
671
(ApplicationRoot, ('subjects', '+manage'), SubjectsManage),
672
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
673
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
674
(ApplicationRoot, ('+semesters', '+new'), SemesterNew),
675
(Subject, '+edit', SubjectEdit),
676
(Semester, '+edit', SemesterEdit),
677
(Offering, '+index', OfferingView),
678
(Offering, '+edit', OfferingEdit),
679
(Offering, '+clone-worksheets', OfferingCloneWorksheets),
680
(Offering, ('+enrolments', '+index'), EnrolmentsView),
681
(Offering, ('+enrolments', '+new'), EnrolView),
682
(Offering, ('+projects', '+index'), OfferingProjectsView),
683
(Project, '+index', ProjectView),
685
(Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
686
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
689
breadcrumbs = {Subject: SubjectBreadcrumb,
690
Offering: OfferingBreadcrumb,
691
User: UserBreadcrumb,
692
Project: ProjectBreadcrumb,
696
('subjects', 'Subjects',
697
'View subject content and complete worksheets',
698
'subjects.png', 'subjects', 5)
701
media = 'subject-media'