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"""
18Handlers for Sample SyncAdapter services.
19
20Contains several RequestHandler subclasses used to handle post operations.
21This script is designed to be run directly as a WSGI application.
22
23"""
24
25import cgi
26import logging
27import time as _time
28from datetime import datetime
29from django.utils import simplejson
30from google.appengine.api import users
31from google.appengine.ext import db
32from google.appengine.ext import webapp
33from model import datastore
34import wsgiref.handlers
35
36
37class 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
109class 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
126class 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
301class 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
343def toJSON(object):
344    """Dumps the data represented by the object to JSON for wire transfer."""
345    return simplejson.dumps(object)
346
347class 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
381class 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
390def 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
399if __name__ == "__main__":
400    main()
401