Skip to content

Commit

Permalink
Merge pull request #14 from oscaro/feature/variable-config
Browse files Browse the repository at this point in the history
Allow altering config values based on request
  • Loading branch information
maiksprenger committed Jul 8, 2015
2 parents e0ef363 + 458d4c0 commit f2c7ccc
Show file tree
Hide file tree
Showing 9 changed files with 244 additions and 149 deletions.
41 changes: 22 additions & 19 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,31 +44,34 @@ Add ``'adyen'`` to ``INSTALLED_APPS`` and run::
to create the appropriate database tables.

Configuration
-------------
=============

Edit your ``settings.py`` to set the following settings:

.. code-block:: python
You have two approaches to configure `django-oscar-adyen`.

ADYEN_IDENTIFIER = 'YourAdyenAccountName'
ADYEN_SECRET_KEY = 'YourAdyenSkinSecretKey'
ADYEN_ACTION_URL = 'https://test.adyen.com/hpp/select.shtml'
Settings-based configuration
----------------------------
For simple deployments, setting the required values in the settings will suffice.

Obviously, you'll need to specify different settings in your test environment
as opposed to your production environment.
Edit your ``settings.py`` to set the following settings:

* ``ADYEN_IDENTIFIER`` - The identifier of your Adyen account.
* ``ADYEN_SKIN_CODE`` - The code for your Adyen skin.
* ``ADYEN_SECRET_KEY`` - The secret key defined in your Adyen skin.
* ``ADYEN_ACTION_URL`` -
The URL towards which the Adyen form should be POSTed to initiate the payment process
(e.g. 'https://test.adyen.com/hpp/select.shtml').
* ``ADYEN_IP_ADDRESS_HTTP_HEADER`` - Optional. The header in `META` to inspect to determine
the IP address of the request. Defaults to `REMOTE_ADDR`.

Settings
========
You will likely need to specify different settings in your test environment
as opposed to your production environment.

====================== =========================================================
Setting Description
---------------------- ---------------------------------------------------------
``ADYEN_IDENTIFIER`` The identifier of your Adyen account
``ADYEN_SECRET_KEY`` The secret key defined in your Adyen skin
``ADYEN_ACTION_URL`` The URL towards which the Adyen form should be POSTed
to initiate the payment process
====================== =========================================================
Class-based configuration
-------------------------
In more complex deployments, you will want to e.g. alter the Adyen identifier based on
the request. That is not easily implemented with Django settings, so you can alternatively
set ``ADYEN_CONFIG_CLASS`` to a config class of your own.
See `adyen.settings_config.FromSettingsConfig` for an example.

License
=======
Expand Down
35 changes: 35 additions & 0 deletions adyen/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from django.conf import settings
from django.utils.module_loading import import_string


def get_config():
"""
Returns an instance of the configured config class.
"""

try:
config_class_string = settings.ADYEN_CONFIG_CLASS
except AttributeError:
config_class_string = 'adyen.settings_config.FromSettingsConfig'
return import_string(config_class_string)()


class AbstractAdyenConfig:
"""
The base implementation for a config class.
"""

def get_identifier(self, request):
raise NotImplementedError

def get_action_url(self, request):
raise NotImplementedError

def get_skin_code(self, request):
raise NotImplementedError

def get_skin_secret(self, request):
raise NotImplementedError

def get_ip_address_header(self):
raise NotImplementedError
44 changes: 19 additions & 25 deletions adyen/facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,33 @@
import iptools
import logging

from django.conf import settings
from django.http import HttpResponse

from .gateway import Constants, Gateway, PaymentNotification, PaymentRedirection
from .models import AdyenTransaction
from .config import get_config

logger = logging.getLogger('adyen')

def get_gateway(request, config):
return Gateway({
Constants.IDENTIFIER: config.get_identifier(request),
Constants.SECRET_KEY: config.get_skin_secret(request),
Constants.ACTION_URL: config.get_action_url(request),
})

class Facade():

def __init__(self, **kwargs):
init_params = {
Constants.IDENTIFIER: settings.ADYEN_IDENTIFIER,
Constants.SECRET_KEY: settings.ADYEN_SECRET_KEY,
Constants.ACTION_URL: settings.ADYEN_ACTION_URL,
}
# Initialize the gateway.
self.gateway = Gateway(init_params)
class Facade:

def __init__(self):
self.config = get_config()

def build_payment_form_fields(self, params):
def build_payment_form_fields(self, request, params):
"""
Return a dict containing the name and value of all the hidden fields
necessary to build the form that will be POSTed to Adyen.
"""
return self.gateway.build_payment_form_fields(params)
return get_gateway(request, self.config).build_payment_form_fields(params)

@classmethod
def _is_valid_ip_address(cls, s):
Expand All @@ -39,8 +40,7 @@ def _is_valid_ip_address(cls, s):
"""
return iptools.ipv4.validate_ip(s) or iptools.ipv6.validate_ip(s)

@classmethod
def _get_origin_ip_address(cls, request):
def _get_origin_ip_address(self, request):
"""
Return the IP address where the payment originated from or None if
we are unable to get it -- which *will* happen if we received a
Expand All @@ -54,17 +54,14 @@ def _get_origin_ip_address(cls, request):
Django setting. We fallback on the canonical `REMOTE_ADDR`, used for
regular, unproxied requests.
"""
try:
ip_address_http_header = settings.ADYEN_IP_ADDRESS_HTTP_HEADER
except AttributeError:
ip_address_http_header = 'REMOTE_ADDR'
ip_address_http_header = self.config.get_ip_address_header()

try:
ip_address = request.META[ip_address_http_header]
except KeyError:
return None

if not cls._is_valid_ip_address(ip_address):
if not self._is_valid_ip_address(ip_address):
logger.warn("%s is not a valid IP address", ip_address)
return None

Expand Down Expand Up @@ -146,11 +143,7 @@ def handle_payment_feedback(self, request, record_audit_trail):
Validate, process, optionally record audit trail and provide feedback
about the current payment response.
"""
success, output_data = False, {}

# We must first find out whether this is a redirection or a notification.
client = self.gateway
params = response_class = None

if request.method == 'GET':
params = request.GET
Expand All @@ -162,7 +155,8 @@ def handle_payment_feedback(self, request, record_audit_trail):
raise RuntimeError("Only GET and POST requests are supported.")

# Then we can instantiate the appropriate class from the gateway.
response = response_class(client, params)
gateway = get_gateway(request, self.config)
response = response_class(gateway, params)

# Note that this may raise an exception if the response is invalid.
# For example: MissingFieldException, UnexpectedFieldException, ...
Expand Down Expand Up @@ -209,7 +203,7 @@ def assess_notification_relevance(self, request):
# - On the other hand we have the `live` POST parameter, which lets
# us know which Adyen platform fired this request.
current_platform = (Constants.LIVE
if Constants.LIVE in settings.ADYEN_ACTION_URL
if Constants.LIVE in self.config.get_action_url(request)
else Constants.TEST)

origin_platform = (Constants.LIVE
Expand Down
82 changes: 28 additions & 54 deletions adyen/scaffold.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
# -*- coding: utf-8 -*-

import bleach

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils import timezone

from .facade import Facade
from .gateway import Constants, MissingFieldException
from .config import get_config


class Scaffold():
class Scaffold:

# These are the constants that all scaffolds are expected to return
# to a multi-psp application. They might look like those actually returned
Expand All @@ -26,34 +21,17 @@ class Scaffold():
Constants.PAYMENT_RESULT_REFUSED: PAYMENT_STATUS_REFUSED,
}

def __init__(self, order_data=None):
self.facade = Facade()
try:
for name, value in order_data.items():
setattr(self, name, value)
except AttributeError:
pass
def __init__(self):
self.config = get_config()

def get_form_action(self):
def get_form_action(self, request):
""" Return the URL where the payment form should be submitted. """
try:
return settings.ADYEN_ACTION_URL
except AttributeError:
raise ImproperlyConfigured("Please set ADYEN_ACTION_URL")
return self.config.get_action_url(request)

def get_form_fields(self):
""" Return the payment form fields, rendered into HTML. """

fields_list = self.get_form_fields_list()
return ''.join([
'<input type="%s" name="%s" value="%s">\n' % (
f.get('type'), f.get('name'), bleach.clean(f.get('value'))
) for f in fields_list
])

def get_form_fields_list(self):
def get_form_fields(self, request, order_data):
"""
Return the payment form fields as a list of dicts.
Expects a large-ish order_data dictionary with details of the order.
"""
now = timezone.now()
session_validity = now + timezone.timedelta(minutes=20)
Expand All @@ -64,35 +42,33 @@ def get_form_fields_list(self):
# Build common field specs
try:
field_specs = {
Constants.MERCHANT_ACCOUNT: settings.ADYEN_IDENTIFIER,
Constants.MERCHANT_REFERENCE: str(self.order_number),
Constants.SHOPPER_REFERENCE: self.client_id,
Constants.SHOPPER_EMAIL: self.client_email,
Constants.CURRENCY_CODE: self.currency_code,
Constants.PAYMENT_AMOUNT: self.amount,
Constants.SKIN_CODE: settings.ADYEN_SKIN_CODE,
Constants.MERCHANT_ACCOUNT: self.config.get_identifier(request),
Constants.SKIN_CODE: self.config.get_skin_code(request),
Constants.SESSION_VALIDITY: session_validity.strftime(session_validity_format),
Constants.SHIP_BEFORE_DATE: ship_before_date.strftime(ship_before_date_format),
Constants.SHOPPER_LOCALE: self.shopper_locale,
Constants.COUNTRY_CODE: self.country_code,

# Adyen does not provide the payment amount in the
# return URL, so we store it in this field to
# avoid a database query to get it back then.
Constants.MERCHANT_RETURN_DATA: self.amount,

Constants.MERCHANT_REFERENCE: str(order_data['order_number']),
Constants.SHOPPER_REFERENCE: order_data['client_id'],
Constants.SHOPPER_EMAIL: order_data['client_email'],
Constants.CURRENCY_CODE: order_data['currency_code'],
Constants.PAYMENT_AMOUNT: order_data['amount'],
Constants.SHOPPER_LOCALE: order_data['shopper_locale'],
Constants.COUNTRY_CODE: order_data['country_code'],
# Adyen does not provide the payment amount in the return URL, so we store it in
# this field to avoid a database query to get it back then.
Constants.MERCHANT_RETURN_DATA: order_data['amount'],
}

except AttributeError:
raise MissingFieldException
except KeyError:
raise MissingFieldException("One or more fields are missing from the order data.")

# Check for overridden return URL.
return_url = getattr(self, 'return_url', None)
return_url = order_data.get('return_url', None)
if return_url is not None:
return_url = return_url.replace('PAYMENT_PROVIDER_CODE', Constants.ADYEN)
field_specs[Constants.MERCHANT_RETURN_URL] = return_url

return self.facade.build_payment_form_fields(field_specs)
return Facade().build_payment_form_fields(request, field_specs)

def _normalize_feedback(self, feedback):
"""
Expand All @@ -105,16 +81,14 @@ def _normalize_feedback(self, feedback):

def handle_payment_feedback(self, request):
return self._normalize_feedback(
self.facade.handle_payment_feedback(
request, record_audit_trail=True))
Facade().handle_payment_feedback(request, record_audit_trail=True))

def check_payment_outcome(self, request):
return self._normalize_feedback(
self.facade.handle_payment_feedback(
request, record_audit_trail=False))
Facade().handle_payment_feedback(request, record_audit_trail=False))

def assess_notification_relevance(self, request):
return self.facade.assess_notification_relevance(request)
return Facade().assess_notification_relevance(request)

def build_notification_response(self, request):
return self.facade.build_notification_response(request)
return Facade().build_notification_response(request)
43 changes: 43 additions & 0 deletions adyen/settings_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured

from .config import AbstractAdyenConfig


class FromSettingsConfig(AbstractAdyenConfig):
"""
This config class is enabled by default and useful in simple deployments.
One can just set all needed values in the Django settings. It also
exists for backwards-compatibility with previous deployments.
"""

def __init__(self):
"""
We complain as early as possible when Django settings are missing.
"""
required_settings = [
'ADYEN_IDENTIFIER', 'ADYEN_ACTION_URL', 'ADYEN_SKIN_CODE', 'ADYEN_SECRET_KEY']
missing_settings = [
setting for setting in required_settings if not hasattr(settings, setting)]
if missing_settings:
raise ImproperlyConfigured(
"You are using the FromSettingsConfig config class, but haven't set the "
"the following required settings: %s" % missing_settings)

def get_identifier(self, request):
return settings.ADYEN_IDENTIFIER

def get_action_url(self, request):
return settings.ADYEN_ACTION_URL

def get_skin_code(self, request):
return settings.ADYEN_SKIN_CODE

def get_skin_secret(self, request):
return settings.ADYEN_SECRET_KEY

def get_ip_address_header(self):
try:
return settings.ADYEN_IP_ADDRESS_HTTP_HEADER
except AttributeError:
return 'REMOTE_ADDR'
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
bleach==1.4
iptools==0.6.1
requests>=2.0.0,<3.0
freezegun==0.1.18
Expand Down
1 change: 0 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
packages=find_packages(),
include_package_data=True,
install_requires=[
'bleach==1.4',
'django-oscar>=0.7',
'iptools==0.6.1',
'requests>=2.0,<3.0',
Expand Down
Loading

0 comments on commit f2c7ccc

Please sign in to comment.