- No longer use view_persons. Query tables directly and aggregate in python (reduces...
[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.33 2007/01/16 17:05:41 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.Debug import log
20 from PLC.Parameter import Parameter
21 from PLC.Filter import Filter
22 from PLC.Table import Row, Table
23 from PLC.Roles import Role, Roles
24 from PLC.Keys import Key, Keys
25 from PLC.Messages import Message, Messages
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     table_name = 'persons'
35     primary_key = 'person_id'
36     join_tables = ['person_key', 'person_role', 'person_site', 'slice_person', 'person_session', 'peer_person']
37     fields = {
38         'person_id': Parameter(int, "User identifier"),
39         'first_name': Parameter(str, "Given name", max = 128),
40         'last_name': Parameter(str, "Surname", max = 128),
41         'title': Parameter(str, "Title", max = 128, nullok = True),
42         'email': Parameter(str, "Primary e-mail address", max = 254),
43         'phone': Parameter(str, "Telephone number", max = 64, nullok = True),
44         'url': Parameter(str, "Home page", max = 254, nullok = True),
45         'bio': Parameter(str, "Biography", max = 254, nullok = True),
46         'enabled': Parameter(bool, "Has been enabled"),
47         'password': Parameter(str, "Account password in crypt() form", max = 254),
48         'verification_key': Parameter(str, "Reset password key", max = 254, nullok = True),
49         'verification_expires': Parameter(int, "Date and time when verification_key expires", nullok = True),
50         'last_updated': Parameter(int, "Date and time of last update", ro = True),
51         'date_created': Parameter(int, "Date and time when account was created", ro = True),
52         'role_ids': Parameter([int], "List of role identifiers"),
53         'roles': Parameter([str], "List of roles"),
54         'site_ids': Parameter([int], "List of site identifiers"),
55         'key_ids': Parameter([int], "List of key identifiers"),
56         'slice_ids': Parameter([int], "List of slice identifiers"),
57         'peer_id': Parameter(int, "Peer to which this user belongs", nullok = True),
58         'peer_person_id': Parameter(int, "Foreign user identifier at peer", nullok = True),
59         }
60
61     # for Cache
62     class_key = 'email'
63     foreign_fields = ['first_name', 'last_name', 'title', 'email', 'phone', 'url',
64                       'bio', 'enabled', 'password', ]
65     # forget about these ones, they are read-only anyway
66     # handling them causes Cache to re-sync all over again 
67     # 'last_updated', 'date_created'
68     foreign_xrefs = [
69         {'field' : 'key_ids',  'class': 'Key',  'table' : 'person_key' } ,
70         {'field' : 'site_ids', 'class': 'Site', 'table' : 'person_site'},
71 #       xxx this is not handled by Cache yet
72 #        'role_ids': Parameter([int], "List of role identifiers"),
73 ]
74
75     def validate_email(self, email):
76         """
77         Validate email address. Stolen from Mailman.
78         """
79
80         invalid_email = PLCInvalidArgument("Invalid e-mail address")
81         email_badchars = r'[][()<>|;^,\200-\377]'
82
83         # Pretty minimal, cheesy check.  We could do better...
84         if not email or email.count(' ') > 0:
85             raise invalid_email
86         if re.search(email_badchars, email) or email[0] == '-':
87             raise invalid_email
88
89         email = email.lower()
90         at_sign = email.find('@')
91         if at_sign < 1:
92             raise invalid_email
93         user = email[:at_sign]
94         rest = email[at_sign+1:]
95         domain = rest.split('.')
96
97         # This means local, unqualified addresses, are not allowed
98         if not domain:
99             raise invalid_email
100         if len(domain) < 2:
101             raise invalid_email
102
103         conflicts = Persons(self.api, [email])
104         for person in conflicts:
105             if 'person_id' not in self or self['person_id'] != person['person_id']:
106                 raise PLCInvalidArgument, "E-mail address already in use"
107
108         return email
109
110     def validate_password(self, password):
111         """
112         Encrypt password if necessary before committing to the
113         database.
114         """
115
116         magic = "$1$"
117
118         if len(password) > len(magic) and \
119            password[0:len(magic)] == magic:
120             return password
121         else:
122             # Generate a somewhat unique 8 character salt string
123             salt = str(time.time()) + str(Random().random())
124             salt = md5.md5(salt).hexdigest()[:8] 
125             return crypt.crypt(password.encode(self.api.encoding), magic + salt + "$")
126
127     validate_date_created = Row.validate_timestamp
128     validate_last_updated = Row.validate_timestamp
129     validate_verification_expires = Row.validate_timestamp
130
131     def can_update(self, person):
132         """
133         Returns true if we can update the specified person. We can
134         update a person if:
135
136         1. We are the person.
137         2. We are an admin.
138         3. We are a PI and the person is a user or tech or at
139            one of our sites.
140         """
141
142         assert isinstance(person, Person)
143
144         if self['person_id'] == person['person_id']:
145             return True
146
147         if 'admin' in self['roles']:
148             return True
149
150         if 'pi' in self['roles']:
151             if set(self['site_ids']).intersection(person['site_ids']):
152                 # Can update people with higher role IDs
153                 return min(self['role_ids']) < min(person['role_ids'])
154
155         return False
156
157     def can_view(self, person):
158         """
159         Returns true if we can view the specified person. We can
160         view a person if:
161
162         1. We are the person.
163         2. We are an admin.
164         3. We are a PI and the person is at one of our sites.
165         """
166
167         assert isinstance(person, Person)
168
169         if self.can_update(person):
170             return True
171
172         if 'pi' in self['roles']:
173             if set(self['site_ids']).intersection(person['site_ids']):
174                 # Can view people with equal or higher role IDs
175                 return min(self['role_ids']) <= min(person['role_ids'])
176
177         return False
178
179     add_role = Row.add_object(Role, 'person_role')
180     remove_role = Row.remove_object(Role, 'person_role')
181
182     add_key = Row.add_object(Key, 'person_key')
183     remove_key = Row.remove_object(Key, 'person_key')
184
185     def set_primary_site(self, site, commit = True):
186         """
187         Set the primary site for an existing user.
188         """
189
190         assert 'person_id' in self
191         assert 'site_id' in site
192
193         person_id = self['person_id']
194         site_id = site['site_id']
195         self.api.db.do("UPDATE person_site SET is_primary = False" \
196                        " WHERE person_id = %(person_id)d",
197                        locals())
198         self.api.db.do("UPDATE person_site SET is_primary = True" \
199                        " WHERE person_id = %(person_id)d" \
200                        " AND site_id = %(site_id)d",
201                        locals())
202
203         if commit:
204             self.api.db.commit()
205
206         assert 'site_ids' in self
207         assert site_id in self['site_ids']
208
209         # Make sure that the primary site is first in the list
210         self['site_ids'].remove(site_id)
211         self['site_ids'].insert(0, site_id)
212
213     def delete(self, commit = True):
214         """
215         Delete existing user.
216         """
217
218         # Delete all keys
219         keys = Keys(self.api, self['key_ids'])
220         for key in keys:
221             key.delete(commit = False)
222
223         # Clean up miscellaneous join tables
224         for table in self.join_tables:
225             self.api.db.do("DELETE FROM %s WHERE person_id = %d" % \
226                            (table, self['person_id']))
227
228         # Mark as deleted
229         self['deleted'] = True
230         self.sync(commit)
231
232 class Persons(Table):
233     """
234     Representation of row(s) from the persons table in the
235     database.
236     """
237
238     def __init__(self, api, person_filter = None, columns = None):
239         Table.__init__(self, api, Person, columns)
240         #sql = "SELECT %s FROM view_persons WHERE deleted IS False" % \
241         #      ", ".join(self.columns)
242         foreign_fields = {'role_ids': ('role_id', 'person_role'),
243                           'roles': ('name', 'roles'),
244                           'site_ids': ('site_id', 'person_site'),
245                           'key_ids': ('key_id', 'person_key'),
246                           'slice_ids': ('slice_id', 'slice_person')
247                           }
248         foreign_keys = {}
249         db_fields = Person(api, Person.fields).db_fields().keys() + \
250                     ['last_updated', 'date_created', 'password', 'verification_key', 'verification_expires']
251         all_fields = db_fields + [value[0] for value in foreign_fields.values()]
252         fields = []
253         _select = "SELECT "
254         _from = " FROM persons "
255         _join = " LEFT JOIN peer_person USING (person_id) "  
256         _where = " WHERE deleted IS False "
257
258         if not columns:
259             # include all columns       
260             fields = all_fields
261             tables = [value[1] for value in foreign_fields.values()]
262             tables.sort()
263             for key in foreign_fields.keys():
264                 foreign_keys[foreign_fields[key][0]] = key  
265             for table in tables:
266                 if table in ['roles']:
267                     _join += " LEFT JOIN roles USING(role_id) "
268                 else:   
269                     _join += " LEFT JOIN %s USING (person_id) " % (table)
270         else: 
271             tables = set()
272             columns = filter(lambda column: column in db_fields+foreign_fields.keys(), columns)
273             columns.sort()
274             for column in columns: 
275                 if column in foreign_fields.keys():
276                     (field, table) = foreign_fields[column]
277                     foreign_keys[field] = column
278                     fields += [field]
279                     tables.add(table)
280                     if column in ['roles']:
281                         _join += " LEFT JOIN roles USING(role_id) "
282                     else:
283                         _join += " LEFT JOIN %s USING (person_id)" % \
284                                 (foreign_fields[column][1])
285                 
286                 else:
287                     fields += [column]  
288         
289         # postgres will return timestamps as datetime objects. 
290         # XMLPRC cannot marshal datetime so convert to int
291         timestamps = ['date_created', 'last_updated']
292         for field in fields:
293             if field in timestamps:
294                 fields[fields.index(field)] = \
295                  "CAST(date_part('epoch', %s) AS bigint) AS %s" % (field, field)
296
297         _select += ", ".join(fields)
298         sql = _select + _from + _join + _where
299
300         # deal with filter                      
301         if person_filter is not None:
302             if isinstance(person_filter, (list, tuple, set)):
303                 # Separate the list into integers and strings
304                 ints = filter(lambda x: isinstance(x, (int, long)), person_filter)
305                 strs = filter(lambda x: isinstance(x, StringTypes), person_filter)
306                 person_filter = Filter(Person.fields, {'person_id': ints, 'email': strs})
307                 sql += " AND (%s)" % person_filter.sql(api, "OR")
308             elif isinstance(person_filter, dict):
309                 person_filter = Filter(Person.fields, person_filter)
310                 sql += " AND (%s)" % person_filter.sql(api, "AND")
311
312         # aggregate data
313         all_persons = {}
314         for row in self.api.db.selectall(sql):
315             person_id = row['person_id']
316
317             if all_persons.has_key(person_id):
318                 for (key, key_list) in foreign_keys.items():
319                     data = row.pop(key)
320                     row[key_list] = [data]
321                     if data and data not in all_persons[person_id][key_list]:
322                         all_persons[person_id][key_list].append(data)
323             else:
324                 for key in foreign_keys.keys():
325                     value = row.pop(key)
326                     if value:   
327                         row[foreign_keys[key]] = [value]
328                     else:
329                         row[foreign_keys[key]] = []
330                 if row: 
331                     all_persons[person_id] = row
332                 
333         # populate self
334         for row in all_persons.values():
335             obj = self.classobj(self.api, row)
336             self.append(obj)
337