~azzar1/unity/add-show-desktop-key

« back to all changes in this revision

Viewing changes to ivle/database.py

  • Committer: William Grant
  • Date: 2009-12-09 00:02:49 UTC
  • mto: This revision was merged to the branch mainline in revision 1384.
  • Revision ID: grantw@unimelb.edu.au-20091209000249-y1teiw7yxkyhuhvd
Indicate when there is nobody assigned to a project, and link to the page to fix that.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# IVLE - Informatics Virtual Learning Environment
 
2
# Copyright (C) 2007-2009 The University of Melbourne
 
3
#
 
4
# This program is free software; you can redistribute it and/or modify
 
5
# it under the terms of the GNU General Public License as published by
 
6
# the Free Software Foundation; either version 2 of the License, or
 
7
# (at your option) any later version.
 
8
#
 
9
# This program is distributed in the hope that it will be useful,
 
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
12
# GNU General Public License for more details.
 
13
#
 
14
# You should have received a copy of the GNU General Public License
 
15
# along with this program; if not, write to the Free Software
 
16
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
 
17
 
 
18
# Author: Matt Giuca, Will Grant
 
19
 
 
20
"""Database utilities and content classes.
 
21
 
 
22
This module provides all of the classes which map to database tables.
 
23
It also provides miscellaneous utility functions for database interaction.
 
24
"""
 
25
 
 
26
import hashlib
 
27
import datetime
 
28
 
 
29
from storm.locals import create_database, Store, Int, Unicode, DateTime, \
 
30
                         Reference, ReferenceSet, Bool, Storm, Desc
 
31
from storm.expr import Select, Max
 
32
from storm.exceptions import NotOneError, IntegrityError
 
33
 
 
34
from ivle.worksheet.rst import rst
 
35
 
 
36
__all__ = ['get_store',
 
37
            'User',
 
38
            'Subject', 'Semester', 'Offering', 'Enrolment',
 
39
            'ProjectSet', 'Project', 'ProjectGroup', 'ProjectGroupMembership',
 
40
            'Assessed', 'ProjectSubmission', 'ProjectExtension',
 
41
            'Exercise', 'Worksheet', 'WorksheetExercise',
 
42
            'ExerciseSave', 'ExerciseAttempt',
 
43
            'TestCase', 'TestSuite', 'TestSuiteVar'
 
44
        ]
 
45
 
 
46
def _kwarg_init(self, **kwargs):
 
47
    for k,v in kwargs.items():
 
48
        if k.startswith('_') or not hasattr(self.__class__, k):
 
49
            raise TypeError("%s got an unexpected keyword argument '%s'"
 
50
                % (self.__class__.__name__, k))
 
51
        setattr(self, k, v)
 
52
 
 
53
def get_conn_string(config):
 
54
    """Create a Storm connection string to the IVLE database
 
55
 
 
56
    @param config: The IVLE configuration.
 
57
    """
 
58
 
 
59
    clusterstr = ''
 
60
    if config['database']['username']:
 
61
        clusterstr += config['database']['username']
 
62
        if config['database']['password']:
 
63
            clusterstr += ':' + config['database']['password']
 
64
        clusterstr += '@'
 
65
 
 
66
    host = config['database']['host'] or 'localhost'
 
67
    port = config['database']['port'] or 5432
 
68
 
 
69
    clusterstr += '%s:%d' % (host, port)
 
70
 
 
71
    return "postgres://%s/%s" % (clusterstr, config['database']['name'])
 
72
 
 
73
def get_store(config):
 
74
    """Create a Storm store connected to the IVLE database.
 
75
 
 
76
    @param config: The IVLE configuration.
 
77
    """
 
78
    return Store(create_database(get_conn_string(config)))
 
79
 
 
80
# USERS #
 
81
 
 
82
class User(Storm):
 
83
    """An IVLE user account."""
 
84
    __storm_table__ = "login"
 
85
 
 
86
    id = Int(primary=True, name="loginid")
 
87
    login = Unicode()
 
88
    passhash = Unicode()
 
89
    state = Unicode()
 
90
    admin = Bool()
 
91
    unixid = Int()
 
92
    nick = Unicode()
 
93
    pass_exp = DateTime()
 
94
    acct_exp = DateTime()
 
95
    last_login = DateTime()
 
96
    svn_pass = Unicode()
 
97
    email = Unicode()
 
98
    fullname = Unicode()
 
99
    studentid = Unicode()
 
100
    settings = Unicode()
 
101
 
 
102
    __init__ = _kwarg_init
 
103
 
 
104
    def __repr__(self):
 
105
        return "<%s '%s'>" % (type(self).__name__, self.login)
 
106
 
 
107
    def authenticate(self, password):
 
108
        """Validate a given password against this user.
 
109
 
 
110
        Returns True if the given password matches the password hash for this
 
111
        User, False if it doesn't match, and None if there is no hash for the
 
112
        user.
 
113
        """
 
114
        if self.passhash is None:
 
115
            return None
 
116
        return self.hash_password(password) == self.passhash
 
117
 
 
118
    @property
 
119
    def display_name(self):
 
120
        """Returns the "nice name" of the user or group."""
 
121
        return self.fullname
 
122
 
 
123
    @property
 
124
    def short_name(self):
 
125
        """Returns the database "identifier" name of the user or group."""
 
126
        return self.login
 
127
 
 
128
    @property
 
129
    def password_expired(self):
 
130
        fieldval = self.pass_exp
 
131
        return fieldval is not None and datetime.datetime.now() > fieldval
 
132
 
 
133
    @property
 
134
    def account_expired(self):
 
135
        fieldval = self.acct_exp
 
136
        return fieldval is not None and datetime.datetime.now() > fieldval
 
137
 
 
138
    @property
 
139
    def valid(self):
 
140
        return self.state == 'enabled' and not self.account_expired
 
141
 
 
142
    def _get_enrolments(self, justactive):
 
143
        return Store.of(self).find(Enrolment,
 
144
            Enrolment.user_id == self.id,
 
145
            (Enrolment.active == True) if justactive else True,
 
146
            Enrolment.offering_id == Offering.id,
 
147
            Offering.semester_id == Semester.id,
 
148
            Offering.subject_id == Subject.id).order_by(
 
149
                Desc(Semester.year),
 
150
                Desc(Semester.semester),
 
151
                Desc(Subject.code)
 
152
            )
 
153
 
 
154
    def _set_password(self, password):
 
155
        if password is None:
 
156
            self.passhash = None
 
157
        else:
 
158
            self.passhash = unicode(User.hash_password(password))
 
159
    password = property(fset=_set_password)
 
160
 
 
161
    @property
 
162
    def subjects(self):
 
163
        return Store.of(self).find(Subject,
 
164
            Enrolment.user_id == self.id,
 
165
            Enrolment.active == True,
 
166
            Offering.id == Enrolment.offering_id,
 
167
            Subject.id == Offering.subject_id).config(distinct=True)
 
168
 
 
169
    # TODO: Invitations should be listed too?
 
170
    def get_groups(self, offering=None):
 
171
        """Get groups of which this user is a member.
 
172
 
 
173
        @param offering: An optional offering to restrict the search to.
 
174
        """
 
175
        preds = [
 
176
            ProjectGroupMembership.user_id == self.id,
 
177
            ProjectGroup.id == ProjectGroupMembership.project_group_id,
 
178
        ]
 
179
        if offering:
 
180
            preds.extend([
 
181
                ProjectSet.offering_id == offering.id,
 
182
                ProjectGroup.project_set_id == ProjectSet.id,
 
183
            ])
 
184
        return Store.of(self).find(ProjectGroup, *preds)
 
185
 
 
186
    @property
 
187
    def groups(self):
 
188
        return self.get_groups()
 
189
 
 
190
    @property
 
191
    def active_enrolments(self):
 
192
        '''A sanely ordered list of the user's active enrolments.'''
 
193
        return self._get_enrolments(True)
 
194
 
 
195
    @property
 
196
    def enrolments(self):
 
197
        '''A sanely ordered list of all of the user's enrolments.'''
 
198
        return self._get_enrolments(False) 
 
199
 
 
200
    def get_projects(self, offering=None, active_only=True):
 
201
        """Find projects that the user can submit.
 
202
 
 
203
        This will include projects for offerings in which the user is
 
204
        enrolled, as long as the project is not in a project set which has
 
205
        groups (ie. if maximum number of group members is 0).
 
206
 
 
207
        @param active_only: Whether to only search active offerings.
 
208
        @param offering: An optional offering to restrict the search to.
 
209
        """
 
210
        return Store.of(self).find(Project,
 
211
            Project.project_set_id == ProjectSet.id,
 
212
            ProjectSet.max_students_per_group == None,
 
213
            ProjectSet.offering_id == Offering.id,
 
214
            (offering is None) or (Offering.id == offering.id),
 
215
            Semester.id == Offering.semester_id,
 
216
            (not active_only) or (Semester.state == u'current'),
 
217
            Enrolment.offering_id == Offering.id,
 
218
            Enrolment.user_id == self.id)
 
219
 
 
220
    @staticmethod
 
221
    def hash_password(password):
 
222
        """Hash a password with MD5."""
 
223
        return hashlib.md5(password).hexdigest()
 
224
 
 
225
    @classmethod
 
226
    def get_by_login(cls, store, login):
 
227
        """Find a user in a store by login name."""
 
228
        return store.find(cls, cls.login == unicode(login)).one()
 
229
 
 
230
    def get_permissions(self, user):
 
231
        """Determine privileges held by a user over this object.
 
232
 
 
233
        If the user requesting privileges is this user or an admin,
 
234
        they may do everything. Otherwise they may do nothing.
 
235
        """
 
236
        if user and user.admin or user is self:
 
237
            return set(['view_public', 'view', 'edit', 'submit_project'])
 
238
        else:
 
239
            return set(['view_public'])
 
240
 
 
241
# SUBJECTS AND ENROLMENTS #
 
242
 
 
243
class Subject(Storm):
 
244
    """A subject (or course) which is run in some semesters."""
 
245
 
 
246
    __storm_table__ = "subject"
 
247
 
 
248
    id = Int(primary=True, name="subjectid")
 
249
    code = Unicode(name="subj_code")
 
250
    name = Unicode(name="subj_name")
 
251
    short_name = Unicode(name="subj_short_name")
 
252
    url = Unicode()
 
253
 
 
254
    offerings = ReferenceSet(id, 'Offering.subject_id')
 
255
 
 
256
    __init__ = _kwarg_init
 
257
 
 
258
    def __repr__(self):
 
259
        return "<%s '%s'>" % (type(self).__name__, self.short_name)
 
260
 
 
261
    def get_permissions(self, user):
 
262
        """Determine privileges held by a user over this object.
 
263
 
 
264
        If the user requesting privileges is an admin, they may edit.
 
265
        Otherwise they may only read.
 
266
        """
 
267
        perms = set()
 
268
        if user is not None:
 
269
            perms.add('view')
 
270
            if user.admin:
 
271
                perms.add('edit')
 
272
        return perms
 
273
 
 
274
    def active_offerings(self):
 
275
        """Find active offerings for this subject.
 
276
 
 
277
        Return a sequence of currently active offerings for this subject
 
278
        (offerings whose semester.state is "current"). There should be 0 or 1
 
279
        elements in this sequence, but it's possible there are more.
 
280
        """
 
281
        return self.offerings.find(Offering.semester_id == Semester.id,
 
282
                                   Semester.state == u'current')
 
283
 
 
284
    def offering_for_semester(self, year, semester):
 
285
        """Get the offering for the given year/semester, or None.
 
286
 
 
287
        @param year: A string representation of the year.
 
288
        @param semester: A string representation of the semester.
 
289
        """
 
290
        return self.offerings.find(Offering.semester_id == Semester.id,
 
291
                               Semester.year == unicode(year),
 
292
                               Semester.semester == unicode(semester)).one()
 
293
 
 
294
class Semester(Storm):
 
295
    """A semester in which subjects can be run."""
 
296
 
 
297
    __storm_table__ = "semester"
 
298
 
 
299
    id = Int(primary=True, name="semesterid")
 
300
    year = Unicode()
 
301
    semester = Unicode()
 
302
    state = Unicode()
 
303
 
 
304
    offerings = ReferenceSet(id, 'Offering.semester_id')
 
305
    enrolments = ReferenceSet(id,
 
306
                              'Offering.semester_id',
 
307
                              'Offering.id',
 
308
                              'Enrolment.offering_id')
 
309
 
 
310
    __init__ = _kwarg_init
 
311
 
 
312
    def __repr__(self):
 
313
        return "<%s %s/%s>" % (type(self).__name__, self.year, self.semester)
 
314
 
 
315
class Offering(Storm):
 
316
    """An offering of a subject in a particular semester."""
 
317
 
 
318
    __storm_table__ = "offering"
 
319
 
 
320
    id = Int(primary=True, name="offeringid")
 
321
    subject_id = Int(name="subject")
 
322
    subject = Reference(subject_id, Subject.id)
 
323
    semester_id = Int(name="semesterid")
 
324
    semester = Reference(semester_id, Semester.id)
 
325
    groups_student_permissions = Unicode()
 
326
 
 
327
    enrolments = ReferenceSet(id, 'Enrolment.offering_id')
 
328
    members = ReferenceSet(id,
 
329
                           'Enrolment.offering_id',
 
330
                           'Enrolment.user_id',
 
331
                           'User.id')
 
332
    project_sets = ReferenceSet(id, 'ProjectSet.offering_id')
 
333
 
 
334
    worksheets = ReferenceSet(id, 
 
335
        'Worksheet.offering_id', 
 
336
        order_by="seq_no"
 
337
    )
 
338
 
 
339
    __init__ = _kwarg_init
 
340
 
 
341
    def __repr__(self):
 
342
        return "<%s %r in %r>" % (type(self).__name__, self.subject,
 
343
                                  self.semester)
 
344
 
 
345
    def enrol(self, user, role=u'student'):
 
346
        """Enrol a user in this offering.
 
347
 
 
348
        Enrolments handle both the staff and student cases. The role controls
 
349
        the privileges granted by this enrolment.
 
350
        """
 
351
        enrolment = Store.of(self).find(Enrolment,
 
352
                               Enrolment.user_id == user.id,
 
353
                               Enrolment.offering_id == self.id).one()
 
354
 
 
355
        if enrolment is None:
 
356
            enrolment = Enrolment(user=user, offering=self)
 
357
            self.enrolments.add(enrolment)
 
358
 
 
359
        enrolment.active = True
 
360
        enrolment.role = role
 
361
 
 
362
    def unenrol(self, user):
 
363
        '''Unenrol a user from this offering.'''
 
364
        enrolment = Store.of(self).find(Enrolment,
 
365
                               Enrolment.user_id == user.id,
 
366
                               Enrolment.offering_id == self.id).one()
 
367
        Store.of(enrolment).remove(enrolment)
 
368
 
 
369
    def get_permissions(self, user):
 
370
        perms = set()
 
371
        if user is not None:
 
372
            enrolment = self.get_enrolment(user)
 
373
            if enrolment or user.admin:
 
374
                perms.add('view')
 
375
            if (enrolment and enrolment.role in (u'tutor', u'lecturer')) \
 
376
               or user.admin:
 
377
                perms.add('edit')
 
378
        return perms
 
379
 
 
380
    def get_enrolment(self, user):
 
381
        """Find the user's enrolment in this offering."""
 
382
        try:
 
383
            enrolment = self.enrolments.find(user=user).one()
 
384
        except NotOneError:
 
385
            enrolment = None
 
386
 
 
387
        return enrolment
 
388
 
 
389
    def get_members_by_role(self, role):
 
390
        return Store.of(self).find(User,
 
391
                Enrolment.user_id == User.id,
 
392
                Enrolment.offering_id == self.id,
 
393
                Enrolment.role == role
 
394
                ).order_by(User.login)
 
395
 
 
396
    @property
 
397
    def students(self):
 
398
        return self.get_members_by_role(u'student')
 
399
 
 
400
class Enrolment(Storm):
 
401
    """An enrolment of a user in an offering.
 
402
 
 
403
    This represents the roles of both staff and students.
 
404
    """
 
405
 
 
406
    __storm_table__ = "enrolment"
 
407
    __storm_primary__ = "user_id", "offering_id"
 
408
 
 
409
    user_id = Int(name="loginid")
 
410
    user = Reference(user_id, User.id)
 
411
    offering_id = Int(name="offeringid")
 
412
    offering = Reference(offering_id, Offering.id)
 
413
    role = Unicode()
 
414
    notes = Unicode()
 
415
    active = Bool()
 
416
 
 
417
    @property
 
418
    def groups(self):
 
419
        return Store.of(self).find(ProjectGroup,
 
420
                ProjectSet.offering_id == self.offering.id,
 
421
                ProjectGroup.project_set_id == ProjectSet.id,
 
422
                ProjectGroupMembership.project_group_id == ProjectGroup.id,
 
423
                ProjectGroupMembership.user_id == self.user.id)
 
424
 
 
425
    __init__ = _kwarg_init
 
426
 
 
427
    def __repr__(self):
 
428
        return "<%s %r in %r>" % (type(self).__name__, self.user,
 
429
                                  self.offering)
 
430
 
 
431
# PROJECTS #
 
432
 
 
433
class ProjectSet(Storm):
 
434
    """A set of projects that share common groups.
 
435
 
 
436
    Each student project group is attached to a project set. The group is
 
437
    valid for all projects in the group's set.
 
438
    """
 
439
 
 
440
    __storm_table__ = "project_set"
 
441
 
 
442
    id = Int(name="projectsetid", primary=True)
 
443
    offering_id = Int(name="offeringid")
 
444
    offering = Reference(offering_id, Offering.id)
 
445
    max_students_per_group = Int()
 
446
 
 
447
    projects = ReferenceSet(id, 'Project.project_set_id')
 
448
    project_groups = ReferenceSet(id, 'ProjectGroup.project_set_id')
 
449
 
 
450
    __init__ = _kwarg_init
 
451
 
 
452
    def __repr__(self):
 
453
        return "<%s %d in %r>" % (type(self).__name__, self.id,
 
454
                                  self.offering)
 
455
 
 
456
    def get_permissions(self, user):
 
457
        return self.offering.get_permissions(user)
 
458
 
 
459
    @property
 
460
    def is_group(self):
 
461
        return self.max_students_per_group is not None
 
462
 
 
463
    @property
 
464
    def assigned(self):
 
465
        """Get the entities (groups or users) assigned to submit this project.
 
466
 
 
467
        This will be a Storm ResultSet.
 
468
        """
 
469
        #If its a solo project, return everyone in offering
 
470
        if self.is_group:
 
471
            return self.project_groups
 
472
        else:
 
473
            return self.offering.students
 
474
 
 
475
class Project(Storm):
 
476
    """A student project for which submissions can be made."""
 
477
 
 
478
    __storm_table__ = "project"
 
479
 
 
480
    id = Int(name="projectid", primary=True)
 
481
    name = Unicode()
 
482
    short_name = Unicode()
 
483
    synopsis = Unicode()
 
484
    url = Unicode()
 
485
    project_set_id = Int(name="projectsetid")
 
486
    project_set = Reference(project_set_id, ProjectSet.id)
 
487
    deadline = DateTime()
 
488
 
 
489
    assesseds = ReferenceSet(id, 'Assessed.project_id')
 
490
    submissions = ReferenceSet(id,
 
491
                               'Assessed.project_id',
 
492
                               'Assessed.id',
 
493
                               'ProjectSubmission.assessed_id')
 
494
 
 
495
    __init__ = _kwarg_init
 
496
 
 
497
    def __repr__(self):
 
498
        return "<%s '%s' in %r>" % (type(self).__name__, self.short_name,
 
499
                                  self.project_set.offering)
 
500
 
 
501
    def can_submit(self, principal):
 
502
        return (self in principal.get_projects() and
 
503
                self.deadline > datetime.datetime.now())
 
504
 
 
505
    def submit(self, principal, path, revision, who):
 
506
        """Submit a Subversion path and revision to a project.
 
507
 
 
508
        @param principal: The owner of the Subversion repository, and the
 
509
                          entity on behalf of whom the submission is being made
 
510
        @param path: A path within that repository to submit.
 
511
        @param revision: The revision of that path to submit.
 
512
        @param who: The user who is actually making the submission.
 
513
        """
 
514
 
 
515
        if not self.can_submit(principal):
 
516
            raise Exception('cannot submit')
 
517
 
 
518
        a = Assessed.get(Store.of(self), principal, self)
 
519
        ps = ProjectSubmission()
 
520
        ps.path = path
 
521
        ps.revision = revision
 
522
        ps.date_submitted = datetime.datetime.now()
 
523
        ps.assessed = a
 
524
        ps.submitter = who
 
525
 
 
526
        return ps
 
527
 
 
528
    def get_permissions(self, user):
 
529
        return self.project_set.offering.get_permissions(user)
 
530
 
 
531
    @property
 
532
    def latest_submissions(self):
 
533
        """Return the latest submission for each Assessed."""
 
534
        return Store.of(self).find(ProjectSubmission,
 
535
            Assessed.project_id == self.id,
 
536
            ProjectSubmission.assessed_id == Assessed.id,
 
537
            ProjectSubmission.date_submitted == Select(
 
538
                    Max(ProjectSubmission.date_submitted),
 
539
                    ProjectSubmission.assessed_id == Assessed.id,
 
540
                    tables=ProjectSubmission
 
541
            )
 
542
        )
 
543
 
 
544
 
 
545
class ProjectGroup(Storm):
 
546
    """A group of students working together on a project."""
 
547
 
 
548
    __storm_table__ = "project_group"
 
549
 
 
550
    id = Int(name="groupid", primary=True)
 
551
    name = Unicode(name="groupnm")
 
552
    project_set_id = Int(name="projectsetid")
 
553
    project_set = Reference(project_set_id, ProjectSet.id)
 
554
    nick = Unicode()
 
555
    created_by_id = Int(name="createdby")
 
556
    created_by = Reference(created_by_id, User.id)
 
557
    epoch = DateTime()
 
558
 
 
559
    members = ReferenceSet(id,
 
560
                           "ProjectGroupMembership.project_group_id",
 
561
                           "ProjectGroupMembership.user_id",
 
562
                           "User.id")
 
563
 
 
564
    __init__ = _kwarg_init
 
565
 
 
566
    def __repr__(self):
 
567
        return "<%s %s in %r>" % (type(self).__name__, self.name,
 
568
                                  self.project_set.offering)
 
569
 
 
570
    @property
 
571
    def display_name(self):
 
572
        """Returns the "nice name" of the user or group."""
 
573
        return self.nick
 
574
 
 
575
    @property
 
576
    def short_name(self):
 
577
        """Returns the database "identifier" name of the user or group."""
 
578
        return self.name
 
579
 
 
580
    def get_projects(self, offering=None, active_only=True):
 
581
        '''Find projects that the group can submit.
 
582
 
 
583
        This will include projects in the project set which owns this group,
 
584
        unless the project set disallows groups (in which case none will be
 
585
        returned).
 
586
 
 
587
        @param active_only: Whether to only search active offerings.
 
588
        @param offering: An optional offering to restrict the search to.
 
589
        '''
 
590
        return Store.of(self).find(Project,
 
591
            Project.project_set_id == ProjectSet.id,
 
592
            ProjectSet.id == self.project_set.id,
 
593
            ProjectSet.max_students_per_group != None,
 
594
            ProjectSet.offering_id == Offering.id,
 
595
            (offering is None) or (Offering.id == offering.id),
 
596
            Semester.id == Offering.semester_id,
 
597
            (not active_only) or (Semester.state == u'current'))
 
598
 
 
599
 
 
600
    def get_permissions(self, user):
 
601
        if user.admin or user in self.members:
 
602
            return set(['submit_project'])
 
603
        else:
 
604
            return set()
 
605
 
 
606
class ProjectGroupMembership(Storm):
 
607
    """A student's membership in a project group."""
 
608
 
 
609
    __storm_table__ = "group_member"
 
610
    __storm_primary__ = "user_id", "project_group_id"
 
611
 
 
612
    user_id = Int(name="loginid")
 
613
    user = Reference(user_id, User.id)
 
614
    project_group_id = Int(name="groupid")
 
615
    project_group = Reference(project_group_id, ProjectGroup.id)
 
616
 
 
617
    __init__ = _kwarg_init
 
618
 
 
619
    def __repr__(self):
 
620
        return "<%s %r in %r>" % (type(self).__name__, self.user,
 
621
                                  self.project_group)
 
622
 
 
623
class Assessed(Storm):
 
624
    """A composite of a user or group combined with a project.
 
625
 
 
626
    Each project submission and extension refers to an Assessed. It is the
 
627
    sole specifier of the repository and project.
 
628
    """
 
629
 
 
630
    __storm_table__ = "assessed"
 
631
 
 
632
    id = Int(name="assessedid", primary=True)
 
633
    user_id = Int(name="loginid")
 
634
    user = Reference(user_id, User.id)
 
635
    project_group_id = Int(name="groupid")
 
636
    project_group = Reference(project_group_id, ProjectGroup.id)
 
637
 
 
638
    project_id = Int(name="projectid")
 
639
    project = Reference(project_id, Project.id)
 
640
 
 
641
    extensions = ReferenceSet(id, 'ProjectExtension.assessed_id')
 
642
    submissions = ReferenceSet(id, 'ProjectSubmission.assessed_id')
 
643
 
 
644
    def __repr__(self):
 
645
        return "<%s %r in %r>" % (type(self).__name__,
 
646
            self.user or self.project_group, self.project)
 
647
 
 
648
    @property
 
649
    def is_group(self):
 
650
        """True if the Assessed is a group, False if it is a user."""
 
651
        return self.project_group is not None
 
652
 
 
653
    @property
 
654
    def principal(self):
 
655
        return self.project_group or self.user
 
656
 
 
657
    @classmethod
 
658
    def get(cls, store, principal, project):
 
659
        """Find or create an Assessed for the given user or group and project.
 
660
 
 
661
        @param principal: The user or group.
 
662
        @param project: The project.
 
663
        """
 
664
        t = type(principal)
 
665
        if t not in (User, ProjectGroup):
 
666
            raise AssertionError('principal must be User or ProjectGroup')
 
667
 
 
668
        a = store.find(cls,
 
669
            (t is User) or (cls.project_group_id == principal.id),
 
670
            (t is ProjectGroup) or (cls.user_id == principal.id),
 
671
            Project.id == project.id).one()
 
672
 
 
673
        if a is None:
 
674
            a = cls()
 
675
            if t is User:
 
676
                a.user = principal
 
677
            else:
 
678
                a.project_group = principal
 
679
            a.project = project
 
680
            store.add(a)
 
681
 
 
682
        return a
 
683
 
 
684
 
 
685
class ProjectExtension(Storm):
 
686
    """An extension granted to a user or group on a particular project.
 
687
 
 
688
    The user or group and project are specified by the Assessed.
 
689
    """
 
690
 
 
691
    __storm_table__ = "project_extension"
 
692
 
 
693
    id = Int(name="extensionid", primary=True)
 
694
    assessed_id = Int(name="assessedid")
 
695
    assessed = Reference(assessed_id, Assessed.id)
 
696
    deadline = DateTime()
 
697
    approver_id = Int(name="approver")
 
698
    approver = Reference(approver_id, User.id)
 
699
    notes = Unicode()
 
700
 
 
701
class ProjectSubmission(Storm):
 
702
    """A submission from a user or group repository to a particular project.
 
703
 
 
704
    The content of a submission is a single path and revision inside a
 
705
    repository. The repository is that owned by the submission's user and
 
706
    group, while the path and revision are explicit.
 
707
 
 
708
    The user or group and project are specified by the Assessed.
 
709
    """
 
710
 
 
711
    __storm_table__ = "project_submission"
 
712
 
 
713
    id = Int(name="submissionid", primary=True)
 
714
    assessed_id = Int(name="assessedid")
 
715
    assessed = Reference(assessed_id, Assessed.id)
 
716
    path = Unicode()
 
717
    revision = Int()
 
718
    submitter_id = Int(name="submitter")
 
719
    submitter = Reference(submitter_id, User.id)
 
720
    date_submitted = DateTime()
 
721
 
 
722
 
 
723
# WORKSHEETS AND EXERCISES #
 
724
 
 
725
class Exercise(Storm):
 
726
    """An exercise for students to complete in a worksheet.
 
727
 
 
728
    An exercise may be present in any number of worksheets.
 
729
    """
 
730
 
 
731
    __storm_table__ = "exercise"
 
732
    id = Unicode(primary=True, name="identifier")
 
733
    name = Unicode()
 
734
    description = Unicode()
 
735
    partial = Unicode()
 
736
    solution = Unicode()
 
737
    include = Unicode()
 
738
    num_rows = Int()
 
739
 
 
740
    worksheet_exercises =  ReferenceSet(id,
 
741
        'WorksheetExercise.exercise_id')
 
742
 
 
743
    worksheets = ReferenceSet(id,
 
744
        'WorksheetExercise.exercise_id',
 
745
        'WorksheetExercise.worksheet_id',
 
746
        'Worksheet.id'
 
747
    )
 
748
 
 
749
    test_suites = ReferenceSet(id, 
 
750
        'TestSuite.exercise_id',
 
751
        order_by='seq_no')
 
752
 
 
753
    __init__ = _kwarg_init
 
754
 
 
755
    def __repr__(self):
 
756
        return "<%s %s>" % (type(self).__name__, self.name)
 
757
 
 
758
    def get_permissions(self, user):
 
759
        perms = set()
 
760
        roles = set()
 
761
        if user is not None:
 
762
            if user.admin:
 
763
                perms.add('edit')
 
764
                perms.add('view')
 
765
            elif u'lecturer' in set((e.role for e in user.active_enrolments)):
 
766
                perms.add('edit')
 
767
                perms.add('view')
 
768
            elif u'tutor' in set((e.role for e in user.active_enrolments)):
 
769
                perms.add('edit')
 
770
                perms.add('view')
 
771
 
 
772
        return perms
 
773
 
 
774
    def get_description(self):
 
775
        """Return the description interpreted as reStructuredText."""
 
776
        return rst(self.description)
 
777
 
 
778
    def delete(self):
 
779
        """Deletes the exercise, providing it has no associated worksheets."""
 
780
        if (self.worksheet_exercises.count() > 0):
 
781
            raise IntegrityError()
 
782
        for suite in self.test_suites:
 
783
            suite.delete()
 
784
        Store.of(self).remove(self)
 
785
 
 
786
class Worksheet(Storm):
 
787
    """A worksheet with exercises for students to complete.
 
788
 
 
789
    Worksheets are owned by offerings.
 
790
    """
 
791
 
 
792
    __storm_table__ = "worksheet"
 
793
 
 
794
    id = Int(primary=True, name="worksheetid")
 
795
    offering_id = Int(name="offeringid")
 
796
    identifier = Unicode()
 
797
    name = Unicode()
 
798
    assessable = Bool()
 
799
    data = Unicode()
 
800
    seq_no = Int()
 
801
    format = Unicode()
 
802
 
 
803
    attempts = ReferenceSet(id, "ExerciseAttempt.worksheetid")
 
804
    offering = Reference(offering_id, 'Offering.id')
 
805
 
 
806
    all_worksheet_exercises = ReferenceSet(id,
 
807
        'WorksheetExercise.worksheet_id')
 
808
 
 
809
    # Use worksheet_exercises to get access to the *active* WorksheetExercise
 
810
    # objects binding worksheets to exercises. This is required to access the
 
811
    # "optional" field.
 
812
 
 
813
    @property
 
814
    def worksheet_exercises(self):
 
815
        return self.all_worksheet_exercises.find(active=True)
 
816
 
 
817
    __init__ = _kwarg_init
 
818
 
 
819
    def __repr__(self):
 
820
        return "<%s %s>" % (type(self).__name__, self.name)
 
821
 
 
822
    def remove_all_exercises(self):
 
823
        """Remove all exercises from this worksheet.
 
824
 
 
825
        This does not delete the exercises themselves. It just removes them
 
826
        from the worksheet.
 
827
        """
 
828
        store = Store.of(self)
 
829
        for ws_ex in self.all_worksheet_exercises:
 
830
            if ws_ex.saves.count() > 0 or ws_ex.attempts.count() > 0:
 
831
                raise IntegrityError()
 
832
        store.find(WorksheetExercise,
 
833
            WorksheetExercise.worksheet == self).remove()
 
834
 
 
835
    def get_permissions(self, user):
 
836
        return self.offering.get_permissions(user)
 
837
 
 
838
    def get_xml(self):
 
839
        """Returns the xml of this worksheet, converts from rst if required."""
 
840
        if self.format == u'rst':
 
841
            ws_xml = rst(self.data)
 
842
            return ws_xml
 
843
        else:
 
844
            return self.data
 
845
 
 
846
    def delete(self):
 
847
        """Deletes the worksheet, provided it has no attempts on any exercises.
 
848
 
 
849
        Returns True if delete succeeded, or False if this worksheet has
 
850
        attempts attached."""
 
851
        for ws_ex in self.all_worksheet_exercises:
 
852
            if ws_ex.saves.count() > 0 or ws_ex.attempts.count() > 0:
 
853
                raise IntegrityError()
 
854
 
 
855
        self.remove_all_exercises()
 
856
        Store.of(self).remove(self)
 
857
 
 
858
class WorksheetExercise(Storm):
 
859
    """A link between a worksheet and one of its exercises.
 
860
 
 
861
    These may be marked optional, in which case the exercise does not count
 
862
    for marking purposes. The sequence number is used to order the worksheet
 
863
    ToC.
 
864
    """
 
865
 
 
866
    __storm_table__ = "worksheet_exercise"
 
867
 
 
868
    id = Int(primary=True, name="ws_ex_id")
 
869
 
 
870
    worksheet_id = Int(name="worksheetid")
 
871
    worksheet = Reference(worksheet_id, Worksheet.id)
 
872
    exercise_id = Unicode(name="exerciseid")
 
873
    exercise = Reference(exercise_id, Exercise.id)
 
874
    optional = Bool()
 
875
    active = Bool()
 
876
    seq_no = Int()
 
877
 
 
878
    saves = ReferenceSet(id, "ExerciseSave.ws_ex_id")
 
879
    attempts = ReferenceSet(id, "ExerciseAttempt.ws_ex_id")
 
880
 
 
881
    __init__ = _kwarg_init
 
882
 
 
883
    def __repr__(self):
 
884
        return "<%s %s in %s>" % (type(self).__name__, self.exercise.name,
 
885
                                  self.worksheet.identifier)
 
886
 
 
887
    def get_permissions(self, user):
 
888
        return self.worksheet.get_permissions(user)
 
889
 
 
890
 
 
891
class ExerciseSave(Storm):
 
892
    """A potential exercise solution submitted by a user for storage.
 
893
 
 
894
    This is not an actual tested attempt at an exercise, it's just a save of
 
895
    the editing session.
 
896
    """
 
897
 
 
898
    __storm_table__ = "exercise_save"
 
899
    __storm_primary__ = "ws_ex_id", "user_id"
 
900
 
 
901
    ws_ex_id = Int(name="ws_ex_id")
 
902
    worksheet_exercise = Reference(ws_ex_id, "WorksheetExercise.id")
 
903
 
 
904
    user_id = Int(name="loginid")
 
905
    user = Reference(user_id, User.id)
 
906
    date = DateTime()
 
907
    text = Unicode()
 
908
 
 
909
    __init__ = _kwarg_init
 
910
 
 
911
    def __repr__(self):
 
912
        return "<%s %s by %s at %s>" % (type(self).__name__,
 
913
            self.exercise.name, self.user.login, self.date.strftime("%c"))
 
914
 
 
915
class ExerciseAttempt(ExerciseSave):
 
916
    """An attempt at solving an exercise.
 
917
 
 
918
    This is a special case of ExerciseSave, used when the user submits a
 
919
    candidate solution. Like an ExerciseSave, it constitutes exercise solution
 
920
    data.
 
921
 
 
922
    In addition, it contains information about the result of the submission:
 
923
 
 
924
     - complete - True if this submission was successful, rendering this
 
925
                  exercise complete for this user in this worksheet.
 
926
     - active   - True if this submission is "active" (usually true).
 
927
                  Submissions may be de-activated by privileged users for
 
928
                  special reasons, and then they won't count (either as a
 
929
                  penalty or success), but will still be stored.
 
930
    """
 
931
 
 
932
    __storm_table__ = "exercise_attempt"
 
933
    __storm_primary__ = "ws_ex_id", "user_id", "date"
 
934
 
 
935
    # The "text" field is the same but has a different name in the DB table
 
936
    # for some reason.
 
937
    text = Unicode(name="attempt")
 
938
    complete = Bool()
 
939
    active = Bool()
 
940
 
 
941
    def get_permissions(self, user):
 
942
        return set(['view']) if user is self.user else set()
 
943
 
 
944
class TestSuite(Storm):
 
945
    """A container to group an exercise's test cases.
 
946
 
 
947
    The test suite contains some information on how to test. The function to
 
948
    test, variables to set and stdin data are stored here.
 
949
    """
 
950
 
 
951
    __storm_table__ = "test_suite"
 
952
    __storm_primary__ = "exercise_id", "suiteid"
 
953
 
 
954
    suiteid = Int()
 
955
    exercise_id = Unicode(name="exerciseid")
 
956
    description = Unicode()
 
957
    seq_no = Int()
 
958
    function = Unicode()
 
959
    stdin = Unicode()
 
960
    exercise = Reference(exercise_id, Exercise.id)
 
961
    test_cases = ReferenceSet(suiteid, 'TestCase.suiteid', order_by="seq_no")
 
962
    variables = ReferenceSet(suiteid, 'TestSuiteVar.suiteid', order_by='arg_no')
 
963
 
 
964
    def delete(self):
 
965
        """Delete this suite, without asking questions."""
 
966
        for vaariable in self.variables:
 
967
            variable.delete()
 
968
        for test_case in self.test_cases:
 
969
            test_case.delete()
 
970
        Store.of(self).remove(self)
 
971
 
 
972
class TestCase(Storm):
 
973
    """A container for actual tests (see TestCasePart), inside a test suite.
 
974
 
 
975
    It is the lowest level shown to students on their pass/fail status."""
 
976
 
 
977
    __storm_table__ = "test_case"
 
978
    __storm_primary__ = "testid", "suiteid"
 
979
 
 
980
    testid = Int()
 
981
    suiteid = Int()
 
982
    suite = Reference(suiteid, "TestSuite.suiteid")
 
983
    passmsg = Unicode()
 
984
    failmsg = Unicode()
 
985
    test_default = Unicode()
 
986
    seq_no = Int()
 
987
 
 
988
    parts = ReferenceSet(testid, "TestCasePart.testid")
 
989
 
 
990
    __init__ = _kwarg_init
 
991
 
 
992
    def delete(self):
 
993
        for part in self.parts:
 
994
            part.delete()
 
995
        Store.of(self).remove(self)
 
996
 
 
997
class TestSuiteVar(Storm):
 
998
    """A variable used by an exercise test suite.
 
999
 
 
1000
    This may represent a function argument or a normal variable.
 
1001
    """
 
1002
 
 
1003
    __storm_table__ = "suite_variable"
 
1004
    __storm_primary__ = "varid"
 
1005
 
 
1006
    varid = Int()
 
1007
    suiteid = Int()
 
1008
    var_name = Unicode()
 
1009
    var_value = Unicode()
 
1010
    var_type = Unicode()
 
1011
    arg_no = Int()
 
1012
 
 
1013
    suite = Reference(suiteid, "TestSuite.suiteid")
 
1014
 
 
1015
    __init__ = _kwarg_init
 
1016
 
 
1017
    def delete(self):
 
1018
        Store.of(self).remove(self)
 
1019
 
 
1020
class TestCasePart(Storm):
 
1021
    """An actual piece of code to test an exercise solution."""
 
1022
 
 
1023
    __storm_table__ = "test_case_part"
 
1024
    __storm_primary__ = "partid"
 
1025
 
 
1026
    partid = Int()
 
1027
    testid = Int()
 
1028
 
 
1029
    part_type = Unicode()
 
1030
    test_type = Unicode()
 
1031
    data = Unicode()
 
1032
    filename = Unicode()
 
1033
 
 
1034
    test = Reference(testid, "TestCase.testid")
 
1035
 
 
1036
    __init__ = _kwarg_init
 
1037
 
 
1038
    def delete(self):
 
1039
        Store.of(self).remove(self)