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

« back to all changes in this revision

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

  • Committer: William Grant
  • Date: 2010-02-15 08:48:29 UTC
  • Revision ID: grantw@unimelb.edu.au-20100215084829-m1bjpp9djl3q6kh2
Actually add the SemesterEdit template.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# IVLE
 
2
# Copyright (C) 2007-2008 The University of Melbourne
 
3
#
 
4
# This program is free software; you can redistribute it and/or modify
 
5
# it under the terms of the GNU General Public License as published by
 
6
# the Free Software Foundation; either version 2 of the License, or
 
7
# (at your option) any later version.
 
8
#
 
9
# This program is distributed in the hope that it will be useful,
 
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
12
# GNU General Public License for more details.
 
13
#
 
14
# You should have received a copy of the GNU General Public License
 
15
# along with this program; if not, write to the Free Software
 
16
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
 
17
 
 
18
# App: subjects
 
19
# Author: Matt Giuca
 
20
# Date: 29/2/2008
 
21
 
 
22
# This is an IVLE application.
 
23
# A sample / testing application for IVLE.
 
24
 
 
25
import os
 
26
import os.path
 
27
import urllib
 
28
import urlparse
 
29
import cgi
 
30
 
 
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
 
45
from ivle import util
 
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, root_to_semester,
 
51
            subject_to_offering, offering_to_projectset, offering_to_project,
 
52
            subject_url, semester_url, offering_url, projectset_url,
 
53
            project_url)
 
54
from ivle.webapp.admin.breadcrumbs import (SubjectBreadcrumb,
 
55
            OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb)
 
56
from ivle.webapp.core import Plugin as CorePlugin
 
57
from ivle.webapp.groups import GroupsView
 
58
from ivle.webapp.media import media_url
 
59
from ivle.webapp.tutorial import Plugin as TutorialPlugin
 
60
 
 
61
class SubjectsView(XHTMLView):
 
62
    '''The view of the list of subjects.'''
 
63
    template = 'templates/subjects.html'
 
64
    tab = 'subjects'
 
65
 
 
66
    def authorize(self, req):
 
67
        return req.user is not None
 
68
 
 
69
    def populate(self, req, ctx):
 
70
        ctx['req'] = req
 
71
        ctx['user'] = req.user
 
72
        ctx['semesters'] = []
 
73
        ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
 
74
        ctx['SubjectEdit'] = SubjectEdit
 
75
 
 
76
        for semester in req.store.find(Semester).order_by(Desc(Semester.year),
 
77
                                                     Desc(Semester.semester)):
 
78
            if req.user.admin:
 
79
                # For admins, show all subjects in the system
 
80
                offerings = list(semester.offerings.find())
 
81
            else:
 
82
                offerings = [enrolment.offering for enrolment in
 
83
                                    semester.enrolments.find(user=req.user)]
 
84
            if len(offerings):
 
85
                ctx['semesters'].append((semester, offerings))
 
86
 
 
87
        # Admins get a separate list of subjects so they can add/edit.
 
88
        if req.user.admin:
 
89
            ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
 
90
 
 
91
 
 
92
class SubjectShortNameUniquenessValidator(formencode.FancyValidator):
 
93
    """A FormEncode validator that checks that a subject name is unused.
 
94
 
 
95
    The subject referenced by state.existing_subject is permitted
 
96
    to hold that name. If any other object holds it, the input is rejected.
 
97
    """
 
98
    def __init__(self, matching=None):
 
99
        self.matching = matching
 
100
 
 
101
    def _to_python(self, value, state):
 
102
        if (state.store.find(
 
103
                Subject, short_name=value).one() not in
 
104
                (None, state.existing_subject)):
 
105
            raise formencode.Invalid(
 
106
                'Short name already taken', value, state)
 
107
        return value
 
108
 
 
109
 
 
110
class SubjectSchema(formencode.Schema):
 
111
    short_name = formencode.All(
 
112
        SubjectShortNameUniquenessValidator(),
 
113
        formencode.validators.UnicodeString(not_empty=True))
 
114
    name = formencode.validators.UnicodeString(not_empty=True)
 
115
    code = formencode.validators.UnicodeString(not_empty=True)
 
116
 
 
117
 
 
118
class SubjectFormView(BaseFormView):
 
119
    """An abstract form to add or edit a subject."""
 
120
    tab = 'subjects'
 
121
 
 
122
    def authorize(self, req):
 
123
        return req.user is not None and req.user.admin
 
124
 
 
125
    def populate_state(self, state):
 
126
        state.existing_subject = None
 
127
 
 
128
    @property
 
129
    def validator(self):
 
130
        return SubjectSchema()
 
131
 
 
132
    def get_return_url(self, obj):
 
133
        return '/subjects'
 
134
 
 
135
 
 
136
class SubjectNew(SubjectFormView):
 
137
    """A form to create a subject."""
 
138
    template = 'templates/subject-new.html'
 
139
 
 
140
    def get_default_data(self, req):
 
141
        return {}
 
142
 
 
143
    def save_object(self, req, data):
 
144
        new_subject = Subject()
 
145
        new_subject.short_name = data['short_name']
 
146
        new_subject.name = data['name']
 
147
        new_subject.code = data['code']
 
148
 
 
149
        req.store.add(new_subject)
 
150
        return new_subject
 
151
 
 
152
 
 
153
class SubjectEdit(SubjectFormView):
 
154
    """A form to edit a subject."""
 
155
    template = 'templates/subject-edit.html'
 
156
 
 
157
    def populate_state(self, state):
 
158
        state.existing_subject = self.context
 
159
 
 
160
    def get_default_data(self, req):
 
161
        return {
 
162
            'short_name': self.context.short_name,
 
163
            'name': self.context.name,
 
164
            'code': self.context.code,
 
165
            }
 
166
 
 
167
    def save_object(self, req, data):
 
168
        self.context.short_name = data['short_name']
 
169
        self.context.name = data['name']
 
170
        self.context.code = data['code']
 
171
 
 
172
        return self.context
 
173
 
 
174
 
 
175
class SemesterUniquenessValidator(formencode.FancyValidator):
 
176
    """A FormEncode validator that checks that a semester is unique.
 
177
 
 
178
    There cannot be more than one semester for the same year and semester.
 
179
    """
 
180
    def _to_python(self, value, state):
 
181
        if (state.store.find(
 
182
                Semester, year=value['year'], semester=value['semester']
 
183
                ).one() not in (None, state.existing_semester)):
 
184
            raise formencode.Invalid(
 
185
                'Semester already exists', value, state)
 
186
        return value
 
187
 
 
188
 
 
189
class SemesterSchema(formencode.Schema):
 
190
    year = formencode.validators.UnicodeString()
 
191
    semester = formencode.validators.UnicodeString()
 
192
    state = formencode.All(
 
193
        formencode.validators.OneOf(["past", "current", "future"]),
 
194
        formencode.validators.UnicodeString())
 
195
    chained_validators = [SemesterUniquenessValidator()]
 
196
 
 
197
 
 
198
class SemesterFormView(BaseFormView):
 
199
    tab = 'subjects'
 
200
 
 
201
    def authorize(self, req):
 
202
        return req.user is not None and req.user.admin
 
203
 
 
204
    @property
 
205
    def validator(self):
 
206
        return SemesterSchema()
 
207
 
 
208
    def get_return_url(self, obj):
 
209
        return '/subjects/+manage'
 
210
 
 
211
 
 
212
class SemesterNew(SemesterFormView):
 
213
    """A form to create a semester."""
 
214
    template = 'templates/semester-new.html'
 
215
    tab = 'subjects'
 
216
 
 
217
    def populate_state(self, state):
 
218
        state.existing_semester = None
 
219
 
 
220
    def get_default_data(self, req):
 
221
        return {}
 
222
 
 
223
    def save_object(self, req, data):
 
224
        new_semester = Semester()
 
225
        new_semester.year = data['year']
 
226
        new_semester.semester = data['semester']
 
227
        new_semester.state = data['state']
 
228
 
 
229
        req.store.add(new_semester)
 
230
        return new_semester
 
231
 
 
232
 
 
233
class SemesterEdit(SemesterFormView):
 
234
    """A form to edit a semester."""
 
235
    template = 'templates/semester-edit.html'
 
236
 
 
237
    def populate_state(self, state):
 
238
        state.existing_semester = self.context
 
239
 
 
240
    def get_default_data(self, req):
 
241
        return {
 
242
            'year': self.context.year,
 
243
            'semester': self.context.semester,
 
244
            'state': self.context.state,
 
245
            }
 
246
 
 
247
    def save_object(self, req, data):
 
248
        self.context.year = data['year']
 
249
        self.context.semester = data['semester']
 
250
        self.context.state = data['state']
 
251
 
 
252
        return self.context
 
253
 
 
254
 
 
255
class OfferingView(XHTMLView):
 
256
    """The home page of an offering."""
 
257
    template = 'templates/offering.html'
 
258
    tab = 'subjects'
 
259
    permission = 'view'
 
260
 
 
261
    def populate(self, req, ctx):
 
262
        # Need the worksheet result styles.
 
263
        self.plugin_styles[TutorialPlugin] = ['tutorial.css']
 
264
        ctx['context'] = self.context
 
265
        ctx['req'] = req
 
266
        ctx['permissions'] = self.context.get_permissions(req.user,req.config)
 
267
        ctx['format_submission_principal'] = util.format_submission_principal
 
268
        ctx['format_datetime'] = ivle.date.make_date_nice
 
269
        ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
 
270
        ctx['OfferingEdit'] = OfferingEdit
 
271
        ctx['GroupsView'] = GroupsView
 
272
 
 
273
        # As we go, calculate the total score for this subject
 
274
        # (Assessable worksheets only, mandatory problems only)
 
275
 
 
276
        ctx['worksheets'], problems_total, problems_done = (
 
277
            ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
 
278
                req.store, req.user, self.context))
 
279
 
 
280
        ctx['exercises_total'] = problems_total
 
281
        ctx['exercises_done'] = problems_done
 
282
        if problems_total > 0:
 
283
            if problems_done >= problems_total:
 
284
                ctx['worksheets_complete_class'] = "complete"
 
285
            elif problems_done > 0:
 
286
                ctx['worksheets_complete_class'] = "semicomplete"
 
287
            else:
 
288
                ctx['worksheets_complete_class'] = "incomplete"
 
289
            # Calculate the final percentage and mark for the subject
 
290
            (ctx['exercises_pct'], ctx['worksheet_mark'],
 
291
             ctx['worksheet_max_mark']) = (
 
292
                ivle.worksheet.utils.calculate_mark(
 
293
                    problems_done, problems_total))
 
294
 
 
295
 
 
296
class SubjectValidator(formencode.FancyValidator):
 
297
    """A FormEncode validator that turns a subject name into a subject.
 
298
 
 
299
    The state must have a 'store' attribute, which is the Storm store
 
300
    to use.
 
301
    """
 
302
    def _to_python(self, value, state):
 
303
        subject = state.store.find(Subject, short_name=value).one()
 
304
        if subject:
 
305
            return subject
 
306
        else:
 
307
            raise formencode.Invalid('Subject does not exist', value, state)
 
308
 
 
309
 
 
310
class SemesterValidator(formencode.FancyValidator):
 
311
    """A FormEncode validator that turns a string into a semester.
 
312
 
 
313
    The string should be of the form 'year/semester', eg. '2009/1'.
 
314
 
 
315
    The state must have a 'store' attribute, which is the Storm store
 
316
    to use.
 
317
    """
 
318
    def _to_python(self, value, state):
 
319
        try:
 
320
            year, semester = value.split('/')
 
321
        except ValueError:
 
322
            year = semester = None
 
323
 
 
324
        semester = state.store.find(
 
325
            Semester, year=year, semester=semester).one()
 
326
        if semester:
 
327
            return semester
 
328
        else:
 
329
            raise formencode.Invalid('Semester does not exist', value, state)
 
330
 
 
331
 
 
332
class OfferingUniquenessValidator(formencode.FancyValidator):
 
333
    """A FormEncode validator that checks that an offering is unique.
 
334
 
 
335
    There cannot be more than one offering in the same year and semester.
 
336
 
 
337
    The offering referenced by state.existing_offering is permitted to
 
338
    hold that year and semester tuple. If any other object holds it, the
 
339
    input is rejected.
 
340
    """
 
341
    def _to_python(self, value, state):
 
342
        if (state.store.find(
 
343
                Offering, subject=value['subject'],
 
344
                semester=value['semester']).one() not in
 
345
                (None, state.existing_offering)):
 
346
            raise formencode.Invalid(
 
347
                'Offering already exists', value, state)
 
348
        return value
 
349
 
 
350
 
 
351
class OfferingSchema(formencode.Schema):
 
352
    description = formencode.validators.UnicodeString(
 
353
        if_missing=None, not_empty=False)
 
354
    url = formencode.validators.URL(if_missing=None, not_empty=False)
 
355
 
 
356
 
 
357
class OfferingAdminSchema(OfferingSchema):
 
358
    subject = formencode.All(
 
359
        SubjectValidator(), formencode.validators.UnicodeString())
 
360
    semester = formencode.All(
 
361
        SemesterValidator(), formencode.validators.UnicodeString())
 
362
    chained_validators = [OfferingUniquenessValidator()]
 
363
 
 
364
 
 
365
class OfferingEdit(BaseFormView):
 
366
    """A form to edit an offering's details."""
 
367
    template = 'templates/offering-edit.html'
 
368
    tab = 'subjects'
 
369
    permission = 'edit'
 
370
 
 
371
    @property
 
372
    def validator(self):
 
373
        if self.req.user.admin:
 
374
            return OfferingAdminSchema()
 
375
        else:
 
376
            return OfferingSchema()
 
377
 
 
378
    def populate(self, req, ctx):
 
379
        super(OfferingEdit, self).populate(req, ctx)
 
380
        ctx['subjects'] = req.store.find(Subject)
 
381
        ctx['semesters'] = req.store.find(Semester)
 
382
 
 
383
    def populate_state(self, state):
 
384
        state.existing_offering = self.context
 
385
 
 
386
    def get_default_data(self, req):
 
387
        return {
 
388
            'subject': self.context.subject.short_name,
 
389
            'semester': self.context.semester.year + '/' +
 
390
                        self.context.semester.semester,
 
391
            'url': self.context.url,
 
392
            'description': self.context.description,
 
393
            }
 
394
 
 
395
    def save_object(self, req, data):
 
396
        if req.user.admin:
 
397
            self.context.subject = data['subject']
 
398
            self.context.semester = data['semester']
 
399
        self.context.description = data['description']
 
400
        self.context.url = unicode(data['url']) if data['url'] else None
 
401
        return self.context
 
402
 
 
403
 
 
404
class OfferingNew(BaseFormView):
 
405
    """A form to create an offering."""
 
406
    template = 'templates/offering-new.html'
 
407
    tab = 'subjects'
 
408
 
 
409
    def authorize(self, req):
 
410
        return req.user is not None and req.user.admin
 
411
 
 
412
    @property
 
413
    def validator(self):
 
414
        return OfferingAdminSchema()
 
415
 
 
416
    def populate(self, req, ctx):
 
417
        super(OfferingNew, self).populate(req, ctx)
 
418
        ctx['subjects'] = req.store.find(Subject)
 
419
        ctx['semesters'] = req.store.find(Semester)
 
420
 
 
421
    def populate_state(self, state):
 
422
        state.existing_offering = None
 
423
 
 
424
    def get_default_data(self, req):
 
425
        return {}
 
426
 
 
427
    def save_object(self, req, data):
 
428
        new_offering = Offering()
 
429
        new_offering.subject = data['subject']
 
430
        new_offering.semester = data['semester']
 
431
        new_offering.description = data['description']
 
432
        new_offering.url = unicode(data['url']) if data['url'] else None
 
433
 
 
434
        req.store.add(new_offering)
 
435
        return new_offering
 
436
 
 
437
 
 
438
class UserValidator(formencode.FancyValidator):
 
439
    """A FormEncode validator that turns a username into a user.
 
440
 
 
441
    The state must have a 'store' attribute, which is the Storm store
 
442
    to use."""
 
443
    def _to_python(self, value, state):
 
444
        user = User.get_by_login(state.store, value)
 
445
        if user:
 
446
            return user
 
447
        else:
 
448
            raise formencode.Invalid('User does not exist', value, state)
 
449
 
 
450
 
 
451
class NoEnrolmentValidator(formencode.FancyValidator):
 
452
    """A FormEncode validator that ensures absence of an enrolment.
 
453
 
 
454
    The state must have an 'offering' attribute.
 
455
    """
 
456
    def _to_python(self, value, state):
 
457
        if state.offering.get_enrolment(value):
 
458
            raise formencode.Invalid('User already enrolled', value, state)
 
459
        return value
 
460
 
 
461
 
 
462
class RoleEnrolmentValidator(formencode.FancyValidator):
 
463
    """A FormEncode validator that checks permission to enrol users with a
 
464
    particular role.
 
465
 
 
466
    The state must have an 'offering' attribute.
 
467
    """
 
468
    def _to_python(self, value, state):
 
469
        if (("enrol_" + value) not in
 
470
                state.offering.get_permissions(state.user, state.config)):
 
471
            raise formencode.Invalid('Not allowed to assign users that role',
 
472
                                     value, state)
 
473
        return value
 
474
 
 
475
 
 
476
class EnrolSchema(formencode.Schema):
 
477
    user = formencode.All(NoEnrolmentValidator(), UserValidator())
 
478
    role = formencode.All(formencode.validators.OneOf(
 
479
                                ["lecturer", "tutor", "student"]),
 
480
                          RoleEnrolmentValidator(),
 
481
                          formencode.validators.UnicodeString())
 
482
 
 
483
 
 
484
class EnrolmentsView(XHTMLView):
 
485
    """A page which displays all users enrolled in an offering."""
 
486
    template = 'templates/enrolments.html'
 
487
    tab = 'subjects'
 
488
    permission = 'edit'
 
489
 
 
490
    def populate(self, req, ctx):
 
491
        ctx['offering'] = self.context
 
492
 
 
493
class EnrolView(XHTMLView):
 
494
    """A form to enrol a user in an offering."""
 
495
    template = 'templates/enrol.html'
 
496
    tab = 'subjects'
 
497
    permission = 'enrol'
 
498
 
 
499
    def filter(self, stream, ctx):
 
500
        return stream | HTMLFormFiller(data=ctx['data'])
 
501
 
 
502
    def populate(self, req, ctx):
 
503
        if req.method == 'POST':
 
504
            data = dict(req.get_fieldstorage())
 
505
            try:
 
506
                validator = EnrolSchema()
 
507
                req.offering = self.context # XXX: Getting into state.
 
508
                data = validator.to_python(data, state=req)
 
509
                self.context.enrol(data['user'], data['role'])
 
510
                req.store.commit()
 
511
                req.throw_redirect(req.uri)
 
512
            except formencode.Invalid, e:
 
513
                errors = e.unpack_errors()
 
514
        else:
 
515
            data = {}
 
516
            errors = {}
 
517
 
 
518
        ctx['data'] = data or {}
 
519
        ctx['offering'] = self.context
 
520
        ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
 
521
        ctx['errors'] = errors
 
522
 
 
523
class OfferingProjectsView(XHTMLView):
 
524
    """View the projects for an offering."""
 
525
    template = 'templates/offering_projects.html'
 
526
    permission = 'edit'
 
527
    tab = 'subjects'
 
528
 
 
529
    def populate(self, req, ctx):
 
530
        self.plugin_styles[Plugin] = ["project.css"]
 
531
        self.plugin_scripts[Plugin] = ["project.js"]
 
532
        ctx['req'] = req
 
533
        ctx['offering'] = self.context
 
534
        ctx['projectsets'] = []
 
535
        ctx['OfferingRESTView'] = OfferingRESTView
 
536
 
 
537
        #Open the projectset Fragment, and render it for inclusion
 
538
        #into the ProjectSets page
 
539
        #XXX: This could be a lot cleaner
 
540
        loader = genshi.template.TemplateLoader(".", auto_reload=True)
 
541
 
 
542
        set_fragment = os.path.join(os.path.dirname(__file__),
 
543
                "templates/projectset_fragment.html")
 
544
        project_fragment = os.path.join(os.path.dirname(__file__),
 
545
                "templates/project_fragment.html")
 
546
 
 
547
        for projectset in self.context.project_sets:
 
548
            settmpl = loader.load(set_fragment)
 
549
            setCtx = Context()
 
550
            setCtx['req'] = req
 
551
            setCtx['projectset'] = projectset
 
552
            setCtx['projects'] = []
 
553
            setCtx['GroupsView'] = GroupsView
 
554
            setCtx['ProjectSetRESTView'] = ProjectSetRESTView
 
555
 
 
556
            for project in projectset.projects:
 
557
                projecttmpl = loader.load(project_fragment)
 
558
                projectCtx = Context()
 
559
                projectCtx['req'] = req
 
560
                projectCtx['project'] = project
 
561
 
 
562
                setCtx['projects'].append(
 
563
                        projecttmpl.generate(projectCtx))
 
564
 
 
565
            ctx['projectsets'].append(settmpl.generate(setCtx))
 
566
 
 
567
 
 
568
class ProjectView(XHTMLView):
 
569
    """View the submissions for a ProjectSet"""
 
570
    template = "templates/project.html"
 
571
    permission = "view_project_submissions"
 
572
    tab = 'subjects'
 
573
 
 
574
    def build_subversion_url(self, svnroot, submission):
 
575
        princ = submission.assessed.principal
 
576
 
 
577
        if isinstance(princ, User):
 
578
            path = 'users/%s' % princ.login
 
579
        else:
 
580
            path = 'groups/%s_%s_%s_%s' % (
 
581
                    princ.project_set.offering.subject.short_name,
 
582
                    princ.project_set.offering.semester.year,
 
583
                    princ.project_set.offering.semester.semester,
 
584
                    princ.name
 
585
                    )
 
586
        return urlparse.urljoin(
 
587
                    svnroot,
 
588
                    os.path.join(path, submission.path[1:] if
 
589
                                       submission.path.startswith(os.sep) else
 
590
                                       submission.path))
 
591
 
 
592
    def populate(self, req, ctx):
 
593
        self.plugin_styles[Plugin] = ["project.css"]
 
594
 
 
595
        ctx['req'] = req
 
596
        ctx['GroupsView'] = GroupsView
 
597
        ctx['EnrolView'] = EnrolView
 
598
        ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
 
599
        ctx['build_subversion_url'] = self.build_subversion_url
 
600
        ctx['svn_addr'] = req.config['urls']['svn_addr']
 
601
        ctx['project'] = self.context
 
602
        ctx['user'] = req.user
 
603
 
 
604
class Plugin(ViewPlugin, MediaPlugin):
 
605
    forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
 
606
                      offering_to_project, offering_to_projectset)
 
607
    reverse_routes = (
 
608
        subject_url, semester_url, offering_url, projectset_url, project_url)
 
609
 
 
610
    views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
 
611
             (ApplicationRoot, ('subjects', '+new'), SubjectNew),
 
612
             (ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
 
613
             (ApplicationRoot, ('+semesters', '+new'), SemesterNew),
 
614
             (Subject, '+edit', SubjectEdit),
 
615
             (Semester, '+edit', SemesterEdit),
 
616
             (Offering, '+index', OfferingView),
 
617
             (Offering, '+edit', OfferingEdit),
 
618
             (Offering, ('+enrolments', '+index'), EnrolmentsView),
 
619
             (Offering, ('+enrolments', '+new'), EnrolView),
 
620
             (Offering, ('+projects', '+index'), OfferingProjectsView),
 
621
             (Project, '+index', ProjectView),
 
622
 
 
623
             (Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
 
624
             (ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
 
625
             ]
 
626
 
 
627
    breadcrumbs = {Subject: SubjectBreadcrumb,
 
628
                   Offering: OfferingBreadcrumb,
 
629
                   User: UserBreadcrumb,
 
630
                   Project: ProjectBreadcrumb,
 
631
                   }
 
632
 
 
633
    tabs = [
 
634
        ('subjects', 'Subjects',
 
635
         'View subject content and complete worksheets',
 
636
         'subjects.png', 'subjects', 5)
 
637
    ]
 
638
 
 
639
    media = 'subject-media'