Commit | Line | Data |
---|---|---|
a2916c06 MW |
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 -------------------------------------------------- |