23
23
# A sample / testing application for IVLE.
32
from storm.locals import Desc, Store
29
from storm.locals import Desc
34
30
from genshi.filters import HTMLFormFiller
35
from genshi.template import Context
37
import formencode.validators
39
from ivle.webapp.base.forms import (BaseFormView, URLNameValidator,
33
from ivle.webapp.base.xhtml import XHTMLView
41
34
from ivle.webapp.base.plugins import ViewPlugin, MediaPlugin
42
from ivle.webapp.base.xhtml import XHTMLView
43
from ivle.webapp.base.text import TextView
44
from ivle.webapp.errors import BadRequest
45
from ivle.webapp import ApplicationRoot
47
from ivle.database import Subject, Semester, Offering, Enrolment, User,\
48
ProjectSet, Project, ProjectSubmission
52
from ivle.webapp.admin.publishing import (root_to_subject, root_to_semester,
53
subject_to_offering, offering_to_projectset, offering_to_project,
54
offering_to_enrolment, subject_url, semester_url, offering_url,
55
projectset_url, project_url, enrolment_url)
56
from ivle.webapp.admin.breadcrumbs import (SubjectBreadcrumb,
57
OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb,
58
ProjectsBreadcrumb, EnrolmentBreadcrumb)
59
from ivle.webapp.core import Plugin as CorePlugin
60
from ivle.webapp.groups import GroupsView
61
from ivle.webapp.media import media_url
62
from ivle.webapp.tutorial import Plugin as TutorialPlugin
35
from ivle.webapp.errors import NotFound
36
from ivle.database import Subject, Semester, Offering, Enrolment, User
64
39
class SubjectsView(XHTMLView):
65
40
'''The view of the list of subjects.'''
66
template = 'templates/subjects.html'
41
template = 'subjects.html'
68
breadcrumb_text = "Subjects"
70
44
def authorize(self, req):
71
45
return req.user is not None
73
47
def populate(self, req, ctx):
75
48
ctx['user'] = req.user
76
49
ctx['semesters'] = []
78
50
for semester in req.store.find(Semester).order_by(Desc(Semester.year),
79
51
Desc(Semester.semester)):
81
# For admins, show all subjects in the system
82
offerings = list(semester.offerings.find())
84
offerings = [enrolment.offering for enrolment in
85
semester.enrolments.find(user=req.user)]
87
ctx['semesters'].append((semester, offerings))
90
class SubjectsManage(XHTMLView):
91
'''Subject management view.'''
92
template = 'templates/subjects-manage.html'
95
def authorize(self, req):
96
return req.user is not None and req.user.admin
98
def populate(self, req, ctx):
100
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
101
ctx['SubjectView'] = SubjectView
102
ctx['SubjectEdit'] = SubjectEdit
103
ctx['SemesterEdit'] = SemesterEdit
105
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
106
ctx['semesters'] = req.store.find(Semester).order_by(
107
Semester.year, Semester.semester)
110
class SubjectUniquenessValidator(formencode.FancyValidator):
111
"""A FormEncode validator that checks that a subject attribute is unique.
113
The subject referenced by state.existing_subject is permitted
114
to hold that name. If any other object holds it, the input is rejected.
116
:param attribute: the name of the attribute to check.
117
:param display: a string to identify the field in case of error.
120
def __init__(self, attribute, display):
121
self.attribute = attribute
122
self.display = display
124
def _to_python(self, value, state):
125
if (state.store.find(Subject, **{self.attribute: value}).one() not in
126
(None, state.existing_subject)):
127
raise formencode.Invalid(
128
'%s already taken' % self.display, value, state)
132
class SubjectSchema(formencode.Schema):
133
short_name = formencode.All(
134
SubjectUniquenessValidator('short_name', 'URL name'),
135
URLNameValidator(not_empty=True))
136
name = formencode.validators.UnicodeString(not_empty=True)
137
code = formencode.All(
138
SubjectUniquenessValidator('code', 'Subject code'),
139
formencode.validators.UnicodeString(not_empty=True))
142
class SubjectFormView(BaseFormView):
143
"""An abstract form to add or edit a subject."""
146
def authorize(self, req):
147
return req.user is not None and req.user.admin
149
def populate_state(self, state):
150
state.existing_subject = None
154
return SubjectSchema()
157
class SubjectNew(SubjectFormView):
158
"""A form to create a subject."""
159
template = 'templates/subject-new.html'
161
def get_default_data(self, req):
164
def save_object(self, req, data):
165
new_subject = Subject()
166
new_subject.short_name = data['short_name']
167
new_subject.name = data['name']
168
new_subject.code = data['code']
170
req.store.add(new_subject)
174
class SubjectEdit(SubjectFormView):
175
"""A form to edit a subject."""
176
template = 'templates/subject-edit.html'
178
def populate_state(self, state):
179
state.existing_subject = self.context
181
def get_default_data(self, req):
183
'short_name': self.context.short_name,
184
'name': self.context.name,
185
'code': self.context.code,
188
def save_object(self, req, data):
189
self.context.short_name = data['short_name']
190
self.context.name = data['name']
191
self.context.code = data['code']
196
class SemesterUniquenessValidator(formencode.FancyValidator):
197
"""A FormEncode validator that checks that a semester is unique.
199
There cannot be more than one semester for the same year and semester.
201
def _to_python(self, value, state):
202
if (state.store.find(
203
Semester, year=value['year'], semester=value['semester']
204
).one() not in (None, state.existing_semester)):
205
raise formencode.Invalid(
206
'Semester already exists', value, state)
210
class SemesterSchema(formencode.Schema):
211
year = URLNameValidator()
212
semester = URLNameValidator()
213
state = formencode.All(
214
formencode.validators.OneOf(["past", "current", "future"]),
215
formencode.validators.UnicodeString())
216
chained_validators = [SemesterUniquenessValidator()]
219
class SemesterFormView(BaseFormView):
222
def authorize(self, req):
223
return req.user is not None and req.user.admin
227
return SemesterSchema()
229
def get_return_url(self, obj):
230
return '/subjects/+manage'
233
class SemesterNew(SemesterFormView):
234
"""A form to create a semester."""
235
template = 'templates/semester-new.html'
238
def populate_state(self, state):
239
state.existing_semester = None
241
def get_default_data(self, req):
244
def save_object(self, req, data):
245
new_semester = Semester()
246
new_semester.year = data['year']
247
new_semester.semester = data['semester']
248
new_semester.state = data['state']
250
req.store.add(new_semester)
254
class SemesterEdit(SemesterFormView):
255
"""A form to edit a semester."""
256
template = 'templates/semester-edit.html'
258
def populate_state(self, state):
259
state.existing_semester = self.context
261
def get_default_data(self, req):
263
'year': self.context.year,
264
'semester': self.context.semester,
265
'state': self.context.state,
268
def save_object(self, req, data):
269
self.context.year = data['year']
270
self.context.semester = data['semester']
271
self.context.state = data['state']
275
class SubjectView(XHTMLView):
276
'''The view of the list of offerings in a given subject.'''
277
template = 'templates/subject.html'
280
def authorize(self, req):
281
return req.user is not None
283
def populate(self, req, ctx):
284
ctx['context'] = self.context
286
ctx['user'] = req.user
287
ctx['offerings'] = list(self.context.offerings)
288
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
289
ctx['SubjectEdit'] = SubjectEdit
290
ctx['SubjectOfferingNew'] = SubjectOfferingNew
293
class OfferingView(XHTMLView):
294
"""The home page of an offering."""
295
template = 'templates/offering.html'
299
def populate(self, req, ctx):
300
# Need the worksheet result styles.
301
self.plugin_styles[TutorialPlugin] = ['tutorial.css']
302
ctx['context'] = self.context
304
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
305
ctx['format_submission_principal'] = util.format_submission_principal
306
ctx['format_datetime'] = ivle.date.make_date_nice
307
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
308
ctx['OfferingEdit'] = OfferingEdit
309
ctx['OfferingCloneWorksheets'] = OfferingCloneWorksheets
310
ctx['GroupsView'] = GroupsView
311
ctx['EnrolmentsView'] = EnrolmentsView
312
ctx['Project'] = ivle.database.Project
314
# As we go, calculate the total score for this subject
315
# (Assessable worksheets only, mandatory problems only)
317
ctx['worksheets'], problems_total, problems_done = (
318
ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
319
req.config, req.store, req.user, self.context,
320
as_of=self.context.worksheet_cutoff))
322
ctx['exercises_total'] = problems_total
323
ctx['exercises_done'] = problems_done
324
if problems_total > 0:
325
if problems_done >= problems_total:
326
ctx['worksheets_complete_class'] = "complete"
327
elif problems_done > 0:
328
ctx['worksheets_complete_class'] = "semicomplete"
330
ctx['worksheets_complete_class'] = "incomplete"
331
# Calculate the final percentage and mark for the subject
332
(ctx['exercises_pct'], ctx['worksheet_mark'],
333
ctx['worksheet_max_mark']) = (
334
ivle.worksheet.utils.calculate_mark(
335
problems_done, problems_total))
338
class SubjectValidator(formencode.FancyValidator):
339
"""A FormEncode validator that turns a subject name into a subject.
341
The state must have a 'store' attribute, which is the Storm store
344
def _to_python(self, value, state):
345
subject = state.store.find(Subject, short_name=value).one()
349
raise formencode.Invalid('Subject does not exist', value, state)
352
class SemesterValidator(formencode.FancyValidator):
353
"""A FormEncode validator that turns a string into a semester.
355
The string should be of the form 'year/semester', eg. '2009/1'.
357
The state must have a 'store' attribute, which is the Storm store
360
def _to_python(self, value, state):
362
year, semester = value.split('/')
364
year = semester = None
366
semester = state.store.find(
367
Semester, year=year, semester=semester).one()
371
raise formencode.Invalid('Semester does not exist', value, state)
374
class OfferingUniquenessValidator(formencode.FancyValidator):
375
"""A FormEncode validator that checks that an offering is unique.
377
There cannot be more than one offering in the same year and semester.
379
The offering referenced by state.existing_offering is permitted to
380
hold that year and semester tuple. If any other object holds it, the
383
def _to_python(self, value, state):
384
if (state.store.find(
385
Offering, subject=value['subject'],
386
semester=value['semester']).one() not in
387
(None, state.existing_offering)):
388
raise formencode.Invalid(
389
'Offering already exists', value, state)
393
class OfferingSchema(formencode.Schema):
394
description = formencode.validators.UnicodeString(
395
if_missing=None, not_empty=False)
396
url = formencode.validators.URL(if_missing=None, not_empty=False)
397
worksheet_cutoff = DateTimeValidator(if_missing=None, not_empty=False)
398
show_worksheet_marks = formencode.validators.StringBoolean(
402
class OfferingAdminSchema(OfferingSchema):
403
subject = formencode.All(
404
SubjectValidator(), formencode.validators.UnicodeString())
405
semester = formencode.All(
406
SemesterValidator(), formencode.validators.UnicodeString())
407
chained_validators = [OfferingUniquenessValidator()]
410
class OfferingEdit(BaseFormView):
411
"""A form to edit an offering's details."""
412
template = 'templates/offering-edit.html'
418
if self.req.user.admin:
419
return OfferingAdminSchema()
421
return OfferingSchema()
423
def populate(self, req, ctx):
424
super(OfferingEdit, self).populate(req, ctx)
425
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
426
ctx['semesters'] = req.store.find(Semester).order_by(
427
Semester.year, Semester.semester)
428
ctx['force_subject'] = None
430
def populate_state(self, state):
431
state.existing_offering = self.context
433
def get_default_data(self, req):
435
'subject': self.context.subject.short_name,
436
'semester': self.context.semester.year + '/' +
437
self.context.semester.semester,
438
'url': self.context.url,
439
'description': self.context.description,
440
'worksheet_cutoff': self.context.worksheet_cutoff,
441
'show_worksheet_marks': self.context.show_worksheet_marks,
444
def save_object(self, req, data):
446
self.context.subject = data['subject']
447
self.context.semester = data['semester']
448
self.context.description = data['description']
449
self.context.url = unicode(data['url']) if data['url'] else None
450
self.context.worksheet_cutoff = data['worksheet_cutoff']
451
self.context.show_worksheet_marks = data['show_worksheet_marks']
455
class OfferingNew(BaseFormView):
456
"""A form to create an offering."""
457
template = 'templates/offering-new.html'
460
def authorize(self, req):
461
return req.user is not None and req.user.admin
465
return OfferingAdminSchema()
467
def populate(self, req, ctx):
468
super(OfferingNew, self).populate(req, ctx)
469
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
470
ctx['semesters'] = req.store.find(Semester).order_by(
471
Semester.year, Semester.semester)
472
ctx['force_subject'] = None
474
def populate_state(self, state):
475
state.existing_offering = None
477
def get_default_data(self, req):
480
def save_object(self, req, data):
481
new_offering = Offering()
482
new_offering.subject = data['subject']
483
new_offering.semester = data['semester']
484
new_offering.description = data['description']
485
new_offering.url = unicode(data['url']) if data['url'] else None
486
new_offering.worksheet_cutoff = data['worksheet_cutoff']
487
new_offering.show_worksheet_marks = data['show_worksheet_marks']
489
req.store.add(new_offering)
492
class SubjectOfferingNew(OfferingNew):
493
"""A form to create an offering for a given subject."""
494
# Identical to OfferingNew, except it forces the subject to be the subject
496
def populate(self, req, ctx):
497
super(SubjectOfferingNew, self).populate(req, ctx)
498
ctx['force_subject'] = self.context
500
class OfferingCloneWorksheetsSchema(formencode.Schema):
501
subject = formencode.All(
502
SubjectValidator(), formencode.validators.UnicodeString())
503
semester = formencode.All(
504
SemesterValidator(), formencode.validators.UnicodeString())
507
class OfferingCloneWorksheets(BaseFormView):
508
"""A form to clone worksheets from one offering to another."""
509
template = 'templates/offering-clone-worksheets.html'
512
def authorize(self, req):
513
return req.user is not None and req.user.admin
517
return OfferingCloneWorksheetsSchema()
519
def populate(self, req, ctx):
520
super(OfferingCloneWorksheets, self).populate(req, ctx)
521
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
522
ctx['semesters'] = req.store.find(Semester).order_by(
523
Semester.year, Semester.semester)
525
def get_default_data(self, req):
528
def save_object(self, req, data):
529
if self.context.worksheets.count() > 0:
531
"Cannot clone to target with existing worksheets.")
532
offering = req.store.find(
533
Offering, subject=data['subject'], semester=data['semester']).one()
535
raise BadRequest("No such offering.")
536
if offering.worksheets.count() == 0:
537
raise BadRequest("Source offering has no worksheets.")
539
self.context.clone_worksheets(offering)
52
enrolments = semester.enrolments.find(user=req.user)
53
if enrolments.count():
54
ctx['semesters'].append((semester, enrolments))
543
57
class UserValidator(formencode.FancyValidator):
632
122
ctx['data'] = data or {}
633
123
ctx['offering'] = self.context
634
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
635
124
ctx['errors'] = errors
636
# If all of the fields validated, set the global form error.
637
if isinstance(errors, basestring):
638
ctx['error_value'] = errors
641
class EnrolmentEditSchema(formencode.Schema):
642
role = formencode.All(formencode.validators.OneOf(
643
["lecturer", "tutor", "student"]),
644
RoleEnrolmentValidator(),
645
formencode.validators.UnicodeString())
648
class EnrolmentEdit(BaseFormView):
649
"""A form to alter an enrolment's role."""
650
template = 'templates/enrolment-edit.html'
654
def populate_state(self, state):
655
state.offering = self.context.offering
657
def get_default_data(self, req):
658
return {'role': self.context.role}
662
return EnrolmentEditSchema()
664
def save_object(self, req, data):
665
self.context.role = data['role']
667
def get_return_url(self, obj):
668
return self.req.publisher.generate(
669
self.context.offering, EnrolmentsView)
671
def populate(self, req, ctx):
672
super(EnrolmentEdit, self).populate(req, ctx)
673
ctx['offering_perms'] = self.context.offering.get_permissions(
674
req.user, req.config)
677
class EnrolmentDelete(XHTMLView):
678
"""A form to alter an enrolment's role."""
679
template = 'templates/enrolment-delete.html'
683
def populate(self, req, ctx):
684
# If POSTing, delete delete delete.
685
if req.method == 'POST':
686
self.context.delete()
688
req.throw_redirect(req.publisher.generate(
689
self.context.offering, EnrolmentsView))
691
ctx['enrolment'] = self.context
694
class OfferingProjectsView(XHTMLView):
695
"""View the projects for an offering."""
696
template = 'templates/offering_projects.html'
699
breadcrumb_text = 'Projects'
701
def populate(self, req, ctx):
702
self.plugin_styles[Plugin] = ["project.css"]
704
ctx['offering'] = self.context
705
ctx['projectsets'] = []
707
#Open the projectset Fragment, and render it for inclusion
708
#into the ProjectSets page
709
set_fragment = os.path.join(os.path.dirname(__file__),
710
"templates/projectset_fragment.html")
711
project_fragment = os.path.join(os.path.dirname(__file__),
712
"templates/project_fragment.html")
715
self.context.project_sets.order_by(ivle.database.ProjectSet.id):
716
settmpl = self._loader.load(set_fragment)
719
setCtx['projectset'] = projectset
720
setCtx['projects'] = []
721
setCtx['GroupsView'] = GroupsView
722
setCtx['ProjectSetEdit'] = ProjectSetEdit
723
setCtx['ProjectNew'] = ProjectNew
726
projectset.projects.order_by(ivle.database.Project.deadline):
727
projecttmpl = self._loader.load(project_fragment)
728
projectCtx = Context()
729
projectCtx['req'] = req
730
projectCtx['project'] = project
731
projectCtx['ProjectEdit'] = ProjectEdit
732
projectCtx['ProjectDelete'] = ProjectDelete
734
setCtx['projects'].append(
735
projecttmpl.generate(projectCtx))
737
ctx['projectsets'].append(settmpl.generate(setCtx))
740
class ProjectView(XHTMLView):
741
"""View the submissions for a ProjectSet"""
742
template = "templates/project.html"
743
permission = "view_project_submissions"
746
def populate(self, req, ctx):
747
self.plugin_styles[Plugin] = ["project.css"]
750
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
751
ctx['GroupsView'] = GroupsView
752
ctx['EnrolView'] = EnrolView
753
ctx['format_datetime'] = ivle.date.make_date_nice
754
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
755
ctx['project'] = self.context
756
ctx['user'] = req.user
757
ctx['ProjectEdit'] = ProjectEdit
758
ctx['ProjectDelete'] = ProjectDelete
759
ctx['ProjectExport'] = ProjectBashExportView
761
class ProjectBashExportView(TextView):
762
"""Produce a Bash script for exporting projects"""
763
template = "templates/project-export.sh"
764
content_type = "text/x-sh"
765
permission = "view_project_submissions"
767
def populate(self, req, ctx):
769
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
770
ctx['format_datetime'] = ivle.date.make_date_nice
771
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
772
ctx['project'] = self.context
773
ctx['user'] = req.user
774
ctx['now'] = datetime.datetime.now()
775
ctx['format_datetime'] = ivle.date.make_date_nice
776
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
778
class ProjectUniquenessValidator(formencode.FancyValidator):
779
"""A FormEncode validator that checks that a project short_name is unique
782
The project referenced by state.existing_project is permitted to
783
hold that short_name. If any other project holds it, the input is rejected.
785
def _to_python(self, value, state):
786
if (state.store.find(
788
Project.short_name == unicode(value),
789
Project.project_set_id == ProjectSet.id,
790
ProjectSet.offering == state.offering).one() not in
791
(None, state.existing_project)):
792
raise formencode.Invalid(
793
"A project with that URL name already exists in this offering."
797
class ProjectSchema(formencode.Schema):
798
name = formencode.validators.UnicodeString(not_empty=True)
799
short_name = formencode.All(
800
URLNameValidator(not_empty=True),
801
ProjectUniquenessValidator())
802
deadline = DateTimeValidator(not_empty=True)
803
url = formencode.validators.URL(if_missing=None, not_empty=False)
804
synopsis = formencode.validators.UnicodeString(not_empty=True)
806
class ProjectEdit(BaseFormView):
807
"""A form to edit a project."""
808
template = 'templates/project-edit.html'
814
return ProjectSchema()
816
def populate(self, req, ctx):
817
super(ProjectEdit, self).populate(req, ctx)
818
ctx['projectset'] = self.context.project_set
820
def populate_state(self, state):
821
state.offering = self.context.project_set.offering
822
state.existing_project = self.context
824
def get_default_data(self, req):
826
'name': self.context.name,
827
'short_name': self.context.short_name,
828
'deadline': self.context.deadline,
829
'url': self.context.url,
830
'synopsis': self.context.synopsis,
833
def save_object(self, req, data):
834
self.context.name = data['name']
835
self.context.short_name = data['short_name']
836
self.context.deadline = data['deadline']
837
self.context.url = unicode(data['url']) if data['url'] else None
838
self.context.synopsis = data['synopsis']
841
class ProjectNew(BaseFormView):
842
"""A form to create a new project."""
843
template = 'templates/project-new.html'
849
return ProjectSchema()
851
def populate(self, req, ctx):
852
super(ProjectNew, self).populate(req, ctx)
853
ctx['projectset'] = self.context
855
def populate_state(self, state):
856
state.offering = self.context.offering
857
state.existing_project = None
859
def get_default_data(self, req):
862
def save_object(self, req, data):
863
new_project = Project()
864
new_project.project_set = self.context
865
new_project.name = data['name']
866
new_project.short_name = data['short_name']
867
new_project.deadline = data['deadline']
868
new_project.url = unicode(data['url']) if data['url'] else None
869
new_project.synopsis = data['synopsis']
870
req.store.add(new_project)
873
class ProjectDelete(XHTMLView):
874
"""A form to delete a project."""
875
template = 'templates/project-delete.html'
879
def populate(self, req, ctx):
880
# If post, delete the project, or display a message explaining that
881
# the project cannot be deleted
882
if self.context.can_delete:
883
if req.method == 'POST':
884
self.context.delete()
885
self.template = 'templates/project-deleted.html'
888
self.template = 'templates/project-undeletable.html'
890
# If get and can delete, display a delete confirmation page
892
# Variables for the template
894
ctx['project'] = self.context
895
ctx['OfferingProjectsView'] = OfferingProjectsView
897
class ProjectSetSchema(formencode.Schema):
898
group_size = formencode.validators.Int(if_missing=None, not_empty=False)
900
class ProjectSetEdit(BaseFormView):
901
"""A form to edit a project set."""
902
template = 'templates/projectset-edit.html'
908
return ProjectSetSchema()
910
def populate(self, req, ctx):
911
super(ProjectSetEdit, self).populate(req, ctx)
913
def get_default_data(self, req):
915
'group_size': self.context.max_students_per_group,
918
def save_object(self, req, data):
919
self.context.max_students_per_group = data['group_size']
922
class ProjectSetNew(BaseFormView):
923
"""A form to create a new project set."""
924
template = 'templates/projectset-new.html'
927
breadcrumb_text = "Projects"
931
return ProjectSetSchema()
933
def populate(self, req, ctx):
934
super(ProjectSetNew, self).populate(req, ctx)
936
def get_default_data(self, req):
939
def save_object(self, req, data):
940
new_set = ProjectSet()
941
new_set.offering = self.context
942
new_set.max_students_per_group = data['group_size']
943
req.store.add(new_set)
946
127
class Plugin(ViewPlugin, MediaPlugin):
947
forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
948
offering_to_project, offering_to_projectset,
949
offering_to_enrolment)
951
subject_url, semester_url, offering_url, projectset_url, project_url,
954
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
955
(ApplicationRoot, ('subjects', '+manage'), SubjectsManage),
956
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
957
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
958
(ApplicationRoot, ('+semesters', '+new'), SemesterNew),
959
(Subject, '+index', SubjectView),
960
(Subject, '+edit', SubjectEdit),
961
(Subject, '+new-offering', SubjectOfferingNew),
962
(Semester, '+edit', SemesterEdit),
963
(Offering, '+index', OfferingView),
964
(Offering, '+edit', OfferingEdit),
965
(Offering, '+clone-worksheets', OfferingCloneWorksheets),
966
(Offering, ('+enrolments', '+index'), EnrolmentsView),
967
(Offering, ('+enrolments', '+new'), EnrolView),
968
(Enrolment, '+edit', EnrolmentEdit),
969
(Enrolment, '+delete', EnrolmentDelete),
970
(Offering, ('+projects', '+index'), OfferingProjectsView),
971
(Offering, ('+projects', '+new-set'), ProjectSetNew),
972
(ProjectSet, '+edit', ProjectSetEdit),
973
(ProjectSet, '+new', ProjectNew),
974
(Project, '+index', ProjectView),
975
(Project, '+edit', ProjectEdit),
976
(Project, '+delete', ProjectDelete),
977
(Project, ('+export', 'project-export.sh'),
978
ProjectBashExportView),
981
breadcrumbs = {Subject: SubjectBreadcrumb,
982
Offering: OfferingBreadcrumb,
983
User: UserBreadcrumb,
984
Project: ProjectBreadcrumb,
985
Enrolment: EnrolmentBreadcrumb,
129
('subjects/', SubjectsView),
130
('subjects/:subject/:year/:semester/+enrolments/+new', EnrolView),
989
134
('subjects', 'Subjects',