chiark / gitweb /
Initial commit.
[chopwood] / cmdutil.py
CommitLineData
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
26from __future__ import with_statement
27
28import dbmaint as D
29import format as F
30import operation as OP
31import output as O
32import service as S
33import util as U
34
35def 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
52def 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
59def 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
102def 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
133def 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
143def 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
164class acctinfo (U.struct):
165 """Information about an account, returned by `list_accounts'."""
166 __slots__ = ['service', 'friendly', 'alias']
167
168def 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
181class userinfo (U.struct):
182 """Information about a user, returned by `list_uesrs'."""
183 __slots__ = ['user', 'email']
184
185def 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
215class 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
220def 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
253def 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 --------------------------------------------------