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

« back to all changes in this revision

Viewing changes to ivle/db.py

  • Committer: William Grant
  • Date: 2009-01-20 02:49:22 UTC
  • mto: This revision was merged to the branch mainline in revision 1090.
  • Revision ID: grantw@unimelb.edu.au-20090120024922-6eb86loc19qwcldh
ivle.database.Enrolment: Add a groups attribute, containing groups of which
    this user is a member in this offering.
www/apps/userservice: Use Storm instead of get_enrolment_groups.
ivle.db.get_enrolment_groups: Kill. Unused.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# IVLE - Informatics Virtual Learning Environment
 
2
# Copyright (C) 2007-2008 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
# Module: Database
 
19
# Author: Matt Giuca
 
20
# Date:   15/2/2008
 
21
 
 
22
# Code to talk to the PostgreSQL database.
 
23
# (This is the Data Access Layer).
 
24
# All DB code should be in this module to ensure portability if we want to
 
25
# change the DB implementation.
 
26
# This means no SQL strings should be outside of this module. Add functions
 
27
# here to perform the activities needed, and place the SQL code for those
 
28
# activities within.
 
29
 
 
30
# CAUTION to editors of this module.
 
31
# All string inputs must be sanitized by calling _escape before being
 
32
# formatted into an SQL query string.
 
33
 
 
34
import pg
 
35
import md5
 
36
import copy
 
37
import time
 
38
 
 
39
import ivle.conf
 
40
from ivle import caps
 
41
 
 
42
TIMESTAMP_FORMAT = '%Y-%m-%d %H:%M:%S'
 
43
 
 
44
def _escape(val):
 
45
    """Wrapper around pg.escape_string. Prepares the Python value for use in
 
46
    SQL. Returns a string, which may be safely placed verbatim into an SQL
 
47
    query.
 
48
    Handles the following types:
 
49
    * str: Escapes the string, and also quotes it.
 
50
    * int/long/float: Just converts to an unquoted string.
 
51
    * bool: Returns as "TRUE" or "FALSE", unquoted.
 
52
    * NoneType: Returns "NULL", unquoted.
 
53
    * common.caps.Role: Returns the role as a quoted, lowercase string.
 
54
    * time.struct_time: Returns the time as a quoted string for insertion into
 
55
        a TIMESTAMP column.
 
56
    Raises a DBException if val has an unsupported type.
 
57
    """
 
58
    # "E'" is postgres's way of making "escape" strings.
 
59
    # Such strings allow backslashes to escape things. Since escape_string
 
60
    # converts a single backslash into two backslashes, it needs to be fed
 
61
    # into E mode.
 
62
    # Ref: http://www.postgresql.org/docs/8.2/static/sql-syntax-lexical.html
 
63
    # WARNING: PostgreSQL-specific code
 
64
    if val is None:
 
65
        return "NULL"
 
66
    elif isinstance(val, str) or isinstance(val, unicode):
 
67
        return "E'" + pg.escape_string(val) + "'"
 
68
    elif isinstance(val, bool):
 
69
        return "TRUE" if val else "FALSE"
 
70
    elif isinstance(val, int) or isinstance(val, long) \
 
71
        or isinstance(val, float):
 
72
        return str(val)
 
73
    elif isinstance(val, caps.Role):
 
74
        return _escape(str(val))
 
75
    elif isinstance(val, time.struct_time):
 
76
        return _escape(time.strftime(TIMESTAMP_FORMAT, val))
 
77
    else:
 
78
        raise DBException("Attempt to insert an unsupported type "
 
79
            "into the database (%s)" % repr(type(val)))
 
80
 
 
81
def _parse_boolean(val):
 
82
    """
 
83
    Accepts a boolean as output from the DB (either the string 't' or 'f').
 
84
    Returns a boolean value True or False.
 
85
    Also accepts other values which mean True or False in PostgreSQL.
 
86
    If none match, raises a DBException.
 
87
    """
 
88
    # On a personal note, what sort of a language allows 7 different values
 
89
    # to denote each of True and False?? (A: SQL)
 
90
    if isinstance(val, bool):
 
91
        return val
 
92
    elif val == 't':
 
93
        return True
 
94
    elif val == 'f':
 
95
        return False
 
96
    elif val == 'true' or val == 'y' or val == 'yes' or val == '1' \
 
97
        or val == 1:
 
98
        return True
 
99
    elif val == 'false' or val == 'n' or val == 'no' or val == '0' \
 
100
        or val == 0:
 
101
        return False
 
102
    else:
 
103
        raise DBException("Invalid boolean value returned from DB")
 
104
 
 
105
class DBException(Exception):
 
106
    """A DBException is for bad conditions in the database or bad input to
 
107
    these methods. If Postgres throws an exception it does not get rebadged.
 
108
    This is only for additional exceptions."""
 
109
    pass
 
110
 
 
111
class DB:
 
112
    """An IVLE database object. This object provides an interface to
 
113
    interacting with the IVLE database without using any external SQL.
 
114
 
 
115
    Most methods of this class have an optional dry argument. If true, they
 
116
    will return the SQL query string and NOT actually execute it. (For
 
117
    debugging purposes).
 
118
 
 
119
    Methods may throw db.DBException, or any of the pg exceptions as well.
 
120
    (In general, be prepared to catch exceptions!)
 
121
    """
 
122
    def __init__(self):
 
123
        """Connects to the database and creates a DB object.
 
124
        Takes no parameters - gets all the DB info from the configuration."""
 
125
        self.open = False
 
126
        self.db = pg.connect(host=ivle.conf.db_host, port=ivle.conf.db_port,
 
127
                         dbname=ivle.conf.db_dbname,
 
128
                         user=ivle.conf.db_user, passwd=ivle.conf.db_password)
 
129
        self.open = True
 
130
 
 
131
    def __del__(self):
 
132
        if self.open:
 
133
            self.db.close()
 
134
 
 
135
    # GENERIC DB FUNCTIONS #
 
136
 
 
137
    @staticmethod
 
138
    def check_dict(dict, tablefields, disallowed=frozenset([]), must=False):
 
139
        """Checks that a dict does not contain keys that are not fields
 
140
        of the specified table.
 
141
        dict: A mapping from string keys to values; the keys are checked to
 
142
            see that they correspond to login table fields.
 
143
        tablefields: Collection of strings for field names in the table.
 
144
            Only these fields will be allowed.
 
145
        disallowed: Optional collection of strings for field names that are
 
146
            not allowed.
 
147
        must: If True, the dict MUST contain all fields in tablefields.
 
148
            If False, it may contain any subset of the fields.
 
149
        Returns True if the dict is valid, False otherwise.
 
150
        """
 
151
        allowed = frozenset(tablefields) - frozenset(disallowed)
 
152
        dictkeys = frozenset(dict.keys())
 
153
        if must:
 
154
            return allowed == dictkeys
 
155
        else:
 
156
            return allowed.issuperset(dictkeys)
 
157
 
 
158
    def insert(self, dict, tablename, tablefields, disallowed=frozenset([]),
 
159
        dry=False):
 
160
        """Inserts a new row in a table, using data from a supplied
 
161
        dictionary (which will be checked by check_dict).
 
162
        dict: Dictionary mapping column names to values. The values may be
 
163
            any of the following types:
 
164
            str, int, long, float, NoneType.
 
165
        tablename: String, name of the table to insert into. Will NOT be
 
166
            escaped - must be a valid identifier.
 
167
        tablefields, disallowed: see check_dict.
 
168
        dry: Returns the SQL query as a string, and does not execute it.
 
169
        Raises a DBException if the dictionary contains invalid fields.
 
170
        """
 
171
        if not DB.check_dict(dict, tablefields, disallowed):
 
172
            extras = set(dict.keys()) - tablefields
 
173
            raise DBException("Supplied dictionary contains invalid fields. (%s)" % (repr(extras)))
 
174
        # Build two lists concurrently: field names and values, as SQL strings
 
175
        fieldnames = []
 
176
        values = []
 
177
        for k,v in dict.items():
 
178
            fieldnames.append(k)
 
179
            values.append(_escape(v))
 
180
        if len(fieldnames) == 0: return
 
181
        fieldnames = ', '.join(fieldnames)
 
182
        values = ', '.join(values)
 
183
        query = ("INSERT INTO %s (%s) VALUES (%s);"
 
184
            % (tablename, fieldnames, values))
 
185
        if dry: return query
 
186
        self.db.query(query)
 
187
 
 
188
    def return_insert(self, dict, tablename, tablefields, returning,
 
189
        disallowed=frozenset([]), dry=False):
 
190
        """Inserts a new row in a table, using data from a supplied
 
191
        dictionary (which will be checked by check_dict) and returns certain 
 
192
        fields as a dict.
 
193
        dict: Dictionary mapping column names to values. The values may be
 
194
            any of the following types:
 
195
            str, int, long, float, NoneType.
 
196
        tablename: String, name of the table to insert into. Will NOT be
 
197
            escaped - must be a valid identifier.
 
198
        returning: List of fields to return, not escaped
 
199
        tablefields, disallowed: see check_dict.
 
200
        dry: Returns the SQL query as a string, and does not execute it.
 
201
        Raises a DBException if the dictionary contains invalid fields.
 
202
        """
 
203
        if not DB.check_dict(dict, tablefields, disallowed):
 
204
            extras = set(dict.keys()) - tablefields
 
205
            raise DBException("Supplied dictionary contains invalid fields. (%s)" % (repr(extras)))
 
206
        # Build two lists concurrently: field names and values, as SQL strings
 
207
        fieldnames = []
 
208
        values = []
 
209
        for k,v in dict.items():
 
210
            fieldnames.append(k)
 
211
            values.append(_escape(v))
 
212
        if len(fieldnames) == 0: return
 
213
        fieldnames = ', '.join(fieldnames)
 
214
        values = ', '.join(values)
 
215
        returns = ', '.join(returning)
 
216
        query = ("INSERT INTO %s (%s) VALUES (%s) RETURNING (%s);"
 
217
            % (tablename, fieldnames, values, returns))
 
218
        if dry: return query
 
219
        return self.db.query(query)
 
220
 
 
221
 
 
222
    def update(self, primarydict, updatedict, tablename, tablefields,
 
223
        primary_keys, disallowed_update=frozenset([]), dry=False):
 
224
        """Updates a row in a table, matching against primarydict to find the
 
225
        row, and using the data in updatedict (which will be checked by
 
226
        check_dict).
 
227
        primarydict: Dict mapping column names to values. The keys should be
 
228
            the table's primary key. Only rows which match this dict's values
 
229
            will be updated.
 
230
        updatedict: Dict mapping column names to values. The columns will be
 
231
            updated with the given values for the matched rows.
 
232
        tablename, tablefields, disallowed_update: See insert.
 
233
        primary_keys: Collection of strings which together form the primary
 
234
            key for this table. primarydict must contain all of these as keys,
 
235
            and only these keys.
 
236
        """
 
237
        if (not (DB.check_dict(primarydict, primary_keys, must=True)
 
238
            and DB.check_dict(updatedict, tablefields, disallowed_update))):
 
239
            raise DBException("Supplied dictionary contains invalid or missing fields (1).")
 
240
        # Make a list of SQL fragments of the form "field = 'new value'"
 
241
        # These fragments are ALREADY-ESCAPED
 
242
        setlist = []
 
243
        for k,v in updatedict.items():
 
244
            setlist.append("%s = %s" % (k, _escape(v)))
 
245
        wherelist = []
 
246
        for k,v in primarydict.items():
 
247
            wherelist.append("%s = %s" % (k, _escape(v)))
 
248
        if len(setlist) == 0 or len(wherelist) == 0:
 
249
            return
 
250
        # Join the fragments into a comma-separated string
 
251
        setstring = ', '.join(setlist)
 
252
        wherestring = ' AND '.join(wherelist)
 
253
        # Build the whole query as an UPDATE statement
 
254
        query = ("UPDATE %s SET %s WHERE %s;"
 
255
            % (tablename, setstring, wherestring))
 
256
        if dry: return query
 
257
        self.db.query(query)
 
258
 
 
259
    def get_single(self, primarydict, tablename, getfields, primary_keys,
 
260
        error_notfound="No rows found", dry=False):
 
261
        """Retrieves a single row from a table, returning it as a dictionary
 
262
        mapping field names to values. Matches against primarydict to find the
 
263
        row.
 
264
        primarydict, tablename, primary_keys: See update/delete.
 
265
        getfields: Collection of strings; the field names which will be
 
266
            returned as keys in the dictionary.
 
267
        error_notfound: Error message if 0 rows match.
 
268
        Raises a DBException if 0 rows match, with error_notfound as the msg.
 
269
        Raises an AssertError if >1 rows match (this should not happen if
 
270
            primary_keys is indeed the primary key).
 
271
        """
 
272
        if not DB.check_dict(primarydict, primary_keys, must=True):
 
273
            raise DBException("Supplied dictionary contains invalid or missing fields (3).")
 
274
        wherelist = []
 
275
        for k,v in primarydict.items():
 
276
            wherelist.append("%s = %s" % (k, _escape(v)))
 
277
        if len(getfields) == 0 or len(wherelist) == 0:
 
278
            return
 
279
        # Join the fragments into a comma-separated string
 
280
        getstring = ', '.join(getfields)
 
281
        wherestring = ' AND '.join(wherelist)
 
282
        # Build the whole query as an SELECT statement
 
283
        query = ("SELECT %s FROM %s WHERE %s;"
 
284
            % (getstring, tablename, wherestring))
 
285
        if dry: return query
 
286
        result = self.db.query(query)
 
287
        # Expecting exactly one
 
288
        if result.ntuples() != 1:
 
289
            # It should not be possible for ntuples to be greater than 1
 
290
            assert (result.ntuples() < 1)
 
291
            raise DBException(error_notfound)
 
292
        # Return as a dictionary
 
293
        return result.dictresult()[0]
 
294
 
 
295
    def start_transaction(self, dry=False):
 
296
        """Starts a DB transaction.
 
297
        Will not commit any changes until self.commit() is called.
 
298
        """
 
299
        query = "START TRANSACTION;"
 
300
        if dry: return query
 
301
        self.db.query(query)
 
302
 
 
303
    def commit(self, dry=False):
 
304
        """Commits (ends) a DB transaction.
 
305
        Commits all changes since the call to start_transaction.
 
306
        """
 
307
        query = "COMMIT;"
 
308
        if dry: return query
 
309
        self.db.query(query)
 
310
 
 
311
    def rollback(self, dry=False):
 
312
        """Rolls back (ends) a DB transaction, undoing all changes since the
 
313
        call to start_transaction.
 
314
        """
 
315
        query = "ROLLBACK;"
 
316
        if dry: return query
 
317
        self.db.query(query)
 
318
 
 
319
    # PROBLEM AND PROBLEM ATTEMPT FUNCTIONS #
 
320
 
 
321
    def get_problem_problemid(self, exercisename, dry=False):
 
322
        """Given an exercise name, returns the associated problemID.
 
323
        If the exercise name is NOT in the database, it inserts it and returns
 
324
        the new problemID. Hence this may mutate the DB, but is idempotent.
 
325
        """
 
326
        try:
 
327
            d = self.get_single({"identifier": exercisename}, "problem",
 
328
                ['problemid'], frozenset(["identifier"]),
 
329
                dry=dry)
 
330
            if dry:
 
331
                return d        # Query string
 
332
        except DBException:
 
333
            if dry:
 
334
                # Shouldn't try again, must have failed for some other reason
 
335
                raise
 
336
            # if we failed to get a problemid, it was probably because
 
337
            # the exercise wasn't in the db. So lets insert it!
 
338
            #
 
339
            # The insert can fail if someone else simultaneously does
 
340
            # the insert, so if the insert fails, we ignore the problem. 
 
341
            try:
 
342
                self.insert({'identifier': exercisename}, "problem",
 
343
                        frozenset(['identifier']))
 
344
            except Exception, e:
 
345
                pass
 
346
 
 
347
            # Assuming the insert succeeded, we should be able to get the
 
348
            # problemid now.
 
349
            d = self.get_single({"identifier": exercisename}, "problem",
 
350
                ['problemid'], frozenset(["identifier"]))
 
351
 
 
352
        return d['problemid']
 
353
 
 
354
    def insert_problem_attempt(self, user, exercisename, date, complete,
 
355
        attempt, dry=False):
 
356
        """Inserts the details of a problem attempt into the database.
 
357
        exercisename: Name of the exercise. (identifier field of problem
 
358
            table). If this exercise does not exist, also creates a new row in
 
359
            the problem table for this exercise name.
 
360
        user: The user submitting the attempt.
 
361
        date: struct_time, the date this attempt was made.
 
362
        complete: bool. Whether the test passed or not.
 
363
        attempt: Text of the attempt.
 
364
 
 
365
        Note: Even if dry, will still physically call get_problem_problemid,
 
366
        which may mutate the DB.
 
367
        """
 
368
        problemid = self.get_problem_problemid(exercisename)
 
369
 
 
370
        return self.insert({
 
371
                'problemid': problemid,
 
372
                'loginid': user.id,
 
373
                'date': date,
 
374
                'complete': complete,
 
375
                'attempt': attempt,
 
376
            }, 'problem_attempt',
 
377
            frozenset(['problemid','loginid','date','complete','attempt']),
 
378
            dry=dry)
 
379
 
 
380
    def write_problem_save(self, user, exercisename, date, text, dry=False):
 
381
        """Writes text to the problem_save table (for when the user saves an
 
382
        exercise). Creates a new row, or overwrites an existing one if the
 
383
        user has already saved that problem.
 
384
        (Unlike problem_attempt, does not keep historical records).
 
385
        """
 
386
        problemid = self.get_problem_problemid(exercisename)
 
387
 
 
388
        try:
 
389
            return self.insert({
 
390
                    'problemid': problemid,
 
391
                    'loginid': user.id,
 
392
                    'date': date,
 
393
                    'text': text,
 
394
                }, 'problem_save',
 
395
                frozenset(['problemid','loginid','date','text']),
 
396
                dry=dry)
 
397
        except pg.ProgrammingError:
 
398
            # May have failed because this problemid/loginid row already
 
399
            # exists (they have a unique key constraint).
 
400
            # Do an update instead.
 
401
            if dry:
 
402
                # Shouldn't try again, must have failed for some other reason
 
403
                raise
 
404
            self.update({
 
405
                    'problemid': problemid,
 
406
                    'loginid': user.id,
 
407
                },
 
408
                {
 
409
                    'date': date,
 
410
                    'text': text,
 
411
                }, "problem_save",
 
412
                frozenset(['date', 'text']),
 
413
                frozenset(['problemid', 'loginid']))
 
414
 
 
415
    # SUBJECTS AND ENROLEMENT
 
416
 
 
417
    def get_offering_semesters(self, subjectid, dry=False):
 
418
        """
 
419
        Get the semester information for a subject as well as providing 
 
420
        information about if the subject is active and which semester it is in.
 
421
        """
 
422
        query = """\
 
423
SELECT offeringid, subj_name, year, semester, active
 
424
FROM semester, offering, subject
 
425
WHERE offering.semesterid = semester.semesterid AND
 
426
    offering.subject = subject.subjectid AND
 
427
    offering.subject = %d;"""%subjectid
 
428
        if dry:
 
429
            return query
 
430
        results = self.db.query(query).dictresult()
 
431
        # Parse boolean varibles
 
432
        for result in results:
 
433
            result['active'] = _parse_boolean(result['active'])
 
434
        return results
 
435
 
 
436
    def close(self):
 
437
        """Close the DB connection. Do not call any other functions after
 
438
        this. (The behaviour of doing so is undefined).
 
439
        """
 
440
        self.db.close()
 
441
        self.open = False