32
29
from storm.locals import create_database, Store, Int, Unicode, DateTime, \
33
30
Reference, ReferenceSet, Bool, Storm, Desc
218
215
Semester.id == Offering.semester_id,
219
216
(not active_only) or (Semester.state == u'current'),
220
217
Enrolment.offering_id == Offering.id,
221
Enrolment.user_id == self.id,
222
Enrolment.active == True)
218
Enrolment.user_id == self.id)
225
221
def hash_password(password):
231
227
"""Find a user in a store by login name."""
232
228
return store.find(cls, cls.login == unicode(login)).one()
234
def get_svn_url(self, config):
235
"""Get the subversion repository URL for this user or group."""
236
url = config['urls']['svn_addr']
237
path = 'users/%s' % self.login
238
return urlparse.urljoin(url, path)
240
def get_permissions(self, user, config):
230
def get_permissions(self, user):
241
231
"""Determine privileges held by a user over this object.
243
233
If the user requesting privileges is this user or an admin,
267
257
def __repr__(self):
268
258
return "<%s '%s'>" % (type(self).__name__, self.short_name)
270
def get_permissions(self, user, config):
260
def get_permissions(self, user):
271
261
"""Determine privileges held by a user over this object.
273
263
If the user requesting privileges is an admin, they may edit.
333
323
semester = Reference(semester_id, Semester.id)
334
324
description = Unicode()
336
show_worksheet_marks = Bool()
337
worksheet_cutoff = DateTime()
338
326
groups_student_permissions = Unicode()
340
328
enrolments = ReferenceSet(id, 'Enrolment.offering_id')
383
371
Enrolment.offering_id == self.id).one()
384
372
Store.of(enrolment).remove(enrolment)
386
def get_permissions(self, user, config):
374
def get_permissions(self, user):
388
376
if user is not None:
389
377
enrolment = self.get_enrolment(user)
390
378
if enrolment or user.admin:
391
379
perms.add('view')
392
if enrolment and enrolment.role == u'tutor':
393
perms.add('view_project_submissions')
394
# Site-specific policy on the role of tutors
395
if config['policy']['tutors_can_enrol_students']:
397
perms.add('enrol_student')
398
if config['policy']['tutors_can_edit_worksheets']:
399
perms.add('edit_worksheets')
400
if config['policy']['tutors_can_admin_groups']:
401
perms.add('admin_groups')
402
if (enrolment and enrolment.role in (u'lecturer')) or user.admin:
403
perms.add('view_project_submissions')
404
perms.add('admin_groups')
405
perms.add('edit_worksheets')
406
perms.add('view_worksheet_marks')
407
perms.add('edit') # Can edit projects & details
380
if (enrolment and enrolment.role in (u'tutor', u'lecturer')) \
383
# XXX Bug #493945 -- should tutors have these permissions?
384
# Potentially move into the next category (lecturer & admin)
408
385
perms.add('enrol') # Can see enrolment screen at all
409
386
perms.add('enrol_student') # Can enrol students
387
if (enrolment and enrolment.role in (u'lecturer')) or user.admin:
410
388
perms.add('enrol_tutor') # Can enrol tutors
412
390
perms.add('enrol_lecturer') # Can enrol lecturers
437
415
# XXX: Respect extensions.
438
416
return self.projects.find(Project.deadline > datetime.datetime.now())
440
def has_worksheet_cutoff_passed(self, user):
441
"""Check whether the worksheet cutoff has passed.
442
A user is required, in case we support extensions.
444
if self.worksheet_cutoff is None:
447
return self.worksheet_cutoff < datetime.datetime.now()
449
def clone_worksheets(self, source):
450
"""Clone all worksheets from the specified source to this offering."""
451
import ivle.worksheet.utils
452
for worksheet in source.worksheets:
454
newws.seq_no = worksheet.seq_no
455
newws.identifier = worksheet.identifier
456
newws.name = worksheet.name
457
newws.assessable = worksheet.assessable
458
newws.published = worksheet.published
459
newws.data = worksheet.data
460
newws.format = worksheet.format
461
newws.offering = self
462
Store.of(self).add(newws)
463
ivle.worksheet.utils.update_exerciselist(newws)
466
418
class Enrolment(Storm):
467
419
"""An enrolment of a user in an offering.
494
446
return "<%s %r in %r>" % (type(self).__name__, self.user,
497
def get_permissions(self, user, config):
498
# A user can edit any enrolment that they could have created.
500
if ('enrol_' + str(self.role)) in self.offering.get_permissions(
506
"""Delete this enrolment."""
507
Store.of(self).remove(self)
512
451
class ProjectSet(Storm):
532
471
return "<%s %d in %r>" % (type(self).__name__, self.id,
535
def get_permissions(self, user, config):
536
return self.offering.get_permissions(user, config)
474
def get_permissions(self, user):
475
return self.offering.get_permissions(user)
538
477
def get_groups_for_user(self, user):
539
478
"""List all groups in this offering of which the user is a member."""
610
549
return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
611
550
self.project_set.offering)
613
def can_submit(self, principal, user, late=False):
615
@param late: If True, does not take the deadline into account.
552
def can_submit(self, principal, user):
617
553
return (self in principal.get_projects() and
618
(late or not self.has_deadline_passed(user)))
554
not self.has_deadline_passed(user))
620
def submit(self, principal, path, revision, who, late=False):
556
def submit(self, principal, path, revision, who):
621
557
"""Submit a Subversion path and revision to a project.
623
559
@param principal: The owner of the Subversion repository, and the
625
561
@param path: A path within that repository to submit.
626
562
@param revision: The revision of that path to submit.
627
563
@param who: The user who is actually making the submission.
628
@param late: If True, will not raise a DeadlinePassed exception even
629
after the deadline. (Default False.)
632
if not self.can_submit(principal, who, late=late):
566
if not self.can_submit(principal, who):
633
567
raise DeadlinePassed()
635
569
a = Assessed.get(Store.of(self), principal, self)
636
570
ps = ProjectSubmission()
637
# Raise SubmissionError if the path is illegal
638
ps.path = ProjectSubmission.test_and_normalise_path(path)
639
572
ps.revision = revision
640
573
ps.date_submitted = datetime.datetime.now()
646
def get_permissions(self, user, config):
647
return self.project_set.offering.get_permissions(user, config)
579
def get_permissions(self, user):
580
return self.project_set.offering.get_permissions(user)
650
583
def latest_submissions(self):
672
605
return assessed.submissions
675
def can_delete(self):
676
"""Can only delete if there are no submissions."""
677
return self.submissions.count() == 0
680
"""Delete the project. Fails if can_delete is False."""
681
if not self.can_delete:
682
raise IntegrityError()
683
for assessed in self.assesseds:
685
Store.of(self).remove(self)
687
609
class ProjectGroup(Storm):
688
610
"""A group of students working together on a project."""
738
660
Semester.id == Offering.semester_id,
739
661
(not active_only) or (Semester.state == u'current'))
741
def get_svn_url(self, config):
742
"""Get the subversion repository URL for this user or group."""
743
url = config['urls']['svn_addr']
744
path = 'groups/%s_%s_%s_%s' % (
745
self.project_set.offering.subject.short_name,
746
self.project_set.offering.semester.year,
747
self.project_set.offering.semester.semester,
750
return urlparse.urljoin(url, path)
752
def get_permissions(self, user, config):
664
def get_permissions(self, user):
753
665
if user.admin or user in self.members:
754
666
return set(['submit_project'])
849
"""Delete the assessed. Fails if there are any submissions. Deletes
851
if self.submissions.count() > 0:
852
raise IntegrityError()
853
for extension in self.extensions:
855
Store.of(self).remove(self)
857
761
class ProjectExtension(Storm):
858
762
"""An extension granted to a user or group on a particular project.
870
774
approver = Reference(approver_id, User.id)
871
775
notes = Unicode()
874
"""Delete the extension."""
875
Store.of(self).remove(self)
877
class SubmissionError(Exception):
878
"""Denotes a validation error during submission."""
881
777
class ProjectSubmission(Storm):
882
778
"""A submission from a user or group repository to a particular project.
913
809
return "/files/%s/%s/%s?r=%d" % (user.login,
914
810
self.assessed.checkout_location, submitpath, self.revision)
916
def get_svn_url(self, config):
917
"""Get subversion URL for this submission"""
918
princ = self.assessed.principal
919
base = princ.get_svn_url(config)
920
if self.path.startswith(os.sep):
921
return os.path.join(base,
922
urllib.quote(self.path[1:].encode('utf-8')))
924
return os.path.join(base, urllib.quote(self.path.encode('utf-8')))
926
def get_svn_export_command(self, req):
927
"""Returns a Unix shell command to export a submission"""
928
svn_url = self.get_svn_url(req.config)
929
username = (req.user.login if req.user.login.isalnum() else
930
"'%s'"%req.user.login)
931
export_dir = self.assessed.principal.short_name
932
return "svn export --username %s -r%d '%s' %s"%(req.user.login,
933
self.revision, svn_url, export_dir)
936
def test_and_normalise_path(path):
937
"""Test that path is valid, and normalise it. This prevents possible
938
injections using malicious paths.
939
Returns the updated path, if successful.
940
Raises SubmissionError if invalid.
942
# Ensure the path is absolute to prevent being tacked onto working
944
# Prevent '\n' because it will break all sorts of things.
945
# Prevent '[' and ']' because they can be used to inject into the
947
# Normalise to avoid resulting in ".." path segments.
948
if not os.path.isabs(path):
949
raise SubmissionError("Path is not absolute")
950
if any(c in path for c in "\n[]"):
951
raise SubmissionError("Path must not contain '\\n', '[' or ']'")
952
return os.path.normpath(path)
956
"""True if the project was submitted late."""
957
return self.days_late > 0
961
"""The number of days the project was submitted late (rounded up), or
963
# XXX: Need to respect extensions.
965
(self.date_submitted - self.assessed.project.deadline).days + 1)
967
812
# WORKSHEETS AND EXERCISES #
969
814
class Exercise(Storm):
1000
844
def __repr__(self):
1001
845
return "<%s %s>" % (type(self).__name__, self.name)
1003
def get_permissions(self, user, config):
1004
return self.global_permissions(user, config)
1007
def global_permissions(user, config):
1008
"""Gets the set of permissions this user has over *all* exercises.
1009
This is used to determine who may view the exercises list, and create
847
def get_permissions(self, user):
1013
850
if user is not None:
1017
854
elif u'lecturer' in set((e.role for e in user.active_enrolments)):
1018
855
perms.add('edit')
1019
856
perms.add('view')
1020
elif (config['policy']['tutors_can_edit_worksheets']
1021
and u'tutor' in set((e.role for e in user.active_enrolments))):
1022
# Site-specific policy on the role of tutors
857
elif u'tutor' in set((e.role for e in user.active_enrolments)):
1023
858
perms.add('edit')
1024
859
perms.add('view')
1028
def _cache_description_xhtml(self, invalidate=False):
1029
# Don't regenerate an existing cache unless forced.
1030
if self._description_xhtml_cache is not None and not invalidate:
1033
if self.description:
1034
self._description_xhtml_cache = rst(self.description)
1036
self._description_xhtml_cache = None
1039
def description_xhtml(self):
1040
"""The XHTML exercise description, converted from reStructuredText."""
1041
self._cache_description_xhtml()
1042
return self._description_xhtml_cache
1044
def set_description(self, description):
1045
self.description = description
1046
self._cache_description_xhtml(invalidate=True)
863
def get_description(self):
864
"""Return the description interpreted as reStructuredText."""
865
return rst(self.description)
1048
867
def delete(self):
1049
868
"""Deletes the exercise, providing it has no associated worksheets."""
1104
921
store.find(WorksheetExercise,
1105
922
WorksheetExercise.worksheet == self).remove()
1107
def get_permissions(self, user, config):
1108
offering_perms = self.offering.get_permissions(user, config)
1112
# Anybody who can view an offering can view a published
1114
if 'view' in offering_perms and self.published:
1117
# Any worksheet editors can both view and edit.
1118
if 'edit_worksheets' in offering_perms:
1124
def _cache_data_xhtml(self, invalidate=False):
1125
# Don't regenerate an existing cache unless forced.
1126
if self._data_xhtml_cache is not None and not invalidate:
1129
if self.format == u'rst':
1130
self._data_xhtml_cache = rst(self.data)
1132
self._data_xhtml_cache = None
1135
def data_xhtml(self):
1136
"""The XHTML of this worksheet, converted from rST if required."""
1137
# Update the rST -> XHTML cache, if required.
1138
self._cache_data_xhtml()
1140
if self.format == u'rst':
1141
return self._data_xhtml_cache
924
def get_permissions(self, user):
925
return self.offering.get_permissions(user)
928
"""Returns the xml of this worksheet, converts from rst if required."""
929
if self.format == u'rst':
930
ws_xml = rst(self.data)
1143
933
return self.data
1145
def set_data(self, data):
1147
self._cache_data_xhtml(invalidate=True)
1149
935
def delete(self):
1150
936
"""Deletes the worksheet, provided it has no attempts on any exercises.
1187
973
return "<%s %s in %s>" % (type(self).__name__, self.exercise.name,
1188
974
self.worksheet.identifier)
1190
def get_permissions(self, user, config):
1191
return self.worksheet.get_permissions(user, config)
976
def get_permissions(self, user):
977
return self.worksheet.get_permissions(user)
1194
980
class ExerciseSave(Storm):
1214
1000
def __repr__(self):
1215
1001
return "<%s %s by %s at %s>" % (type(self).__name__,
1216
self.worksheet_exercise.exercise.name, self.user.login,
1217
self.date.strftime("%c"))
1002
self.exercise.name, self.user.login, self.date.strftime("%c"))
1219
1004
class ExerciseAttempt(ExerciseSave):
1220
1005
"""An attempt at solving an exercise.