- fix recursive import problem
[plcapi.git] / PLC / Persons.py
1 #
2 # Functions for interacting with the persons table in the database
3 #
4 # Mark Huang <mlhuang@cs.princeton.edu>
5 # Copyright (C) 2006 The Trustees of Princeton University
6 #
7 # $Id: Persons.py,v 1.1 2006/09/06 15:36:07 mlhuang Exp $
8 #
9
10 from types import StringTypes
11 from datetime import datetime
12 import md5
13 import time
14 from random import Random
15 import re
16
17 from PLC.Faults import *
18 from PLC.Parameter import Parameter
19 from PLC.Debug import profile
20 from PLC.Table import Row, Table
21 from PLC.Roles import Roles
22 from PLC.Addresses import Address, Addresses
23 from PLC.Keys import Key, Keys
24 from PLC import md5crypt
25 import PLC.Sites
26
27 class Person(Row):
28     """
29     Representation of a row in the persons table. To use, optionally
30     instantiate with a dict of values. Update as you would a
31     dict. Commit to the database with flush().
32     """
33
34     fields = {
35         'person_id': Parameter(int, "Account identifier"),
36         'first_name': Parameter(str, "Given name"),
37         'last_name': Parameter(str, "Surname"),
38         'title': Parameter(str, "Title"),
39         'email': Parameter(str, "Primary e-mail address"),
40         'phone': Parameter(str, "Telephone number"),
41         'url': Parameter(str, "Home page"),
42         'bio': Parameter(str, "Biography"),
43         'accepted_aup': Parameter(bool, "Has accepted the AUP"),
44         'enabled': Parameter(bool, "Has been enabled"),
45         'deleted': Parameter(bool, "Has been deleted"),
46         'password': Parameter(str, "Account password in crypt() form"),
47         'last_updated': Parameter(str, "Date and time of last update"),
48         'date_created': Parameter(str, "Date and time when account was created"),
49         }
50
51     # These fields are derived from join tables and are not actually
52     # in the persons table.
53     join_fields = {
54         'role_ids': Parameter([int], "List of role identifiers"),
55         'roles': Parameter([str], "List of roles"),
56         'site_ids': Parameter([int], "List of site identifiers"),
57         }
58     
59     # These fields are derived from join tables and are not returned
60     # by default unless specified.
61     extra_fields = {
62         'address_ids': Parameter([int], "List of address identifiers"),
63         'key_ids': Parameter([int], "List of key identifiers"),
64         'slice_ids': Parameter([int], "List of slice identifiers"),
65         }
66
67     default_fields = dict(fields.items() + join_fields.items())
68     all_fields = dict(default_fields.items() + extra_fields.items())
69
70     def __init__(self, api, fields):
71         Row.__init__(self, fields)
72         self.api = api
73
74     def validate_email(self, email):
75         """
76         Validate email address. Stolen from Mailman.
77         """
78
79         invalid_email = PLCInvalidArgument("Invalid e-mail address")
80         email_badchars = r'[][()<>|;^,\200-\377]'
81
82         # Pretty minimal, cheesy check.  We could do better...
83         if not email or email.count(' ') > 0:
84             raise invalid_email
85         if re.search(email_badchars, email) or email[0] == '-':
86             raise invalid_email
87
88         email = email.lower()
89         at_sign = email.find('@')
90         if at_sign < 1:
91             raise invalid_email
92         user = email[:at_sign]
93         rest = email[at_sign+1:]
94         domain = rest.split('.')
95
96         # This means local, unqualified addresses, are no allowed
97         if not domain:
98             raise invalid_email
99         if len(domain) < 2:
100             raise invalid_email
101
102         conflicts = Persons(self.api, [email])
103         for person_id, person in conflicts.iteritems():
104             if not person['deleted'] and ('person_id' not in self or self['person_id'] != person_id):
105                 raise PLCInvalidArgument, "E-mail address already in use"
106
107         return email
108
109     def validate_password(self, password):
110         """
111         Encrypt password if necessary before committing to the
112         database.
113         """
114
115         if len(password) > len(md5crypt.MAGIC) and \
116            password[0:len(md5crypt.MAGIC)] == md5crypt.MAGIC:
117             return password
118         else:
119             # Generate a somewhat unique 2 character salt string
120             salt = str(time.time()) + str(Random().random())
121             salt = md5.md5(salt).hexdigest()[:8] 
122             return md5crypt.md5crypt(password, salt)
123
124     def validate_role_ids(self, role_ids):
125         """
126         Ensure that the specified role_ids are all valid.
127         """
128
129         roles = Roles(self.api)
130         for role_id in role_ids:
131             if role_id not in roles:
132                 raise PLCInvalidArgument, "No such role"
133
134         return role_ids
135
136     def validate_site_ids(self, site_ids):
137         """
138         Ensure that the specified site_ids are all valid.
139         """
140
141         sites = PLC.Sites.Sites(self.api, site_ids)
142         for site_id in site_ids:
143             if site_id not in sites:
144                 raise PLCInvalidArgument, "No such site"
145
146         return site_ids
147
148     def can_update(self, person):
149         """
150         Returns true if we can update the specified person. We can
151         update a person if:
152
153         1. We are the person.
154         2. We are an admin.
155         3. We are a PI and the person is a user or tech or at
156            one of our sites.
157         """
158
159         assert isinstance(person, Person)
160
161         if self['person_id'] == person['person_id']:
162             return True
163
164         if 'admin' in self['roles']:
165             return True
166
167         if 'pi' in self['roles']:
168             if set(self['site_ids']).intersection(person['site_ids']):
169                 # Can update people with higher role IDs
170                 return min(self['role_ids']) < min(person['role_ids'])
171
172         return False
173
174     def can_view(self, person):
175         """
176         Returns true if we can view the specified person. We can
177         view a person if:
178
179         1. We are the person.
180         2. We are an admin.
181         3. We are a PI and the person is at one of our sites.
182         """
183
184         assert isinstance(person, Person)
185
186         if self.can_update(person):
187             return True
188
189         if 'pi' in self['roles']:
190             if set(self['site_ids']).intersection(person['site_ids']):
191                 # Can view people with equal or higher role IDs
192                 return min(self['role_ids']) <= min(person['role_ids'])
193
194         return False
195
196     def add_role(self, role_id, commit = True):
197         """
198         Add role to existing account.
199         """
200
201         assert 'person_id' in self
202
203         person_id = self['person_id']
204         self.api.db.do("INSERT INTO person_roles (person_id, role_id)" \
205                        " VALUES(%(person_id)d, %(role_id)d)",
206                        locals())
207
208         if commit:
209             self.api.db.commit()
210
211         assert 'role_ids' in self
212         if role_id not in self['role_ids']:
213             self['role_ids'].append(role_id)
214
215     def remove_role(self, role_id, commit = True):
216         """
217         Remove role from existing account.
218         """
219
220         assert 'person_id' in self
221
222         person_id = self['person_id']
223         self.api.db.do("DELETE FROM person_roles" \
224                        " WHERE person_id = %(person_id)d" \
225                        " AND role_id = %(role_id)d",
226                        locals())
227
228         if commit:
229             self.api.db.commit()
230
231         assert 'role_ids' in self
232         if role_id in self['role_ids']:
233             self['role_ids'].remove(role_id)
234
235     def set_primary_site(self, site, commit = True):
236         """
237         Set the primary site for an existing account.
238         """
239
240         assert 'person_id' in self
241         assert isinstance(site, PLC.Sites.Site)
242         assert 'site_id' in site
243
244         person_id = self['person_id']
245         site_id = site['site_id']
246         self.api.db.do("UPDATE person_site SET is_primary = False" \
247                        " WHERE person_id = %(person_id)d",
248                        locals())
249         self.api.db.do("UPDATE person_site SET is_primary = True" \
250                        " WHERE person_id = %(person_id)d" \
251                        " AND site_id = %(site_id)d",
252                        locals())
253
254         if commit:
255             self.api.db.commit()
256
257         assert 'site_ids' in self
258         assert site_id in self['site_ids']
259
260         # Make sure that the primary site is first in the list
261         self['site_ids'].remove(site_id)
262         self['site_ids'].insert(0, site_id)
263
264     def flush(self, commit = True):
265         """
266         Commit changes back to the database.
267         """
268
269         self.validate()
270
271         # Fetch a new person_id if necessary
272         if 'person_id' not in self:
273             rows = self.api.db.selectall("SELECT NEXTVAL('persons_person_id_seq') AS person_id")
274             if not rows:
275                 raise PLCDBError, "Unable to fetch new person_id"
276             self['person_id'] = rows[0]['person_id']
277             insert = True
278         else:
279             insert = False
280
281         # Filter out fields that cannot be set or updated directly
282         fields = dict(filter(lambda (key, value): key in self.fields,
283                              self.items()))
284
285         # Parameterize for safety
286         keys = fields.keys()
287         values = [self.api.db.param(key, value) for (key, value) in fields.items()]
288
289         if insert:
290             # Insert new row in persons table
291             sql = "INSERT INTO persons (%s) VALUES (%s)" % \
292                   (", ".join(keys), ", ".join(values))
293         else:
294             # Update existing row in persons table
295             columns = ["%s = %s" % (key, value) for (key, value) in zip(keys, values)]
296             sql = "UPDATE persons SET " + \
297                   ", ".join(columns) + \
298                   " WHERE person_id = %(person_id)d"
299
300         self.api.db.do(sql, fields)
301
302         if commit:
303             self.api.db.commit()
304
305     def delete(self, commit = True):
306         """
307         Delete existing account.
308         """
309
310         assert 'person_id' in self
311
312         # Make sure extra fields are present
313         persons = Persons(self.api, [self['person_id']],
314                           ['address_ids', 'key_ids'])
315         assert persons
316         self.update(persons.values()[0])
317
318         # Delete all addresses
319         addresses = Addresses(self.api, self['address_ids'])
320         for address in addresses.values():
321             address.delete(commit = False)
322
323         # Delete all keys
324         keys = Keys(self.api, self['key_ids'])
325         for key in keys.values():
326             key.delete(commit = False)
327
328         # Clean up miscellaneous join tables
329         for table in ['person_roles', 'person_capabilities', 'person_site',
330                       'node_root_access', 'dslice03_sliceuser']:
331             self.api.db.do("DELETE FROM %s" \
332                            " WHERE person_id = %d" % \
333                            (table, self['person_id']))
334
335         # Mark as deleted
336         self['deleted'] = True
337         self.flush(commit)
338
339 class Persons(Table):
340     """
341     Representation of row(s) from the persons table in the
342     database. Specify deleted and/or enabled to force a match on
343     whether a person is deleted and/or enabled. Default is to match on
344     non-deleted accounts.
345     """
346
347     def __init__(self, api, person_id_or_email_list = None, extra_fields = [], deleted = False, enabled = None):
348         self.api = api
349
350         role_max = Roles.role_max
351
352         # N.B.: Site IDs returned may be deleted. Persons returned are
353         # never deleted, but may not be enabled.
354         sql = "SELECT persons.*" \
355               ", roles.role_id, roles.name AS role" \
356               ", person_site.site_id" \
357
358         # N.B.: Joined IDs may be marked as deleted in their primary tables
359         join_tables = {
360             # extra_field: (extra_table, extra_column, join_using)
361             'address_ids': ('person_address', 'address_id', 'person_id'),
362             'key_ids': ('person_keys', 'key_id', 'person_id'),
363             'slice_ids': ('dslice03_sliceuser', 'slice_id', 'person_id'),
364             }
365
366         extra_fields = filter(join_tables.has_key, extra_fields)
367         extra_tables = ["%s USING (%s)" % \
368                         (join_tables[field][0], join_tables[field][2]) \
369                         for field in extra_fields]
370         extra_columns = ["%s.%s" % \
371                          (join_tables[field][0], join_tables[field][1]) \
372                          for field in extra_fields]
373
374         if extra_columns:
375             sql += ", " + ", ".join(extra_columns)
376
377         sql += " FROM persons" \
378                " LEFT JOIN person_roles USING (person_id)" \
379                " LEFT JOIN roles USING (role_id)" \
380                " LEFT JOIN person_site USING (person_id)"
381
382         if extra_tables:
383             sql += " LEFT JOIN " + " LEFT JOIN ".join(extra_tables)
384
385         # So that people with no roles have empty role_ids and roles values
386         sql += " WHERE (role_id IS NULL or role_id <= %(role_max)d)"
387
388         if deleted is not None:
389             sql += " AND persons.deleted IS %(deleted)s"
390
391         if enabled is not None:
392             sql += " AND persons.enabled IS %(enabled)s"
393
394         if person_id_or_email_list:
395             # Separate the list into integers and strings
396             person_ids = filter(lambda person_id: isinstance(person_id, (int, long)),
397                                 person_id_or_email_list)
398             emails = filter(lambda email: isinstance(email, StringTypes),
399                             person_id_or_email_list)
400             sql += " AND (False"
401             if person_ids:
402                 sql += " OR person_id IN (%s)" % ", ".join(map(str, person_ids))
403             if emails:
404                 # Case insensitive e-mail address comparison
405                 sql += " OR lower(email) IN (%s)" % ", ".join(api.db.quote(emails)).lower()
406             sql += ")"
407
408         # The first site_id in the site_ids list is the primary site
409         # of the user. See AdmGetPersonSites().
410         sql += " ORDER BY person_site.is_primary DESC"
411
412         rows = self.api.db.selectall(sql, locals())
413         for row in rows:
414             if self.has_key(row['person_id']):
415                 person = self[row['person_id']]
416                 person.update(row)
417             else:
418                 self[row['person_id']] = Person(api, row)