| 1 | ### -*-python-*- |
| 2 | ### |
| 3 | ### Utilities for the various commands |
| 4 | ### |
| 5 | ### (c) 2013 Mark Wooding |
| 6 | ### |
| 7 | |
| 8 | ###----- Licensing notice --------------------------------------------------- |
| 9 | ### |
| 10 | ### This file is part of Chopwood: a password-changing service. |
| 11 | ### |
| 12 | ### Chopwood is free software; you can redistribute it and/or modify |
| 13 | ### it under the terms of the GNU Affero General Public License as |
| 14 | ### published by the Free Software Foundation; either version 3 of the |
| 15 | ### License, or (at your option) any later version. |
| 16 | ### |
| 17 | ### Chopwood is distributed in the hope that it will be useful, |
| 18 | ### but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 19 | ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 20 | ### GNU Affero General Public License for more details. |
| 21 | ### |
| 22 | ### You should have received a copy of the GNU Affero General Public |
| 23 | ### License along with Chopwood; if not, see |
| 24 | ### <http://www.gnu.org/licenses/>. |
| 25 | |
| 26 | from __future__ import with_statement |
| 27 | |
| 28 | import dbmaint as D |
| 29 | import format as F |
| 30 | import operation as OP |
| 31 | import output as O |
| 32 | import service as S |
| 33 | import util as U |
| 34 | |
| 35 | def check_user(user, must_exist_p = True): |
| 36 | """ |
| 37 | Check the existence state of the USER. |
| 38 | |
| 39 | If MUST_EXIST_P is true (the default), ensure that USER exists; otherwise, |
| 40 | ensure that it does not. Raise an appropriate `ExpectedError' if the check |
| 41 | fails. |
| 42 | """ |
| 43 | |
| 44 | D.DB.execute("SELECT 1 FROM users WHERE user = $user", user = user) |
| 45 | existsp = D.DB.fetchone() is not None |
| 46 | |
| 47 | if must_exist_p and not existsp: |
| 48 | raise U.ExpectedError, (400, "Unknown user `%s'" % user) |
| 49 | elif not must_exist_p and existsp: |
| 50 | raise U.ExpectedError, (400, "User `%s' already exists" % user) |
| 51 | |
| 52 | def set_user(u): |
| 53 | """Check that U is a known user, and, if so, store it in `USER'.""" |
| 54 | global USER |
| 55 | D.opendb() |
| 56 | check_user(u) |
| 57 | USER = u |
| 58 | |
| 59 | def check_service(service, must_config_p = True, must_records_p = None): |
| 60 | """ |
| 61 | Check the existence state of the SERVICE. |
| 62 | |
| 63 | If MUST_CONFIG_P is true (the default), ensure that the service is |
| 64 | configured, i.e., there is an entry in the `SERVICES' dictionary; if false, |
| 65 | ensure tht it is not configured; if `None', then don't care either way. |
| 66 | |
| 67 | Similarly, if MUST_RECORDS_P is true, ensure that there is at least one |
| 68 | account defined for the service in the database; if false, ensure that |
| 69 | there are no accounts; and if `None' (the default), then don't care either |
| 70 | way. |
| 71 | |
| 72 | Raise an appropriate `ExpectedError' if the check fails. The return value |
| 73 | on successful completion is the service object, or `None' if it is not |
| 74 | configured. |
| 75 | """ |
| 76 | |
| 77 | try: svc = S.SERVICES[service] |
| 78 | except KeyError: svc = None |
| 79 | |
| 80 | ## Check whether the service is configured. |
| 81 | if must_config_p is not None: |
| 82 | if must_config_p and not svc: |
| 83 | raise U.ExpectedError, (400, "Unknown service `%s'" % service) |
| 84 | elif not must_config_p and svc: |
| 85 | raise U.ExpectedError, \ |
| 86 | (400, "Service `%s' is still configured" % service) |
| 87 | |
| 88 | ## Check whether the service has any accounts. |
| 89 | if must_records_p is not None: |
| 90 | D.DB.execute("SELECT 1 FROM services WHERE service = $service", |
| 91 | service = service) |
| 92 | recordsp = D.DB.fetchone() is not None |
| 93 | if must_records_p and not recordsp: |
| 94 | raise U.ExpectedError, (400, "Service `%s' is unused" % service) |
| 95 | elif not must_records_p and recordsp: |
| 96 | raise U.ExpectedError, \ |
| 97 | (400, "Service `%s' is already in use" % service) |
| 98 | |
| 99 | ## Done. |
| 100 | return svc |
| 101 | |
| 102 | def resolve_accounts(user, services): |
| 103 | """ |
| 104 | Resolve multiple accounts, returning a list of `acct' objects. |
| 105 | """ |
| 106 | |
| 107 | ## Make sure the user actually exists. |
| 108 | check_user(user) |
| 109 | |
| 110 | ## Work through the list of services. |
| 111 | accts = [] |
| 112 | for service in services: |
| 113 | svc = check_service(service) |
| 114 | |
| 115 | ## Find the account record from the services table. |
| 116 | with D.DB: |
| 117 | D.DB.execute("""SELECT alias FROM services |
| 118 | WHERE user = $user AND service = $service""", |
| 119 | user = user, service = service) |
| 120 | row = D.DB.fetchone() |
| 121 | if row is None: |
| 122 | raise U.ExpectedError, \ |
| 123 | (400, "No `%s' account for `%s'" % (service, user)) |
| 124 | |
| 125 | ## Pick the result apart and extend the list. |
| 126 | alias, = row |
| 127 | if alias is None: alias = user |
| 128 | accts.append(OP.acct(svc, alias)) |
| 129 | |
| 130 | ## Done. |
| 131 | return accts |
| 132 | |
| 133 | def resolve_account(service, user): |
| 134 | """ |
| 135 | Resolve a pair of SERVICE and USER names, and return a pair (SVC, ALIAS) of |
| 136 | the (local or remote) service object, and the USER's alias for the service. |
| 137 | Raise an appropriate `ExpectedError' if the service or user don't exist. |
| 138 | """ |
| 139 | |
| 140 | acct, = resolve_accounts(user, [service]) |
| 141 | return acct.svc, acct.user |
| 142 | |
| 143 | def matching_items(want, tab, cond = [], tail = '', **kw): |
| 144 | """ |
| 145 | Generate the matching items from a query constructed dynamically. |
| 146 | |
| 147 | Usually you wouldn't go through this palaver for a static query, but his |
| 148 | function helps with building queries in pieces. WANT should be a list of |
| 149 | column names we should output, appropriately qualified if there are |
| 150 | multiple tables; TAB should be a list of table names, in the form `FOO as |
| 151 | F' if aliases are wanted; COND should be a list of SQL expressions all of |
| 152 | which the generated records must satisfy; TAIL should be a string |
| 153 | containing any other bits of the query wanted; and the remaining keyword |
| 154 | arguments are made available to the query conditions via `$KEY' |
| 155 | placeholders. |
| 156 | """ |
| 157 | for row in D.DB.execute("SELECT %s FROM %s %s %s" % |
| 158 | (', '.join(want), ', '.join(tab), |
| 159 | cond and "WHERE " + " AND ".join(cond) or "", |
| 160 | tail), |
| 161 | **kw): |
| 162 | yield row |
| 163 | |
| 164 | class acctinfo (U.struct): |
| 165 | """Information about an account, returned by `list_accounts'.""" |
| 166 | __slots__ = ['service', 'friendly', 'alias'] |
| 167 | |
| 168 | def list_accounts(user): |
| 169 | """ |
| 170 | Return a list of `acctinfo' objets representing the USER's accounts. |
| 171 | """ |
| 172 | def friendly_name(service): |
| 173 | try: return S.SERVICES[service].friendly |
| 174 | except KeyError: return "<unknown service `%s'>" % service |
| 175 | return [acctinfo(service, friendly_name(service), alias) |
| 176 | for service, alias in |
| 177 | matching_items(['service', 'alias'], ['services'], |
| 178 | ['user = $user'], "ORDER BY service", |
| 179 | user = user)] |
| 180 | |
| 181 | class userinfo (U.struct): |
| 182 | """Information about a user, returned by `list_uesrs'.""" |
| 183 | __slots__ = ['user', 'email'] |
| 184 | |
| 185 | def list_users(service = None, pat = None): |
| 186 | """ |
| 187 | Return a list of `userinfo' objects for the matching users. |
| 188 | |
| 189 | If SERVICE is given, return only users who have accounts for that service. |
| 190 | If PAT is given, it should be a glob-like pattern; return only users whose |
| 191 | names match it. |
| 192 | """ |
| 193 | |
| 194 | ## Basic pieces of the query. |
| 195 | kw = {} |
| 196 | tab = ['users AS u'] |
| 197 | cond = [] |
| 198 | |
| 199 | ## Restrict according to the services. |
| 200 | if service is not None: |
| 201 | tab.append('services AS s') |
| 202 | cond.append('u.user = s.user AND s.service = $service') |
| 203 | kw['service'] = service |
| 204 | |
| 205 | ## Restrict according to the user name. |
| 206 | if pat is not None: |
| 207 | cond.append("u.user LIKE $pat ESCAPE '\\'") |
| 208 | kw['pat'] = U.globtolike(pat) |
| 209 | |
| 210 | ## Build and return the list. |
| 211 | return [userinfo(user, email) for user, email in |
| 212 | matching_items(['u.user', 'u.email'], |
| 213 | tab, cond, "ORDER BY u.user", **kw)] |
| 214 | |
| 215 | class column (U.struct): |
| 216 | """Description of a column, to be passed to `format_list'.""" |
| 217 | __slots__ = ['head', 'format', 'width'] |
| 218 | DEFAULTS = dict(width = 0) |
| 219 | |
| 220 | def format_list(items, columns): |
| 221 | """ |
| 222 | Present the ITEMS in tabular form on the current output. |
| 223 | |
| 224 | The COLUMNS are a list of `column' objects, describing the columns in the |
| 225 | table to be written: the `head' slot gives a string to be printed in the |
| 226 | first line; the `format' slot gives a `format' string to produce the text |
| 227 | for a given item, provided as the positional argument, in that column; and |
| 228 | `width' gives the minimum width for the column, in characters. Note that |
| 229 | the column may be wider than requested. |
| 230 | """ |
| 231 | |
| 232 | ## First pass: format the items and work out the actual column widths. |
| 233 | n = len(columns) |
| 234 | wd = [c.width for c in columns] |
| 235 | cells = [] |
| 236 | def addrow(row): |
| 237 | for i in xrange(n): |
| 238 | if len(row[i]) > wd[i]: |
| 239 | wd[i] = len(row[i]) |
| 240 | cells.append(row) |
| 241 | addrow([c.head for c in columns]) |
| 242 | for i in items: |
| 243 | addrow([F.format(None, c.format, i) for c in columns]) |
| 244 | |
| 245 | ## Second pass: print the table. We've already formatted the items, but we |
| 246 | ## need to set the column widths, so do that by compiling a formatter. |
| 247 | ## Note that the width of the last column is irrelevant: in this way, we |
| 248 | ## suppress trailing spaces. |
| 249 | fmt = F.compile(F.format(None, "~{~#[~;~~~*A~:;~~~DA~]~^ ~}~~%", wd)) |
| 250 | for row in cells: |
| 251 | F.format(O.OUT, fmt, *row) |
| 252 | |
| 253 | def edit_records(table, cond, edits, **kw): |
| 254 | """ |
| 255 | Edit some database records. |
| 256 | |
| 257 | This function modifies one or more records in TABLE (which, I suppose, |
| 258 | could actually be a join of multiple tables), specifically the ones which |
| 259 | match COND (with $TAG placeholders filled in from the keyword arguments), |
| 260 | according to EDITS. |
| 261 | |
| 262 | EDITS is a list of tuples of the form (FIELD, VALUE, NULLP): FIELD names a |
| 263 | field to be modified: VALUE, if it is not `None', is the new value to set; |
| 264 | if NULLP is true, then set the field to SQL `NULL'. If both actions are |
| 265 | requested then raise an exception. |
| 266 | |
| 267 | Exceptions are also raised if there are no operations to perform, or if |
| 268 | there are no records which match the condition. |
| 269 | """ |
| 270 | |
| 271 | ## We'll build up the query string in pieces. |
| 272 | d = dict(kw) |
| 273 | ops = [] |
| 274 | q = 0 |
| 275 | |
| 276 | ## Work through the edits, building up the pieces of the query. |
| 277 | for field, value, nullp in edits: |
| 278 | if value is not None and nullp: |
| 279 | raise U.ExpectedError, (400, "Can't set and clear `%s' field" % field) |
| 280 | elif nullp: |
| 281 | ops.append('%s = NULL' % field) |
| 282 | elif value is not None: |
| 283 | tag = 't%d' % q |
| 284 | q += 1 |
| 285 | ops.append('%s = $%s' % (field, tag)) |
| 286 | d[tag] = value |
| 287 | |
| 288 | ## If there are no changes to be made, then we're done. |
| 289 | if not ops: raise U.ExpectedError, (400, 'Nothing to do') |
| 290 | |
| 291 | ## See whether the query actually matches any records at all. |
| 292 | D.DB.execute("SELECT 1 FROM %s WHERE %s" % (table, cond), **d) |
| 293 | if D.DB.fetchone() is None: |
| 294 | raise U.ExpectedError, (400, 'No records to edit') |
| 295 | |
| 296 | ## Go ahead and make the changes. |
| 297 | D.DB.execute("UPDATE %s SET %s WHERE %s" % (table, ', '.join(ops), cond), |
| 298 | **d) |
| 299 | |
| 300 | ###----- That's all, folks -------------------------------------------------- |