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 |
669
by mattgiuca
Timestamps are now stored within the program as Python "time" module's |
38 |
import time |
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
39 |
|
500
by mattgiuca
db: get_user and get_users now return User objects instead of dictionaries. |
40 |
from common import (caps, user) |
476
by mattgiuca
Added new module: common/caps.py. This is the Capabilities centre of IVLE. |
41 |
|
669
by mattgiuca
Timestamps are now stored within the program as Python "time" module's |
42 |
TIMESTAMP_FORMAT = '%Y-%m-%d %H:%M:%S' |
43 |
||
468
by mattgiuca
db.py: Epic Refactor. |
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.
|
|
476
by mattgiuca
Added new module: common/caps.py. This is the Capabilities centre of IVLE. |
53 |
* common.caps.Role: Returns the role as a quoted, lowercase string.
|
725
by mattgiuca
The database now stores a cache of all the worksheets and what problems |
54 |
* time.struct_time: Returns the time as a quoted string for insertion into
|
55 |
a TIMESTAMP column.
|
|
468
by mattgiuca
db.py: Epic Refactor. |
56 |
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" |
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
|
|
468
by mattgiuca
db.py: Epic Refactor. |
64 |
if val is None: |
429
by mattgiuca
makeuser and common.db now allow StudentID to be unsupplied / None. |
65 |
return "NULL" |
700
by mattgiuca
db.py: Now allows unicode strings to be inserted. |
66 |
elif isinstance(val, str) or isinstance(val, unicode): |
468
by mattgiuca
db.py: Epic Refactor. |
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) |
|
476
by mattgiuca
Added new module: common/caps.py. This is the Capabilities centre of IVLE. |
73 |
elif isinstance(val, caps.Role): |
74 |
return _escape(str(val)) |
|
669
by mattgiuca
Timestamps are now stored within the program as Python "time" module's |
75 |
elif isinstance(val, time.struct_time): |
76 |
return _escape(time.strftime(TIMESTAMP_FORMAT, val)) |
|
468
by mattgiuca
db.py: Epic Refactor. |
77 |
else: |
78 |
raise DBException("Attempt to insert an unsupported type " |
|
725
by mattgiuca
The database now stores a cache of all the worksheets and what problems |
79 |
"into the database (%s)" % repr(type(val))) |
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
80 |
|
730
by mattgiuca
Added per-worksheet and per-subject score calculation. |
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 |
||
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
105 |
def _passhash(password): |
106 |
return md5.md5(password).hexdigest() |
|
107 |
||
108 |
class DBException(Exception): |
|
109 |
"""A DBException is for bad conditions in the database or bad input to
|
|
110 |
these methods. If Postgres throws an exception it does not get rebadged.
|
|
111 |
This is only for additional exceptions."""
|
|
112 |
pass
|
|
113 |
||
114 |
class DB: |
|
115 |
"""An IVLE database object. This object provides an interface to
|
|
116 |
interacting with the IVLE database without using any external SQL.
|
|
117 |
||
118 |
Most methods of this class have an optional dry argument. If true, they
|
|
119 |
will return the SQL query string and NOT actually execute it. (For
|
|
120 |
debugging purposes).
|
|
121 |
||
122 |
Methods may throw db.DBException, or any of the pg exceptions as well.
|
|
123 |
(In general, be prepared to catch exceptions!)
|
|
124 |
"""
|
|
125 |
def __init__(self): |
|
126 |
"""Connects to the database and creates a DB object.
|
|
127 |
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 |
128 |
self.open = False |
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
129 |
self.db = pg.connect(dbname=conf.db_dbname, host=conf.db_host, |
130 |
port=conf.db_port, user=conf.db_user, passwd=conf.db_password) |
|
466
by drtomc
db: Make the DB object self-closing. |
131 |
self.open = True |
132 |
||
133 |
def __del__(self): |
|
134 |
if self.open: |
|
135 |
self.db.close() |
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
136 |
|
468
by mattgiuca
db.py: Epic Refactor. |
137 |
# GENERIC DB FUNCTIONS #
|
138 |
||
139 |
@staticmethod
|
|
140 |
def check_dict(dict, tablefields, disallowed=frozenset([]), must=False): |
|
141 |
"""Checks that a dict does not contain keys that are not fields
|
|
142 |
of the specified table.
|
|
143 |
dict: A mapping from string keys to values; the keys are checked to
|
|
144 |
see that they correspond to login table fields.
|
|
145 |
tablefields: Collection of strings for field names in the table.
|
|
146 |
Only these fields will be allowed.
|
|
147 |
disallowed: Optional collection of strings for field names that are
|
|
148 |
not allowed.
|
|
149 |
must: If True, the dict MUST contain all fields in tablefields.
|
|
150 |
If False, it may contain any subset of the fields.
|
|
151 |
Returns True if the dict is valid, False otherwise.
|
|
152 |
"""
|
|
153 |
allowed = frozenset(tablefields) - frozenset(disallowed) |
|
154 |
dictkeys = frozenset(dict.keys()) |
|
155 |
if must: |
|
156 |
return allowed == dictkeys |
|
157 |
else: |
|
158 |
return allowed.issuperset(dictkeys) |
|
159 |
||
160 |
def insert(self, dict, tablename, tablefields, disallowed=frozenset([]), |
|
161 |
dry=False): |
|
162 |
"""Inserts a new row in a table, using data from a supplied
|
|
163 |
dictionary (which will be checked by check_dict).
|
|
164 |
dict: Dictionary mapping column names to values. The values may be
|
|
165 |
any of the following types:
|
|
166 |
str, int, long, float, NoneType.
|
|
167 |
tablename: String, name of the table to insert into. Will NOT be
|
|
168 |
escaped - must be a valid identifier.
|
|
169 |
tablefields, disallowed: see check_dict.
|
|
170 |
dry: Returns the SQL query as a string, and does not execute it.
|
|
171 |
Raises a DBException if the dictionary contains invalid fields.
|
|
172 |
"""
|
|
173 |
if not DB.check_dict(dict, tablefields, disallowed): |
|
585
by drtomc
db: remove local_password before trying to create accounts. |
174 |
extras = set(dict.keys()) - tablefields |
175 |
raise DBException("Supplied dictionary contains invalid fields. (%s)" % (repr(extras))) |
|
468
by mattgiuca
db.py: Epic Refactor. |
176 |
# Build two lists concurrently: field names and values, as SQL strings
|
177 |
fieldnames = [] |
|
178 |
values = [] |
|
179 |
for k,v in dict.items(): |
|
180 |
fieldnames.append(k) |
|
181 |
values.append(_escape(v)) |
|
182 |
if len(fieldnames) == 0: return |
|
183 |
fieldnames = ', '.join(fieldnames) |
|
184 |
values = ', '.join(values) |
|
185 |
query = ("INSERT INTO %s (%s) VALUES (%s);" |
|
186 |
% (tablename, fieldnames, values)) |
|
187 |
if dry: return query |
|
188 |
self.db.query(query) |
|
189 |
||
190 |
def update(self, primarydict, updatedict, tablename, tablefields, |
|
191 |
primary_keys, disallowed_update=frozenset([]), dry=False): |
|
192 |
"""Updates a row in a table, matching against primarydict to find the
|
|
193 |
row, and using the data in updatedict (which will be checked by
|
|
194 |
check_dict).
|
|
195 |
primarydict: Dict mapping column names to values. The keys should be
|
|
196 |
the table's primary key. Only rows which match this dict's values
|
|
197 |
will be updated.
|
|
198 |
updatedict: Dict mapping column names to values. The columns will be
|
|
199 |
updated with the given values for the matched rows.
|
|
200 |
tablename, tablefields, disallowed_update: See insert.
|
|
201 |
primary_keys: Collection of strings which together form the primary
|
|
202 |
key for this table. primarydict must contain all of these as keys,
|
|
203 |
and only these keys.
|
|
204 |
"""
|
|
205 |
if (not (DB.check_dict(primarydict, primary_keys, must=True) |
|
206 |
and DB.check_dict(updatedict, tablefields, disallowed_update))): |
|
522
by drtomc
Add quite a lot of stuff to get usrmgt happening. |
207 |
raise DBException("Supplied dictionary contains invalid or missing fields (1).") |
468
by mattgiuca
db.py: Epic Refactor. |
208 |
# Make a list of SQL fragments of the form "field = 'new value'"
|
209 |
# These fragments are ALREADY-ESCAPED
|
|
210 |
setlist = [] |
|
211 |
for k,v in updatedict.items(): |
|
212 |
setlist.append("%s = %s" % (k, _escape(v))) |
|
213 |
wherelist = [] |
|
214 |
for k,v in primarydict.items(): |
|
215 |
wherelist.append("%s = %s" % (k, _escape(v))) |
|
216 |
if len(setlist) == 0 or len(wherelist) == 0: |
|
217 |
return
|
|
218 |
# Join the fragments into a comma-separated string
|
|
219 |
setstring = ', '.join(setlist) |
|
220 |
wherestring = ' AND '.join(wherelist) |
|
221 |
# Build the whole query as an UPDATE statement
|
|
222 |
query = ("UPDATE %s SET %s WHERE %s;" |
|
223 |
% (tablename, setstring, wherestring)) |
|
224 |
if dry: return query |
|
225 |
self.db.query(query) |
|
226 |
||
227 |
def delete(self, primarydict, tablename, primary_keys, dry=False): |
|
228 |
"""Deletes a row in the table, matching against primarydict to find
|
|
229 |
the row.
|
|
230 |
primarydict, tablename, primary_keys: See update.
|
|
231 |
"""
|
|
232 |
if not DB.check_dict(primarydict, primary_keys, must=True): |
|
522
by drtomc
Add quite a lot of stuff to get usrmgt happening. |
233 |
raise DBException("Supplied dictionary contains invalid or missing fields (2).") |
468
by mattgiuca
db.py: Epic Refactor. |
234 |
wherelist = [] |
235 |
for k,v in primarydict.items(): |
|
236 |
wherelist.append("%s = %s" % (k, _escape(v))) |
|
237 |
if len(wherelist) == 0: |
|
238 |
return
|
|
239 |
wherestring = ' AND '.join(wherelist) |
|
240 |
query = ("DELETE FROM %s WHERE %s;" % (tablename, wherestring)) |
|
241 |
if dry: return query |
|
242 |
self.db.query(query) |
|
243 |
||
244 |
def get_single(self, primarydict, tablename, getfields, primary_keys, |
|
245 |
error_notfound="No rows found", dry=False): |
|
246 |
"""Retrieves a single row from a table, returning it as a dictionary
|
|
247 |
mapping field names to values. Matches against primarydict to find the
|
|
248 |
row.
|
|
249 |
primarydict, tablename, primary_keys: See update/delete.
|
|
250 |
getfields: Collection of strings; the field names which will be
|
|
251 |
returned as keys in the dictionary.
|
|
252 |
error_notfound: Error message if 0 rows match.
|
|
253 |
Raises a DBException if 0 rows match, with error_notfound as the msg.
|
|
254 |
Raises an AssertError if >1 rows match (this should not happen if
|
|
255 |
primary_keys is indeed the primary key).
|
|
256 |
"""
|
|
257 |
if not DB.check_dict(primarydict, primary_keys, must=True): |
|
522
by drtomc
Add quite a lot of stuff to get usrmgt happening. |
258 |
raise DBException("Supplied dictionary contains invalid or missing fields (3).") |
468
by mattgiuca
db.py: Epic Refactor. |
259 |
wherelist = [] |
260 |
for k,v in primarydict.items(): |
|
261 |
wherelist.append("%s = %s" % (k, _escape(v))) |
|
262 |
if len(getfields) == 0 or len(wherelist) == 0: |
|
263 |
return
|
|
264 |
# Join the fragments into a comma-separated string
|
|
265 |
getstring = ', '.join(getfields) |
|
266 |
wherestring = ' AND '.join(wherelist) |
|
267 |
# Build the whole query as an SELECT statement
|
|
268 |
query = ("SELECT %s FROM %s WHERE %s;" |
|
269 |
% (getstring, tablename, wherestring)) |
|
270 |
if dry: return query |
|
271 |
result = self.db.query(query) |
|
272 |
# Expecting exactly one
|
|
273 |
if result.ntuples() != 1: |
|
274 |
# It should not be possible for ntuples to be greater than 1
|
|
275 |
assert (result.ntuples() < 1) |
|
276 |
raise DBException(error_notfound) |
|
277 |
# Return as a dictionary
|
|
278 |
return result.dictresult()[0] |
|
279 |
||
280 |
def get_all(self, tablename, getfields, dry=False): |
|
281 |
"""Retrieves all rows from a table, returning it as a list of
|
|
282 |
dictionaries mapping field names to values.
|
|
283 |
tablename, getfields: See get_single.
|
|
284 |
"""
|
|
285 |
if len(getfields) == 0: |
|
286 |
return
|
|
287 |
getstring = ', '.join(getfields) |
|
288 |
query = ("SELECT %s FROM %s;" % (getstring, tablename)) |
|
289 |
if dry: return query |
|
290 |
return self.db.query(query).dictresult() |
|
291 |
||
492
by mattgiuca
db.py: Added start_transaction, commit and rollback methods (tested). |
292 |
def start_transaction(self, dry=False): |
293 |
"""Starts a DB transaction.
|
|
294 |
Will not commit any changes until self.commit() is called.
|
|
295 |
"""
|
|
296 |
query = "START TRANSACTION;" |
|
297 |
if dry: return query |
|
298 |
self.db.query(query) |
|
299 |
||
300 |
def commit(self, dry=False): |
|
301 |
"""Commits (ends) a DB transaction.
|
|
302 |
Commits all changes since the call to start_transaction.
|
|
303 |
"""
|
|
304 |
query = "COMMIT;" |
|
305 |
if dry: return query |
|
306 |
self.db.query(query) |
|
307 |
||
308 |
def rollback(self, dry=False): |
|
309 |
"""Rolls back (ends) a DB transaction, undoing all changes since the
|
|
310 |
call to start_transaction.
|
|
311 |
"""
|
|
312 |
query = "ROLLBACK;" |
|
313 |
if dry: return query |
|
314 |
self.db.query(query) |
|
315 |
||
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
316 |
# USER MANAGEMENT FUNCTIONS #
|
317 |
||
468
by mattgiuca
db.py: Epic Refactor. |
318 |
login_primary = frozenset(["login"]) |
470
by mattgiuca
db: Added a field login_fields_list which is an ordered version of the |
319 |
login_fields_list = [ |
468
by mattgiuca
db.py: Epic Refactor. |
320 |
"login", "passhash", "state", "unixid", "email", "nick", "fullname", |
522
by drtomc
Add quite a lot of stuff to get usrmgt happening. |
321 |
"rolenm", "studentid", "acct_exp", "pass_exp", "last_login", "svn_pass" |
470
by mattgiuca
db: Added a field login_fields_list which is an ordered version of the |
322 |
]
|
323 |
login_fields = frozenset(login_fields_list) |
|
468
by mattgiuca
db.py: Epic Refactor. |
324 |
|
532
by mattgiuca
db.py: Augmented create_user. |
325 |
def create_user(self, user_obj=None, dry=False, **kwargs): |
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
326 |
"""Creates a user login entry in the database.
|
532
by mattgiuca
db.py: Augmented create_user. |
327 |
Two ways to call this - passing a user object, or passing
|
328 |
all fields as separate arguments.
|
|
329 |
||
330 |
Either pass a "user_obj" as the first argument (in which case other
|
|
331 |
fields will be ignored), or pass all fields as arguments.
|
|
332 |
||
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
333 |
All user fields are to be passed as args. The argument names
|
468
by mattgiuca
db.py: Epic Refactor. |
334 |
are the field names of the "login" table of the DB schema.
|
335 |
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 |
336 |
"password" argument, which will be hashed internally.
|
468
by mattgiuca
db.py: Epic Refactor. |
337 |
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 |
338 |
"no_agreement".
|
468
by mattgiuca
db.py: Epic Refactor. |
339 |
Raises an exception if the user already exists, or the dict contains
|
340 |
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" |
341 |
"""
|
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
342 |
if 'passhash' in kwargs: |
522
by drtomc
Add quite a lot of stuff to get usrmgt happening. |
343 |
raise DBException("Supplied arguments include passhash (invalid) (1).") |
468
by mattgiuca
db.py: Epic Refactor. |
344 |
# Make a copy of the dict. Change password to passhash (hashing it),
|
345 |
# and set 'state' to "no_agreement".
|
|
532
by mattgiuca
db.py: Augmented create_user. |
346 |
if user_obj is None: |
347 |
# Use the kwargs
|
|
348 |
fields = copy.copy(kwargs) |
|
349 |
else: |
|
350 |
# Use the user object
|
|
351 |
fields = dict(user_obj) |
|
352 |
if 'password' in fields: |
|
353 |
fields['passhash'] = _passhash(fields['password']) |
|
354 |
del fields['password'] |
|
355 |
if 'role' in fields: |
|
356 |
# Convert role to rolenm
|
|
357 |
fields['rolenm'] = str(user_obj.role) |
|
358 |
del fields['role'] |
|
359 |
if user_obj is None: |
|
360 |
fields['state'] = "no_agreement" |
|
361 |
# else, we'll trust the user, but it SHOULD be "no_agreement"
|
|
362 |
# (We can't change it because then the user object would not
|
|
363 |
# reflect the DB).
|
|
585
by drtomc
db: remove local_password before trying to create accounts. |
364 |
if 'local_password' in fields: |
365 |
del fields['local_password'] |
|
468
by mattgiuca
db.py: Epic Refactor. |
366 |
# Execute the query.
|
532
by mattgiuca
db.py: Augmented create_user. |
367 |
return self.insert(fields, "login", self.login_fields, dry=dry) |
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
368 |
|
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
369 |
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" |
370 |
"""Updates fields of a particular user. login is the name of the user
|
468
by mattgiuca
db.py: Epic Refactor. |
371 |
to update. The dict contains the fields which will be modified, and
|
372 |
their new values. If any value is omitted from the dict, it does not
|
|
373 |
get modified. login and studentid may not be modified.
|
|
374 |
Passhash may be modified by supplying a "password" field, in
|
|
375 |
cleartext, not a hashed password.
|
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
376 |
|
377 |
Note that no checking is done. It is expected this function is called
|
|
378 |
by a trusted source. In particular, it allows the password to be
|
|
379 |
changed without knowing the old password. The caller should check
|
|
380 |
that the user knows the existing password before calling this function
|
|
381 |
with a new one.
|
|
382 |
"""
|
|
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
383 |
if 'passhash' in kwargs: |
522
by drtomc
Add quite a lot of stuff to get usrmgt happening. |
384 |
raise DBException("Supplied arguments include passhash (invalid) (2).") |
469
by mattgiuca
db.py: Changed interface (again) to user management methods: Changed the dict |
385 |
if "password" in kwargs: |
386 |
kwargs = copy.copy(kwargs) |
|
387 |
kwargs['passhash'] = _passhash(kwargs['password']) |
|
388 |
del kwargs['password'] |
|
389 |
return self.update({"login": login}, kwargs, "login", |
|
390 |
self.login_fields, self.login_primary, ["login", "studentid"], |
|
391 |
dry=dry) |
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
392 |
|
393 |
def get_user(self, login, dry=False): |
|
500
by mattgiuca
db: get_user and get_users now return User objects instead of dictionaries. |
394 |
"""Given a login, returns a User object containing details looked up
|
395 |
in the DB.
|
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
396 |
|
397 |
Raises a DBException if the login is not found in the DB.
|
|
398 |
"""
|
|
500
by mattgiuca
db: get_user and get_users now return User objects instead of dictionaries. |
399 |
userdict = self.get_single({"login": login}, "login", |
575
by mattgiuca
common/usr, common/db: Added field to User, "local_password", |
400 |
self.login_fields, self.login_primary, |
468
by mattgiuca
db.py: Epic Refactor. |
401 |
error_notfound="get_user: No user with that login name", dry=dry) |
500
by mattgiuca
db: get_user and get_users now return User objects instead of dictionaries. |
402 |
if dry: |
403 |
return userdict # Query string |
|
404 |
# Package into a User object
|
|
405 |
return user.User(**userdict) |
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
406 |
|
428
by mattgiuca
common.db: Added get_users function to get all users. |
407 |
def get_users(self, dry=False): |
500
by mattgiuca
db: get_user and get_users now return User objects instead of dictionaries. |
408 |
"""Returns a list of all users in the DB, as User objects.
|
428
by mattgiuca
common.db: Added get_users function to get all users. |
409 |
"""
|
575
by mattgiuca
common/usr, common/db: Added field to User, "local_password", |
410 |
userdicts = self.get_all("login", self.login_fields, dry=dry) |
500
by mattgiuca
db: get_user and get_users now return User objects instead of dictionaries. |
411 |
if dry: |
412 |
return userdicts # Query string |
|
413 |
# Package into User objects
|
|
414 |
return [user.User(**userdict) for userdict in userdicts] |
|
428
by mattgiuca
common.db: Added get_users function to get all users. |
415 |
|
692
by mattgiuca
db.py: Added new methods for handling problems and problem attempts: |
416 |
def get_user_loginid(self, login, dry=False): |
417 |
"""Given a login, returns the integer loginid for this user.
|
|
418 |
||
419 |
Raises a DBException if the login is not found in the DB.
|
|
420 |
"""
|
|
421 |
userdict = self.get_single({"login": login}, "login", |
|
422 |
['loginid'], self.login_primary, |
|
423 |
error_notfound="get_user_loginid: No user with that login name", |
|
424 |
dry=dry) |
|
425 |
if dry: |
|
426 |
return userdict # Query string |
|
427 |
return userdict['loginid'] |
|
428 |
||
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
429 |
def user_authenticate(self, login, password, dry=False): |
430 |
"""Performs a password authentication on a user. Returns True if
|
|
466
by drtomc
db: Make the DB object self-closing. |
431 |
"passhash" is the correct passhash for the given login, False
|
509
by mattgiuca
common.db: Rewrote user_authenticate to return 3 values (True, false, None) |
432 |
if the passhash does not match the password in the DB,
|
433 |
and None if the passhash in the DB is NULL.
|
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
434 |
Also returns False if the login does not exist (so if you want to
|
435 |
differentiate these cases, use get_user and catch an exception).
|
|
436 |
"""
|
|
696
by mattgiuca
db: Fixed potential SQL injection (unlikely; you'd need a malicious login |
437 |
query = ("SELECT passhash FROM login WHERE login = %s;" |
438 |
% _escape(login)) |
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
439 |
if dry: return query |
440 |
result = self.db.query(query) |
|
509
by mattgiuca
common.db: Rewrote user_authenticate to return 3 values (True, false, None) |
441 |
if result.ntuples() == 1: |
442 |
# Valid username. Check password.
|
|
443 |
passhash = result.getresult()[0][0] |
|
444 |
if passhash is None: |
|
445 |
return None |
|
446 |
return _passhash(password) == passhash |
|
447 |
else: |
|
448 |
return False |
|
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
449 |
|
692
by mattgiuca
db.py: Added new methods for handling problems and problem attempts: |
450 |
# PROBLEM AND PROBLEM ATTEMPT FUNCTIONS #
|
451 |
||
452 |
def get_problem_problemid(self, exercisename, dry=False): |
|
453 |
"""Given an exercise name, returns the associated problemID.
|
|
693
by mattgiuca
db: get_problem_problemid now automatically inserts a new entry if the problem |
454 |
If the exercise name is NOT in the database, it inserts it and returns
|
455 |
the new problemID. Hence this may mutate the DB, but is idempotent.
|
|
692
by mattgiuca
db.py: Added new methods for handling problems and problem attempts: |
456 |
"""
|
693
by mattgiuca
db: get_problem_problemid now automatically inserts a new entry if the problem |
457 |
try: |
458 |
d = self.get_single({"identifier": exercisename}, "problem", |
|
459 |
['problemid'], frozenset(["identifier"]), |
|
460 |
dry=dry) |
|
461 |
if dry: |
|
462 |
return d # Query string |
|
463 |
except DBException: |
|
464 |
if dry: |
|
465 |
# Shouldn't try again, must have failed for some other reason
|
|
466 |
raise
|
|
467 |
# if we failed to get a problemid, it was probably because
|
|
468 |
# the exercise wasn't in the db. So lets insert it!
|
|
469 |
#
|
|
470 |
# The insert can fail if someone else simultaneously does
|
|
471 |
# the insert, so if the insert fails, we ignore the problem.
|
|
472 |
try: |
|
473 |
self.insert({'identifier': exercisename}, "problem", |
|
474 |
frozenset(['identifier'])) |
|
475 |
except Exception, e: |
|
476 |
pass
|
|
477 |
||
478 |
# Assuming the insert succeeded, we should be able to get the
|
|
479 |
# problemid now.
|
|
480 |
d = self.get_single({"identifier": exercisename}, "problem", |
|
481 |
['problemid'], frozenset(["identifier"])) |
|
482 |
||
692
by mattgiuca
db.py: Added new methods for handling problems and problem attempts: |
483 |
return d['problemid'] |
484 |
||
695
by mattgiuca
db: Added function get_problem_attempt_last_text: returns the last submitted |
485 |
def insert_problem_attempt(self, login, exercisename, date, complete, |
692
by mattgiuca
db.py: Added new methods for handling problems and problem attempts: |
486 |
attempt, dry=False): |
487 |
"""Inserts the details of a problem attempt into the database.
|
|
694
by mattgiuca
db: insert_problem_attempt now takes an exercisename and login name instead of |
488 |
exercisename: Name of the exercise. (identifier field of problem
|
489 |
table). If this exercise does not exist, also creates a new row in
|
|
490 |
the problem table for this exercise name.
|
|
491 |
login: Name of the user submitting the attempt. (login field of the
|
|
492 |
login table).
|
|
692
by mattgiuca
db.py: Added new methods for handling problems and problem attempts: |
493 |
date: struct_time, the date this attempt was made.
|
494 |
complete: bool. Whether the test passed or not.
|
|
495 |
attempt: Text of the attempt.
|
|
694
by mattgiuca
db: insert_problem_attempt now takes an exercisename and login name instead of |
496 |
|
497 |
Note: Even if dry, will still physically call get_problem_problemid,
|
|
498 |
which may mutate the DB, and get_user_loginid, which may fail.
|
|
692
by mattgiuca
db.py: Added new methods for handling problems and problem attempts: |
499 |
"""
|
694
by mattgiuca
db: insert_problem_attempt now takes an exercisename and login name instead of |
500 |
problemid = self.get_problem_problemid(exercisename) |
501 |
loginid = self.get_user_loginid(login) # May raise a DBException |
|
502 |
||
692
by mattgiuca
db.py: Added new methods for handling problems and problem attempts: |
503 |
return self.insert({ |
504 |
'problemid': problemid, |
|
505 |
'loginid': loginid, |
|
506 |
'date': date, |
|
507 |
'complete': complete, |
|
508 |
'attempt': attempt, |
|
509 |
}, 'problem_attempt', |
|
510 |
frozenset(['problemid','loginid','date','complete','attempt']), |
|
511 |
dry=dry) |
|
512 |
||
697
by mattgiuca
users.sql: Added database table problem_save, for storing exercises that are |
513 |
def write_problem_save(self, login, exercisename, date, text, dry=False): |
514 |
"""Writes text to the problem_save table (for when the user saves an
|
|
515 |
exercise). Creates a new row, or overwrites an existing one if the
|
|
516 |
user has already saved that problem.
|
|
517 |
(Unlike problem_attempt, does not keep historical records).
|
|
518 |
"""
|
|
519 |
problemid = self.get_problem_problemid(exercisename) |
|
520 |
loginid = self.get_user_loginid(login) # May raise a DBException |
|
521 |
||
522 |
try: |
|
523 |
return self.insert({ |
|
524 |
'problemid': problemid, |
|
525 |
'loginid': loginid, |
|
526 |
'date': date, |
|
527 |
'text': text, |
|
528 |
}, 'problem_save', |
|
529 |
frozenset(['problemid','loginid','date','text']), |
|
530 |
dry=dry) |
|
531 |
except pg.ProgrammingError: |
|
532 |
# May have failed because this problemid/loginid row already
|
|
533 |
# exists (they have a unique key constraint).
|
|
534 |
# Do an update instead.
|
|
535 |
if dry: |
|
536 |
# Shouldn't try again, must have failed for some other reason
|
|
537 |
raise
|
|
538 |
self.update({ |
|
539 |
'problemid': problemid, |
|
540 |
'loginid': loginid, |
|
541 |
},
|
|
542 |
{
|
|
543 |
'date': date, |
|
544 |
'text': text, |
|
545 |
}, "problem_save", |
|
546 |
frozenset(['date', 'text']), |
|
547 |
frozenset(['problemid', 'loginid'])) |
|
548 |
||
699
by mattgiuca
db.py: Replaced method "get_problem_attempt_last_text" with |
549 |
def get_problem_stored_text(self, login, exercisename, dry=False): |
695
by mattgiuca
db: Added function get_problem_attempt_last_text: returns the last submitted |
550 |
"""Given a login name and exercise name, returns the text of the
|
699
by mattgiuca
db.py: Replaced method "get_problem_attempt_last_text" with |
551 |
last saved/submitted attempt for this question.
|
552 |
Returns None if the user has not saved or made an attempt on this
|
|
553 |
problem.
|
|
554 |
(If the user has both saved and submitted, it returns whichever was
|
|
555 |
made last).
|
|
695
by mattgiuca
db: Added function get_problem_attempt_last_text: returns the last submitted |
556 |
|
557 |
Note: Even if dry, will still physically call get_problem_problemid,
|
|
558 |
which may mutate the DB, and get_user_loginid, which may fail.
|
|
559 |
"""
|
|
560 |
problemid = self.get_problem_problemid(exercisename) |
|
561 |
loginid = self.get_user_loginid(login) # May raise a DBException |
|
699
by mattgiuca
db.py: Replaced method "get_problem_attempt_last_text" with |
562 |
# This very complex query finds all submissions made by this user for
|
563 |
# this problem, as well as the save made by this user for this
|
|
564 |
# problem, and returns the text of the newest one.
|
|
565 |
# (Whichever is newer out of the save or the submit).
|
|
566 |
query = """SELECT text FROM |
|
567 |
(
|
|
568 |
(SELECT * FROM problem_save WHERE loginid = %d AND problemid = %d) |
|
569 |
UNION
|
|
570 |
(SELECT problemid, loginid, date, text FROM problem_attempt
|
|
571 |
AS problem_attempt (problemid, loginid, date, text)
|
|
572 |
WHERE loginid = %d AND problemid = %d) |
|
573 |
)
|
|
574 |
AS _
|
|
575 |
ORDER BY date DESC
|
|
576 |
LIMIT 1;""" % (loginid, problemid, loginid, problemid) |
|
695
by mattgiuca
db: Added function get_problem_attempt_last_text: returns the last submitted |
577 |
if dry: return query |
578 |
result = self.db.query(query) |
|
579 |
if result.ntuples() == 1: |
|
580 |
# The user has made at least 1 attempt. Return the newest.
|
|
581 |
return result.getresult()[0][0] |
|
582 |
else: |
|
583 |
return None |
|
584 |
||
704
by mattgiuca
db.py: Added get_problem_status, which returns a boolean, whether or not the |
585 |
def get_problem_status(self, login, exercisename, dry=False): |
586 |
"""Given a login name and exercise name, returns information about the
|
|
587 |
user's performance on that problem.
|
|
588 |
Returns a tuple of:
|
|
589 |
- A boolean, whether they have successfully passed this exercise.
|
|
590 |
- An int, the number of attempts they have made up to and
|
|
591 |
including the first successful attempt (or the total number of
|
|
592 |
attempts, if not yet successful).
|
|
730
by mattgiuca
Added per-worksheet and per-subject score calculation. |
593 |
Note: exercisename may be an int, in which case it will be directly
|
594 |
used as the problemid.
|
|
704
by mattgiuca
db.py: Added get_problem_status, which returns a boolean, whether or not the |
595 |
"""
|
730
by mattgiuca
Added per-worksheet and per-subject score calculation. |
596 |
if isinstance(exercisename, int): |
597 |
problemid = exercisename |
|
598 |
else: |
|
599 |
problemid = self.get_problem_problemid(exercisename) |
|
704
by mattgiuca
db.py: Added get_problem_status, which returns a boolean, whether or not the |
600 |
loginid = self.get_user_loginid(login) # May raise a DBException |
601 |
||
602 |
# ASSUME that it is completed, get the total number of attempts up to
|
|
603 |
# and including the first successful attempt.
|
|
604 |
# (Get the date of the first successful attempt. Then count the number
|
|
605 |
# of attempts made <= that date).
|
|
606 |
# Will return an empty table if the problem has never been
|
|
607 |
# successfully completed.
|
|
608 |
query = """SELECT COUNT(*) FROM problem_attempt |
|
609 |
WHERE loginid = %d AND problemid = %d AND date <= |
|
610 |
(SELECT date FROM problem_attempt
|
|
611 |
WHERE loginid = %d AND problemid = %d AND complete = TRUE |
|
612 |
ORDER BY date ASC
|
|
613 |
LIMIT 1);""" % (loginid, problemid, loginid, problemid) |
|
614 |
if dry: return query |
|
615 |
result = self.db.query(query) |
|
616 |
count = int(result.getresult()[0][0]) |
|
617 |
if count > 0: |
|
618 |
# The user has made at least 1 successful attempt.
|
|
619 |
# Return True for success, and the number of attempts up to and
|
|
620 |
# including the successful one.
|
|
621 |
return (True, count) |
|
622 |
else: |
|
623 |
# Returned 0 rows - this indicates that the problem has not been
|
|
624 |
# completed.
|
|
625 |
# Return the total number of attempts, and False for success.
|
|
626 |
query = """SELECT COUNT(*) FROM problem_attempt |
|
627 |
WHERE loginid = %d AND problemid = %d;""" % (loginid, problemid) |
|
628 |
result = self.db.query(query) |
|
629 |
count = int(result.getresult()[0][0]) |
|
630 |
return (False, count) |
|
631 |
||
725
by mattgiuca
The database now stores a cache of all the worksheets and what problems |
632 |
# WORKSHEET/PROBLEM ASSOCIATION AND MARKS CALCULATION
|
633 |
||
634 |
def get_worksheet_mtime(self, subject, worksheet, dry=False): |
|
635 |
"""
|
|
636 |
For a given subject/worksheet name, gets the time the worksheet was
|
|
637 |
last updated in the DB, if any.
|
|
638 |
This can be used to check if there is a newer version on disk.
|
|
639 |
Returns the timestamp as a time.struct_time, or None if the worksheet
|
|
640 |
is not found or has no stored mtime.
|
|
641 |
"""
|
|
642 |
try: |
|
643 |
r = self.get_single( |
|
644 |
{"subject": subject, "identifier": worksheet}, |
|
645 |
"worksheet", ["mtime"], ["subject", "identifier"], |
|
646 |
dry=dry) |
|
647 |
except DBException: |
|
648 |
# Assume the worksheet is not in the DB
|
|
649 |
return None |
|
650 |
if dry: |
|
651 |
return r |
|
652 |
if r["mtime"] is None: |
|
653 |
return None |
|
654 |
return time.strptime(r["mtime"], TIMESTAMP_FORMAT) |
|
655 |
||
732
by mattgiuca
db/tutorial refactoring: |
656 |
def create_worksheet(self, subject, worksheet, problems=None, |
657 |
assessable=None): |
|
725
by mattgiuca
The database now stores a cache of all the worksheets and what problems |
658 |
"""
|
659 |
Inserts or updates rows in the worksheet and worksheet_problems
|
|
660 |
tables, to create a worksheet in the database.
|
|
661 |
This atomically performs all operations. If the worksheet is already
|
|
662 |
in the DB, removes it and all its associated problems and rebuilds.
|
|
663 |
Sets the timestamp to the current time.
|
|
664 |
||
665 |
problems is a collection of pairs. The first element of the pair is
|
|
666 |
the problem identifier ("identifier" column of the problem table). The
|
|
667 |
second element is an optional boolean, "optional". This can be omitted
|
|
668 |
(so it's a 1-tuple), and then it will default to False.
|
|
669 |
||
732
by mattgiuca
db/tutorial refactoring: |
670 |
Problems and assessable are optional, and if omitted, will not change
|
671 |
the existing data. If the worksheet does not yet exist, and assessable
|
|
672 |
is omitted, it defaults to False.
|
|
673 |
||
725
by mattgiuca
The database now stores a cache of all the worksheets and what problems |
674 |
Note: As with get_problem_problemid, if a problem name is not in the
|
675 |
DB, it will be added to the problem table.
|
|
676 |
"""
|
|
677 |
self.start_transaction() |
|
678 |
try: |
|
679 |
# Use the current time as the "mtime" field
|
|
680 |
mtime = time.localtime() |
|
681 |
try: |
|
682 |
# Get the worksheetid
|
|
683 |
r = self.get_single( |
|
684 |
{"subject": subject, "identifier": worksheet}, |
|
685 |
"worksheet", ["worksheetid"], ["subject", "identifier"]) |
|
686 |
worksheetid = r["worksheetid"] |
|
687 |
||
732
by mattgiuca
db/tutorial refactoring: |
688 |
# Delete any problems which might exist, if problems is
|
689 |
# supplied. If it isn't, keep the existing ones.
|
|
690 |
if problems is not None: |
|
691 |
query = ("DELETE FROM worksheet_problem " |
|
692 |
"WHERE worksheetid = %d;" % worksheetid) |
|
693 |
self.db.query(query) |
|
725
by mattgiuca
The database now stores a cache of all the worksheets and what problems |
694 |
# Update the row with the new details
|
732
by mattgiuca
db/tutorial refactoring: |
695 |
if assessable is None: |
696 |
query = ("UPDATE worksheet " |
|
697 |
"SET mtime = %s WHERE worksheetid = %d;" |
|
698 |
% (_escape(mtime), worksheetid)) |
|
699 |
else: |
|
700 |
query = ("UPDATE worksheet " |
|
701 |
"SET assessable = %s, mtime = %s " |
|
702 |
"WHERE worksheetid = %d;" |
|
703 |
% (_escape(assessable), _escape(mtime), worksheetid)) |
|
725
by mattgiuca
The database now stores a cache of all the worksheets and what problems |
704 |
self.db.query(query) |
705 |
except DBException: |
|
706 |
# Assume the worksheet is not in the DB
|
|
732
by mattgiuca
db/tutorial refactoring: |
707 |
# If assessable is not supplied, default to False.
|
708 |
if assessable is None: |
|
709 |
assessable = False |
|
725
by mattgiuca
The database now stores a cache of all the worksheets and what problems |
710 |
# Create the worksheet row
|
711 |
query = ("INSERT INTO worksheet " |
|
712 |
"(subject, identifier, assessable, mtime) "
|
|
713 |
"VALUES (%s, %s, %s, %s);""" |
|
714 |
% (_escape(subject), _escape(worksheet), |
|
715 |
_escape(assessable), _escape(mtime))) |
|
716 |
self.db.query(query) |
|
717 |
# Now get the worksheetid again - should succeed
|
|
718 |
r = self.get_single( |
|
719 |
{"subject": subject, "identifier": worksheet}, |
|
720 |
"worksheet", ["worksheetid"], ["subject", "identifier"]) |
|
721 |
worksheetid = r["worksheetid"] |
|
722 |
||
723 |
# Now insert each problem into the worksheet_problem table
|
|
732
by mattgiuca
db/tutorial refactoring: |
724 |
if problems is not None: |
725 |
for problem in problems: |
|
726 |
if isinstance(problem, tuple): |
|
727 |
prob_identifier = problem[0] |
|
728 |
try: |
|
729 |
optional = problem[1] |
|
730 |
except IndexError: |
|
731 |
optional = False |
|
732 |
else: |
|
733 |
prob_identifier = problem |
|
725
by mattgiuca
The database now stores a cache of all the worksheets and what problems |
734 |
optional = False |
732
by mattgiuca
db/tutorial refactoring: |
735 |
problemid = self.get_problem_problemid(prob_identifier) |
736 |
query = ("INSERT INTO worksheet_problem " |
|
737 |
"(worksheetid, problemid, optional) "
|
|
738 |
"VALUES (%d, %d, %s);" |
|
739 |
% (worksheetid, problemid, _escape(optional))) |
|
740 |
self.db.query(query) |
|
725
by mattgiuca
The database now stores a cache of all the worksheets and what problems |
741 |
|
742 |
self.commit() |
|
743 |
except: |
|
744 |
self.rollback() |
|
745 |
raise
|
|
746 |
||
733
by mattgiuca
db.py: Added new function set_worksheet_assessable. |
747 |
def set_worksheet_assessable(self, subject, worksheet, assessable, |
748 |
dry=False): |
|
749 |
"""
|
|
750 |
Sets the "assessable" field of a worksheet without updating the mtime.
|
|
751 |
||
752 |
IMPORTANT: This will NOT update the mtime. This is designed to allow
|
|
753 |
updates which did not come from the worksheet XML file. It would be
|
|
754 |
bad to update the mtime without consulting the XML file because then
|
|
755 |
it would appear the database is up to date, when it isn't.
|
|
756 |
||
757 |
Therefore, call this method if you are getting "assessable"
|
|
758 |
information from outside the worksheet XML file (eg. from the subject
|
|
759 |
XML file).
|
|
760 |
||
761 |
Unlike create_worksheet, raises a DBException if the worksheet is not
|
|
762 |
in the database.
|
|
763 |
"""
|
|
764 |
return self.update({"subject": subject, "identifier": worksheet}, |
|
765 |
{"assessable": assessable}, "worksheet", ["assessable"], |
|
766 |
["subject", "identifier"], dry=dry) |
|
767 |
||
730
by mattgiuca
Added per-worksheet and per-subject score calculation. |
768 |
def worksheet_is_assessable(self, subject, worksheet, dry=False): |
769 |
r = self.get_single( |
|
770 |
{"subject": subject, "identifier": worksheet}, |
|
771 |
"worksheet", ["assessable"], ["subject", "identifier"], dry=dry) |
|
772 |
return _parse_boolean(r["assessable"]) |
|
773 |
||
774 |
def calculate_score_worksheet(self, login, subject, worksheet): |
|
775 |
"""
|
|
776 |
Calculates the score for a user on a given worksheet.
|
|
777 |
Returns a 4-tuple of ints, consisting of:
|
|
778 |
(No. mandatory exercises completed,
|
|
779 |
Total no. mandatory exercises,
|
|
780 |
No. optional exercises completed,
|
|
781 |
Total no. optional exercises)
|
|
782 |
"""
|
|
783 |
self.start_transaction() |
|
784 |
try: |
|
785 |
mand_done = 0 |
|
786 |
mand_total = 0 |
|
787 |
opt_done = 0 |
|
788 |
opt_total = 0 |
|
789 |
# Get a list of problems and optionality for all problems in the
|
|
790 |
# worksheet
|
|
791 |
query = ("""SELECT problemid, optional FROM worksheet_problem |
|
792 |
WHERE worksheetid = (SELECT worksheetid FROM worksheet
|
|
793 |
WHERE subject = %s and identifier = %s);""" |
|
794 |
% (_escape(subject), _escape(worksheet))) |
|
795 |
result = self.db.query(query) |
|
796 |
# Now get the student's pass/fail for each problem in this worksheet
|
|
797 |
for problemid, optional in result.getresult(): |
|
798 |
done, _ = self.get_problem_status(login, problemid) |
|
799 |
# done is a bool, whether this student has completed that
|
|
800 |
# problem
|
|
801 |
if _parse_boolean(optional): |
|
802 |
opt_total += 1 |
|
803 |
if done: opt_done += 1 |
|
804 |
else: |
|
805 |
mand_total += 1 |
|
806 |
if done: mand_done += 1 |
|
807 |
self.commit() |
|
808 |
except: |
|
809 |
self.rollback() |
|
810 |
raise
|
|
811 |
return mand_done, mand_total, opt_done, opt_total |
|
812 |
||
926
by mattgiuca
db: Added get_enrolment and get_groups_by_user methods to retrieve details |
813 |
# ENROLMENT INFORMATION
|
814 |
||
876
by mattgiuca
common/db.py: Added add_enrolment method. |
815 |
def add_enrolment(self, login, subj_code, semester, year=None, dry=False): |
816 |
"""
|
|
817 |
Enrol a student in the given offering of a subject.
|
|
818 |
Returns True on success, False on failure (which usually means either
|
|
819 |
the student is already enrolled in the subject, the student was not
|
|
820 |
found, or no offering existed with the given details).
|
|
821 |
The return value can usually be ignored.
|
|
822 |
"""
|
|
823 |
subj_code = str(subj_code) |
|
824 |
semester = str(semester) |
|
825 |
if year is None: |
|
826 |
year = str(time.gmtime().tm_year) |
|
827 |
else: |
|
828 |
year = str(year) |
|
829 |
query = """\ |
|
830 |
INSERT INTO enrolment (loginid, offeringid)
|
|
831 |
VALUES (
|
|
832 |
(SELECT loginid FROM login WHERE login=%s), |
|
833 |
(SELECT offeringid
|
|
834 |
FROM (offering INNER JOIN subject
|
|
935
by wagrant
userdb: Large changes: |
835 |
ON subject.subjectid = offering.subject
|
836 |
INNER JOIN semester
|
|
837 |
ON semester.semesterid = offering.semesterid)
|
|
876
by mattgiuca
common/db.py: Added add_enrolment method. |
838 |
WHERE subj_code=%s AND semester=%s AND year=%s) |
839 |
);""" % (_escape(login), _escape(subj_code), _escape(semester), |
|
840 |
_escape(year)) |
|
841 |
if dry: |
|
842 |
return query |
|
843 |
try: |
|
844 |
result = self.db.query(query) |
|
845 |
except pg.ProgrammingError: |
|
846 |
return False |
|
847 |
return True |
|
848 |
||
930
by dcoles
Userservice: Added get_enrolments handler to allow a JSON query of a students |
849 |
# SUBJECTS AND ENROLEMENT
|
850 |
||
926
by mattgiuca
db: Added get_enrolment and get_groups_by_user methods to retrieve details |
851 |
def get_enrolment(self, login, dry=False): |
852 |
"""
|
|
853 |
Get all subjects (in IVLE) the student is enrolled in.
|
|
854 |
Returns a list of tuples (all elements strings):
|
|
855 |
(offeringid, subj_code, subj_name, subj_short_name, year, semester).
|
|
856 |
"""
|
|
857 |
query = """\ |
|
858 |
SELECT offering.offeringid, subj_code, subj_name, subj_short_name,
|
|
935
by wagrant
userdb: Large changes: |
859 |
semester.year, semester.semester
|
860 |
FROM login, enrolment, offering, subject, semester
|
|
926
by mattgiuca
db: Added get_enrolment and get_groups_by_user methods to retrieve details |
861 |
WHERE enrolment.offeringid=offering.offeringid
|
862 |
AND login.loginid=enrolment.loginid
|
|
863 |
AND offering.subject=subject.subjectid
|
|
935
by wagrant
userdb: Large changes: |
864 |
AND semester.semesterid=offering.semesterid
|
865 |
AND enrolment.active
|
|
926
by mattgiuca
db: Added get_enrolment and get_groups_by_user methods to retrieve details |
866 |
AND login=%s;""" % _escape(login) |
867 |
if dry: |
|
868 |
return query |
|
930
by dcoles
Userservice: Added get_enrolments handler to allow a JSON query of a students |
869 |
return self.db.query(query).dictresult() |
926
by mattgiuca
db: Added get_enrolment and get_groups_by_user methods to retrieve details |
870 |
|
871 |
# PROJECT GROUPS
|
|
872 |
||
873 |
def get_groups_by_user(self, login, offeringid=None, dry=False): |
|
874 |
"""
|
|
875 |
Get all project groups the student is in, corresponding to a
|
|
876 |
particular subject offering (or all offerings, if omitted).
|
|
877 |
Returns a list of tuples:
|
|
878 |
(int groupid, str groupnm, str group_nick, bool is_member).
|
|
879 |
(Note: If is_member is false, it means they have just been invited to
|
|
880 |
this group, not a member).
|
|
881 |
"""
|
|
882 |
if offeringid is None: |
|
883 |
and_offering = "" |
|
884 |
else: |
|
935
by wagrant
userdb: Large changes: |
885 |
and_projectset_table = ", project_set" |
886 |
and_offering = """ |
|
887 |
AND project_group.projectsetid = project_set.projectsetid
|
|
888 |
AND project_set.offeringid = %s""" % _escape(offeringid) |
|
926
by mattgiuca
db: Added get_enrolment and get_groups_by_user methods to retrieve details |
889 |
# Union both the groups this user is a member of, and the groups this
|
890 |
# user is invited to.
|
|
891 |
query = """\ |
|
892 |
SELECT project_group.groupid, groupnm, project_group.nick, True
|
|
935
by wagrant
userdb: Large changes: |
893 |
FROM project_group, group_member, login %(and_projectset_table)s |
926
by mattgiuca
db: Added get_enrolment and get_groups_by_user methods to retrieve details |
894 |
WHERE project_group.groupid = group_member.groupid
|
895 |
AND group_member.loginid = login.loginid
|
|
896 |
AND login = %(login)s |
|
897 |
%(and_offering)s |
|
898 |
UNION
|
|
899 |
SELECT project_group.groupid, groupnm, project_group.nick, False
|
|
935
by wagrant
userdb: Large changes: |
900 |
FROM project_group, group_invitation, login %(and_projectset_table)s |
926
by mattgiuca
db: Added get_enrolment and get_groups_by_user methods to retrieve details |
901 |
WHERE project_group.groupid = group_invitation.groupid
|
902 |
AND group_invitation.loginid = login.loginid
|
|
903 |
AND login = %(login)s |
|
904 |
%(and_offering)s |
|
935
by wagrant
userdb: Large changes: |
905 |
;""" % {"login": _escape(login), "and_offering": and_offering, |
906 |
"and_projectset_table": and_projectset_table} |
|
926
by mattgiuca
db: Added get_enrolment and get_groups_by_user methods to retrieve details |
907 |
if dry: |
908 |
return query |
|
909 |
# Convert 't' -> True, 'f' -> False
|
|
910 |
return [(groupid, groupnm, nick, ismember == 't') |
|
911 |
for groupid, groupnm, nick, ismember |
|
912 |
in self.db.query(query).getresult()] |
|
913 |
||
409
by mattgiuca
Moved www/conf and www/common to a new directory lib. This separates the "web" |
914 |
def close(self): |
915 |
"""Close the DB connection. Do not call any other functions after
|
|
916 |
this. (The behaviour of doing so is undefined).
|
|
917 |
"""
|
|
918 |
self.db.close() |
|
466
by drtomc
db: Make the DB object self-closing. |
919 |
self.open = False |