23
23
# A sample / testing application for IVLE.
29
from ivle.webapp.base.views import XHTMLView
30
from ivle.webapp.base.plugins import BasePlugin
31
from ivle.webapp.errors import NotFound
32
from ivle.database import Subject
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
33
46
from ivle import util
49
from ivle.webapp.admin.projectservice import ProjectSetRESTView
50
from ivle.webapp.admin.publishing import (root_to_subject, root_to_semester,
51
subject_to_offering, offering_to_projectset, offering_to_project,
52
offering_to_enrolment, subject_url, semester_url, offering_url,
53
projectset_url, project_url, enrolment_url)
54
from ivle.webapp.admin.breadcrumbs import (SubjectBreadcrumb,
55
OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb,
56
ProjectsBreadcrumb, EnrolmentBreadcrumb)
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
36
62
class SubjectsView(XHTMLView):
37
63
'''The view of the list of subjects.'''
38
app_template = 'subjects.html'
41
def populate(self, req, ctx):
42
req.styles = ["media/subjects/subjects.css"]
44
enrolled_subjects = req.user.subjects
45
unenrolled_subjects = [subject for subject in
46
req.store.find(Subject)
47
if subject not in enrolled_subjects]
49
ctx['enrolled_subjects'] = []
50
ctx['other_subjects'] = []
52
req.content_type = "text/html"
53
req.write_html_head_foot = True
55
for subject in enrolled_subjects:
57
new_subj['name'] = subject.name
58
new_subj['url'] = subject.url
59
ctx['enrolled_subjects'].append(new_subj)
61
if len(unenrolled_subjects) > 0:
62
for subject in unenrolled_subjects:
64
new_subj['name'] = subject.name
65
new_subj['url'] = subject.url
66
ctx['other_subjects'].append(new_subj)
64
template = 'templates/subjects.html'
66
breadcrumb_text = "Subjects"
68
def authorize(self, req):
69
return req.user is not None
71
def populate(self, req, ctx):
73
ctx['user'] = req.user
76
for semester in req.store.find(Semester).order_by(Desc(Semester.year),
77
Desc(Semester.semester)):
79
# For admins, show all subjects in the system
80
offerings = list(semester.offerings.find())
82
offerings = [enrolment.offering for enrolment in
83
semester.enrolments.find(user=req.user)]
85
ctx['semesters'].append((semester, offerings))
88
class SubjectsManage(XHTMLView):
89
'''Subject management view.'''
90
template = 'templates/subjects-manage.html'
93
def authorize(self, req):
94
return req.user is not None and req.user.admin
96
def populate(self, req, ctx):
98
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
99
ctx['SubjectView'] = SubjectView
100
ctx['SubjectEdit'] = SubjectEdit
101
ctx['SemesterEdit'] = SemesterEdit
103
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
104
ctx['semesters'] = req.store.find(Semester).order_by(
105
Semester.year, Semester.semester)
108
class SubjectShortNameUniquenessValidator(formencode.FancyValidator):
109
"""A FormEncode validator that checks that a subject name is unused.
111
The subject referenced by state.existing_subject is permitted
112
to hold that name. If any other object holds it, the input is rejected.
114
def __init__(self, matching=None):
115
self.matching = matching
117
def _to_python(self, value, state):
118
if (state.store.find(
119
Subject, short_name=value).one() not in
120
(None, state.existing_subject)):
121
raise formencode.Invalid(
122
'Short name already taken', value, state)
126
class SubjectSchema(formencode.Schema):
127
short_name = formencode.All(
128
SubjectShortNameUniquenessValidator(),
129
URLNameValidator(not_empty=True))
130
name = formencode.validators.UnicodeString(not_empty=True)
131
code = formencode.validators.UnicodeString(not_empty=True)
134
class SubjectFormView(BaseFormView):
135
"""An abstract form to add or edit a subject."""
138
def authorize(self, req):
139
return req.user is not None and req.user.admin
141
def populate_state(self, state):
142
state.existing_subject = None
146
return SubjectSchema()
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 = URLNameValidator()
204
semester = URLNameValidator()
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']
69
267
class SubjectView(XHTMLView):
70
'''The view of a subject.'''
71
app_template = 'subject.html'
72
appname = 'subjects' # XXX
74
def __init__(self, req, subject, path):
75
self.subject = req.store.find(Subject, code=subject).one()
78
def populate(self, req, ctx):
79
if self.subject is None:
82
ctx['serve_loc'] = urllib.quote(util.make_path(os.path.join('media',
83
'subjects', self.subject.code, self.path)))
85
class Plugin(BasePlugin):
87
('subjects/', SubjectsView),
88
('subjects/:subject', SubjectView, {'path': ''}),
89
('subjects/:subject/*(path)', SubjectView),
268
'''The view of the list of offerings in a given subject.'''
269
template = 'templates/subject.html'
272
def authorize(self, req):
273
return req.user is not None
275
def populate(self, req, ctx):
276
ctx['context'] = self.context
278
ctx['user'] = req.user
279
ctx['offerings'] = list(self.context.offerings)
280
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
281
ctx['SubjectEdit'] = SubjectEdit
282
ctx['SubjectOfferingNew'] = SubjectOfferingNew
285
class OfferingView(XHTMLView):
286
"""The home page of an offering."""
287
template = 'templates/offering.html'
291
def populate(self, req, ctx):
292
# Need the worksheet result styles.
293
self.plugin_styles[TutorialPlugin] = ['tutorial.css']
294
ctx['context'] = self.context
296
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
297
ctx['format_submission_principal'] = util.format_submission_principal
298
ctx['format_datetime'] = ivle.date.make_date_nice
299
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
300
ctx['OfferingEdit'] = OfferingEdit
301
ctx['OfferingCloneWorksheets'] = OfferingCloneWorksheets
302
ctx['GroupsView'] = GroupsView
303
ctx['EnrolmentsView'] = EnrolmentsView
305
# As we go, calculate the total score for this subject
306
# (Assessable worksheets only, mandatory problems only)
308
ctx['worksheets'], problems_total, problems_done = (
309
ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
310
req.config, req.store, req.user, self.context))
312
ctx['exercises_total'] = problems_total
313
ctx['exercises_done'] = problems_done
314
if problems_total > 0:
315
if problems_done >= problems_total:
316
ctx['worksheets_complete_class'] = "complete"
317
elif problems_done > 0:
318
ctx['worksheets_complete_class'] = "semicomplete"
320
ctx['worksheets_complete_class'] = "incomplete"
321
# Calculate the final percentage and mark for the subject
322
(ctx['exercises_pct'], ctx['worksheet_mark'],
323
ctx['worksheet_max_mark']) = (
324
ivle.worksheet.utils.calculate_mark(
325
problems_done, problems_total))
328
class SubjectValidator(formencode.FancyValidator):
329
"""A FormEncode validator that turns a subject name into a subject.
331
The state must have a 'store' attribute, which is the Storm store
334
def _to_python(self, value, state):
335
subject = state.store.find(Subject, short_name=value).one()
339
raise formencode.Invalid('Subject does not exist', value, state)
342
class SemesterValidator(formencode.FancyValidator):
343
"""A FormEncode validator that turns a string into a semester.
345
The string should be of the form 'year/semester', eg. '2009/1'.
347
The state must have a 'store' attribute, which is the Storm store
350
def _to_python(self, value, state):
352
year, semester = value.split('/')
354
year = semester = None
356
semester = state.store.find(
357
Semester, year=year, semester=semester).one()
361
raise formencode.Invalid('Semester does not exist', value, state)
364
class OfferingUniquenessValidator(formencode.FancyValidator):
365
"""A FormEncode validator that checks that an offering is unique.
367
There cannot be more than one offering in the same year and semester.
369
The offering referenced by state.existing_offering is permitted to
370
hold that year and semester tuple. If any other object holds it, the
373
def _to_python(self, value, state):
374
if (state.store.find(
375
Offering, subject=value['subject'],
376
semester=value['semester']).one() not in
377
(None, state.existing_offering)):
378
raise formencode.Invalid(
379
'Offering already exists', value, state)
383
class OfferingSchema(formencode.Schema):
384
description = formencode.validators.UnicodeString(
385
if_missing=None, not_empty=False)
386
url = formencode.validators.URL(if_missing=None, not_empty=False)
387
show_worksheet_marks = formencode.validators.StringBoolean(
391
class OfferingAdminSchema(OfferingSchema):
392
subject = formencode.All(
393
SubjectValidator(), formencode.validators.UnicodeString())
394
semester = formencode.All(
395
SemesterValidator(), formencode.validators.UnicodeString())
396
chained_validators = [OfferingUniquenessValidator()]
399
class OfferingEdit(BaseFormView):
400
"""A form to edit an offering's details."""
401
template = 'templates/offering-edit.html'
407
if self.req.user.admin:
408
return OfferingAdminSchema()
410
return OfferingSchema()
412
def populate(self, req, ctx):
413
super(OfferingEdit, self).populate(req, ctx)
414
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
415
ctx['semesters'] = req.store.find(Semester).order_by(
416
Semester.year, Semester.semester)
417
ctx['force_subject'] = None
419
def populate_state(self, state):
420
state.existing_offering = self.context
422
def get_default_data(self, req):
424
'subject': self.context.subject.short_name,
425
'semester': self.context.semester.year + '/' +
426
self.context.semester.semester,
427
'url': self.context.url,
428
'description': self.context.description,
429
'show_worksheet_marks': self.context.show_worksheet_marks,
432
def save_object(self, req, data):
434
self.context.subject = data['subject']
435
self.context.semester = data['semester']
436
self.context.description = data['description']
437
self.context.url = unicode(data['url']) if data['url'] else None
438
self.context.show_worksheet_marks = data['show_worksheet_marks']
442
class OfferingNew(BaseFormView):
443
"""A form to create an offering."""
444
template = 'templates/offering-new.html'
447
def authorize(self, req):
448
return req.user is not None and req.user.admin
452
return OfferingAdminSchema()
454
def populate(self, req, ctx):
455
super(OfferingNew, self).populate(req, ctx)
456
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
457
ctx['semesters'] = req.store.find(Semester).order_by(
458
Semester.year, Semester.semester)
459
ctx['force_subject'] = None
461
def populate_state(self, state):
462
state.existing_offering = None
464
def get_default_data(self, req):
467
def save_object(self, req, data):
468
new_offering = Offering()
469
new_offering.subject = data['subject']
470
new_offering.semester = data['semester']
471
new_offering.description = data['description']
472
new_offering.url = unicode(data['url']) if data['url'] else None
473
new_offering.show_worksheet_marks = data['show_worksheet_marks']
475
req.store.add(new_offering)
478
class SubjectOfferingNew(OfferingNew):
479
"""A form to create an offering for a given subject."""
480
# Identical to OfferingNew, except it forces the subject to be the subject
482
def populate(self, req, ctx):
483
super(SubjectOfferingNew, self).populate(req, ctx)
484
ctx['force_subject'] = self.context
486
class OfferingCloneWorksheetsSchema(formencode.Schema):
487
subject = formencode.All(
488
SubjectValidator(), formencode.validators.UnicodeString())
489
semester = formencode.All(
490
SemesterValidator(), formencode.validators.UnicodeString())
493
class OfferingCloneWorksheets(BaseFormView):
494
"""A form to clone worksheets from one offering to another."""
495
template = 'templates/offering-clone-worksheets.html'
498
def authorize(self, req):
499
return req.user is not None and req.user.admin
503
return OfferingCloneWorksheetsSchema()
505
def populate(self, req, ctx):
506
super(OfferingCloneWorksheets, self).populate(req, ctx)
507
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
508
ctx['semesters'] = req.store.find(Semester).order_by(
509
Semester.year, Semester.semester)
511
def get_default_data(self, req):
514
def save_object(self, req, data):
515
if self.context.worksheets.count() > 0:
517
"Cannot clone to target with existing worksheets.")
518
offering = req.store.find(
519
Offering, subject=data['subject'], semester=data['semester']).one()
521
raise BadRequest("No such offering.")
522
if offering.worksheets.count() == 0:
523
raise BadRequest("Source offering has no worksheets.")
525
self.context.clone_worksheets(offering)
529
class UserValidator(formencode.FancyValidator):
530
"""A FormEncode validator that turns a username into a user.
532
The state must have a 'store' attribute, which is the Storm store
534
def _to_python(self, value, state):
535
user = User.get_by_login(state.store, value)
539
raise formencode.Invalid('User does not exist', value, state)
542
class NoEnrolmentValidator(formencode.FancyValidator):
543
"""A FormEncode validator that ensures absence of an enrolment.
545
The state must have an 'offering' attribute.
547
def _to_python(self, value, state):
548
if state.offering.get_enrolment(value):
549
raise formencode.Invalid('User already enrolled', value, state)
553
class RoleEnrolmentValidator(formencode.FancyValidator):
554
"""A FormEncode validator that checks permission to enrol users with a
557
The state must have an 'offering' attribute.
559
def _to_python(self, value, state):
560
if (("enrol_" + value) not in
561
state.offering.get_permissions(state.user, state.config)):
562
raise formencode.Invalid('Not allowed to assign users that role',
567
class EnrolSchema(formencode.Schema):
568
user = formencode.All(NoEnrolmentValidator(), UserValidator())
569
role = formencode.All(formencode.validators.OneOf(
570
["lecturer", "tutor", "student"]),
571
RoleEnrolmentValidator(),
572
formencode.validators.UnicodeString())
575
class EnrolmentsView(XHTMLView):
576
"""A page which displays all users enrolled in an offering."""
577
template = 'templates/enrolments.html'
580
breadcrumb_text = 'Enrolments'
582
def populate(self, req, ctx):
584
ctx['offering'] = self.context
585
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
586
ctx['offering_perms'] = self.context.get_permissions(
587
req.user, req.config)
588
ctx['EnrolView'] = EnrolView
589
ctx['EnrolmentEdit'] = EnrolmentEdit
590
ctx['EnrolmentDelete'] = EnrolmentDelete
593
class EnrolView(XHTMLView):
594
"""A form to enrol a user in an offering."""
595
template = 'templates/enrol.html'
599
def filter(self, stream, ctx):
600
return stream | HTMLFormFiller(data=ctx['data'])
602
def populate(self, req, ctx):
603
if req.method == 'POST':
604
data = dict(req.get_fieldstorage())
606
validator = EnrolSchema()
607
req.offering = self.context # XXX: Getting into state.
608
data = validator.to_python(data, state=req)
609
self.context.enrol(data['user'], data['role'])
611
req.throw_redirect(req.uri)
612
except formencode.Invalid, e:
613
errors = e.unpack_errors()
618
ctx['data'] = data or {}
619
ctx['offering'] = self.context
620
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
621
ctx['errors'] = errors
622
# If all of the fields validated, set the global form error.
623
if isinstance(errors, basestring):
624
ctx['error_value'] = errors
627
class EnrolmentEditSchema(formencode.Schema):
628
role = formencode.All(formencode.validators.OneOf(
629
["lecturer", "tutor", "student"]),
630
RoleEnrolmentValidator(),
631
formencode.validators.UnicodeString())
634
class EnrolmentEdit(BaseFormView):
635
"""A form to alter an enrolment's role."""
636
template = 'templates/enrolment-edit.html'
640
def populate_state(self, state):
641
state.offering = self.context.offering
643
def get_default_data(self, req):
644
return {'role': self.context.role}
648
return EnrolmentEditSchema()
650
def save_object(self, req, data):
651
self.context.role = data['role']
653
def get_return_url(self, obj):
654
return self.req.publisher.generate(
655
self.context.offering, EnrolmentsView)
657
def populate(self, req, ctx):
658
super(EnrolmentEdit, self).populate(req, ctx)
659
ctx['offering_perms'] = self.context.offering.get_permissions(
660
req.user, req.config)
663
class EnrolmentDelete(XHTMLView):
664
"""A form to alter an enrolment's role."""
665
template = 'templates/enrolment-delete.html'
669
def populate(self, req, ctx):
670
# If POSTing, delete delete delete.
671
if req.method == 'POST':
672
self.context.delete()
674
req.throw_redirect(req.publisher.generate(
675
self.context.offering, EnrolmentsView))
677
ctx['enrolment'] = self.context
680
class OfferingProjectsView(XHTMLView):
681
"""View the projects for an offering."""
682
template = 'templates/offering_projects.html'
685
breadcrumb_text = 'Projects'
687
def populate(self, req, ctx):
688
self.plugin_styles[Plugin] = ["project.css"]
689
self.plugin_scripts[Plugin] = ["project.js"]
691
ctx['offering'] = self.context
692
ctx['projectsets'] = []
694
#Open the projectset Fragment, and render it for inclusion
695
#into the ProjectSets page
696
#XXX: This could be a lot cleaner
697
loader = genshi.template.TemplateLoader(".", auto_reload=True)
699
set_fragment = os.path.join(os.path.dirname(__file__),
700
"templates/projectset_fragment.html")
701
project_fragment = os.path.join(os.path.dirname(__file__),
702
"templates/project_fragment.html")
704
for projectset in self.context.project_sets:
705
settmpl = loader.load(set_fragment)
708
setCtx['projectset'] = projectset
709
setCtx['projects'] = []
710
setCtx['GroupsView'] = GroupsView
711
setCtx['ProjectSetEdit'] = ProjectSetEdit
712
setCtx['ProjectSetRESTView'] = ProjectSetRESTView
714
for project in projectset.projects:
715
projecttmpl = loader.load(project_fragment)
716
projectCtx = Context()
717
projectCtx['req'] = req
718
projectCtx['project'] = project
720
setCtx['projects'].append(
721
projecttmpl.generate(projectCtx))
723
ctx['projectsets'].append(settmpl.generate(setCtx))
726
class ProjectView(XHTMLView):
727
"""View the submissions for a ProjectSet"""
728
template = "templates/project.html"
729
permission = "view_project_submissions"
732
def build_subversion_url(self, svnroot, submission):
733
princ = submission.assessed.principal
735
if isinstance(princ, User):
736
path = 'users/%s' % princ.login
738
path = 'groups/%s_%s_%s_%s' % (
739
princ.project_set.offering.subject.short_name,
740
princ.project_set.offering.semester.year,
741
princ.project_set.offering.semester.semester,
744
return urlparse.urljoin(
746
os.path.join(path, submission.path[1:] if
747
submission.path.startswith(os.sep) else
750
def populate(self, req, ctx):
751
self.plugin_styles[Plugin] = ["project.css"]
754
ctx['GroupsView'] = GroupsView
755
ctx['EnrolView'] = EnrolView
756
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
757
ctx['build_subversion_url'] = self.build_subversion_url
758
ctx['svn_addr'] = req.config['urls']['svn_addr']
759
ctx['project'] = self.context
760
ctx['user'] = req.user
762
class ProjectSetSchema(formencode.Schema):
763
group_size = formencode.validators.Int(if_missing=None, not_empty=False)
765
class ProjectSetEdit(BaseFormView):
766
"""A form to edit a project set."""
767
template = 'templates/projectset-edit.html'
773
return ProjectSetSchema()
775
def populate(self, req, ctx):
776
super(ProjectSetEdit, self).populate(req, ctx)
778
def get_default_data(self, req):
780
'group_size': self.context.max_students_per_group,
783
def save_object(self, req, data):
784
self.context.max_students_per_group = data['group_size']
787
class ProjectSetNew(BaseFormView):
788
"""A form to create a new project set."""
789
template = 'templates/projectset-new.html'
792
breadcrumb_text = "Projects"
796
return ProjectSetSchema()
798
def populate(self, req, ctx):
799
super(ProjectSetNew, self).populate(req, ctx)
801
def get_default_data(self, req):
804
def save_object(self, req, data):
805
new_set = ProjectSet()
806
new_set.offering = self.context
807
new_set.max_students_per_group = data['group_size']
808
req.store.add(new_set)
811
class Plugin(ViewPlugin, MediaPlugin):
812
forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
813
offering_to_project, offering_to_projectset,
814
offering_to_enrolment)
816
subject_url, semester_url, offering_url, projectset_url, project_url,
819
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
820
(ApplicationRoot, ('subjects', '+manage'), SubjectsManage),
821
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
822
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
823
(ApplicationRoot, ('+semesters', '+new'), SemesterNew),
824
(Subject, '+index', SubjectView),
825
(Subject, '+edit', SubjectEdit),
826
(Subject, '+new-offering', SubjectOfferingNew),
827
(Semester, '+edit', SemesterEdit),
828
(Offering, '+index', OfferingView),
829
(Offering, '+edit', OfferingEdit),
830
(Offering, '+clone-worksheets', OfferingCloneWorksheets),
831
(Offering, ('+enrolments', '+index'), EnrolmentsView),
832
(Offering, ('+enrolments', '+new'), EnrolView),
833
(Enrolment, '+edit', EnrolmentEdit),
834
(Enrolment, '+delete', EnrolmentDelete),
835
(Offering, ('+projects', '+index'), OfferingProjectsView),
836
(Offering, ('+projects', '+new-set'), ProjectSetNew),
837
(ProjectSet, '+edit', ProjectSetEdit),
838
(Project, '+index', ProjectView),
840
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
843
breadcrumbs = {Subject: SubjectBreadcrumb,
844
Offering: OfferingBreadcrumb,
845
User: UserBreadcrumb,
846
Project: ProjectBreadcrumb,
847
Enrolment: EnrolmentBreadcrumb,
851
('subjects', 'Subjects',
852
'View subject content and complete worksheets',
853
'subjects.png', 'subjects', 5)
856
media = 'subject-media'