Skip to content
This repository has been archived by the owner on Sep 12, 2024. It is now read-only.

Commit

Permalink
Merge pull request #222 from duffelhq/nlopes-add-links
Browse files Browse the repository at this point in the history
Add support for links sessions
  • Loading branch information
Norberto Lopes authored Feb 15, 2023
2 parents 3b72e8b + ca746a9 commit 59b58de
Show file tree
Hide file tree
Showing 7 changed files with 331 additions and 3 deletions.
9 changes: 6 additions & 3 deletions duffel_api/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
from .supporting.aircraft import AircraftClient
from .supporting.airports import AirportClient
from .supporting.airlines import AirlineClient
from .booking.offer_requests import OfferRequestClient, OfferRequestCreate
from .booking.offers import OfferClient
from .booking.orders import OrderClient, OrderCreate, OrderUpdate
Expand All @@ -15,12 +12,18 @@
from .booking.payments import PaymentClient
from .booking.seat_maps import SeatMapClient
from .duffel_payments.payment_intents import PaymentIntentClient, PaymentIntentCreate
from .links.sessions import LinksSessionClient, LinksSessionCreate
from .notifications.webhooks import WebhookClient
from .supporting.aircraft import AircraftClient
from .supporting.airports import AirportClient
from .supporting.airlines import AirlineClient

__all__ = [
"AircraftClient",
"AirportClient",
"AirlineClient",
"LinksSessionClient",
"LinksSessionCreate",
"OfferRequestClient",
"OfferRequestCreate",
"OfferClient",
Expand Down
Empty file.
237 changes: 237 additions & 0 deletions duffel_api/api/links/sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
from typing import Optional

from ...http_client import HttpClient
from ...models import Session


class LinksSessionClient(HttpClient):
def __init__(self, **kwargs):
self._url = "/links/sessions"
super().__init__(**kwargs)

def create(self):
return LinksSessionCreate(self)


class LinksSessionCreate(object):
"""Auxiliary class to provide methods for session request creation related data."""

_reference: str
_success_url: str
_failure_url: str
_abandonment_url: str
_logo_url: Optional[str]
_primary_color: Optional[str]
_secondary_color: Optional[str]
_checkout_display_text: Optional[str]
_traveller_currency: Optional[str]
_markup_amount: Optional[str]
_markup_currency: Optional[str]
_markup_rate: Optional[str]

def __init__(self, client):
self._client = client
self._reference = ""
self._success_url = ""
self._failure_url = ""
self._abandonment_url = ""
self._logo_url = None
self._primary_color = None
self._secondary_color = None
self._checkout_display_text = None
self._traveller_currency = None
self._markup_amount = None
self._markup_currency = None
self._markup_rate = None

def reference(self, reference: str):
"""Way to identify the Session.
This can be a user ID, or similar, and can be
used to reconcile the Session with your internal systems.
Example: "user_123"
"""
self._reference = reference
return self

def success_url(self, success_url: str):
"""URL the traveller will be redirected to once an order has been created.
Where the traveller will end up when their orders has been successfully created
and they press the 'Return' button.
Example: "https://example.com/success"
"""
self._success_url = success_url
return self

def failure_url(self, failure_url: str):
"""URL the traveller will be redirected to if there is a failure.
This is only applicable to a failure that can not be mitigated.
Example: "https://example.com/failure"
"""
self._failure_url = failure_url
return self

def abandonment_url(self, abandonment_url: str):
"""URL the traveller will be redirected to if they decide to abandon the session.
This happens when the users presses the 'Return' button.
Example: "https://example.com/abandonment"
"""
self._abandonment_url = abandonment_url
return self

def logo_url(self, logo_url: str):
"""URL to the logo that will appear at the top-left corner.
If not provided, Duffel's logo will be used. The logo provided will be resized to
be 16 pixels high, to ensure it fits the header. The aspect ratio will be
maintained, ensuring it won't look squashed or have misproportioned.
Example: "https://example.com/logo.svg"
"""
self._logo_url = logo_url
return self

def primary_color(self, primary_color: str):
"""Primary colour that will be used to customise the session.
It should be an hexadecimal CSS-compatible colour. If one is not provided the
default Duffel colour will be used.
Example: "#000000"
"""
self._primary_color = primary_color
return self

def secondary_color(self, secondary_color: str):
"""Secondary colour that will be used to customise the session.
It should be an hexadecimal CSS-compatible colour. If one is not provided the
default Duffel colour will be used.
Example: "#000000"
"""
self._secondary_color = secondary_color
return self

def checkout_display_text(self, checkout_display_text: str):
"""Text that will appear at the bottom of the checkout form.
If not provided nothing will be displayed.
Example: "Thank you for booking with us."
"""
self._checkout_display_text = checkout_display_text
return self

def traveller_currency(self, traveller_currency: str):
"""The currency in which the traveller will see prices and pay in. If not provided
it will default to the settlement currency of your account. The traveller will be
able to change this currency before searching.
Example: "GBP"
"""
self._traveller_currency = traveller_currency
return self

def markup_amount(self, markup_amount: str):
"""The absolute amount that will be added to the final price to be paid by the
traveller. If not provided it will default to zero. This field is required if
markup_currency is provided.
Example: "1.00"
"""
self._markup_amount = markup_amount
return self

def markup_currency(self, markup_currency: str):
"""The currency of the markup_amount. It should always match the settlement
currency of the organisation. This field is required if markup_amount is provided.
Example: "GBP"
"""
self._markup_currency = markup_currency
return self

def markup_rate(self, markup_rate: str):
"""The rate that will be applied to the total amount to be paid by the
traveller. For a 1% markup provide 0.01 as the markup_rate. If not provided it
will default to zero.
Example: "0.01"
"""
self._markup_rate = markup_rate
return self

class InvalidMandatoryFields(Exception):
"""Fields 'reference', 'success_url', 'failure_url', and 'abandonment_url' are
mandatory"""

class InvalidMarkup(Exception):
"""Both fields 'markup_amount' and 'markup_currency' have to exist or not at all
but it is not possible to have one and not the other"""

def _validate_mandatory(self):
if (
self._reference == ""
or self._success_url == ""
or self._failure_url == ""
or self._abandonment_url == ""
):
raise LinksSessionCreate.InvalidMandatoryFields

def _validate_markup(self):
if (self._markup_currency is None and self._markup_amount is not None) or (
self._markup_currency is not None and self._markup_amount is None
):
raise LinksSessionCreate.InvalidMarkup

def execute(self):
"""POST /links/sessions - trigger the call to create the session"""
self._validate_mandatory()

body_data = {
"reference": self._reference,
"success_url": self._success_url,
"failure_url": self._failure_url,
"abandonment_url": self._abandonment_url,
}

if self._logo_url:
body_data["logo_url"] = self._logo_url
if self._primary_color:
body_data["primary_color"] = self._primary_color
if self._secondary_color:
body_data["secondary_color"] = self._secondary_color
if self._checkout_display_text:
body_data["checkout_display_text"] = self._checkout_display_text
if self._traveller_currency:
body_data["traveller_currency"] = self._traveller_currency
if self._markup_rate:
body_data["markup_rate"] = self._markup_rate
if self._markup_currency and self._markup_amount:
body_data["markup_currency"] = self._markup_currency
body_data["markup_amount"] = self._markup_amount
else:
self._validate_markup()

res = self._client.do_post(self._client._url, body={"data": body_data})
return Session.from_json(res["data"])
6 changes: 6 additions & 0 deletions duffel_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
PartialOfferRequestClient,
PaymentClient,
PaymentIntentClient,
LinksSessionClient,
SeatMapClient,
WebhookClient,
)
Expand Down Expand Up @@ -123,6 +124,11 @@ def seat_maps(self):
"""Seat Maps API - /air/seat_maps"""
return SeatMapClient(**self._kwargs)

@lazy_property
def sessions(self):
"""Links Sessions API - /links/sessions"""
return LinksSessionClient(**self._kwargs)

@lazy_property
def webhooks(self):
"""Webhooks API - /air/webhooks (Preview)"""
Expand Down
2 changes: 2 additions & 0 deletions duffel_api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .payment import Payment
from .payment_intent import PaymentIntent
from .seat_map import SeatMap
from .session import Session
from .webhook import Webhook

__all__ = [
Expand All @@ -46,5 +47,6 @@
"PaymentIntent",
"Refund",
"SeatMap",
"Session",
"Webhook",
]
29 changes: 29 additions & 0 deletions duffel_api/models/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from dataclasses import dataclass


@dataclass
class Session:
"""A Session represents the traveller's session as they go through the search and book
flow to create an order.
You should create a Session every time a user wishes to go through the search and book
flow.
Once an order has been created as part of a Session it will no longer be usable to
create an order.
Each Session is valid for 20 minutes after it is first used, and can be used up to 1
hour after it is created.
"""

# The URL to the search and book Session. Redirect travellers to this URL to take them
# to Links. If you’re using a custom subdomain, the URL will use your
# subdomain. Otherwise, it’ll use links.duffel.com.
#
# Example: "https://links.duffel.com?token=U0ZNeU5UWS5nMmdEYlFBQUFCWXdNREF3TESTWU5rNWxPWGR1VDNoUFYydEdiMVZEYmdZQXB5M0RPb1lCWWdBQlVZQS5aTESTRHYwdmVyQl9vbkJ5TESTNHVsSGdIZjFiaGctY0tmdVdITESTNVlv" # noqa: E501
url: str

@classmethod
def from_json(cls, json: dict):
"""Construct a class instance from a JSON response."""
return cls(url=json["url"])
51 changes: 51 additions & 0 deletions tests/test_links_sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import pytest

from duffel_api import Duffel
from duffel_api.api import LinksSessionCreate


def test_create_links_session(requests_mock):
expected_response = {"data": {"url": "https://links.duffel.com?token=some-token"}}
requests_mock.post(
"http://someaddress/links/sessions", json=expected_response, status_code=201
)
client = Duffel(access_token="some_token", api_url="http://someaddress")
response = (
client.sessions.create()
.reference("some-reference")
.success_url("http://some-url")
.failure_url("http://some-url")
.abandonment_url("http://some-url")
.markup_currency("USD")
.markup_amount("123")
.execute()
)
assert response.url == expected_response["data"]["url"]


def test_create_links_session_with_invalid_data(requests_mock):
requests_mock.post(
"http://someaddress/links/sessions",
json={"data": {"url": "doesnt-matter"}},
status_code=201,
)
client = Duffel(access_token="some_token", api_url="http://someaddress")
creation = client.sessions.create()

with pytest.raises(LinksSessionCreate.InvalidMandatoryFields):
creation.execute()

creation = (
creation.reference("some-reference")
.success_url("http://some-url")
.failure_url("http://some-url")
.abandonment_url("http://some-url")
)

with pytest.raises(LinksSessionCreate.InvalidMarkup):
creation.markup_currency("USD").execute()

# Override this so that the next one also fails
creation._markup_currency = None
with pytest.raises(LinksSessionCreate.InvalidMarkup):
creation.markup_amount("123").execute()

0 comments on commit 59b58de

Please sign in to comment.