18
18
# Author: Matt Giuca, Will Grant
20
"""Database utilities and content classes.
21
Database Classes and Utilities for Storm ORM
22
23
This module provides all of the classes which map to database tables.
23
24
It also provides miscellaneous utility functions for database interaction.
31
30
from storm.locals import create_database, Store, Int, Unicode, DateTime, \
32
31
Reference, ReferenceSet, Bool, Storm, Desc
33
from storm.expr import Select, Max
34
32
from storm.exceptions import NotOneError, IntegrityError
36
35
from ivle.worksheet.rst import rst
38
37
__all__ = ['get_store',
52
51
% (self.__class__.__name__, k))
53
52
setattr(self, k, v)
55
def get_conn_string(config):
56
"""Create a Storm connection string to the IVLE database
58
@param config: The IVLE configuration.
54
def get_conn_string():
56
Returns the Storm connection string, generated from the conf file.
62
if config['database']['username']:
63
clusterstr += config['database']['username']
64
if config['database']['password']:
65
clusterstr += ':' + config['database']['password']
61
clusterstr += ivle.conf.db_user
62
if ivle.conf.db_password:
63
clusterstr += ':' + ivle.conf.db_password
68
host = config['database']['host'] or 'localhost'
69
port = config['database']['port'] or 5432
66
host = ivle.conf.db_host or 'localhost'
67
port = ivle.conf.db_port or 5432
71
69
clusterstr += '%s:%d' % (host, port)
73
return "postgres://%s/%s" % (clusterstr, config['database']['name'])
75
def get_store(config):
76
"""Create a Storm store connected to the IVLE database.
78
@param config: The IVLE configuration.
80
return Store(create_database(get_conn_string(config)))
71
return "postgres://%s/%s" % (clusterstr, ivle.conf.db_dbname)
75
Open a database connection and transaction. Return a storm.store.Store
76
instance connected to the configured IVLE database.
78
return Store(create_database(get_conn_string()))
85
"""An IVLE user account."""
84
Represents an IVLE user.
86
86
__storm_table__ = "login"
88
88
id = Int(primary=True, name="loginid")
200
186
return self._get_enrolments(False)
202
188
def get_projects(self, offering=None, active_only=True):
203
"""Find projects that the user can submit.
189
'''Return Projects that the user can submit.
205
191
This will include projects for offerings in which the user is
206
192
enrolled, as long as the project is not in a project set which has
207
193
groups (ie. if maximum number of group members is 0).
209
@param active_only: Whether to only search active offerings.
210
@param offering: An optional offering to restrict the search to.
195
Unless active_only is False, only projects for active offerings will
198
If an offering is specified, returned projects will be limited to
199
those for that offering.
212
201
return Store.of(self).find(Project,
213
202
Project.project_set_id == ProjectSet.id,
214
ProjectSet.max_students_per_group == None,
203
ProjectSet.max_students_per_group == 0,
215
204
ProjectSet.offering_id == Offering.id,
216
205
(offering is None) or (Offering.id == offering.id),
217
206
Semester.id == Offering.semester_id,
218
207
(not active_only) or (Semester.state == u'current'),
219
208
Enrolment.offering_id == Offering.id,
220
Enrolment.user_id == self.id,
221
Enrolment.active == True)
209
Enrolment.user_id == self.id)
224
212
def hash_password(password):
225
"""Hash a password with MD5."""
226
return hashlib.md5(password).hexdigest()
213
return md5.md5(password).hexdigest()
229
216
def get_by_login(cls, store, login):
230
"""Find a user in a store by login name."""
218
Get the User from the db associated with a given store and
231
221
return store.find(cls, cls.login == unicode(login)).one()
233
def get_svn_url(self, config):
234
"""Get the subversion repository URL for this user or group."""
235
path = 'users/%s' % self.login
236
return urlparse.urljoin(config['urls']['svn_addr'], path)
238
def get_permissions(self, user, config):
239
"""Determine privileges held by a user over this object.
241
If the user requesting privileges is this user or an admin,
242
they may do everything. Otherwise they may do nothing.
223
def get_permissions(self, user):
244
224
if user and user.admin or user is self:
245
return set(['view_public', 'view', 'edit', 'submit_project'])
225
return set(['view', 'edit', 'submit_project'])
247
return set(['view_public'])
249
229
# SUBJECTS AND ENROLMENTS #
251
231
class Subject(Storm):
252
"""A subject (or course) which is run in some semesters."""
254
232
__storm_table__ = "subject"
256
234
id = Int(primary=True, name="subjectid")
257
235
code = Unicode(name="subj_code")
258
236
name = Unicode(name="subj_name")
259
237
short_name = Unicode(name="subj_short_name")
261
240
offerings = ReferenceSet(id, 'Offering.subject_id')
278
252
perms.add('edit')
281
def active_offerings(self):
282
"""Find active offerings for this subject.
284
Return a sequence of currently active offerings for this subject
285
(offerings whose semester.state is "current"). There should be 0 or 1
286
elements in this sequence, but it's possible there are more.
288
return self.offerings.find(Offering.semester_id == Semester.id,
289
Semester.state == u'current')
291
def offering_for_semester(self, year, semester):
292
"""Get the offering for the given year/semester, or None.
294
@param year: A string representation of the year.
295
@param semester: A string representation of the semester.
297
return self.offerings.find(Offering.semester_id == Semester.id,
298
Semester.year == unicode(year),
299
Semester.semester == unicode(semester)).one()
301
255
class Semester(Storm):
302
"""A semester in which subjects can be run."""
304
256
__storm_table__ = "semester"
306
258
id = Int(primary=True, name="semesterid")
381
319
Enrolment.offering_id == self.id).one()
382
320
Store.of(enrolment).remove(enrolment)
384
def get_permissions(self, user, config):
322
def get_permissions(self, user):
386
324
if user is not None:
387
325
enrolment = self.get_enrolment(user)
388
326
if enrolment or user.admin:
389
327
perms.add('view')
390
if enrolment and enrolment.role == u'tutor':
391
perms.add('view_project_submissions')
392
# Site-specific policy on the role of tutors
393
if config['policy']['tutors_can_enrol_students']:
395
perms.add('enrol_student')
396
if config['policy']['tutors_can_edit_worksheets']:
397
perms.add('edit_worksheets')
398
if config['policy']['tutors_can_admin_groups']:
399
perms.add('admin_groups')
400
if (enrolment and enrolment.role in (u'lecturer')) or user.admin:
401
perms.add('view_project_submissions')
402
perms.add('admin_groups')
403
perms.add('edit_worksheets')
404
perms.add('view_worksheet_marks')
405
perms.add('edit') # Can edit projects & details
406
perms.add('enrol') # Can see enrolment screen at all
407
perms.add('enrol_student') # Can enrol students
408
perms.add('enrol_tutor') # Can enrol tutors
410
perms.add('enrol_lecturer') # Can enrol lecturers
328
if (enrolment and enrolment.role in (u'tutor', u'lecturer')) \
413
333
def get_enrolment(self, user):
414
"""Find the user's enrolment in this offering."""
416
335
enrolment = self.enrolments.find(user=user).one()
417
336
except NotOneError:
422
def get_members_by_role(self, role):
423
return Store.of(self).find(User,
424
Enrolment.user_id == User.id,
425
Enrolment.offering_id == self.id,
426
Enrolment.role == role
427
).order_by(User.login)
431
return self.get_members_by_role(u'student')
433
def get_open_projects_for_user(self, user):
434
"""Find all projects currently open to submissions by a user."""
435
# XXX: Respect extensions.
436
return self.projects.find(Project.deadline > datetime.datetime.now())
438
def has_worksheet_cutoff_passed(self, user):
439
"""Check whether the worksheet cutoff has passed.
440
A user is required, in case we support extensions.
442
if self.worksheet_cutoff is None:
445
return self.worksheet_cutoff < datetime.datetime.now()
447
def clone_worksheets(self, source):
448
"""Clone all worksheets from the specified source to this offering."""
449
import ivle.worksheet.utils
450
for worksheet in source.worksheets:
452
newws.seq_no = worksheet.seq_no
453
newws.identifier = worksheet.identifier
454
newws.name = worksheet.name
455
newws.assessable = worksheet.assessable
456
newws.published = worksheet.published
457
newws.data = worksheet.data
458
newws.format = worksheet.format
459
newws.offering = self
460
Store.of(self).add(newws)
461
ivle.worksheet.utils.update_exerciselist(newws)
464
341
class Enrolment(Storm):
465
"""An enrolment of a user in an offering.
467
This represents the roles of both staff and students.
470
342
__storm_table__ = "enrolment"
471
343
__storm_primary__ = "user_id", "offering_id"
492
364
return "<%s %r in %r>" % (type(self).__name__, self.user,
495
def get_permissions(self, user, config):
496
# A user can edit any enrolment that they could have created.
498
if ('enrol_' + str(self.role)) in self.offering.get_permissions(
504
"""Delete this enrolment."""
505
Store.of(self).remove(self)
510
369
class ProjectSet(Storm):
511
"""A set of projects that share common groups.
513
Each student project group is attached to a project set. The group is
514
valid for all projects in the group's set.
517
370
__storm_table__ = "project_set"
519
372
id = Int(name="projectsetid", primary=True)
530
383
return "<%s %d in %r>" % (type(self).__name__, self.id,
533
def get_permissions(self, user, config):
534
return self.offering.get_permissions(user, config)
536
def get_groups_for_user(self, user):
537
"""List all groups in this offering of which the user is a member."""
539
return Store.of(self).find(
541
ProjectGroupMembership.user_id == user.id,
542
ProjectGroupMembership.project_group_id == ProjectGroup.id,
543
ProjectGroup.project_set_id == self.id)
545
def get_submission_principal(self, user):
546
"""Get the principal on behalf of which the user can submit.
548
If this is a solo project set, the given user is returned. If
549
the user is a member of exactly one group, all the group is
550
returned. Otherwise, None is returned.
553
groups = self.get_groups_for_user(user)
554
if groups.count() == 1:
563
return self.max_students_per_group is not None
567
"""Get the entities (groups or users) assigned to submit this project.
569
This will be a Storm ResultSet.
571
#If its a solo project, return everyone in offering
573
return self.project_groups
575
return self.offering.students
577
class DeadlinePassed(Exception):
578
"""An exception indicating that a project cannot be submitted because the
579
deadline has passed."""
583
return "The project deadline has passed"
585
386
class Project(Storm):
586
"""A student project for which submissions can be made."""
588
387
__storm_table__ = "project"
590
389
id = Int(name="projectid", primary=True)
608
407
return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
609
408
self.project_set.offering)
611
def can_submit(self, principal, user):
612
return (self in principal.get_projects() and
613
not self.has_deadline_passed(user))
615
def submit(self, principal, path, revision, who):
616
"""Submit a Subversion path and revision to a project.
618
@param principal: The owner of the Subversion repository, and the
619
entity on behalf of whom the submission is being made
620
@param path: A path within that repository to submit.
621
@param revision: The revision of that path to submit.
622
@param who: The user who is actually making the submission.
625
if not self.can_submit(principal, who):
626
raise DeadlinePassed()
628
a = Assessed.get(Store.of(self), principal, self)
629
ps = ProjectSubmission()
630
# Raise SubmissionError if the path is illegal
631
ps.path = ProjectSubmission.test_and_normalise_path(path)
632
ps.revision = revision
633
ps.date_submitted = datetime.datetime.now()
639
def get_permissions(self, user, config):
640
return self.project_set.offering.get_permissions(user, config)
643
def latest_submissions(self):
644
"""Return the latest submission for each Assessed."""
645
return Store.of(self).find(ProjectSubmission,
646
Assessed.project_id == self.id,
647
ProjectSubmission.assessed_id == Assessed.id,
648
ProjectSubmission.date_submitted == Select(
649
Max(ProjectSubmission.date_submitted),
650
ProjectSubmission.assessed_id == Assessed.id,
651
tables=ProjectSubmission
655
def has_deadline_passed(self, user):
656
"""Check whether the deadline has passed."""
657
# XXX: Need to respect extensions.
658
return self.deadline < datetime.datetime.now()
660
def get_submissions_for_principal(self, principal):
661
"""Fetch a ResultSet of all submissions by a particular principal."""
662
assessed = Assessed.get(Store.of(self), principal, self)
665
return assessed.submissions
668
def can_delete(self):
669
"""Can only delete if there are no submissions."""
670
return self.submissions.count() == 0
673
"""Delete the project. Fails if can_delete is False."""
674
if not self.can_delete:
675
raise IntegrityError()
676
for assessed in self.assesseds:
678
Store.of(self).remove(self)
680
410
class ProjectGroup(Storm):
681
"""A group of students working together on a project."""
683
411
__storm_table__ = "project_group"
685
413
id = Int(name="groupid", primary=True)
702
430
return "<%s %s in %r>" % (type(self).__name__, self.name,
703
431
self.project_set.offering)
706
def display_name(self):
707
"""Returns the "nice name" of the user or group."""
711
def short_name(self):
712
"""Returns the database "identifier" name of the user or group."""
715
433
def get_projects(self, offering=None, active_only=True):
716
'''Find projects that the group can submit.
434
'''Return Projects that the group can submit.
718
436
This will include projects in the project set which owns this group,
719
437
unless the project set disallows groups (in which case none will be
722
@param active_only: Whether to only search active offerings.
723
@param offering: An optional offering to restrict the search to.
440
Unless active_only is False, projects will only be returned if the
441
group's offering is active.
443
If an offering is specified, projects will only be returned if it
725
446
return Store.of(self).find(Project,
726
447
Project.project_set_id == ProjectSet.id,
727
448
ProjectSet.id == self.project_set.id,
728
ProjectSet.max_students_per_group != None,
449
ProjectSet.max_students_per_group > 0,
729
450
ProjectSet.offering_id == Offering.id,
730
451
(offering is None) or (Offering.id == offering.id),
731
452
Semester.id == Offering.semester_id,
732
453
(not active_only) or (Semester.state == u'current'))
734
def get_svn_url(self, config):
735
"""Get the subversion repository URL for this user or group."""
736
path = 'groups/%s_%s_%s_%s' % (
737
self.project_set.offering.subject.short_name,
738
self.project_set.offering.semester.year,
739
self.project_set.offering.semester.semester,
742
return urlparse.urljoin(config['urls']['svn_addr'], path)
744
def get_permissions(self, user, config):
456
def get_permissions(self, user):
745
457
if user.admin or user in self.members:
746
458
return set(['submit_project'])
750
462
class ProjectGroupMembership(Storm):
751
"""A student's membership in a project group."""
753
463
__storm_table__ = "group_member"
754
464
__storm_primary__ = "user_id", "project_group_id"
783
487
project = Reference(project_id, Project.id)
785
489
extensions = ReferenceSet(id, 'ProjectExtension.assessed_id')
786
submissions = ReferenceSet(
787
id, 'ProjectSubmission.assessed_id', order_by='date_submitted')
490
submissions = ReferenceSet(id, 'ProjectSubmission.assessed_id')
789
492
def __repr__(self):
790
493
return "<%s %r in %r>" % (type(self).__name__,
791
494
self.user or self.project_group, self.project)
795
"""True if the Assessed is a group, False if it is a user."""
796
return self.project_group is not None
800
return self.project_group or self.user
803
def checkout_location(self):
804
"""Returns the location of the Subversion workspace for this piece of
805
assessment, relative to each group member's home directory."""
806
subjectname = self.project.project_set.offering.subject.short_name
808
checkout_dir_name = self.principal.short_name
810
checkout_dir_name = "mywork"
811
return subjectname + "/" + checkout_dir_name
814
def get(cls, store, principal, project):
815
"""Find or create an Assessed for the given user or group and project.
817
@param principal: The user or group.
818
@param project: The project.
821
if t not in (User, ProjectGroup):
822
raise AssertionError('principal must be User or ProjectGroup')
825
(t is User) or (cls.project_group_id == principal.id),
826
(t is ProjectGroup) or (cls.user_id == principal.id),
827
cls.project_id == project.id).one()
834
a.project_group = principal
841
"""Delete the assessed. Fails if there are any submissions. Deletes
843
if self.submissions.count() > 0:
844
raise IntegrityError()
845
for extension in self.extensions:
847
Store.of(self).remove(self)
849
496
class ProjectExtension(Storm):
850
"""An extension granted to a user or group on a particular project.
852
The user or group and project are specified by the Assessed.
855
497
__storm_table__ = "project_extension"
857
499
id = Int(name="extensionid", primary=True)
862
504
approver = Reference(approver_id, User.id)
863
505
notes = Unicode()
866
"""Delete the extension."""
867
Store.of(self).remove(self)
869
class SubmissionError(Exception):
870
"""Denotes a validation error during submission."""
873
507
class ProjectSubmission(Storm):
874
"""A submission from a user or group repository to a particular project.
876
The content of a submission is a single path and revision inside a
877
repository. The repository is that owned by the submission's user and
878
group, while the path and revision are explicit.
880
The user or group and project are specified by the Assessed.
883
508
__storm_table__ = "project_submission"
885
510
id = Int(name="submissionid", primary=True)
887
512
assessed = Reference(assessed_id, Assessed.id)
890
submitter_id = Int(name="submitter")
891
submitter = Reference(submitter_id, User.id)
892
515
date_submitted = DateTime()
894
def get_verify_url(self, user):
895
"""Get the URL for verifying this submission, within the account of
897
# If this is a solo project, then self.path will be prefixed with the
898
# subject name. Remove the first path segment.
899
submitpath = self.path[1:] if self.path[:1] == '/' else self.path
900
if not self.assessed.is_group:
901
if '/' in submitpath:
902
submitpath = submitpath.split('/', 1)[1]
905
return "/files/%s/%s/%s?r=%d" % (user.login,
906
self.assessed.checkout_location, submitpath, self.revision)
909
def test_and_normalise_path(path):
910
"""Test that path is valid, and normalise it. This prevents possible
911
injections using malicious paths.
912
Returns the updated path, if successful.
913
Raises SubmissionError if invalid.
915
# Ensure the path is absolute to prevent being tacked onto working
917
# Prevent '\n' because it will break all sorts of things.
918
# Prevent '[' and ']' because they can be used to inject into the
920
# Normalise to avoid resulting in ".." path segments.
921
if not os.path.isabs(path):
922
raise SubmissionError("Path is not absolute")
923
if any(c in path for c in "\n[]"):
924
raise SubmissionError("Path must not contain '\\n', '[' or ']'")
925
return os.path.normpath(path)
927
518
# WORKSHEETS AND EXERCISES #
929
520
class Exercise(Storm):
930
"""An exercise for students to complete in a worksheet.
932
An exercise may be present in any number of worksheets.
935
521
__storm_table__ = "exercise"
936
522
id = Unicode(primary=True, name="identifier")
938
524
description = Unicode()
939
_description_xhtml_cache = Unicode(name='description_xhtml_cache')
940
525
partial = Unicode()
941
526
solution = Unicode()
942
527
include = Unicode()
960
545
def __repr__(self):
961
546
return "<%s %s>" % (type(self).__name__, self.name)
963
def get_permissions(self, user, config):
964
return self.global_permissions(user, config)
967
def global_permissions(user, config):
968
"""Gets the set of permissions this user has over *all* exercises.
969
This is used to determine who may view the exercises list, and create
548
def get_permissions(self, user):
973
551
if user is not None:
975
553
perms.add('edit')
976
554
perms.add('view')
977
elif u'lecturer' in set((e.role for e in user.active_enrolments)):
980
elif (config['policy']['tutors_can_edit_worksheets']
981
and u'tutor' in set((e.role for e in user.active_enrolments))):
982
# Site-specific policy on the role of tutors
555
elif 'lecturer' in set((e.role for e in user.active_enrolments)):
988
def _cache_description_xhtml(self, invalidate=False):
989
# Don't regenerate an existing cache unless forced.
990
if self._description_xhtml_cache is not None and not invalidate:
994
self._description_xhtml_cache = rst(self.description)
996
self._description_xhtml_cache = None
999
def description_xhtml(self):
1000
"""The XHTML exercise description, converted from reStructuredText."""
1001
self._cache_description_xhtml()
1002
return self._description_xhtml_cache
1004
def set_description(self, description):
1005
self.description = description
1006
self._cache_description_xhtml(invalidate=True)
561
def get_description(self):
562
return rst(self.description)
1008
564
def delete(self):
1009
565
"""Deletes the exercise, providing it has no associated worksheets."""
1051
600
def __repr__(self):
1052
601
return "<%s %s>" % (type(self).__name__, self.name)
603
# XXX Refactor this - make it an instance method of Subject rather than a
604
# class method of Worksheet. Can't do that now because Subject isn't
605
# linked referentially to the Worksheet.
607
def get_by_name(cls, store, subjectname, worksheetname):
609
Get the Worksheet from the db associated with a given store, subject
610
name and worksheet name.
612
return store.find(cls, cls.subject == unicode(subjectname),
613
cls.name == unicode(worksheetname)).one()
1054
615
def remove_all_exercises(self):
1055
"""Remove all exercises from this worksheet.
617
Remove all exercises from this worksheet.
1057
618
This does not delete the exercises themselves. It just removes them
1058
619
from the worksheet.
1063
624
raise IntegrityError()
1064
625
store.find(WorksheetExercise,
1065
626
WorksheetExercise.worksheet == self).remove()
1067
def get_permissions(self, user, config):
1068
offering_perms = self.offering.get_permissions(user, config)
1072
# Anybody who can view an offering can view a published
1074
if 'view' in offering_perms and self.published:
1077
# Any worksheet editors can both view and edit.
1078
if 'edit_worksheets' in offering_perms:
1084
def _cache_data_xhtml(self, invalidate=False):
1085
# Don't regenerate an existing cache unless forced.
1086
if self._data_xhtml_cache is not None and not invalidate:
1089
if self.format == u'rst':
1090
self._data_xhtml_cache = rst(self.data)
1092
self._data_xhtml_cache = None
1095
def data_xhtml(self):
1096
"""The XHTML of this worksheet, converted from rST if required."""
1097
# Update the rST -> XHTML cache, if required.
1098
self._cache_data_xhtml()
1100
if self.format == u'rst':
1101
return self._data_xhtml_cache
628
def get_permissions(self, user):
629
return self.offering.get_permissions(user)
632
"""Returns the xml of this worksheet, converts from rst if required."""
633
if self.format == u'rst':
634
ws_xml = rst(self.data)
1103
637
return self.data
1105
def set_data(self, data):
1107
self._cache_data_xhtml(invalidate=True)
1109
639
def delete(self):
1110
640
"""Deletes the worksheet, provided it has no attempts on any exercises.
1112
642
Returns True if delete succeeded, or False if this worksheet has
1113
643
attempts attached."""
1114
644
for ws_ex in self.all_worksheet_exercises:
1115
645
if ws_ex.saves.count() > 0 or ws_ex.attempts.count() > 0:
1116
646
raise IntegrityError()
1118
648
self.remove_all_exercises()
1119
649
Store.of(self).remove(self)
1121
651
class WorksheetExercise(Storm):
1122
"""A link between a worksheet and one of its exercises.
1124
These may be marked optional, in which case the exercise does not count
1125
for marking purposes. The sequence number is used to order the worksheet
1129
652
__storm_table__ = "worksheet_exercise"
1131
654
id = Int(primary=True, name="ws_ex_id")
1133
656
worksheet_id = Int(name="worksheetid")
1147
670
return "<%s %s in %s>" % (type(self).__name__, self.exercise.name,
1148
671
self.worksheet.identifier)
1150
def get_permissions(self, user, config):
1151
return self.worksheet.get_permissions(user, config)
673
def get_permissions(self, user):
674
return self.worksheet.get_permissions(user)
1154
677
class ExerciseSave(Storm):
1155
"""A potential exercise solution submitted by a user for storage.
1157
This is not an actual tested attempt at an exercise, it's just a save of
1158
the editing session.
679
Represents a potential solution to an exercise that a user has submitted
680
to the server for storage.
681
A basic ExerciseSave is just the current saved text for this exercise for
682
this user (doesn't count towards their attempts).
683
ExerciseSave may be extended with additional semantics (such as
1161
686
__storm_table__ = "exercise_save"
1162
687
__storm_primary__ = "ws_ex_id", "user_id"
1174
699
def __repr__(self):
1175
700
return "<%s %s by %s at %s>" % (type(self).__name__,
1176
self.worksheet_exercise.exercise.name, self.user.login,
1177
self.date.strftime("%c"))
701
self.exercise.name, self.user.login, self.date.strftime("%c"))
1179
703
class ExerciseAttempt(ExerciseSave):
1180
"""An attempt at solving an exercise.
1182
This is a special case of ExerciseSave, used when the user submits a
1183
candidate solution. Like an ExerciseSave, it constitutes exercise solution
1186
In addition, it contains information about the result of the submission:
1188
- complete - True if this submission was successful, rendering this
1189
exercise complete for this user in this worksheet.
1190
- active - True if this submission is "active" (usually true).
1191
Submissions may be de-activated by privileged users for
1192
special reasons, and then they won't count (either as a
1193
penalty or success), but will still be stored.
705
An ExerciseAttempt is a special case of an ExerciseSave. Like an
706
ExerciseSave, it constitutes exercise solution data that the user has
707
submitted to the server for storage.
708
In addition, it contains additional information about the submission.
709
complete - True if this submission was successful, rendering this exercise
710
complete for this user.
711
active - True if this submission is "active" (usually true). Submissions
712
may be de-activated by privileged users for special reasons, and then
713
they won't count (either as a penalty or success), but will still be
1196
716
__storm_table__ = "exercise_attempt"
1197
717
__storm_primary__ = "ws_ex_id", "user_id", "date"
1201
721
text = Unicode(name="attempt")
1202
722
complete = Bool()
1205
def get_permissions(self, user, config):
725
def get_permissions(self, user):
1206
726
return set(['view']) if user is self.user else set()
1208
728
class TestSuite(Storm):
1209
"""A container to group an exercise's test cases.
1211
The test suite contains some information on how to test. The function to
1212
test, variables to set and stdin data are stored here.
729
"""A Testsuite acts as a container for the test cases of an exercise."""
1215
730
__storm_table__ = "test_suite"
1216
731
__storm_primary__ = "exercise_id", "suiteid"
1219
734
exercise_id = Unicode(name="exerciseid")
1220
735
description = Unicode()
1224
739
exercise = Reference(exercise_id, Exercise.id)
1225
740
test_cases = ReferenceSet(suiteid, 'TestCase.suiteid', order_by="seq_no")
1226
741
variables = ReferenceSet(suiteid, 'TestSuiteVar.suiteid', order_by='arg_no')
1228
743
def delete(self):
1229
744
"""Delete this suite, without asking questions."""
1230
for variable in self.variables:
745
for vaariable in self.variables:
1231
746
variable.delete()
1232
747
for test_case in self.test_cases:
1233
748
test_case.delete()
1234
749
Store.of(self).remove(self)
1236
751
class TestCase(Storm):
1237
"""A container for actual tests (see TestCasePart), inside a test suite.
1239
It is the lowest level shown to students on their pass/fail status."""
752
"""A TestCase is a member of a TestSuite.
754
It contains the data necessary to check if an exercise is correct"""
1241
755
__storm_table__ = "test_case"
1242
756
__storm_primary__ = "testid", "suiteid"
1246
760
suite = Reference(suiteid, "TestSuite.suiteid")
1247
761
passmsg = Unicode()
1248
762
failmsg = Unicode()
1249
test_default = Unicode() # Currently unused - only used for file matching.
763
test_default = Unicode()
1252
766
parts = ReferenceSet(testid, "TestCasePart.testid")
1254
768
__init__ = _kwarg_init
1256
770
def delete(self):
1257
771
for part in self.parts:
1259
773
Store.of(self).remove(self)
1261
775
class TestSuiteVar(Storm):
1262
"""A variable used by an exercise test suite.
1264
This may represent a function argument or a normal variable.
776
"""A container for the arguments of a Test Suite"""
1267
777
__storm_table__ = "suite_variable"
1268
778
__storm_primary__ = "varid"
1272
782
var_name = Unicode()
1273
783
var_value = Unicode()
1274
784
var_type = Unicode()
1277
787
suite = Reference(suiteid, "TestSuite.suiteid")
1279
789
__init__ = _kwarg_init
1281
791
def delete(self):
1282
792
Store.of(self).remove(self)
1284
794
class TestCasePart(Storm):
1285
"""An actual piece of code to test an exercise solution."""
795
"""A container for the test elements of a Test Case"""
1287
796
__storm_table__ = "test_case_part"
1288
797
__storm_primary__ = "partid"
1293
802
part_type = Unicode()
1294
803
test_type = Unicode()
1295
804
data = Unicode()
1296
805
filename = Unicode()
1298
807
test = Reference(testid, "TestCase.testid")
1300
809
__init__ = _kwarg_init
1302
811
def delete(self):
1303
812
Store.of(self).remove(self)