~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-12 04:34:18 UTC
  • Revision ID: grantw@unimelb.edu.au-20100212043418-larofo41ndao7d4p
Add the SemesterNew template. Oops.

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