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

Commit 2cb325b

Browse files
committed
feat: add support for links
1 parent 3b72e8b commit 2cb325b

File tree

7 files changed

+331
-3
lines changed

7 files changed

+331
-3
lines changed

duffel_api/api/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
from .supporting.aircraft import AircraftClient
2-
from .supporting.airports import AirportClient
3-
from .supporting.airlines import AirlineClient
41
from .booking.offer_requests import OfferRequestClient, OfferRequestCreate
52
from .booking.offers import OfferClient
63
from .booking.orders import OrderClient, OrderCreate, OrderUpdate
@@ -15,12 +12,18 @@
1512
from .booking.payments import PaymentClient
1613
from .booking.seat_maps import SeatMapClient
1714
from .duffel_payments.payment_intents import PaymentIntentClient, PaymentIntentCreate
15+
from .links.sessions import LinksSessionClient, LinksSessionCreate
1816
from .notifications.webhooks import WebhookClient
17+
from .supporting.aircraft import AircraftClient
18+
from .supporting.airports import AirportClient
19+
from .supporting.airlines import AirlineClient
1920

2021
__all__ = [
2122
"AircraftClient",
2223
"AirportClient",
2324
"AirlineClient",
25+
"LinksSessionClient",
26+
"LinksSessionCreate",
2427
"OfferRequestClient",
2528
"OfferRequestCreate",
2629
"OfferClient",

duffel_api/api/links/__init__.py

Whitespace-only changes.

duffel_api/api/links/sessions.py

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
from typing import Optional
2+
3+
from ...http_client import HttpClient
4+
from ...models import Session
5+
6+
7+
class LinksSessionClient(HttpClient):
8+
def __init__(self, **kwargs):
9+
self._url = "/links/sessions"
10+
super().__init__(**kwargs)
11+
12+
def create(self):
13+
return LinksSessionCreate(self)
14+
15+
16+
class LinksSessionCreate(object):
17+
"""Auxiliary class to provide methods for session request creation related data."""
18+
19+
_reference: str
20+
_success_url: str
21+
_failure_url: str
22+
_abandonment_url: str
23+
_logo_url: Optional[str]
24+
_primary_color: Optional[str]
25+
_secondary_color: Optional[str]
26+
_checkout_display_text: Optional[str]
27+
_traveller_currency: Optional[str]
28+
_markup_amount: Optional[str]
29+
_markup_currency: Optional[str]
30+
_markup_rate: Optional[str]
31+
32+
def __init__(self, client):
33+
self._client = client
34+
self._reference = ""
35+
self._success_url = ""
36+
self._failure_url = ""
37+
self._abandonment_url = ""
38+
self._logo_url = None
39+
self._primary_color = None
40+
self._secondary_color = None
41+
self._checkout_display_text = None
42+
self._traveller_currency = None
43+
self._markup_amount = None
44+
self._markup_currency = None
45+
self._markup_rate = None
46+
47+
def reference(self, reference: str):
48+
"""Way to identify the Session.
49+
50+
This can be a user ID, or similar, and can be
51+
used to reconcile the Session with your internal systems.
52+
53+
Example: "user_123"
54+
55+
"""
56+
self._reference = reference
57+
return self
58+
59+
def success_url(self, success_url: str):
60+
"""URL the traveller will be redirected to once an order has been created.
61+
62+
Where the traveller will end up when their orders has been successfully created
63+
and they press the 'Return' button.
64+
65+
Example: "https://example.com/success"
66+
67+
"""
68+
self._success_url = success_url
69+
return self
70+
71+
def failure_url(self, failure_url: str):
72+
"""URL the traveller will be redirected to if there is a failure.
73+
74+
This is only applicable to a failure that can not be mitigated.
75+
76+
Example: "https://example.com/failure"
77+
78+
"""
79+
self._failure_url = failure_url
80+
return self
81+
82+
def abandonment_url(self, abandonment_url: str):
83+
"""URL the traveller will be redirected to if they decide to abandon the session.
84+
85+
This happens when the users presses the 'Return' button.
86+
87+
Example: "https://example.com/abandonment"
88+
89+
"""
90+
self._abandonment_url = abandonment_url
91+
return self
92+
93+
def logo_url(self, logo_url: str):
94+
"""URL to the logo that will appear at the top-left corner.
95+
96+
If not provided, Duffel's logo will be used. The logo provided will be resized to
97+
be 16 pixels high, to ensure it fits the header. The aspect ratio will be
98+
maintained, ensuring it won't look squashed or have misproportioned.
99+
100+
Example: "https://example.com/logo.svg"
101+
102+
"""
103+
self._logo_url = logo_url
104+
return self
105+
106+
def primary_color(self, primary_color: str):
107+
"""Primary colour that will be used to customise the session.
108+
109+
It should be an hexadecimal CSS-compatible colour. If one is not provided the
110+
default Duffel colour will be used.
111+
112+
Example: "#000000"
113+
114+
"""
115+
self._primary_color = primary_color
116+
return self
117+
118+
def secondary_color(self, secondary_color: str):
119+
"""Secondary colour that will be used to customise the session.
120+
121+
It should be an hexadecimal CSS-compatible colour. If one is not provided the
122+
default Duffel colour will be used.
123+
124+
Example: "#000000"
125+
126+
"""
127+
self._secondary_color = secondary_color
128+
return self
129+
130+
def checkout_display_text(self, checkout_display_text: str):
131+
"""Text that will appear at the bottom of the checkout form.
132+
133+
If not provided nothing will be displayed.
134+
135+
Example: "Thank you for booking with us."
136+
137+
"""
138+
self._checkout_display_text = checkout_display_text
139+
return self
140+
141+
def traveller_currency(self, traveller_currency: str):
142+
"""The currency in which the traveller will see prices and pay in. If not provided
143+
it will default to the settlement currency of your account. The traveller will be
144+
able to change this currency before searching.
145+
146+
Example: "GBP"
147+
148+
"""
149+
self._traveller_currency = traveller_currency
150+
return self
151+
152+
def markup_amount(self, markup_amount: str):
153+
"""The absolute amount that will be added to the final price to be paid by the
154+
traveller. If not provided it will default to zero. This field is required if
155+
markup_currency is provided.
156+
157+
Example: "1.00"
158+
159+
"""
160+
self._markup_amount = markup_amount
161+
return self
162+
163+
def markup_currency(self, markup_currency: str):
164+
"""The currency of the markup_amount. It should always match the settlement
165+
currency of the organisation. This field is required is markup_amount is provided.
166+
167+
Example: "GBP"
168+
169+
"""
170+
self._markup_currency = markup_currency
171+
return self
172+
173+
def markup_rate(self, markup_rate: str):
174+
"""The rate that will be applied to the total amount to be paid by the
175+
traveller. For a 1% markup provide 0.01 as the markup_rate. If not provided it
176+
will default to zero.
177+
178+
Example: "0.01"
179+
180+
"""
181+
self._markup_rate = markup_rate
182+
return self
183+
184+
class InvalidMandatoryFields(Exception):
185+
"""Fields 'reference', 'success_url', 'failure_url', and 'abandonment_url' are
186+
mandatory"""
187+
188+
class InvalidMarkup(Exception):
189+
"""Both fields 'markup_amount' and 'markup_currency' have to exist or not at all
190+
but it is not possible to have one and not the other"""
191+
192+
def _validate_mandatory(self):
193+
if (
194+
self._reference == ""
195+
or self._success_url == ""
196+
or self._failure_url == ""
197+
or self._abandonment_url == ""
198+
):
199+
raise LinksSessionCreate.InvalidMandatoryFields
200+
201+
def _validate_markup(self):
202+
if (self._markup_currency is None and self._markup_amount is not None) or (
203+
self._markup_currency is not None and self._markup_amount is None
204+
):
205+
raise LinksSessionCreate.InvalidMarkup
206+
207+
def execute(self):
208+
"""POST /links/sessions - trigger the call to create the session"""
209+
self._validate_mandatory()
210+
211+
body_data = {
212+
"reference": self._reference,
213+
"success_url": self._success_url,
214+
"failure_url": self._failure_url,
215+
"abandonment_url": self._abandonment_url,
216+
}
217+
218+
if self._logo_url:
219+
body_data["logo_url"] = self._logo_url
220+
if self._primary_color:
221+
body_data["primary_color"] = self._primary_color
222+
if self._secondary_color:
223+
body_data["secondary_color"] = self._secondary_color
224+
if self._checkout_display_text:
225+
body_data["checkout_display_text"] = self._checkout_display_text
226+
if self._traveller_currency:
227+
body_data["traveller_currency"] = self._traveller_currency
228+
if self._markup_rate:
229+
body_data["markup_rate"] = self._markup_rate
230+
if self._markup_currency and self._markup_amount:
231+
body_data["markup_currency"] = self._markup_currency
232+
body_data["markup_amount"] = self._markup_amount
233+
else:
234+
self._validate_markup()
235+
236+
res = self._client.do_post(self._client._url, body={"data": body_data})
237+
return Session.from_json(res["data"])

duffel_api/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
PartialOfferRequestClient,
1414
PaymentClient,
1515
PaymentIntentClient,
16+
LinksSessionClient,
1617
SeatMapClient,
1718
WebhookClient,
1819
)
@@ -123,6 +124,11 @@ def seat_maps(self):
123124
"""Seat Maps API - /air/seat_maps"""
124125
return SeatMapClient(**self._kwargs)
125126

127+
@lazy_property
128+
def sessions(self):
129+
"""Links Sessions API - /links/sessions"""
130+
return LinksSessionClient(**self._kwargs)
131+
126132
@lazy_property
127133
def webhooks(self):
128134
"""Webhooks API - /air/webhooks (Preview)"""

duffel_api/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .payment import Payment
2222
from .payment_intent import PaymentIntent
2323
from .seat_map import SeatMap
24+
from .session import Session
2425
from .webhook import Webhook
2526

2627
__all__ = [
@@ -46,5 +47,6 @@
4647
"PaymentIntent",
4748
"Refund",
4849
"SeatMap",
50+
"Session",
4951
"Webhook",
5052
]

duffel_api/models/session.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass
5+
class Session:
6+
"""A Session represents the traveller's session as they go through the search and book
7+
flow to create an order.
8+
9+
You should create a Session every time a user wishes to go through the search and book
10+
flow.
11+
12+
Once an order has been created as part of a Session it will no longer be usable to
13+
create an order.
14+
15+
Each Session is valid for 20 minutes after it is first used, and can be used up to 1
16+
hour after it is created.
17+
"""
18+
19+
# The URL to the search and book Session. Redirect travellers to this URL to take them
20+
# to Links. If you’re using a custom subdomain, the URL will use your
21+
# subdomain. Otherwise, it’ll use links.duffel.com.
22+
#
23+
# Example: "https://links.duffel.com?token=U0ZNeU5UWS5nMmdEYlFBQUFCWXdNREF3TESTWU5rNWxPWGR1VDNoUFYydEdiMVZEYmdZQXB5M0RPb1lCWWdBQlVZQS5aTESTRHYwdmVyQl9vbkJ5TESTNHVsSGdIZjFiaGctY0tmdVdITESTNVlv" # noqa: E501
24+
url: str
25+
26+
@classmethod
27+
def from_json(cls, json: dict):
28+
"""Construct a class instance from a JSON response."""
29+
return cls(url=json["url"])

tests/test_links_sessions.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import pytest
2+
3+
from duffel_api import Duffel
4+
from duffel_api.api import LinksSessionCreate
5+
6+
7+
def test_create_links_session(requests_mock):
8+
expected_response = {"data": {"url": "https://links.duffel.com?token=some-token"}}
9+
requests_mock.post(
10+
"http://someaddress/links/sessions", json=expected_response, status_code=201
11+
)
12+
client = Duffel(access_token="some_token", api_url="http://someaddress")
13+
response = (
14+
client.sessions.create()
15+
.reference("some-reference")
16+
.success_url("http://some-url")
17+
.failure_url("http://some-url")
18+
.abandonment_url("http://some-url")
19+
.markup_currency("USD")
20+
.markup_amount("123")
21+
.execute()
22+
)
23+
assert response.url == expected_response["data"]["url"]
24+
25+
26+
def test_create_links_session_with_invalid_data(requests_mock):
27+
requests_mock.post(
28+
"http://someaddress/links/sessions",
29+
json={"data": {"url": "doesnt-matter"}},
30+
status_code=201,
31+
)
32+
client = Duffel(access_token="some_token", api_url="http://someaddress")
33+
creation = client.sessions.create()
34+
35+
with pytest.raises(LinksSessionCreate.InvalidMandatoryFields):
36+
creation.execute()
37+
38+
creation = (
39+
creation.reference("some-reference")
40+
.success_url("http://some-url")
41+
.failure_url("http://some-url")
42+
.abandonment_url("http://some-url")
43+
)
44+
45+
with pytest.raises(LinksSessionCreate.InvalidMarkup):
46+
creation.markup_currency("USD").execute()
47+
48+
# Override this so that the next one also fails
49+
creation._markup_currency = None
50+
with pytest.raises(LinksSessionCreate.InvalidMarkup):
51+
creation.markup_amount("123").execute()

0 commit comments

Comments
 (0)