- remove problematic Persons -> Sites -> Persons circular import
[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.31 2007/01/09 16:22:49 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),
49         'verification_expires': Parameter(int, "Date and time when verification_key expires"),
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 send_initiate_password_reset_email(self):
214         # email user next step instructions
215         to_addr = {}
216         to_addr[self['email']] = "%s %s" % \
217             (self['first_name'], self['last_name'])
218         from_addr = {}
219         from_addr[self.api.config.PLC_MAIL_SUPPORT_ADDRESS] = \
220         "%s %s" % ('Planetlab', 'Support')
221
222         # fill in template
223         messages = Messages(self.api, ['ASSWORD_RESET_INITIATE'])
224         if not messages:
225             print >> log, "No such message template"
226             return 1
227
228         message = messages[0]
229         subject = message['subject']
230         template = message['template'] % \
231             (self.api.config.PLC_WWW_HOST,
232              self['verification_key'], self['person_id'],
233              self.api.config.PLC_MAIL_SUPPORT_ADDRESS,
234              self.api.config.PLC_WWW_HOST)
235
236         self.api.mailer.mail(to_addr, None, from_addr, subject, template)
237     
238     def send_account_registered_email(self, site):
239         to_addr = {}
240         cc_addr = {}
241         from_addr = {}
242         from_addr[self.api.config.PLC_MAIL_SUPPORT_ADDRESS] = \
243         "%s %s" % ('Planetlab', 'Support')
244
245         # email user
246         user_full_name = "%s %s" % (self['first_name'], self['last_name'])
247         to_addr[self['email']] = "%s" % user_full_name
248
249         # if the account had a admin role or a pi role, email support.
250         if set(['admin', 'pi']).intersection(self['roles']):
251             to_addr[self.api.config.PLC_MAIL_SUPPORT_ADDRESS] = \
252                 "%s %s" % ('Planetlab', 'Support')
253         
254         # cc site pi's
255         site_persons = Persons(self.api, site['person_ids'])
256         for person in site_persons:
257             if 'pi' in person['roles'] and not person['email'] in to_addr.keys():
258                 cc_addr[person['email']] = "%s %s" % \
259                 (person['first_name'], person['last_name'])
260
261         # fill in template
262         messages = Messages(self.api, ['ACCOUNT_REGISTERED'])
263         if not messages:
264             print >> log, "No such message template"
265             return 1
266
267         message = messages[0]
268         subject = message['subject'] % (user_full_name, site['name'])
269         template = message['template'] % \
270             (user_full_name, site['name'], ", ".join(self['roles']),
271              self.api.config.PLC_WWW_HOST, self['person_id'],
272              self.api.config.PLC_MAIL_SUPPORT_ADDRESS,
273              self.api.config.PLC_WWW_HOST)
274                                 
275         self.api.mailer.mail(to_addr, cc_addr, from_addr, subject, template)
276
277     def delete(self, commit = True):
278         """
279         Delete existing user.
280         """
281
282         # Delete all keys
283         keys = Keys(self.api, self['key_ids'])
284         for key in keys:
285             key.delete(commit = False)
286
287         # Clean up miscellaneous join tables
288         for table in self.join_tables:
289             self.api.db.do("DELETE FROM %s WHERE person_id = %d" % \
290                            (table, self['person_id']))
291
292         # Mark as deleted
293         self['deleted'] = True
294         self.sync(commit)
295
296 class Persons(Table):
297     """
298     Representation of row(s) from the persons table in the
299     database.
300     """
301
302     def __init__(self, api, person_filter = None, columns = None):
303         Table.__init__(self, api, Person, columns)
304
305         sql = "SELECT %s FROM view_persons WHERE deleted IS False" % \
306               ", ".join(self.columns)
307
308         if person_filter is not None:
309             if isinstance(person_filter, (list, tuple, set)):
310                 # Separate the list into integers and strings
311                 ints = filter(lambda x: isinstance(x, (int, long)), person_filter)
312                 strs = filter(lambda x: isinstance(x, StringTypes), person_filter)
313                 person_filter = Filter(Person.fields, {'person_id': ints, 'email': strs})
314                 sql += " AND (%s)" % person_filter.sql(api, "OR")
315             elif isinstance(person_filter, dict):
316                 person_filter = Filter(Person.fields, person_filter)
317                 sql += " AND (%s)" % person_filter.sql(api, "AND")
318
319         self.selectall(sql)