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
36
import formencode.validators
38
from ivle.webapp.base.forms import BaseFormView, URLNameValidator
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.errors import BadRequest
42
from ivle.webapp import ApplicationRoot
44
from ivle.database import Subject, Semester, Offering, Enrolment, User,\
45
ProjectSet, Project, ProjectSubmission
33
46
from ivle import util
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
36
63
class SubjectsView(XHTMLView):
37
64
'''The view of the list of subjects.'''
38
template = 'subjects.html'
39
appname = 'subjects' # XXX
41
def authorize(self, req):
42
return req.user is not None
44
def populate(self, req, ctx):
45
ctx['enrolments'] = req.user.active_enrolments
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.config, 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)
388
show_worksheet_marks = formencode.validators.StringBoolean(
392
class OfferingAdminSchema(OfferingSchema):
393
subject = formencode.All(
394
SubjectValidator(), formencode.validators.UnicodeString())
395
semester = formencode.All(
396
SemesterValidator(), formencode.validators.UnicodeString())
397
chained_validators = [OfferingUniquenessValidator()]
400
class OfferingEdit(BaseFormView):
401
"""A form to edit an offering's details."""
402
template = 'templates/offering-edit.html'
408
if self.req.user.admin:
409
return OfferingAdminSchema()
411
return OfferingSchema()
413
def populate(self, req, ctx):
414
super(OfferingEdit, self).populate(req, ctx)
415
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
416
ctx['semesters'] = req.store.find(Semester).order_by(
417
Semester.year, Semester.semester)
418
ctx['force_subject'] = None
420
def populate_state(self, state):
421
state.existing_offering = self.context
423
def get_default_data(self, req):
425
'subject': self.context.subject.short_name,
426
'semester': self.context.semester.year + '/' +
427
self.context.semester.semester,
428
'url': self.context.url,
429
'description': self.context.description,
430
'show_worksheet_marks': self.context.show_worksheet_marks,
433
def save_object(self, req, data):
435
self.context.subject = data['subject']
436
self.context.semester = data['semester']
437
self.context.description = data['description']
438
self.context.url = unicode(data['url']) if data['url'] else None
439
self.context.show_worksheet_marks = data['show_worksheet_marks']
443
class OfferingNew(BaseFormView):
444
"""A form to create an offering."""
445
template = 'templates/offering-new.html'
448
def authorize(self, req):
449
return req.user is not None and req.user.admin
453
return OfferingAdminSchema()
455
def populate(self, req, ctx):
456
super(OfferingNew, self).populate(req, ctx)
457
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
458
ctx['semesters'] = req.store.find(Semester).order_by(
459
Semester.year, Semester.semester)
460
ctx['force_subject'] = None
462
def populate_state(self, state):
463
state.existing_offering = None
465
def get_default_data(self, req):
468
def save_object(self, req, data):
469
new_offering = Offering()
470
new_offering.subject = data['subject']
471
new_offering.semester = data['semester']
472
new_offering.description = data['description']
473
new_offering.url = unicode(data['url']) if data['url'] else None
474
new_offering.show_worksheet_marks = data['show_worksheet_marks']
476
req.store.add(new_offering)
479
class SubjectOfferingNew(OfferingNew):
480
"""A form to create an offering for a given subject."""
481
# Identical to OfferingNew, except it forces the subject to be the subject
483
def populate(self, req, ctx):
484
super(SubjectOfferingNew, self).populate(req, ctx)
485
ctx['force_subject'] = self.context
487
class OfferingCloneWorksheetsSchema(formencode.Schema):
488
subject = formencode.All(
489
SubjectValidator(), formencode.validators.UnicodeString())
490
semester = formencode.All(
491
SemesterValidator(), formencode.validators.UnicodeString())
494
class OfferingCloneWorksheets(BaseFormView):
495
"""A form to clone worksheets from one offering to another."""
496
template = 'templates/offering-clone-worksheets.html'
499
def authorize(self, req):
500
return req.user is not None and req.user.admin
504
return OfferingCloneWorksheetsSchema()
506
def populate(self, req, ctx):
507
super(OfferingCloneWorksheets, self).populate(req, ctx)
508
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
509
ctx['semesters'] = req.store.find(Semester).order_by(
510
Semester.year, Semester.semester)
512
def get_default_data(self, req):
515
def save_object(self, req, data):
516
if self.context.worksheets.count() > 0:
518
"Cannot clone to target with existing worksheets.")
519
offering = req.store.find(
520
Offering, subject=data['subject'], semester=data['semester']).one()
522
raise BadRequest("No such offering.")
523
if offering.worksheets.count() == 0:
524
raise BadRequest("Source offering has no worksheets.")
526
self.context.clone_worksheets(offering)
530
class UserValidator(formencode.FancyValidator):
531
"""A FormEncode validator that turns a username into a user.
533
The state must have a 'store' attribute, which is the Storm store
535
def _to_python(self, value, state):
536
user = User.get_by_login(state.store, value)
540
raise formencode.Invalid('User does not exist', value, state)
543
class NoEnrolmentValidator(formencode.FancyValidator):
544
"""A FormEncode validator that ensures absence of an enrolment.
546
The state must have an 'offering' attribute.
548
def _to_python(self, value, state):
549
if state.offering.get_enrolment(value):
550
raise formencode.Invalid('User already enrolled', value, state)
554
class RoleEnrolmentValidator(formencode.FancyValidator):
555
"""A FormEncode validator that checks permission to enrol users with a
558
The state must have an 'offering' attribute.
560
def _to_python(self, value, state):
561
if (("enrol_" + value) not in
562
state.offering.get_permissions(state.user, state.config)):
563
raise formencode.Invalid('Not allowed to assign users that role',
568
class EnrolSchema(formencode.Schema):
569
user = formencode.All(NoEnrolmentValidator(), UserValidator())
570
role = formencode.All(formencode.validators.OneOf(
571
["lecturer", "tutor", "student"]),
572
RoleEnrolmentValidator(),
573
formencode.validators.UnicodeString())
576
class EnrolmentsView(XHTMLView):
577
"""A page which displays all users enrolled in an offering."""
578
template = 'templates/enrolments.html'
581
breadcrumb_text = 'Enrolments'
583
def populate(self, req, ctx):
585
ctx['offering'] = self.context
586
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
587
ctx['offering_perms'] = self.context.get_permissions(
588
req.user, req.config)
589
ctx['EnrolView'] = EnrolView
590
ctx['EnrolmentEdit'] = EnrolmentEdit
591
ctx['EnrolmentDelete'] = EnrolmentDelete
594
class EnrolView(XHTMLView):
595
"""A form to enrol a user in an offering."""
596
template = 'templates/enrol.html'
600
def filter(self, stream, ctx):
601
return stream | HTMLFormFiller(data=ctx['data'])
603
def populate(self, req, ctx):
604
if req.method == 'POST':
605
data = dict(req.get_fieldstorage())
607
validator = EnrolSchema()
608
req.offering = self.context # XXX: Getting into state.
609
data = validator.to_python(data, state=req)
610
self.context.enrol(data['user'], data['role'])
612
req.throw_redirect(req.uri)
613
except formencode.Invalid, e:
614
errors = e.unpack_errors()
619
ctx['data'] = data or {}
620
ctx['offering'] = self.context
621
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
622
ctx['errors'] = errors
623
# If all of the fields validated, set the global form error.
624
if isinstance(errors, basestring):
625
ctx['error_value'] = errors
628
class EnrolmentEditSchema(formencode.Schema):
629
role = formencode.All(formencode.validators.OneOf(
630
["lecturer", "tutor", "student"]),
631
RoleEnrolmentValidator(),
632
formencode.validators.UnicodeString())
635
class EnrolmentEdit(BaseFormView):
636
"""A form to alter an enrolment's role."""
637
template = 'templates/enrolment-edit.html'
641
def populate_state(self, state):
642
state.offering = self.context.offering
644
def get_default_data(self, req):
645
return {'role': self.context.role}
649
return EnrolmentEditSchema()
651
def save_object(self, req, data):
652
self.context.role = data['role']
654
def get_return_url(self, obj):
655
return self.req.publisher.generate(
656
self.context.offering, EnrolmentsView)
658
def populate(self, req, ctx):
659
super(EnrolmentEdit, self).populate(req, ctx)
660
ctx['offering_perms'] = self.context.offering.get_permissions(
661
req.user, req.config)
664
class EnrolmentDelete(XHTMLView):
665
"""A form to alter an enrolment's role."""
666
template = 'templates/enrolment-delete.html'
670
def populate(self, req, ctx):
671
# If POSTing, delete delete delete.
672
if req.method == 'POST':
673
self.context.delete()
675
req.throw_redirect(req.publisher.generate(
676
self.context.offering, EnrolmentsView))
678
ctx['enrolment'] = self.context
681
class OfferingProjectsView(XHTMLView):
682
"""View the projects for an offering."""
683
template = 'templates/offering_projects.html'
686
breadcrumb_text = 'Projects'
688
def populate(self, req, ctx):
689
self.plugin_styles[Plugin] = ["project.css"]
690
self.plugin_scripts[Plugin] = ["project.js"]
692
ctx['offering'] = self.context
693
ctx['projectsets'] = []
694
ctx['OfferingRESTView'] = OfferingRESTView
696
#Open the projectset Fragment, and render it for inclusion
697
#into the ProjectSets page
698
set_fragment = os.path.join(os.path.dirname(__file__),
699
"templates/projectset_fragment.html")
700
project_fragment = os.path.join(os.path.dirname(__file__),
701
"templates/project_fragment.html")
704
self.context.project_sets.order_by(ivle.database.ProjectSet.id):
705
settmpl = self._loader.load(set_fragment)
708
setCtx['projectset'] = projectset
709
setCtx['projects'] = []
710
setCtx['GroupsView'] = GroupsView
711
setCtx['ProjectSetRESTView'] = ProjectSetRESTView
714
projectset.projects.order_by(ivle.database.Project.deadline):
715
projecttmpl = self._loader.load(project_fragment)
716
projectCtx = Context()
717
projectCtx['req'] = req
718
projectCtx['project'] = project
720
setCtx['projects'].append(
721
projecttmpl.generate(projectCtx))
723
ctx['projectsets'].append(settmpl.generate(setCtx))
726
class ProjectView(XHTMLView):
727
"""View the submissions for a ProjectSet"""
728
template = "templates/project.html"
729
permission = "view_project_submissions"
732
def build_subversion_url(self, svnroot, submission):
733
princ = submission.assessed.principal
735
if isinstance(princ, User):
736
path = 'users/%s' % princ.login
738
path = 'groups/%s_%s_%s_%s' % (
739
princ.project_set.offering.subject.short_name,
740
princ.project_set.offering.semester.year,
741
princ.project_set.offering.semester.semester,
744
return urlparse.urljoin(
746
os.path.join(path, submission.path[1:] if
747
submission.path.startswith(os.sep) else
750
def populate(self, req, ctx):
751
self.plugin_styles[Plugin] = ["project.css"]
754
ctx['GroupsView'] = GroupsView
755
ctx['EnrolView'] = EnrolView
756
ctx['format_datetime'] = ivle.date.make_date_nice
757
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
758
ctx['build_subversion_url'] = self.build_subversion_url
759
ctx['svn_addr'] = req.config['urls']['svn_addr']
760
ctx['project'] = self.context
761
ctx['user'] = req.user
47
763
class Plugin(ViewPlugin, MediaPlugin):
49
('subjects/', SubjectsView),
764
forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
765
offering_to_project, offering_to_projectset,
766
offering_to_enrolment)
768
subject_url, semester_url, offering_url, projectset_url, project_url,
771
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
772
(ApplicationRoot, ('subjects', '+manage'), SubjectsManage),
773
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
774
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
775
(ApplicationRoot, ('+semesters', '+new'), SemesterNew),
776
(Subject, '+index', SubjectView),
777
(Subject, '+edit', SubjectEdit),
778
(Subject, '+new-offering', SubjectOfferingNew),
779
(Semester, '+edit', SemesterEdit),
780
(Offering, '+index', OfferingView),
781
(Offering, '+edit', OfferingEdit),
782
(Offering, '+clone-worksheets', OfferingCloneWorksheets),
783
(Offering, ('+enrolments', '+index'), EnrolmentsView),
784
(Offering, ('+enrolments', '+new'), EnrolView),
785
(Enrolment, '+edit', EnrolmentEdit),
786
(Enrolment, '+delete', EnrolmentDelete),
787
(Offering, ('+projects', '+index'), OfferingProjectsView),
788
(Project, '+index', ProjectView),
790
(Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
791
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
794
breadcrumbs = {Subject: SubjectBreadcrumb,
795
Offering: OfferingBreadcrumb,
796
User: UserBreadcrumb,
797
Project: ProjectBreadcrumb,
798
Enrolment: EnrolmentBreadcrumb,
53
('subjects', 'Subjects', 'Announcements and information about the '
54
'subjects you are enrolled in.', 'subjects.png', 'subjects', 5)
802
('subjects', 'Subjects',
803
'View subject content and complete worksheets',
804
'subjects.png', 'subjects', 5)
57
807
media = 'subject-media'