~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
 
 
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
 
 
467
 
382
468
class Enrolment(Storm):
383
469
    """An enrolment of a user in an offering.
384
470
 
410
496
        return "<%s %r in %r>" % (type(self).__name__, self.user,
411
497
                                  self.offering)
412
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
 
413
512
# PROJECTS #
414
513
 
415
514
class ProjectSet(Storm):
435
534
        return "<%s %d in %r>" % (type(self).__name__, self.id,
436
535
                                  self.offering)
437
536
 
438
 
    def get_permissions(self, user):
439
 
        return self.offering.get_permissions(user)
 
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
 
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:
 
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"
440
588
 
441
589
class Project(Storm):
442
590
    """A student project for which submissions can be made."""
464
612
        return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
465
613
                                  self.project_set.offering)
466
614
 
467
 
    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
        """
468
619
        return (self in principal.get_projects() and
469
 
                self.deadline > datetime.datetime.now())
 
620
                (late or not self.has_deadline_passed(user)))
470
621
 
471
 
    def submit(self, principal, path, revision, who):
 
622
    def submit(self, principal, path, revision, who, late=False):
472
623
        """Submit a Subversion path and revision to a project.
473
624
 
474
625
        @param principal: The owner of the Subversion repository, and the
476
627
        @param path: A path within that repository to submit.
477
628
        @param revision: The revision of that path to submit.
478
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.)
479
632
        """
480
633
 
481
 
        if not self.can_submit(principal):
482
 
            raise Exception('cannot submit')
 
634
        if not self.can_submit(principal, who, late=late):
 
635
            raise DeadlinePassed()
483
636
 
484
637
        a = Assessed.get(Store.of(self), principal, self)
485
638
        ps = ProjectSubmission()
486
 
        ps.path = path
 
639
        # Raise SubmissionError if the path is illegal
 
640
        ps.path = ProjectSubmission.test_and_normalise_path(path)
487
641
        ps.revision = revision
488
642
        ps.date_submitted = datetime.datetime.now()
489
643
        ps.assessed = a
491
645
 
492
646
        return ps
493
647
 
494
 
    def get_permissions(self, user):
495
 
        return self.project_set.offering.get_permissions(user)
496
 
 
 
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)
497
688
 
498
689
class ProjectGroup(Storm):
499
690
    """A group of students working together on a project."""
522
713
 
523
714
    @property
524
715
    def display_name(self):
525
 
        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
526
723
 
527
724
    def get_projects(self, offering=None, active_only=True):
528
725
        '''Find projects that the group can submit.
543
740
            Semester.id == Offering.semester_id,
544
741
            (not active_only) or (Semester.state == u'current'))
545
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)
546
753
 
547
 
    def get_permissions(self, user):
 
754
    def get_permissions(self, user, config):
548
755
        if user.admin or user in self.members:
549
756
            return set(['submit_project'])
550
757
        else:
586
793
    project = Reference(project_id, Project.id)
587
794
 
588
795
    extensions = ReferenceSet(id, 'ProjectExtension.assessed_id')
589
 
    submissions = ReferenceSet(id, 'ProjectSubmission.assessed_id')
 
796
    submissions = ReferenceSet(
 
797
        id, 'ProjectSubmission.assessed_id', order_by='date_submitted')
590
798
 
591
799
    def __repr__(self):
592
800
        return "<%s %r in %r>" % (type(self).__name__,
593
801
            self.user or self.project_group, self.project)
594
802
 
 
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
 
809
    def principal(self):
 
810
        return self.project_group or self.user
 
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
 
595
823
    @classmethod
596
824
    def get(cls, store, principal, project):
597
825
        """Find or create an Assessed for the given user or group and project.
606
834
        a = store.find(cls,
607
835
            (t is User) or (cls.project_group_id == principal.id),
608
836
            (t is ProjectGroup) or (cls.user_id == principal.id),
609
 
            Project.id == project.id).one()
 
837
            cls.project_id == project.id).one()
610
838
 
611
839
        if a is None:
612
840
            a = cls()
619
847
 
620
848
        return a
621
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)
622
858
 
623
859
class ProjectExtension(Storm):
624
860
    """An extension granted to a user or group on a particular project.
631
867
    id = Int(name="extensionid", primary=True)
632
868
    assessed_id = Int(name="assessedid")
633
869
    assessed = Reference(assessed_id, Assessed.id)
634
 
    deadline = DateTime()
 
870
    days = Int()
635
871
    approver_id = Int(name="approver")
636
872
    approver = Reference(approver_id, User.id)
637
873
    notes = Unicode()
638
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
 
639
883
class ProjectSubmission(Storm):
640
884
    """A submission from a user or group repository to a particular project.
641
885
 
657
901
    submitter = Reference(submitter_id, User.id)
658
902
    date_submitted = DateTime()
659
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)
660
972
 
661
973
# WORKSHEETS AND EXERCISES #
662
974
 
670
982
    id = Unicode(primary=True, name="identifier")
671
983
    name = Unicode()
672
984
    description = Unicode()
 
985
    _description_xhtml_cache = Unicode(name='description_xhtml_cache')
673
986
    partial = Unicode()
674
987
    solution = Unicode()
675
988
    include = Unicode()
693
1006
    def __repr__(self):
694
1007
        return "<%s %s>" % (type(self).__name__, self.name)
695
1008
 
696
 
    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."""
697
1017
        perms = set()
698
1018
        roles = set()
699
1019
        if user is not None:
703
1023
            elif u'lecturer' in set((e.role for e in user.active_enrolments)):
704
1024
                perms.add('edit')
705
1025
                perms.add('view')
706
 
            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
707
1029
                perms.add('edit')
708
1030
                perms.add('view')
709
1031
 
710
1032
        return perms
711
1033
 
712
 
    def get_description(self):
713
 
        """Return the description interpreted as reStructuredText."""
714
 
        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)
715
1053
 
716
1054
    def delete(self):
717
1055
        """Deletes the exercise, providing it has no associated worksheets."""
734
1072
    identifier = Unicode()
735
1073
    name = Unicode()
736
1074
    assessable = Bool()
 
1075
    published = Bool()
737
1076
    data = Unicode()
 
1077
    _data_xhtml_cache = Unicode(name='data_xhtml_cache')
738
1078
    seq_no = Int()
739
1079
    format = Unicode()
740
1080
 
770
1110
        store.find(WorksheetExercise,
771
1111
            WorksheetExercise.worksheet == self).remove()
772
1112
 
773
 
    def get_permissions(self, user):
774
 
        return self.offering.get_permissions(user)
775
 
 
776
 
    def get_xml(self):
777
 
        """Returns the xml of this worksheet, converts from rst if required."""
778
 
        if self.format == u'rst':
779
 
            ws_xml = rst(self.data)
780
 
            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
781
1148
        else:
782
1149
            return self.data
783
1150
 
 
1151
    def set_data(self, data):
 
1152
        self.data = data
 
1153
        self._cache_data_xhtml(invalidate=True)
 
1154
 
784
1155
    def delete(self):
785
1156
        """Deletes the worksheet, provided it has no attempts on any exercises.
786
1157
 
822
1193
        return "<%s %s in %s>" % (type(self).__name__, self.exercise.name,
823
1194
                                  self.worksheet.identifier)
824
1195
 
825
 
    def get_permissions(self, user):
826
 
        return self.worksheet.get_permissions(user)
 
1196
    def get_permissions(self, user, config):
 
1197
        return self.worksheet.get_permissions(user, config)
827
1198
 
828
1199
 
829
1200
class ExerciseSave(Storm):
848
1219
 
849
1220
    def __repr__(self):
850
1221
        return "<%s %s by %s at %s>" % (type(self).__name__,
851
 
            self.exercise.name, self.user.login, self.date.strftime("%c"))
 
1222
            self.worksheet_exercise.exercise.name, self.user.login,
 
1223
            self.date.strftime("%c"))
852
1224
 
853
1225
class ExerciseAttempt(ExerciseSave):
854
1226
    """An attempt at solving an exercise.
876
1248
    complete = Bool()
877
1249
    active = Bool()
878
1250
 
879
 
    def get_permissions(self, user):
 
1251
    def get_permissions(self, user, config):
880
1252
        return set(['view']) if user is self.user else set()
881
1253
 
882
1254
class TestSuite(Storm):
901
1273
 
902
1274
    def delete(self):
903
1275
        """Delete this suite, without asking questions."""
904
 
        for vaariable in self.variables:
 
1276
        for variable in self.variables:
905
1277
            variable.delete()
906
1278
        for test_case in self.test_cases:
907
1279
            test_case.delete()
920
1292
    suite = Reference(suiteid, "TestSuite.suiteid")
921
1293
    passmsg = Unicode()
922
1294
    failmsg = Unicode()
923
 
    test_default = Unicode()
 
1295
    test_default = Unicode() # Currently unused - only used for file matching.
924
1296
    seq_no = Int()
925
1297
 
926
1298
    parts = ReferenceSet(testid, "TestCasePart.testid")