~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-12 03:19:48 UTC
  • Revision ID: matt.giuca@gmail.com-20100212031948-4co7z01d5lqk2oa6
Removed 'edit' permission on offerings for tutors, as per Bug #520232. This prevents tutors from editing offering details, projects or worksheets. Note that we *do* want tutors to edit worksheets. I'm about to add that back, as a separate permission.

Show diffs side-by-side

added added

removed removed

Lines of Context:
23
23
# A sample / testing application for IVLE.
24
24
 
25
25
import os
 
26
import os.path
26
27
import urllib
 
28
import urlparse
27
29
import cgi
28
30
 
29
 
import ivle.database
 
31
from storm.locals import Desc, Store
 
32
import genshi
 
33
from genshi.filters import HTMLFormFiller
 
34
from genshi.template import Context, TemplateLoader
 
35
import formencode
 
36
import formencode.validators
 
37
 
 
38
from ivle.webapp.base.forms import BaseFormView
 
39
from ivle.webapp.base.plugins import ViewPlugin, MediaPlugin
 
40
from ivle.webapp.base.xhtml import XHTMLView
 
41
from ivle.webapp import ApplicationRoot
 
42
 
 
43
from ivle.database import Subject, Semester, Offering, Enrolment, User,\
 
44
                          ProjectSet, Project, ProjectSubmission
30
45
from ivle import util
31
 
 
32
 
import genshi
33
 
import genshi.template
34
 
 
35
 
def handle(req):
36
 
    """Handler for the Subjects application. Links to subject home pages."""
37
 
 
38
 
    req.styles = ["media/subjects/subjects.css"]
39
 
    ctx = genshi.template.Context()
40
 
    if req.path == "":
41
 
        # This is represented as a directory. Redirect and add a slash if it is
42
 
        # missing.
43
 
        if req.uri[-1] != '/':
44
 
            req.throw_redirect(req.uri + '/')
45
 
        ctx['whichpage'] = "toplevel"
46
 
        handle_toplevel_menu(req, ctx)
47
 
    else:
48
 
        ctx['whichpage'] = "subject"
49
 
        handle_subject_page(req, req.path, ctx)
50
 
        
51
 
    loader = genshi.template.TemplateLoader(".", auto_reload=True)
52
 
    tmpl = loader.load(util.make_local_path("apps/subjects/template.html"))
53
 
    req.write(tmpl.generate(ctx).render('html')) #'xhtml', doctype='xhtml'))
54
 
 
55
 
def handle_toplevel_menu(req, ctx):
56
 
 
57
 
    enrolled_subjects = req.user.subjects
58
 
    unenrolled_subjects = [subject for subject in
59
 
                           req.store.find(ivle.database.Subject)
60
 
                           if subject not in enrolled_subjects]
61
 
 
62
 
    ctx['enrolled_subjects'] = []
63
 
    ctx['other_subjects'] = []
64
 
 
65
 
    req.content_type = "text/html"
66
 
    req.write_html_head_foot = True
67
 
 
68
 
    for subject in enrolled_subjects:
69
 
        new_subj = {}
70
 
        new_subj['name'] = subject.name
71
 
        new_subj['url'] = subject.url
72
 
        ctx['enrolled_subjects'].append(new_subj)
73
 
 
74
 
    if len(unenrolled_subjects) > 0:
75
 
        for subject in unenrolled_subjects:
76
 
            new_subj = {}
77
 
            new_subj['name'] = subject.name
78
 
            new_subj['url'] = subject.url
79
 
            ctx['other_subjects'].append(new_subj)
80
 
 
81
 
 
82
 
def handle_subject_page(req, path, ctx):
83
 
    req.content_type = "text/html"
84
 
    req.write_html_head_foot = True     # Have dispatch print head and foot
85
 
 
86
 
    # Just make the iframe pointing to media/subjects
87
 
    ctx['serve_loc'] = urllib.quote(util.make_path(os.path.join('media', 'subjects', path)))
 
46
import ivle.date
 
47
 
 
48
from ivle.webapp.admin.projectservice import ProjectSetRESTView
 
49
from ivle.webapp.admin.offeringservice import OfferingRESTView
 
50
from ivle.webapp.admin.publishing import (root_to_subject,
 
51
            subject_to_offering, offering_to_projectset, offering_to_project,
 
52
            subject_url, offering_url, projectset_url, project_url)
 
53
from ivle.webapp.admin.breadcrumbs import (SubjectBreadcrumb,
 
54
            OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb)
 
55
from ivle.webapp.core import Plugin as CorePlugin
 
56
from ivle.webapp.groups import GroupsView
 
57
from ivle.webapp.media import media_url
 
58
from ivle.webapp.tutorial import Plugin as TutorialPlugin
 
59
 
 
60
class SubjectsView(XHTMLView):
 
61
    '''The view of the list of subjects.'''
 
62
    template = 'templates/subjects.html'
 
63
    tab = 'subjects'
 
64
 
 
65
    def authorize(self, req):
 
66
        return req.user is not None
 
67
 
 
68
    def populate(self, req, ctx):
 
69
        ctx['req'] = req
 
70
        ctx['user'] = req.user
 
71
        ctx['semesters'] = []
 
72
        ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
 
73
        ctx['SubjectEdit'] = SubjectEdit
 
74
 
 
75
        for semester in req.store.find(Semester).order_by(Desc(Semester.year),
 
76
                                                     Desc(Semester.semester)):
 
77
            if req.user.admin:
 
78
                # For admins, show all subjects in the system
 
79
                offerings = list(semester.offerings.find())
 
80
            else:
 
81
                offerings = [enrolment.offering for enrolment in
 
82
                                    semester.enrolments.find(user=req.user)]
 
83
            if len(offerings):
 
84
                ctx['semesters'].append((semester, offerings))
 
85
 
 
86
        # Admins get a separate list of subjects so they can add/edit.
 
87
        if req.user.admin:
 
88
            ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
 
89
 
 
90
 
 
91
class SubjectShortNameUniquenessValidator(formencode.FancyValidator):
 
92
    """A FormEncode validator that checks that a subject name is unused.
 
93
 
 
94
    The subject referenced by state.existing_subject is permitted
 
95
    to hold that name. If any other object holds it, the input is rejected.
 
96
    """
 
97
    def __init__(self, matching=None):
 
98
        self.matching = matching
 
99
 
 
100
    def _to_python(self, value, state):
 
101
        if (state.store.find(
 
102
                Subject, short_name=value).one() not in
 
103
                (None, state.existing_subject)):
 
104
            raise formencode.Invalid(
 
105
                'Short name already taken', value, state)
 
106
        return value
 
107
 
 
108
 
 
109
class SubjectSchema(formencode.Schema):
 
110
    short_name = formencode.All(
 
111
        SubjectShortNameUniquenessValidator(),
 
112
        formencode.validators.UnicodeString(not_empty=True))
 
113
    name = formencode.validators.UnicodeString(not_empty=True)
 
114
    code = formencode.validators.UnicodeString(not_empty=True)
 
115
 
 
116
 
 
117
class SubjectFormView(XHTMLView):
 
118
    """An abstract form to add or edit a subject."""
 
119
    tab = 'subjects'
 
120
 
 
121
    def authorize(self, req):
 
122
        return req.user is not None and req.user.admin
 
123
 
 
124
    def filter(self, stream, ctx):
 
125
        return stream | HTMLFormFiller(data=ctx['data'])
 
126
 
 
127
    def populate_state(self, state):
 
128
        state.existing_subject = None
 
129
 
 
130
    def populate(self, req, ctx):
 
131
        if req.method == 'POST':
 
132
            data = dict(req.get_fieldstorage())
 
133
            try:
 
134
                validator = SubjectSchema()
 
135
                self.populate_state(req)
 
136
                data = validator.to_python(data, state=req)
 
137
 
 
138
                subject = self.update_subject_object(req, data)
 
139
 
 
140
                req.store.commit()
 
141
                req.throw_redirect(req.publisher.generate(subject))
 
142
            except formencode.Invalid, e:
 
143
                errors = e.unpack_errors()
 
144
        else:
 
145
            data = self.get_default_data(req)
 
146
            errors = {}
 
147
 
 
148
        if errors:
 
149
            req.store.rollback()
 
150
 
 
151
        ctx['context'] = self.context
 
152
        ctx['data'] = data or {}
 
153
        ctx['errors'] = errors
 
154
 
 
155
 
 
156
class SubjectNew(SubjectFormView):
 
157
    """A form to create a subject."""
 
158
    template = 'templates/subject-new.html'
 
159
 
 
160
    def populate_state(self, state):
 
161
        state.existing_subject = self.context
 
162
 
 
163
    def get_default_data(self, req):
 
164
        return {}
 
165
 
 
166
    def update_subject_object(self, req, data):
 
167
        new_subject = Subject()
 
168
        new_subject.short_name = data['short_name']
 
169
        new_subject.name = data['name']
 
170
        new_subject.code = data['code']
 
171
 
 
172
        req.store.add(new_subject)
 
173
        return new_subject
 
174
 
 
175
 
 
176
class SubjectEdit(SubjectFormView):
 
177
    """A form to edit a subject."""
 
178
    template = 'templates/subject-edit.html'
 
179
 
 
180
    def populate_state(self, state):
 
181
        state.existing_subject = self.context
 
182
 
 
183
    def get_default_data(self, req):
 
184
        return {
 
185
            'short_name': self.context.short_name,
 
186
            'name': self.context.name,
 
187
            'code': self.context.code,
 
188
            }
 
189
 
 
190
    def update_subject_object(self, req, data):
 
191
        self.context.short_name = data['short_name']
 
192
        self.context.name = data['name']
 
193
        self.context.code = data['code']
 
194
 
 
195
        return self.context
 
196
 
 
197
 
 
198
class OfferingView(XHTMLView):
 
199
    """The home page of an offering."""
 
200
    template = 'templates/offering.html'
 
201
    tab = 'subjects'
 
202
    permission = 'view'
 
203
 
 
204
    def populate(self, req, ctx):
 
205
        # Need the worksheet result styles.
 
206
        self.plugin_styles[TutorialPlugin] = ['tutorial.css']
 
207
        ctx['context'] = self.context
 
208
        ctx['req'] = req
 
209
        ctx['permissions'] = self.context.get_permissions(req.user)
 
210
        ctx['format_submission_principal'] = util.format_submission_principal
 
211
        ctx['format_datetime'] = ivle.date.make_date_nice
 
212
        ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
 
213
        ctx['OfferingEdit'] = OfferingEdit
 
214
 
 
215
        # As we go, calculate the total score for this subject
 
216
        # (Assessable worksheets only, mandatory problems only)
 
217
 
 
218
        ctx['worksheets'], problems_total, problems_done = (
 
219
            ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
 
220
                req.store, req.user, self.context))
 
221
 
 
222
        ctx['exercises_total'] = problems_total
 
223
        ctx['exercises_done'] = problems_done
 
224
        if problems_total > 0:
 
225
            if problems_done >= problems_total:
 
226
                ctx['worksheets_complete_class'] = "complete"
 
227
            elif problems_done > 0:
 
228
                ctx['worksheets_complete_class'] = "semicomplete"
 
229
            else:
 
230
                ctx['worksheets_complete_class'] = "incomplete"
 
231
            # Calculate the final percentage and mark for the subject
 
232
            (ctx['exercises_pct'], ctx['worksheet_mark'],
 
233
             ctx['worksheet_max_mark']) = (
 
234
                ivle.worksheet.utils.calculate_mark(
 
235
                    problems_done, problems_total))
 
236
 
 
237
 
 
238
class SubjectValidator(formencode.FancyValidator):
 
239
    """A FormEncode validator that turns a subject name into a subject.
 
240
 
 
241
    The state must have a 'store' attribute, which is the Storm store
 
242
    to use.
 
243
    """
 
244
    def _to_python(self, value, state):
 
245
        subject = state.store.find(Subject, short_name=value).one()
 
246
        if subject:
 
247
            return subject
 
248
        else:
 
249
            raise formencode.Invalid('Subject does not exist', value, state)
 
250
 
 
251
 
 
252
class SemesterValidator(formencode.FancyValidator):
 
253
    """A FormEncode validator that turns a string into a semester.
 
254
 
 
255
    The string should be of the form 'year/semester', eg. '2009/1'.
 
256
 
 
257
    The state must have a 'store' attribute, which is the Storm store
 
258
    to use.
 
259
    """
 
260
    def _to_python(self, value, state):
 
261
        try:
 
262
            year, semester = value.split('/')
 
263
        except ValueError:
 
264
            year = semester = None
 
265
 
 
266
        semester = state.store.find(
 
267
            Semester, year=year, semester=semester).one()
 
268
        if semester:
 
269
            return semester
 
270
        else:
 
271
            raise formencode.Invalid('Semester does not exist', value, state)
 
272
 
 
273
 
 
274
class OfferingUniquenessValidator(formencode.FancyValidator):
 
275
    """A FormEncode validator that checks that an offering is unique.
 
276
 
 
277
    There cannot be more than one offering in the same year and semester.
 
278
 
 
279
    The offering referenced by state.existing_offering is permitted to
 
280
    hold that year and semester tuple. If any other object holds it, the
 
281
    input is rejected.
 
282
    """
 
283
    def _to_python(self, value, state):
 
284
        if (state.store.find(
 
285
                Offering, subject=value['subject'],
 
286
                semester=value['semester']).one() not in
 
287
                (None, state.existing_offering)):
 
288
            raise formencode.Invalid(
 
289
                'Offering already exists', value, state)
 
290
        return value
 
291
 
 
292
 
 
293
class OfferingSchema(formencode.Schema):
 
294
    description = formencode.validators.UnicodeString(
 
295
        if_missing=None, not_empty=False)
 
296
    url = formencode.validators.URL(if_missing=None, not_empty=False)
 
297
 
 
298
 
 
299
class OfferingAdminSchema(OfferingSchema):
 
300
    subject = formencode.All(
 
301
        SubjectValidator(), formencode.validators.UnicodeString())
 
302
    semester = formencode.All(
 
303
        SemesterValidator(), formencode.validators.UnicodeString())
 
304
    chained_validators = [OfferingUniquenessValidator()]
 
305
 
 
306
 
 
307
class OfferingEdit(BaseFormView):
 
308
    """A form to edit an offering's details."""
 
309
    template = 'templates/offering-edit.html'
 
310
    tab = 'subjects'
 
311
    permission = 'edit'
 
312
 
 
313
    @property
 
314
    def validator(self):
 
315
        if self.req.user.admin:
 
316
            return OfferingAdminSchema()
 
317
        else:
 
318
            return OfferingSchema()
 
319
 
 
320
    def populate(self, req, ctx):
 
321
        super(OfferingEdit, self).populate(req, ctx)
 
322
        ctx['subjects'] = req.store.find(Subject)
 
323
        ctx['semesters'] = req.store.find(Semester)
 
324
 
 
325
    def populate_state(self, state):
 
326
        state.existing_offering = self.context
 
327
 
 
328
    def get_default_data(self, req):
 
329
        return {
 
330
            'subject': self.context.subject.short_name,
 
331
            'semester': self.context.semester.year + '/' +
 
332
                        self.context.semester.semester,
 
333
            'url': self.context.url,
 
334
            'description': self.context.description,
 
335
            }
 
336
 
 
337
    def save_object(self, req, data):
 
338
        if req.user.admin:
 
339
            self.context.subject = data['subject']
 
340
            self.context.semester = data['semester']
 
341
        self.context.description = data['description']
 
342
        self.context.url = unicode(data['url']) if data['url'] else None
 
343
        return self.context
 
344
 
 
345
 
 
346
class OfferingNew(BaseFormView):
 
347
    """A form to create an offering."""
 
348
    template = 'templates/offering-new.html'
 
349
    tab = 'subjects'
 
350
 
 
351
    def authorize(self, req):
 
352
        return req.user is not None and req.user.admin
 
353
 
 
354
    @property
 
355
    def validator(self):
 
356
        return OfferingAdminSchema()
 
357
 
 
358
    def populate(self, req, ctx):
 
359
        super(OfferingNew, self).populate(req, ctx)
 
360
        ctx['subjects'] = req.store.find(Subject)
 
361
        ctx['semesters'] = req.store.find(Semester)
 
362
 
 
363
    def populate_state(self, state):
 
364
        state.existing_offering = None
 
365
 
 
366
    def get_default_data(self, req):
 
367
        return {}
 
368
 
 
369
    def save_object(self, req, data):
 
370
        new_offering = Offering()
 
371
        new_offering.subject = data['subject']
 
372
        new_offering.semester = data['semester']
 
373
        new_offering.description = data['description']
 
374
        new_offering.url = unicode(data['url']) if data['url'] else None
 
375
 
 
376
        req.store.add(new_offering)
 
377
        return new_offering
 
378
 
 
379
 
 
380
class UserValidator(formencode.FancyValidator):
 
381
    """A FormEncode validator that turns a username into a user.
 
382
 
 
383
    The state must have a 'store' attribute, which is the Storm store
 
384
    to use."""
 
385
    def _to_python(self, value, state):
 
386
        user = User.get_by_login(state.store, value)
 
387
        if user:
 
388
            return user
 
389
        else:
 
390
            raise formencode.Invalid('User does not exist', value, state)
 
391
 
 
392
 
 
393
class NoEnrolmentValidator(formencode.FancyValidator):
 
394
    """A FormEncode validator that ensures absence of an enrolment.
 
395
 
 
396
    The state must have an 'offering' attribute.
 
397
    """
 
398
    def _to_python(self, value, state):
 
399
        if state.offering.get_enrolment(value):
 
400
            raise formencode.Invalid('User already enrolled', value, state)
 
401
        return value
 
402
 
 
403
 
 
404
class RoleEnrolmentValidator(formencode.FancyValidator):
 
405
    """A FormEncode validator that checks permission to enrol users with a
 
406
    particular role.
 
407
 
 
408
    The state must have an 'offering' attribute.
 
409
    """
 
410
    def _to_python(self, value, state):
 
411
        if ("enrol_" + value) not in state.offering.get_permissions(state.user):
 
412
            raise formencode.Invalid('Not allowed to assign users that role',
 
413
                                     value, state)
 
414
        return value
 
415
 
 
416
 
 
417
class EnrolSchema(formencode.Schema):
 
418
    user = formencode.All(NoEnrolmentValidator(), UserValidator())
 
419
    role = formencode.All(formencode.validators.OneOf(
 
420
                                ["lecturer", "tutor", "student"]),
 
421
                          RoleEnrolmentValidator(),
 
422
                          formencode.validators.UnicodeString())
 
423
 
 
424
 
 
425
class EnrolmentsView(XHTMLView):
 
426
    """A page which displays all users enrolled in an offering."""
 
427
    template = 'templates/enrolments.html'
 
428
    tab = 'subjects'
 
429
    permission = 'edit'
 
430
 
 
431
    def populate(self, req, ctx):
 
432
        ctx['offering'] = self.context
 
433
 
 
434
class EnrolView(XHTMLView):
 
435
    """A form to enrol a user in an offering."""
 
436
    template = 'templates/enrol.html'
 
437
    tab = 'subjects'
 
438
    permission = 'enrol'
 
439
 
 
440
    def filter(self, stream, ctx):
 
441
        return stream | HTMLFormFiller(data=ctx['data'])
 
442
 
 
443
    def populate(self, req, ctx):
 
444
        if req.method == 'POST':
 
445
            data = dict(req.get_fieldstorage())
 
446
            try:
 
447
                validator = EnrolSchema()
 
448
                req.offering = self.context # XXX: Getting into state.
 
449
                data = validator.to_python(data, state=req)
 
450
                self.context.enrol(data['user'], data['role'])
 
451
                req.store.commit()
 
452
                req.throw_redirect(req.uri)
 
453
            except formencode.Invalid, e:
 
454
                errors = e.unpack_errors()
 
455
        else:
 
456
            data = {}
 
457
            errors = {}
 
458
 
 
459
        ctx['data'] = data or {}
 
460
        ctx['offering'] = self.context
 
461
        ctx['roles_auth'] = self.context.get_permissions(req.user)
 
462
        ctx['errors'] = errors
 
463
 
 
464
class OfferingProjectsView(XHTMLView):
 
465
    """View the projects for an offering."""
 
466
    template = 'templates/offering_projects.html'
 
467
    permission = 'edit'
 
468
    tab = 'subjects'
 
469
 
 
470
    def populate(self, req, ctx):
 
471
        self.plugin_styles[Plugin] = ["project.css"]
 
472
        self.plugin_scripts[Plugin] = ["project.js"]
 
473
        ctx['req'] = req
 
474
        ctx['offering'] = self.context
 
475
        ctx['projectsets'] = []
 
476
        ctx['OfferingRESTView'] = OfferingRESTView
 
477
 
 
478
        #Open the projectset Fragment, and render it for inclusion
 
479
        #into the ProjectSets page
 
480
        #XXX: This could be a lot cleaner
 
481
        loader = genshi.template.TemplateLoader(".", auto_reload=True)
 
482
 
 
483
        set_fragment = os.path.join(os.path.dirname(__file__),
 
484
                "templates/projectset_fragment.html")
 
485
        project_fragment = os.path.join(os.path.dirname(__file__),
 
486
                "templates/project_fragment.html")
 
487
 
 
488
        for projectset in self.context.project_sets:
 
489
            settmpl = loader.load(set_fragment)
 
490
            setCtx = Context()
 
491
            setCtx['req'] = req
 
492
            setCtx['projectset'] = projectset
 
493
            setCtx['projects'] = []
 
494
            setCtx['GroupsView'] = GroupsView
 
495
            setCtx['ProjectSetRESTView'] = ProjectSetRESTView
 
496
 
 
497
            for project in projectset.projects:
 
498
                projecttmpl = loader.load(project_fragment)
 
499
                projectCtx = Context()
 
500
                projectCtx['req'] = req
 
501
                projectCtx['project'] = project
 
502
 
 
503
                setCtx['projects'].append(
 
504
                        projecttmpl.generate(projectCtx))
 
505
 
 
506
            ctx['projectsets'].append(settmpl.generate(setCtx))
 
507
 
 
508
 
 
509
class ProjectView(XHTMLView):
 
510
    """View the submissions for a ProjectSet"""
 
511
    template = "templates/project.html"
 
512
    permission = "edit"
 
513
    tab = 'subjects'
 
514
 
 
515
    def build_subversion_url(self, svnroot, submission):
 
516
        princ = submission.assessed.principal
 
517
 
 
518
        if isinstance(princ, User):
 
519
            path = 'users/%s' % princ.login
 
520
        else:
 
521
            path = 'groups/%s_%s_%s_%s' % (
 
522
                    princ.project_set.offering.subject.short_name,
 
523
                    princ.project_set.offering.semester.year,
 
524
                    princ.project_set.offering.semester.semester,
 
525
                    princ.name
 
526
                    )
 
527
        return urlparse.urljoin(
 
528
                    svnroot,
 
529
                    os.path.join(path, submission.path[1:] if
 
530
                                       submission.path.startswith(os.sep) else
 
531
                                       submission.path))
 
532
 
 
533
    def populate(self, req, ctx):
 
534
        self.plugin_styles[Plugin] = ["project.css"]
 
535
 
 
536
        ctx['req'] = req
 
537
        ctx['GroupsView'] = GroupsView
 
538
        ctx['EnrolView'] = EnrolView
 
539
        ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
 
540
        ctx['build_subversion_url'] = self.build_subversion_url
 
541
        ctx['svn_addr'] = req.config['urls']['svn_addr']
 
542
        ctx['project'] = self.context
 
543
        ctx['user'] = req.user
 
544
 
 
545
class Plugin(ViewPlugin, MediaPlugin):
 
546
    forward_routes = (root_to_subject, subject_to_offering,
 
547
                      offering_to_project, offering_to_projectset)
 
548
    reverse_routes = (subject_url, offering_url, projectset_url, project_url)
 
549
 
 
550
    views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
 
551
             (ApplicationRoot, ('subjects', '+new'), SubjectNew),
 
552
             (ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
 
553
             (Subject, '+edit', SubjectEdit),
 
554
             (Offering, '+index', OfferingView),
 
555
             (Offering, '+edit', OfferingEdit),
 
556
             (Offering, ('+enrolments', '+index'), EnrolmentsView),
 
557
             (Offering, ('+enrolments', '+new'), EnrolView),
 
558
             (Offering, ('+projects', '+index'), OfferingProjectsView),
 
559
             (Project, '+index', ProjectView),
 
560
 
 
561
             (Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
 
562
             (ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
 
563
             ]
 
564
 
 
565
    breadcrumbs = {Subject: SubjectBreadcrumb,
 
566
                   Offering: OfferingBreadcrumb,
 
567
                   User: UserBreadcrumb,
 
568
                   Project: ProjectBreadcrumb,
 
569
                   }
 
570
 
 
571
    tabs = [
 
572
        ('subjects', 'Subjects',
 
573
         'View subject content and complete worksheets',
 
574
         'subjects.png', 'subjects', 5)
 
575
    ]
 
576
 
 
577
    media = 'subject-media'