23
23
# A sample / testing application for IVLE.
31
from storm.locals import Desc, Store
33
from genshi.filters import HTMLFormFiller
34
from genshi.template import Context, TemplateLoader
36
import formencode.validators
38
from ivle.webapp.base.forms import BaseFormView, URLNameValidator
39
from ivle.webapp.base.plugins import ViewPlugin, MediaPlugin
40
from ivle.webapp.base.xhtml import XHTMLView
41
from ivle.webapp.errors import BadRequest
42
from ivle.webapp import ApplicationRoot
44
from ivle.database import Subject, Semester, Offering, Enrolment, User,\
45
ProjectSet, Project, ProjectSubmission
30
46
from ivle import util
33
import genshi.template
36
"""Handler for the Subjects application. Links to subject home pages."""
38
req.styles = ["media/subjects/subjects.css"]
39
ctx = genshi.template.Context()
41
# This is represented as a directory. Redirect and add a slash if it is
43
if req.uri[-1] != '/':
44
req.throw_redirect(req.uri + '/')
45
ctx['whichpage'] = "toplevel"
46
handle_toplevel_menu(req, ctx)
48
ctx['whichpage'] = "subject"
49
handle_subject_page(req, req.path, ctx)
51
loader = genshi.template.TemplateLoader(".", auto_reload=True)
52
tmpl = loader.load(util.make_local_path("apps/subjects/template.html"))
53
req.write(tmpl.generate(ctx).render('html')) #'xhtml', doctype='xhtml'))
55
def handle_toplevel_menu(req, ctx):
57
enrolled_subjects = req.user.subjects
58
unenrolled_subjects = [subject for subject in
59
req.store.find(ivle.database.Subject)
60
if subject not in enrolled_subjects]
62
ctx['enrolled_subjects'] = []
63
ctx['other_subjects'] = []
65
req.content_type = "text/html"
66
req.write_html_head_foot = True
68
for subject in enrolled_subjects:
70
new_subj['name'] = subject.name
71
new_subj['url'] = subject.url
72
ctx['enrolled_subjects'].append(new_subj)
74
if len(unenrolled_subjects) > 0:
75
for subject in unenrolled_subjects:
77
new_subj['name'] = subject.name
78
new_subj['url'] = subject.url
79
ctx['other_subjects'].append(new_subj)
82
def handle_subject_page(req, path, ctx):
83
req.content_type = "text/html"
84
req.write_html_head_foot = True # Have dispatch print head and foot
86
# Just make the iframe pointing to media/subjects
87
ctx['serve_loc'] = urllib.quote(util.make_path(os.path.join('media', 'subjects', path)))
49
from ivle.webapp.admin.projectservice import ProjectSetRESTView
50
from ivle.webapp.admin.offeringservice import OfferingRESTView
51
from ivle.webapp.admin.publishing import (root_to_subject, root_to_semester,
52
subject_to_offering, offering_to_projectset, offering_to_project,
53
offering_to_enrolment, subject_url, semester_url, offering_url,
54
projectset_url, project_url, enrolment_url)
55
from ivle.webapp.admin.breadcrumbs import (SubjectBreadcrumb,
56
OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb,
58
from ivle.webapp.core import Plugin as CorePlugin
59
from ivle.webapp.groups import GroupsView
60
from ivle.webapp.media import media_url
61
from ivle.webapp.tutorial import Plugin as TutorialPlugin
63
class SubjectsView(XHTMLView):
64
'''The view of the list of subjects.'''
65
template = 'templates/subjects.html'
67
breadcrumb_text = "Subjects"
69
def authorize(self, req):
70
return req.user is not None
72
def populate(self, req, ctx):
74
ctx['user'] = req.user
77
for semester in req.store.find(Semester).order_by(Desc(Semester.year),
78
Desc(Semester.semester)):
80
# For admins, show all subjects in the system
81
offerings = list(semester.offerings.find())
83
offerings = [enrolment.offering for enrolment in
84
semester.enrolments.find(user=req.user)]
86
ctx['semesters'].append((semester, offerings))
89
class SubjectsManage(XHTMLView):
90
'''Subject management view.'''
91
template = 'templates/subjects-manage.html'
94
def authorize(self, req):
95
return req.user is not None and req.user.admin
97
def populate(self, req, ctx):
99
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
100
ctx['SubjectView'] = SubjectView
101
ctx['SubjectEdit'] = SubjectEdit
102
ctx['SemesterEdit'] = SemesterEdit
104
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
105
ctx['semesters'] = req.store.find(Semester).order_by(
106
Semester.year, Semester.semester)
109
class SubjectShortNameUniquenessValidator(formencode.FancyValidator):
110
"""A FormEncode validator that checks that a subject name is unused.
112
The subject referenced by state.existing_subject is permitted
113
to hold that name. If any other object holds it, the input is rejected.
115
def __init__(self, matching=None):
116
self.matching = matching
118
def _to_python(self, value, state):
119
if (state.store.find(
120
Subject, short_name=value).one() not in
121
(None, state.existing_subject)):
122
raise formencode.Invalid(
123
'Short name already taken', value, state)
127
class SubjectSchema(formencode.Schema):
128
short_name = formencode.All(
129
SubjectShortNameUniquenessValidator(),
130
URLNameValidator(not_empty=True))
131
name = formencode.validators.UnicodeString(not_empty=True)
132
code = formencode.validators.UnicodeString(not_empty=True)
135
class SubjectFormView(BaseFormView):
136
"""An abstract form to add or edit a subject."""
139
def authorize(self, req):
140
return req.user is not None and req.user.admin
142
def populate_state(self, state):
143
state.existing_subject = None
147
return SubjectSchema()
150
class SubjectNew(SubjectFormView):
151
"""A form to create a subject."""
152
template = 'templates/subject-new.html'
154
def get_default_data(self, req):
157
def save_object(self, req, data):
158
new_subject = Subject()
159
new_subject.short_name = data['short_name']
160
new_subject.name = data['name']
161
new_subject.code = data['code']
163
req.store.add(new_subject)
167
class SubjectEdit(SubjectFormView):
168
"""A form to edit a subject."""
169
template = 'templates/subject-edit.html'
171
def populate_state(self, state):
172
state.existing_subject = self.context
174
def get_default_data(self, req):
176
'short_name': self.context.short_name,
177
'name': self.context.name,
178
'code': self.context.code,
181
def save_object(self, req, data):
182
self.context.short_name = data['short_name']
183
self.context.name = data['name']
184
self.context.code = data['code']
189
class SemesterUniquenessValidator(formencode.FancyValidator):
190
"""A FormEncode validator that checks that a semester is unique.
192
There cannot be more than one semester for the same year and semester.
194
def _to_python(self, value, state):
195
if (state.store.find(
196
Semester, year=value['year'], semester=value['semester']
197
).one() not in (None, state.existing_semester)):
198
raise formencode.Invalid(
199
'Semester already exists', value, state)
203
class SemesterSchema(formencode.Schema):
204
year = URLNameValidator()
205
semester = URLNameValidator()
206
state = formencode.All(
207
formencode.validators.OneOf(["past", "current", "future"]),
208
formencode.validators.UnicodeString())
209
chained_validators = [SemesterUniquenessValidator()]
212
class SemesterFormView(BaseFormView):
215
def authorize(self, req):
216
return req.user is not None and req.user.admin
220
return SemesterSchema()
222
def get_return_url(self, obj):
223
return '/subjects/+manage'
226
class SemesterNew(SemesterFormView):
227
"""A form to create a semester."""
228
template = 'templates/semester-new.html'
231
def populate_state(self, state):
232
state.existing_semester = None
234
def get_default_data(self, req):
237
def save_object(self, req, data):
238
new_semester = Semester()
239
new_semester.year = data['year']
240
new_semester.semester = data['semester']
241
new_semester.state = data['state']
243
req.store.add(new_semester)
247
class SemesterEdit(SemesterFormView):
248
"""A form to edit a semester."""
249
template = 'templates/semester-edit.html'
251
def populate_state(self, state):
252
state.existing_semester = self.context
254
def get_default_data(self, req):
256
'year': self.context.year,
257
'semester': self.context.semester,
258
'state': self.context.state,
261
def save_object(self, req, data):
262
self.context.year = data['year']
263
self.context.semester = data['semester']
264
self.context.state = data['state']
268
class SubjectView(XHTMLView):
269
'''The view of the list of offerings in a given subject.'''
270
template = 'templates/subject.html'
273
def authorize(self, req):
274
return req.user is not None
276
def populate(self, req, ctx):
277
ctx['context'] = self.context
279
ctx['user'] = req.user
280
ctx['offerings'] = list(self.context.offerings)
281
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
282
ctx['SubjectEdit'] = SubjectEdit
283
ctx['SubjectOfferingNew'] = SubjectOfferingNew
286
class OfferingView(XHTMLView):
287
"""The home page of an offering."""
288
template = 'templates/offering.html'
292
def populate(self, req, ctx):
293
# Need the worksheet result styles.
294
self.plugin_styles[TutorialPlugin] = ['tutorial.css']
295
ctx['context'] = self.context
297
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
298
ctx['format_submission_principal'] = util.format_submission_principal
299
ctx['format_datetime'] = ivle.date.make_date_nice
300
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
301
ctx['OfferingEdit'] = OfferingEdit
302
ctx['OfferingCloneWorksheets'] = OfferingCloneWorksheets
303
ctx['GroupsView'] = GroupsView
304
ctx['EnrolmentsView'] = EnrolmentsView
306
# As we go, calculate the total score for this subject
307
# (Assessable worksheets only, mandatory problems only)
309
ctx['worksheets'], problems_total, problems_done = (
310
ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
311
req.store, req.user, self.context))
313
ctx['exercises_total'] = problems_total
314
ctx['exercises_done'] = problems_done
315
if problems_total > 0:
316
if problems_done >= problems_total:
317
ctx['worksheets_complete_class'] = "complete"
318
elif problems_done > 0:
319
ctx['worksheets_complete_class'] = "semicomplete"
321
ctx['worksheets_complete_class'] = "incomplete"
322
# Calculate the final percentage and mark for the subject
323
(ctx['exercises_pct'], ctx['worksheet_mark'],
324
ctx['worksheet_max_mark']) = (
325
ivle.worksheet.utils.calculate_mark(
326
problems_done, problems_total))
329
class SubjectValidator(formencode.FancyValidator):
330
"""A FormEncode validator that turns a subject name into a subject.
332
The state must have a 'store' attribute, which is the Storm store
335
def _to_python(self, value, state):
336
subject = state.store.find(Subject, short_name=value).one()
340
raise formencode.Invalid('Subject does not exist', value, state)
343
class SemesterValidator(formencode.FancyValidator):
344
"""A FormEncode validator that turns a string into a semester.
346
The string should be of the form 'year/semester', eg. '2009/1'.
348
The state must have a 'store' attribute, which is the Storm store
351
def _to_python(self, value, state):
353
year, semester = value.split('/')
355
year = semester = None
357
semester = state.store.find(
358
Semester, year=year, semester=semester).one()
362
raise formencode.Invalid('Semester does not exist', value, state)
365
class OfferingUniquenessValidator(formencode.FancyValidator):
366
"""A FormEncode validator that checks that an offering is unique.
368
There cannot be more than one offering in the same year and semester.
370
The offering referenced by state.existing_offering is permitted to
371
hold that year and semester tuple. If any other object holds it, the
374
def _to_python(self, value, state):
375
if (state.store.find(
376
Offering, subject=value['subject'],
377
semester=value['semester']).one() not in
378
(None, state.existing_offering)):
379
raise formencode.Invalid(
380
'Offering already exists', value, state)
384
class OfferingSchema(formencode.Schema):
385
description = formencode.validators.UnicodeString(
386
if_missing=None, not_empty=False)
387
url = formencode.validators.URL(if_missing=None, not_empty=False)
390
class OfferingAdminSchema(OfferingSchema):
391
subject = formencode.All(
392
SubjectValidator(), formencode.validators.UnicodeString())
393
semester = formencode.All(
394
SemesterValidator(), formencode.validators.UnicodeString())
395
chained_validators = [OfferingUniquenessValidator()]
398
class OfferingEdit(BaseFormView):
399
"""A form to edit an offering's details."""
400
template = 'templates/offering-edit.html'
406
if self.req.user.admin:
407
return OfferingAdminSchema()
409
return OfferingSchema()
411
def populate(self, req, ctx):
412
super(OfferingEdit, self).populate(req, ctx)
413
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
414
ctx['semesters'] = req.store.find(Semester).order_by(
415
Semester.year, Semester.semester)
416
ctx['force_subject'] = None
418
def populate_state(self, state):
419
state.existing_offering = self.context
421
def get_default_data(self, req):
423
'subject': self.context.subject.short_name,
424
'semester': self.context.semester.year + '/' +
425
self.context.semester.semester,
426
'url': self.context.url,
427
'description': self.context.description,
430
def save_object(self, req, data):
432
self.context.subject = data['subject']
433
self.context.semester = data['semester']
434
self.context.description = data['description']
435
self.context.url = unicode(data['url']) if data['url'] else None
439
class OfferingNew(BaseFormView):
440
"""A form to create an offering."""
441
template = 'templates/offering-new.html'
444
def authorize(self, req):
445
return req.user is not None and req.user.admin
449
return OfferingAdminSchema()
451
def populate(self, req, ctx):
452
super(OfferingNew, self).populate(req, ctx)
453
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
454
ctx['semesters'] = req.store.find(Semester).order_by(
455
Semester.year, Semester.semester)
456
ctx['force_subject'] = None
458
def populate_state(self, state):
459
state.existing_offering = None
461
def get_default_data(self, req):
464
def save_object(self, req, data):
465
new_offering = Offering()
466
new_offering.subject = data['subject']
467
new_offering.semester = data['semester']
468
new_offering.description = data['description']
469
new_offering.url = unicode(data['url']) if data['url'] else None
471
req.store.add(new_offering)
474
class SubjectOfferingNew(OfferingNew):
475
"""A form to create an offering for a given subject."""
476
# Identical to OfferingNew, except it forces the subject to be the subject
478
def populate(self, req, ctx):
479
super(SubjectOfferingNew, self).populate(req, ctx)
480
ctx['force_subject'] = self.context
482
class OfferingCloneWorksheetsSchema(formencode.Schema):
483
subject = formencode.All(
484
SubjectValidator(), formencode.validators.UnicodeString())
485
semester = formencode.All(
486
SemesterValidator(), formencode.validators.UnicodeString())
489
class OfferingCloneWorksheets(BaseFormView):
490
"""A form to clone worksheets from one offering to another."""
491
template = 'templates/offering-clone-worksheets.html'
494
def authorize(self, req):
495
return req.user is not None and req.user.admin
499
return OfferingCloneWorksheetsSchema()
501
def populate(self, req, ctx):
502
super(OfferingCloneWorksheets, self).populate(req, ctx)
503
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
504
ctx['semesters'] = req.store.find(Semester).order_by(
505
Semester.year, Semester.semester)
507
def get_default_data(self, req):
510
def save_object(self, req, data):
511
if self.context.worksheets.count() > 0:
513
"Cannot clone to target with existing worksheets.")
514
offering = req.store.find(
515
Offering, subject=data['subject'], semester=data['semester']).one()
517
raise BadRequest("No such offering.")
518
if offering.worksheets.count() == 0:
519
raise BadRequest("Source offering has no worksheets.")
521
self.context.clone_worksheets(offering)
525
class UserValidator(formencode.FancyValidator):
526
"""A FormEncode validator that turns a username into a user.
528
The state must have a 'store' attribute, which is the Storm store
530
def _to_python(self, value, state):
531
user = User.get_by_login(state.store, value)
535
raise formencode.Invalid('User does not exist', value, state)
538
class NoEnrolmentValidator(formencode.FancyValidator):
539
"""A FormEncode validator that ensures absence of an enrolment.
541
The state must have an 'offering' attribute.
543
def _to_python(self, value, state):
544
if state.offering.get_enrolment(value):
545
raise formencode.Invalid('User already enrolled', value, state)
549
class RoleEnrolmentValidator(formencode.FancyValidator):
550
"""A FormEncode validator that checks permission to enrol users with a
553
The state must have an 'offering' attribute.
555
def _to_python(self, value, state):
556
if (("enrol_" + value) not in
557
state.offering.get_permissions(state.user, state.config)):
558
raise formencode.Invalid('Not allowed to assign users that role',
563
class EnrolSchema(formencode.Schema):
564
user = formencode.All(NoEnrolmentValidator(), UserValidator())
565
role = formencode.All(formencode.validators.OneOf(
566
["lecturer", "tutor", "student"]),
567
RoleEnrolmentValidator(),
568
formencode.validators.UnicodeString())
571
class EnrolmentsView(XHTMLView):
572
"""A page which displays all users enrolled in an offering."""
573
template = 'templates/enrolments.html'
576
breadcrumb_text = 'Enrolments'
578
def populate(self, req, ctx):
580
ctx['offering'] = self.context
581
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
582
ctx['offering_perms'] = self.context.get_permissions(
583
req.user, req.config)
584
ctx['EnrolView'] = EnrolView
585
ctx['EnrolmentEdit'] = EnrolmentEdit
586
ctx['EnrolmentDelete'] = EnrolmentDelete
589
class EnrolView(XHTMLView):
590
"""A form to enrol a user in an offering."""
591
template = 'templates/enrol.html'
595
def filter(self, stream, ctx):
596
return stream | HTMLFormFiller(data=ctx['data'])
598
def populate(self, req, ctx):
599
if req.method == 'POST':
600
data = dict(req.get_fieldstorage())
602
validator = EnrolSchema()
603
req.offering = self.context # XXX: Getting into state.
604
data = validator.to_python(data, state=req)
605
self.context.enrol(data['user'], data['role'])
607
req.throw_redirect(req.uri)
608
except formencode.Invalid, e:
609
errors = e.unpack_errors()
614
ctx['data'] = data or {}
615
ctx['offering'] = self.context
616
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
617
ctx['errors'] = errors
620
class EnrolmentEditSchema(formencode.Schema):
621
role = formencode.All(formencode.validators.OneOf(
622
["lecturer", "tutor", "student"]),
623
RoleEnrolmentValidator(),
624
formencode.validators.UnicodeString())
627
class EnrolmentEdit(BaseFormView):
628
"""A form to alter an enrolment's role."""
629
template = 'templates/enrolment-edit.html'
633
def populate_state(self, state):
634
state.offering = self.context.offering
636
def get_default_data(self, req):
637
return {'role': self.context.role}
641
return EnrolmentEditSchema()
643
def save_object(self, req, data):
644
self.context.role = data['role']
646
def get_return_url(self, obj):
647
return self.req.publisher.generate(
648
self.context.offering, EnrolmentsView)
650
def populate(self, req, ctx):
651
super(EnrolmentEdit, self).populate(req, ctx)
652
ctx['offering_perms'] = self.context.offering.get_permissions(
653
req.user, req.config)
656
class EnrolmentDelete(XHTMLView):
657
"""A form to alter an enrolment's role."""
658
template = 'templates/enrolment-delete.html'
662
def populate(self, req, ctx):
663
# If POSTing, delete delete delete.
664
if req.method == 'POST':
665
self.context.delete()
667
req.throw_redirect(req.publisher.generate(
668
self.context.offering, EnrolmentsView))
670
ctx['enrolment'] = self.context
673
class OfferingProjectsView(XHTMLView):
674
"""View the projects for an offering."""
675
template = 'templates/offering_projects.html'
678
breadcrumb_text = 'Projects'
680
def populate(self, req, ctx):
681
self.plugin_styles[Plugin] = ["project.css"]
682
self.plugin_scripts[Plugin] = ["project.js"]
684
ctx['offering'] = self.context
685
ctx['projectsets'] = []
686
ctx['OfferingRESTView'] = OfferingRESTView
688
#Open the projectset Fragment, and render it for inclusion
689
#into the ProjectSets page
690
#XXX: This could be a lot cleaner
691
loader = genshi.template.TemplateLoader(".", auto_reload=True)
693
set_fragment = os.path.join(os.path.dirname(__file__),
694
"templates/projectset_fragment.html")
695
project_fragment = os.path.join(os.path.dirname(__file__),
696
"templates/project_fragment.html")
698
for projectset in self.context.project_sets:
699
settmpl = loader.load(set_fragment)
702
setCtx['projectset'] = projectset
703
setCtx['projects'] = []
704
setCtx['GroupsView'] = GroupsView
705
setCtx['ProjectSetRESTView'] = ProjectSetRESTView
707
for project in projectset.projects:
708
projecttmpl = loader.load(project_fragment)
709
projectCtx = Context()
710
projectCtx['req'] = req
711
projectCtx['project'] = project
713
setCtx['projects'].append(
714
projecttmpl.generate(projectCtx))
716
ctx['projectsets'].append(settmpl.generate(setCtx))
719
class ProjectView(XHTMLView):
720
"""View the submissions for a ProjectSet"""
721
template = "templates/project.html"
722
permission = "view_project_submissions"
725
def build_subversion_url(self, svnroot, submission):
726
princ = submission.assessed.principal
728
if isinstance(princ, User):
729
path = 'users/%s' % princ.login
731
path = 'groups/%s_%s_%s_%s' % (
732
princ.project_set.offering.subject.short_name,
733
princ.project_set.offering.semester.year,
734
princ.project_set.offering.semester.semester,
737
return urlparse.urljoin(
739
os.path.join(path, submission.path[1:] if
740
submission.path.startswith(os.sep) else
743
def populate(self, req, ctx):
744
self.plugin_styles[Plugin] = ["project.css"]
747
ctx['GroupsView'] = GroupsView
748
ctx['EnrolView'] = EnrolView
749
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
750
ctx['build_subversion_url'] = self.build_subversion_url
751
ctx['svn_addr'] = req.config['urls']['svn_addr']
752
ctx['project'] = self.context
753
ctx['user'] = req.user
755
class Plugin(ViewPlugin, MediaPlugin):
756
forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
757
offering_to_project, offering_to_projectset,
758
offering_to_enrolment)
760
subject_url, semester_url, offering_url, projectset_url, project_url,
763
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
764
(ApplicationRoot, ('subjects', '+manage'), SubjectsManage),
765
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
766
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
767
(ApplicationRoot, ('+semesters', '+new'), SemesterNew),
768
(Subject, '+index', SubjectView),
769
(Subject, '+edit', SubjectEdit),
770
(Subject, '+new-offering', SubjectOfferingNew),
771
(Semester, '+edit', SemesterEdit),
772
(Offering, '+index', OfferingView),
773
(Offering, '+edit', OfferingEdit),
774
(Offering, '+clone-worksheets', OfferingCloneWorksheets),
775
(Offering, ('+enrolments', '+index'), EnrolmentsView),
776
(Offering, ('+enrolments', '+new'), EnrolView),
777
(Enrolment, '+edit', EnrolmentEdit),
778
(Enrolment, '+delete', EnrolmentDelete),
779
(Offering, ('+projects', '+index'), OfferingProjectsView),
780
(Project, '+index', ProjectView),
782
(Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
783
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
786
breadcrumbs = {Subject: SubjectBreadcrumb,
787
Offering: OfferingBreadcrumb,
788
User: UserBreadcrumb,
789
Project: ProjectBreadcrumb,
790
Enrolment: EnrolmentBreadcrumb,
794
('subjects', 'Subjects',
795
'View subject content and complete worksheets',
796
'subjects.png', 'subjects', 5)
799
media = 'subject-media'