43
47
from ivle import util
46
from ivle.webapp.admin.projectservice import ProjectSetRESTView,\
48
from ivle.webapp.admin.offeringservice import OfferingRESTView
49
from ivle.webapp.admin.traversal import (root_to_subject,
50
from ivle.webapp.admin.publishing import (root_to_subject, root_to_semester,
50
51
subject_to_offering, offering_to_projectset, offering_to_project,
51
subject_url, offering_url, projectset_url, project_url)
52
offering_to_enrolment, subject_url, semester_url, offering_url,
53
projectset_url, project_url, enrolment_url)
54
from ivle.webapp.admin.breadcrumbs import (SubjectBreadcrumb,
55
OfferingBreadcrumb, UserBreadcrumb, ProjectBreadcrumb,
56
ProjectsBreadcrumb, EnrolmentBreadcrumb)
57
from ivle.webapp.core import Plugin as CorePlugin
58
from ivle.webapp.groups import GroupsView
59
from ivle.webapp.media import media_url
60
from ivle.webapp.tutorial import Plugin as TutorialPlugin
53
62
class SubjectsView(XHTMLView):
54
63
'''The view of the list of subjects.'''
55
64
template = 'templates/subjects.html'
66
breadcrumb_text = "Subjects"
58
68
def authorize(self, req):
59
69
return req.user is not None
61
71
def populate(self, req, ctx):
62
73
ctx['user'] = req.user
63
74
ctx['semesters'] = []
64
76
for semester in req.store.find(Semester).order_by(Desc(Semester.year),
65
77
Desc(Semester.semester)):
66
enrolments = semester.enrolments.find(user=req.user)
67
if enrolments.count():
68
ctx['semesters'].append((semester, enrolments))
79
# For admins, show all subjects in the system
80
offerings = list(semester.offerings.find())
82
offerings = [enrolment.offering for enrolment in
83
semester.enrolments.find(user=req.user)]
85
ctx['semesters'].append((semester, offerings))
88
class SubjectsManage(XHTMLView):
89
'''Subject management view.'''
90
template = 'templates/subjects-manage.html'
93
def authorize(self, req):
94
return req.user is not None and req.user.admin
96
def populate(self, req, ctx):
98
ctx['mediapath'] = media_url(req, CorePlugin, 'images/')
99
ctx['SubjectView'] = SubjectView
100
ctx['SubjectEdit'] = SubjectEdit
101
ctx['SemesterEdit'] = SemesterEdit
103
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
104
ctx['semesters'] = req.store.find(Semester).order_by(
105
Semester.year, Semester.semester)
108
class SubjectShortNameUniquenessValidator(formencode.FancyValidator):
109
"""A FormEncode validator that checks that a subject name is unused.
111
The subject referenced by state.existing_subject is permitted
112
to hold that name. If any other object holds it, the input is rejected.
114
def __init__(self, matching=None):
115
self.matching = matching
117
def _to_python(self, value, state):
118
if (state.store.find(
119
Subject, short_name=value).one() not in
120
(None, state.existing_subject)):
121
raise formencode.Invalid(
122
'Short name already taken', value, state)
126
class SubjectSchema(formencode.Schema):
127
short_name = formencode.All(
128
SubjectShortNameUniquenessValidator(),
129
URLNameValidator(not_empty=True))
130
name = formencode.validators.UnicodeString(not_empty=True)
131
code = formencode.validators.UnicodeString(not_empty=True)
134
class SubjectFormView(BaseFormView):
135
"""An abstract form to add or edit a subject."""
138
def authorize(self, req):
139
return req.user is not None and req.user.admin
141
def populate_state(self, state):
142
state.existing_subject = None
146
return SubjectSchema()
149
class SubjectNew(SubjectFormView):
150
"""A form to create a subject."""
151
template = 'templates/subject-new.html'
153
def get_default_data(self, req):
156
def save_object(self, req, data):
157
new_subject = Subject()
158
new_subject.short_name = data['short_name']
159
new_subject.name = data['name']
160
new_subject.code = data['code']
162
req.store.add(new_subject)
166
class SubjectEdit(SubjectFormView):
167
"""A form to edit a subject."""
168
template = 'templates/subject-edit.html'
170
def populate_state(self, state):
171
state.existing_subject = self.context
173
def get_default_data(self, req):
175
'short_name': self.context.short_name,
176
'name': self.context.name,
177
'code': self.context.code,
180
def save_object(self, req, data):
181
self.context.short_name = data['short_name']
182
self.context.name = data['name']
183
self.context.code = data['code']
188
class SemesterUniquenessValidator(formencode.FancyValidator):
189
"""A FormEncode validator that checks that a semester is unique.
191
There cannot be more than one semester for the same year and semester.
193
def _to_python(self, value, state):
194
if (state.store.find(
195
Semester, year=value['year'], semester=value['semester']
196
).one() not in (None, state.existing_semester)):
197
raise formencode.Invalid(
198
'Semester already exists', value, state)
202
class SemesterSchema(formencode.Schema):
203
year = URLNameValidator()
204
semester = URLNameValidator()
205
state = formencode.All(
206
formencode.validators.OneOf(["past", "current", "future"]),
207
formencode.validators.UnicodeString())
208
chained_validators = [SemesterUniquenessValidator()]
211
class SemesterFormView(BaseFormView):
214
def authorize(self, req):
215
return req.user is not None and req.user.admin
219
return SemesterSchema()
221
def get_return_url(self, obj):
222
return '/subjects/+manage'
225
class SemesterNew(SemesterFormView):
226
"""A form to create a semester."""
227
template = 'templates/semester-new.html'
230
def populate_state(self, state):
231
state.existing_semester = None
233
def get_default_data(self, req):
236
def save_object(self, req, data):
237
new_semester = Semester()
238
new_semester.year = data['year']
239
new_semester.semester = data['semester']
240
new_semester.state = data['state']
242
req.store.add(new_semester)
246
class SemesterEdit(SemesterFormView):
247
"""A form to edit a semester."""
248
template = 'templates/semester-edit.html'
250
def populate_state(self, state):
251
state.existing_semester = self.context
253
def get_default_data(self, req):
255
'year': self.context.year,
256
'semester': self.context.semester,
257
'state': self.context.state,
260
def save_object(self, req, data):
261
self.context.year = data['year']
262
self.context.semester = data['semester']
263
self.context.state = data['state']
267
class SubjectView(XHTMLView):
268
'''The view of the list of offerings in a given subject.'''
269
template = 'templates/subject.html'
272
def authorize(self, req):
273
return req.user is not None
275
def populate(self, req, ctx):
276
ctx['context'] = self.context
278
ctx['user'] = req.user
279
ctx['offerings'] = list(self.context.offerings)
280
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
281
ctx['SubjectEdit'] = SubjectEdit
282
ctx['SubjectOfferingNew'] = SubjectOfferingNew
285
class OfferingView(XHTMLView):
286
"""The home page of an offering."""
287
template = 'templates/offering.html'
291
def populate(self, req, ctx):
292
# Need the worksheet result styles.
293
self.plugin_styles[TutorialPlugin] = ['tutorial.css']
294
ctx['context'] = self.context
296
ctx['permissions'] = self.context.get_permissions(req.user,req.config)
297
ctx['format_submission_principal'] = util.format_submission_principal
298
ctx['format_datetime'] = ivle.date.make_date_nice
299
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
300
ctx['OfferingEdit'] = OfferingEdit
301
ctx['OfferingCloneWorksheets'] = OfferingCloneWorksheets
302
ctx['GroupsView'] = GroupsView
303
ctx['EnrolmentsView'] = EnrolmentsView
305
# As we go, calculate the total score for this subject
306
# (Assessable worksheets only, mandatory problems only)
308
ctx['worksheets'], problems_total, problems_done = (
309
ivle.worksheet.utils.create_list_of_fake_worksheets_and_stats(
310
req.config, req.store, req.user, self.context))
312
ctx['exercises_total'] = problems_total
313
ctx['exercises_done'] = problems_done
314
if problems_total > 0:
315
if problems_done >= problems_total:
316
ctx['worksheets_complete_class'] = "complete"
317
elif problems_done > 0:
318
ctx['worksheets_complete_class'] = "semicomplete"
320
ctx['worksheets_complete_class'] = "incomplete"
321
# Calculate the final percentage and mark for the subject
322
(ctx['exercises_pct'], ctx['worksheet_mark'],
323
ctx['worksheet_max_mark']) = (
324
ivle.worksheet.utils.calculate_mark(
325
problems_done, problems_total))
328
class SubjectValidator(formencode.FancyValidator):
329
"""A FormEncode validator that turns a subject name into a subject.
331
The state must have a 'store' attribute, which is the Storm store
334
def _to_python(self, value, state):
335
subject = state.store.find(Subject, short_name=value).one()
339
raise formencode.Invalid('Subject does not exist', value, state)
342
class SemesterValidator(formencode.FancyValidator):
343
"""A FormEncode validator that turns a string into a semester.
345
The string should be of the form 'year/semester', eg. '2009/1'.
347
The state must have a 'store' attribute, which is the Storm store
350
def _to_python(self, value, state):
352
year, semester = value.split('/')
354
year = semester = None
356
semester = state.store.find(
357
Semester, year=year, semester=semester).one()
361
raise formencode.Invalid('Semester does not exist', value, state)
364
class OfferingUniquenessValidator(formencode.FancyValidator):
365
"""A FormEncode validator that checks that an offering is unique.
367
There cannot be more than one offering in the same year and semester.
369
The offering referenced by state.existing_offering is permitted to
370
hold that year and semester tuple. If any other object holds it, the
373
def _to_python(self, value, state):
374
if (state.store.find(
375
Offering, subject=value['subject'],
376
semester=value['semester']).one() not in
377
(None, state.existing_offering)):
378
raise formencode.Invalid(
379
'Offering already exists', value, state)
383
class OfferingSchema(formencode.Schema):
384
description = formencode.validators.UnicodeString(
385
if_missing=None, not_empty=False)
386
url = formencode.validators.URL(if_missing=None, not_empty=False)
387
show_worksheet_marks = formencode.validators.StringBoolean(
391
class OfferingAdminSchema(OfferingSchema):
392
subject = formencode.All(
393
SubjectValidator(), formencode.validators.UnicodeString())
394
semester = formencode.All(
395
SemesterValidator(), formencode.validators.UnicodeString())
396
chained_validators = [OfferingUniquenessValidator()]
399
class OfferingEdit(BaseFormView):
400
"""A form to edit an offering's details."""
401
template = 'templates/offering-edit.html'
407
if self.req.user.admin:
408
return OfferingAdminSchema()
410
return OfferingSchema()
412
def populate(self, req, ctx):
413
super(OfferingEdit, self).populate(req, ctx)
414
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
415
ctx['semesters'] = req.store.find(Semester).order_by(
416
Semester.year, Semester.semester)
417
ctx['force_subject'] = None
419
def populate_state(self, state):
420
state.existing_offering = self.context
422
def get_default_data(self, req):
424
'subject': self.context.subject.short_name,
425
'semester': self.context.semester.year + '/' +
426
self.context.semester.semester,
427
'url': self.context.url,
428
'description': self.context.description,
429
'show_worksheet_marks': self.context.show_worksheet_marks,
432
def save_object(self, req, data):
434
self.context.subject = data['subject']
435
self.context.semester = data['semester']
436
self.context.description = data['description']
437
self.context.url = unicode(data['url']) if data['url'] else None
438
self.context.show_worksheet_marks = data['show_worksheet_marks']
442
class OfferingNew(BaseFormView):
443
"""A form to create an offering."""
444
template = 'templates/offering-new.html'
447
def authorize(self, req):
448
return req.user is not None and req.user.admin
452
return OfferingAdminSchema()
454
def populate(self, req, ctx):
455
super(OfferingNew, self).populate(req, ctx)
456
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
457
ctx['semesters'] = req.store.find(Semester).order_by(
458
Semester.year, Semester.semester)
459
ctx['force_subject'] = None
461
def populate_state(self, state):
462
state.existing_offering = None
464
def get_default_data(self, req):
467
def save_object(self, req, data):
468
new_offering = Offering()
469
new_offering.subject = data['subject']
470
new_offering.semester = data['semester']
471
new_offering.description = data['description']
472
new_offering.url = unicode(data['url']) if data['url'] else None
473
new_offering.show_worksheet_marks = data['show_worksheet_marks']
475
req.store.add(new_offering)
478
class SubjectOfferingNew(OfferingNew):
479
"""A form to create an offering for a given subject."""
480
# Identical to OfferingNew, except it forces the subject to be the subject
482
def populate(self, req, ctx):
483
super(SubjectOfferingNew, self).populate(req, ctx)
484
ctx['force_subject'] = self.context
486
class OfferingCloneWorksheetsSchema(formencode.Schema):
487
subject = formencode.All(
488
SubjectValidator(), formencode.validators.UnicodeString())
489
semester = formencode.All(
490
SemesterValidator(), formencode.validators.UnicodeString())
493
class OfferingCloneWorksheets(BaseFormView):
494
"""A form to clone worksheets from one offering to another."""
495
template = 'templates/offering-clone-worksheets.html'
498
def authorize(self, req):
499
return req.user is not None and req.user.admin
503
return OfferingCloneWorksheetsSchema()
505
def populate(self, req, ctx):
506
super(OfferingCloneWorksheets, self).populate(req, ctx)
507
ctx['subjects'] = req.store.find(Subject).order_by(Subject.name)
508
ctx['semesters'] = req.store.find(Semester).order_by(
509
Semester.year, Semester.semester)
511
def get_default_data(self, req):
514
def save_object(self, req, data):
515
if self.context.worksheets.count() > 0:
517
"Cannot clone to target with existing worksheets.")
518
offering = req.store.find(
519
Offering, subject=data['subject'], semester=data['semester']).one()
521
raise BadRequest("No such offering.")
522
if offering.worksheets.count() == 0:
523
raise BadRequest("Source offering has no worksheets.")
525
self.context.clone_worksheets(offering)
71
529
class UserValidator(formencode.FancyValidator):
124
618
ctx['data'] = data or {}
125
619
ctx['offering'] = self.context
620
ctx['roles_auth'] = self.context.get_permissions(req.user, req.config)
126
621
ctx['errors'] = errors
622
# If all of the fields validated, set the global form error.
623
if isinstance(errors, basestring):
624
ctx['error_value'] = errors
627
class EnrolmentEditSchema(formencode.Schema):
628
role = formencode.All(formencode.validators.OneOf(
629
["lecturer", "tutor", "student"]),
630
RoleEnrolmentValidator(),
631
formencode.validators.UnicodeString())
634
class EnrolmentEdit(BaseFormView):
635
"""A form to alter an enrolment's role."""
636
template = 'templates/enrolment-edit.html'
640
def populate_state(self, state):
641
state.offering = self.context.offering
643
def get_default_data(self, req):
644
return {'role': self.context.role}
648
return EnrolmentEditSchema()
650
def save_object(self, req, data):
651
self.context.role = data['role']
653
def get_return_url(self, obj):
654
return self.req.publisher.generate(
655
self.context.offering, EnrolmentsView)
657
def populate(self, req, ctx):
658
super(EnrolmentEdit, self).populate(req, ctx)
659
ctx['offering_perms'] = self.context.offering.get_permissions(
660
req.user, req.config)
663
class EnrolmentDelete(XHTMLView):
664
"""A form to alter an enrolment's role."""
665
template = 'templates/enrolment-delete.html'
669
def populate(self, req, ctx):
670
# If POSTing, delete delete delete.
671
if req.method == 'POST':
672
self.context.delete()
674
req.throw_redirect(req.publisher.generate(
675
self.context.offering, EnrolmentsView))
677
ctx['enrolment'] = self.context
128
680
class OfferingProjectsView(XHTMLView):
129
681
"""View the projects for an offering."""
130
682
template = 'templates/offering_projects.html'
131
683
permission = 'edit'
134
def project_url(self, projectset, project):
135
return "/subjects/%s/%s/%s/+projects/%s" % (
136
self.context.subject.short_name,
137
self.context.semester.year,
138
self.context.semester.semester,
142
def new_project_url(self, projectset):
143
return "/api/subjects/" + self.context.subject.short_name + "/" +\
144
self.context.semester.year + "/" + \
145
self.context.semester.semester + "/+projectsets/" +\
146
str(projectset.id) + "/+projects/+new"
685
breadcrumb_text = 'Projects'
148
687
def populate(self, req, ctx):
149
688
self.plugin_styles[Plugin] = ["project.css"]
150
self.plugin_scripts[Plugin] = ["project.js"]
151
690
ctx['offering'] = self.context
152
691
ctx['projectsets'] = []
154
693
#Open the projectset Fragment, and render it for inclusion
155
694
#into the ProjectSets page
156
#XXX: This could be a lot cleaner
157
loader = genshi.template.TemplateLoader(".", auto_reload=True)
159
695
set_fragment = os.path.join(os.path.dirname(__file__),
160
696
"templates/projectset_fragment.html")
161
697
project_fragment = os.path.join(os.path.dirname(__file__),
162
698
"templates/project_fragment.html")
164
for projectset in self.context.project_sets:
165
settmpl = loader.load(set_fragment)
701
self.context.project_sets.order_by(ivle.database.ProjectSet.id):
702
settmpl = self._loader.load(set_fragment)
166
703
setCtx = Context()
167
705
setCtx['projectset'] = projectset
168
setCtx['new_project_url'] = self.new_project_url(projectset)
169
706
setCtx['projects'] = []
707
setCtx['GroupsView'] = GroupsView
708
setCtx['ProjectSetEdit'] = ProjectSetEdit
709
setCtx['ProjectNew'] = ProjectNew
171
for project in projectset.projects:
172
projecttmpl = loader.load(project_fragment)
712
projectset.projects.order_by(ivle.database.Project.deadline):
713
projecttmpl = self._loader.load(project_fragment)
173
714
projectCtx = Context()
715
projectCtx['req'] = req
174
716
projectCtx['project'] = project
175
projectCtx['project_url'] = self.project_url(projectset, project)
177
718
setCtx['projects'].append(
178
719
projecttmpl.generate(projectCtx))
207
748
def populate(self, req, ctx):
208
749
self.plugin_styles[Plugin] = ["project.css"]
752
ctx['GroupsView'] = GroupsView
753
ctx['EnrolView'] = EnrolView
754
ctx['format_datetime'] = ivle.date.make_date_nice
210
755
ctx['format_datetime_short'] = ivle.date.format_datetime_for_paragraph
211
756
ctx['build_subversion_url'] = self.build_subversion_url
212
757
ctx['svn_addr'] = req.config['urls']['svn_addr']
213
758
ctx['project'] = self.context
214
759
ctx['user'] = req.user
216
class OfferingEnrolmentSet(object):
217
def __init__(self, offering):
218
self.offering = offering
761
class ProjectUniquenessValidator(formencode.FancyValidator):
762
"""A FormEncode validator that checks that a project short_name is unique
765
The project referenced by state.existing_project is permitted to
766
hold that short_name. If any other project holds it, the input is rejected.
768
def _to_python(self, value, state):
769
if (state.store.find(
771
Project.short_name == unicode(value),
772
Project.project_set_id == ProjectSet.id,
773
ProjectSet.offering == state.offering).one() not in
774
(None, state.existing_project)):
775
raise formencode.Invalid(
776
"A project with that URL name already exists in this offering."
780
class ProjectSchema(formencode.Schema):
781
name = formencode.validators.UnicodeString(not_empty=True)
782
short_name = formencode.All(
783
URLNameValidator(not_empty=True),
784
ProjectUniquenessValidator())
785
deadline = DateTimeValidator(not_empty=True)
786
url = formencode.validators.URL(if_missing=None, not_empty=False)
787
synopsis = formencode.validators.UnicodeString(not_empty=True)
789
class ProjectNew(BaseFormView):
790
"""A form to create a new project."""
791
template = 'templates/project-new.html'
797
return ProjectSchema()
799
def populate(self, req, ctx):
800
super(ProjectNew, self).populate(req, ctx)
801
ctx['projectset'] = self.context
803
def populate_state(self, state):
804
state.offering = self.context.offering
805
state.existing_project = None
807
def get_default_data(self, req):
810
def save_object(self, req, data):
811
new_project = Project()
812
new_project.project_set = self.context
813
new_project.name = data['name']
814
new_project.short_name = data['short_name']
815
new_project.deadline = data['deadline']
816
new_project.url = unicode(data['url']) if data['url'] else None
817
new_project.synopsis = data['synopsis']
818
req.store.add(new_project)
821
class ProjectSetSchema(formencode.Schema):
822
group_size = formencode.validators.Int(if_missing=None, not_empty=False)
824
class ProjectSetEdit(BaseFormView):
825
"""A form to edit a project set."""
826
template = 'templates/projectset-edit.html'
832
return ProjectSetSchema()
834
def populate(self, req, ctx):
835
super(ProjectSetEdit, self).populate(req, ctx)
837
def get_default_data(self, req):
839
'group_size': self.context.max_students_per_group,
842
def save_object(self, req, data):
843
self.context.max_students_per_group = data['group_size']
846
class ProjectSetNew(BaseFormView):
847
"""A form to create a new project set."""
848
template = 'templates/projectset-new.html'
851
breadcrumb_text = "Projects"
855
return ProjectSetSchema()
857
def populate(self, req, ctx):
858
super(ProjectSetNew, self).populate(req, ctx)
860
def get_default_data(self, req):
863
def save_object(self, req, data):
864
new_set = ProjectSet()
865
new_set.offering = self.context
866
new_set.max_students_per_group = data['group_size']
867
req.store.add(new_set)
220
870
class Plugin(ViewPlugin, MediaPlugin):
221
forward_routes = (root_to_subject, subject_to_offering,
222
offering_to_project, offering_to_projectset)
223
reverse_routes = (subject_url, offering_url, projectset_url, project_url)
871
forward_routes = (root_to_subject, root_to_semester, subject_to_offering,
872
offering_to_project, offering_to_projectset,
873
offering_to_enrolment)
875
subject_url, semester_url, offering_url, projectset_url, project_url,
225
878
views = [(ApplicationRoot, ('subjects', '+index'), SubjectsView),
879
(ApplicationRoot, ('subjects', '+manage'), SubjectsManage),
880
(ApplicationRoot, ('subjects', '+new'), SubjectNew),
881
(ApplicationRoot, ('subjects', '+new-offering'), OfferingNew),
882
(ApplicationRoot, ('+semesters', '+new'), SemesterNew),
883
(Subject, '+index', SubjectView),
884
(Subject, '+edit', SubjectEdit),
885
(Subject, '+new-offering', SubjectOfferingNew),
886
(Semester, '+edit', SemesterEdit),
887
(Offering, '+index', OfferingView),
888
(Offering, '+edit', OfferingEdit),
889
(Offering, '+clone-worksheets', OfferingCloneWorksheets),
890
(Offering, ('+enrolments', '+index'), EnrolmentsView),
226
891
(Offering, ('+enrolments', '+new'), EnrolView),
892
(Enrolment, '+edit', EnrolmentEdit),
893
(Enrolment, '+delete', EnrolmentDelete),
227
894
(Offering, ('+projects', '+index'), OfferingProjectsView),
895
(Offering, ('+projects', '+new-set'), ProjectSetNew),
896
(ProjectSet, '+edit', ProjectSetEdit),
897
(ProjectSet, '+new', ProjectNew),
228
898
(Project, '+index', ProjectView),
230
(Offering, ('+projectsets', '+new'), OfferingRESTView, 'api'),
231
(ProjectSet, ('+projects', '+new'), ProjectSetRESTView, 'api'),
232
(Project, '+index', ProjectRESTView, 'api'),
901
breadcrumbs = {Subject: SubjectBreadcrumb,
902
Offering: OfferingBreadcrumb,
903
User: UserBreadcrumb,
904
Project: ProjectBreadcrumb,
905
Enrolment: EnrolmentBreadcrumb,
236
909
('subjects', 'Subjects',
237
910
'View subject content and complete worksheets',