~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 05:37:50 UTC
  • Revision ID: grantw@unimelb.edu.au-20100215053750-hihmegnp8e7dshc2
Ignore test coverage files.

Show diffs side-by-side

added added

removed removed

Lines of Context:
28
28
import urlparse
29
29
import cgi
30
30
 
31
 
from storm.locals import Desc
 
31
from storm.locals import Desc, Store
32
32
import genshi
33
33
from genshi.filters import HTMLFormFiller
34
34
from genshi.template import Context, TemplateLoader
35
35
import formencode
 
36
import formencode.validators
36
37
 
 
38
from ivle.webapp.base.forms import BaseFormView
 
39
from ivle.webapp.base.plugins import ViewPlugin, MediaPlugin
37
40
from ivle.webapp.base.xhtml import XHTMLView
38
 
from ivle.webapp.base.plugins import ViewPlugin, MediaPlugin
39
 
from ivle.webapp.errors import NotFound
 
41
from ivle.webapp import ApplicationRoot
40
42
 
41
43
from ivle.database import Subject, Semester, Offering, Enrolment, User,\
42
44
                          ProjectSet, Project, ProjectSubmission
43
45
from ivle import util
44
46
import ivle.date
45
47
 
46
 
from ivle.webapp.admin.projectservice import ProjectSetRESTView,\
47
 
                                             ProjectRESTView
 
48
from ivle.webapp.admin.projectservice import ProjectSetRESTView
48
49
from ivle.webapp.admin.offeringservice import OfferingRESTView
49
 
 
 
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
50
59
 
51
60
class SubjectsView(XHTMLView):
52
61
    '''The view of the list of subjects.'''
57
66
        return req.user is not None
58
67
 
59
68
    def populate(self, req, ctx):
 
69
        ctx['req'] = req
60
70
        ctx['user'] = req.user
61
71
        ctx['semesters'] = []
 
72
        ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
 
73
        ctx['SubjectEdit'] = SubjectEdit
 
74
 
62
75
        for semester in req.store.find(Semester).order_by(Desc(Semester.year),
63
76
                                                     Desc(Semester.semester)):
64
 
            enrolments = semester.enrolments.find(user=req.user)
65
 
            if enrolments.count():
66
 
                ctx['semesters'].append((semester, enrolments))
 
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
        ctx['GroupsView'] = GroupsView
 
238
 
 
239
        # As we go, calculate the total score for this subject
 
240
        # (Assessable worksheets only, mandatory problems only)
 
241
 
 
242
        ctx['worksheets'], problems_total, problems_done = (
 
243
            ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
 
244
                req.store, req.user, self.context))
 
245
 
 
246
        ctx['exercises_total'] = problems_total
 
247
        ctx['exercises_done'] = problems_done
 
248
        if problems_total > 0:
 
249
            if problems_done >= problems_total:
 
250
                ctx['worksheets_complete_class'] = "complete"
 
251
            elif problems_done > 0:
 
252
                ctx['worksheets_complete_class'] = "semicomplete"
 
253
            else:
 
254
                ctx['worksheets_complete_class'] = "incomplete"
 
255
            # Calculate the final percentage and mark for the subject
 
256
            (ctx['exercises_pct'], ctx['worksheet_mark'],
 
257
             ctx['worksheet_max_mark']) = (
 
258
                ivle.worksheet.utils.calculate_mark(
 
259
                    problems_done, problems_total))
 
260
 
 
261
 
 
262
class SubjectValidator(formencode.FancyValidator):
 
263
    """A FormEncode validator that turns a subject name into a subject.
 
264
 
 
265
    The state must have a 'store' attribute, which is the Storm store
 
266
    to use.
 
267
    """
 
268
    def _to_python(self, value, state):
 
269
        subject = state.store.find(Subject, short_name=value).one()
 
270
        if subject:
 
271
            return subject
 
272
        else:
 
273
            raise formencode.Invalid('Subject does not exist', value, state)
 
274
 
 
275
 
 
276
class SemesterValidator(formencode.FancyValidator):
 
277
    """A FormEncode validator that turns a string into a semester.
 
278
 
 
279
    The string should be of the form 'year/semester', eg. '2009/1'.
 
280
 
 
281
    The state must have a 'store' attribute, which is the Storm store
 
282
    to use.
 
283
    """
 
284
    def _to_python(self, value, state):
 
285
        try:
 
286
            year, semester = value.split('/')
 
287
        except ValueError:
 
288
            year = semester = None
 
289
 
 
290
        semester = state.store.find(
 
291
            Semester, year=year, semester=semester).one()
 
292
        if semester:
 
293
            return semester
 
294
        else:
 
295
            raise formencode.Invalid('Semester does not exist', value, state)
 
296
 
 
297
 
 
298
class OfferingUniquenessValidator(formencode.FancyValidator):
 
299
    """A FormEncode validator that checks that an offering is unique.
 
300
 
 
301
    There cannot be more than one offering in the same year and semester.
 
302
 
 
303
    The offering referenced by state.existing_offering is permitted to
 
304
    hold that year and semester tuple. If any other object holds it, the
 
305
    input is rejected.
 
306
    """
 
307
    def _to_python(self, value, state):
 
308
        if (state.store.find(
 
309
                Offering, subject=value['subject'],
 
310
                semester=value['semester']).one() not in
 
311
                (None, state.existing_offering)):
 
312
            raise formencode.Invalid(
 
313
                'Offering already exists', value, state)
 
314
        return value
 
315
 
 
316
 
 
317
class OfferingSchema(formencode.Schema):
 
318
    description = formencode.validators.UnicodeString(
 
319
        if_missing=None, not_empty=False)
 
320
    url = formencode.validators.URL(if_missing=None, not_empty=False)
 
321
 
 
322
 
 
323
class OfferingAdminSchema(OfferingSchema):
 
324
    subject = formencode.All(
 
325
        SubjectValidator(), formencode.validators.UnicodeString())
 
326
    semester = formencode.All(
 
327
        SemesterValidator(), formencode.validators.UnicodeString())
 
328
    chained_validators = [OfferingUniquenessValidator()]
 
329
 
 
330
 
 
331
class OfferingEdit(BaseFormView):
 
332
    """A form to edit an offering's details."""
 
333
    template = 'templates/offering-edit.html'
 
334
    tab = 'subjects'
 
335
    permission = 'edit'
 
336
 
 
337
    @property
 
338
    def validator(self):
 
339
        if self.req.user.admin:
 
340
            return OfferingAdminSchema()
 
341
        else:
 
342
            return OfferingSchema()
 
343
 
 
344
    def populate(self, req, ctx):
 
345
        super(OfferingEdit, self).populate(req, ctx)
 
346
        ctx['subjects'] = req.store.find(Subject)
 
347
        ctx['semesters'] = req.store.find(Semester)
 
348
 
 
349
    def populate_state(self, state):
 
350
        state.existing_offering = self.context
 
351
 
 
352
    def get_default_data(self, req):
 
353
        return {
 
354
            'subject': self.context.subject.short_name,
 
355
            'semester': self.context.semester.year + '/' +
 
356
                        self.context.semester.semester,
 
357
            'url': self.context.url,
 
358
            'description': self.context.description,
 
359
            }
 
360
 
 
361
    def save_object(self, req, data):
 
362
        if req.user.admin:
 
363
            self.context.subject = data['subject']
 
364
            self.context.semester = data['semester']
 
365
        self.context.description = data['description']
 
366
        self.context.url = unicode(data['url']) if data['url'] else None
 
367
        return self.context
 
368
 
 
369
 
 
370
class OfferingNew(BaseFormView):
 
371
    """A form to create an offering."""
 
372
    template = 'templates/offering-new.html'
 
373
    tab = 'subjects'
 
374
 
 
375
    def authorize(self, req):
 
376
        return req.user is not None and req.user.admin
 
377
 
 
378
    @property
 
379
    def validator(self):
 
380
        return OfferingAdminSchema()
 
381
 
 
382
    def populate(self, req, ctx):
 
383
        super(OfferingNew, self).populate(req, ctx)
 
384
        ctx['subjects'] = req.store.find(Subject)
 
385
        ctx['semesters'] = req.store.find(Semester)
 
386
 
 
387
    def populate_state(self, state):
 
388
        state.existing_offering = None
 
389
 
 
390
    def get_default_data(self, req):
 
391
        return {}
 
392
 
 
393
    def save_object(self, req, data):
 
394
        new_offering = Offering()
 
395
        new_offering.subject = data['subject']
 
396
        new_offering.semester = data['semester']
 
397
        new_offering.description = data['description']
 
398
        new_offering.url = unicode(data['url']) if data['url'] else None
 
399
 
 
400
        req.store.add(new_offering)
 
401
        return new_offering
67
402
 
68
403
 
69
404
class UserValidator(formencode.FancyValidator):
90
425
        return value
91
426
 
92
427
 
 
428
class RoleEnrolmentValidator(formencode.FancyValidator):
 
429
    """A FormEncode validator that checks permission to enrol users with a
 
430
    particular role.
 
431
 
 
432
    The state must have an 'offering' attribute.
 
433
    """
 
434
    def _to_python(self, value, state):
 
435
        if (("enrol_" + value) not in
 
436
                state.offering.get_permissions(state.user, state.config)):
 
437
            raise formencode.Invalid('Not allowed to assign users that role',
 
438
                                     value, state)
 
439
        return value
 
440
 
 
441
 
93
442
class EnrolSchema(formencode.Schema):
94
443
    user = formencode.All(NoEnrolmentValidator(), UserValidator())
95
 
 
 
444
    role = formencode.All(formencode.validators.OneOf(
 
445
                                ["lecturer", "tutor", "student"]),
 
446
                          RoleEnrolmentValidator(),
 
447
                          formencode.validators.UnicodeString())
 
448
 
 
449
 
 
450
class EnrolmentsView(XHTMLView):
 
451
    """A page which displays all users enrolled in an offering."""
 
452
    template = 'templates/enrolments.html'
 
453
    tab = 'subjects'
 
454
    permission = 'edit'
 
455
 
 
456
    def populate(self, req, ctx):
 
457
        ctx['offering'] = self.context
96
458
 
97
459
class EnrolView(XHTMLView):
98
460
    """A form to enrol a user in an offering."""
99
461
    template = 'templates/enrol.html'
100
462
    tab = 'subjects'
101
 
    permission = 'edit'
102
 
 
103
 
    def __init__(self, req, subject, year, semester):
104
 
        """Find the given offering by subject, year and semester."""
105
 
        self.context = req.store.find(Offering,
106
 
            Offering.subject_id == Subject.id,
107
 
            Subject.short_name == subject,
108
 
            Offering.semester_id == Semester.id,
109
 
            Semester.year == year,
110
 
            Semester.semester == semester).one()
111
 
 
112
 
        if not self.context:
113
 
            raise NotFound()
 
463
    permission = 'enrol'
114
464
 
115
465
    def filter(self, stream, ctx):
116
466
        return stream | HTMLFormFiller(data=ctx['data'])
122
472
                validator = EnrolSchema()
123
473
                req.offering = self.context # XXX: Getting into state.
124
474
                data = validator.to_python(data, state=req)
125
 
                self.context.enrol(data['user'])
 
475
                self.context.enrol(data['user'], data['role'])
126
476
                req.store.commit()
127
477
                req.throw_redirect(req.uri)
128
478
            except formencode.Invalid, e:
133
483
 
134
484
        ctx['data'] = data or {}
135
485
        ctx['offering'] = self.context
 
486
        ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
136
487
        ctx['errors'] = errors
137
488
 
138
489
class OfferingProjectsView(XHTMLView):
140
491
    template = 'templates/offering_projects.html'
141
492
    permission = 'edit'
142
493
    tab = 'subjects'
143
 
    
144
 
    def __init__(self, req, subject, year, semester):
145
 
        self.context = req.store.find(Offering,
146
 
            Offering.subject_id == Subject.id,
147
 
            Subject.short_name == subject,
148
 
            Offering.semester_id == Semester.id,
149
 
            Semester.year == year,
150
 
            Semester.semester == semester).one()
151
 
 
152
 
        if not self.context:
153
 
            raise NotFound()
154
 
 
155
 
    def project_url(self, projectset, project):
156
 
        return "/subjects/%s/%s/%s/+projects/%s" % (
157
 
                    self.context.subject.short_name,
158
 
                    self.context.semester.year,
159
 
                    self.context.semester.semester,
160
 
                    project.short_name
161
 
                    )
162
 
 
163
 
    def new_project_url(self, projectset):
164
 
        return "/api/subjects/" + self.context.subject.short_name + "/" +\
165
 
                self.context.semester.year + "/" + \
166
 
                self.context.semester.semester + "/+projectsets/" +\
167
 
                str(projectset.id) + "/+projects/+new"
168
 
    
 
494
 
169
495
    def populate(self, req, ctx):
170
496
        self.plugin_styles[Plugin] = ["project.css"]
171
497
        self.plugin_scripts[Plugin] = ["project.js"]
 
498
        ctx['req'] = req
172
499
        ctx['offering'] = self.context
173
500
        ctx['projectsets'] = []
 
501
        ctx['OfferingRESTView'] = OfferingRESTView
174
502
 
175
503
        #Open the projectset Fragment, and render it for inclusion
176
504
        #into the ProjectSets page
185
513
        for projectset in self.context.project_sets:
186
514
            settmpl = loader.load(set_fragment)
187
515
            setCtx = Context()
 
516
            setCtx['req'] = req
188
517
            setCtx['projectset'] = projectset
189
 
            setCtx['new_project_url'] = self.new_project_url(projectset)
190
518
            setCtx['projects'] = []
 
519
            setCtx['GroupsView'] = GroupsView
 
520
            setCtx['ProjectSetRESTView'] = ProjectSetRESTView
191
521
 
192
522
            for project in projectset.projects:
193
523
                projecttmpl = loader.load(project_fragment)
194
524
                projectCtx = Context()
 
525
                projectCtx['req'] = req
195
526
                projectCtx['project'] = project
196
 
                projectCtx['project_url'] = self.project_url(projectset, project)
197
527
 
198
528
                setCtx['projects'].append(
199
529
                        projecttmpl.generate(projectCtx))
204
534
class ProjectView(XHTMLView):
205
535
    """View the submissions for a ProjectSet"""
206
536
    template = "templates/project.html"
207
 
    permission = "edit"
 
537
    permission = "view_project_submissions"
208
538
    tab = 'subjects'
209
539
 
210
 
    def __init__(self, req, subject, year, semester, project):
211
 
        self.context = req.store.find(Project,
212
 
                Project.short_name == project,
213
 
                Project.project_set_id == ProjectSet.id,
214
 
                ProjectSet.offering_id == Offering.id,
215
 
                Offering.semester_id == Semester.id,
216
 
                Semester.year == year,
217
 
                Semester.semester == semester,
218
 
                Offering.subject_id == Subject.id,
219
 
                Subject.short_name == subject).one()
220
 
        if self.context is None:
221
 
            raise NotFound()
222
 
 
223
540
    def build_subversion_url(self, svnroot, submission):
224
541
        princ = submission.assessed.principal
225
542
 
241
558
    def populate(self, req, ctx):
242
559
        self.plugin_styles[Plugin] = ["project.css"]
243
560
 
 
561
        ctx['req'] = req
 
562
        ctx['GroupsView'] = GroupsView
 
563
        ctx['EnrolView'] = EnrolView
244
564
        ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
245
565
        ctx['build_subversion_url'] = self.build_subversion_url
246
566
        ctx['svn_addr'] = req.config['urls']['svn_addr']
248
568
        ctx['user'] = req.user
249
569
 
250
570
class Plugin(ViewPlugin, MediaPlugin):
251
 
    urls = [
252
 
        ('subjects/', SubjectsView),
253
 
        ('subjects/:subject/:year/:semester/+enrolments/+new', EnrolView),
254
 
        ('subjects/:subject/:year/:semester/+projects', OfferingProjectsView),
255
 
        ('subjects/:subject/:year/:semester/+projects/:project', ProjectView),
256
 
        #API Views
257
 
        ('api/subjects/:subject/:year/:semester/+projectsets/+new',
258
 
            OfferingRESTView),
259
 
        ('api/subjects/:subject/:year/:semester/+projectsets/:projectset/+projects/+new',
260
 
            ProjectSetRESTView),
261
 
        ('api/subjects/:subject/:year/:semester/+projects/:project', 
262
 
            ProjectRESTView),
263
 
 
264
 
    ]
 
571
    forward_routes = (root_to_subject, subject_to_offering,
 
572
                      offering_to_project, offering_to_projectset)
 
573
    reverse_routes = (subject_url, offering_url, projectset_url, project_url)
 
574
 
 
575
    views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
 
576
             (ApplicationRoot, ('subjects', '+new'), SubjectNew),
 
577
             (ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
 
578
             (ApplicationRoot, ('subjects', '+new-semester'), SemesterNew),
 
579
             (Subject, '+edit', SubjectEdit),
 
580
             (Offering, '+index', OfferingView),
 
581
             (Offering, '+edit', OfferingEdit),
 
582
             (Offering, ('+enrolments', '+index'), EnrolmentsView),
 
583
             (Offering, ('+enrolments', '+new'), EnrolView),
 
584
             (Offering, ('+projects', '+index'), OfferingProjectsView),
 
585
             (Project, '+index', ProjectView),
 
586
 
 
587
             (Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
 
588
             (ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
 
589
             ]
 
590
 
 
591
    breadcrumbs = {Subject: SubjectBreadcrumb,
 
592
                   Offering: OfferingBreadcrumb,
 
593
                   User: UserBreadcrumb,
 
594
                   Project: ProjectBreadcrumb,
 
595
                   }
265
596
 
266
597
    tabs = [
267
598
        ('subjects', 'Subjects',