shipaw 0.3.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.
Files changed (42) hide show
  1. shipaw/__init__.py +3 -0
  2. shipaw/config.py +176 -0
  3. shipaw/fapi/__init__.py +0 -0
  4. shipaw/fapi/alerts.py +84 -0
  5. shipaw/fapi/app.py +78 -0
  6. shipaw/fapi/backend.py +89 -0
  7. shipaw/fapi/callbacks.py +7 -0
  8. shipaw/fapi/form_data.py +99 -0
  9. shipaw/fapi/requests.py +36 -0
  10. shipaw/fapi/responses.py +79 -0
  11. shipaw/fapi/routes_api.py +134 -0
  12. shipaw/fapi/routes_html.py +100 -0
  13. shipaw/models/__init__.py +0 -0
  14. shipaw/models/address.py +80 -0
  15. shipaw/models/base.py +12 -0
  16. shipaw/models/conversation.py +13 -0
  17. shipaw/models/label_file.py +51 -0
  18. shipaw/models/logging.py +50 -0
  19. shipaw/models/provider.py +103 -0
  20. shipaw/models/services.py +41 -0
  21. shipaw/models/ship_types.py +74 -0
  22. shipaw/models/shipment.py +59 -0
  23. shipaw/providers/__init__.py +4 -0
  24. shipaw/providers/apc_provider.py +163 -0
  25. shipaw/providers/parcelforce_provider.py +181 -0
  26. shipaw/providers_wip/__init__.py +0 -0
  27. shipaw/providers_wip/royal_mail_provider.py +88 -0
  28. shipaw/ui/static/favicon.svg +69 -0
  29. shipaw/ui/static/ship.css +281 -0
  30. shipaw/ui/static/ship.js +274 -0
  31. shipaw/ui/static/style.css +213 -0
  32. shipaw/ui/templates/alerts.html +14 -0
  33. shipaw/ui/templates/app_alerts.html +18 -0
  34. shipaw/ui/templates/base.html +31 -0
  35. shipaw/ui/templates/order_results.html +57 -0
  36. shipaw/ui/templates/order_summary.html +108 -0
  37. shipaw/ui/templates/shipping_form.html +122 -0
  38. shipaw/ui/templates/shipping_form_container.html +7 -0
  39. shipaw/ui/zzzxxxccc +0 -0
  40. shipaw-0.3.0.dist-info/METADATA +35 -0
  41. shipaw-0.3.0.dist-info/RECORD +42 -0
  42. shipaw-0.3.0.dist-info/WHEEL +4 -0
shipaw/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # from .providers import *
2
+ #
3
+ # __all__ = ['ParcelforceShippingProvider', 'APCShippingProvider']
shipaw/config.py ADDED
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+ from importlib.resources import files
3
+
4
+ import functools
5
+ import os
6
+ import re
7
+ from datetime import date, datetime
8
+ from pathlib import Path
9
+ from urllib.parse import quote
10
+
11
+ import pydantic as _p
12
+ from dotenv import load_dotenv
13
+ from fastapi.encoders import jsonable_encoder
14
+ from loguru import logger
15
+ from pydantic import Field, computed_field, model_validator
16
+ from pydantic_settings import BaseSettings, SettingsConfigDict
17
+ from starlette.templating import Jinja2Templates
18
+
19
+ from shipaw.models.address import Address, Contact, FullContact
20
+ from shipaw.models.ship_types import ShipDirection
21
+
22
+
23
+ def load_env_index(envs_index: Path) -> None:
24
+ load_dotenv(envs_index)
25
+
26
+
27
+ # def load_envs() -> Path:
28
+ # shipaw_envs = Path(os.getenv('SHIPAW_ENVS'))
29
+ # if not shipaw_envs or not shipaw_envs.exists():
30
+ # raise ValueError('SHIPAW_ENVS not set')
31
+ #
32
+ # apc = shipaw_envs / 'apc.env'
33
+ # pf = shipaw_envs / 'parcelforce.env'
34
+ # shipaw_env = shipaw_envs / 'shipaw.env'
35
+ #
36
+ # for f in (apc, pf, shipaw_env):
37
+ # if not f.exists():
38
+ # raise ValueError(f'Env file {f} does not exist')
39
+ #
40
+ # os.environ['APC_ENV'] = str(apc)
41
+ # os.environ['PARCELFORCE_ENV'] = str(pf)
42
+ # os.environ['SHIPAW_ENV'] = str(shipaw_env)
43
+ # return shipaw_env
44
+
45
+
46
+ #
47
+ def load_env():
48
+ ei = Path(os.environ.get('ENV_INDEX'))
49
+ load_env_index(ei)
50
+ shipaw_env = os.getenv('SHIPAW_ENV')
51
+ shipaw_env = Path(shipaw_env) if shipaw_env else None
52
+ if not shipaw_env or not shipaw_env.exists():
53
+ raise ValueError('SHIPAW_ENV not set')
54
+ logger.debug(f'Loading SHIPAW environment from {shipaw_env}')
55
+ return shipaw_env
56
+
57
+
58
+ def sanitise_id(value):
59
+ return re.sub(r'\W|^(?=\d)', '_', value).lower()
60
+
61
+
62
+ def date_int_w_ordinal(n: int):
63
+ """Convert an integer to its ordinal as a string, e.g. 1 -> 1st, 2 -> 2nd, etc."""
64
+ return str(n) + ('th' if 4 <= n % 100 <= 20 else {1: 'st', 2: 'nd', 3: 'rd'}.get(n % 10, 'th'))
65
+
66
+
67
+ def ordinal_dt(dt: datetime | date) -> str:
68
+ """Convert a datetime or date to a string with an ordinal day, e.g. 'Mon 1st Jan 2020'."""
69
+ return dt.strftime(f'%a {date_int_w_ordinal(dt.day)} %b %Y')
70
+
71
+
72
+ def get_ui():
73
+ res = files('shipaw').joinpath('ui')
74
+ return res
75
+
76
+
77
+ class ShipawSettings(BaseSettings):
78
+ # toggles
79
+ shipper_live: bool = False
80
+ log_level: str = 'DEBUG'
81
+
82
+ # dirs
83
+ label_dir: Path
84
+ log_dir: Path
85
+ ui_dir: Path = Field(default_factory=get_ui)
86
+
87
+ # auto dirs
88
+ static_dir: Path | None = None
89
+ template_dir: Path | None = None
90
+ templates: Jinja2Templates | None = None
91
+
92
+ # sender details
93
+ address_line1: str
94
+ address_line2: str | None = None
95
+ address_line3: str | None = None
96
+ town: str
97
+ postcode: str
98
+ country: str = 'GB'
99
+ business_name: str
100
+ contact_name: str
101
+ email: str
102
+ phone: str | None = None
103
+ mobile_phone: str
104
+
105
+ model_config = SettingsConfigDict(env_ignore_empty=True, env_file=load_env())
106
+
107
+ ## SET UI/TEMPLATE DIRS ##
108
+ @model_validator(mode='after')
109
+ def set_ui(self):
110
+ self.static_dir = self.static_dir or self.ui_dir / 'static'
111
+ self.template_dir = self.template_dir or self.ui_dir / 'templates'
112
+ self.templates = self.templates or Jinja2Templates(directory=self.template_dir)
113
+ self.templates.env.filters['jsonable'] = jsonable_encoder
114
+ self.templates.env.filters['urlencode'] = lambda value: quote(str(value))
115
+ self.templates.env.filters['sanitise_id'] = sanitise_id
116
+ self.templates.env.filters['ordinal_dt'] = ordinal_dt
117
+ return self
118
+
119
+ ## SET LOGGING & LABELS ##
120
+ @computed_field
121
+ @property
122
+ def log_file(self) -> Path:
123
+ return self.log_dir / 'shipaw.log'
124
+
125
+ @computed_field
126
+ @property
127
+ def ndjson_log_file(self) -> Path:
128
+ return self.log_dir / 'shipaw.ndjson'
129
+
130
+ @_p.model_validator(mode='after')
131
+ def create_log_files(self):
132
+ self.log_dir.mkdir(parents=True, exist_ok=True)
133
+ for v in (self.log_file, self.ndjson_log_file):
134
+ v.touch()
135
+ return self
136
+
137
+ @_p.field_validator('label_dir', mode='after')
138
+ def create_label_dirs(cls, v, values):
139
+ directions = [_ for _ in ShipDirection]
140
+ for subdir in directions:
141
+ apath = v / subdir
142
+ if not apath.exists():
143
+ apath.mkdir(parents=True, exist_ok=True)
144
+ return v
145
+
146
+ ## SET ADDRESS/CONTACT OBJECTS FROM ENV VARS ##
147
+ @property
148
+ def contact(self):
149
+ return Contact(
150
+ contact_name=self.contact_name,
151
+ email_address=self.email,
152
+ mobile_phone=self.mobile_phone,
153
+ )
154
+
155
+ @property
156
+ def address(self):
157
+ return Address(
158
+ address_lines=[_ for _ in [self.address_line1, self.address_line2, self.address_line3] if _],
159
+ town=self.town,
160
+ postcode=self.postcode,
161
+ country=self.country,
162
+ business_name=self.business_name,
163
+ )
164
+
165
+ @property
166
+ def full_contact(self) -> FullContact:
167
+ return FullContact(
168
+ address=self.address,
169
+ contact=self.contact,
170
+ )
171
+
172
+
173
+ @functools.lru_cache
174
+ def shipaw_settings() -> ShipawSettings:
175
+ return ShipawSettings.model_validate({})
176
+
File without changes
shipaw/fapi/alerts.py ADDED
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+
5
+ from pydantic import Field
6
+
7
+ from shipaw.models.base import ShipawBaseModel
8
+
9
+
10
+ class AlertType(StrEnum):
11
+ ERROR = 'ERROR'
12
+ WARNING = 'WARNING'
13
+ NOTIFICATION = 'NOTIFICATION'
14
+
15
+
16
+ class Alert(ShipawBaseModel):
17
+ code: int | None = None
18
+ message: str
19
+ type: AlertType = AlertType.NOTIFICATION
20
+
21
+ def __eq__(self, other):
22
+ if not isinstance(other, Alert):
23
+ return NotImplemented
24
+ return (self.code, self.message, self.type) == (other.code, other.message, other.type)
25
+
26
+ def __hash__(self):
27
+ return hash((self.code, self.message, self.type))
28
+
29
+ @classmethod
30
+ def from_exception(cls, e: Exception):
31
+ return cls(message=str(e), type=AlertType.ERROR)
32
+
33
+
34
+ class Alerts(ShipawBaseModel):
35
+ alert: list[Alert] = Field(default_factory=list[Alert])
36
+
37
+ def __bool__(self):
38
+ return bool(self.alert)
39
+
40
+ def __add__(self, other: Alerts | Alert):
41
+ if not isinstance(other, Alerts) and not isinstance(other, Alert):
42
+ raise TypeError(f'Expected Alerts or Alert instance, got {type(other)}')
43
+ if isinstance(other, Alert):
44
+ other = Alerts(alert=[other])
45
+ combined = set(self.alert) | set(other.alert)
46
+ return Alerts(alert=list(combined))
47
+
48
+ def __iadd__(self, other: Alerts | Alert):
49
+ if not isinstance(other, Alerts) and not isinstance(other, Alert):
50
+ raise TypeError(f'Expected Alerts or Alert instance, got {type(other)}')
51
+ if isinstance(other, Alert):
52
+ other = Alerts(alert=[other])
53
+ self.alert = list(set(self.alert) | set(other.alert))
54
+ return self
55
+
56
+ def __sub__(self, other: Alerts | Alert):
57
+ if not isinstance(other, Alerts) and not isinstance(other, Alert):
58
+ raise TypeError(f'Expected Alerts or Alert instance, got {type(other)}')
59
+ if isinstance(other, Alert):
60
+ other = Alerts(alert=[other])
61
+ diff = set(self.alert) - set(other.alert)
62
+ return Alerts(alert=list(diff))
63
+
64
+ def __contains__(self, alert: Alert):
65
+ return alert in set(self.alert)
66
+
67
+ @classmethod
68
+ def empty(cls):
69
+ return cls(alert=[])
70
+
71
+
72
+ async def maybe_alert_phone_number(phone_num: str):
73
+ """Alert if phone number is not 11 digits or does not start with 01, 02 or 07. (parcelforce requirement)"""
74
+ alerts = Alerts.empty()
75
+ if len(phone_num) != 11 or not phone_num[1] in [
76
+ '1',
77
+ '2',
78
+ '7',
79
+ ]:
80
+ alerts += Alert(
81
+ type=AlertType.WARNING,
82
+ message='The Mobile phone number must be 11 digits and begin with 01, 02 or 07, no SMS will be sent',
83
+ )
84
+ return alerts
shipaw/fapi/app.py ADDED
@@ -0,0 +1,78 @@
1
+ import contextlib
2
+
3
+ from fastapi import FastAPI, responses
4
+ from fastapi.encoders import jsonable_encoder
5
+ from fastapi.exceptions import RequestValidationError
6
+ from loguru import logger
7
+ from starlette.requests import Request
8
+ from starlette.responses import JSONResponse
9
+ from starlette.staticfiles import StaticFiles
10
+
11
+ from shipaw.config import shipaw_settings
12
+ from shipaw.fapi.alerts import Alert, AlertType, Alerts
13
+ from shipaw.fapi.routes_api import router as json_router
14
+ from shipaw.fapi.routes_html import router as html_router
15
+
16
+
17
+ @contextlib.asynccontextmanager
18
+ async def lifespan(app_: FastAPI):
19
+ try:
20
+ # set_pf_env()
21
+ # pythoncom.CoInitialize()
22
+ # with sqm.Session(am_db.ENGINE) as session:
23
+ # pf_shipper = ELClient()
24
+ # populate_db_from_cmc(session, pf_shipper)
25
+ yield
26
+
27
+ finally:
28
+ # pythoncom.CoUninitialize()
29
+
30
+ ...
31
+
32
+
33
+ app = FastAPI(lifespan=lifespan)
34
+ app.mount('/static', StaticFiles(directory=str(shipaw_settings().static_dir)), name='static')
35
+ app.include_router(json_router, prefix='/api')
36
+ app.include_router(html_router)
37
+ app.ship_live = shipaw_settings().shipper_live
38
+ app.alerts = Alerts.empty()
39
+
40
+
41
+ @app.exception_handler(RequestValidationError)
42
+ async def request_validation_exception_handler(request: Request, exc: RequestValidationError):
43
+ errors = exc.errors()
44
+ msg2 = ''
45
+ for err in errors:
46
+ if type := err.get('type'):
47
+ msg2 += type + ' '
48
+ if loc := err.get('loc'):
49
+ msg2 += f'in {loc} '
50
+ if ctx := err.get('ctx'):
51
+ if reason := ctx.get('reason'):
52
+ msg2 += f': {reason} '
53
+ if input_ := err.get('input'):
54
+ msg2 += f'. Input = {input_} '
55
+
56
+ logger.error(msg2)
57
+ alerts = Alerts(alert=[Alert(code=1, message=msg2, type=AlertType.ERROR)])
58
+ return JSONResponse(
59
+ status_code=422, content={'detail': jsonable_encoder(exc.errors()), 'alerts': alerts.model_dump(mode='json'), 'message': msg2}
60
+ )
61
+
62
+
63
+ @app.get('/robots.txt', response_class=responses.PlainTextResponse)
64
+ async def robots_txt() -> str:
65
+ return 'User-agent: *\nAllow: /'
66
+
67
+
68
+ @app.get('/favicon.ico', include_in_schema=False)
69
+ async def favicon_ico():
70
+ return responses.RedirectResponse(url='/static/favicon.svg')
71
+
72
+
73
+ @app.get('/', response_class=JSONResponse)
74
+ async def base():
75
+ return JSONResponse(content={'status': 'ok'})
76
+
77
+
78
+
shipaw/fapi/backend.py ADDED
@@ -0,0 +1,89 @@
1
+ import json
2
+
3
+ from httpx import HTTPStatusError
4
+ from loguru import logger
5
+ from parcelforce_expresslink.client import ParcelforceClient
6
+
7
+ from shipaw.fapi.requests import ShipmentRequest
8
+ from shipaw.fapi.responses import ShipmentBookingResponse
9
+ from shipaw.fapi.alerts import AlertType, Alert
10
+
11
+
12
+ def extract_http_error_message(exception: HTTPStatusError) -> str:
13
+ if hasattr(exception, 'response') and exception.response is not None:
14
+ return exception.response.text
15
+ logger.warning('HTTPStatusError has no response attribute')
16
+ return str(exception)
17
+
18
+
19
+ def extract_http_error_message_json(exception: HTTPStatusError) -> dict:
20
+ error_string = extract_http_error_message(exception)
21
+ try:
22
+ error_data = json.loads(error_string)
23
+ return error_data.get('Messages')
24
+
25
+ except json.JSONDecodeError:
26
+ logger.warning('Error.response.text is not valid JSON')
27
+ return {'Code': error_string, 'Description': ''}
28
+
29
+
30
+ async def http_status_alerts(exception: HTTPStatusError) -> list[Alert]:
31
+ error_dict = extract_http_error_message_json(exception)
32
+ return [Alert(message=f'{error_dict.get('Code')}: {error_dict.get('Description')}', type=AlertType.ERROR)]
33
+
34
+
35
+ async def try_book_shipment(shipment_request: ShipmentRequest) -> ShipmentBookingResponse:
36
+ shipment_response = ShipmentBookingResponse(shipment=shipment_request.shipment)
37
+ try:
38
+ shipment_response = shipment_request.provider.book_shipment(shipment_request.shipment)
39
+
40
+ except HTTPStatusError as e:
41
+ for alert in await http_status_alerts(e):
42
+ shipment_response.alerts += alert
43
+
44
+ except Exception as e:
45
+ logger.exception(f'Error booking shipment: {e}')
46
+ shipment_response.alerts += Alert.from_exception(e)
47
+
48
+ return shipment_response
49
+
50
+
51
+ async def try_get_label_data(request: ShipmentRequest, response: ShipmentBookingResponse) -> bytes | None:
52
+ try:
53
+ if response.label_data is None and response.shipment_num:
54
+ response.label_data = request.provider.get_label_content(response.shipment_num)
55
+ return response.label_data
56
+
57
+ except HTTPStatusError as e:
58
+ for alert in await http_status_alerts(e):
59
+ response.alerts += alert
60
+ logger.exception(f'HTTP error getting label data')
61
+
62
+ except Exception as e:
63
+ logger.exception(f'Error getting label data')
64
+ response.alerts += Alert.from_exception(e)
65
+
66
+ return None
67
+
68
+
69
+ async def try_write_label(response: ShipmentBookingResponse):
70
+ try:
71
+ await response.write_label_file()
72
+ except Exception as e:
73
+ logger.exception(f'Error writing label file: {e}')
74
+ response.alerts += Alert.from_exception(e)
75
+
76
+
77
+ async def try_get_write_label(request: ShipmentRequest, response: ShipmentBookingResponse):
78
+ if not response.label_data:
79
+ await try_get_label_data(request, response)
80
+ await try_write_label(response)
81
+
82
+
83
+ def get_el_client() -> ParcelforceClient:
84
+ try:
85
+ return ParcelforceClient()
86
+ except Exception as e:
87
+ logger.error(f'Error getting Parcelforce ExpressLink Client: {e}')
88
+ raise
89
+
@@ -0,0 +1,7 @@
1
+ from typing import Callable, Any, TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from shipaw.fapi.requests import ShipmentRequest
5
+ from shipaw.fapi.responses import ShipmentBookingResponse
6
+
7
+ CALLBACK_REGISTER: dict[str, Callable[['ShipmentRequest', 'ShipmentBookingResponse'], Any]] = {}
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import date
5
+
6
+ from fastapi import Depends, Form
7
+ from loguru import logger
8
+ from pawdantic.paw_types import VALID_POSTCODE
9
+ from pydantic import EmailStr
10
+
11
+ from shipaw.models.address import Address, Contact, FullContact
12
+ from shipaw.config import shipaw_settings
13
+ from shipaw.fapi.requests import ShipmentRequest
14
+ from shipaw.models.ship_types import ShipDirection
15
+ from shipaw.models.shipment import Shipment
16
+
17
+
18
+ async def full_contact_form(
19
+ address_line1: str = Form(...),
20
+ address_line2: str = Form(''),
21
+ address_line3: str = Form(''),
22
+ town: str = Form(...),
23
+ postcode: VALID_POSTCODE = Form(...),
24
+ contact_name: str = Form(...),
25
+ email_address: EmailStr = Form(...),
26
+ business_name: str = Form(...),
27
+ mobile_phone: str = Form(...),
28
+ ) -> FullContact:
29
+ return FullContact(
30
+ address=Address(
31
+ address_lines=[address_line1, address_line2, address_line3],
32
+ town=town,
33
+ postcode=postcode,
34
+ business_name=business_name,
35
+ ),
36
+ contact=Contact(
37
+ contact_name=contact_name,
38
+ email_address=email_address,
39
+ mobile_phone=mobile_phone,
40
+ ),
41
+ )
42
+
43
+
44
+ async def shipment_f_form(
45
+ full_contact: FullContact = Depends(full_contact_form),
46
+ shipping_date: date = Form(...),
47
+ boxes: int = Form(...),
48
+ service: str = Form(...),
49
+ direction: ShipDirection = Form(...),
50
+ reference: str = Form(...),
51
+ context_json: str = Form(...),
52
+ ) -> Shipment:
53
+ context = json.loads(context_json)
54
+ logger.info('Creating Shipment Request from form')
55
+
56
+ if direction == ShipDirection.OUTBOUND:
57
+ recipient = full_contact
58
+ sender = None
59
+ elif direction in {ShipDirection.INBOUND, ShipDirection.DROPOFF}:
60
+ recipient = shipaw_settings().full_contact
61
+ sender = full_contact
62
+ else:
63
+ raise ValueError(f'Unknown direction: {direction}')
64
+
65
+ shipment = Shipment(
66
+ recipient=recipient,
67
+ sender=sender,
68
+ boxes=boxes,
69
+ shipping_date=shipping_date,
70
+ direction=direction,
71
+ reference=reference,
72
+ service=service,
73
+ context=context,
74
+ )
75
+ return shipment
76
+
77
+
78
+ async def shipment_request_form(
79
+ shipment: Shipment = Depends(shipment_f_form), provider_name: str = Form(...)
80
+ ) -> ShipmentRequest:
81
+ return ShipmentRequest(
82
+ shipment=shipment,
83
+ provider_name=provider_name,
84
+ )
85
+
86
+
87
+ async def shipment_form_json(shipment_json: str = Form(...)) -> Shipment:
88
+ shipy = Shipment.model_validate_json(shipment_json)
89
+ return shipy
90
+
91
+
92
+ async def shipment_request_form_json(shipment_request_json: str = Form(...)) -> ShipmentRequest:
93
+ shipy = ShipmentRequest.model_validate_json(shipment_request_json)
94
+ return shipy
95
+
96
+
97
+ async def context_form_json(context_json: str = Form(...)) -> dict:
98
+ context = json.loads(context_json)
99
+ return context
@@ -0,0 +1,36 @@
1
+ import base64
2
+ import uuid
3
+ from typing import Annotated
4
+
5
+ from pydantic import StringConstraints
6
+
7
+ from shipaw.models.address import Address, Contact
8
+ from shipaw.models.base import ShipawBaseModel
9
+ from shipaw.models.shipment import Shipment
10
+ from shipaw.models.provider import PROVIDER_REGISTER, ShippingProvider
11
+
12
+
13
+ class Authentication(ShipawBaseModel):
14
+ # todo SecretStr!!!!
15
+ user_name: Annotated[str, StringConstraints(max_length=80)]
16
+ password: Annotated[str, StringConstraints(max_length=80)]
17
+
18
+
19
+ class ShipmentRequest(ShipawBaseModel):
20
+ id: uuid.UUID = uuid.uuid4()
21
+ shipment: Shipment
22
+ provider_name: str
23
+
24
+ @property
25
+ def provider(self) -> ShippingProvider:
26
+ if not self.provider_name:
27
+ raise ValueError('Provider name is not set')
28
+ if self.provider_name not in PROVIDER_REGISTER:
29
+ raise ValueError(f'Unknown provider: {self.provider_name}')
30
+ return PROVIDER_REGISTER[self.provider_name]()
31
+
32
+
33
+ class AddressRequest(ShipawBaseModel):
34
+ postcode: str
35
+ address: Address | None = None
36
+ contact: Contact | None = None
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from base64 import b64encode, b64decode
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from loguru import logger
8
+ from pawdf.array_pdf.array_p import on_a4
9
+
10
+ from pydantic import Field, ConfigDict, field_validator, model_validator, BaseModel
11
+
12
+ from shipaw.models.base import ShipawBaseModel
13
+
14
+ from shipaw.fapi.alerts import Alerts
15
+ from shipaw.models.label_file import get_label_folder, get_label_stem, unused_path
16
+ from shipaw.models.shipment import Shipment
17
+
18
+
19
+ class ShipawTemplate(ShipawBaseModel):
20
+ template_path: str
21
+ context: dict = Field(default_factory=dict)
22
+
23
+ def render_template(self, request):
24
+ from shipaw.config import shipaw_settings
25
+
26
+ if not self.template_path:
27
+ raise ValueError('No template_path set')
28
+ return shipaw_settings().templates.TemplateResponse(
29
+ request=request, name=self.template_path, context=self.context
30
+ )
31
+
32
+
33
+ class BaseResponse(ShipawBaseModel):
34
+ alerts: Alerts = Alerts.empty()
35
+ data: dict | None = None
36
+ success: bool | None = None
37
+ status: str | None = None
38
+ template: ShipawTemplate | None = None
39
+
40
+
41
+ class ShipmentBookingResponse(BaseResponse):
42
+ shipment: Shipment
43
+ shipment_num: str | None = None
44
+ tracking_link: str | None = None
45
+ label_data: bytes | None = None
46
+ label_path: Path | None = None
47
+
48
+ model_config = ConfigDict(json_encoders={bytes: lambda v: b64encode(v).decode('utf-8') if v else None})
49
+
50
+ @field_validator('label_data', mode='before')
51
+ def decode_label_data(cls, value):
52
+ if isinstance(value, str):
53
+ return b64decode(value)
54
+ return value
55
+
56
+ @model_validator(mode='after')
57
+ def get_label_path(self):
58
+ if self.label_path is None and self.label_data:
59
+ folder = get_label_folder(self.shipment.direction)
60
+ label_stem = get_label_stem(self.shipment)
61
+ label_filepath = (folder / label_stem).with_suffix('.pdf')
62
+ self.label_path = unused_path(label_filepath)
63
+ return self
64
+
65
+ async def write_label_file(self):
66
+ try:
67
+ label_content = self.label_data
68
+ except Exception as e:
69
+ logger.error(f'Error getting label content: {e}')
70
+ raise
71
+ label_path = self.label_path
72
+ unsize = label_path.parent / 'original_size' / label_path.name
73
+ unsize.parent.mkdir(parents=True, exist_ok=True)
74
+ unsize.write_bytes(label_content)
75
+ on_a4(input_file=unsize, output_file=label_path)
76
+ logger.info(f'Wrote label to {label_path}')
77
+
78
+ class ShipawTemplateResponse(BaseResponse):
79
+ template: ShipawTemplate