~azzar1/unity/add-show-desktop-key

« back to all changes in this revision

Viewing changes to ivle/webapp/admin/subject.py

  • Committer: Matt Giuca
  • Date: 2010-02-24 01:05:33 UTC
  • Revision ID: matt.giuca@gmail.com-20100224010533-ccr0d5fnp2k0qoyv
Removed ivle/webapp/admin/templates/subject.html, an empty template. Offeringservice no longer depends on it (it was the default template for that view, but never used).

Show diffs side-by-side

added added

removed removed

Lines of Context:
35
35
import formencode
36
36
import formencode.validators
37
37
 
 
38
from ivle.webapp.base.forms import BaseFormView, URLNameValidator
 
39
from ivle.webapp.base.plugins import ViewPlugin, MediaPlugin
38
40
from ivle.webapp.base.xhtml import XHTMLView
39
 
from ivle.webapp.base.plugins import ViewPlugin, MediaPlugin
 
41
from ivle.webapp.errors import BadRequest
40
42
from ivle.webapp import ApplicationRoot
41
43
 
42
44
from ivle.database import Subject, Semester, Offering, Enrolment, User,\
46
48
 
47
49
from ivle.webapp.admin.projectservice import ProjectSetRESTView
48
50
from ivle.webapp.admin.offeringservice import OfferingRESTView
49
 
from ivle.webapp.admin.publishing import (root_to_subject,
 
51
from ivle.webapp.admin.publishing import (root_to_subject, root_to_semester,
50
52
            subject_to_offering, offering_to_projectset, offering_to_project,
51
 
            subject_url, offering_url, projectset_url, project_url)
 
53
            offering_to_enrolment, subject_url, semester_url, offering_url,
 
54
            projectset_url, project_url, enrolment_url)
52
55
from ivle.webapp.admin.breadcrumbs import (SubjectBreadcrumb,
53
 
            OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb)
 
56
            OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb,
 
57
            EnrolmentBreadcrumb)
54
58
from ivle.webapp.core import Plugin as CorePlugin
55
59
from ivle.webapp.groups import GroupsView
56
60
from ivle.webapp.media import media_url
68
72
        ctx['req'] = req
69
73
        ctx['user'] = req.user
70
74
        ctx['semesters'] = []
71
 
        ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
72
 
        ctx['SubjectEdit'] = SubjectEdit
73
75
 
74
76
        for semester in req.store.find(Semester).order_by(Desc(Semester.year),
75
77
                                                     Desc(Semester.semester)):
82
84
            if len(offerings):
83
85
                ctx['semesters'].append((semester, offerings))
84
86
 
85
 
        # Admins get a separate list of subjects so they can add/edit.
86
 
        if req.user.admin:
87
 
            ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
 
87
 
 
88
class SubjectsManage(XHTMLView):
 
89
    '''Subject management view.'''
 
90
    template = 'templates/subjects-manage.html'
 
91
    tab = 'subjects'
 
92
 
 
93
    def authorize(self, req):
 
94
        return req.user is not None and req.user.admin
 
95
 
 
96
    def populate(self, req, ctx):
 
97
        ctx['req'] = req
 
98
        ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
 
99
        ctx['SubjectEdit'] = SubjectEdit
 
100
        ctx['SemesterEdit'] = SemesterEdit
 
101
 
 
102
        ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
 
103
        ctx['semesters'] = req.store.find(Semester).order_by(
 
104
            Semester.year, Semester.semester)
88
105
 
89
106
 
90
107
class SubjectShortNameUniquenessValidator(formencode.FancyValidator):
108
125
class SubjectSchema(formencode.Schema):
109
126
    short_name = formencode.All(
110
127
        SubjectShortNameUniquenessValidator(),
111
 
        formencode.validators.UnicodeString(not_empty=True))
 
128
        URLNameValidator(not_empty=True))
112
129
    name = formencode.validators.UnicodeString(not_empty=True)
113
130
    code = formencode.validators.UnicodeString(not_empty=True)
114
131
 
115
132
 
116
 
class SubjectFormView(XHTMLView):
 
133
class SubjectFormView(BaseFormView):
117
134
    """An abstract form to add or edit a subject."""
118
135
    tab = 'subjects'
119
136
 
120
137
    def authorize(self, req):
121
138
        return req.user is not None and req.user.admin
122
139
 
123
 
    def filter(self, stream, ctx):
124
 
        return stream | HTMLFormFiller(data=ctx['data'])
125
 
 
126
140
    def populate_state(self, state):
127
141
        state.existing_subject = None
128
142
 
129
 
    def populate(self, req, ctx):
130
 
        if req.method == 'POST':
131
 
            data = dict(req.get_fieldstorage())
132
 
            try:
133
 
                validator = SubjectSchema()
134
 
                self.populate_state(req)
135
 
                data = validator.to_python(data, state=req)
136
 
 
137
 
                subject = self.update_subject_object(req, data)
138
 
 
139
 
                req.store.commit()
140
 
                req.throw_redirect(req.publisher.generate(subject))
141
 
            except formencode.Invalid, e:
142
 
                errors = e.unpack_errors()
143
 
        else:
144
 
            data = self.get_default_data(req)
145
 
            errors = {}
146
 
 
147
 
        if errors:
148
 
            req.store.rollback()
149
 
 
150
 
        ctx['context'] = self.context
151
 
        ctx['data'] = data or {}
152
 
        ctx['errors'] = errors
 
143
    @property
 
144
    def validator(self):
 
145
        return SubjectSchema()
 
146
 
 
147
    def get_return_url(self, obj):
 
148
        return '/subjects'
153
149
 
154
150
 
155
151
class SubjectNew(SubjectFormView):
156
152
    """A form to create a subject."""
157
153
    template = 'templates/subject-new.html'
158
154
 
159
 
    def populate_state(self, state):
160
 
        state.existing_subject = self.context
161
 
 
162
155
    def get_default_data(self, req):
163
156
        return {}
164
157
 
165
 
    def update_subject_object(self, req, data):
 
158
    def save_object(self, req, data):
166
159
        new_subject = Subject()
167
160
        new_subject.short_name = data['short_name']
168
161
        new_subject.name = data['name']
186
179
            'code': self.context.code,
187
180
            }
188
181
 
189
 
    def update_subject_object(self, req, data):
 
182
    def save_object(self, req, data):
190
183
        self.context.short_name = data['short_name']
191
184
        self.context.name = data['name']
192
185
        self.context.code = data['code']
194
187
        return self.context
195
188
 
196
189
 
 
190
class SemesterUniquenessValidator(formencode.FancyValidator):
 
191
    """A FormEncode validator that checks that a semester is unique.
 
192
 
 
193
    There cannot be more than one semester for the same year and semester.
 
194
    """
 
195
    def _to_python(self, value, state):
 
196
        if (state.store.find(
 
197
                Semester, year=value['year'], semester=value['semester']
 
198
                ).one() not in (None, state.existing_semester)):
 
199
            raise formencode.Invalid(
 
200
                'Semester already exists', value, state)
 
201
        return value
 
202
 
 
203
 
 
204
class SemesterSchema(formencode.Schema):
 
205
    year = URLNameValidator()
 
206
    semester = URLNameValidator()
 
207
    state = formencode.All(
 
208
        formencode.validators.OneOf(["past", "current", "future"]),
 
209
        formencode.validators.UnicodeString())
 
210
    chained_validators = [SemesterUniquenessValidator()]
 
211
 
 
212
 
 
213
class SemesterFormView(BaseFormView):
 
214
    tab = 'subjects'
 
215
 
 
216
    def authorize(self, req):
 
217
        return req.user is not None and req.user.admin
 
218
 
 
219
    @property
 
220
    def validator(self):
 
221
        return SemesterSchema()
 
222
 
 
223
    def get_return_url(self, obj):
 
224
        return '/subjects/+manage'
 
225
 
 
226
 
 
227
class SemesterNew(SemesterFormView):
 
228
    """A form to create a semester."""
 
229
    template = 'templates/semester-new.html'
 
230
    tab = 'subjects'
 
231
 
 
232
    def populate_state(self, state):
 
233
        state.existing_semester = None
 
234
 
 
235
    def get_default_data(self, req):
 
236
        return {}
 
237
 
 
238
    def save_object(self, req, data):
 
239
        new_semester = Semester()
 
240
        new_semester.year = data['year']
 
241
        new_semester.semester = data['semester']
 
242
        new_semester.state = data['state']
 
243
 
 
244
        req.store.add(new_semester)
 
245
        return new_semester
 
246
 
 
247
 
 
248
class SemesterEdit(SemesterFormView):
 
249
    """A form to edit a semester."""
 
250
    template = 'templates/semester-edit.html'
 
251
 
 
252
    def populate_state(self, state):
 
253
        state.existing_semester = self.context
 
254
 
 
255
    def get_default_data(self, req):
 
256
        return {
 
257
            'year': self.context.year,
 
258
            'semester': self.context.semester,
 
259
            'state': self.context.state,
 
260
            }
 
261
 
 
262
    def save_object(self, req, data):
 
263
        self.context.year = data['year']
 
264
        self.context.semester = data['semester']
 
265
        self.context.state = data['state']
 
266
 
 
267
        return self.context
 
268
 
 
269
 
197
270
class OfferingView(XHTMLView):
198
271
    """The home page of an offering."""
199
272
    template = 'templates/offering.html'
205
278
        self.plugin_styles[TutorialPlugin] = ['tutorial.css']
206
279
        ctx['context'] = self.context
207
280
        ctx['req'] = req
208
 
        ctx['permissions'] = self.context.get_permissions(req.user)
 
281
        ctx['permissions'] = self.context.get_permissions(req.user,req.config)
209
282
        ctx['format_submission_principal'] = util.format_submission_principal
210
283
        ctx['format_datetime'] = ivle.date.make_date_nice
211
284
        ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
212
285
        ctx['OfferingEdit'] = OfferingEdit
 
286
        ctx['OfferingCloneWorksheets'] = OfferingCloneWorksheets
 
287
        ctx['GroupsView'] = GroupsView
 
288
        ctx['EnrolmentsView'] = EnrolmentsView
213
289
 
214
290
        # As we go, calculate the total score for this subject
215
291
        # (Assessable worksheets only, mandatory problems only)
234
310
                    problems_done, problems_total))
235
311
 
236
312
 
 
313
class SubjectValidator(formencode.FancyValidator):
 
314
    """A FormEncode validator that turns a subject name into a subject.
 
315
 
 
316
    The state must have a 'store' attribute, which is the Storm store
 
317
    to use.
 
318
    """
 
319
    def _to_python(self, value, state):
 
320
        subject = state.store.find(Subject, short_name=value).one()
 
321
        if subject:
 
322
            return subject
 
323
        else:
 
324
            raise formencode.Invalid('Subject does not exist', value, state)
 
325
 
 
326
 
 
327
class SemesterValidator(formencode.FancyValidator):
 
328
    """A FormEncode validator that turns a string into a semester.
 
329
 
 
330
    The string should be of the form 'year/semester', eg. '2009/1'.
 
331
 
 
332
    The state must have a 'store' attribute, which is the Storm store
 
333
    to use.
 
334
    """
 
335
    def _to_python(self, value, state):
 
336
        try:
 
337
            year, semester = value.split('/')
 
338
        except ValueError:
 
339
            year = semester = None
 
340
 
 
341
        semester = state.store.find(
 
342
            Semester, year=year, semester=semester).one()
 
343
        if semester:
 
344
            return semester
 
345
        else:
 
346
            raise formencode.Invalid('Semester does not exist', value, state)
 
347
 
 
348
 
 
349
class OfferingUniquenessValidator(formencode.FancyValidator):
 
350
    """A FormEncode validator that checks that an offering is unique.
 
351
 
 
352
    There cannot be more than one offering in the same year and semester.
 
353
 
 
354
    The offering referenced by state.existing_offering is permitted to
 
355
    hold that year and semester tuple. If any other object holds it, the
 
356
    input is rejected.
 
357
    """
 
358
    def _to_python(self, value, state):
 
359
        if (state.store.find(
 
360
                Offering, subject=value['subject'],
 
361
                semester=value['semester']).one() not in
 
362
                (None, state.existing_offering)):
 
363
            raise formencode.Invalid(
 
364
                'Offering already exists', value, state)
 
365
        return value
 
366
 
 
367
 
237
368
class OfferingSchema(formencode.Schema):
238
369
    description = formencode.validators.UnicodeString(
239
370
        if_missing=None, not_empty=False)
240
371
    url = formencode.validators.URL(if_missing=None, not_empty=False)
241
372
 
242
373
 
243
 
class OfferingEdit(XHTMLView):
 
374
class OfferingAdminSchema(OfferingSchema):
 
375
    subject = formencode.All(
 
376
        SubjectValidator(), formencode.validators.UnicodeString())
 
377
    semester = formencode.All(
 
378
        SemesterValidator(), formencode.validators.UnicodeString())
 
379
    chained_validators = [OfferingUniquenessValidator()]
 
380
 
 
381
 
 
382
class OfferingEdit(BaseFormView):
244
383
    """A form to edit an offering's details."""
245
384
    template = 'templates/offering-edit.html'
246
385
    tab = 'subjects'
247
386
    permission = 'edit'
248
387
 
249
 
    def filter(self, stream, ctx):
250
 
        return stream | HTMLFormFiller(data=ctx['data'])
251
 
 
252
 
    def populate(self, req, ctx):
253
 
        if req.method == 'POST':
254
 
            data = dict(req.get_fieldstorage())
255
 
            try:
256
 
                validator = OfferingSchema()
257
 
                data = validator.to_python(data, state=req)
258
 
 
259
 
                self.context.url = unicode(data['url']) if data['url'] else None
260
 
                self.context.description = data['description']
261
 
                req.store.commit()
262
 
                req.throw_redirect(req.publisher.generate(self.context))
263
 
            except formencode.Invalid, e:
264
 
                errors = e.unpack_errors()
 
388
    @property
 
389
    def validator(self):
 
390
        if self.req.user.admin:
 
391
            return OfferingAdminSchema()
265
392
        else:
266
 
            data = {
267
 
                'url': self.context.url,
268
 
                'description': self.context.description,
 
393
            return OfferingSchema()
 
394
 
 
395
    def populate(self, req, ctx):
 
396
        super(OfferingEdit, self).populate(req, ctx)
 
397
        ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
 
398
        ctx['semesters'] = req.store.find(Semester).order_by(
 
399
            Semester.year, Semester.semester)
 
400
 
 
401
    def populate_state(self, state):
 
402
        state.existing_offering = self.context
 
403
 
 
404
    def get_default_data(self, req):
 
405
        return {
 
406
            'subject': self.context.subject.short_name,
 
407
            'semester': self.context.semester.year + '/' +
 
408
                        self.context.semester.semester,
 
409
            'url': self.context.url,
 
410
            'description': self.context.description,
269
411
            }
270
 
            errors = {}
271
 
 
272
 
        ctx['data'] = data or {}
273
 
        ctx['context'] = self.context
274
 
        ctx['errors'] = errors
 
412
 
 
413
    def save_object(self, req, data):
 
414
        if req.user.admin:
 
415
            self.context.subject = data['subject']
 
416
            self.context.semester = data['semester']
 
417
        self.context.description = data['description']
 
418
        self.context.url = unicode(data['url']) if data['url'] else None
 
419
        return self.context
 
420
 
 
421
 
 
422
class OfferingNew(BaseFormView):
 
423
    """A form to create an offering."""
 
424
    template = 'templates/offering-new.html'
 
425
    tab = 'subjects'
 
426
 
 
427
    def authorize(self, req):
 
428
        return req.user is not None and req.user.admin
 
429
 
 
430
    @property
 
431
    def validator(self):
 
432
        return OfferingAdminSchema()
 
433
 
 
434
    def populate(self, req, ctx):
 
435
        super(OfferingNew, self).populate(req, ctx)
 
436
        ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
 
437
        ctx['semesters'] = req.store.find(Semester).order_by(
 
438
            Semester.year, Semester.semester)
 
439
 
 
440
    def populate_state(self, state):
 
441
        state.existing_offering = None
 
442
 
 
443
    def get_default_data(self, req):
 
444
        return {}
 
445
 
 
446
    def save_object(self, req, data):
 
447
        new_offering = Offering()
 
448
        new_offering.subject = data['subject']
 
449
        new_offering.semester = data['semester']
 
450
        new_offering.description = data['description']
 
451
        new_offering.url = unicode(data['url']) if data['url'] else None
 
452
 
 
453
        req.store.add(new_offering)
 
454
        return new_offering
 
455
 
 
456
 
 
457
class OfferingCloneWorksheetsSchema(formencode.Schema):
 
458
    subject = formencode.All(
 
459
        SubjectValidator(), formencode.validators.UnicodeString())
 
460
    semester = formencode.All(
 
461
        SemesterValidator(), formencode.validators.UnicodeString())
 
462
 
 
463
 
 
464
class OfferingCloneWorksheets(BaseFormView):
 
465
    """A form to clone worksheets from one offering to another."""
 
466
    template = 'templates/offering-clone-worksheets.html'
 
467
    tab = 'subjects'
 
468
 
 
469
    def authorize(self, req):
 
470
        return req.user is not None and req.user.admin
 
471
 
 
472
    @property
 
473
    def validator(self):
 
474
        return OfferingCloneWorksheetsSchema()
 
475
 
 
476
    def populate(self, req, ctx):
 
477
        super(OfferingCloneWorksheets, self).populate(req, ctx)
 
478
        ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
 
479
        ctx['semesters'] = req.store.find(Semester).order_by(
 
480
            Semester.year, Semester.semester)
 
481
 
 
482
    def get_default_data(self, req):
 
483
        return {}
 
484
 
 
485
    def save_object(self, req, data):
 
486
        if self.context.worksheets.count() > 0:
 
487
            raise BadRequest(
 
488
                "Cannot clone to target with existing worksheets.")
 
489
        offering = req.store.find(
 
490
            Offering, subject=data['subject'], semester=data['semester']).one()
 
491
        if offering is None:
 
492
            raise BadRequest("No such offering.")
 
493
        if offering.worksheets.count() == 0:
 
494
            raise BadRequest("Source offering has no worksheets.")
 
495
 
 
496
        self.context.clone_worksheets(offering)
 
497
        return self.context
275
498
 
276
499
 
277
500
class UserValidator(formencode.FancyValidator):
305
528
    The state must have an 'offering' attribute.
306
529
    """
307
530
    def _to_python(self, value, state):
308
 
        if ("enrol_" + value) not in state.offering.get_permissions(state.user):
 
531
        if (("enrol_" + value) not in
 
532
                state.offering.get_permissions(state.user, state.config)):
309
533
            raise formencode.Invalid('Not allowed to assign users that role',
310
534
                                     value, state)
311
535
        return value
324
548
    template = 'templates/enrolments.html'
325
549
    tab = 'subjects'
326
550
    permission = 'edit'
 
551
    breadcrumb_text = 'Enrolments'
327
552
 
328
553
    def populate(self, req, ctx):
 
554
        ctx['req'] = req
329
555
        ctx['offering'] = self.context
 
556
        ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
 
557
        ctx['offering_perms'] = self.context.get_permissions(
 
558
            req.user, req.config)
 
559
        ctx['EnrolView'] = EnrolView
 
560
        ctx['EnrolmentEdit'] = EnrolmentEdit
 
561
        ctx['EnrolmentDelete'] = EnrolmentDelete
 
562
 
330
563
 
331
564
class EnrolView(XHTMLView):
332
565
    """A form to enrol a user in an offering."""
355
588
 
356
589
        ctx['data'] = data or {}
357
590
        ctx['offering'] = self.context
358
 
        ctx['roles_auth'] = self.context.get_permissions(req.user)
 
591
        ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
359
592
        ctx['errors'] = errors
360
593
 
 
594
 
 
595
class EnrolmentEditSchema(formencode.Schema):
 
596
    role = formencode.All(formencode.validators.OneOf(
 
597
                                ["lecturer", "tutor", "student"]),
 
598
                          RoleEnrolmentValidator(),
 
599
                          formencode.validators.UnicodeString())
 
600
 
 
601
 
 
602
class EnrolmentEdit(BaseFormView):
 
603
    """A form to alter an enrolment's role."""
 
604
    template = 'templates/enrolment-edit.html'
 
605
    tab = 'subjects'
 
606
    permission = 'edit'
 
607
 
 
608
    def populate_state(self, state):
 
609
        state.offering = self.context.offering
 
610
 
 
611
    def get_default_data(self, req):
 
612
        return {'role': self.context.role}
 
613
 
 
614
    @property
 
615
    def validator(self):
 
616
        return EnrolmentEditSchema()
 
617
 
 
618
    def save_object(self, req, data):
 
619
        self.context.role = data['role']
 
620
 
 
621
    def get_return_url(self, obj):
 
622
        return self.req.publisher.generate(
 
623
            self.context.offering, EnrolmentsView)
 
624
 
 
625
    def populate(self, req, ctx):
 
626
        super(EnrolmentEdit, self).populate(req, ctx)
 
627
        ctx['offering_perms'] = self.context.offering.get_permissions(
 
628
            req.user, req.config)
 
629
 
 
630
 
 
631
class EnrolmentDelete(XHTMLView):
 
632
    """A form to alter an enrolment's role."""
 
633
    template = 'templates/enrolment-delete.html'
 
634
    tab = 'subjects'
 
635
    permission = 'edit'
 
636
 
 
637
    def populate(self, req, ctx):
 
638
        # If POSTing, delete delete delete.
 
639
        if req.method == 'POST':
 
640
            self.context.delete()
 
641
            req.store.commit()
 
642
            req.throw_redirect(req.publisher.generate(
 
643
                self.context.offering, EnrolmentsView))
 
644
 
 
645
        ctx['enrolment'] = self.context
 
646
 
 
647
 
361
648
class OfferingProjectsView(XHTMLView):
362
649
    """View the projects for an offering."""
363
650
    template = 'templates/offering_projects.html'
364
651
    permission = 'edit'
365
652
    tab = 'subjects'
 
653
    breadcrumb_text = 'Projects'
366
654
 
367
655
    def populate(self, req, ctx):
368
656
        self.plugin_styles[Plugin] = ["project.css"]
406
694
class ProjectView(XHTMLView):
407
695
    """View the submissions for a ProjectSet"""
408
696
    template = "templates/project.html"
409
 
    permission = "edit"
 
697
    permission = "view_project_submissions"
410
698
    tab = 'subjects'
411
699
 
412
700
    def build_subversion_url(self, svnroot, submission):
440
728
        ctx['user'] = req.user
441
729
 
442
730
class Plugin(ViewPlugin, MediaPlugin):
443
 
    forward_routes = (root_to_subject, subject_to_offering,
444
 
                      offering_to_project, offering_to_projectset)
445
 
    reverse_routes = (subject_url, offering_url, projectset_url, project_url)
 
731
    forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
 
732
                      offering_to_project, offering_to_projectset,
 
733
                      offering_to_enrolment)
 
734
    reverse_routes = (
 
735
        subject_url, semester_url, offering_url, projectset_url, project_url,
 
736
        enrolment_url)
446
737
 
447
738
    views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
 
739
             (ApplicationRoot, ('subjects', '+manage'), SubjectsManage),
448
740
             (ApplicationRoot, ('subjects', '+new'), SubjectNew),
 
741
             (ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
 
742
             (ApplicationRoot, ('+semesters', '+new'), SemesterNew),
449
743
             (Subject, '+edit', SubjectEdit),
 
744
             (Semester, '+edit', SemesterEdit),
450
745
             (Offering, '+index', OfferingView),
451
746
             (Offering, '+edit', OfferingEdit),
 
747
             (Offering, '+clone-worksheets', OfferingCloneWorksheets),
452
748
             (Offering, ('+enrolments', '+index'), EnrolmentsView),
453
749
             (Offering, ('+enrolments', '+new'), EnrolView),
 
750
             (Enrolment, '+edit', EnrolmentEdit),
 
751
             (Enrolment, '+delete', EnrolmentDelete),
454
752
             (Offering, ('+projects', '+index'), OfferingProjectsView),
455
753
             (Project, '+index', ProjectView),
456
754
 
462
760
                   Offering: OfferingBreadcrumb,
463
761
                   User: UserBreadcrumb,
464
762
                   Project: ProjectBreadcrumb,
 
763
                   Enrolment: EnrolmentBreadcrumb,
465
764
                   }
466
765
 
467
766
    tabs = [