30
32
from storm.locals import create_database, Store, Int, Unicode, DateTime, \
31
33
Reference, ReferenceSet, Bool, Storm, Desc
229
231
"""Find a user in a store by login name."""
230
232
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)
232
240
def get_permissions(self, user, config):
233
241
"""Determine privileges held by a user over this object.
291
299
return self.offerings.find(Offering.semester_id == Semester.id,
292
300
Semester.year == unicode(year),
293
Semester.semester == unicode(semester)).one()
301
Semester.url_name == unicode(semester)).one()
295
303
class Semester(Storm):
296
304
"""A semester in which subjects can be run."""
311
321
__init__ = _kwarg_init
313
323
def __repr__(self):
314
return "<%s %s/%s>" % (type(self).__name__, self.year, self.semester)
324
return "<%s %s/%s>" % (type(self).__name__, self.year, self.code)
316
326
class Offering(Storm):
317
327
"""An offering of a subject in a particular semester."""
325
335
semester = Reference(semester_id, Semester.id)
326
336
description = Unicode()
338
show_worksheet_marks = Bool()
339
worksheet_cutoff = DateTime()
328
340
groups_student_permissions = Unicode()
330
342
enrolments = ReferenceSet(id, 'Enrolment.offering_id')
393
405
perms.add('view_project_submissions')
394
406
perms.add('admin_groups')
395
407
perms.add('edit_worksheets')
408
perms.add('view_worksheet_marks')
396
409
perms.add('edit') # Can edit projects & details
397
410
perms.add('enrol') # Can see enrolment screen at all
398
411
perms.add('enrol_student') # Can enrol students
426
439
# XXX: Respect extensions.
427
440
return self.projects.find(Project.deadline > datetime.datetime.now())
442
def has_worksheet_cutoff_passed(self, user):
443
"""Check whether the worksheet cutoff has passed.
444
A user is required, in case we support extensions.
446
if self.worksheet_cutoff is None:
449
return self.worksheet_cutoff < datetime.datetime.now()
429
451
def clone_worksheets(self, source):
430
452
"""Clone all worksheets from the specified source to this offering."""
431
453
import ivle.worksheet.utils
435
457
newws.identifier = worksheet.identifier
436
458
newws.name = worksheet.name
437
459
newws.assessable = worksheet.assessable
460
newws.published = worksheet.published
438
461
newws.data = worksheet.data
439
462
newws.format = worksheet.format
440
463
newws.offering = self
589
612
return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
590
613
self.project_set.offering)
592
def can_submit(self, principal, user):
615
def can_submit(self, principal, user, late=False):
617
@param late: If True, does not take the deadline into account.
593
619
return (self in principal.get_projects() and
594
not self.has_deadline_passed(user))
620
(late or not self.has_deadline_passed(user)))
596
def submit(self, principal, path, revision, who):
622
def submit(self, principal, path, revision, who, late=False):
597
623
"""Submit a Subversion path and revision to a project.
599
625
@param principal: The owner of the Subversion repository, and the
601
627
@param path: A path within that repository to submit.
602
628
@param revision: The revision of that path to submit.
603
629
@param who: The user who is actually making the submission.
630
@param late: If True, will not raise a DeadlinePassed exception even
631
after the deadline. (Default False.)
606
if not self.can_submit(principal, who):
634
if not self.can_submit(principal, who, late=late):
607
635
raise DeadlinePassed()
609
637
a = Assessed.get(Store.of(self), principal, self)
646
674
return assessed.submissions
677
def can_delete(self):
678
"""Can only delete if there are no submissions."""
679
return self.submissions.count() == 0
682
"""Delete the project. Fails if can_delete is False."""
683
if not self.can_delete:
684
raise IntegrityError()
685
for assessed in self.assesseds:
687
Store.of(self).remove(self)
650
689
class ProjectGroup(Storm):
651
690
"""A group of students working together on a project."""
701
740
Semester.id == Offering.semester_id,
702
741
(not active_only) or (Semester.state == u'current'))
743
def get_svn_url(self, config):
744
"""Get the subversion repository URL for this user or group."""
745
url = config['urls']['svn_addr']
746
path = 'groups/%s_%s_%s_%s' % (
747
self.project_set.offering.subject.short_name,
748
self.project_set.offering.semester.year,
749
self.project_set.offering.semester.url_name,
752
return urlparse.urljoin(url, path)
705
754
def get_permissions(self, user, config):
706
755
if user.admin or user in self.members:
851
"""Delete the assessed. Fails if there are any submissions. Deletes
853
if self.submissions.count() > 0:
854
raise IntegrityError()
855
for extension in self.extensions:
857
Store.of(self).remove(self)
802
859
class ProjectExtension(Storm):
803
860
"""An extension granted to a user or group on a particular project.
810
867
id = Int(name="extensionid", primary=True)
811
868
assessed_id = Int(name="assessedid")
812
869
assessed = Reference(assessed_id, Assessed.id)
813
deadline = DateTime()
814
871
approver_id = Int(name="approver")
815
872
approver = Reference(approver_id, User.id)
816
873
notes = Unicode()
876
"""Delete the extension."""
877
Store.of(self).remove(self)
818
879
class SubmissionError(Exception):
819
880
"""Denotes a validation error during submission."""
854
915
return "/files/%s/%s/%s?r=%d" % (user.login,
855
916
self.assessed.checkout_location, submitpath, self.revision)
918
def get_svn_url(self, config):
919
"""Get subversion URL for this submission"""
920
princ = self.assessed.principal
921
base = princ.get_svn_url(config)
922
if self.path.startswith(os.sep):
923
return os.path.join(base,
924
urllib.quote(self.path[1:].encode('utf-8')))
926
return os.path.join(base, urllib.quote(self.path.encode('utf-8')))
928
def get_svn_export_command(self, req):
929
"""Returns a Unix shell command to export a submission"""
930
svn_url = self.get_svn_url(req.config)
931
_, ext = os.path.splitext(svn_url)
932
username = (req.user.login if req.user.login.isalnum() else
933
"'%s'"%req.user.login)
934
# Export to a file or directory relative to the current directory,
935
# with the student's login name, appended with the submitted file's
937
export_path = self.assessed.principal.short_name + ext
938
return "svn export --username %s -r%d '%s' %s"%(req.user.login,
939
self.revision, svn_url, export_path)
858
942
def test_and_normalise_path(path):
859
943
"""Test that path is valid, and normalise it. This prevents possible
873
957
raise SubmissionError("Path must not contain '\\n', '[' or ']'")
874
958
return os.path.normpath(path)
962
"""True if the project was submitted late."""
963
return self.days_late > 0
967
"""The number of days the project was submitted late (rounded up), or
969
# XXX: Need to respect extensions.
971
(self.date_submitted - self.assessed.project.deadline).days + 1)
876
973
# WORKSHEETS AND EXERCISES #
878
975
class Exercise(Storm):
936
def get_description(self):
937
"""Return the description interpreted as reStructuredText."""
938
return rst(self.description)
1034
def _cache_description_xhtml(self, invalidate=False):
1035
# Don't regenerate an existing cache unless forced.
1036
if self._description_xhtml_cache is not None and not invalidate:
1039
if self.description:
1040
self._description_xhtml_cache = rst(self.description)
1042
self._description_xhtml_cache = None
1045
def description_xhtml(self):
1046
"""The XHTML exercise description, converted from reStructuredText."""
1047
self._cache_description_xhtml()
1048
return self._description_xhtml_cache
1050
def set_description(self, description):
1051
self.description = description
1052
self._cache_description_xhtml(invalidate=True)
940
1054
def delete(self):
941
1055
"""Deletes the exercise, providing it has no associated worksheets."""
995
1111
WorksheetExercise.worksheet == self).remove()
997
1113
def get_permissions(self, user, config):
998
# Almost the same permissions as for the offering itself
999
perms = self.offering.get_permissions(user, config)
1000
# However, "edit" permission is derived from the "edit_worksheets"
1001
# permission of the offering
1002
if 'edit_worksheets' in perms:
1114
offering_perms = self.offering.get_permissions(user, config)
1118
# Anybody who can view an offering can view a published
1120
if 'view' in offering_perms and self.published:
1123
# Any worksheet editors can both view and edit.
1124
if 'edit_worksheets' in offering_perms:
1003
1126
perms.add('edit')
1005
perms.discard('edit')
1009
"""Returns the xml of this worksheet, converts from rst if required."""
1010
if self.format == u'rst':
1011
ws_xml = rst(self.data)
1130
def _cache_data_xhtml(self, invalidate=False):
1131
# Don't regenerate an existing cache unless forced.
1132
if self._data_xhtml_cache is not None and not invalidate:
1135
if self.format == u'rst':
1136
self._data_xhtml_cache = rst(self.data)
1138
self._data_xhtml_cache = None
1141
def data_xhtml(self):
1142
"""The XHTML of this worksheet, converted from rST if required."""
1143
# Update the rST -> XHTML cache, if required.
1144
self._cache_data_xhtml()
1146
if self.format == u'rst':
1147
return self._data_xhtml_cache
1014
1149
return self.data
1151
def set_data(self, data):
1153
self._cache_data_xhtml(invalidate=True)
1016
1155
def delete(self):
1017
1156
"""Deletes the worksheet, provided it has no attempts on any exercises.
1081
1220
def __repr__(self):
1082
1221
return "<%s %s by %s at %s>" % (type(self).__name__,
1083
self.exercise.name, self.user.login, self.date.strftime("%c"))
1222
self.worksheet_exercise.exercise.name, self.user.login,
1223
self.date.strftime("%c"))
1085
1225
class ExerciseAttempt(ExerciseSave):
1086
1226
"""An attempt at solving an exercise.