~azzar1/unity/add-show-desktop-key

« back to all changes in this revision

Viewing changes to ivle/database.py

  • Committer: William Grant
  • Date: 2012-06-28 01:52:02 UTC
  • Revision ID: me@williamgrant.id.au-20120628015202-f6ru7o367gt6nvgz
Hah

Show diffs side-by-side

added added

removed removed

Lines of Context:
25
25
 
26
26
import hashlib
27
27
import datetime
 
28
import os
 
29
import urlparse
 
30
import urllib
28
31
 
29
32
from storm.locals import create_database, Store, Int, Unicode, DateTime, \
30
33
                         Reference, ReferenceSet, Bool, Storm, Desc
147
150
            Offering.semester_id == Semester.id,
148
151
            Offering.subject_id == Subject.id).order_by(
149
152
                Desc(Semester.year),
150
 
                Desc(Semester.semester),
 
153
                Desc(Semester.display_name),
151
154
                Desc(Subject.code)
152
155
            )
153
156
 
228
231
        """Find a user in a store by login name."""
229
232
        return store.find(cls, cls.login == unicode(login)).one()
230
233
 
 
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)
 
239
 
231
240
    def get_permissions(self, user, config):
232
241
        """Determine privileges held by a user over this object.
233
242
 
289
298
        """
290
299
        return self.offerings.find(Offering.semester_id == Semester.id,
291
300
                               Semester.year == unicode(year),
292
 
                               Semester.semester == unicode(semester)).one()
 
301
                               Semester.url_name == unicode(semester)).one()
293
302
 
294
303
class Semester(Storm):
295
304
    """A semester in which subjects can be run."""
298
307
 
299
308
    id = Int(primary=True, name="semesterid")
300
309
    year = Unicode()
301
 
    semester = Unicode()
 
310
    code = Unicode()
 
311
    url_name = Unicode()
 
312
    display_name = Unicode()
302
313
    state = Unicode()
303
314
 
304
315
    offerings = ReferenceSet(id, 'Offering.semester_id')
310
321
    __init__ = _kwarg_init
311
322
 
312
323
    def __repr__(self):
313
 
        return "<%s %s/%s>" % (type(self).__name__, self.year, self.semester)
 
324
        return "<%s %s/%s>" % (type(self).__name__, self.year, self.code)
314
325
 
315
326
class Offering(Storm):
316
327
    """An offering of a subject in a particular semester."""
324
335
    semester = Reference(semester_id, Semester.id)
325
336
    description = Unicode()
326
337
    url = Unicode()
 
338
    show_worksheet_marks = Bool()
 
339
    worksheet_cutoff = DateTime()
327
340
    groups_student_permissions = Unicode()
328
341
 
329
342
    enrolments = ReferenceSet(id, 'Enrolment.offering_id')
392
405
                perms.add('view_project_submissions')
393
406
                perms.add('admin_groups')
394
407
                perms.add('edit_worksheets')
 
408
                perms.add('view_worksheet_marks')
395
409
                perms.add('edit')           # Can edit projects & details
396
410
                perms.add('enrol')          # Can see enrolment screen at all
397
411
                perms.add('enrol_student')  # Can enrol students
425
439
        # XXX: Respect extensions.
426
440
        return self.projects.find(Project.deadline > datetime.datetime.now())
427
441
 
 
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.
 
445
        """
 
446
        if self.worksheet_cutoff is None:
 
447
            return False
 
448
        else:
 
449
            return self.worksheet_cutoff < datetime.datetime.now()
 
450
 
 
451
    def clone_worksheets(self, source):
 
452
        """Clone all worksheets from the specified source to this offering."""
 
453
        import ivle.worksheet.utils
 
454
        for worksheet in source.worksheets:
 
455
            newws = Worksheet()
 
456
            newws.seq_no = worksheet.seq_no
 
457
            newws.identifier = worksheet.identifier
 
458
            newws.name = worksheet.name
 
459
            newws.assessable = worksheet.assessable
 
460
            newws.published = worksheet.published
 
461
            newws.data = worksheet.data
 
462
            newws.format = worksheet.format
 
463
            newws.offering = self
 
464
            Store.of(self).add(newws)
 
465
            ivle.worksheet.utils.update_exerciselist(newws)
 
466
 
 
467
 
428
468
class Enrolment(Storm):
429
469
    """An enrolment of a user in an offering.
430
470
 
456
496
        return "<%s %r in %r>" % (type(self).__name__, self.user,
457
497
                                  self.offering)
458
498
 
 
499
    def get_permissions(self, user, config):
 
500
        # A user can edit any enrolment that they could have created.
 
501
        perms = set()
 
502
        if ('enrol_' + str(self.role)) in self.offering.get_permissions(
 
503
            user, config):
 
504
            perms.add('edit')
 
505
        return perms
 
506
 
 
507
    def delete(self):
 
508
        """Delete this enrolment."""
 
509
        Store.of(self).remove(self)
 
510
 
 
511
 
459
512
# PROJECTS #
460
513
 
461
514
class ProjectSet(Storm):
559
612
        return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
560
613
                                  self.project_set.offering)
561
614
 
562
 
    def can_submit(self, principal, user):
 
615
    def can_submit(self, principal, user, late=False):
 
616
        """
 
617
        @param late: If True, does not take the deadline into account.
 
618
        """
563
619
        return (self in principal.get_projects() and
564
 
                not self.has_deadline_passed(user))
 
620
                (late or not self.has_deadline_passed(user)))
565
621
 
566
 
    def submit(self, principal, path, revision, who):
 
622
    def submit(self, principal, path, revision, who, late=False):
567
623
        """Submit a Subversion path and revision to a project.
568
624
 
569
625
        @param principal: The owner of the Subversion repository, and the
571
627
        @param path: A path within that repository to submit.
572
628
        @param revision: The revision of that path to submit.
573
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.)
574
632
        """
575
633
 
576
 
        if not self.can_submit(principal, who):
 
634
        if not self.can_submit(principal, who, late=late):
577
635
            raise DeadlinePassed()
578
636
 
579
637
        a = Assessed.get(Store.of(self), principal, self)
580
638
        ps = ProjectSubmission()
581
 
        ps.path = path
 
639
        # Raise SubmissionError if the path is illegal
 
640
        ps.path = ProjectSubmission.test_and_normalise_path(path)
582
641
        ps.revision = revision
583
642
        ps.date_submitted = datetime.datetime.now()
584
643
        ps.assessed = a
614
673
            return
615
674
        return assessed.submissions
616
675
 
 
676
    @property
 
677
    def can_delete(self):
 
678
        """Can only delete if there are no submissions."""
 
679
        return self.submissions.count() == 0
617
680
 
 
681
    def delete(self):
 
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:
 
686
            assessed.delete()
 
687
        Store.of(self).remove(self)
618
688
 
619
689
class ProjectGroup(Storm):
620
690
    """A group of students working together on a project."""
670
740
            Semester.id == Offering.semester_id,
671
741
            (not active_only) or (Semester.state == u'current'))
672
742
 
 
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,
 
750
                self.name
 
751
                )
 
752
        return urlparse.urljoin(url, path)
673
753
 
674
754
    def get_permissions(self, user, config):
675
755
        if user.admin or user in self.members:
767
847
 
768
848
        return a
769
849
 
 
850
    def delete(self):
 
851
        """Delete the assessed. Fails if there are any submissions. Deletes
 
852
        extensions."""
 
853
        if self.submissions.count() > 0:
 
854
            raise IntegrityError()
 
855
        for extension in self.extensions:
 
856
            extension.delete()
 
857
        Store.of(self).remove(self)
770
858
 
771
859
class ProjectExtension(Storm):
772
860
    """An extension granted to a user or group on a particular project.
779
867
    id = Int(name="extensionid", primary=True)
780
868
    assessed_id = Int(name="assessedid")
781
869
    assessed = Reference(assessed_id, Assessed.id)
782
 
    deadline = DateTime()
 
870
    days = Int()
783
871
    approver_id = Int(name="approver")
784
872
    approver = Reference(approver_id, User.id)
785
873
    notes = Unicode()
786
874
 
 
875
    def delete(self):
 
876
        """Delete the extension."""
 
877
        Store.of(self).remove(self)
 
878
 
 
879
class SubmissionError(Exception):
 
880
    """Denotes a validation error during submission."""
 
881
    pass
 
882
 
787
883
class ProjectSubmission(Storm):
788
884
    """A submission from a user or group repository to a particular project.
789
885
 
819
915
        return "/files/%s/%s/%s?r=%d" % (user.login,
820
916
            self.assessed.checkout_location, submitpath, self.revision)
821
917
 
 
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')))
 
925
        else:
 
926
            return os.path.join(base, urllib.quote(self.path.encode('utf-8')))
 
927
 
 
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
 
936
        # extension, if any
 
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)
 
940
 
 
941
    @staticmethod
 
942
    def test_and_normalise_path(path):
 
943
        """Test that path is valid, and normalise it. This prevents possible
 
944
        injections using malicious paths.
 
945
        Returns the updated path, if successful.
 
946
        Raises SubmissionError if invalid.
 
947
        """
 
948
        # Ensure the path is absolute to prevent being tacked onto working
 
949
        # directories.
 
950
        # Prevent '\n' because it will break all sorts of things.
 
951
        # Prevent '[' and ']' because they can be used to inject into the
 
952
        # svn.conf.
 
953
        # Normalise to avoid resulting in ".." path segments.
 
954
        if not os.path.isabs(path):
 
955
            raise SubmissionError("Path is not absolute")
 
956
        if any(c in path for c in "\n[]"):
 
957
            raise SubmissionError("Path must not contain '\\n', '[' or ']'")
 
958
        return os.path.normpath(path)
 
959
 
 
960
    @property
 
961
    def late(self):
 
962
        """True if the project was submitted late."""
 
963
        return self.days_late > 0
 
964
 
 
965
    @property
 
966
    def days_late(self):
 
967
        """The number of days the project was submitted late (rounded up), or
 
968
        0 if on-time."""
 
969
        # XXX: Need to respect extensions.
 
970
        return max(0,
 
971
            (self.date_submitted - self.assessed.project.deadline).days + 1)
 
972
 
822
973
# WORKSHEETS AND EXERCISES #
823
974
 
824
975
class Exercise(Storm):
831
982
    id = Unicode(primary=True, name="identifier")
832
983
    name = Unicode()
833
984
    description = Unicode()
 
985
    _description_xhtml_cache = Unicode(name='description_xhtml_cache')
834
986
    partial = Unicode()
835
987
    solution = Unicode()
836
988
    include = Unicode()
879
1031
 
880
1032
        return perms
881
1033
 
882
 
    def get_description(self):
883
 
        """Return the description interpreted as reStructuredText."""
884
 
        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:
 
1037
            return
 
1038
 
 
1039
        if self.description:
 
1040
            self._description_xhtml_cache = rst(self.description)
 
1041
        else:
 
1042
            self._description_xhtml_cache = None
 
1043
 
 
1044
    @property
 
1045
    def description_xhtml(self):
 
1046
        """The XHTML exercise description, converted from reStructuredText."""
 
1047
        self._cache_description_xhtml()
 
1048
        return self._description_xhtml_cache
 
1049
 
 
1050
    def set_description(self, description):
 
1051
        self.description = description
 
1052
        self._cache_description_xhtml(invalidate=True)
885
1053
 
886
1054
    def delete(self):
887
1055
        """Deletes the exercise, providing it has no associated worksheets."""
904
1072
    identifier = Unicode()
905
1073
    name = Unicode()
906
1074
    assessable = Bool()
 
1075
    published = Bool()
907
1076
    data = Unicode()
 
1077
    _data_xhtml_cache = Unicode(name='data_xhtml_cache')
908
1078
    seq_no = Int()
909
1079
    format = Unicode()
910
1080
 
941
1111
            WorksheetExercise.worksheet == self).remove()
942
1112
 
943
1113
    def get_permissions(self, user, config):
944
 
        # Almost the same permissions as for the offering itself
945
 
        perms = self.offering.get_permissions(user, config)
946
 
        # However, "edit" permission is derived from the "edit_worksheets"
947
 
        # permission of the offering
948
 
        if 'edit_worksheets' in perms:
 
1114
        offering_perms = self.offering.get_permissions(user, config)
 
1115
 
 
1116
        perms = set()
 
1117
 
 
1118
        # Anybody who can view an offering can view a published
 
1119
        # worksheet.
 
1120
        if 'view' in offering_perms and self.published:
 
1121
            perms.add('view')
 
1122
 
 
1123
        # Any worksheet editors can both view and edit.
 
1124
        if 'edit_worksheets' in offering_perms:
 
1125
            perms.add('view')
949
1126
            perms.add('edit')
950
 
        else:
951
 
            perms.discard('edit')
 
1127
 
952
1128
        return perms
953
1129
 
954
 
    def get_xml(self):
955
 
        """Returns the xml of this worksheet, converts from rst if required."""
956
 
        if self.format == u'rst':
957
 
            ws_xml = rst(self.data)
958
 
            return ws_xml
 
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:
 
1133
            return
 
1134
 
 
1135
        if self.format == u'rst':
 
1136
            self._data_xhtml_cache = rst(self.data)
 
1137
        else:
 
1138
            self._data_xhtml_cache = None
 
1139
 
 
1140
    @property
 
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()
 
1145
 
 
1146
        if self.format == u'rst':
 
1147
            return self._data_xhtml_cache
959
1148
        else:
960
1149
            return self.data
961
1150
 
 
1151
    def set_data(self, data):
 
1152
        self.data = data
 
1153
        self._cache_data_xhtml(invalidate=True)
 
1154
 
962
1155
    def delete(self):
963
1156
        """Deletes the worksheet, provided it has no attempts on any exercises.
964
1157
 
1026
1219
 
1027
1220
    def __repr__(self):
1028
1221
        return "<%s %s by %s at %s>" % (type(self).__name__,
1029
 
            self.exercise.name, self.user.login, self.date.strftime("%c"))
 
1222
            self.worksheet_exercise.exercise.name, self.user.login,
 
1223
            self.date.strftime("%c"))
1030
1224
 
1031
1225
class ExerciseAttempt(ExerciseSave):
1032
1226
    """An attempt at solving an exercise.