1 #!/usr/bin/python2.5 2 3 # Copyright (C) 2010 The Android Open Source Project 4 # 5 # Licensed under the Apache License, Version 2.0 (the "License"); you may not 6 # use this file except in compliance with the License. You may obtain a copy of 7 # the License at 8 # 9 # http://www.apache.org/licenses/LICENSE-2.0 10 # 11 # Unless required by applicable law or agreed to in writing, software 12 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 # License for the specific language governing permissions and limitations under 15 # the License. 16 17 """ 18 Handlers for Sample SyncAdapter services. 19 20 Contains several RequestHandler subclasses used to handle post operations. 21 This script is designed to be run directly as a WSGI application. 22 23 """ 24 25 import cgi 26 import logging 27 import time as _time 28 from datetime import datetime 29 from django.utils import simplejson 30 from google.appengine.api import users 31 from google.appengine.ext import db 32 from google.appengine.ext import webapp 33 from model import datastore 34 import wsgiref.handlers 35 36 37 class BaseWebServiceHandler(webapp.RequestHandler): 38 """ 39 Base class for our web services. We put some common helper 40 functions here. 41 """ 42 43 """ 44 Since we're only simulating a single user account, declare our 45 hard-coded credentials here, so that they're easy to see/find. 46 We actually accept any and all usernames that start with this 47 hard-coded values. So if ACCT_USER_NAME is 'user', then we'll 48 accept 'user', 'user75', 'userbuddy', etc, all as legal account 49 usernames. 50 """ 51 ACCT_USER_NAME = 'user' 52 ACCT_PASSWORD = 'test' 53 ACCT_AUTH_TOKEN = 'xyzzy' 54 55 DATE_TIME_FORMAT = '%Y/%m/%d %H:%M' 56 57 """ 58 Process a request to authenticate a client. We assume that the username 59 and password will be included in the request. If successful, we'll return 60 an authtoken as the only content. If auth fails, we'll send an "invalid 61 credentials" error. 62 We return a boolean indicating whether we were successful (true) or not (false). 63 In the event that this call fails, we will setup the response, so callers just 64 need to RETURN in the error case. 65 """ 66 def authenticate(self): 67 self.username = self.request.get('username') 68 self.password = self.request.get('password') 69 70 logging.info('Authenticatng username: ' + self.username) 71 72 if ((self.username != None) and 73 (self.username.startswith(BaseWebServiceHandler.ACCT_USER_NAME)) and 74 (self.password == BaseWebServiceHandler.ACCT_PASSWORD)): 75 # Authentication was successful - return our hard-coded 76 # auth-token as the only response. 77 self.response.set_status(200, 'OK') 78 self.response.out.write(BaseWebServiceHandler.ACCT_AUTH_TOKEN) 79 return True 80 else: 81 # Authentication failed. Return the standard HTTP auth failure 82 # response to let the client know. 83 self.response.set_status(401, 'Invalid Credentials') 84 return False 85 86 """ 87 Validate the credentials of the client for a web service request. 88 The request should include username/password parameters that correspond 89 to our hard-coded single account values. 90 We return a boolean indicating whether we were successful (true) or not (false). 91 In the event that this call fails, we will setup the response, so callers just 92 need to RETURN in the error case. 93 """ 94 def validate(self): 95 self.username = self.request.get('username') 96 self.authtoken = self.request.get('authtoken') 97 98 logging.info('Validating username: ' + self.username) 99 100 if ((self.username != None) and 101 (self.username.startswith(BaseWebServiceHandler.ACCT_USER_NAME)) and 102 (self.authtoken == BaseWebServiceHandler.ACCT_AUTH_TOKEN)): 103 return True 104 else: 105 self.response.set_status(401, 'Invalid Credentials') 106 return False 107 108 109 class Authenticate(BaseWebServiceHandler): 110 """ 111 Handles requests for login and authentication. 112 113 UpdateHandler only accepts post events. It expects each 114 request to include username and password fields. It returns authtoken 115 after successful authentication and "invalid credentials" error otherwise. 116 """ 117 118 def post(self): 119 self.authenticate() 120 121 def get(self): 122 """Used for debugging in a browser...""" 123 self.post() 124 125 126 class SyncContacts(BaseWebServiceHandler): 127 """Handles requests for fetching user's contacts. 128 129 UpdateHandler only accepts post events. It expects each 130 request to include username and authtoken. If the authtoken is valid 131 it returns user's contact info in JSON format. 132 """ 133 134 def get(self): 135 """Used for debugging in a browser...""" 136 self.post() 137 138 def post(self): 139 logging.info('*** Starting contact sync ***') 140 if (not self.validate()): 141 return 142 143 updated_contacts = [] 144 145 # Process any client-side changes sent up in the request. 146 # Any new contacts that were added are included in the 147 # updated_contacts list, so that we return them to the 148 # client. That way, the client can see the serverId of 149 # the newly added contact. 150 client_buffer = self.request.get('contacts') 151 if ((client_buffer != None) and (client_buffer != '')): 152 self.process_client_changes(client_buffer, updated_contacts) 153 154 # Add any contacts that have been updated on the server-side 155 # since the last sync by this client. 156 client_state = self.request.get('syncstate') 157 self.get_updated_contacts(client_state, updated_contacts) 158 159 logging.info('Returning ' + str(len(updated_contacts)) + ' contact records') 160 161 # Return the list of updated contacts to the client 162 self.response.set_status(200) 163 self.response.out.write(toJSON(updated_contacts)) 164 165 def get_updated_contacts(self, client_state, updated_contacts): 166 logging.info('* Processing server changes') 167 timestamp = None 168 169 base_url = self.request.host_url 170 171 # The client sends the last high-water-mark that they successfully 172 # sync'd to in the syncstate parameter. It's opaque to them, but 173 # its actually a seconds-in-unix-epoch timestamp that we use 174 # as a baseline. 175 if client_state: 176 logging.info('Client sync state: ' + client_state) 177 timestamp = datetime.utcfromtimestamp(float(client_state)) 178 179 # Keep track of the update/delete counts, so we can log it 180 # below. Makes debugging easier... 181 update_count = 0 182 delete_count = 0 183 184 contacts = datastore.Contact.all() 185 if contacts: 186 # Find the high-water mark for the most recently updated friend. 187 # We'll return this as the syncstate (x) value for all the friends 188 # we return from this function. 189 high_water_date = datetime.min 190 for contact in contacts: 191 if (contact.updated > high_water_date): 192 high_water_date = contact.updated 193 high_water_mark = str(long(_time.mktime(high_water_date.utctimetuple())) + 1) 194 logging.info('New sync state: ' + high_water_mark) 195 196 # Now build the updated_contacts containing all the friends that have been 197 # changed since the last sync 198 for contact in contacts: 199 # If our list of contacts we're returning already contains this 200 # contact (for example, it's a contact just uploaded from the client) 201 # then don't bother processing it any further... 202 if (self.list_contains_contact(updated_contacts, contact)): 203 continue 204 205 handle = contact.handle 206 207 if timestamp is None or contact.updated > timestamp: 208 if contact.deleted == True: 209 delete_count = delete_count + 1 210 DeletedContactData(updated_contacts, handle, high_water_mark) 211 else: 212 update_count = update_count + 1 213 UpdatedContactData(updated_contacts, handle, None, base_url, high_water_mark) 214 215 logging.info('Server-side updates: ' + str(update_count)) 216 logging.info('Server-side deletes: ' + str(delete_count)) 217 218 def process_client_changes(self, contacts_buffer, updated_contacts): 219 logging.info('* Processing client changes: ' + self.username) 220 221 base_url = self.request.host_url 222 223 # Build an array of generic objects containing contact data, 224 # using the Django built-in JSON parser 225 logging.info('Uploaded contacts buffer: ' + contacts_buffer) 226 json_list = simplejson.loads(contacts_buffer) 227 logging.info('Client-side updates: ' + str(len(json_list))) 228 229 # Keep track of the number of new contacts the client sent to us, 230 # so that we can log it below. 231 new_contact_count = 0 232 233 for jcontact in json_list: 234 new_contact = False 235 id = self.safe_attr(jcontact, 'i') 236 if (id != None): 237 logging.info('Updating contact: ' + str(id)) 238 contact = datastore.Contact.get(db.Key.from_path('Contact', id)) 239 else: 240 logging.info('Creating new contact record') 241 new_contact = True 242 contact = datastore.Contact(handle='temp') 243 244 # If the 'change' for this contact is that they were deleted 245 # on the client-side, all we want to do is set the deleted 246 # flag here, and we're done. 247 if (self.safe_attr(jcontact, 'd') == True): 248 contact.deleted = True 249 contact.put() 250 logging.info('Deleted contact: ' + contact.handle) 251 continue 252 253 contact.firstname = self.safe_attr(jcontact, 'f') 254 contact.lastname = self.safe_attr(jcontact, 'l') 255 contact.phone_home = self.safe_attr(jcontact, 'h') 256 contact.phone_office = self.safe_attr(jcontact, 'o') 257 contact.phone_mobile = self.safe_attr(jcontact, 'm') 258 contact.email = self.safe_attr(jcontact, 'e') 259 contact.deleted = (self.safe_attr(jcontact, 'd') == 'true') 260 if (new_contact): 261 # New record - add them to db... 262 new_contact_count = new_contact_count + 1 263 contact.handle = contact.firstname + '_' + contact.lastname 264 logging.info('Created new contact handle: ' + contact.handle) 265 contact.put() 266 logging.info('Saved contact: ' + contact.handle) 267 268 # We don't save off the client_id value (thus we add it after 269 # the "put"), but we want it to be in the JSON object we 270 # serialize out, so that the client can match this contact 271 # up with the client version. 272 client_id = self.safe_attr(jcontact, 'c') 273 274 # Create a high-water-mark for sync-state from the 'updated' time 275 # for this contact, so we return the correct value to the client. 276 high_water = str(long(_time.mktime(contact.updated.utctimetuple())) + 1) 277 278 # Add new contacts to our updated_contacts, so that we return them 279 # to the client (so the client gets the serverId for the 280 # added contact) 281 if (new_contact): 282 UpdatedContactData(updated_contacts, contact.handle, client_id, base_url, 283 high_water) 284 285 logging.info('Client-side adds: ' + str(new_contact_count)) 286 287 def list_contains_contact(self, contact_list, contact): 288 if (contact is None): 289 return False 290 contact_id = str(contact.key().id()) 291 for next in contact_list: 292 if ((next != None) and (next['i'] == contact_id)): 293 return True 294 return False 295 296 def safe_attr(self, obj, attr_name): 297 if attr_name in obj: 298 return obj[attr_name] 299 return None 300 301 class ResetDatabase(BaseWebServiceHandler): 302 """ 303 Handles cron request to reset the contact database. 304 305 We have a weekly cron task that resets the database back to a 306 few contacts, so that it doesn't grow to an absurd size. 307 """ 308 309 def get(self): 310 # Delete all the existing contacts from the database 311 contacts = datastore.Contact.all() 312 for contact in contacts: 313 contact.delete() 314 315 # Now create three sample contacts 316 contact1 = datastore.Contact(handle = 'juliet', 317 firstname = 'Juliet', 318 lastname = 'Capulet', 319 phone_mobile = '(650) 555-1000', 320 phone_home = '(650) 555-1001', 321 status = 'Wherefore art thou Romeo?') 322 contact1.put() 323 324 contact2 = datastore.Contact(handle = 'romeo', 325 firstname = 'Romeo', 326 lastname = 'Montague', 327 phone_mobile = '(650) 555-2000', 328 phone_home = '(650) 555-2001', 329 status = 'I dream\'d a dream to-night') 330 contact2.put() 331 332 contact3 = datastore.Contact(handle = 'tybalt', 333 firstname = 'Tybalt', 334 lastname = 'Capulet', 335 phone_mobile = '(650) 555-3000', 336 phone_home = '(650) 555-3001', 337 status = 'Have at thee, coward') 338 contact3.put() 339 340 341 342 343 def toJSON(object): 344 """Dumps the data represented by the object to JSON for wire transfer.""" 345 return simplejson.dumps(object) 346 347 class UpdatedContactData(object): 348 """Holds data for user's contacts. 349 350 This class knows how to serialize itself to JSON. 351 """ 352 __FIELD_MAP = { 353 'handle': 'u', 354 'firstname': 'f', 355 'lastname': 'l', 356 'status': 's', 357 'phone_home': 'h', 358 'phone_office': 'o', 359 'phone_mobile': 'm', 360 'email': 'e', 361 'client_id': 'c' 362 } 363 364 def __init__(self, contact_list, username, client_id, host_url, high_water_mark): 365 obj = datastore.Contact.get_contact_info(username) 366 contact = {} 367 for obj_name, json_name in self.__FIELD_MAP.items(): 368 if hasattr(obj, obj_name): 369 v = getattr(obj, obj_name) 370 if (v != None): 371 contact[json_name] = str(v) 372 else: 373 contact[json_name] = None 374 contact['i'] = str(obj.key().id()) 375 contact['a'] = host_url + "/avatar?id=" + str(obj.key().id()) 376 contact['x'] = high_water_mark 377 if (client_id != None): 378 contact['c'] = str(client_id) 379 contact_list.append(contact) 380 381 class DeletedContactData(object): 382 def __init__(self, contact_list, username, high_water_mark): 383 obj = datastore.Contact.get_contact_info(username) 384 contact = {} 385 contact['d'] = 'true' 386 contact['i'] = str(obj.key().id()) 387 contact['x'] = high_water_mark 388 contact_list.append(contact) 389 390 def main(): 391 application = webapp.WSGIApplication( 392 [('/auth', Authenticate), 393 ('/sync', SyncContacts), 394 ('/reset_database', ResetDatabase), 395 ], 396 debug=True) 397 wsgiref.handlers.CGIHandler().run(application) 398 399 if __name__ == "__main__": 400 main() 401