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