Commits

Anonymous committed 2ef4c30

* Added property on `FacebookCreditsContext` for order_details and modified field
* Added event notification for payment update.
* Added functional test on payment update.
* Some doc.

Comments (0)

Files changed (8)

docs/source/index.rst

 Setup
 -----
 
-Once :mod:`pyramid_facebook` is installed, you must use the
-:meth:`config.include <pyramid.config.Configurator.include>` mechanism to
-include it into your Pyramid project's configuration.
-In your Pyramid project's ``__init__.py``:
+#. Once `pyramid_facebook` is installed, you must use the
+   :meth:`config.include <pyramid.config.Configurator.include>` mechanism to
+   include it into your Pyramid project's configuration.
+   In your Pyramid project's ``__init__.py``:
 
-.. code-block:: python
+   .. code-block:: python
 
-   config = Configurator(.....)
-   config.include('pyramid_facebook')
+      config = Configurator(.....)
+      config.include('pyramid_facebook')
 
-Alternately, instead of using the Configurator's
-:meth:`config.include <pyramid.config.Configurator.include>` method, you can
-activate Pyramid by changing your application's ``.ini`` file, use the
-following line:
+   Alternately, instead of using the Configurator's
+   :meth:`config.include <pyramid.config.Configurator.include>` method, you can
+   activate Pyramid by changing your application's ``.ini`` file, use the
+   following line:
 
-.. code-block:: ini
+   .. code-block:: ini
 
-   pyramid.includes = pyramid_facebook
+      pyramid.includes = pyramid_facebook
 
-``pyramid_facebook`` obtains facebook application information from
-the ``**settings`` dictionary passed to the
-Configurator.  It assumes that you've placed some of your facebook application
-configuration parameters prefixed with ``facebook.`` in your Pyramid
-application's ``.ini`` file:
+#. Create facebook application on https://developers.facebook.com/apps
 
-.. code-block:: ini
+#. ``pyramid_facebook`` obtains facebook application information from
+   the ``**settings`` dictionary passed to the
+   Configurator.  It assumes that you've placed some of your facebook application
+   configuration parameters prefixed with ``facebook.`` in your Pyramid
+   application's ``.ini`` file:
 
-   [app:myapp]
-   ...
-   facebook.app_id = 123456789
-   facebook.secret_key = 5fbf6252b38eec5d7f8a6962c8a00556
-   facebook.namespace = myfacebookapp
-   facebook.scope = user_events
-   ...
+   .. code-block:: ini
+
+      [app:myapp]
+      ...
+      facebook.app_id = 123456789
+      facebook.secret_key = 5fbf6252b38eec5d7f8a6962c8a00556
+      facebook.namespace = myfacebookapp
+      facebook.scope = user_events
+      ...
+
+#. In the app settings on https://developers.facebook.com/apps, set callbak url
+   to point to your server. when developing, it is handy to point your
+   localhost::
+
+      http://127.0.0.1:6543/[facebook app namespace]/
+
+#. Define your facebook canvas view::
+
+      from pyramid_facebook import facebook_canvas
+
+      @facebook_canvas()
+      def canvas(context, request):
+         # canvas is available only to users who accepted facebook permission
+         # defined in setting['facebook.scope'].
+         # context.facebook_data dict contains signed_request content.
+         # i.e.:
+         # user_id = context.facebook["user_id"]
+         ...
+         return Response('Hello Facebok World')
+
+#. Visit your app on ``http://apps.facebook.com/[facebook app namespace]``
+
+#. **To get facebook credits running**, set credits callback in your facebook
+   application settings to point to ``http://yourserver.com/[facebook app namespace]/credits``
+
+#. Define your ``get item method`` using
+   :class:`~pyramid_facebook.facebook_payments_get_items` decorator.
+
+#. Subscribe to payments update events:
+
+   #.  :class:`~pyramid_facebook.views.DisputedOrder`
+
+   #.  :class:`~pyramid_facebook.views.RefundedOrder`
+
+   #.  :class:`~pyramid_facebook.views.PlacedItemOrder`
+
+   #.  :class:`~pyramid_facebook.views.EarnedCurrencyOrder`
+
 
 
 Under The Hood
 --------------
 
-Pyramid includes
-````````````````
-
 .. automodule:: pyramid_facebook
    :members:
 
-Security
-````````
-
 .. automodule:: pyramid_facebook.security
    :members:
 
+.. automodule:: pyramid_facebook.views
+   :members:
+
+
 Lib
 ```
 

pyramid_facebook/__init__.py

 # -*- coding: utf-8 -*-
+"""
+Pyramid includes
+````````````````
+"""
 from __future__ import absolute_import
 import logging
 
 
 
 def main(global_config, **settings):
+    # I should remove this and construct app in functional test
     log.debug('global_config: %r\nsettings: %r', global_config, settings)
     config = Configurator(settings=settings)
     config.include(includeme)
     * Authentication with :class:`~pyramid_facebook.security.FacebookAuthenticationPolicy`
       using :meth:`config.set_authentication_policy <pyramid.config.Configurator.set_authentication_policy>`
 
-    It adds routes:
+    It adds routes which all requires :
 
-    * ``facebook_canvas`` associated to url ``/{namespace}/``. It requires:
+    * a ``POST`` method, ``GET`` method will be considered as not found and
+      will return a HTTP 404)
+    * a ``signed_request`` parameter in body as defined on `Facebook
+      documentation
+      <http://developers.facebook.com/docs/authentication/signed_request/>`_.
 
-       * a ``POST`` method, ``GET`` method will be considered as not found and
-         will return a HTTP 404)
-       * a ``signed_request`` parameter in body as defined on `Facebook
-         documentation
-         <http://developers.facebook.com/docs/authentication/signed_request/>`_.
+    Routes added:
+
+    * ``facebook_canvas`` associated to url ``/{namespace}/`` which musts be
+      configured as canvas callback url in facebook application settings.
 
     * ``facebook_canvas_oauth`` associated to url ``/{namespace}/oauth`` for
       authentication.
+
+    * ``facebook_payments_get_items`` associated to url ``/{namespace}/credits``.
+
+    * ``facebook_payments_status_update_placed`` associated to url
+      ``/{namespace}/credits``.
+
+    * ``facebook_payments_status_update_disputed`` associated to url
+      ``/{namespace}/credits``.
+
+    * ``facebook_payments_status_update_refunded`` associated to url
+      ``/{namespace}/credits``.
     """
     log.debug('config: %r', config)
     settings = config.registry.settings
                 method='payments_get_items',
                 )
             ],
-        factory='pyramid_facebook.security.SignedRequestContext',
+        factory='pyramid_facebook.security.FacebookCreditsContext',
         )
 
     update_predicate = request_params_predicate(
             update_predicate,
             request_params_predicate(status='placed'),
             ],
-        factory='pyramid_facebook.security.SignedRequestContext',
+        factory='pyramid_facebook.security.FacebookCreditsContext',
         )
 
     log.debug(
             update_predicate,
             request_params_predicate(status='disputed'),
             ],
-        factory='pyramid_facebook.security.SignedRequestContext',
+        factory='pyramid_facebook.security.FacebookCreditsContext',
         )
 
     log.debug(
             update_predicate,
             request_params_predicate(status='refunded'),
             ],
-        factory='pyramid_facebook.security.SignedRequestContext',
+        factory='pyramid_facebook.security.FacebookCreditsContext',
         )
 
     config.scan('pyramid_facebook.views')
 
     * ``request``: The request itself.
 
-
     * Keyword argument ``order_info``: The order information passed
       `when the FB.ui is invoked
       <https://developers.facebook.com/docs/reference/dialogs/pay/#properties>`_.

pyramid_facebook/security.py

-    # -*- coding: utf-8 -*-
+# -*- coding: utf-8 -*-
+"""
+Security
+````````
+"""
+import json
 import logging
 
 from pyramid.authentication import CallbackAuthenticationPolicy
     FacebookSignatureException
     )
 
-__all__ = ['FacebookAuthenticationPolicy', 'SignedRequestContext', 'ViewCanvas']
+__all__ = ['FacebookAuthenticationPolicy', 'SignedRequestContext', 'ViewCanvas',
+    'FacebookCreditsContext']
 
 log = logging.getLogger(__name__)
 
 
 
 class SignedRequestContext(object):
-    """Security context for facebook signed request routes.
-    """
+    "Security context for facebook signed request routes."
+
     def __init__(self, request):
         self.__acl__ = [
             (Allow, Authenticated, ViewCanvas),
             raise ValueError('Property can be set only once')
 
 
+class FacebookCreditsContext(SignedRequestContext):
+    "Context for facebook credits callback requests."
+
+    @property
+    def order_details(self):
+        """Order details received in `facebook credits callback for payment
+        status updates <http://developers.facebook.com/docs/credits/callback/#payments_status_update>`_."""
+        if not hasattr(self, '_order_details'):
+            self._order_details = json.loads(
+                self.facebook_data['credits']['order_details']
+                )
+        return self._order_details
+
+    @property
+    def earned_currency_data(self):
+        """Modified field received in `facebook credits callback for payment
+        status update for earned app currency
+        <http://developers.facebook.com/docs/credits/callback/#payments_status_update_earn_app_currency>`_."""
+        if not hasattr(self, '_earned_currency_data'):
+            data = self.order_details['items'][0]['data']
+            if data:
+                data = json.loads(data)['modified']
+            self._earned_currency_data = data
+        return self._earned_currency_data
+
+
 @implementer(IAuthenticationPolicy)
 class FacebookAuthenticationPolicy(CallbackAuthenticationPolicy):
     """A policy which authenticates user from ``signed_request`` parameter:
     * It assigns :py:attr:`context.facebook_data <pyramid_facebook.security.SignedRequestContext.facebook_data>`
       with decrypted data.
 
-    See :py:class:`CallbackAuthenticationPolicy <pyramid.authentication.CallbackAuthenticationPolicy>`
+    See `CallbackAuthenticationPolicy code <https://github.com/Pylons/pyramid/blob/master/pyramid/authentication.py#L35>`_
     For more info.
     """
 

pyramid_facebook/tests/functionals/test_facebook_credits.py

 # -*- coding: utf-8 -*-
 from __future__ import absolute_import
 
+import json
+
 from ...tests import get_signed_request, conf
 from ..functionals import TestController
 
+# Do not ask why facebook payment callback is that twisted:
+# 1 - Read the docs https://developers.facebook.com/docs/credits/callback/
+# 2 - if headache take aspirin and go back to point 1
+
 USER_ID = 123
 ORDER_ID = 987
 
     'issued_at': 1325203762
     }
 
+order_details = {
+    'order_id': ORDER_ID,
+    'buyer': USER_ID,
+    'app': 9876,
+    'receiver': USER_ID,
+    'amount': 10,
+    'time_placed': 1329243276,
+    'update_time': 1329243277,
+    'data':'',
+    'items':[{
+        'item_id': '0',
+        'title':'100 Diamonds',
+        'description': 'Spend Diamonds in dimonds game.',
+        'image_url': 'http://image.com/diamonds.png',
+        'product_url':'',
+        'price': 10,
+        'data':''
+        }],
+    'status': 'placed'}
+
+earned_app_currency = {
+    'modified':{
+        'product': 'URL_TO_APP_CURR_WEBPAGE',
+        'product_title': 'Diamonds',
+        'product_amount': 3,
+        'credits_amount':10
+        }
+    }
+
 
 class TestFacebookCredits(TestController):
+    """Test based on fb documentation:
+    https://developers.facebook.com/docs/credits/callback/"""
 
     def test_payments_get_items(self):
         params = request_data.copy()
             status=404
             )
 
+    def test_payments_status_update_placed_item_order(self):
+        params = request_data.copy()
+        params.update(
+            {
+            'credits':{
+                'order_details': json.dumps(order_details.copy()),
+                'status': 'placed',
+                'order_id': ORDER_ID
+                }
+            })
 
-    def test_payments_status_update(self):
         params = {
-            'signed_request': get_signed_request(**request_data),
+            'signed_request': get_signed_request(**params),
+            'order_details': json.dumps(order_details.copy()),
+            'order_id': ORDER_ID,
+            'method': 'payments_status_update',
+            'status': 'placed',
+            }
+
+        result = self.app.post(
+            '/%s/credits' % conf['facebook.namespace'],
+            params,
+            )
+
+        expected = {
+            'content': {
+                'status': 'settled',
+                'order_id': 987
+                },
             'method': 'payments_status_update'
             }
-        params.update(credits_data)
+        self.assertDictEqual(expected, json.loads(result.body))
 
-        self.app.post(
+
+    def test_payments_status_update_earned_app_currency(self):
+        params = request_data.copy()
+        details = order_details.copy()
+        details['items'][0]['data'] = json.dumps(earned_app_currency)
+        params.update(
+            {
+            'credits':{
+                'order_details': json.dumps(details),
+                'status': 'placed',
+                'order_id': ORDER_ID
+                }
+            })
+
+        params = {
+            'signed_request': get_signed_request(**params),
+            'order_details': json.dumps(details),
+            'order_id': ORDER_ID,
+            'method': 'payments_status_update',
+            'status': 'placed',
+            }
+
+        result = self.app.post(
             '/%s/credits' % conf['facebook.namespace'],
             params,
-            status=404
             )
+
+        expected = {
+            'content': {
+                'status': 'settled',
+                'order_id': 987
+                },
+            'method': 'payments_status_update'
+            }
+        self.assertDictEqual(expected, json.loads(result.body))

pyramid_facebook/tests/unittests/test_pyramid_facebook.py

             '/%s/credits' % settings['facebook.namespace'],
             request_method='POST',
             custom_predicates=[m_predicate.return_value],
-            factory='pyramid_facebook.security.SignedRequestContext',
+            factory='pyramid_facebook.security.FacebookCreditsContext',
             )
         self.assertEqual(expected, call)
 
             '/%s/credits' % settings['facebook.namespace'],
             request_method='POST',
             custom_predicates=[m_predicate.return_value,m_predicate.return_value],
-            factory='pyramid_facebook.security.SignedRequestContext',
+            factory='pyramid_facebook.security.FacebookCreditsContext',
             )
         self.assertEqual(expected, call)
 
             '/%s/credits' % settings['facebook.namespace'],
             request_method='POST',
             custom_predicates=[m_predicate.return_value,m_predicate.return_value],
-            factory='pyramid_facebook.security.SignedRequestContext',
+            factory='pyramid_facebook.security.FacebookCreditsContext',
             )
         self.assertEqual(expected, call)
 
             '/%s/credits' % settings['facebook.namespace'],
             request_method='POST',
             custom_predicates=[m_predicate.return_value,m_predicate.return_value],
-            factory='pyramid_facebook.security.SignedRequestContext',
+            factory='pyramid_facebook.security.FacebookCreditsContext',
             )
         self.assertEqual(expected, call)
 

pyramid_facebook/tests/unittests/test_security.py

     Allow
     )
 
+
+class TestFacebookCreditsContext(unittest.TestCase):
+
+    @mock.patch('pyramid_facebook.security.json')
+    def test_order_details(self, m_json):
+        from pyramid_facebook.security import FacebookCreditsContext
+        ctx = FacebookCreditsContext(None)
+        ctx._fb_data = data = mock.MagicMock()
+
+        self.assertEqual(m_json.loads.return_value, ctx.order_details)
+        self.assertEqual(m_json.loads.return_value, ctx._order_details)
+
+        m_json.loads.assert_called_once_with(data['credits']['order_details'])
+
+    @mock.patch('pyramid_facebook.security.json')
+    def test_earned_currency_data(self, m_json):
+        from pyramid_facebook.security import FacebookCreditsContext
+        ctx = FacebookCreditsContext(None)
+        ctx._fb_data = data = mock.MagicMock()
+
+        self.assertEqual(
+            m_json.loads.return_value['modified'],
+            ctx.earned_currency_data
+            )
+        self.assertEqual(
+            m_json.loads.return_value['modified'],
+            ctx._earned_currency_data
+            )
+
+
 class TestSignedRequestContext(unittest.TestCase):
 
     def test_SignedRequestContext_init(self):

pyramid_facebook/tests/unittests/test_views.py

 from pyramid import testing
 from pyramid.request import Request
 from pyramid.response import Response
+from pyramid.httpexceptions import HTTPOk
 
 
 def _get_settings():
     }
 
 
+class TestFacebookPaymentsCallback(unittest.TestCase):
+
+    @mock.patch('pyramid_facebook.views.DisputedOrder')
+    def test_facebook_payments_status_update_disputed(self, m_order):
+        from pyramid_facebook.views import facebook_payments_status_update_disputed
+        ctx = mock.Mock()
+        req = mock.Mock()
+
+        res = facebook_payments_status_update_disputed(ctx, req)
+
+        req.registry.notify.assert_called_once_with(m_order.return_value)
+        self.assertIsInstance(res, HTTPOk)
+
+        # test exception
+        req.reset_mock()
+        req.registry.notify.side_effect = Exception('boom!')
+
+        res = facebook_payments_status_update_disputed(ctx, req)
+
+        req.registry.notify.assert_called_once_with(m_order.return_value)
+        self.assertIsInstance(res, HTTPOk)
+
+    @mock.patch('pyramid_facebook.views.RefundedOrder')
+    def test_facebook_payments_status_update_refunded(self, m_order):
+        from pyramid_facebook.views import facebook_payments_status_update_refunded
+        ctx = mock.Mock()
+        req = mock.Mock()
+
+        res = facebook_payments_status_update_refunded(ctx, req)
+
+        req.registry.notify.assert_called_once_with(m_order.return_value)
+        self.assertIsInstance(res, HTTPOk)
+
+        # test exception
+        req.reset_mock()
+        req.registry.notify.side_effect = Exception('boom!')
+
+        res = facebook_payments_status_update_refunded(ctx, req)
+
+        req.registry.notify.assert_called_once_with(m_order.return_value)
+        self.assertIsInstance(res, HTTPOk)
+
+
+    @mock.patch('pyramid_facebook.views.EarnedCurrencyOrder')
+    def test_facebook_payments_status_update_placed_currency_app_order(self, m_order):
+        from pyramid_facebook.views import facebook_payments_status_update_placed
+        ctx = mock.MagicMock()
+        req = mock.Mock()
+
+        res = facebook_payments_status_update_placed(ctx, req)
+
+        req.registry.notify.assert_called_once_with(m_order.return_value)
+
+        expected = {
+            'content':{
+                'status': 'settled',
+                'order_id': ctx.order_details['order_id']
+                },
+            'method': 'payments_status_update',
+            }
+        self.assertDictEqual(expected, res)
+
+    @mock.patch('pyramid_facebook.views.PlacedItemOrder')
+    def test_facebook_payments_status_update_placed_item_order(self, m_order):
+        from pyramid_facebook.views import facebook_payments_status_update_placed
+        ctx = mock.MagicMock()
+        ctx.earned_currency_data = None
+        req = mock.Mock()
+
+        res = facebook_payments_status_update_placed(ctx, req)
+
+        req.registry.notify.assert_called_once_with(m_order.return_value)
+
+    @mock.patch('pyramid_facebook.views.EarnedCurrencyOrder')
+    def test_facebook_payments_status_update_exception(self, m_order):
+        from pyramid_facebook.views import facebook_payments_status_update_placed
+        ctx = mock.MagicMock()
+        req = mock.Mock()
+        req.registry.notify.side_effect = Exception('boooom!')
+
+        res = facebook_payments_status_update_placed(ctx, req)
+
+        req.registry.notify.assert_called_once_with(m_order.return_value)
+
+        expected = {
+            'content':{
+                'status': 'canceled',
+                'order_id': ctx.order_details['order_id']
+                },
+            'method': 'payments_status_update',
+            }
+        self.assertDictEqual(expected, res)
+
+
 class TestFacebookCanvas(unittest.TestCase):
 
-
     def test_init(self):
         from pyramid_facebook.views import FacebookCanvas
         request = mock.MagicMock()
   </body>
 </html>"""
         self.assertEqual(expected, str(result))
+
+

pyramid_facebook/views.py

 # -*- coding: utf-8 -*-
+"""
+.. events
+
+Events & Views
+``````````````
+
+.. note::
+    For oauth and payments callback, `pyramid_facebook` uses custom events
+    propagated throught `pyramid` registry.
+    Read :ref:`pyramid documentation <pyramid:events_chapter>` to learn about
+    configuring an event listener.
+
+
+"""
+
 from __future__ import absolute_import
 
+import json
 import logging
 import urllib
 from string import Template
 
 
 class Base(object):
+    "Base class for views and events"
     def __init__(self, context, request):
-        self.context = context
-        self.request = request
+        self._context = context
+        self._request = request
+
+    @property
+    def context(self):
+        """Route context which can be of 2 types:
+
+        * :class:`~pyramid_facebook.security.SignedRequestContext`
+
+        * :class:`~pyramid_facebook.security.FacebookCreditsContext`
+        """
+        return self._context
+
+    @property
+    def request(self):
+        ""
+        return self._request
 
 
 class OauthAccept(Base):
+    "Event notified when an user accepts app authentication."
     pass
 
 
 class OauthDeny(Base):
+    "Event notified when an user denies app authentication."
     pass
 
 
 class DisputedOrder(Base):
+    "Event notified when an user disputes an order."
     pass
 
 
 class RefundedOrder(Base):
+    "Event notified when an user got refunded for an order."
+    pass
+
+
+class PlacedItemOrder(Base):
+    "Event notified when an user placed an item order."
+    pass
+
+
+class EarnedCurrencyOrder(Base):
+    "Event notified when an user placed an currency order."
     pass
 
 
     @view_config(permission=ViewCanvas)
     def canvas(self):
         """When user is logged in, he is authorized to view canvas.
+
+        This view raises a :py:exc:`exceptions.NotImplementedError`
         """
         raise NotImplementedError()
 
     except Exception as exc:
         log.error('facebook_payments_status_update_refunded %r', exc)
     return HTTPOk()
+
+
+@view_config(
+    route_name='facebook_payments_status_update_placed',
+    permission=ViewCanvas,
+    renderer='json'
+    )
+def facebook_payments_status_update_placed(context, request):
+    if context.earned_currency_data:
+        log.debug('Order Status Update - Earned App Currency')
+        event_class = EarnedCurrencyOrder
+    else:
+        log.debug('Order Status Update - Placed Item Order')
+        event_class = PlacedItemOrder
+    try:
+        request.registry.notify(event_class(context, request))
+    except Exception as exc:
+        log.error('facebook_payments_status_update_placed %r', exc)
+        status = 'canceled'
+    else:
+        log.debug('')
+        status = 'settled'
+    return {
+        'content':{
+            'status': status,
+            'order_id': context.order_details['order_id'],
+            },
+        'method':'payments_status_update',
+        }