- support new schema
[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.3 2006/09/08 19:45:46 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 import crypt
17
18 from PLC.Faults import *
19 from PLC.Parameter import Parameter
20 from PLC.Debug import profile
21 from PLC.Table import Row, Table
22 from PLC.Roles import Roles
23 from PLC.Addresses import Address, Addresses
24 from PLC.Keys import Key, Keys
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 sync().
32     """
33
34     fields = {
35         'person_id': Parameter(int, "Account identifier"),
36         'first_name': Parameter(str, "Given name", max = 128),
37         'last_name': Parameter(str, "Surname", max = 128),
38         'title': Parameter(str, "Title", max = 128),
39         'email': Parameter(str, "Primary e-mail address", max = 254),
40         'phone': Parameter(str, "Telephone number", max = 64),
41         'url': Parameter(str, "Home page", max = 254),
42         'bio': Parameter(str, "Biography", max = 254),
43         'enabled': Parameter(bool, "Has been enabled"),
44         'deleted': Parameter(bool, "Has been deleted"),
45         'password': Parameter(str, "Account password in crypt() form", max = 254),
46         'last_updated': Parameter(str, "Date and time of last update"),
47         'date_created': Parameter(str, "Date and time when account was created"),
48         'role_ids': Parameter([int], "List of role identifiers"),
49         'roles': Parameter([str], "List of roles"),
50         'site_ids': Parameter([int], "List of site identifiers"),
51         'address_ids': Parameter([int], "List of address identifiers"),
52         'key_ids': Parameter([int], "List of key identifiers"),
53         # 'slice_ids': Parameter([int], "List of slice identifiers"),
54         }
55
56     def __init__(self, api, fields):
57         Row.__init__(self, fields)
58         self.api = api
59
60     def validate_email(self, email):
61         """
62         Validate email address. Stolen from Mailman.
63         """
64
65         invalid_email = PLCInvalidArgument("Invalid e-mail address")
66         email_badchars = r'[][()<>|;^,\200-\377]'
67
68         # Pretty minimal, cheesy check.  We could do better...
69         if not email or email.count(' ') > 0:
70             raise invalid_email
71         if re.search(email_badchars, email) or email[0] == '-':
72             raise invalid_email
73
74         email = email.lower()
75         at_sign = email.find('@')
76         if at_sign < 1:
77             raise invalid_email
78         user = email[:at_sign]
79         rest = email[at_sign+1:]
80         domain = rest.split('.')
81
82         # This means local, unqualified addresses, are no allowed
83         if not domain:
84             raise invalid_email
85         if len(domain) < 2:
86             raise invalid_email
87
88         conflicts = Persons(self.api, [email])
89         for person_id, person in conflicts.iteritems():
90             if not person['deleted'] and ('person_id' not in self or self['person_id'] != person_id):
91                 raise PLCInvalidArgument, "E-mail address already in use"
92
93         return email
94
95     def validate_password(self, password):
96         """
97         Encrypt password if necessary before committing to the
98         database.
99         """
100
101         magic = "$1$"
102
103         if len(password) > len(magic) and \
104            password[0:len(magic)] == magic:
105             return password
106         else:
107             # Generate a somewhat unique 8 character salt string
108             salt = str(time.time()) + str(Random().random())
109             salt = md5.md5(salt).hexdigest()[:8] 
110             return crypt.crypt(password.encode(self.api.encoding), magic + salt + "$")
111
112     def can_update(self, person):
113         """
114         Returns true if we can update the specified person. We can
115         update a person if:
116
117         1. We are the person.
118         2. We are an admin.
119         3. We are a PI and the person is a user or tech or at
120            one of our sites.
121         """
122
123         assert isinstance(person, Person)
124
125         if self['person_id'] == person['person_id']:
126             return True
127
128         if 'admin' in self['roles']:
129             return True
130
131         if 'pi' in self['roles']:
132             if set(self['site_ids']).intersection(person['site_ids']):
133                 # Can update people with higher role IDs
134                 return min(self['role_ids']) < min(person['role_ids'])
135
136         return False
137
138     def can_view(self, person):
139         """
140         Returns true if we can view the specified person. We can
141         view a person if:
142
143         1. We are the person.
144         2. We are an admin.
145         3. We are a PI and the person is at one of our sites.
146         """
147
148         assert isinstance(person, Person)
149
150         if self.can_update(person):
151             return True
152
153         if 'pi' in self['roles']:
154             if set(self['site_ids']).intersection(person['site_ids']):
155                 # Can view people with equal or higher role IDs
156                 return min(self['role_ids']) <= min(person['role_ids'])
157
158         return False
159
160     def add_role(self, role_id, commit = True):
161         """
162         Add role to existing account.
163         """
164
165         assert 'person_id' in self
166
167         person_id = self['person_id']
168         self.api.db.do("INSERT INTO person_role (person_id, role_id)" \
169                        " VALUES(%(person_id)d, %(role_id)d)",
170                        locals())
171
172         if commit:
173             self.api.db.commit()
174
175         assert 'role_ids' in self
176         if role_id not in self['role_ids']:
177             self['role_ids'].append(role_id)
178
179     def remove_role(self, role_id, commit = True):
180         """
181         Remove role from existing account.
182         """
183
184         assert 'person_id' in self
185
186         person_id = self['person_id']
187         self.api.db.do("DELETE FROM person_role" \
188                        " WHERE person_id = %(person_id)d" \
189                        " AND role_id = %(role_id)d",
190                        locals())
191
192         if commit:
193             self.api.db.commit()
194
195         assert 'role_ids' in self
196         if role_id in self['role_ids']:
197             self['role_ids'].remove(role_id)
198
199     def set_primary_site(self, site, commit = True):
200         """
201         Set the primary site for an existing account.
202         """
203
204         assert 'person_id' in self
205         assert isinstance(site, PLC.Sites.Site)
206         assert 'site_id' in site
207
208         person_id = self['person_id']
209         site_id = site['site_id']
210         self.api.db.do("UPDATE person_site SET is_primary = False" \
211                        " WHERE person_id = %(person_id)d",
212                        locals())
213         self.api.db.do("UPDATE person_site SET is_primary = True" \
214                        " WHERE person_id = %(person_id)d" \
215                        " AND site_id = %(site_id)d",
216                        locals())
217
218         if commit:
219             self.api.db.commit()
220
221         assert 'site_ids' in self
222         assert site_id in self['site_ids']
223
224         # Make sure that the primary site is first in the list
225         self['site_ids'].remove(site_id)
226         self['site_ids'].insert(0, site_id)
227
228     def sync(self, commit = True):
229         """
230         Commit changes back to the database.
231         """
232
233         self.validate()
234
235         # Fetch a new person_id if necessary
236         if 'person_id' not in self:
237             rows = self.api.db.selectall("SELECT NEXTVAL('persons_person_id_seq') AS person_id")
238             if not rows:
239                 raise PLCDBError, "Unable to fetch new person_id"
240             self['person_id'] = rows[0]['person_id']
241             insert = True
242         else:
243             insert = False
244
245         # Filter out fields that cannot be set or updated directly
246         persons_fields = self.api.db.fields('persons')
247         fields = dict(filter(lambda (key, value): key in persons_fields,
248                              self.items()))
249
250         # Parameterize for safety
251         keys = fields.keys()
252         values = [self.api.db.param(key, value) for (key, value) in fields.items()]
253
254         if insert:
255             # Insert new row in persons table
256             sql = "INSERT INTO persons (%s) VALUES (%s)" % \
257                   (", ".join(keys), ", ".join(values))
258         else:
259             # Update existing row in persons table
260             columns = ["%s = %s" % (key, value) for (key, value) in zip(keys, values)]
261             sql = "UPDATE persons SET " + \
262                   ", ".join(columns) + \
263                   " WHERE person_id = %(person_id)d"
264
265         self.api.db.do(sql, fields)
266
267         if commit:
268             self.api.db.commit()
269
270     def delete(self, commit = True):
271         """
272         Delete existing account.
273         """
274
275         # Delete all addresses
276         addresses = Addresses(self.api, self['address_ids'])
277         for address in addresses.values():
278             address.delete(commit = False)
279
280         # Delete all keys
281         keys = Keys(self.api, self['key_ids'])
282         for key in keys.values():
283             key.delete(commit = False)
284
285         # Clean up miscellaneous join tables
286         for table in ['person_role', 'person_site']:
287             self.api.db.do("DELETE FROM %s" \
288                            " WHERE person_id = %d" % \
289                            (table, self['person_id']))
290
291         # Mark as deleted
292         self['deleted'] = True
293         self.sync(commit)
294
295 class Persons(Table):
296     """
297     Representation of row(s) from the persons table in the
298     database. Specify deleted and/or enabled to force a match on
299     whether a person is deleted and/or enabled. Default is to match on
300     non-deleted accounts.
301     """
302
303     def __init__(self, api, person_id_or_email_list = None, fields = Person.fields, deleted = False, enabled = None):
304         self.api = api
305
306         sql = "SELECT %s FROM view_persons WHERE TRUE" % \
307               ", ".join(fields)
308
309         if deleted is not None:
310             sql += " AND deleted IS %(deleted)s"
311
312         if enabled is not None:
313             sql += " AND enabled IS %(enabled)s"
314
315         if person_id_or_email_list:
316             # Separate the list into integers and strings
317             person_ids = filter(lambda person_id: isinstance(person_id, (int, long)),
318                                 person_id_or_email_list)
319             emails = filter(lambda email: isinstance(email, StringTypes),
320                             person_id_or_email_list)
321             sql += " AND (False"
322             if person_ids:
323                 sql += " OR person_id IN (%s)" % ", ".join(map(str, person_ids))
324             if emails:
325                 # Case insensitive e-mail address comparison
326                 sql += " OR lower(email) IN (%s)" % ", ".join(api.db.quote(emails)).lower()
327             sql += ")"
328
329         rows = self.api.db.selectall(sql, locals())
330
331         for row in rows:
332             self[row['person_id']] = person = Person(api, row)
333             for aggregate in 'role_ids', 'roles', 'site_ids', 'address_ids', 'key_ids':
334                 if not person.has_key(aggregate) or person[aggregate] is None:
335                     person[aggregate] = []
336                 else:
337                     elements = person[aggregate].split(',')
338                     try:
339                         person[aggregate] = map(int, elements)
340                     except ValueError:
341                         person[aggregate] = elements