chiark / gitweb /
*.fhtml: Use double quotes for HTML attributes.
[chopwood] / cmdutil.py
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 --------------------------------------------------