airalo-sdk 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of airalo-sdk might be problematic. Click here for more details.
- airalo/__init__.py +29 -0
- airalo/airalo.py +620 -0
- airalo/config.py +146 -0
- airalo/constants/__init__.py +8 -0
- airalo/constants/api_constants.py +49 -0
- airalo/constants/sdk_constants.py +32 -0
- airalo/exceptions/__init__.py +21 -0
- airalo/exceptions/airalo_exception.py +64 -0
- airalo/helpers/__init__.py +9 -0
- airalo/helpers/cached.py +177 -0
- airalo/helpers/cloud_sim_share_validator.py +89 -0
- airalo/helpers/crypt.py +154 -0
- airalo/helpers/date_helper.py +12 -0
- airalo/helpers/signature.py +119 -0
- airalo/resources/__init__.py +8 -0
- airalo/resources/http_resource.py +324 -0
- airalo/resources/multi_http_resource.py +312 -0
- airalo/services/__init__.py +17 -0
- airalo/services/compatibility_devices_service.py +34 -0
- airalo/services/exchange_rates_service.py +69 -0
- airalo/services/future_order_service.py +113 -0
- airalo/services/installation_instructions_service.py +63 -0
- airalo/services/oauth_service.py +186 -0
- airalo/services/order_service.py +463 -0
- airalo/services/packages_service.py +354 -0
- airalo/services/sim_service.py +349 -0
- airalo/services/topup_service.py +127 -0
- airalo/services/voucher_service.py +138 -0
- airalo_sdk-1.0.0.dist-info/METADATA +939 -0
- airalo_sdk-1.0.0.dist-info/RECORD +33 -0
- airalo_sdk-1.0.0.dist-info/WHEEL +5 -0
- airalo_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- airalo_sdk-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth Service Module
|
|
3
|
+
|
|
4
|
+
This module handles OAuth authentication and access token management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
from ..config import Config
|
|
14
|
+
from ..constants.api_constants import ApiConstants
|
|
15
|
+
from ..constants.sdk_constants import SdkConstants
|
|
16
|
+
from ..exceptions.airalo_exception import AuthenticationError
|
|
17
|
+
from ..helpers.cached import Cached
|
|
18
|
+
from ..helpers.crypt import Crypt
|
|
19
|
+
from ..helpers.signature import Signature
|
|
20
|
+
from ..resources.http_resource import HttpResource
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OAuthService:
|
|
24
|
+
"""
|
|
25
|
+
OAuth service for managing API authentication.
|
|
26
|
+
|
|
27
|
+
Handles access token generation, caching, and refresh with automatic retry logic.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
CACHE_NAME = "airalo_access_token"
|
|
31
|
+
RETRY_LIMIT = SdkConstants.DEFAULT_RETRY_COUNT
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self, config: Config, http_resource: HttpResource, signature: Signature
|
|
35
|
+
):
|
|
36
|
+
"""
|
|
37
|
+
Initialize OAuth service.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
config: SDK configuration
|
|
41
|
+
http_resource: HTTP client for API requests
|
|
42
|
+
signature: Signature generator for request signing
|
|
43
|
+
"""
|
|
44
|
+
self._config = config
|
|
45
|
+
self._http_resource = http_resource
|
|
46
|
+
self._signature = signature
|
|
47
|
+
|
|
48
|
+
# Prepare OAuth payload
|
|
49
|
+
self._payload = {
|
|
50
|
+
**self._config.get_credentials(),
|
|
51
|
+
"grant_type": "client_credentials",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def get_access_token(self) -> Optional[str]:
|
|
55
|
+
"""
|
|
56
|
+
Get access token with caching and retry logic.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Access token string or None if failed
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
AuthenticationError: If token generation fails after retries
|
|
63
|
+
"""
|
|
64
|
+
retry_count = 0
|
|
65
|
+
|
|
66
|
+
# Generate cache key based on credentials
|
|
67
|
+
cache_name = f"{self.CACHE_NAME}_{self._generate_cache_key()}"
|
|
68
|
+
|
|
69
|
+
while retry_count < self.RETRY_LIMIT:
|
|
70
|
+
try:
|
|
71
|
+
# Try to get cached token
|
|
72
|
+
encrypted_token = Cached.get(
|
|
73
|
+
lambda: self._request_token(),
|
|
74
|
+
cache_name,
|
|
75
|
+
ttl=SdkConstants.TOKEN_CACHE_TTL,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Decrypt and return token
|
|
79
|
+
if encrypted_token:
|
|
80
|
+
return Crypt.decrypt(encrypted_token, self._get_encryption_key())
|
|
81
|
+
|
|
82
|
+
except Exception as e:
|
|
83
|
+
retry_count += 1
|
|
84
|
+
|
|
85
|
+
if retry_count >= self.RETRY_LIMIT:
|
|
86
|
+
raise AuthenticationError(
|
|
87
|
+
f"Failed to get access token from API after {self.RETRY_LIMIT} attempts: {str(e)}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Wait before retry (exponential backoff)
|
|
91
|
+
time.sleep(0.5 * (2 ** (retry_count - 1)))
|
|
92
|
+
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _request_token(self) -> str:
|
|
96
|
+
"""
|
|
97
|
+
Request new access token from API.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Encrypted access token
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
AuthenticationError: If token request fails
|
|
104
|
+
"""
|
|
105
|
+
# Prepare request
|
|
106
|
+
url = self._config.get_url() + ApiConstants.TOKEN_SLUG
|
|
107
|
+
|
|
108
|
+
# Generate signature
|
|
109
|
+
signature_hash = self._signature.get_signature(self._payload)
|
|
110
|
+
|
|
111
|
+
# Set headers
|
|
112
|
+
headers = {
|
|
113
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
114
|
+
"airalo-signature": signature_hash,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Make request
|
|
118
|
+
self._http_resource.set_headers(headers)
|
|
119
|
+
response = self._http_resource.post(url, urlencode(self._payload))
|
|
120
|
+
|
|
121
|
+
# Check response
|
|
122
|
+
if not response or self._http_resource.code != 200:
|
|
123
|
+
raise AuthenticationError(
|
|
124
|
+
f"Access token generation failed, status code: {self._http_resource.code}, "
|
|
125
|
+
f"response: {response}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Parse response
|
|
129
|
+
try:
|
|
130
|
+
response_data = json.loads(response)
|
|
131
|
+
except json.JSONDecodeError as e:
|
|
132
|
+
raise AuthenticationError(
|
|
133
|
+
f"Failed to parse access token response: {str(e)}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Extract token
|
|
137
|
+
if not isinstance(response_data, dict) or "data" not in response_data:
|
|
138
|
+
raise AuthenticationError("Invalid response format: missing 'data' field")
|
|
139
|
+
|
|
140
|
+
if "access_token" not in response_data["data"]:
|
|
141
|
+
raise AuthenticationError("Access token not found in response")
|
|
142
|
+
|
|
143
|
+
access_token = response_data["data"]["access_token"]
|
|
144
|
+
|
|
145
|
+
# Encrypt token for caching
|
|
146
|
+
return Crypt.encrypt(access_token, self._get_encryption_key())
|
|
147
|
+
|
|
148
|
+
def _get_encryption_key(self) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Generate encryption key from credentials.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Encryption key
|
|
154
|
+
"""
|
|
155
|
+
credentials_string = self._config.get_credentials(as_string=True)
|
|
156
|
+
return hashlib.md5(credentials_string.encode()).hexdigest()
|
|
157
|
+
|
|
158
|
+
def _generate_cache_key(self) -> str:
|
|
159
|
+
"""
|
|
160
|
+
Generate unique cache key for token storage.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Cache key hash
|
|
164
|
+
"""
|
|
165
|
+
credentials_string = self._config.get_credentials(as_string=True)
|
|
166
|
+
return hashlib.sha256(credentials_string.encode()).hexdigest()
|
|
167
|
+
|
|
168
|
+
def clear_token_cache(self) -> None:
|
|
169
|
+
"""Clear cached access token."""
|
|
170
|
+
cache_name = f"{self.CACHE_NAME}_{self._generate_cache_key()}"
|
|
171
|
+
# Clear specific token cache
|
|
172
|
+
# Note: This would need enhancement in Cached class to clear specific cache
|
|
173
|
+
Cached.clear_cache()
|
|
174
|
+
|
|
175
|
+
def refresh_token(self) -> Optional[str]:
|
|
176
|
+
"""
|
|
177
|
+
Force refresh of access token.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
New access token
|
|
181
|
+
"""
|
|
182
|
+
# Clear existing cache
|
|
183
|
+
self.clear_token_cache()
|
|
184
|
+
|
|
185
|
+
# Get new token
|
|
186
|
+
return self.get_access_token()
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Order Service Module
|
|
3
|
+
|
|
4
|
+
This module handles all order-related API operations including single orders,
|
|
5
|
+
bulk orders, and asynchronous orders.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Dict, List, Optional, Union
|
|
10
|
+
from urllib.parse import urlencode
|
|
11
|
+
|
|
12
|
+
from ..config import Config
|
|
13
|
+
from ..constants.api_constants import ApiConstants
|
|
14
|
+
from ..constants.sdk_constants import SdkConstants
|
|
15
|
+
from ..exceptions.airalo_exception import AiraloException, ValidationError, APIError
|
|
16
|
+
from ..helpers.signature import Signature
|
|
17
|
+
from ..helpers.cloud_sim_share_validator import CloudSimShareValidator
|
|
18
|
+
from ..resources.http_resource import HttpResource
|
|
19
|
+
from ..resources.multi_http_resource import MultiHttpResource
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OrderService:
|
|
23
|
+
"""
|
|
24
|
+
Service for managing order operations.
|
|
25
|
+
|
|
26
|
+
Handles single orders, bulk orders, async orders, and email SIM sharing.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
config: Config,
|
|
32
|
+
http_resource: HttpResource,
|
|
33
|
+
multi_http_resource: MultiHttpResource,
|
|
34
|
+
signature: Signature,
|
|
35
|
+
access_token: str,
|
|
36
|
+
):
|
|
37
|
+
"""
|
|
38
|
+
Initialize order service.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
config: SDK configuration
|
|
42
|
+
http_resource: HTTP client for single requests
|
|
43
|
+
multi_http_resource: HTTP client for concurrent requests
|
|
44
|
+
signature: Signature generator for request signing
|
|
45
|
+
access_token: API access token
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
AiraloException: If access token is invalid
|
|
49
|
+
"""
|
|
50
|
+
if not access_token:
|
|
51
|
+
raise AiraloException("Invalid access token, please check your credentials")
|
|
52
|
+
|
|
53
|
+
self._config = config
|
|
54
|
+
self._http = http_resource
|
|
55
|
+
self._multi_http = multi_http_resource
|
|
56
|
+
self._signature = signature
|
|
57
|
+
self._access_token = access_token
|
|
58
|
+
self._base_url = config.get_url()
|
|
59
|
+
|
|
60
|
+
def create_order(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
61
|
+
"""
|
|
62
|
+
Create a single order.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
payload: Order data including:
|
|
66
|
+
- package_id: Package ID to order
|
|
67
|
+
- quantity: Number of SIMs (1-50)
|
|
68
|
+
- type: Order type (default: 'sim')
|
|
69
|
+
- description: Order description
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Order response data or None
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ValidationError: If payload is invalid
|
|
76
|
+
APIError: If API request fails
|
|
77
|
+
"""
|
|
78
|
+
self._validate_order(payload)
|
|
79
|
+
|
|
80
|
+
# Set default type if not provided
|
|
81
|
+
if "type" not in payload:
|
|
82
|
+
payload["type"] = "sim"
|
|
83
|
+
|
|
84
|
+
# Set headers with signature
|
|
85
|
+
headers = self._get_headers(payload)
|
|
86
|
+
self._http.set_headers(headers)
|
|
87
|
+
|
|
88
|
+
# Make request
|
|
89
|
+
url = self._base_url + ApiConstants.ORDERS_SLUG
|
|
90
|
+
response = self._http.post(url, payload)
|
|
91
|
+
|
|
92
|
+
# Check response
|
|
93
|
+
if self._http.code != 200:
|
|
94
|
+
raise APIError(
|
|
95
|
+
f"Order creation failed, status code: {self._http.code}, response: {response}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Parse and return response
|
|
99
|
+
try:
|
|
100
|
+
return json.loads(response)
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
raise APIError("Failed to parse order response")
|
|
103
|
+
|
|
104
|
+
def create_order_with_email_sim_share(
|
|
105
|
+
self, payload: Dict[str, Any], esim_cloud: Dict[str, Any]
|
|
106
|
+
) -> Optional[Dict[str, Any]]:
|
|
107
|
+
"""
|
|
108
|
+
Create an order with email SIM sharing.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
payload: Order data
|
|
112
|
+
esim_cloud: Email sharing configuration:
|
|
113
|
+
- to_email: Recipient email (required)
|
|
114
|
+
- sharing_option: List of options ['link', 'pdf'] (required)
|
|
115
|
+
- copy_address: List of CC emails (optional)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Order response data or None
|
|
119
|
+
"""
|
|
120
|
+
self._validate_order(payload)
|
|
121
|
+
self._validate_cloud_sim_share(esim_cloud)
|
|
122
|
+
|
|
123
|
+
# Add email sharing to payload
|
|
124
|
+
payload["to_email"] = esim_cloud["to_email"]
|
|
125
|
+
payload["sharing_option"] = esim_cloud["sharing_option"]
|
|
126
|
+
|
|
127
|
+
if esim_cloud.get("copy_address"):
|
|
128
|
+
payload["copy_address"] = esim_cloud["copy_address"]
|
|
129
|
+
|
|
130
|
+
# Set default type
|
|
131
|
+
if "type" not in payload:
|
|
132
|
+
payload["type"] = "sim"
|
|
133
|
+
|
|
134
|
+
# Make request
|
|
135
|
+
headers = self._get_headers(payload)
|
|
136
|
+
self._http.set_headers(headers)
|
|
137
|
+
|
|
138
|
+
url = self._base_url + ApiConstants.ORDERS_SLUG
|
|
139
|
+
response = self._http.post(url, payload)
|
|
140
|
+
|
|
141
|
+
if self._http.code != 200:
|
|
142
|
+
raise APIError(
|
|
143
|
+
f"Order creation failed, status code: {self._http.code}, response: {response}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
return json.loads(response)
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
raise APIError("Failed to parse order response")
|
|
150
|
+
|
|
151
|
+
def create_order_async(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
152
|
+
"""
|
|
153
|
+
Create an asynchronous order.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
payload: Order data including:
|
|
157
|
+
- package_id: Package ID to order
|
|
158
|
+
- quantity: Number of SIMs
|
|
159
|
+
- webhook_url: URL for order completion notification
|
|
160
|
+
- description: Order description
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Order response data or None
|
|
164
|
+
"""
|
|
165
|
+
self._validate_order(payload)
|
|
166
|
+
|
|
167
|
+
# Set default type
|
|
168
|
+
if "type" not in payload:
|
|
169
|
+
payload["type"] = "sim"
|
|
170
|
+
|
|
171
|
+
# Make request
|
|
172
|
+
headers = self._get_headers(payload)
|
|
173
|
+
self._http.set_headers(headers)
|
|
174
|
+
|
|
175
|
+
url = self._base_url + ApiConstants.ASYNC_ORDERS_SLUG
|
|
176
|
+
response = self._http.post(url, payload)
|
|
177
|
+
|
|
178
|
+
# Async orders return 202 Accepted
|
|
179
|
+
if self._http.code != 202:
|
|
180
|
+
raise APIError(
|
|
181
|
+
f"Async order creation failed, status code: {self._http.code}, response: {response}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
return json.loads(response)
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
raise APIError("Failed to parse order response")
|
|
188
|
+
|
|
189
|
+
def create_order_bulk(
|
|
190
|
+
self,
|
|
191
|
+
packages: Union[Dict[str, int], List[Dict]],
|
|
192
|
+
description: Optional[str] = None,
|
|
193
|
+
) -> Optional[Dict[str, Any]]:
|
|
194
|
+
"""
|
|
195
|
+
Create multiple orders in bulk.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
packages: Either:
|
|
199
|
+
- Dict mapping package_id to quantity
|
|
200
|
+
- List of dicts with 'package_id' and 'quantity' keys
|
|
201
|
+
description: Order description for all orders
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Dict mapping package IDs to order responses
|
|
205
|
+
"""
|
|
206
|
+
# Convert list format to dict format
|
|
207
|
+
if isinstance(packages, list):
|
|
208
|
+
packages_dict = {}
|
|
209
|
+
for item in packages:
|
|
210
|
+
packages_dict[item["package_id"]] = item["quantity"]
|
|
211
|
+
packages = packages_dict
|
|
212
|
+
|
|
213
|
+
self._validate_bulk_order(packages)
|
|
214
|
+
|
|
215
|
+
if not packages:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
# Prepare concurrent requests
|
|
219
|
+
for package_id, quantity in packages.items():
|
|
220
|
+
payload = {
|
|
221
|
+
"package_id": package_id,
|
|
222
|
+
"quantity": quantity,
|
|
223
|
+
"type": "sim",
|
|
224
|
+
"description": description or "Bulk order placed via Airalo Python SDK",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
self._validate_order(payload)
|
|
228
|
+
|
|
229
|
+
# Add request to multi-http queue
|
|
230
|
+
headers = self._get_headers(payload)
|
|
231
|
+
self._multi_http.tag(package_id).set_headers(headers).post(
|
|
232
|
+
self._base_url + ApiConstants.ORDERS_SLUG, payload
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Execute all requests
|
|
236
|
+
responses = self._multi_http.exec()
|
|
237
|
+
|
|
238
|
+
if not responses:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
# Parse responses
|
|
242
|
+
result = {}
|
|
243
|
+
for package_id, response in responses.items():
|
|
244
|
+
try:
|
|
245
|
+
result[package_id] = json.loads(response)
|
|
246
|
+
except json.JSONDecodeError:
|
|
247
|
+
result[package_id] = {
|
|
248
|
+
"error": "Failed to parse response",
|
|
249
|
+
"raw": response,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return result
|
|
253
|
+
|
|
254
|
+
def create_order_bulk_with_email_sim_share(
|
|
255
|
+
self,
|
|
256
|
+
packages: Union[Dict[str, int], List[Dict]],
|
|
257
|
+
esim_cloud: Dict[str, Any],
|
|
258
|
+
description: Optional[str] = None,
|
|
259
|
+
) -> Optional[Dict[str, Any]]:
|
|
260
|
+
"""
|
|
261
|
+
Create bulk orders with email SIM sharing.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
packages: Package IDs and quantities
|
|
265
|
+
esim_cloud: Email sharing configuration
|
|
266
|
+
description: Order description
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Dict mapping package IDs to order responses
|
|
270
|
+
"""
|
|
271
|
+
# Convert list format to dict format
|
|
272
|
+
if isinstance(packages, list):
|
|
273
|
+
packages_dict = {}
|
|
274
|
+
for item in packages:
|
|
275
|
+
packages_dict[item["package_id"]] = item["quantity"]
|
|
276
|
+
packages = packages_dict
|
|
277
|
+
|
|
278
|
+
self._validate_bulk_order(packages)
|
|
279
|
+
self._validate_cloud_sim_share(esim_cloud)
|
|
280
|
+
|
|
281
|
+
if not packages:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
# Prepare concurrent requests
|
|
285
|
+
for package_id, quantity in packages.items():
|
|
286
|
+
payload = {
|
|
287
|
+
"package_id": package_id,
|
|
288
|
+
"quantity": quantity,
|
|
289
|
+
"type": "sim",
|
|
290
|
+
"description": description or "Bulk order placed via Airalo Python SDK",
|
|
291
|
+
"to_email": esim_cloud["to_email"],
|
|
292
|
+
"sharing_option": esim_cloud["sharing_option"],
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if esim_cloud.get("copy_address"):
|
|
296
|
+
payload["copy_address"] = esim_cloud["copy_address"]
|
|
297
|
+
|
|
298
|
+
self._validate_order(payload)
|
|
299
|
+
|
|
300
|
+
# Add request to queue
|
|
301
|
+
headers = self._get_headers(payload)
|
|
302
|
+
self._multi_http.tag(package_id).set_headers(headers).post(
|
|
303
|
+
self._base_url + ApiConstants.ORDERS_SLUG, payload
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Execute all requests
|
|
307
|
+
responses = self._multi_http.exec()
|
|
308
|
+
|
|
309
|
+
if not responses:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
# Parse responses
|
|
313
|
+
result = {}
|
|
314
|
+
for package_id, response in responses.items():
|
|
315
|
+
try:
|
|
316
|
+
result[package_id] = json.loads(response)
|
|
317
|
+
except json.JSONDecodeError:
|
|
318
|
+
result[package_id] = {
|
|
319
|
+
"error": "Failed to parse response",
|
|
320
|
+
"raw": response,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return result
|
|
324
|
+
|
|
325
|
+
def create_order_async_bulk(
|
|
326
|
+
self,
|
|
327
|
+
packages: Union[Dict[str, int], List[Dict]],
|
|
328
|
+
webhook_url: Optional[str] = None,
|
|
329
|
+
description: Optional[str] = None,
|
|
330
|
+
) -> Optional[Dict[str, Any]]:
|
|
331
|
+
"""
|
|
332
|
+
Create multiple asynchronous orders in bulk.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
packages: Package IDs and quantities
|
|
336
|
+
webhook_url: Webhook URL for notifications
|
|
337
|
+
description: Order description
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Dict mapping package IDs to order responses
|
|
341
|
+
"""
|
|
342
|
+
# Convert list format to dict format
|
|
343
|
+
if isinstance(packages, list):
|
|
344
|
+
packages_dict = {}
|
|
345
|
+
for item in packages:
|
|
346
|
+
packages_dict[item["package_id"]] = item["quantity"]
|
|
347
|
+
packages = packages_dict
|
|
348
|
+
|
|
349
|
+
self._validate_bulk_order(packages)
|
|
350
|
+
|
|
351
|
+
if not packages:
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
# Prepare concurrent requests
|
|
355
|
+
for package_id, quantity in packages.items():
|
|
356
|
+
payload = {
|
|
357
|
+
"package_id": package_id,
|
|
358
|
+
"quantity": quantity,
|
|
359
|
+
"type": "sim",
|
|
360
|
+
"description": description
|
|
361
|
+
or "Bulk async order placed via Airalo Python SDK",
|
|
362
|
+
"webhook_url": webhook_url,
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
self._validate_order(payload)
|
|
366
|
+
|
|
367
|
+
# Add request to queue
|
|
368
|
+
headers = self._get_headers(payload)
|
|
369
|
+
self._multi_http.tag(package_id).set_headers(headers).post(
|
|
370
|
+
self._base_url + ApiConstants.ASYNC_ORDERS_SLUG, payload
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Execute all requests
|
|
374
|
+
responses = self._multi_http.exec()
|
|
375
|
+
|
|
376
|
+
if not responses:
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
# Parse responses
|
|
380
|
+
result = {}
|
|
381
|
+
for package_id, response in responses.items():
|
|
382
|
+
try:
|
|
383
|
+
result[package_id] = json.loads(response)
|
|
384
|
+
except json.JSONDecodeError:
|
|
385
|
+
result[package_id] = {
|
|
386
|
+
"error": "Failed to parse response",
|
|
387
|
+
"raw": response,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return result
|
|
391
|
+
|
|
392
|
+
def _get_headers(self, payload: Dict[str, Any]) -> Dict[str, str]:
|
|
393
|
+
"""
|
|
394
|
+
Get headers for order request with signature.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
payload: Request payload
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Headers dictionary
|
|
401
|
+
"""
|
|
402
|
+
return {
|
|
403
|
+
"Authorization": f"Bearer {self._access_token}",
|
|
404
|
+
"Content-Type": "application/json",
|
|
405
|
+
"airalo-signature": self._signature.get_signature(payload),
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
def _validate_order(self, payload: Dict[str, Any]) -> None:
|
|
409
|
+
"""
|
|
410
|
+
Validate order payload.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
payload: Order data
|
|
414
|
+
|
|
415
|
+
Raises:
|
|
416
|
+
ValidationError: If validation fails
|
|
417
|
+
"""
|
|
418
|
+
if not payload.get("package_id"):
|
|
419
|
+
raise ValidationError(
|
|
420
|
+
f"The package_id is required, payload: {json.dumps(payload)}"
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
quantity = payload.get("quantity", 0)
|
|
424
|
+
if quantity < 1:
|
|
425
|
+
raise ValidationError(
|
|
426
|
+
f"The quantity must be at least 1, payload: {json.dumps(payload)}"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
if quantity > SdkConstants.ORDER_LIMIT:
|
|
430
|
+
raise ValidationError(
|
|
431
|
+
f"The quantity may not be greater than {SdkConstants.ORDER_LIMIT}, "
|
|
432
|
+
f"payload: {json.dumps(payload)}"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
def _validate_bulk_order(self, packages: Dict[str, int]) -> None:
|
|
436
|
+
"""
|
|
437
|
+
Validate bulk order payload.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
packages: Package IDs and quantities
|
|
441
|
+
|
|
442
|
+
Raises:
|
|
443
|
+
ValidationError: If validation fails
|
|
444
|
+
"""
|
|
445
|
+
if len(packages) > SdkConstants.BULK_ORDER_LIMIT:
|
|
446
|
+
raise ValidationError(
|
|
447
|
+
f"The packages count may not be greater than {SdkConstants.BULK_ORDER_LIMIT}"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def _validate_cloud_sim_share(self, sim_cloud_share: Dict[str, Any]) -> None:
|
|
451
|
+
"""
|
|
452
|
+
Validate email SIM sharing configuration using CloudSimShareValidator.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
sim_cloud_share: Email sharing configuration
|
|
456
|
+
|
|
457
|
+
Raises:
|
|
458
|
+
ValidationError: If validation fails
|
|
459
|
+
"""
|
|
460
|
+
# Use CloudSimShareValidator with required fields for order service
|
|
461
|
+
CloudSimShareValidator.validate(
|
|
462
|
+
sim_cloud_share, required_fields=["to_email", "sharing_option"]
|
|
463
|
+
)
|