18
18
# Author: Matt Giuca, Will Grant
21
Database Classes and Utilities for Storm ORM
20
"""Database utilities and content classes.
23
22
This module provides all of the classes which map to database tables.
24
23
It also provides miscellaneous utility functions for database interaction.
30
30
from storm.locals import create_database, Store, Int, Unicode, DateTime, \
31
31
Reference, ReferenceSet, Bool, Storm, Desc
32
from storm.expr import Select, Max
33
from storm.exceptions import NotOneError, IntegrityError
35
from ivle.worksheet.rst import rst
36
37
__all__ = ['get_store',
38
39
'Subject', 'Semester', 'Offering', 'Enrolment',
39
40
'ProjectSet', 'Project', 'ProjectGroup', 'ProjectGroupMembership',
41
'Assessed', 'ProjectSubmission', 'ProjectExtension',
40
42
'Exercise', 'Worksheet', 'WorksheetExercise',
41
43
'ExerciseSave', 'ExerciseAttempt',
42
'AlreadyEnrolledError'
44
'TestCase', 'TestSuite', 'TestSuiteVar'
45
47
def _kwarg_init(self, **kwargs):
184
198
'''A sanely ordered list of all of the user's enrolments.'''
185
199
return self._get_enrolments(False)
201
def get_projects(self, offering=None, active_only=True):
202
"""Find projects that the user can submit.
204
This will include projects for offerings in which the user is
205
enrolled, as long as the project is not in a project set which has
206
groups (ie. if maximum number of group members is 0).
208
@param active_only: Whether to only search active offerings.
209
@param offering: An optional offering to restrict the search to.
211
return Store.of(self).find(Project,
212
Project.project_set_id == ProjectSet.id,
213
ProjectSet.max_students_per_group == None,
214
ProjectSet.offering_id == Offering.id,
215
(offering is None) or (Offering.id == offering.id),
216
Semester.id == Offering.semester_id,
217
(not active_only) or (Semester.state == u'current'),
218
Enrolment.offering_id == Offering.id,
219
Enrolment.user_id == self.id,
220
Enrolment.active == True)
188
223
def hash_password(password):
189
return md5.md5(password).hexdigest()
224
"""Hash a password with MD5."""
225
return hashlib.md5(password).hexdigest()
192
228
def get_by_login(cls, store, login):
194
Get the User from the db associated with a given store and
229
"""Find a user in a store by login name."""
197
230
return store.find(cls, cls.login == unicode(login)).one()
232
def get_permissions(self, user, config):
233
"""Determine privileges held by a user over this object.
235
If the user requesting privileges is this user or an admin,
236
they may do everything. Otherwise they may do nothing.
238
if user and user.admin or user is self:
239
return set(['view_public', 'view', 'edit', 'submit_project'])
241
return set(['view_public'])
199
243
# SUBJECTS AND ENROLMENTS #
201
245
class Subject(Storm):
246
"""A subject (or course) which is run in some semesters."""
202
248
__storm_table__ = "subject"
204
250
id = Int(primary=True, name="subjectid")
205
251
code = Unicode(name="subj_code")
206
252
name = Unicode(name="subj_name")
207
253
short_name = Unicode(name="subj_short_name")
210
255
offerings = ReferenceSet(id, 'Offering.subject_id')
214
259
def __repr__(self):
215
260
return "<%s '%s'>" % (type(self).__name__, self.short_name)
262
def get_permissions(self, user, config):
263
"""Determine privileges held by a user over this object.
265
If the user requesting privileges is an admin, they may edit.
266
Otherwise they may only read.
275
def active_offerings(self):
276
"""Find active offerings for this subject.
278
Return a sequence of currently active offerings for this subject
279
(offerings whose semester.state is "current"). There should be 0 or 1
280
elements in this sequence, but it's possible there are more.
282
return self.offerings.find(Offering.semester_id == Semester.id,
283
Semester.state == u'current')
285
def offering_for_semester(self, year, semester):
286
"""Get the offering for the given year/semester, or None.
288
@param year: A string representation of the year.
289
@param semester: A string representation of the semester.
291
return self.offerings.find(Offering.semester_id == Semester.id,
292
Semester.year == unicode(year),
293
Semester.semester == unicode(semester)).one()
217
295
class Semester(Storm):
296
"""A semester in which subjects can be run."""
218
298
__storm_table__ = "semester"
220
300
id = Int(primary=True, name="semesterid")
222
302
semester = Unicode()
225
305
offerings = ReferenceSet(id, 'Offering.semester_id')
306
enrolments = ReferenceSet(id,
307
'Offering.semester_id',
309
'Enrolment.offering_id')
227
311
__init__ = _kwarg_init
252
351
return "<%s %r in %r>" % (type(self).__name__, self.subject,
255
def enrol(self, user):
256
'''Enrol a user in this offering.'''
257
# We'll get a horrible database constraint violation error if we try
258
# to add a second enrolment.
259
if Store.of(self).find(Enrolment,
260
Enrolment.user_id == user.id,
261
Enrolment.offering_id == self.id).count() == 1:
262
raise AlreadyEnrolledError()
264
e = Enrolment(user=user, offering=self, active=True)
265
self.enrolments.add(e)
354
def enrol(self, user, role=u'student'):
355
"""Enrol a user in this offering.
357
Enrolments handle both the staff and student cases. The role controls
358
the privileges granted by this enrolment.
360
enrolment = Store.of(self).find(Enrolment,
361
Enrolment.user_id == user.id,
362
Enrolment.offering_id == self.id).one()
364
if enrolment is None:
365
enrolment = Enrolment(user=user, offering=self)
366
self.enrolments.add(enrolment)
368
enrolment.active = True
369
enrolment.role = role
371
def unenrol(self, user):
372
'''Unenrol a user from this offering.'''
373
enrolment = Store.of(self).find(Enrolment,
374
Enrolment.user_id == user.id,
375
Enrolment.offering_id == self.id).one()
376
Store.of(enrolment).remove(enrolment)
378
def get_permissions(self, user, config):
381
enrolment = self.get_enrolment(user)
382
if enrolment or user.admin:
384
if enrolment and enrolment.role == u'tutor':
385
perms.add('view_project_submissions')
386
# Site-specific policy on the role of tutors
387
if config['policy']['tutors_can_enrol_students']:
389
perms.add('enrol_student')
390
if config['policy']['tutors_can_edit_worksheets']:
391
perms.add('edit_worksheets')
392
if config['policy']['tutors_can_admin_groups']:
393
perms.add('admin_groups')
394
if (enrolment and enrolment.role in (u'lecturer')) or user.admin:
395
perms.add('view_project_submissions')
396
perms.add('admin_groups')
397
perms.add('edit_worksheets')
398
perms.add('view_worksheet_marks')
399
perms.add('edit') # Can edit projects & details
400
perms.add('enrol') # Can see enrolment screen at all
401
perms.add('enrol_student') # Can enrol students
402
perms.add('enrol_tutor') # Can enrol tutors
404
perms.add('enrol_lecturer') # Can enrol lecturers
407
def get_enrolment(self, user):
408
"""Find the user's enrolment in this offering."""
410
enrolment = self.enrolments.find(user=user).one()
416
def get_members_by_role(self, role):
417
return Store.of(self).find(User,
418
Enrolment.user_id == User.id,
419
Enrolment.offering_id == self.id,
420
Enrolment.role == role
421
).order_by(User.login)
425
return self.get_members_by_role(u'student')
427
def get_open_projects_for_user(self, user):
428
"""Find all projects currently open to submissions by a user."""
429
# XXX: Respect extensions.
430
return self.projects.find(Project.deadline > datetime.datetime.now())
432
def has_worksheet_cutoff_passed(self, user):
433
"""Check whether the worksheet cutoff has passed.
434
A user is required, in case we support extensions.
436
if self.worksheet_cutoff is None:
439
return self.worksheet_cutoff < datetime.datetime.now()
441
def clone_worksheets(self, source):
442
"""Clone all worksheets from the specified source to this offering."""
443
import ivle.worksheet.utils
444
for worksheet in source.worksheets:
446
newws.seq_no = worksheet.seq_no
447
newws.identifier = worksheet.identifier
448
newws.name = worksheet.name
449
newws.assessable = worksheet.assessable
450
newws.published = worksheet.published
451
newws.data = worksheet.data
452
newws.format = worksheet.format
453
newws.offering = self
454
Store.of(self).add(newws)
455
ivle.worksheet.utils.update_exerciselist(newws)
267
458
class Enrolment(Storm):
459
"""An enrolment of a user in an offering.
461
This represents the roles of both staff and students.
268
464
__storm_table__ = "enrolment"
269
465
__storm_primary__ = "user_id", "offering_id"
311
524
return "<%s %d in %r>" % (type(self).__name__, self.id,
527
def get_permissions(self, user, config):
528
return self.offering.get_permissions(user, config)
530
def get_groups_for_user(self, user):
531
"""List all groups in this offering of which the user is a member."""
533
return Store.of(self).find(
535
ProjectGroupMembership.user_id == user.id,
536
ProjectGroupMembership.project_group_id == ProjectGroup.id,
537
ProjectGroup.project_set_id == self.id)
539
def get_submission_principal(self, user):
540
"""Get the principal on behalf of which the user can submit.
542
If this is a solo project set, the given user is returned. If
543
the user is a member of exactly one group, all the group is
544
returned. Otherwise, None is returned.
547
groups = self.get_groups_for_user(user)
548
if groups.count() == 1:
557
return self.max_students_per_group is not None
561
"""Get the entities (groups or users) assigned to submit this project.
563
This will be a Storm ResultSet.
565
#If its a solo project, return everyone in offering
567
return self.project_groups
569
return self.offering.students
571
class DeadlinePassed(Exception):
572
"""An exception indicating that a project cannot be submitted because the
573
deadline has passed."""
577
return "The project deadline has passed"
314
579
class Project(Storm):
580
"""A student project for which submissions can be made."""
315
582
__storm_table__ = "project"
317
584
id = Int(name="projectid", primary=True)
586
short_name = Unicode()
318
587
synopsis = Unicode()
320
589
project_set_id = Int(name="projectsetid")
321
590
project_set = Reference(project_set_id, ProjectSet.id)
322
591
deadline = DateTime()
593
assesseds = ReferenceSet(id, 'Assessed.project_id')
594
submissions = ReferenceSet(id,
595
'Assessed.project_id',
597
'ProjectSubmission.assessed_id')
324
599
__init__ = _kwarg_init
326
601
def __repr__(self):
327
return "<%s '%s' in %r>" % (type(self).__name__, self.synopsis,
602
return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
328
603
self.project_set.offering)
605
def can_submit(self, principal, user):
606
return (self in principal.get_projects() and
607
not self.has_deadline_passed(user))
609
def submit(self, principal, path, revision, who):
610
"""Submit a Subversion path and revision to a project.
612
@param principal: The owner of the Subversion repository, and the
613
entity on behalf of whom the submission is being made
614
@param path: A path within that repository to submit.
615
@param revision: The revision of that path to submit.
616
@param who: The user who is actually making the submission.
619
if not self.can_submit(principal, who):
620
raise DeadlinePassed()
622
a = Assessed.get(Store.of(self), principal, self)
623
ps = ProjectSubmission()
624
# Raise SubmissionError if the path is illegal
625
ps.path = ProjectSubmission.test_and_normalise_path(path)
626
ps.revision = revision
627
ps.date_submitted = datetime.datetime.now()
633
def get_permissions(self, user, config):
634
return self.project_set.offering.get_permissions(user, config)
637
def latest_submissions(self):
638
"""Return the latest submission for each Assessed."""
639
return Store.of(self).find(ProjectSubmission,
640
Assessed.project_id == self.id,
641
ProjectSubmission.assessed_id == Assessed.id,
642
ProjectSubmission.date_submitted == Select(
643
Max(ProjectSubmission.date_submitted),
644
ProjectSubmission.assessed_id == Assessed.id,
645
tables=ProjectSubmission
649
def has_deadline_passed(self, user):
650
"""Check whether the deadline has passed."""
651
# XXX: Need to respect extensions.
652
return self.deadline < datetime.datetime.now()
654
def get_submissions_for_principal(self, principal):
655
"""Fetch a ResultSet of all submissions by a particular principal."""
656
assessed = Assessed.get(Store.of(self), principal, self)
659
return assessed.submissions
662
def can_delete(self):
663
"""Can only delete if there are no submissions."""
664
return self.submissions.count() == 0
667
"""Delete the project. Fails if can_delete is False."""
668
if not self.can_delete:
669
raise IntegrityError()
670
for assessed in self.assesseds:
672
Store.of(self).remove(self)
330
674
class ProjectGroup(Storm):
675
"""A group of students working together on a project."""
331
677
__storm_table__ = "project_group"
333
679
id = Int(name="groupid", primary=True)
350
696
return "<%s %s in %r>" % (type(self).__name__, self.name,
351
697
self.project_set.offering)
700
def display_name(self):
701
"""Returns the "nice name" of the user or group."""
705
def short_name(self):
706
"""Returns the database "identifier" name of the user or group."""
709
def get_projects(self, offering=None, active_only=True):
710
'''Find projects that the group can submit.
712
This will include projects in the project set which owns this group,
713
unless the project set disallows groups (in which case none will be
716
@param active_only: Whether to only search active offerings.
717
@param offering: An optional offering to restrict the search to.
719
return Store.of(self).find(Project,
720
Project.project_set_id == ProjectSet.id,
721
ProjectSet.id == self.project_set.id,
722
ProjectSet.max_students_per_group != None,
723
ProjectSet.offering_id == Offering.id,
724
(offering is None) or (Offering.id == offering.id),
725
Semester.id == Offering.semester_id,
726
(not active_only) or (Semester.state == u'current'))
729
def get_permissions(self, user, config):
730
if user.admin or user in self.members:
731
return set(['submit_project'])
353
735
class ProjectGroupMembership(Storm):
736
"""A student's membership in a project group."""
354
738
__storm_table__ = "group_member"
355
739
__storm_primary__ = "user_id", "project_group_id"
365
749
return "<%s %r in %r>" % (type(self).__name__, self.user,
366
750
self.project_group)
752
class Assessed(Storm):
753
"""A composite of a user or group combined with a project.
755
Each project submission and extension refers to an Assessed. It is the
756
sole specifier of the repository and project.
759
__storm_table__ = "assessed"
761
id = Int(name="assessedid", primary=True)
762
user_id = Int(name="loginid")
763
user = Reference(user_id, User.id)
764
project_group_id = Int(name="groupid")
765
project_group = Reference(project_group_id, ProjectGroup.id)
767
project_id = Int(name="projectid")
768
project = Reference(project_id, Project.id)
770
extensions = ReferenceSet(id, 'ProjectExtension.assessed_id')
771
submissions = ReferenceSet(
772
id, 'ProjectSubmission.assessed_id', order_by='date_submitted')
775
return "<%s %r in %r>" % (type(self).__name__,
776
self.user or self.project_group, self.project)
780
"""True if the Assessed is a group, False if it is a user."""
781
return self.project_group is not None
785
return self.project_group or self.user
788
def checkout_location(self):
789
"""Returns the location of the Subversion workspace for this piece of
790
assessment, relative to each group member's home directory."""
791
subjectname = self.project.project_set.offering.subject.short_name
793
checkout_dir_name = self.principal.short_name
795
checkout_dir_name = "mywork"
796
return subjectname + "/" + checkout_dir_name
799
def get(cls, store, principal, project):
800
"""Find or create an Assessed for the given user or group and project.
802
@param principal: The user or group.
803
@param project: The project.
806
if t not in (User, ProjectGroup):
807
raise AssertionError('principal must be User or ProjectGroup')
810
(t is User) or (cls.project_group_id == principal.id),
811
(t is ProjectGroup) or (cls.user_id == principal.id),
812
cls.project_id == project.id).one()
819
a.project_group = principal
826
"""Delete the assessed. Fails if there are any submissions. Deletes
828
if self.submissions.count() > 0:
829
raise IntegrityError()
830
for extension in self.extensions:
832
Store.of(self).remove(self)
834
class ProjectExtension(Storm):
835
"""An extension granted to a user or group on a particular project.
837
The user or group and project are specified by the Assessed.
840
__storm_table__ = "project_extension"
842
id = Int(name="extensionid", primary=True)
843
assessed_id = Int(name="assessedid")
844
assessed = Reference(assessed_id, Assessed.id)
845
deadline = DateTime()
846
approver_id = Int(name="approver")
847
approver = Reference(approver_id, User.id)
851
"""Delete the extension."""
852
Store.of(self).remove(self)
854
class SubmissionError(Exception):
855
"""Denotes a validation error during submission."""
858
class ProjectSubmission(Storm):
859
"""A submission from a user or group repository to a particular project.
861
The content of a submission is a single path and revision inside a
862
repository. The repository is that owned by the submission's user and
863
group, while the path and revision are explicit.
865
The user or group and project are specified by the Assessed.
868
__storm_table__ = "project_submission"
870
id = Int(name="submissionid", primary=True)
871
assessed_id = Int(name="assessedid")
872
assessed = Reference(assessed_id, Assessed.id)
875
submitter_id = Int(name="submitter")
876
submitter = Reference(submitter_id, User.id)
877
date_submitted = DateTime()
879
def get_verify_url(self, user):
880
"""Get the URL for verifying this submission, within the account of
882
# If this is a solo project, then self.path will be prefixed with the
883
# subject name. Remove the first path segment.
884
submitpath = self.path[1:] if self.path[:1] == '/' else self.path
885
if not self.assessed.is_group:
886
if '/' in submitpath:
887
submitpath = submitpath.split('/', 1)[1]
890
return "/files/%s/%s/%s?r=%d" % (user.login,
891
self.assessed.checkout_location, submitpath, self.revision)
894
def test_and_normalise_path(path):
895
"""Test that path is valid, and normalise it. This prevents possible
896
injections using malicious paths.
897
Returns the updated path, if successful.
898
Raises SubmissionError if invalid.
900
# Ensure the path is absolute to prevent being tacked onto working
902
# Prevent '\n' because it will break all sorts of things.
903
# Prevent '[' and ']' because they can be used to inject into the
905
# Normalise to avoid resulting in ".." path segments.
906
if not os.path.isabs(path):
907
raise SubmissionError("Path is not absolute")
908
if any(c in path for c in "\n[]"):
909
raise SubmissionError("Path must not contain '\\n', '[' or ']'")
910
return os.path.normpath(path)
368
912
# WORKSHEETS AND EXERCISES #
370
914
class Exercise(Storm):
371
# Note: Table "problem" is called "Exercise" in the Object layer, since
372
# it's called that everywhere else.
373
__storm_table__ = "problem"
375
id = Int(primary=True, name="problemid")
376
name = Unicode(name="identifier")
915
"""An exercise for students to complete in a worksheet.
917
An exercise may be present in any number of worksheets.
920
__storm_table__ = "exercise"
921
id = Unicode(primary=True, name="identifier")
923
description = Unicode()
924
_description_xhtml_cache = Unicode(name='description_xhtml_cache')
930
worksheet_exercises = ReferenceSet(id,
931
'WorksheetExercise.exercise_id')
379
933
worksheets = ReferenceSet(id,
380
934
'WorksheetExercise.exercise_id',
939
test_suites = ReferenceSet(id,
940
'TestSuite.exercise_id',
385
943
__init__ = _kwarg_init
387
945
def __repr__(self):
388
946
return "<%s %s>" % (type(self).__name__, self.name)
391
def get_by_name(cls, store, name):
393
Get the Exercise from the db associated with a given store and name.
394
If the exercise is not in the database, creates it and inserts it
397
ex = store.find(cls, cls.name == unicode(name)).one()
400
ex = Exercise(name=unicode(name))
948
def get_permissions(self, user, config):
949
return self.global_permissions(user, config)
952
def global_permissions(user, config):
953
"""Gets the set of permissions this user has over *all* exercises.
954
This is used to determine who may view the exercises list, and create
962
elif u'lecturer' in set((e.role for e in user.active_enrolments)):
965
elif (config['policy']['tutors_can_edit_worksheets']
966
and u'tutor' in set((e.role for e in user.active_enrolments))):
967
# Site-specific policy on the role of tutors
973
def _cache_description_xhtml(self, invalidate=False):
974
# Don't regenerate an existing cache unless forced.
975
if self._description_xhtml_cache is not None and not invalidate:
979
self._description_xhtml_cache = rst(self.description)
981
self._description_xhtml_cache = None
984
def description_xhtml(self):
985
"""The XHTML exercise description, converted from reStructuredText."""
986
self._cache_description_xhtml()
987
return self._description_xhtml_cache
989
def set_description(self, description):
990
self.description = description
991
self._cache_description_xhtml(invalidate=True)
994
"""Deletes the exercise, providing it has no associated worksheets."""
995
if (self.worksheet_exercises.count() > 0):
996
raise IntegrityError()
997
for suite in self.test_suites:
999
Store.of(self).remove(self)
405
1001
class Worksheet(Storm):
1002
"""A worksheet with exercises for students to complete.
1004
Worksheets are owned by offerings.
406
1007
__storm_table__ = "worksheet"
408
1009
id = Int(primary=True, name="worksheetid")
409
# XXX subject is not linked to a Subject object. This is a property of
410
# the database, and will be refactored.
412
name = Unicode(name="identifier")
1010
offering_id = Int(name="offeringid")
1011
identifier = Unicode()
413
1013
assessable = Bool()
416
exercises = ReferenceSet(id,
417
'WorksheetExercise.worksheet_id',
418
'WorksheetExercise.exercise_id',
420
# Use worksheet_exercises to get access to the WorksheetExercise objects
421
# binding worksheets to exercises. This is required to access the
1016
_data_xhtml_cache = Unicode(name='data_xhtml_cache')
1020
attempts = ReferenceSet(id, "ExerciseAttempt.worksheetid")
1021
offering = Reference(offering_id, 'Offering.id')
1023
all_worksheet_exercises = ReferenceSet(id,
1024
'WorksheetExercise.worksheet_id')
1026
# Use worksheet_exercises to get access to the *active* WorksheetExercise
1027
# objects binding worksheets to exercises. This is required to access the
422
1028
# "optional" field.
423
worksheet_exercises = ReferenceSet(id,
424
'WorksheetExercise.worksheet_id')
1031
def worksheet_exercises(self):
1032
return self.all_worksheet_exercises.find(active=True)
426
1034
__init__ = _kwarg_init
428
1036
def __repr__(self):
429
1037
return "<%s %s>" % (type(self).__name__, self.name)
431
# XXX Refactor this - make it an instance method of Subject rather than a
432
# class method of Worksheet. Can't do that now because Subject isn't
433
# linked referentially to the Worksheet.
435
def get_by_name(cls, store, subjectname, worksheetname):
437
Get the Worksheet from the db associated with a given store, subject
438
name and worksheet name.
440
return store.find(cls, cls.subject == unicode(subjectname),
441
cls.name == unicode(worksheetname)).one()
1039
def remove_all_exercises(self):
1040
"""Remove all exercises from this worksheet.
443
def remove_all_exercises(self, store):
445
Remove all exercises from this worksheet.
446
1042
This does not delete the exercises themselves. It just removes them
447
1043
from the worksheet.
1045
store = Store.of(self)
1046
for ws_ex in self.all_worksheet_exercises:
1047
if ws_ex.saves.count() > 0 or ws_ex.attempts.count() > 0:
1048
raise IntegrityError()
449
1049
store.find(WorksheetExercise,
450
1050
WorksheetExercise.worksheet == self).remove()
1052
def get_permissions(self, user, config):
1053
offering_perms = self.offering.get_permissions(user, config)
1057
# Anybody who can view an offering can view a published
1059
if 'view' in offering_perms and self.published:
1062
# Any worksheet editors can both view and edit.
1063
if 'edit_worksheets' in offering_perms:
1069
def _cache_data_xhtml(self, invalidate=False):
1070
# Don't regenerate an existing cache unless forced.
1071
if self._data_xhtml_cache is not None and not invalidate:
1074
if self.format == u'rst':
1075
self._data_xhtml_cache = rst(self.data)
1077
self._data_xhtml_cache = None
1080
def data_xhtml(self):
1081
"""The XHTML of this worksheet, converted from rST if required."""
1082
# Update the rST -> XHTML cache, if required.
1083
self._cache_data_xhtml()
1085
if self.format == u'rst':
1086
return self._data_xhtml_cache
1090
def set_data(self, data):
1092
self._cache_data_xhtml(invalidate=True)
1095
"""Deletes the worksheet, provided it has no attempts on any exercises.
1097
Returns True if delete succeeded, or False if this worksheet has
1098
attempts attached."""
1099
for ws_ex in self.all_worksheet_exercises:
1100
if ws_ex.saves.count() > 0 or ws_ex.attempts.count() > 0:
1101
raise IntegrityError()
1103
self.remove_all_exercises()
1104
Store.of(self).remove(self)
452
1106
class WorksheetExercise(Storm):
453
__storm_table__ = "worksheet_problem"
454
__storm_primary__ = "worksheet_id", "exercise_id"
1107
"""A link between a worksheet and one of its exercises.
1109
These may be marked optional, in which case the exercise does not count
1110
for marking purposes. The sequence number is used to order the worksheet
1114
__storm_table__ = "worksheet_exercise"
1116
id = Int(primary=True, name="ws_ex_id")
456
1118
worksheet_id = Int(name="worksheetid")
457
1119
worksheet = Reference(worksheet_id, Worksheet.id)
458
exercise_id = Int(name="problemid")
1120
exercise_id = Unicode(name="exerciseid")
459
1121
exercise = Reference(exercise_id, Exercise.id)
460
1122
optional = Bool()
1126
saves = ReferenceSet(id, "ExerciseSave.ws_ex_id")
1127
attempts = ReferenceSet(id, "ExerciseAttempt.ws_ex_id")
462
1129
__init__ = _kwarg_init
464
1131
def __repr__(self):
465
1132
return "<%s %s in %s>" % (type(self).__name__, self.exercise.name,
1133
self.worksheet.identifier)
1135
def get_permissions(self, user, config):
1136
return self.worksheet.get_permissions(user, config)
468
1139
class ExerciseSave(Storm):
470
Represents a potential solution to an exercise that a user has submitted
471
to the server for storage.
472
A basic ExerciseSave is just the current saved text for this exercise for
473
this user (doesn't count towards their attempts).
474
ExerciseSave may be extended with additional semantics (such as
477
__storm_table__ = "problem_save"
478
__storm_primary__ = "exercise_id", "user_id", "date"
480
exercise_id = Int(name="problemid")
481
exercise = Reference(exercise_id, Exercise.id)
1140
"""A potential exercise solution submitted by a user for storage.
1142
This is not an actual tested attempt at an exercise, it's just a save of
1143
the editing session.
1146
__storm_table__ = "exercise_save"
1147
__storm_primary__ = "ws_ex_id", "user_id"
1149
ws_ex_id = Int(name="ws_ex_id")
1150
worksheet_exercise = Reference(ws_ex_id, "WorksheetExercise.id")
482
1152
user_id = Int(name="loginid")
483
1153
user = Reference(user_id, User.id)
484
1154
date = DateTime()
489
1159
def __repr__(self):
490
1160
return "<%s %s by %s at %s>" % (type(self).__name__,
491
self.exercise.name, self.user.login, self.date.strftime("%c"))
1161
self.worksheet_exercise.exercise.name, self.user.login,
1162
self.date.strftime("%c"))
493
1164
class ExerciseAttempt(ExerciseSave):
495
An ExerciseAttempt is a special case of an ExerciseSave. Like an
496
ExerciseSave, it constitutes exercise solution data that the user has
497
submitted to the server for storage.
498
In addition, it contains additional information about the submission.
499
complete - True if this submission was successful, rendering this exercise
500
complete for this user.
501
active - True if this submission is "active" (usually true). Submissions
502
may be de-activated by privileged users for special reasons, and then
503
they won't count (either as a penalty or success), but will still be
506
__storm_table__ = "problem_attempt"
507
__storm_primary__ = "exercise_id", "user_id", "date"
1165
"""An attempt at solving an exercise.
1167
This is a special case of ExerciseSave, used when the user submits a
1168
candidate solution. Like an ExerciseSave, it constitutes exercise solution
1171
In addition, it contains information about the result of the submission:
1173
- complete - True if this submission was successful, rendering this
1174
exercise complete for this user in this worksheet.
1175
- active - True if this submission is "active" (usually true).
1176
Submissions may be de-activated by privileged users for
1177
special reasons, and then they won't count (either as a
1178
penalty or success), but will still be stored.
1181
__storm_table__ = "exercise_attempt"
1182
__storm_primary__ = "ws_ex_id", "user_id", "date"
509
1184
# The "text" field is the same but has a different name in the DB table
510
1185
# for some reason.
511
1186
text = Unicode(name="attempt")
512
1187
complete = Bool()
1190
def get_permissions(self, user, config):
1191
return set(['view']) if user is self.user else set()
1193
class TestSuite(Storm):
1194
"""A container to group an exercise's test cases.
1196
The test suite contains some information on how to test. The function to
1197
test, variables to set and stdin data are stored here.
1200
__storm_table__ = "test_suite"
1201
__storm_primary__ = "exercise_id", "suiteid"
1204
exercise_id = Unicode(name="exerciseid")
1205
description = Unicode()
1207
function = Unicode()
1209
exercise = Reference(exercise_id, Exercise.id)
1210
test_cases = ReferenceSet(suiteid, 'TestCase.suiteid', order_by="seq_no")
1211
variables = ReferenceSet(suiteid, 'TestSuiteVar.suiteid', order_by='arg_no')
1214
"""Delete this suite, without asking questions."""
1215
for variable in self.variables:
1217
for test_case in self.test_cases:
1219
Store.of(self).remove(self)
1221
class TestCase(Storm):
1222
"""A container for actual tests (see TestCasePart), inside a test suite.
1224
It is the lowest level shown to students on their pass/fail status."""
1226
__storm_table__ = "test_case"
1227
__storm_primary__ = "testid", "suiteid"
1231
suite = Reference(suiteid, "TestSuite.suiteid")
1234
test_default = Unicode() # Currently unused - only used for file matching.
1237
parts = ReferenceSet(testid, "TestCasePart.testid")
1239
__init__ = _kwarg_init
1242
for part in self.parts:
1244
Store.of(self).remove(self)
1246
class TestSuiteVar(Storm):
1247
"""A variable used by an exercise test suite.
1249
This may represent a function argument or a normal variable.
1252
__storm_table__ = "suite_variable"
1253
__storm_primary__ = "varid"
1257
var_name = Unicode()
1258
var_value = Unicode()
1259
var_type = Unicode()
1262
suite = Reference(suiteid, "TestSuite.suiteid")
1264
__init__ = _kwarg_init
1267
Store.of(self).remove(self)
1269
class TestCasePart(Storm):
1270
"""An actual piece of code to test an exercise solution."""
1272
__storm_table__ = "test_case_part"
1273
__storm_primary__ = "partid"
1278
part_type = Unicode()
1279
test_type = Unicode()
1281
filename = Unicode()
1283
test = Reference(testid, "TestCase.testid")
1285
__init__ = _kwarg_init
1288
Store.of(self).remove(self)