87
76
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.display_name)
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'], url_name=value['url_name']
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
code = formencode.validators.UnicodeString()
213
url_name = URLNameValidator()
214
display_name = formencode.validators.UnicodeString()
215
state = formencode.All(
216
formencode.validators.OneOf(["past", "current", "future"]),
217
formencode.validators.UnicodeString())
218
chained_validators = [SemesterUniquenessValidator()]
221
class SemesterFormView(BaseFormView):
224
def authorize(self, req):
225
return req.user is not None and req.user.admin
229
return SemesterSchema()
231
def get_return_url(self, obj):
232
return '/subjects/+manage'
235
class SemesterNew(SemesterFormView):
236
"""A form to create a semester."""
237
template = 'templates/semester-new.html'
240
def populate_state(self, state):
241
state.existing_semester = None
243
def get_default_data(self, req):
246
def save_object(self, req, data):
247
new_semester = Semester()
248
new_semester.year = data['year']
249
new_semester.code = data['code']
250
new_semester.url_name = data['url_name']
251
new_semester.display_name = data['display_name']
252
new_semester.state = data['state']
254
req.store.add(new_semester)
258
class SemesterEdit(SemesterFormView):
259
"""A form to edit a semester."""
260
template = 'templates/semester-edit.html'
262
def populate_state(self, state):
263
state.existing_semester = self.context
265
def get_default_data(self, req):
267
'year': self.context.year,
268
'code': self.context.code,
269
'url_name': self.context.url_name,
270
'display_name': self.context.display_name,
271
'state': self.context.state,
274
def save_object(self, req, data):
275
self.context.year = data['year']
276
self.context.code = data['code']
277
self.context.url_name = data['url_name']
278
self.context.display_name = data['display_name']
279
self.context.state = data['state']
283
class SubjectView(XHTMLView):
284
'''The view of the list of offerings in a given subject.'''
285
template = 'templates/subject.html'
288
def authorize(self, req):
289
return req.user is not None
291
def populate(self, req, ctx):
292
ctx['context'] = self.context
294
ctx['user'] = req.user
295
ctx['offerings'] = list(self.context.offerings)
296
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
297
ctx['SubjectEdit'] = SubjectEdit
298
ctx['SubjectOfferingNew'] = SubjectOfferingNew
79
def format_submission_principal(user, principal):
80
"""Render a list of users to fit in the offering project listing.
82
Given a user and a list of submitters, returns 'solo' if the
83
only submitter is the user, or a string of the form
84
'with A, B and C' if there are any other submitters.
86
If submitters is None, we assume that the list of members could
87
not be determined, so we just return 'group'.
95
display_names = sorted(
96
member.display_name for member in principal.members
97
if member is not user)
99
if len(display_names) == 0:
100
return 'solo (%s)' % principal.name
101
elif len(display_names) == 1:
102
return 'with %s (%s)' % (display_names[0], principal.name)
103
elif len(display_names) > 5:
104
return 'with %d others (%s)' % (len(display_names), principal.name)
106
return 'with %s and %s (%s)' % (', '.join(display_names[:-1]),
107
display_names[-1], principal.name)
301
110
class OfferingView(XHTMLView):
343
147
problems_done, problems_total))
346
class SubjectValidator(formencode.FancyValidator):
347
"""A FormEncode validator that turns a subject name into a subject.
349
The state must have a 'store' attribute, which is the Storm store
352
def _to_python(self, value, state):
353
subject = state.store.find(Subject, short_name=value).one()
357
raise formencode.Invalid('Subject does not exist', value, state)
360
class SemesterValidator(formencode.FancyValidator):
361
"""A FormEncode validator that turns a string into a semester.
363
The string should be of the form 'year/semester', eg. '2009/1'.
365
The state must have a 'store' attribute, which is the Storm store
368
def _to_python(self, value, state):
370
year, semester = value.split('/')
372
year = semester = None
374
semester = state.store.find(
375
Semester, year=year, url_name=semester).one()
379
raise formencode.Invalid('Semester does not exist', value, state)
382
class OfferingUniquenessValidator(formencode.FancyValidator):
383
"""A FormEncode validator that checks that an offering is unique.
385
There cannot be more than one offering in the same year and semester.
387
The offering referenced by state.existing_offering is permitted to
388
hold that year and semester tuple. If any other object holds it, the
391
def _to_python(self, value, state):
392
if (state.store.find(
393
Offering, subject=value['subject'],
394
semester=value['semester']).one() not in
395
(None, state.existing_offering)):
396
raise formencode.Invalid(
397
'Offering already exists', value, state)
401
150
class OfferingSchema(formencode.Schema):
402
151
description = formencode.validators.UnicodeString(
403
152
if_missing=None, not_empty=False)
404
153
url = formencode.validators.URL(if_missing=None, not_empty=False)
405
worksheet_cutoff = DateTimeValidator(if_missing=None, not_empty=False)
406
show_worksheet_marks = formencode.validators.StringBoolean(
410
class OfferingAdminSchema(OfferingSchema):
411
subject = formencode.All(
412
SubjectValidator(), formencode.validators.UnicodeString())
413
semester = formencode.All(
414
SemesterValidator(), formencode.validators.UnicodeString())
415
chained_validators = [OfferingUniquenessValidator()]
418
class OfferingEdit(BaseFormView):
156
class OfferingEdit(XHTMLView):
419
157
"""A form to edit an offering's details."""
420
158
template = 'templates/offering-edit.html'
422
159
permission = 'edit'
426
if self.req.user.admin:
427
return OfferingAdminSchema()
161
def filter(self, stream, ctx):
162
return stream | HTMLFormFiller(data=ctx['data'])
164
def populate(self, req, ctx):
165
if req.method == 'POST':
166
data = dict(req.get_fieldstorage())
168
validator = OfferingSchema()
169
data = validator.to_python(data, state=req)
171
self.context.url = unicode(data['url']) if data['url'] else None
172
self.context.description = data['description']
174
req.throw_redirect(req.publisher.generate(self.context))
175
except formencode.Invalid, e:
176
errors = e.unpack_errors()
429
return OfferingSchema()
431
def populate(self, req, ctx):
432
super(OfferingEdit, self).populate(req, ctx)
433
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
434
ctx['semesters'] = req.store.find(Semester).order_by(
435
Semester.year, Semester.display_name)
436
ctx['force_subject'] = None
438
def populate_state(self, state):
439
state.existing_offering = self.context
441
def get_default_data(self, req):
443
'subject': self.context.subject.short_name,
444
'semester': self.context.semester.year + '/' +
445
self.context.semester.url_name,
446
'url': self.context.url,
447
'description': self.context.description,
448
'worksheet_cutoff': self.context.worksheet_cutoff,
449
'show_worksheet_marks': self.context.show_worksheet_marks,
179
'url': self.context.url,
180
'description': self.context.description,
452
def save_object(self, req, data):
454
self.context.subject = data['subject']
455
self.context.semester = data['semester']
456
self.context.description = data['description']
457
self.context.url = unicode(data['url']) if data['url'] else None
458
self.context.worksheet_cutoff = data['worksheet_cutoff']
459
self.context.show_worksheet_marks = data['show_worksheet_marks']
463
class OfferingNew(BaseFormView):
464
"""A form to create an offering."""
465
template = 'templates/offering-new.html'
468
def authorize(self, req):
469
return req.user is not None and req.user.admin
473
return OfferingAdminSchema()
475
def populate(self, req, ctx):
476
super(OfferingNew, self).populate(req, ctx)
477
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
478
ctx['semesters'] = req.store.find(Semester).order_by(
479
Semester.year, Semester.display_name)
480
ctx['force_subject'] = None
482
def populate_state(self, state):
483
state.existing_offering = None
485
def get_default_data(self, req):
488
def save_object(self, req, data):
489
new_offering = Offering()
490
new_offering.subject = data['subject']
491
new_offering.semester = data['semester']
492
new_offering.description = data['description']
493
new_offering.url = unicode(data['url']) if data['url'] else None
494
new_offering.worksheet_cutoff = data['worksheet_cutoff']
495
new_offering.show_worksheet_marks = data['show_worksheet_marks']
497
req.store.add(new_offering)
500
class SubjectOfferingNew(OfferingNew):
501
"""A form to create an offering for a given subject."""
502
# Identical to OfferingNew, except it forces the subject to be the subject
504
def populate(self, req, ctx):
505
super(SubjectOfferingNew, self).populate(req, ctx)
506
ctx['force_subject'] = self.context
508
class OfferingCloneWorksheetsSchema(formencode.Schema):
509
subject = formencode.All(
510
SubjectValidator(), formencode.validators.UnicodeString())
511
semester = formencode.All(
512
SemesterValidator(), formencode.validators.UnicodeString())
515
class OfferingCloneWorksheets(BaseFormView):
516
"""A form to clone worksheets from one offering to another."""
517
template = 'templates/offering-clone-worksheets.html'
520
def authorize(self, req):
521
return req.user is not None and req.user.admin
525
return OfferingCloneWorksheetsSchema()
527
def populate(self, req, ctx):
528
super(OfferingCloneWorksheets, self).populate(req, ctx)
529
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
530
ctx['semesters'] = req.store.find(Semester).order_by(
531
Semester.year, Semester.display_name)
533
def get_default_data(self, req):
536
def save_object(self, req, data):
537
if self.context.worksheets.count() > 0:
539
"Cannot clone to target with existing worksheets.")
540
offering = req.store.find(
541
Offering, subject=data['subject'], semester=data['semester']).one()
543
raise BadRequest("No such offering.")
544
if offering.worksheets.count() == 0:
545
raise BadRequest("Source offering has no worksheets.")
547
self.context.clone_worksheets(offering)
184
ctx['data'] = data or {}
185
ctx['context'] = self.context
186
ctx['errors'] = errors
551
189
class UserValidator(formencode.FancyValidator):
640
267
ctx['data'] = data or {}
641
268
ctx['offering'] = self.context
642
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
269
ctx['roles_auth'] = self.context.get_permissions(req.user)
643
270
ctx['errors'] = errors
644
# If all of the fields validated, set the global form error.
645
if isinstance(errors, basestring):
646
ctx['error_value'] = errors
649
class EnrolmentEditSchema(formencode.Schema):
650
role = formencode.All(formencode.validators.OneOf(
651
["lecturer", "tutor", "student"]),
652
RoleEnrolmentValidator(),
653
formencode.validators.UnicodeString())
656
class EnrolmentEdit(BaseFormView):
657
"""A form to alter an enrolment's role."""
658
template = 'templates/enrolment-edit.html'
662
def populate_state(self, state):
663
state.offering = self.context.offering
665
def get_default_data(self, req):
666
return {'role': self.context.role}
670
return EnrolmentEditSchema()
672
def save_object(self, req, data):
673
self.context.role = data['role']
675
def get_return_url(self, obj):
676
return self.req.publisher.generate(
677
self.context.offering, EnrolmentsView)
679
def populate(self, req, ctx):
680
super(EnrolmentEdit, self).populate(req, ctx)
681
ctx['offering_perms'] = self.context.offering.get_permissions(
682
req.user, req.config)
685
class EnrolmentDelete(XHTMLView):
686
"""A form to alter an enrolment's role."""
687
template = 'templates/enrolment-delete.html'
691
def populate(self, req, ctx):
692
# If POSTing, delete delete delete.
693
if req.method == 'POST':
694
self.context.delete()
696
req.throw_redirect(req.publisher.generate(
697
self.context.offering, EnrolmentsView))
699
ctx['enrolment'] = self.context
702
272
class OfferingProjectsView(XHTMLView):
703
273
"""View the projects for an offering."""
704
274
template = 'templates/offering_projects.html'
705
275
permission = 'edit'
707
breadcrumb_text = 'Projects'
709
278
def populate(self, req, ctx):
710
279
self.plugin_styles[Plugin] = ["project.css"]
280
self.plugin_scripts[Plugin] = ["project.js"]
712
282
ctx['offering'] = self.context
713
283
ctx['projectsets'] = []
284
ctx['OfferingRESTView'] = OfferingRESTView
715
286
#Open the projectset Fragment, and render it for inclusion
716
287
#into the ProjectSets page
288
#XXX: This could be a lot cleaner
289
loader = genshi.template.TemplateLoader(".", auto_reload=True)
717
291
set_fragment = os.path.join(os.path.dirname(__file__),
718
292
"templates/projectset_fragment.html")
719
293
project_fragment = os.path.join(os.path.dirname(__file__),
720
294
"templates/project_fragment.html")
723
self.context.project_sets.order_by(ivle.database.ProjectSet.id):
724
settmpl = self._loader.load(set_fragment)
296
for projectset in self.context.project_sets:
297
settmpl = loader.load(set_fragment)
725
298
setCtx = Context()
726
299
setCtx['req'] = req
727
300
setCtx['projectset'] = projectset
728
301
setCtx['projects'] = []
729
302
setCtx['GroupsView'] = GroupsView
730
setCtx['ProjectSetEdit'] = ProjectSetEdit
731
setCtx['ProjectNew'] = ProjectNew
303
setCtx['ProjectSetRESTView'] = ProjectSetRESTView
734
projectset.projects.order_by(ivle.database.Project.deadline):
735
projecttmpl = self._loader.load(project_fragment)
305
for project in projectset.projects:
306
projecttmpl = loader.load(project_fragment)
736
307
projectCtx = Context()
737
308
projectCtx['req'] = req
738
309
projectCtx['project'] = project
739
projectCtx['ProjectEdit'] = ProjectEdit
740
projectCtx['ProjectDelete'] = ProjectDelete
742
311
setCtx['projects'].append(
743
312
projecttmpl.generate(projectCtx))
748
317
class ProjectView(XHTMLView):
749
318
"""View the submissions for a ProjectSet"""
750
319
template = "templates/project.html"
751
permission = "view_project_submissions"
323
def build_subversion_url(self, svnroot, submission):
324
princ = submission.assessed.principal
326
if isinstance(princ, User):
327
path = 'users/%s' % princ.login
329
path = 'groups/%s_%s_%s_%s' % (
330
princ.project_set.offering.subject.short_name,
331
princ.project_set.offering.semester.year,
332
princ.project_set.offering.semester.semester,
335
return urlparse.urljoin(
337
os.path.join(path, submission.path[1:] if
338
submission.path.startswith(os.sep) else
754
341
def populate(self, req, ctx):
755
342
self.plugin_styles[Plugin] = ["project.css"]
758
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
759
345
ctx['GroupsView'] = GroupsView
760
346
ctx['EnrolView'] = EnrolView
761
ctx['format_datetime'] = ivle.date.make_date_nice
762
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
763
ctx['project'] = self.context
764
ctx['user'] = req.user
765
ctx['ProjectEdit'] = ProjectEdit
766
ctx['ProjectDelete'] = ProjectDelete
767
ctx['ProjectExport'] = ProjectBashExportView
769
class ProjectBashExportView(TextView):
770
"""Produce a Bash script for exporting projects"""
771
template = "templates/project-export.sh"
772
content_type = "text/x-sh"
773
permission = "view_project_submissions"
775
def populate(self, req, ctx):
777
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
778
ctx['format_datetime'] = ivle.date.make_date_nice
779
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
780
ctx['project'] = self.context
781
ctx['user'] = req.user
782
ctx['now'] = datetime.datetime.now()
783
ctx['format_datetime'] = ivle.date.make_date_nice
784
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
786
class ProjectUniquenessValidator(formencode.FancyValidator):
787
"""A FormEncode validator that checks that a project short_name is unique
790
The project referenced by state.existing_project is permitted to
791
hold that short_name. If any other project holds it, the input is rejected.
793
def _to_python(self, value, state):
794
if (state.store.find(
796
Project.short_name == unicode(value),
797
Project.project_set_id == ProjectSet.id,
798
ProjectSet.offering == state.offering).one() not in
799
(None, state.existing_project)):
800
raise formencode.Invalid(
801
"A project with that URL name already exists in this offering."
805
class ProjectSchema(formencode.Schema):
806
name = formencode.validators.UnicodeString(not_empty=True)
807
short_name = formencode.All(
808
URLNameValidator(not_empty=True),
809
ProjectUniquenessValidator())
810
deadline = DateTimeValidator(not_empty=True)
811
url = formencode.validators.URL(if_missing=None, not_empty=False)
812
synopsis = formencode.validators.UnicodeString(not_empty=True)
814
class ProjectEdit(BaseFormView):
815
"""A form to edit a project."""
816
template = 'templates/project-edit.html'
822
return ProjectSchema()
824
def populate(self, req, ctx):
825
super(ProjectEdit, self).populate(req, ctx)
826
ctx['projectset'] = self.context.project_set
828
def populate_state(self, state):
829
state.offering = self.context.project_set.offering
830
state.existing_project = self.context
832
def get_default_data(self, req):
834
'name': self.context.name,
835
'short_name': self.context.short_name,
836
'deadline': self.context.deadline,
837
'url': self.context.url,
838
'synopsis': self.context.synopsis,
841
def save_object(self, req, data):
842
self.context.name = data['name']
843
self.context.short_name = data['short_name']
844
self.context.deadline = data['deadline']
845
self.context.url = unicode(data['url']) if data['url'] else None
846
self.context.synopsis = data['synopsis']
849
class ProjectNew(BaseFormView):
850
"""A form to create a new project."""
851
template = 'templates/project-new.html'
857
return ProjectSchema()
859
def populate(self, req, ctx):
860
super(ProjectNew, self).populate(req, ctx)
861
ctx['projectset'] = self.context
863
def populate_state(self, state):
864
state.offering = self.context.offering
865
state.existing_project = None
867
def get_default_data(self, req):
870
def save_object(self, req, data):
871
new_project = Project()
872
new_project.project_set = self.context
873
new_project.name = data['name']
874
new_project.short_name = data['short_name']
875
new_project.deadline = data['deadline']
876
new_project.url = unicode(data['url']) if data['url'] else None
877
new_project.synopsis = data['synopsis']
878
req.store.add(new_project)
881
class ProjectDelete(XHTMLView):
882
"""A form to delete a project."""
883
template = 'templates/project-delete.html'
887
def populate(self, req, ctx):
888
# If post, delete the project, or display a message explaining that
889
# the project cannot be deleted
890
if self.context.can_delete:
891
if req.method == 'POST':
892
self.context.delete()
893
self.template = 'templates/project-deleted.html'
896
self.template = 'templates/project-undeletable.html'
898
# If get and can delete, display a delete confirmation page
900
# Variables for the template
902
ctx['project'] = self.context
903
ctx['OfferingProjectsView'] = OfferingProjectsView
905
class ProjectSetSchema(formencode.Schema):
906
group_size = formencode.validators.Int(if_missing=None, not_empty=False)
908
class ProjectSetEdit(BaseFormView):
909
"""A form to edit a project set."""
910
template = 'templates/projectset-edit.html'
916
return ProjectSetSchema()
918
def populate(self, req, ctx):
919
super(ProjectSetEdit, self).populate(req, ctx)
921
def get_default_data(self, req):
923
'group_size': self.context.max_students_per_group,
926
def save_object(self, req, data):
927
self.context.max_students_per_group = data['group_size']
930
class ProjectSetNew(BaseFormView):
931
"""A form to create a new project set."""
932
template = 'templates/projectset-new.html'
935
breadcrumb_text = "Projects"
939
return ProjectSetSchema()
941
def populate(self, req, ctx):
942
super(ProjectSetNew, self).populate(req, ctx)
944
def get_default_data(self, req):
947
def save_object(self, req, data):
948
new_set = ProjectSet()
949
new_set.offering = self.context
950
new_set.max_students_per_group = data['group_size']
951
req.store.add(new_set)
347
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
348
ctx['build_subversion_url'] = self.build_subversion_url
349
ctx['svn_addr'] = req.config['urls']['svn_addr']
350
ctx['project'] = self.context
351
ctx['user'] = req.user
954
353
class Plugin(ViewPlugin, MediaPlugin):
955
forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
956
offering_to_project, offering_to_projectset,
957
offering_to_enrolment)
959
subject_url, semester_url, offering_url, projectset_url, project_url,
354
forward_routes = (root_to_subject, subject_to_offering,
355
offering_to_project, offering_to_projectset)
356
reverse_routes = (subject_url, offering_url, projectset_url, project_url)
962
358
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
963
(ApplicationRoot, ('subjects', '+manage'), SubjectsManage),
964
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
965
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
966
(ApplicationRoot, ('+semesters', '+new'), SemesterNew),
967
(Subject, '+index', SubjectView),
968
(Subject, '+edit', SubjectEdit),
969
(Subject, '+new-offering', SubjectOfferingNew),
970
(Semester, '+edit', SemesterEdit),
971
359
(Offering, '+index', OfferingView),
972
360
(Offering, '+edit', OfferingEdit),
973
(Offering, '+clone-worksheets', OfferingCloneWorksheets),
974
361
(Offering, ('+enrolments', '+index'), EnrolmentsView),
975
362
(Offering, ('+enrolments', '+new'), EnrolView),
976
(Enrolment, '+edit', EnrolmentEdit),
977
(Enrolment, '+delete', EnrolmentDelete),
978
363
(Offering, ('+projects', '+index'), OfferingProjectsView),
979
(Offering, ('+projects', '+new-set'), ProjectSetNew),
980
(ProjectSet, '+edit', ProjectSetEdit),
981
(ProjectSet, '+new', ProjectNew),
982
364
(Project, '+index', ProjectView),
983
(Project, '+edit', ProjectEdit),
984
(Project, '+delete', ProjectDelete),
985
(Project, ('+export', 'project-export.sh'),
986
ProjectBashExportView),
366
(Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
367
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
989
370
breadcrumbs = {Subject: SubjectBreadcrumb,
990
371
Offering: OfferingBreadcrumb,
991
372
User: UserBreadcrumb,
992
373
Project: ProjectBreadcrumb,
993
Enrolment: EnrolmentBreadcrumb,