409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
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
|
|
468
by mattgiuca
db.py: Epic Refactor. |
20 |
# Date: 15/2/2008
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
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 conf |
|
36 |
import md5 |
|
468
by mattgiuca
db.py: Epic Refactor. |
37 |
import copy |
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
38 |
|
476
by mattgiuca
Added new module: common/caps.py. This is the Capabilities centre of IVLE. |
39 |
from common import caps |
40 |
||
468
by mattgiuca
db.py: Epic Refactor. |
41 |
def _escape(val): |
42 |
"""Wrapper around pg.escape_string. Prepares the Python value for use in
|
|
43 |
SQL. Returns a string, which may be safely placed verbatim into an SQL
|
|
44 |
query.
|
|
45 |
Handles the following types:
|
|
46 |
* str: Escapes the string, and also quotes it.
|
|
47 |
* int/long/float: Just converts to an unquoted string.
|
|
48 |
* bool: Returns as "TRUE" or "FALSE", unquoted.
|
|
49 |
* NoneType: Returns "NULL", unquoted.
|
|
476
by mattgiuca
Added new module: common/caps.py. This is the Capabilities centre of IVLE. |
50 |
* common.caps.Role: Returns the role as a quoted, lowercase string.
|
468
by mattgiuca
db.py: Epic Refactor. |
51 |
Raises a DBException if val has an unsupported type.
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
52 |
"""
|
53 |
# "E'" is postgres's way of making "escape" strings.
|
|
54 |
# Such strings allow backslashes to escape things. Since escape_string
|
|
55 |
# converts a single backslash into two backslashes, it needs to be fed
|
|
56 |
# into E mode.
|
|
57 |
# Ref: http://www.postgresql.org/docs/8.2/static/sql-syntax-lexical.html
|
|
58 |
# WARNING: PostgreSQL-specific code
|
|
468
by mattgiuca
db.py: Epic Refactor. |
59 |
if val is None: |
429
by mattgiuca
makeuser and common.db now allow StudentID to be unsupplied / None. |
60 |
return "NULL" |
468
by mattgiuca
db.py: Epic Refactor. |
61 |
elif isinstance(val, str): |
62 |
return "E'" + pg.escape_string(val) + "'" |
|
63 |
elif isinstance(val, bool): |
|
64 |
return "TRUE" if val else "FALSE" |
|
65 |
elif isinstance(val, int) or isinstance(val, long) \ |
|
66 |
or isinstance(val, float): |
|
67 |
return str(val) |
|
476
by mattgiuca
Added new module: common/caps.py. This is the Capabilities centre of IVLE. |
68 |
elif isinstance(val, caps.Role): |
69 |
return _escape(str(val)) |
|
468
by mattgiuca
db.py: Epic Refactor. |
70 |
else: |
71 |
raise DBException("Attempt to insert an unsupported type " |
|
72 |
"into the database") |
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
73 |
|
74 |
def _passhash(password): |
|
75 |
return md5.md5(password).hexdigest() |
|
76 |
||
77 |
class DBException(Exception): |
|
78 |
"""A DBException is for bad conditions in the database or bad input to
|
|
79 |
these methods. If Postgres throws an exception it does not get rebadged.
|
|
80 |
This is only for additional exceptions."""
|
|
81 |
pass
|
|
82 |
||
83 |
class DB: |
|
84 |
"""An IVLE database object. This object provides an interface to
|
|
85 |
interacting with the IVLE database without using any external SQL.
|
|
86 |
||
87 |
Most methods of this class have an optional dry argument. If true, they
|
|
88 |
will return the SQL query string and NOT actually execute it. (For
|
|
89 |
debugging purposes).
|
|
90 |
||
91 |
Methods may throw db.DBException, or any of the pg exceptions as well.
|
|
92 |
(In general, be prepared to catch exceptions!)
|
|
93 |
"""
|
|
94 |
def __init__(self): |
|
95 |
"""Connects to the database and creates a DB object.
|
|
96 |
Takes no parameters - gets all the DB info from the configuration."""
|
|
484
by mattgiuca
lib/common/db.py: Fixed a cascading error if the constructor throws an |
97 |
self.open = False |
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
98 |
self.db = pg.connect(dbname=conf.db_dbname, host=conf.db_host, |
99 |
port=conf.db_port, user=conf.db_user, passwd=conf.db_password) |
|
466
by drtomc
db: Make the DB object self-closing. |
100 |
self.open = True |
101 |
||
102 |
def __del__(self): |
|
103 |
if self.open: |
|
104 |
self.db.close() |
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
105 |
|
468
by mattgiuca
db.py: Epic Refactor. |
106 |
# GENERIC DB FUNCTIONS #
|
107 |
||
108 |
@staticmethod
|
|
109 |
def check_dict(dict, tablefields, disallowed=frozenset([]), must=False): |
|
110 |
"""Checks that a dict does not contain keys that are not fields
|
|
111 |
of the specified table.
|
|
112 |
dict: A mapping from string keys to values; the keys are checked to
|
|
113 |
see that they correspond to login table fields.
|
|
114 |
tablefields: Collection of strings for field names in the table.
|
|
115 |
Only these fields will be allowed.
|
|
116 |
disallowed: Optional collection of strings for field names that are
|
|
117 |
not allowed.
|
|
118 |
must: If True, the dict MUST contain all fields in tablefields.
|
|
119 |
If False, it may contain any subset of the fields.
|
|
120 |
Returns True if the dict is valid, False otherwise.
|
|
121 |
"""
|
|
122 |
allowed = frozenset(tablefields) - frozenset(disallowed) |
|
123 |
dictkeys = frozenset(dict.keys()) |
|
124 |
if must: |
|
125 |
return allowed == dictkeys |
|
126 |
else: |
|
127 |
return allowed.issuperset(dictkeys) |
|
128 |
||
129 |
def insert(self, dict, tablename, tablefields, disallowed=frozenset([]), |
|
130 |
dry=False): |
|
131 |
"""Inserts a new row in a table, using data from a supplied
|
|
132 |
dictionary (which will be checked by check_dict).
|
|
133 |
dict: Dictionary mapping column names to values. The values may be
|
|
134 |
any of the following types:
|
|
135 |
str, int, long, float, NoneType.
|
|
136 |
tablename: String, name of the table to insert into. Will NOT be
|
|
137 |
escaped - must be a valid identifier.
|
|
138 |
tablefields, disallowed: see check_dict.
|
|
139 |
dry: Returns the SQL query as a string, and does not execute it.
|
|
140 |
Raises a DBException if the dictionary contains invalid fields.
|
|
141 |
"""
|
|
142 |
if not DB.check_dict(dict, tablefields, disallowed): |
|
143 |
raise DBException("Supplied dictionary contains invalid fields.") |
|
144 |
# Build two lists concurrently: field names and values, as SQL strings
|
|
145 |
fieldnames = [] |
|
146 |
values = [] |
|
147 |
for k,v in dict.items(): |
|
148 |
fieldnames.append(k) |
|
149 |
values.append(_escape(v)) |
|
150 |
if len(fieldnames) == 0: return |
|
151 |
fieldnames = ', '.join(fieldnames) |
|
152 |
values = ', '.join(values) |
|
153 |
query = ("INSERT INTO %s (%s) VALUES (%s);" |
|
154 |
% (tablename, fieldnames, values)) |
|
155 |
if dry: return query |
|
156 |
self.db.query(query) |
|
157 |
||
158 |
def update(self, primarydict, updatedict, tablename, tablefields, |
|
159 |
primary_keys, disallowed_update=frozenset([]), dry=False): |
|
160 |
"""Updates a row in a table, matching against primarydict to find the
|
|
161 |
row, and using the data in updatedict (which will be checked by
|
|
162 |
check_dict).
|
|
163 |
primarydict: Dict mapping column names to values. The keys should be
|
|
164 |
the table's primary key. Only rows which match this dict's values
|
|
165 |
will be updated.
|
|
166 |
updatedict: Dict mapping column names to values. The columns will be
|
|
167 |
updated with the given values for the matched rows.
|
|
168 |
tablename, tablefields, disallowed_update: See insert.
|
|
169 |
primary_keys: Collection of strings which together form the primary
|
|
170 |
key for this table. primarydict must contain all of these as keys,
|
|
171 |
and only these keys.
|
|
172 |
"""
|
|
173 |
if (not (DB.check_dict(primarydict, primary_keys, must=True) |
|
174 |
and DB.check_dict(updatedict, tablefields, disallowed_update))): |
|
175 |
raise DBException("Supplied dictionary contains invalid or " |
|
176 |
" missing fields.") |
|
177 |
# Make a list of SQL fragments of the form "field = 'new value'"
|
|
178 |
# These fragments are ALREADY-ESCAPED
|
|
179 |
setlist = [] |
|
180 |
for k,v in updatedict.items(): |
|
181 |
setlist.append("%s = %s" % (k, _escape(v))) |
|
182 |
wherelist = [] |
|
183 |
for k,v in primarydict.items(): |
|
184 |
wherelist.append("%s = %s" % (k, _escape(v))) |
|
185 |
if len(setlist) == 0 or len(wherelist) == 0: |
|
186 |
return
|
|
187 |
# Join the fragments into a comma-separated string
|
|
188 |
setstring = ', '.join(setlist) |
|
189 |
wherestring = ' AND '.join(wherelist) |
|
190 |
# Build the whole query as an UPDATE statement
|
|
191 |
query = ("UPDATE %s SET %s WHERE %s;" |
|
192 |
% (tablename, setstring, wherestring)) |
|
193 |
if dry: return query |
|
194 |
self.db.query(query) |
|
195 |
||
196 |
def delete(self, primarydict, tablename, primary_keys, dry=False): |
|
197 |
"""Deletes a row in the table, matching against primarydict to find
|
|
198 |
the row.
|
|
199 |
primarydict, tablename, primary_keys: See update.
|
|
200 |
"""
|
|
201 |
if not DB.check_dict(primarydict, primary_keys, must=True): |
|
202 |
raise DBException("Supplied dictionary contains invalid or " |
|
203 |
" missing fields.") |
|
204 |
wherelist = [] |
|
205 |
for k,v in primarydict.items(): |
|
206 |
wherelist.append("%s = %s" % (k, _escape(v))) |
|
207 |
if len(wherelist) == 0: |
|
208 |
return
|
|
209 |
wherestring = ' AND '.join(wherelist) |
|
210 |
query = ("DELETE FROM %s WHERE %s;" % (tablename, wherestring)) |
|
211 |
if dry: return query |
|
212 |
self.db.query(query) |
|
213 |
||
214 |
def get_single(self, primarydict, tablename, getfields, primary_keys, |
|
215 |
error_notfound="No rows found", dry=False): |
|
216 |
"""Retrieves a single row from a table, returning it as a dictionary
|
|
217 |
mapping field names to values. Matches against primarydict to find the
|
|
218 |
row.
|
|
219 |
primarydict, tablename, primary_keys: See update/delete.
|
|
220 |
getfields: Collection of strings; the field names which will be
|
|
221 |
returned as keys in the dictionary.
|
|
222 |
error_notfound: Error message if 0 rows match.
|
|
223 |
Raises a DBException if 0 rows match, with error_notfound as the msg.
|
|
224 |
Raises an AssertError if >1 rows match (this should not happen if
|
|
225 |
primary_keys is indeed the primary key).
|
|
226 |
"""
|
|
227 |
if not DB.check_dict(primarydict, primary_keys, must=True): |
|
228 |
raise DBException("Supplied dictionary contains invalid or " |
|
229 |
" missing fields.") |
|
230 |
wherelist = [] |
|
231 |
for k,v in primarydict.items(): |
|
232 |
wherelist.append("%s = %s" % (k, _escape(v))) |
|
233 |
if len(getfields) == 0 or len(wherelist) == 0: |
|
234 |
return
|
|
235 |
# Join the fragments into a comma-separated string
|
|
236 |
getstring = ', '.join(getfields) |
|
237 |
wherestring = ' AND '.join(wherelist) |
|
238 |
# Build the whole query as an SELECT statement
|
|
239 |
query = ("SELECT %s FROM %s WHERE %s;" |
|
240 |
% (getstring, tablename, wherestring)) |
|
241 |
if dry: return query |
|
242 |
result = self.db.query(query) |
|
243 |
# Expecting exactly one
|
|
244 |
if result.ntuples() != 1: |
|
245 |
# It should not be possible for ntuples to be greater than 1
|
|
246 |
assert (result.ntuples() < 1) |
|
247 |
raise DBException(error_notfound) |
|
248 |
# Return as a dictionary
|
|
249 |
return result.dictresult()[0] |
|
250 |
||
251 |
def get_all(self, tablename, getfields, dry=False): |
|
252 |
"""Retrieves all rows from a table, returning it as a list of
|
|
253 |
dictionaries mapping field names to values.
|
|
254 |
tablename, getfields: See get_single.
|
|
255 |
"""
|
|
256 |
if len(getfields) == 0: |
|
257 |
return
|
|
258 |
getstring = ', '.join(getfields) |
|
259 |
query = ("SELECT %s FROM %s;" % (getstring, tablename)) |
|
260 |
if dry: return query |
|
261 |
return self.db.query(query).dictresult() |
|
262 |
||
492
by mattgiuca
db.py: Added start_transaction, commit and rollback methods (tested). |
263 |
def start_transaction(self, dry=False): |
264 |
"""Starts a DB transaction.
|
|
265 |
Will not commit any changes until self.commit() is called.
|
|
266 |
"""
|
|
267 |
query = "START TRANSACTION;" |
|
268 |
if dry: return query |
|
269 |
self.db.query(query) |
|
270 |
||
271 |
def commit(self, dry=False): |
|
272 |
"""Commits (ends) a DB transaction.
|
|
273 |
Commits all changes since the call to start_transaction.
|
|
274 |
"""
|
|
275 |
query = "COMMIT;" |
|
276 |
if dry: return query |
|
277 |
self.db.query(query) |
|
278 |
||
279 |
def rollback(self, dry=False): |
|
280 |
"""Rolls back (ends) a DB transaction, undoing all changes since the
|
|
281 |
call to start_transaction.
|
|
282 |
"""
|
|
283 |
query = "ROLLBACK;" |
|
284 |
if dry: return query |
|
285 |
self.db.query(query) |
|
286 |
||
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
287 |
# USER MANAGEMENT FUNCTIONS #
|
288 |
||
468
by mattgiuca
db.py: Epic Refactor. |
289 |
login_primary = frozenset(["login"]) |
470
by mattgiuca
db: Added a field login_fields_list which is an ordered version of the |
290 |
login_fields_list = [ |
468
by mattgiuca
db.py: Epic Refactor. |
291 |
"login", "passhash", "state", "unixid", "email", "nick", "fullname", |
475
by mattgiuca
Commited some earlier changes to users.sql (not committed earlier due to |
292 |
"rolenm", "studentid", "acct_exp", "pass_exp", "last_login" |
470
by mattgiuca
db: Added a field login_fields_list which is an ordered version of the |
293 |
]
|
294 |
login_fields = frozenset(login_fields_list) |
|
468
by mattgiuca
db.py: Epic Refactor. |
295 |
# Do not return passhash when reading from the DB
|
296 |
login_getfields = login_fields - frozenset(["passhash"]) |
|
297 |
||
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
298 |
def create_user(self, dry=False, **kwargs): |
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
299 |
"""Creates a user login entry in the database.
|
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
300 |
All user fields are to be passed as args. The argument names
|
468
by mattgiuca
db.py: Epic Refactor. |
301 |
are the field names of the "login" table of the DB schema.
|
302 |
However, instead of supplying a "passhash", you must supply a
|
|
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
303 |
"password" argument, which will be hashed internally.
|
468
by mattgiuca
db.py: Epic Refactor. |
304 |
Also "state" must not given explicitly; it is implicitly set to
|
451
by mattgiuca
Updated common/db.py and listusers to deal with the new "state" column in the |
305 |
"no_agreement".
|
468
by mattgiuca
db.py: Epic Refactor. |
306 |
Raises an exception if the user already exists, or the dict contains
|
307 |
invalid keys or is missing required keys.
|
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
308 |
"""
|
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
309 |
if 'passhash' in kwargs: |
310 |
raise DBException("Supplied arguments include passhash (invalid).") |
|
468
by mattgiuca
db.py: Epic Refactor. |
311 |
# Make a copy of the dict. Change password to passhash (hashing it),
|
312 |
# and set 'state' to "no_agreement".
|
|
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
313 |
kwargs = copy.copy(kwargs) |
472
by mattgiuca
db.py: No longer exceptions if password is not supplied. |
314 |
if 'password' in kwargs: |
315 |
kwargs['passhash'] = _passhash(kwargs['password']) |
|
316 |
del kwargs['password'] |
|
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
317 |
kwargs['state'] = "no_agreement" |
468
by mattgiuca
db.py: Epic Refactor. |
318 |
# Execute the query.
|
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
319 |
return self.insert(kwargs, "login", self.login_fields, dry=dry) |
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
320 |
|
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
321 |
def update_user(self, login, dry=False, **kwargs): |
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
322 |
"""Updates fields of a particular user. login is the name of the user
|
468
by mattgiuca
db.py: Epic Refactor. |
323 |
to update. The dict contains the fields which will be modified, and
|
324 |
their new values. If any value is omitted from the dict, it does not
|
|
325 |
get modified. login and studentid may not be modified.
|
|
326 |
Passhash may be modified by supplying a "password" field, in
|
|
327 |
cleartext, not a hashed password.
|
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
328 |
|
329 |
Note that no checking is done. It is expected this function is called
|
|
330 |
by a trusted source. In particular, it allows the password to be
|
|
331 |
changed without knowing the old password. The caller should check
|
|
332 |
that the user knows the existing password before calling this function
|
|
333 |
with a new one.
|
|
334 |
"""
|
|
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
335 |
if 'passhash' in kwargs: |
336 |
raise DBException("Supplied arguments include passhash (invalid).") |
|
337 |
if "password" in kwargs: |
|
338 |
kwargs = copy.copy(kwargs) |
|
339 |
kwargs['passhash'] = _passhash(kwargs['password']) |
|
340 |
del kwargs['password'] |
|
341 |
return self.update({"login": login}, kwargs, "login", |
|
342 |
self.login_fields, self.login_primary, ["login", "studentid"], |
|
343 |
dry=dry) |
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
344 |
|
345 |
def get_user(self, login, dry=False): |
|
346 |
"""Given a login, returns a dictionary of the user's DB fields,
|
|
347 |
excluding the passhash field.
|
|
348 |
||
349 |
Raises a DBException if the login is not found in the DB.
|
|
350 |
"""
|
|
468
by mattgiuca
db.py: Epic Refactor. |
351 |
return self.get_single({"login": login}, "login", |
352 |
self.login_getfields, self.login_primary, |
|
353 |
error_notfound="get_user: No user with that login name", dry=dry) |
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
354 |
|
428
by mattgiuca
common.db: Added get_users function to get all users. |
355 |
def get_users(self, dry=False): |
356 |
"""Returns a list of all users. The list elements are a dictionary of
|
|
357 |
the user's DB fields, excluding the passhash field.
|
|
358 |
"""
|
|
468
by mattgiuca
db.py: Epic Refactor. |
359 |
return self.get_all("login", self.login_getfields, dry=dry) |
428
by mattgiuca
common.db: Added get_users function to get all users. |
360 |
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
361 |
def user_authenticate(self, login, password, dry=False): |
362 |
"""Performs a password authentication on a user. Returns True if
|
|
466
by drtomc
db: Make the DB object self-closing. |
363 |
"passhash" is the correct passhash for the given login, False
|
364 |
otherwise.
|
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
365 |
Also returns False if the login does not exist (so if you want to
|
366 |
differentiate these cases, use get_user and catch an exception).
|
|
367 |
"""
|
|
368 |
query = ("SELECT login FROM login " |
|
369 |
"WHERE login = '%s' AND passhash = %s;" |
|
370 |
% (login, _escape(_passhash(password)))) |
|
371 |
if dry: return query |
|
372 |
result = self.db.query(query) |
|
373 |
# If one row was returned, succeed.
|
|
374 |
# Otherwise, fail to authenticate.
|
|
375 |
return result.ntuples() == 1 |
|
376 |
||
377 |
def close(self): |
|
378 |
"""Close the DB connection. Do not call any other functions after
|
|
379 |
this. (The behaviour of doing so is undefined).
|
|
380 |
"""
|
|
381 |
self.db.close() |
|
466
by drtomc
db: Make the DB object self-closing. |
382 |
self.open = False |