~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
 
34
from storm.expr import Select, Max
31
35
from storm.exceptions import NotOneError, IntegrityError
32
36
 
33
37
from ivle.worksheet.rst import rst
116
120
 
117
121
    @property
118
122
    def display_name(self):
 
123
        """Returns the "nice name" of the user or group."""
119
124
        return self.fullname
120
125
 
121
126
    @property
 
127
    def short_name(self):
 
128
        """Returns the database "identifier" name of the user or group."""
 
129
        return self.login
 
130
 
 
131
    @property
122
132
    def password_expired(self):
123
133
        fieldval = self.pass_exp
124
134
        return fieldval is not None and datetime.datetime.now() > fieldval
140
150
            Offering.semester_id == Semester.id,
141
151
            Offering.subject_id == Subject.id).order_by(
142
152
                Desc(Semester.year),
143
 
                Desc(Semester.semester),
 
153
                Desc(Semester.display_name),
144
154
                Desc(Subject.code)
145
155
            )
146
156
 
208
218
            Semester.id == Offering.semester_id,
209
219
            (not active_only) or (Semester.state == u'current'),
210
220
            Enrolment.offering_id == Offering.id,
211
 
            Enrolment.user_id == self.id)
 
221
            Enrolment.user_id == self.id,
 
222
            Enrolment.active == True)
212
223
 
213
224
    @staticmethod
214
225
    def hash_password(password):
220
231
        """Find a user in a store by login name."""
221
232
        return store.find(cls, cls.login == unicode(login)).one()
222
233
 
223
 
    def get_permissions(self, user):
 
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
 
 
240
    def get_permissions(self, user, config):
224
241
        """Determine privileges held by a user over this object.
225
242
 
226
243
        If the user requesting privileges is this user or an admin,
227
244
        they may do everything. Otherwise they may do nothing.
228
245
        """
229
246
        if user and user.admin or user is self:
230
 
            return set(['view', 'edit', 'submit_project'])
 
247
            return set(['view_public', 'view', 'edit', 'submit_project'])
231
248
        else:
232
 
            return set()
 
249
            return set(['view_public'])
233
250
 
234
251
# SUBJECTS AND ENROLMENTS #
235
252
 
242
259
    code = Unicode(name="subj_code")
243
260
    name = Unicode(name="subj_name")
244
261
    short_name = Unicode(name="subj_short_name")
245
 
    url = Unicode()
246
262
 
247
263
    offerings = ReferenceSet(id, 'Offering.subject_id')
248
264
 
251
267
    def __repr__(self):
252
268
        return "<%s '%s'>" % (type(self).__name__, self.short_name)
253
269
 
254
 
    def get_permissions(self, user):
 
270
    def get_permissions(self, user, config):
255
271
        """Determine privileges held by a user over this object.
256
272
 
257
273
        If the user requesting privileges is an admin, they may edit.
282
298
        """
283
299
        return self.offerings.find(Offering.semester_id == Semester.id,
284
300
                               Semester.year == unicode(year),
285
 
                               Semester.semester == unicode(semester)).one()
 
301
                               Semester.url_name == unicode(semester)).one()
286
302
 
287
303
class Semester(Storm):
288
304
    """A semester in which subjects can be run."""
291
307
 
292
308
    id = Int(primary=True, name="semesterid")
293
309
    year = Unicode()
294
 
    semester = Unicode()
 
310
    code = Unicode()
 
311
    url_name = Unicode()
 
312
    display_name = Unicode()
295
313
    state = Unicode()
296
314
 
297
315
    offerings = ReferenceSet(id, 'Offering.semester_id')
303
321
    __init__ = _kwarg_init
304
322
 
305
323
    def __repr__(self):
306
 
        return "<%s %s/%s>" % (type(self).__name__, self.year, self.semester)
 
324
        return "<%s %s/%s>" % (type(self).__name__, self.year, self.code)
307
325
 
308
326
class Offering(Storm):
309
327
    """An offering of a subject in a particular semester."""
315
333
    subject = Reference(subject_id, Subject.id)
316
334
    semester_id = Int(name="semesterid")
317
335
    semester = Reference(semester_id, Semester.id)
 
336
    description = Unicode()
 
337
    url = Unicode()
 
338
    show_worksheet_marks = Bool()
 
339
    worksheet_cutoff = DateTime()
318
340
    groups_student_permissions = Unicode()
319
341
 
320
342
    enrolments = ReferenceSet(id, 'Enrolment.offering_id')
323
345
                           'Enrolment.user_id',
324
346
                           'User.id')
325
347
    project_sets = ReferenceSet(id, 'ProjectSet.offering_id')
 
348
    projects = ReferenceSet(id,
 
349
                            'ProjectSet.offering_id',
 
350
                            'ProjectSet.id',
 
351
                            'Project.project_set_id')
326
352
 
327
353
    worksheets = ReferenceSet(id, 
328
354
        'Worksheet.offering_id', 
359
385
                               Enrolment.offering_id == self.id).one()
360
386
        Store.of(enrolment).remove(enrolment)
361
387
 
362
 
    def get_permissions(self, user):
 
388
    def get_permissions(self, user, config):
363
389
        perms = set()
364
390
        if user is not None:
365
391
            enrolment = self.get_enrolment(user)
366
392
            if enrolment or user.admin:
367
393
                perms.add('view')
368
 
            if (enrolment and enrolment.role in (u'tutor', u'lecturer')) \
369
 
               or user.admin:
370
 
                perms.add('edit')
 
394
            if enrolment and enrolment.role == u'tutor':
 
395
                perms.add('view_project_submissions')
 
396
                # Site-specific policy on the role of tutors
 
397
                if config['policy']['tutors_can_enrol_students']:
 
398
                    perms.add('enrol')
 
399
                    perms.add('enrol_student')
 
400
                if config['policy']['tutors_can_edit_worksheets']:
 
401
                    perms.add('edit_worksheets')
 
402
                if config['policy']['tutors_can_admin_groups']:
 
403
                    perms.add('admin_groups')
 
404
            if (enrolment and enrolment.role in (u'lecturer')) or user.admin:
 
405
                perms.add('view_project_submissions')
 
406
                perms.add('admin_groups')
 
407
                perms.add('edit_worksheets')
 
408
                perms.add('view_worksheet_marks')
 
409
                perms.add('edit')           # Can edit projects & details
 
410
                perms.add('enrol')          # Can see enrolment screen at all
 
411
                perms.add('enrol_student')  # Can enrol students
 
412
                perms.add('enrol_tutor')    # Can enrol tutors
 
413
            if user.admin:
 
414
                perms.add('enrol_lecturer') # Can enrol lecturers
371
415
        return perms
372
416
 
373
417
    def get_enrolment(self, user):
379
423
 
380
424
        return enrolment
381
425
 
382
 
    def get_students(self):
383
 
        enrolments = self.enrolments.find(role=u'student')
384
 
        return [enrolment.user for enrolment in enrolments]
 
426
    def get_members_by_role(self, role):
 
427
        return Store.of(self).find(User,
 
428
                Enrolment.user_id == User.id,
 
429
                Enrolment.offering_id == self.id,
 
430
                Enrolment.role == role
 
431
                ).order_by(User.login)
 
432
 
 
433
    @property
 
434
    def students(self):
 
435
        return self.get_members_by_role(u'student')
 
436
 
 
437
    def get_open_projects_for_user(self, user):
 
438
        """Find all projects currently open to submissions by a user."""
 
439
        # XXX: Respect extensions.
 
440
        return self.projects.find(Project.deadline > datetime.datetime.now())
 
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
 
385
467
 
386
468
class Enrolment(Storm):
387
469
    """An enrolment of a user in an offering.
414
496
        return "<%s %r in %r>" % (type(self).__name__, self.user,
415
497
                                  self.offering)
416
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
 
417
512
# PROJECTS #
418
513
 
419
514
class ProjectSet(Storm):
439
534
        return "<%s %d in %r>" % (type(self).__name__, self.id,
440
535
                                  self.offering)
441
536
 
442
 
    def get_permissions(self, user):
443
 
        return self.offering.get_permissions(user)
444
 
 
445
 
    # Get the individuals (groups or users) Assigned to this project
446
 
    def get_assigned(self):
447
 
        #If its a Solo project, return everyone in offering
448
 
        if self.max_students_per_group is None:
449
 
            return self.offering.get_students()
 
537
    def get_permissions(self, user, config):
 
538
        return self.offering.get_permissions(user, config)
 
539
 
 
540
    def get_groups_for_user(self, user):
 
541
        """List all groups in this offering of which the user is a member."""
 
542
        assert self.is_group
 
543
        return Store.of(self).find(
 
544
            ProjectGroup,
 
545
            ProjectGroupMembership.user_id == user.id,
 
546
            ProjectGroupMembership.project_group_id == ProjectGroup.id,
 
547
            ProjectGroup.project_set_id == self.id)
 
548
 
 
549
    def get_submission_principal(self, user):
 
550
        """Get the principal on behalf of which the user can submit.
 
551
 
 
552
        If this is a solo project set, the given user is returned. If
 
553
        the user is a member of exactly one group, all the group is
 
554
        returned. Otherwise, None is returned.
 
555
        """
 
556
        if self.is_group:
 
557
            groups = self.get_groups_for_user(user)
 
558
            if groups.count() == 1:
 
559
                return groups.one()
 
560
            else:
 
561
                return None
450
562
        else:
 
563
            return user
 
564
 
 
565
    @property
 
566
    def is_group(self):
 
567
        return self.max_students_per_group is not None
 
568
 
 
569
    @property
 
570
    def assigned(self):
 
571
        """Get the entities (groups or users) assigned to submit this project.
 
572
 
 
573
        This will be a Storm ResultSet.
 
574
        """
 
575
        #If its a solo project, return everyone in offering
 
576
        if self.is_group:
451
577
            return self.project_groups
 
578
        else:
 
579
            return self.offering.students
 
580
 
 
581
class DeadlinePassed(Exception):
 
582
    """An exception indicating that a project cannot be submitted because the
 
583
    deadline has passed."""
 
584
    def __init__(self):
 
585
        pass
 
586
    def __str__(self):
 
587
        return "The project deadline has passed"
452
588
 
453
589
class Project(Storm):
454
590
    """A student project for which submissions can be made."""
476
612
        return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
477
613
                                  self.project_set.offering)
478
614
 
479
 
    def can_submit(self, principal):
 
615
    def can_submit(self, principal, user, late=False):
 
616
        """
 
617
        @param late: If True, does not take the deadline into account.
 
618
        """
480
619
        return (self in principal.get_projects() and
481
 
                self.deadline > datetime.datetime.now())
 
620
                (late or not self.has_deadline_passed(user)))
482
621
 
483
 
    def submit(self, principal, path, revision, who):
 
622
    def submit(self, principal, path, revision, who, late=False):
484
623
        """Submit a Subversion path and revision to a project.
485
624
 
486
625
        @param principal: The owner of the Subversion repository, and the
488
627
        @param path: A path within that repository to submit.
489
628
        @param revision: The revision of that path to submit.
490
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.)
491
632
        """
492
633
 
493
 
        if not self.can_submit(principal):
494
 
            raise Exception('cannot submit')
 
634
        if not self.can_submit(principal, who, late=late):
 
635
            raise DeadlinePassed()
495
636
 
496
637
        a = Assessed.get(Store.of(self), principal, self)
497
638
        ps = ProjectSubmission()
498
 
        ps.path = path
 
639
        # Raise SubmissionError if the path is illegal
 
640
        ps.path = ProjectSubmission.test_and_normalise_path(path)
499
641
        ps.revision = revision
500
642
        ps.date_submitted = datetime.datetime.now()
501
643
        ps.assessed = a
503
645
 
504
646
        return ps
505
647
 
506
 
    def get_permissions(self, user):
507
 
        return self.project_set.offering.get_permissions(user)
508
 
 
 
648
    def get_permissions(self, user, config):
 
649
        return self.project_set.offering.get_permissions(user, config)
 
650
 
 
651
    @property
 
652
    def latest_submissions(self):
 
653
        """Return the latest submission for each Assessed."""
 
654
        return Store.of(self).find(ProjectSubmission,
 
655
            Assessed.project_id == self.id,
 
656
            ProjectSubmission.assessed_id == Assessed.id,
 
657
            ProjectSubmission.date_submitted == Select(
 
658
                    Max(ProjectSubmission.date_submitted),
 
659
                    ProjectSubmission.assessed_id == Assessed.id,
 
660
                    tables=ProjectSubmission
 
661
            )
 
662
        )
 
663
 
 
664
    def has_deadline_passed(self, user):
 
665
        """Check whether the deadline has passed."""
 
666
        # XXX: Need to respect extensions.
 
667
        return self.deadline < datetime.datetime.now()
 
668
 
 
669
    def get_submissions_for_principal(self, principal):
 
670
        """Fetch a ResultSet of all submissions by a particular principal."""
 
671
        assessed = Assessed.get(Store.of(self), principal, self)
 
672
        if assessed is None:
 
673
            return
 
674
        return assessed.submissions
 
675
 
 
676
    @property
 
677
    def can_delete(self):
 
678
        """Can only delete if there are no submissions."""
 
679
        return self.submissions.count() == 0
 
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)
509
688
 
510
689
class ProjectGroup(Storm):
511
690
    """A group of students working together on a project."""
534
713
 
535
714
    @property
536
715
    def display_name(self):
537
 
        return '%s (%s)' % (self.nick, self.name)
 
716
        """Returns the "nice name" of the user or group."""
 
717
        return self.nick
 
718
 
 
719
    @property
 
720
    def short_name(self):
 
721
        """Returns the database "identifier" name of the user or group."""
 
722
        return self.name
538
723
 
539
724
    def get_projects(self, offering=None, active_only=True):
540
725
        '''Find projects that the group can submit.
555
740
            Semester.id == Offering.semester_id,
556
741
            (not active_only) or (Semester.state == u'current'))
557
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)
558
753
 
559
 
    def get_permissions(self, user):
 
754
    def get_permissions(self, user, config):
560
755
        if user.admin or user in self.members:
561
756
            return set(['submit_project'])
562
757
        else:
598
793
    project = Reference(project_id, Project.id)
599
794
 
600
795
    extensions = ReferenceSet(id, 'ProjectExtension.assessed_id')
601
 
    submissions = ReferenceSet(id, 'ProjectSubmission.assessed_id')
 
796
    submissions = ReferenceSet(
 
797
        id, 'ProjectSubmission.assessed_id', order_by='date_submitted')
602
798
 
603
799
    def __repr__(self):
604
800
        return "<%s %r in %r>" % (type(self).__name__,
605
801
            self.user or self.project_group, self.project)
606
802
 
607
803
    @property
 
804
    def is_group(self):
 
805
        """True if the Assessed is a group, False if it is a user."""
 
806
        return self.project_group is not None
 
807
 
 
808
    @property
608
809
    def principal(self):
609
810
        return self.project_group or self.user
610
811
 
 
812
    @property
 
813
    def checkout_location(self):
 
814
        """Returns the location of the Subversion workspace for this piece of
 
815
        assessment, relative to each group member's home directory."""
 
816
        subjectname = self.project.project_set.offering.subject.short_name
 
817
        if self.is_group:
 
818
            checkout_dir_name = self.principal.short_name
 
819
        else:
 
820
            checkout_dir_name = "mywork"
 
821
        return subjectname + "/" + checkout_dir_name
 
822
 
611
823
    @classmethod
612
824
    def get(cls, store, principal, project):
613
825
        """Find or create an Assessed for the given user or group and project.
622
834
        a = store.find(cls,
623
835
            (t is User) or (cls.project_group_id == principal.id),
624
836
            (t is ProjectGroup) or (cls.user_id == principal.id),
625
 
            Project.id == project.id).one()
 
837
            cls.project_id == project.id).one()
626
838
 
627
839
        if a is None:
628
840
            a = cls()
635
847
 
636
848
        return a
637
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)
638
858
 
639
859
class ProjectExtension(Storm):
640
860
    """An extension granted to a user or group on a particular project.
647
867
    id = Int(name="extensionid", primary=True)
648
868
    assessed_id = Int(name="assessedid")
649
869
    assessed = Reference(assessed_id, Assessed.id)
650
 
    deadline = DateTime()
 
870
    days = Int()
651
871
    approver_id = Int(name="approver")
652
872
    approver = Reference(approver_id, User.id)
653
873
    notes = Unicode()
654
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
 
655
883
class ProjectSubmission(Storm):
656
884
    """A submission from a user or group repository to a particular project.
657
885
 
673
901
    submitter = Reference(submitter_id, User.id)
674
902
    date_submitted = DateTime()
675
903
 
 
904
    def get_verify_url(self, user):
 
905
        """Get the URL for verifying this submission, within the account of
 
906
        the given user."""
 
907
        # If this is a solo project, then self.path will be prefixed with the
 
908
        # subject name. Remove the first path segment.
 
909
        submitpath = self.path[1:] if self.path[:1] == '/' else self.path
 
910
        if not self.assessed.is_group:
 
911
            if '/' in submitpath:
 
912
                submitpath = submitpath.split('/', 1)[1]
 
913
            else:
 
914
                submitpath = ''
 
915
        return "/files/%s/%s/%s?r=%d" % (user.login,
 
916
            self.assessed.checkout_location, submitpath, self.revision)
 
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)
676
972
 
677
973
# WORKSHEETS AND EXERCISES #
678
974
 
686
982
    id = Unicode(primary=True, name="identifier")
687
983
    name = Unicode()
688
984
    description = Unicode()
 
985
    _description_xhtml_cache = Unicode(name='description_xhtml_cache')
689
986
    partial = Unicode()
690
987
    solution = Unicode()
691
988
    include = Unicode()
709
1006
    def __repr__(self):
710
1007
        return "<%s %s>" % (type(self).__name__, self.name)
711
1008
 
712
 
    def get_permissions(self, user):
 
1009
    def get_permissions(self, user, config):
 
1010
        return self.global_permissions(user, config)
 
1011
 
 
1012
    @staticmethod
 
1013
    def global_permissions(user, config):
 
1014
        """Gets the set of permissions this user has over *all* exercises.
 
1015
        This is used to determine who may view the exercises list, and create
 
1016
        new exercises."""
713
1017
        perms = set()
714
1018
        roles = set()
715
1019
        if user is not None:
719
1023
            elif u'lecturer' in set((e.role for e in user.active_enrolments)):
720
1024
                perms.add('edit')
721
1025
                perms.add('view')
722
 
            elif u'tutor' in set((e.role for e in user.active_enrolments)):
 
1026
            elif (config['policy']['tutors_can_edit_worksheets']
 
1027
            and u'tutor' in set((e.role for e in user.active_enrolments))):
 
1028
                # Site-specific policy on the role of tutors
723
1029
                perms.add('edit')
724
1030
                perms.add('view')
725
1031
 
726
1032
        return perms
727
1033
 
728
 
    def get_description(self):
729
 
        """Return the description interpreted as reStructuredText."""
730
 
        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)
731
1053
 
732
1054
    def delete(self):
733
1055
        """Deletes the exercise, providing it has no associated worksheets."""
750
1072
    identifier = Unicode()
751
1073
    name = Unicode()
752
1074
    assessable = Bool()
 
1075
    published = Bool()
753
1076
    data = Unicode()
 
1077
    _data_xhtml_cache = Unicode(name='data_xhtml_cache')
754
1078
    seq_no = Int()
755
1079
    format = Unicode()
756
1080
 
786
1110
        store.find(WorksheetExercise,
787
1111
            WorksheetExercise.worksheet == self).remove()
788
1112
 
789
 
    def get_permissions(self, user):
790
 
        return self.offering.get_permissions(user)
791
 
 
792
 
    def get_xml(self):
793
 
        """Returns the xml of this worksheet, converts from rst if required."""
794
 
        if self.format == u'rst':
795
 
            ws_xml = rst(self.data)
796
 
            return ws_xml
 
1113
    def get_permissions(self, user, config):
 
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')
 
1126
            perms.add('edit')
 
1127
 
 
1128
        return perms
 
1129
 
 
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
797
1148
        else:
798
1149
            return self.data
799
1150
 
 
1151
    def set_data(self, data):
 
1152
        self.data = data
 
1153
        self._cache_data_xhtml(invalidate=True)
 
1154
 
800
1155
    def delete(self):
801
1156
        """Deletes the worksheet, provided it has no attempts on any exercises.
802
1157
 
838
1193
        return "<%s %s in %s>" % (type(self).__name__, self.exercise.name,
839
1194
                                  self.worksheet.identifier)
840
1195
 
841
 
    def get_permissions(self, user):
842
 
        return self.worksheet.get_permissions(user)
 
1196
    def get_permissions(self, user, config):
 
1197
        return self.worksheet.get_permissions(user, config)
843
1198
 
844
1199
 
845
1200
class ExerciseSave(Storm):
864
1219
 
865
1220
    def __repr__(self):
866
1221
        return "<%s %s by %s at %s>" % (type(self).__name__,
867
 
            self.exercise.name, self.user.login, self.date.strftime("%c"))
 
1222
            self.worksheet_exercise.exercise.name, self.user.login,
 
1223
            self.date.strftime("%c"))
868
1224
 
869
1225
class ExerciseAttempt(ExerciseSave):
870
1226
    """An attempt at solving an exercise.
892
1248
    complete = Bool()
893
1249
    active = Bool()
894
1250
 
895
 
    def get_permissions(self, user):
 
1251
    def get_permissions(self, user, config):
896
1252
        return set(['view']) if user is self.user else set()
897
1253
 
898
1254
class TestSuite(Storm):
917
1273
 
918
1274
    def delete(self):
919
1275
        """Delete this suite, without asking questions."""
920
 
        for vaariable in self.variables:
 
1276
        for variable in self.variables:
921
1277
            variable.delete()
922
1278
        for test_case in self.test_cases:
923
1279
            test_case.delete()
936
1292
    suite = Reference(suiteid, "TestSuite.suiteid")
937
1293
    passmsg = Unicode()
938
1294
    failmsg = Unicode()
939
 
    test_default = Unicode()
 
1295
    test_default = Unicode() # Currently unused - only used for file matching.
940
1296
    seq_no = Int()
941
1297
 
942
1298
    parts = ReferenceSet(testid, "TestCasePart.testid")