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
32
33
from storm.exceptions import NotOneError, IntegrityError
35
35
from ivle.worksheet.rst import rst
37
37
__all__ = ['get_store',
51
51
% (self.__class__.__name__, k))
52
52
setattr(self, k, v)
54
def get_conn_string():
56
Returns the Storm connection string, generated from the conf file.
54
def get_conn_string(config):
55
"""Create a Storm connection string to the IVLE database
57
@param config: The IVLE configuration.
61
clusterstr += ivle.conf.db_user
62
if ivle.conf.db_password:
63
clusterstr += ':' + ivle.conf.db_password
61
if config['database']['username']:
62
clusterstr += config['database']['username']
63
if config['database']['password']:
64
clusterstr += ':' + config['database']['password']
66
host = ivle.conf.db_host or 'localhost'
67
port = ivle.conf.db_port or 5432
67
host = config['database']['host'] or 'localhost'
68
port = config['database']['port'] or 5432
69
70
clusterstr += '%s:%d' % (host, port)
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()))
72
return "postgres://%s/%s" % (clusterstr, config['database']['name'])
74
def get_store(config):
75
"""Create a Storm store connected to the IVLE database.
77
@param config: The IVLE configuration.
79
return Store(create_database(get_conn_string(config)))
84
Represents an IVLE user.
84
"""An IVLE user account."""
86
85
__storm_table__ = "login"
88
87
id = Int(primary=True, name="loginid")
165
170
# TODO: Invitations should be listed too?
166
171
def get_groups(self, offering=None):
172
"""Get groups of which this user is a member.
174
@param offering: An optional offering to restrict the search to.
168
177
ProjectGroupMembership.user_id == self.id,
169
178
ProjectGroup.id == ProjectGroupMembership.project_group_id,
190
199
return self._get_enrolments(False)
192
201
def get_projects(self, offering=None, active_only=True):
193
'''Return Projects that the user can submit.
202
"""Find projects that the user can submit.
195
204
This will include projects for offerings in which the user is
196
205
enrolled, as long as the project is not in a project set which has
197
206
groups (ie. if maximum number of group members is 0).
199
Unless active_only is False, only projects for active offerings will
202
If an offering is specified, returned projects will be limited to
203
those for that offering.
208
@param active_only: Whether to only search active offerings.
209
@param offering: An optional offering to restrict the search to.
205
211
return Store.of(self).find(Project,
206
212
Project.project_set_id == ProjectSet.id,
207
213
ProjectSet.max_students_per_group == None,
210
216
Semester.id == Offering.semester_id,
211
217
(not active_only) or (Semester.state == u'current'),
212
218
Enrolment.offering_id == Offering.id,
213
Enrolment.user_id == self.id)
219
Enrolment.user_id == self.id,
220
Enrolment.active == True)
216
223
def hash_password(password):
224
"""Hash a password with MD5."""
217
225
return hashlib.md5(password).hexdigest()
220
228
def get_by_login(cls, store, login):
222
Get the User from the db associated with a given store and
229
"""Find a user in a store by login name."""
225
230
return store.find(cls, cls.login == unicode(login)).one()
227
def get_permissions(self, user):
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.
228
238
if user and user.admin or user is self:
229
return set(['view', 'edit', 'submit_project'])
239
return set(['view_public', 'view', 'edit', 'submit_project'])
241
return set(['view_public'])
233
243
# SUBJECTS AND ENROLMENTS #
235
245
class Subject(Storm):
246
"""A subject (or course) which is run in some semesters."""
236
248
__storm_table__ = "subject"
238
250
id = Int(primary=True, name="subjectid")
239
251
code = Unicode(name="subj_code")
240
252
name = Unicode(name="subj_name")
241
253
short_name = Unicode(name="subj_short_name")
244
255
offerings = ReferenceSet(id, 'Offering.subject_id')
248
259
def __repr__(self):
249
260
return "<%s '%s'>" % (type(self).__name__, self.short_name)
251
def get_permissions(self, user):
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.
253
269
if user is not None:
254
270
perms.add('view')
259
275
def active_offerings(self):
260
"""Return a sequence of currently active offerings for this subject
276
"""Find active offerings for this subject.
278
Return a sequence of currently active offerings for this subject
261
279
(offerings whose semester.state is "current"). There should be 0 or 1
262
280
elements in this sequence, but it's possible there are more.
265
283
Semester.state == u'current')
267
285
def offering_for_semester(self, year, semester):
268
"""Get the offering for the given year/semester, or None."""
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.
269
291
return self.offerings.find(Offering.semester_id == Semester.id,
270
292
Semester.year == unicode(year),
271
293
Semester.semester == unicode(semester)).one()
273
295
class Semester(Storm):
296
"""A semester in which subjects can be run."""
274
298
__storm_table__ = "semester"
276
300
id = Int(primary=True, name="semesterid")
290
314
return "<%s %s/%s>" % (type(self).__name__, self.year, self.semester)
292
316
class Offering(Storm):
317
"""An offering of a subject in a particular semester."""
293
319
__storm_table__ = "offering"
295
321
id = Int(primary=True, name="offeringid")
297
323
subject = Reference(subject_id, Subject.id)
298
324
semester_id = Int(name="semesterid")
299
325
semester = Reference(semester_id, Semester.id)
326
description = Unicode()
328
show_worksheet_marks = Bool()
329
worksheet_cutoff = DateTime()
300
330
groups_student_permissions = Unicode()
302
332
enrolments = ReferenceSet(id, 'Enrolment.offering_id')
305
335
'Enrolment.user_id',
307
337
project_sets = ReferenceSet(id, 'ProjectSet.offering_id')
338
projects = ReferenceSet(id,
339
'ProjectSet.offering_id',
341
'Project.project_set_id')
309
343
worksheets = ReferenceSet(id,
310
344
'Worksheet.offering_id',
320
354
def enrol(self, user, role=u'student'):
321
'''Enrol a user in this offering.'''
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.
322
360
enrolment = Store.of(self).find(Enrolment,
323
361
Enrolment.user_id == user.id,
324
362
Enrolment.offering_id == self.id).one()
337
375
Enrolment.offering_id == self.id).one()
338
376
Store.of(enrolment).remove(enrolment)
340
def get_permissions(self, user):
378
def get_permissions(self, user, config):
342
380
if user is not None:
343
381
enrolment = self.get_enrolment(user)
344
382
if enrolment or user.admin:
345
383
perms.add('view')
346
if (enrolment and enrolment.role in (u'tutor', u'lecturer')) \
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
351
407
def get_enrolment(self, user):
408
"""Find the user's enrolment in this offering."""
353
410
enrolment = self.enrolments.find(user=user).one()
354
411
except NotOneError:
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)
359
458
class Enrolment(Storm):
459
"""An enrolment of a user in an offering.
461
This represents the roles of both staff and students.
360
464
__storm_table__ = "enrolment"
361
465
__storm_primary__ = "user_id", "offering_id"
382
486
return "<%s %r in %r>" % (type(self).__name__, self.user,
489
def get_permissions(self, user, config):
490
# A user can edit any enrolment that they could have created.
492
if ('enrol_' + str(self.role)) in self.offering.get_permissions(
498
"""Delete this enrolment."""
499
Store.of(self).remove(self)
387
504
class ProjectSet(Storm):
505
"""A set of projects that share common groups.
507
Each student project group is attached to a project set. The group is
508
valid for all projects in the group's set.
388
511
__storm_table__ = "project_set"
390
513
id = Int(name="projectsetid", primary=True)
401
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"
404
579
class Project(Storm):
580
"""A student project for which submissions can be made."""
405
582
__storm_table__ = "project"
407
584
id = Int(name="projectid", primary=True)
425
602
return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
426
603
self.project_set.offering)
428
def can_submit(self, principal):
605
def can_submit(self, principal, user):
429
606
return (self in principal.get_projects() and
430
self.deadline > datetime.datetime.now())
607
not self.has_deadline_passed(user))
432
609
def submit(self, principal, path, revision, who):
433
610
"""Submit a Subversion path and revision to a project.
435
'principal' is the owner of the Subversion repository, and the
436
entity on behalf of whom the submission is being made. 'path' is
437
a path within that repository, and 'revision' specifies which
438
revision of that path. 'who' is the person making the submission.
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.
441
if not self.can_submit(principal):
442
raise Exception('cannot submit')
619
if not self.can_submit(principal, who):
620
raise DeadlinePassed()
444
622
a = Assessed.get(Store.of(self), principal, self)
445
623
ps = ProjectSubmission()
624
# Raise SubmissionError if the path is illegal
625
ps.path = ProjectSubmission.test_and_normalise_path(path)
447
626
ps.revision = revision
448
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)
455
674
class ProjectGroup(Storm):
675
"""A group of students working together on a project."""
456
677
__storm_table__ = "project_group"
458
679
id = Int(name="groupid", primary=True)
479
700
def display_name(self):
480
return '%s (%s)' % (self.nick, self.name)
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."""
482
709
def get_projects(self, offering=None, active_only=True):
483
'''Return Projects that the group can submit.
710
'''Find projects that the group can submit.
485
712
This will include projects in the project set which owns this group,
486
713
unless the project set disallows groups (in which case none will be
489
Unless active_only is False, projects will only be returned if the
490
group's offering is active.
492
If an offering is specified, projects will only be returned if it
716
@param active_only: Whether to only search active offerings.
717
@param offering: An optional offering to restrict the search to.
495
719
return Store.of(self).find(Project,
496
720
Project.project_set_id == ProjectSet.id,
502
726
(not active_only) or (Semester.state == u'current'))
505
def get_permissions(self, user):
729
def get_permissions(self, user, config):
506
730
if user.admin or user in self.members:
507
731
return set(['submit_project'])
511
735
class ProjectGroupMembership(Storm):
736
"""A student's membership in a project group."""
512
738
__storm_table__ = "group_member"
513
739
__storm_primary__ = "user_id", "project_group_id"
524
750
self.project_group)
526
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.
527
759
__storm_table__ = "assessed"
529
761
id = Int(name="assessedid", primary=True)
536
768
project = Reference(project_id, Project.id)
538
770
extensions = ReferenceSet(id, 'ProjectExtension.assessed_id')
539
submissions = ReferenceSet(id, 'ProjectSubmission.assessed_id')
771
submissions = ReferenceSet(
772
id, 'ProjectSubmission.assessed_id', order_by='date_submitted')
541
774
def __repr__(self):
542
775
return "<%s %r in %r>" % (type(self).__name__,
543
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
546
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.
547
805
t = type(principal)
548
806
if t not in (User, ProjectGroup):
549
807
raise AssertionError('principal must be User or ProjectGroup')
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)
568
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.
569
840
__storm_table__ = "project_extension"
571
842
id = Int(name="extensionid", primary=True)
576
847
approver = Reference(approver_id, User.id)
577
848
notes = Unicode()
851
"""Delete the extension."""
852
Store.of(self).remove(self)
854
class SubmissionError(Exception):
855
"""Denotes a validation error during submission."""
579
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.
580
868
__storm_table__ = "project_submission"
582
870
id = Int(name="submissionid", primary=True)
588
876
submitter = Reference(submitter_id, User.id)
589
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)
592
912
# WORKSHEETS AND EXERCISES #
594
914
class Exercise(Storm):
915
"""An exercise for students to complete in a worksheet.
917
An exercise may be present in any number of worksheets.
595
920
__storm_table__ = "exercise"
596
921
id = Unicode(primary=True, name="identifier")
598
923
description = Unicode()
924
_description_xhtml_cache = Unicode(name='description_xhtml_cache')
599
925
partial = Unicode()
600
926
solution = Unicode()
601
927
include = Unicode()
619
945
def __repr__(self):
620
946
return "<%s %s>" % (type(self).__name__, self.name)
622
def get_permissions(self, user):
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
625
958
if user is not None:
627
960
perms.add('edit')
628
961
perms.add('view')
629
elif 'lecturer' in set((e.role for e in user.active_enrolments)):
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
635
def get_description(self):
636
return rst(self.description)
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)
638
993
def delete(self):
639
994
"""Deletes the exercise, providing it has no associated worksheets."""
675
1037
return "<%s %s>" % (type(self).__name__, self.name)
677
1039
def remove_all_exercises(self):
679
Remove all exercises from this worksheet.
1040
"""Remove all exercises from this worksheet.
680
1042
This does not delete the exercises themselves. It just removes them
681
1043
from the worksheet.
686
1048
raise IntegrityError()
687
1049
store.find(WorksheetExercise,
688
1050
WorksheetExercise.worksheet == self).remove()
690
def get_permissions(self, user):
691
return self.offering.get_permissions(user)
694
"""Returns the xml of this worksheet, converts from rst if required."""
695
if self.format == u'rst':
696
ws_xml = rst(self.data)
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
699
1088
return self.data
1090
def set_data(self, data):
1092
self._cache_data_xhtml(invalidate=True)
701
1094
def delete(self):
702
1095
"""Deletes the worksheet, provided it has no attempts on any exercises.
704
1097
Returns True if delete succeeded, or False if this worksheet has
705
1098
attempts attached."""
706
1099
for ws_ex in self.all_worksheet_exercises:
707
1100
if ws_ex.saves.count() > 0 or ws_ex.attempts.count() > 0:
708
1101
raise IntegrityError()
710
1103
self.remove_all_exercises()
711
1104
Store.of(self).remove(self)
713
1106
class WorksheetExercise(Storm):
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
714
1114
__storm_table__ = "worksheet_exercise"
716
1116
id = Int(primary=True, name="ws_ex_id")
718
1118
worksheet_id = Int(name="worksheetid")
732
1132
return "<%s %s in %s>" % (type(self).__name__, self.exercise.name,
733
1133
self.worksheet.identifier)
735
def get_permissions(self, user):
736
return self.worksheet.get_permissions(user)
1135
def get_permissions(self, user, config):
1136
return self.worksheet.get_permissions(user, config)
739
1139
class ExerciseSave(Storm):
741
Represents a potential solution to an exercise that a user has submitted
742
to the server for storage.
743
A basic ExerciseSave is just the current saved text for this exercise for
744
this user (doesn't count towards their attempts).
745
ExerciseSave may be extended with additional semantics (such as
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.
748
1146
__storm_table__ = "exercise_save"
749
1147
__storm_primary__ = "ws_ex_id", "user_id"
761
1159
def __repr__(self):
762
1160
return "<%s %s by %s at %s>" % (type(self).__name__,
763
self.exercise.name, self.user.login, self.date.strftime("%c"))
1161
self.worksheet_exercise.exercise.name, self.user.login,
1162
self.date.strftime("%c"))
765
1164
class ExerciseAttempt(ExerciseSave):
767
An ExerciseAttempt is a special case of an ExerciseSave. Like an
768
ExerciseSave, it constitutes exercise solution data that the user has
769
submitted to the server for storage.
770
In addition, it contains additional information about the submission.
771
complete - True if this submission was successful, rendering this exercise
772
complete for this user.
773
active - True if this submission is "active" (usually true). Submissions
774
may be de-activated by privileged users for special reasons, and then
775
they won't count (either as a penalty or success), but will still be
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.
778
1181
__storm_table__ = "exercise_attempt"
779
1182
__storm_primary__ = "ws_ex_id", "user_id", "date"
783
1186
text = Unicode(name="attempt")
784
1187
complete = Bool()
787
def get_permissions(self, user):
1190
def get_permissions(self, user, config):
788
1191
return set(['view']) if user is self.user else set()
790
1193
class TestSuite(Storm):
791
"""A Testsuite acts as a container for the test cases of an exercise."""
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.
792
1200
__storm_table__ = "test_suite"
793
1201
__storm_primary__ = "exercise_id", "suiteid"
796
1204
exercise_id = Unicode(name="exerciseid")
797
1205
description = Unicode()
801
1209
exercise = Reference(exercise_id, Exercise.id)
802
1210
test_cases = ReferenceSet(suiteid, 'TestCase.suiteid', order_by="seq_no")
803
1211
variables = ReferenceSet(suiteid, 'TestSuiteVar.suiteid', order_by='arg_no')
805
1213
def delete(self):
806
1214
"""Delete this suite, without asking questions."""
807
for vaariable in self.variables:
1215
for variable in self.variables:
808
1216
variable.delete()
809
1217
for test_case in self.test_cases:
810
1218
test_case.delete()
811
1219
Store.of(self).remove(self)
813
1221
class TestCase(Storm):
814
"""A TestCase is a member of a TestSuite.
816
It contains the data necessary to check if an exercise is correct"""
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."""
817
1226
__storm_table__ = "test_case"
818
1227
__storm_primary__ = "testid", "suiteid"
822
1231
suite = Reference(suiteid, "TestSuite.suiteid")
823
1232
passmsg = Unicode()
824
1233
failmsg = Unicode()
825
test_default = Unicode()
1234
test_default = Unicode() # Currently unused - only used for file matching.
828
1237
parts = ReferenceSet(testid, "TestCasePart.testid")
830
1239
__init__ = _kwarg_init
832
1241
def delete(self):
833
1242
for part in self.parts:
835
1244
Store.of(self).remove(self)
837
1246
class TestSuiteVar(Storm):
838
"""A container for the arguments of a Test Suite"""
1247
"""A variable used by an exercise test suite.
1249
This may represent a function argument or a normal variable.
839
1252
__storm_table__ = "suite_variable"
840
1253
__storm_primary__ = "varid"
844
1257
var_name = Unicode()
845
1258
var_value = Unicode()
846
1259
var_type = Unicode()
849
1262
suite = Reference(suiteid, "TestSuite.suiteid")
851
1264
__init__ = _kwarg_init
853
1266
def delete(self):
854
1267
Store.of(self).remove(self)
856
1269
class TestCasePart(Storm):
857
"""A container for the test elements of a Test Case"""
1270
"""An actual piece of code to test an exercise solution."""
858
1272
__storm_table__ = "test_case_part"
859
1273
__storm_primary__ = "partid"
864
1278
part_type = Unicode()
865
1279
test_type = Unicode()
866
1280
data = Unicode()
867
1281
filename = Unicode()
869
1283
test = Reference(testid, "TestCase.testid")
871
1285
__init__ = _kwarg_init
873
1287
def delete(self):
874
1288
Store.of(self).remove(self)