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

Commit dcb791c

Browse files
author
Norberto Lopes
authored
Merge pull request #217 from duffelhq/nlopes-add-multi-step-search
Add support for multi-step search
2 parents 93ae39d + c66a123 commit dcb791c

16 files changed

+1742
-2
lines changed

duffel_api/api/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
from .booking.order_changes import OrderChangeClient
99
from .booking.order_change_offers import OrderChangeOfferClient
1010
from .booking.order_change_requests import OrderChangeRequestClient
11+
from .booking.partial_offer_requests import (
12+
PartialOfferRequestClient,
13+
PartialOfferRequestCreate,
14+
)
1115
from .booking.payments import PaymentClient
1216
from .booking.seat_maps import SeatMapClient
1317
from .duffel_payments.payment_intents import PaymentIntentClient, PaymentIntentCreate
@@ -27,6 +31,8 @@
2731
"OrderCreate",
2832
"OrderUpdate",
2933
"OrderCancellationClient",
34+
"PartialOfferRequestClient",
35+
"PartialOfferRequestCreate",
3036
"PaymentClient",
3137
"PaymentIntentClient",
3238
"PaymentIntentCreate",
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
from ...http_client import HttpClient
2+
from ...models import OfferRequest
3+
4+
5+
class PartialOfferRequestClient(HttpClient):
6+
"""To search for and select flights separately for each slice of the journey, you'll
7+
need to create a partial offer reques. A partial offer request describes the
8+
passengers and where and when they want to travel (in the form of a list of
9+
slices). It may also include additional filters (e.g. a particular cabin to
10+
travel in).
11+
12+
"""
13+
14+
def __init__(self, **kwargs):
15+
self._url = "/air/partial_offer_requests"
16+
super().__init__(**kwargs)
17+
18+
def get(self, id_, selected_partial_offer=None):
19+
"""GET /air/partial_offer_requests/:id
20+
21+
If a selected_partial_offer is passed:
22+
GET /air/partial_offer_requests/:id?selected_partial_offer[]=:selected_partial_offer
23+
24+
25+
Retrieves a partial offers request by its ID, only including partial offers for
26+
the current slice of multi-step search flow.
27+
""" # noqa: E501
28+
response = None
29+
if selected_partial_offer is None:
30+
response = self.do_get(f"{self._url}/{id_}")
31+
else:
32+
response = self.do_get(
33+
f"{self._url}/{id_}",
34+
query_params={"selected_partial_offer[]": selected_partial_offer},
35+
)
36+
37+
if response is not None:
38+
return OfferRequest.from_json(response["data"])
39+
40+
def fares(self, id_, selected_partial_offers=[]):
41+
"""GET /air/partial_offer_requests/:id/fares
42+
43+
Retrieves an offer request with offers for fares matching selected partial offers.
44+
"""
45+
response = self.do_get(
46+
f"{self._url}/{id_}/fares",
47+
query_params={"selected_partial_offer[]": selected_partial_offers},
48+
)
49+
if response is not None:
50+
return OfferRequest.from_json(response["data"])
51+
52+
def create(self):
53+
"""Initiate creation of a Partial Offer Request"""
54+
return PartialOfferRequestCreate(self)
55+
56+
57+
class PartialOfferRequestCreate(object):
58+
"""Auxiliary class to provide methods for partial offer request creation related data""" # noqa: E501
59+
60+
class InvalidCabinClass(Exception):
61+
"""Invalid cabin class provided"""
62+
63+
class InvalidNumberOfPassengers(Exception):
64+
"""Invalid number of passengers provided"""
65+
66+
class InvalidNumberOfSlices(Exception):
67+
"""Invalid number of slices provided"""
68+
69+
class InvalidPassenger(Exception):
70+
"""Invalid passenger data provided"""
71+
72+
class InvalidSlice(Exception):
73+
"""Invalid slice data provided"""
74+
75+
class InvalidMaxConnectionValue(Exception):
76+
"""Invalid max connection value provided"""
77+
78+
def __init__(self, client):
79+
self._client = client
80+
self._cabin_class = "economy"
81+
self._passengers = []
82+
self._slices = []
83+
self._max_connections = 1
84+
85+
@staticmethod
86+
def _validate_cabin_class(cabin_class):
87+
"""Validate cabin class"""
88+
if cabin_class not in [
89+
"first",
90+
"business",
91+
"economy",
92+
"premium_economy",
93+
]:
94+
raise PartialOfferRequestCreate.InvalidCabinClass(cabin_class)
95+
96+
@staticmethod
97+
def _validate_passengers(passengers):
98+
"""Validate passenger count and the data provided for each if any were given"""
99+
if len(passengers) == 0:
100+
raise PartialOfferRequestCreate.InvalidNumberOfPassengers(passengers)
101+
for passenger in passengers:
102+
if not ("type" in passenger or "age" in passenger):
103+
raise PartialOfferRequestCreate.InvalidPassenger(passenger)
104+
105+
@staticmethod
106+
def _validate_slices(slices):
107+
"""Validate number of slices and the data provided for each if any were given"""
108+
if len(slices) == 0:
109+
raise PartialOfferRequestCreate.InvalidNumberOfSlices(slices)
110+
for travel_slice in slices:
111+
if set(travel_slice.keys()) != set(
112+
["departure_date", "destination", "origin"]
113+
):
114+
raise PartialOfferRequestCreate.InvalidSlice(travel_slice)
115+
116+
@staticmethod
117+
def _validate_max_connections(max_connections):
118+
"""Validate the max connection number"""
119+
if not isinstance(max_connections, int) or max_connections < 0:
120+
raise PartialOfferRequestCreate.InvalidMaxConnectionValue(max_connections)
121+
122+
def cabin_class(self, cabin_class):
123+
"""Set cabin_class - defaults to 'economy'"""
124+
PartialOfferRequestCreate._validate_cabin_class(cabin_class)
125+
self._cabin_class = cabin_class
126+
return self
127+
128+
def passengers(self, passengers):
129+
"""Set the passengers that will be travelling"""
130+
PartialOfferRequestCreate._validate_passengers(passengers)
131+
self._passengers = passengers
132+
return self
133+
134+
def slices(self, slices):
135+
"""Set the slices for the origin-destination we want to travel"""
136+
PartialOfferRequestCreate._validate_slices(slices)
137+
self._slices = slices
138+
return self
139+
140+
def max_connections(self, max_connections):
141+
"""Set the max_connections for the journey we want to travel"""
142+
PartialOfferRequestCreate._validate_max_connections(max_connections)
143+
self._max_connections = max_connections
144+
return self
145+
146+
def execute(self):
147+
"""POST /air/partial_offer_requests - trigger the call to create the offer_request""" # noqa: E501
148+
PartialOfferRequestCreate._validate_passengers(self._passengers)
149+
PartialOfferRequestCreate._validate_slices(self._slices)
150+
res = self._client.do_post(
151+
self._client._url,
152+
body={
153+
"data": {
154+
"cabin_class": self._cabin_class,
155+
"passengers": self._passengers,
156+
"max_connections": self._max_connections,
157+
"slices": self._slices,
158+
}
159+
},
160+
)
161+
return OfferRequest.from_json(res["data"])

duffel_api/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
OrderClient,
1111
OrderChangeClient,
1212
OrderChangeOfferClient,
13+
PartialOfferRequestClient,
1314
PaymentClient,
1415
PaymentIntentClient,
1516
SeatMapClient,
@@ -102,6 +103,11 @@ def order_change_requests(self):
102103
"""Order Change Requests API - /air/order_change_requests"""
103104
return OrderChangeRequestClient(**self._kwargs)
104105

106+
@lazy_property
107+
def partial_offer_requests(self):
108+
"""Partial Offer Requests API - /air/partial_offer_requests"""
109+
return PartialOfferRequestClient(**self._kwargs)
110+
105111
@lazy_property
106112
def payment_intents(self):
107113
"""Payment Intents API - /payments/payment_intents"""

duffel_api/models/offer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ class Offer:
439439
updated_at: datetime
440440
expires_at: datetime
441441
owner: Airline
442+
partial: bool
442443
passenger_identity_documents_required: bool
443444
passengers: Sequence[OfferPassenger]
444445
payment_requirements: PaymentRequirements
@@ -471,6 +472,7 @@ def from_json(cls, json: dict):
471472
updated_at=datetime.strptime(json["updated_at"], "%Y-%m-%dT%H:%M:%S.%fZ"),
472473
expires_at=parse_datetime(json["expires_at"]),
473474
owner=Airline.from_json(json["owner"]),
475+
partial=json["partial"],
474476
passenger_identity_documents_required=json[
475477
"passenger_identity_documents_required"
476478
],

examples/handling-error.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from datetime import date
2+
import os
3+
4+
from duffel_api import Duffel
5+
from duffel_api.http_client import ApiError
6+
7+
8+
if __name__ == "__main__":
9+
print("Duffel Flights API - Python Example on handling errors")
10+
11+
os.environ["DUFFEL_ACCESS_TOKEN"] = "some-invalid-token-to-trigger-an-error"
12+
13+
client = Duffel()
14+
departure_date = date.today().replace(date.today().year + 1)
15+
slices = [
16+
{
17+
"origin": "LHR",
18+
"destination": "STN",
19+
"departure_date": departure_date.strftime("%Y-%m-%d"),
20+
},
21+
]
22+
try:
23+
offer_request = (
24+
client.offer_requests.create()
25+
.passengers(
26+
[{"type": "adult"}, {"age": 1}, {"age": (date.today().year - 2003)}]
27+
)
28+
.slices(slices)
29+
.execute()
30+
)
31+
except ApiError as exc:
32+
# This is super useful when contacting Duffel support
33+
print(f"Request ID: {exc.meta['request_id']}")
34+
print(f"Status Code: {exc.meta['status']}")
35+
print("Errors: ")
36+
for error in exc.errors:
37+
print(f" Title: {error['title']}")
38+
print(f" Code: {error['code']}")
39+
print(f" Message: {error['message']}")
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from datetime import date, timedelta
2+
from decimal import Decimal
3+
4+
from duffel_api import Duffel
5+
6+
7+
if __name__ == "__main__":
8+
print(
9+
"Duffel Flights API - search, book and cancel example for multi-step search - one way"
10+
)
11+
12+
client = Duffel()
13+
departure_date = (date.today() + timedelta(weeks=2)).strftime("%Y-%m-%d")
14+
slices = [
15+
{
16+
"origin": "LHR",
17+
"destination": "STN",
18+
"departure_date": departure_date,
19+
},
20+
]
21+
22+
partial_offer_request = (
23+
client.partial_offer_requests.create()
24+
.passengers([{"type": "adult"}])
25+
.slices(slices)
26+
.execute()
27+
)
28+
29+
print(f"Created partial offer request: {partial_offer_request.id}")
30+
31+
offers_list = list(enumerate(partial_offer_request.offers))
32+
print(f"Got {len(offers_list)} outbound offers")
33+
outbound_partial_offer_id = offers_list[0][1].id
34+
print(f"Selected offer {outbound_partial_offer_id} to search for inbound")
35+
36+
fares_offer_request = client.partial_offer_requests.fares(
37+
partial_offer_request.id, [outbound_partial_offer_id]
38+
)
39+
offers_list = list(enumerate(fares_offer_request.offers))
40+
print(f"Got {len(offers_list)} full offers")
41+
selected_offer = offers_list[0][1]
42+
43+
priced_offer = client.offers.get(selected_offer.id, return_available_services=True)
44+
45+
print(
46+
f"The final price for offer {priced_offer.id} is {priced_offer.total_amount} ({priced_offer.total_currency})"
47+
)
48+
49+
available_service = priced_offer.available_services[0]
50+
51+
print(
52+
f"Adding an extra bag with service {available_service.id}, costing {available_service.total_amount} ({available_service.total_currency})"
53+
)
54+
55+
total_amount = str(
56+
Decimal(priced_offer.total_amount) + Decimal(available_service.total_amount)
57+
)
58+
payments = [
59+
{
60+
"currency": selected_offer.total_currency,
61+
"amount": total_amount,
62+
"type": "balance",
63+
}
64+
]
65+
services = [
66+
{
67+
"id": available_service.id,
68+
"quantity": 1,
69+
}
70+
]
71+
passengers = [
72+
{
73+
"born_on": "1976-01-21",
74+
"email": "[email protected]",
75+
"family_name": "Corde",
76+
"gender": "f",
77+
"given_name": "Conelia",
78+
"id": partial_offer_request.passengers[0].id,
79+
"phone_number": "+442080160508",
80+
"title": "ms",
81+
}
82+
]
83+
84+
print(total_amount)
85+
print(payments)
86+
print(services)
87+
print(passengers)
88+
order = (
89+
client.orders.create()
90+
.payments(payments)
91+
.passengers(passengers)
92+
.selected_offers([selected_offer.id])
93+
.services(services)
94+
.execute()
95+
)
96+
97+
print(f"Created order {order.id} with booking reference {order.booking_reference}")
98+
99+
order_cancellation = client.order_cancellations.create(order.id)
100+
101+
print(
102+
f"Requested refund quote for order {order.id}{order_cancellation.refund_amount} ({order_cancellation.refund_currency}) is available"
103+
)
104+
105+
client.order_cancellations.confirm(order_cancellation.id)
106+
107+
print(f"Confirmed refund quote for order {order.id}")

0 commit comments

Comments
 (0)