~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: 2009-05-26 03:06:53 UTC
  • Revision ID: grantw@unimelb.edu.au-20090526030653-axxawt0o5ws4icbt
Remove ivle.conf usage from ivle.studpath.

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, Store
 
31
from storm.locals import Desc
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
37
36
 
38
 
from ivle.webapp.base.forms import BaseFormView
 
37
from ivle.webapp.base.xhtml import XHTMLView
39
38
from ivle.webapp.base.plugins import ViewPlugin, MediaPlugin
40
 
from ivle.webapp.base.xhtml import XHTMLView
41
 
from ivle.webapp import ApplicationRoot
 
39
from ivle.webapp.errors import NotFound
42
40
 
43
41
from ivle.database import Subject, Semester, Offering, Enrolment, User,\
44
42
                          ProjectSet, Project, ProjectSubmission
45
43
from ivle import util
46
44
import ivle.date
47
45
 
48
 
from ivle.webapp.admin.projectservice import ProjectSetRESTView
 
46
from ivle.webapp.admin.projectservice import ProjectSetRESTView,\
 
47
                                             ProjectRESTView
49
48
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
 
49
 
59
50
 
60
51
class SubjectsView(XHTMLView):
61
52
    '''The view of the list of subjects.'''
66
57
        return req.user is not None
67
58
 
68
59
    def populate(self, req, ctx):
69
 
        ctx['req'] = req
70
60
        ctx['user'] = req.user
71
61
        ctx['semesters'] = []
72
 
        ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
73
 
        ctx['SubjectEdit'] = SubjectEdit
74
 
 
75
62
        for semester in req.store.find(Semester).order_by(Desc(Semester.year),
76
63
                                                     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
 
        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
 
64
            enrolments = semester.enrolments.find(user=req.user)
 
65
            if enrolments.count():
 
66
                ctx['semesters'].append((semester, enrolments))
402
67
 
403
68
 
404
69
class UserValidator(formencode.FancyValidator):
425
90
        return value
426
91
 
427
92
 
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
 
 
442
93
class EnrolSchema(formencode.Schema):
443
94
    user = formencode.All(NoEnrolmentValidator(), UserValidator())
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
 
95
 
458
96
 
459
97
class EnrolView(XHTMLView):
460
98
    """A form to enrol a user in an offering."""
461
99
    template = 'templates/enrol.html'
462
100
    tab = 'subjects'
463
 
    permission = 'enrol'
 
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()
464
114
 
465
115
    def filter(self, stream, ctx):
466
116
        return stream | HTMLFormFiller(data=ctx['data'])
472
122
                validator = EnrolSchema()
473
123
                req.offering = self.context # XXX: Getting into state.
474
124
                data = validator.to_python(data, state=req)
475
 
                self.context.enrol(data['user'], data['role'])
 
125
                self.context.enrol(data['user'])
476
126
                req.store.commit()
477
127
                req.throw_redirect(req.uri)
478
128
            except formencode.Invalid, e:
483
133
 
484
134
        ctx['data'] = data or {}
485
135
        ctx['offering'] = self.context
486
 
        ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
487
136
        ctx['errors'] = errors
488
137
 
489
138
class OfferingProjectsView(XHTMLView):
491
140
    template = 'templates/offering_projects.html'
492
141
    permission = 'edit'
493
142
    tab = 'subjects'
494
 
 
 
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
    
495
169
    def populate(self, req, ctx):
496
170
        self.plugin_styles[Plugin] = ["project.css"]
497
171
        self.plugin_scripts[Plugin] = ["project.js"]
498
 
        ctx['req'] = req
499
172
        ctx['offering'] = self.context
500
173
        ctx['projectsets'] = []
501
 
        ctx['OfferingRESTView'] = OfferingRESTView
502
174
 
503
175
        #Open the projectset Fragment, and render it for inclusion
504
176
        #into the ProjectSets page
513
185
        for projectset in self.context.project_sets:
514
186
            settmpl = loader.load(set_fragment)
515
187
            setCtx = Context()
516
 
            setCtx['req'] = req
517
188
            setCtx['projectset'] = projectset
 
189
            setCtx['new_project_url'] = self.new_project_url(projectset)
518
190
            setCtx['projects'] = []
519
 
            setCtx['GroupsView'] = GroupsView
520
 
            setCtx['ProjectSetRESTView'] = ProjectSetRESTView
521
191
 
522
192
            for project in projectset.projects:
523
193
                projecttmpl = loader.load(project_fragment)
524
194
                projectCtx = Context()
525
 
                projectCtx['req'] = req
526
195
                projectCtx['project'] = project
 
196
                projectCtx['project_url'] = self.project_url(projectset, project)
527
197
 
528
198
                setCtx['projects'].append(
529
199
                        projecttmpl.generate(projectCtx))
534
204
class ProjectView(XHTMLView):
535
205
    """View the submissions for a ProjectSet"""
536
206
    template = "templates/project.html"
537
 
    permission = "view_project_submissions"
 
207
    permission = "edit"
538
208
    tab = 'subjects'
539
209
 
 
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
 
540
223
    def build_subversion_url(self, svnroot, submission):
541
224
        princ = submission.assessed.principal
542
225
 
558
241
    def populate(self, req, ctx):
559
242
        self.plugin_styles[Plugin] = ["project.css"]
560
243
 
561
 
        ctx['req'] = req
562
 
        ctx['GroupsView'] = GroupsView
563
 
        ctx['EnrolView'] = EnrolView
564
244
        ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
565
245
        ctx['build_subversion_url'] = self.build_subversion_url
566
246
        ctx['svn_addr'] = req.config['urls']['svn_addr']
568
248
        ctx['user'] = req.user
569
249
 
570
250
class Plugin(ViewPlugin, MediaPlugin):
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
 
                   }
 
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
    ]
596
265
 
597
266
    tabs = [
598
267
        ('subjects', 'Subjects',