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

« back to all changes in this revision

Viewing changes to ivle/database.py

  • Committer: William Grant
  • Date: 2013-08-08 01:20:27 UTC
  • Revision ID: me@williamgrant.id.au-20130808012027-mb17vkfdosq3y12a
Blargh

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
 
215
218
            Semester.id == Offering.semester_id,
216
219
            (not active_only) or (Semester.state == u'current'),
217
220
            Enrolment.offering_id == Offering.id,
218
 
            Enrolment.user_id == self.id)
 
221
            Enrolment.user_id == self.id,
 
222
            Enrolment.active == True)
219
223
 
220
224
    @staticmethod
221
225
    def hash_password(password):
227
231
        """Find a user in a store by login name."""
228
232
        return store.find(cls, cls.login == unicode(login)).one()
229
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
 
230
240
    def get_permissions(self, user, config):
231
241
        """Determine privileges held by a user over this object.
232
242
 
288
298
        """
289
299
        return self.offerings.find(Offering.semester_id == Semester.id,
290
300
                               Semester.year == unicode(year),
291
 
                               Semester.semester == unicode(semester)).one()
 
301
                               Semester.url_name == unicode(semester)).one()
292
302
 
293
303
class Semester(Storm):
294
304
    """A semester in which subjects can be run."""
297
307
 
298
308
    id = Int(primary=True, name="semesterid")
299
309
    year = Unicode()
300
 
    semester = Unicode()
 
310
    code = Unicode()
 
311
    url_name = Unicode()
 
312
    display_name = Unicode()
301
313
    state = Unicode()
302
314
 
303
315
    offerings = ReferenceSet(id, 'Offering.semester_id')
309
321
    __init__ = _kwarg_init
310
322
 
311
323
    def __repr__(self):
312
 
        return "<%s %s/%s>" % (type(self).__name__, self.year, self.semester)
 
324
        return "<%s %s/%s>" % (type(self).__name__, self.year, self.code)
313
325
 
314
326
class Offering(Storm):
315
327
    """An offering of a subject in a particular semester."""
323
335
    semester = Reference(semester_id, Semester.id)
324
336
    description = Unicode()
325
337
    url = Unicode()
 
338
    show_worksheet_marks = Bool()
 
339
    worksheet_cutoff = DateTime()
326
340
    groups_student_permissions = Unicode()
327
341
 
328
342
    enrolments = ReferenceSet(id, 'Enrolment.offering_id')
391
405
                perms.add('view_project_submissions')
392
406
                perms.add('admin_groups')
393
407
                perms.add('edit_worksheets')
 
408
                perms.add('view_worksheet_marks')
394
409
                perms.add('edit')           # Can edit projects & details
395
410
                perms.add('enrol')          # Can see enrolment screen at all
396
411
                perms.add('enrol_student')  # Can enrol students
424
439
        # XXX: Respect extensions.
425
440
        return self.projects.find(Project.deadline > datetime.datetime.now())
426
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
 
427
468
class Enrolment(Storm):
428
469
    """An enrolment of a user in an offering.
429
470
 
455
496
        return "<%s %r in %r>" % (type(self).__name__, self.user,
456
497
                                  self.offering)
457
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
 
458
512
# PROJECTS #
459
513
 
460
514
class ProjectSet(Storm):
558
612
        return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
559
613
                                  self.project_set.offering)
560
614
 
561
 
    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
        """
562
619
        return (self in principal.get_projects() and
563
 
                not self.has_deadline_passed(user))
 
620
                (late or not self.has_deadline_passed(user)))
564
621
 
565
 
    def submit(self, principal, path, revision, who):
 
622
    def submit(self, principal, path, revision, who, late=False):
566
623
        """Submit a Subversion path and revision to a project.
567
624
 
568
625
        @param principal: The owner of the Subversion repository, and the
570
627
        @param path: A path within that repository to submit.
571
628
        @param revision: The revision of that path to submit.
572
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.)
573
632
        """
574
633
 
575
 
        if not self.can_submit(principal, who):
 
634
        if not self.can_submit(principal, who, late=late):
576
635
            raise DeadlinePassed()
577
636
 
578
637
        a = Assessed.get(Store.of(self), principal, self)
579
638
        ps = ProjectSubmission()
580
 
        ps.path = path
 
639
        # Raise SubmissionError if the path is illegal
 
640
        ps.path = ProjectSubmission.test_and_normalise_path(path)
581
641
        ps.revision = revision
582
642
        ps.date_submitted = datetime.datetime.now()
583
643
        ps.assessed = a
613
673
            return
614
674
        return assessed.submissions
615
675
 
 
676
    @property
 
677
    def can_delete(self):
 
678
        """Can only delete if there are no submissions."""
 
679
        return self.submissions.count() == 0
616
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)
617
688
 
618
689
class ProjectGroup(Storm):
619
690
    """A group of students working together on a project."""
669
740
            Semester.id == Offering.semester_id,
670
741
            (not active_only) or (Semester.state == u'current'))
671
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)
672
753
 
673
754
    def get_permissions(self, user, config):
674
755
        if user.admin or user in self.members:
766
847
 
767
848
        return a
768
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)
769
858
 
770
859
class ProjectExtension(Storm):
771
860
    """An extension granted to a user or group on a particular project.
778
867
    id = Int(name="extensionid", primary=True)
779
868
    assessed_id = Int(name="assessedid")
780
869
    assessed = Reference(assessed_id, Assessed.id)
781
 
    deadline = DateTime()
 
870
    days = Int()
782
871
    approver_id = Int(name="approver")
783
872
    approver = Reference(approver_id, User.id)
784
873
    notes = Unicode()
785
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
 
786
883
class ProjectSubmission(Storm):
787
884
    """A submission from a user or group repository to a particular project.
788
885
 
818
915
        return "/files/%s/%s/%s?r=%d" % (user.login,
819
916
            self.assessed.checkout_location, submitpath, self.revision)
820
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
 
821
973
# WORKSHEETS AND EXERCISES #
822
974
 
823
975
class Exercise(Storm):
830
982
    id = Unicode(primary=True, name="identifier")
831
983
    name = Unicode()
832
984
    description = Unicode()
 
985
    _description_xhtml_cache = Unicode(name='description_xhtml_cache')
833
986
    partial = Unicode()
834
987
    solution = Unicode()
835
988
    include = Unicode()
878
1031
 
879
1032
        return perms
880
1033
 
881
 
    def get_description(self):
882
 
        """Return the description interpreted as reStructuredText."""
883
 
        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)
884
1053
 
885
1054
    def delete(self):
886
1055
        """Deletes the exercise, providing it has no associated worksheets."""
903
1072
    identifier = Unicode()
904
1073
    name = Unicode()
905
1074
    assessable = Bool()
 
1075
    published = Bool()
906
1076
    data = Unicode()
 
1077
    _data_xhtml_cache = Unicode(name='data_xhtml_cache')
907
1078
    seq_no = Int()
908
1079
    format = Unicode()
909
1080
 
940
1111
            WorksheetExercise.worksheet == self).remove()
941
1112
 
942
1113
    def get_permissions(self, user, config):
943
 
        # Almost the same permissions as for the offering itself
944
 
        perms = self.offering.get_permissions(user, config)
945
 
        # However, "edit" permission is derived from the "edit_worksheets"
946
 
        # permission of the offering
947
 
        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')
948
1126
            perms.add('edit')
949
 
        else:
950
 
            perms.discard('edit')
 
1127
 
951
1128
        return perms
952
1129
 
953
 
    def get_xml(self):
954
 
        """Returns the xml of this worksheet, converts from rst if required."""
955
 
        if self.format == u'rst':
956
 
            ws_xml = rst(self.data)
957
 
            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
958
1148
        else:
959
1149
            return self.data
960
1150
 
 
1151
    def set_data(self, data):
 
1152
        self.data = data
 
1153
        self._cache_data_xhtml(invalidate=True)
 
1154
 
961
1155
    def delete(self):
962
1156
        """Deletes the worksheet, provided it has no attempts on any exercises.
963
1157
 
1025
1219
 
1026
1220
    def __repr__(self):
1027
1221
        return "<%s %s by %s at %s>" % (type(self).__name__,
1028
 
            self.exercise.name, self.user.login, self.date.strftime("%c"))
 
1222
            self.worksheet_exercise.exercise.name, self.user.login,
 
1223
            self.date.strftime("%c"))
1029
1224
 
1030
1225
class ExerciseAttempt(ExerciseSave):
1031
1226
    """An attempt at solving an exercise.