set svn:keywords property for proper keywords expansion
[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$
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         # check only against users on the same peer  
104         if 'peer_id' in self:
105             namespace_peer_id = self['peer_id']
106         else:
107             namespace_peer_id = None
108          
109         conflicts = Persons(self.api, {'email':email,'peer_id':namespace_peer_id}) 
110         
111         for person in conflicts:
112             if 'person_id' not in self or self['person_id'] != person['person_id']:
113                 raise PLCInvalidArgument, "E-mail address already in use"
114
115         return email
116
117     def validate_password(self, password):
118         """
119         Encrypt password if necessary before committing to the
120         database.
121         """
122
123         magic = "$1$"
124
125         if len(password) > len(magic) and \
126            password[0:len(magic)] == magic:
127             return password
128         else:
129             # Generate a somewhat unique 8 character salt string
130             salt = str(time.time()) + str(Random().random())
131             salt = md5.md5(salt).hexdigest()[:8] 
132             return crypt.crypt(password.encode(self.api.encoding), magic + salt + "$")
133
134     validate_date_created = Row.validate_timestamp
135     validate_last_updated = Row.validate_timestamp
136     validate_verification_expires = Row.validate_timestamp
137
138     def can_update(self, person):
139         """
140         Returns true if we can update the specified person. We can
141         update 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 a user or tech or at
146            one of our sites.
147         """
148
149         assert isinstance(person, Person)
150
151         if self['person_id'] == person['person_id']:
152             return True
153
154         if 'admin' in self['roles']:
155             return True
156
157         if 'pi' in self['roles']:
158             if set(self['site_ids']).intersection(person['site_ids']):
159                 # Can update people with higher role IDs
160                 return min(self['role_ids']) < min(person['role_ids'])
161
162         return False
163
164     def can_view(self, person):
165         """
166         Returns true if we can view the specified person. We can
167         view a person if:
168
169         1. We are the person.
170         2. We are an admin.
171         3. We are a PI and the person is at one of our sites.
172         """
173
174         assert isinstance(person, Person)
175
176         if self.can_update(person):
177             return True
178
179         if 'pi' in self['roles']:
180             if set(self['site_ids']).intersection(person['site_ids']):
181                 # Can view people with equal or higher role IDs
182                 return min(self['role_ids']) <= min(person['role_ids'])
183
184         return False
185
186     add_role = Row.add_object(Role, 'person_role')
187     remove_role = Row.remove_object(Role, 'person_role')
188
189     add_key = Row.add_object(Key, 'person_key')
190     remove_key = Row.remove_object(Key, 'person_key')
191
192     def set_primary_site(self, site, commit = True):
193         """
194         Set the primary site for an existing user.
195         """
196
197         assert 'person_id' in self
198         assert 'site_id' in site
199
200         person_id = self['person_id']
201         site_id = site['site_id']
202         self.api.db.do("UPDATE person_site SET is_primary = False" \
203                        " WHERE person_id = %(person_id)d",
204                        locals())
205         self.api.db.do("UPDATE person_site SET is_primary = True" \
206                        " WHERE person_id = %(person_id)d" \
207                        " AND site_id = %(site_id)d",
208                        locals())
209
210         if commit:
211             self.api.db.commit()
212
213         assert 'site_ids' in self
214         assert site_id in self['site_ids']
215
216         # Make sure that the primary site is first in the list
217         self['site_ids'].remove(site_id)
218         self['site_ids'].insert(0, site_id)
219
220     def update_last_updated(self, commit = True):
221         """
222         Update last_updated field with current time
223         """
224         
225         assert 'person_id' in self
226         assert self.table_name
227         
228         self.api.db.do("UPDATE %s SET last_updated = CURRENT_TIMESTAMP " % (self.table_name) + \
229                        " where person_id = %d" % (self['person_id']) )
230         self.sync(commit)
231
232     def delete(self, commit = True):
233         """
234         Delete existing user.
235         """
236
237         # Delete all keys
238         keys = Keys(self.api, self['key_ids'])
239         for key in keys:
240             key.delete(commit = False)
241
242         # Clean up miscellaneous join tables
243         for table in self.join_tables:
244             self.api.db.do("DELETE FROM %s WHERE person_id = %d" % \
245                            (table, self['person_id']))
246
247         # Mark as deleted
248         self['deleted'] = True
249         self.sync(commit)
250
251 class Persons(Table):
252     """
253     Representation of row(s) from the persons table in the
254     database.
255     """
256
257     def __init__(self, api, person_filter = None, columns = None):
258         Table.__init__(self, api, Person, columns)
259         #sql = "SELECT %s FROM view_persons WHERE deleted IS False" % \
260         #      ", ".join(self.columns)
261         foreign_fields = {'role_ids': ('role_id', 'person_role'),
262                           'roles': ('name', 'roles'),
263                           'site_ids': ('site_id', 'person_site'),
264                           'key_ids': ('key_id', 'person_key'),
265                           'slice_ids': ('slice_id', 'slice_person')
266                           }
267         foreign_keys = {}
268         db_fields = filter(lambda field: field not in foreign_fields.keys(), Person.fields.keys())
269         all_fields = db_fields + [value[0] for value in foreign_fields.values()]
270         fields = []
271         _select = "SELECT "
272         _from = " FROM persons "
273         _join = " LEFT JOIN peer_person USING (person_id) "  
274         _where = " WHERE deleted IS False "
275
276         if not columns:
277             # include all columns       
278             fields = all_fields
279             tables = [value[1] for value in foreign_fields.values()]
280             tables.sort()
281             for key in foreign_fields.keys():
282                 foreign_keys[foreign_fields[key][0]] = key  
283             for table in tables:
284                 if table in ['roles']:
285                     _join += " LEFT JOIN roles USING(role_id) "
286                 else:   
287                     _join += " LEFT JOIN %s USING (person_id) " % (table)
288         else: 
289             tables = set()
290             columns = filter(lambda column: column in db_fields+foreign_fields.keys(), columns)
291             columns.sort()
292             for column in columns: 
293                 if column in foreign_fields.keys():
294                     (field, table) = foreign_fields[column]
295                     foreign_keys[field] = column
296                     fields += [field]
297                     tables.add(table)
298                     if column in ['roles']:
299                         _join += " LEFT JOIN roles USING(role_id) "
300                     else:
301                         _join += " LEFT JOIN %s USING (person_id)" % \
302                                 (foreign_fields[column][1])
303                 
304                 else:
305                     fields += [column]  
306         
307         # postgres will return timestamps as datetime objects. 
308         # XMLPRC cannot marshal datetime so convert to int
309         timestamps = ['date_created', 'last_updated', 'verification_expires']
310         for field in fields:
311             if field in timestamps:
312                 fields[fields.index(field)] = \
313                  "CAST(date_part('epoch', %s) AS bigint) AS %s" % (field, field)
314
315         _select += ", ".join(fields)
316         sql = _select + _from + _join + _where
317
318         # deal with filter                      
319         if person_filter is not None:
320             if isinstance(person_filter, (list, tuple, set)):
321                 # Separate the list into integers and strings
322                 ints = filter(lambda x: isinstance(x, (int, long)), person_filter)
323                 strs = filter(lambda x: isinstance(x, StringTypes), person_filter)
324                 person_filter = Filter(Person.fields, {'person_id': ints, 'email': strs})
325                 sql += " AND (%s) %s" % person_filter.sql(api, "OR")
326             elif isinstance(person_filter, dict):
327                 person_filter = Filter(Person.fields, person_filter)
328                 sql += " AND (%s) %s" % person_filter.sql(api, "AND")
329             elif isinstance (person_filter, StringTypes):
330                 person_filter = Filter(Person.fields, {'email':[person_filter]})
331                 sql += " AND (%s) %s" % person_filter.sql(api, "AND")
332             elif isinstance (person_filter, int):
333                 person_filter = Filter(Person.fields, {'person_id':[person_filter]})
334                 sql += " AND (%s) %s" % person_filter.sql(api, "AND")
335             else:
336                 raise PLCInvalidArgument, "Wrong person filter %r"%person_filter
337
338         # aggregate data
339         all_persons = {}
340         for row in self.api.db.selectall(sql):
341             person_id = row['person_id']
342
343             if all_persons.has_key(person_id):
344                 for (key, key_list) in foreign_keys.items():
345                     data = row.pop(key)
346                     row[key_list] = [data]
347                     if data and data not in all_persons[person_id][key_list]:
348                         all_persons[person_id][key_list].append(data)
349             else:
350                 for key in foreign_keys.keys():
351                     value = row.pop(key)
352                     if value:   
353                         row[foreign_keys[key]] = [value]
354                     else:
355                         row[foreign_keys[key]] = []
356                 if row: 
357                     all_persons[person_id] = row
358                 
359         # populate self
360         for row in all_persons.values():
361             obj = self.classobj(self.api, row)
362             self.append(obj)
363