31
29
from storm.locals import create_database, Store, Int, Unicode, DateTime, \
32
30
Reference, ReferenceSet, Bool, Storm, Desc
217
215
Semester.id == Offering.semester_id,
218
216
(not active_only) or (Semester.state == u'current'),
219
217
Enrolment.offering_id == Offering.id,
220
Enrolment.user_id == self.id,
221
Enrolment.active == True)
218
Enrolment.user_id == self.id)
224
221
def hash_password(password):
230
227
"""Find a user in a store by login name."""
231
228
return store.find(cls, cls.login == unicode(login)).one()
233
def get_svn_url(self, config, req):
234
"""Get the subversion repository URL for this user or group."""
235
login = req.user.login
236
url = urlparse.urlsplit(config['urls']['svn_addr'])
237
url = urlparse.urlunsplit(url[:1] + (login+'@'+url[1],) + url[2:])
238
path = 'users/%s' % self.login
239
return urlparse.urljoin(url, path)
241
def get_permissions(self, user, config):
230
def get_permissions(self, user):
242
231
"""Determine privileges held by a user over this object.
244
233
If the user requesting privileges is this user or an admin,
260
249
code = Unicode(name="subj_code")
261
250
name = Unicode(name="subj_name")
262
251
short_name = Unicode(name="subj_short_name")
264
254
offerings = ReferenceSet(id, 'Offering.subject_id')
268
258
def __repr__(self):
269
259
return "<%s '%s'>" % (type(self).__name__, self.short_name)
271
def get_permissions(self, user, config):
261
def get_permissions(self, user):
272
262
"""Determine privileges held by a user over this object.
274
264
If the user requesting privileges is an admin, they may edit.
332
322
subject = Reference(subject_id, Subject.id)
333
323
semester_id = Int(name="semesterid")
334
324
semester = Reference(semester_id, Semester.id)
335
description = Unicode()
337
show_worksheet_marks = Bool()
338
worksheet_cutoff = DateTime()
339
325
groups_student_permissions = Unicode()
341
327
enrolments = ReferenceSet(id, 'Enrolment.offering_id')
344
330
'Enrolment.user_id',
346
332
project_sets = ReferenceSet(id, 'ProjectSet.offering_id')
347
projects = ReferenceSet(id,
348
'ProjectSet.offering_id',
350
'Project.project_set_id')
352
334
worksheets = ReferenceSet(id,
353
335
'Worksheet.offering_id',
384
366
Enrolment.offering_id == self.id).one()
385
367
Store.of(enrolment).remove(enrolment)
387
def get_permissions(self, user, config):
369
def get_permissions(self, user):
389
371
if user is not None:
390
372
enrolment = self.get_enrolment(user)
391
373
if enrolment or user.admin:
392
374
perms.add('view')
393
if enrolment and enrolment.role == u'tutor':
394
perms.add('view_project_submissions')
395
# Site-specific policy on the role of tutors
396
if config['policy']['tutors_can_enrol_students']:
398
perms.add('enrol_student')
399
if config['policy']['tutors_can_edit_worksheets']:
400
perms.add('edit_worksheets')
401
if config['policy']['tutors_can_admin_groups']:
402
perms.add('admin_groups')
403
if (enrolment and enrolment.role in (u'lecturer')) or user.admin:
404
perms.add('view_project_submissions')
405
perms.add('admin_groups')
406
perms.add('edit_worksheets')
407
perms.add('view_worksheet_marks')
408
perms.add('edit') # Can edit projects & details
409
perms.add('enrol') # Can see enrolment screen at all
410
perms.add('enrol_student') # Can enrol students
411
perms.add('enrol_tutor') # Can enrol tutors
413
perms.add('enrol_lecturer') # Can enrol lecturers
375
if (enrolment and enrolment.role in (u'tutor', u'lecturer')) \
416
380
def get_enrolment(self, user):
427
391
Enrolment.user_id == User.id,
428
392
Enrolment.offering_id == self.id,
429
393
Enrolment.role == role
430
).order_by(User.login)
433
397
def students(self):
434
398
return self.get_members_by_role(u'student')
436
def get_open_projects_for_user(self, user):
437
"""Find all projects currently open to submissions by a user."""
438
# XXX: Respect extensions.
439
return self.projects.find(Project.deadline > datetime.datetime.now())
441
def has_worksheet_cutoff_passed(self, user):
442
"""Check whether the worksheet cutoff has passed.
443
A user is required, in case we support extensions.
445
if self.worksheet_cutoff is None:
448
return self.worksheet_cutoff < datetime.datetime.now()
450
def clone_worksheets(self, source):
451
"""Clone all worksheets from the specified source to this offering."""
452
import ivle.worksheet.utils
453
for worksheet in source.worksheets:
455
newws.seq_no = worksheet.seq_no
456
newws.identifier = worksheet.identifier
457
newws.name = worksheet.name
458
newws.assessable = worksheet.assessable
459
newws.published = worksheet.published
460
newws.data = worksheet.data
461
newws.format = worksheet.format
462
newws.offering = self
463
Store.of(self).add(newws)
464
ivle.worksheet.utils.update_exerciselist(newws)
467
400
class Enrolment(Storm):
468
401
"""An enrolment of a user in an offering.
495
428
return "<%s %r in %r>" % (type(self).__name__, self.user,
498
def get_permissions(self, user, config):
499
# A user can edit any enrolment that they could have created.
501
if ('enrol_' + str(self.role)) in self.offering.get_permissions(
507
"""Delete this enrolment."""
508
Store.of(self).remove(self)
513
433
class ProjectSet(Storm):
533
453
return "<%s %d in %r>" % (type(self).__name__, self.id,
536
def get_permissions(self, user, config):
537
return self.offering.get_permissions(user, config)
539
def get_groups_for_user(self, user):
540
"""List all groups in this offering of which the user is a member."""
542
return Store.of(self).find(
544
ProjectGroupMembership.user_id == user.id,
545
ProjectGroupMembership.project_group_id == ProjectGroup.id,
546
ProjectGroup.project_set_id == self.id)
548
def get_submission_principal(self, user):
549
"""Get the principal on behalf of which the user can submit.
551
If this is a solo project set, the given user is returned. If
552
the user is a member of exactly one group, all the group is
553
returned. Otherwise, None is returned.
556
groups = self.get_groups_for_user(user)
557
if groups.count() == 1:
566
return self.max_students_per_group is not None
456
def get_permissions(self, user):
457
return self.offering.get_permissions(user)
569
460
def assigned(self):
572
463
This will be a Storm ResultSet.
574
465
#If its a solo project, return everyone in offering
466
if self.max_students_per_group is None:
467
return self.offering.students
576
469
return self.project_groups
578
return self.offering.students
580
class DeadlinePassed(Exception):
581
"""An exception indicating that a project cannot be submitted because the
582
deadline has passed."""
586
return "The project deadline has passed"
588
471
class Project(Storm):
589
472
"""A student project for which submissions can be made."""
611
494
return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
612
495
self.project_set.offering)
614
def can_submit(self, principal, user):
497
def can_submit(self, principal):
615
498
return (self in principal.get_projects() and
616
not self.has_deadline_passed(user))
499
self.deadline > datetime.datetime.now())
618
501
def submit(self, principal, path, revision, who):
619
502
"""Submit a Subversion path and revision to a project.
625
508
@param who: The user who is actually making the submission.
628
if not self.can_submit(principal, who):
629
raise DeadlinePassed()
511
if not self.can_submit(principal):
512
raise Exception('cannot submit')
631
514
a = Assessed.get(Store.of(self), principal, self)
632
515
ps = ProjectSubmission()
633
# Raise SubmissionError if the path is illegal
634
ps.path = ProjectSubmission.test_and_normalise_path(path)
635
517
ps.revision = revision
636
518
ps.date_submitted = datetime.datetime.now()
642
def get_permissions(self, user, config):
643
return self.project_set.offering.get_permissions(user, config)
524
def get_permissions(self, user):
525
return self.project_set.offering.get_permissions(user)
646
528
def latest_submissions(self):
658
def has_deadline_passed(self, user):
659
"""Check whether the deadline has passed."""
660
# XXX: Need to respect extensions.
661
return self.deadline < datetime.datetime.now()
663
def get_submissions_for_principal(self, principal):
664
"""Fetch a ResultSet of all submissions by a particular principal."""
665
assessed = Assessed.get(Store.of(self), principal, self)
668
return assessed.submissions
671
def can_delete(self):
672
"""Can only delete if there are no submissions."""
673
return self.submissions.count() == 0
676
"""Delete the project. Fails if can_delete is False."""
677
if not self.can_delete:
678
raise IntegrityError()
679
for assessed in self.assesseds:
681
Store.of(self).remove(self)
683
541
class ProjectGroup(Storm):
684
542
"""A group of students working together on a project."""
734
592
Semester.id == Offering.semester_id,
735
593
(not active_only) or (Semester.state == u'current'))
737
def get_svn_url(self, config, req):
738
"""Get the subversion repository URL for this user or group."""
739
login = req.user.login
740
url = urlparse.urlsplit(config['urls']['svn_addr'])
741
url = urlparse.urlunsplit(url[:1] + (login+'@'+url[1],) + url[2:])
742
path = 'groups/%s_%s_%s_%s' % (
743
self.project_set.offering.subject.short_name,
744
self.project_set.offering.semester.year,
745
self.project_set.offering.semester.semester,
748
return urlparse.urljoin(url, path)
750
def get_permissions(self, user, config):
596
def get_permissions(self, user):
751
597
if user.admin or user in self.members:
752
598
return set(['submit_project'])
789
635
project = Reference(project_id, Project.id)
791
637
extensions = ReferenceSet(id, 'ProjectExtension.assessed_id')
792
submissions = ReferenceSet(
793
id, 'ProjectSubmission.assessed_id', order_by='date_submitted')
638
submissions = ReferenceSet(id, 'ProjectSubmission.assessed_id')
795
640
def __repr__(self):
796
641
return "<%s %r in %r>" % (type(self).__name__,
805
650
def principal(self):
806
651
return self.project_group or self.user
809
def checkout_location(self):
810
"""Returns the location of the Subversion workspace for this piece of
811
assessment, relative to each group member's home directory."""
812
subjectname = self.project.project_set.offering.subject.short_name
814
checkout_dir_name = self.principal.short_name
816
checkout_dir_name = "mywork"
817
return subjectname + "/" + checkout_dir_name
820
654
def get(cls, store, principal, project):
821
655
"""Find or create an Assessed for the given user or group and project.
830
664
a = store.find(cls,
831
665
(t is User) or (cls.project_group_id == principal.id),
832
666
(t is ProjectGroup) or (cls.user_id == principal.id),
833
cls.project_id == project.id).one()
667
Project.id == project.id).one()
847
"""Delete the assessed. Fails if there are any submissions. Deletes
849
if self.submissions.count() > 0:
850
raise IntegrityError()
851
for extension in self.extensions:
853
Store.of(self).remove(self)
855
681
class ProjectExtension(Storm):
856
682
"""An extension granted to a user or group on a particular project.
868
694
approver = Reference(approver_id, User.id)
869
695
notes = Unicode()
872
"""Delete the extension."""
873
Store.of(self).remove(self)
875
class SubmissionError(Exception):
876
"""Denotes a validation error during submission."""
879
697
class ProjectSubmission(Storm):
880
698
"""A submission from a user or group repository to a particular project.
897
715
submitter = Reference(submitter_id, User.id)
898
716
date_submitted = DateTime()
900
def get_verify_url(self, user):
901
"""Get the URL for verifying this submission, within the account of
903
# If this is a solo project, then self.path will be prefixed with the
904
# subject name. Remove the first path segment.
905
submitpath = self.path[1:] if self.path[:1] == '/' else self.path
906
if not self.assessed.is_group:
907
if '/' in submitpath:
908
submitpath = submitpath.split('/', 1)[1]
911
return "/files/%s/%s/%s?r=%d" % (user.login,
912
self.assessed.checkout_location, submitpath, self.revision)
915
def test_and_normalise_path(path):
916
"""Test that path is valid, and normalise it. This prevents possible
917
injections using malicious paths.
918
Returns the updated path, if successful.
919
Raises SubmissionError if invalid.
921
# Ensure the path is absolute to prevent being tacked onto working
923
# Prevent '\n' because it will break all sorts of things.
924
# Prevent '[' and ']' because they can be used to inject into the
926
# Normalise to avoid resulting in ".." path segments.
927
if not os.path.isabs(path):
928
raise SubmissionError("Path is not absolute")
929
if any(c in path for c in "\n[]"):
930
raise SubmissionError("Path must not contain '\\n', '[' or ']'")
931
return os.path.normpath(path)
933
719
# WORKSHEETS AND EXERCISES #
966
751
def __repr__(self):
967
752
return "<%s %s>" % (type(self).__name__, self.name)
969
def get_permissions(self, user, config):
970
return self.global_permissions(user, config)
973
def global_permissions(user, config):
974
"""Gets the set of permissions this user has over *all* exercises.
975
This is used to determine who may view the exercises list, and create
754
def get_permissions(self, user):
979
757
if user is not None:
983
761
elif u'lecturer' in set((e.role for e in user.active_enrolments)):
984
762
perms.add('edit')
985
763
perms.add('view')
986
elif (config['policy']['tutors_can_edit_worksheets']
987
and u'tutor' in set((e.role for e in user.active_enrolments))):
988
# Site-specific policy on the role of tutors
764
elif u'tutor' in set((e.role for e in user.active_enrolments)):
989
765
perms.add('edit')
990
766
perms.add('view')
994
def _cache_description_xhtml(self, invalidate=False):
995
# Don't regenerate an existing cache unless forced.
996
if self._description_xhtml_cache is not None and not invalidate:
1000
self._description_xhtml_cache = rst(self.description)
1002
self._description_xhtml_cache = None
1005
def description_xhtml(self):
1006
"""The XHTML exercise description, converted from reStructuredText."""
1007
self._cache_description_xhtml()
1008
return self._description_xhtml_cache
1010
def set_description(self, description):
1011
self.description = description
1012
self._cache_description_xhtml(invalidate=True)
770
def get_description(self):
771
"""Return the description interpreted as reStructuredText."""
772
return rst(self.description)
1014
774
def delete(self):
1015
775
"""Deletes the exercise, providing it has no associated worksheets."""
1070
828
store.find(WorksheetExercise,
1071
829
WorksheetExercise.worksheet == self).remove()
1073
def get_permissions(self, user, config):
1074
offering_perms = self.offering.get_permissions(user, config)
1078
# Anybody who can view an offering can view a published
1080
if 'view' in offering_perms and self.published:
1083
# Any worksheet editors can both view and edit.
1084
if 'edit_worksheets' in offering_perms:
1090
def _cache_data_xhtml(self, invalidate=False):
1091
# Don't regenerate an existing cache unless forced.
1092
if self._data_xhtml_cache is not None and not invalidate:
1095
if self.format == u'rst':
1096
self._data_xhtml_cache = rst(self.data)
1098
self._data_xhtml_cache = None
1101
def data_xhtml(self):
1102
"""The XHTML of this worksheet, converted from rST if required."""
1103
# Update the rST -> XHTML cache, if required.
1104
self._cache_data_xhtml()
1106
if self.format == u'rst':
1107
return self._data_xhtml_cache
831
def get_permissions(self, user):
832
return self.offering.get_permissions(user)
835
"""Returns the xml of this worksheet, converts from rst if required."""
836
if self.format == u'rst':
837
ws_xml = rst(self.data)
1109
840
return self.data
1111
def set_data(self, data):
1113
self._cache_data_xhtml(invalidate=True)
1115
842
def delete(self):
1116
843
"""Deletes the worksheet, provided it has no attempts on any exercises.
1153
880
return "<%s %s in %s>" % (type(self).__name__, self.exercise.name,
1154
881
self.worksheet.identifier)
1156
def get_permissions(self, user, config):
1157
return self.worksheet.get_permissions(user, config)
883
def get_permissions(self, user):
884
return self.worksheet.get_permissions(user)
1160
887
class ExerciseSave(Storm):
1180
907
def __repr__(self):
1181
908
return "<%s %s by %s at %s>" % (type(self).__name__,
1182
self.worksheet_exercise.exercise.name, self.user.login,
1183
self.date.strftime("%c"))
909
self.exercise.name, self.user.login, self.date.strftime("%c"))
1185
911
class ExerciseAttempt(ExerciseSave):
1186
912
"""An attempt at solving an exercise.
1208
934
complete = Bool()
1211
def get_permissions(self, user, config):
937
def get_permissions(self, user):
1212
938
return set(['view']) if user is self.user else set()
1214
940
class TestSuite(Storm):
1234
960
def delete(self):
1235
961
"""Delete this suite, without asking questions."""
1236
for variable in self.variables:
962
for vaariable in self.variables:
1237
963
variable.delete()
1238
964
for test_case in self.test_cases:
1239
965
test_case.delete()