shipaw 0.3.0__tar.gz
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.
- shipaw-0.3.0/PKG-INFO +35 -0
- shipaw-0.3.0/README.md +0 -0
- shipaw-0.3.0/pyproject.toml +83 -0
- shipaw-0.3.0/src/shipaw/__init__.py +3 -0
- shipaw-0.3.0/src/shipaw/config.py +176 -0
- shipaw-0.3.0/src/shipaw/fapi/__init__.py +0 -0
- shipaw-0.3.0/src/shipaw/fapi/alerts.py +84 -0
- shipaw-0.3.0/src/shipaw/fapi/app.py +78 -0
- shipaw-0.3.0/src/shipaw/fapi/backend.py +89 -0
- shipaw-0.3.0/src/shipaw/fapi/callbacks.py +7 -0
- shipaw-0.3.0/src/shipaw/fapi/form_data.py +99 -0
- shipaw-0.3.0/src/shipaw/fapi/requests.py +36 -0
- shipaw-0.3.0/src/shipaw/fapi/responses.py +79 -0
- shipaw-0.3.0/src/shipaw/fapi/routes_api.py +134 -0
- shipaw-0.3.0/src/shipaw/fapi/routes_html.py +100 -0
- shipaw-0.3.0/src/shipaw/models/__init__.py +0 -0
- shipaw-0.3.0/src/shipaw/models/address.py +80 -0
- shipaw-0.3.0/src/shipaw/models/base.py +12 -0
- shipaw-0.3.0/src/shipaw/models/conversation.py +13 -0
- shipaw-0.3.0/src/shipaw/models/label_file.py +51 -0
- shipaw-0.3.0/src/shipaw/models/logging.py +50 -0
- shipaw-0.3.0/src/shipaw/models/provider.py +103 -0
- shipaw-0.3.0/src/shipaw/models/services.py +41 -0
- shipaw-0.3.0/src/shipaw/models/ship_types.py +74 -0
- shipaw-0.3.0/src/shipaw/models/shipment.py +59 -0
- shipaw-0.3.0/src/shipaw/providers/__init__.py +4 -0
- shipaw-0.3.0/src/shipaw/providers/apc_provider.py +163 -0
- shipaw-0.3.0/src/shipaw/providers/parcelforce_provider.py +181 -0
- shipaw-0.3.0/src/shipaw/providers_wip/__init__.py +0 -0
- shipaw-0.3.0/src/shipaw/providers_wip/royal_mail_provider.py +88 -0
- shipaw-0.3.0/src/shipaw/ui/static/favicon.svg +69 -0
- shipaw-0.3.0/src/shipaw/ui/static/ship.css +281 -0
- shipaw-0.3.0/src/shipaw/ui/static/ship.js +274 -0
- shipaw-0.3.0/src/shipaw/ui/static/style.css +213 -0
- shipaw-0.3.0/src/shipaw/ui/templates/alerts.html +14 -0
- shipaw-0.3.0/src/shipaw/ui/templates/app_alerts.html +18 -0
- shipaw-0.3.0/src/shipaw/ui/templates/base.html +31 -0
- shipaw-0.3.0/src/shipaw/ui/templates/order_results.html +57 -0
- shipaw-0.3.0/src/shipaw/ui/templates/order_summary.html +108 -0
- shipaw-0.3.0/src/shipaw/ui/templates/shipping_form.html +122 -0
- shipaw-0.3.0/src/shipaw/ui/templates/shipping_form_container.html +7 -0
- shipaw-0.3.0/src/shipaw/ui/zzzxxxccc +0 -0
shipaw-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shipaw
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Shipping client
|
|
5
|
+
Author: pawrequest
|
|
6
|
+
Author-email: pawrequest <shipaw+pawrequest@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Dist: pawdantic
|
|
9
|
+
Requires-Dist: combadge[zeep]
|
|
10
|
+
Requires-Dist: thefuzz
|
|
11
|
+
Requires-Dist: loguru
|
|
12
|
+
Requires-Dist: phonenumbers
|
|
13
|
+
Requires-Dist: pydantic-settings
|
|
14
|
+
Requires-Dist: httpx>=0.28.1
|
|
15
|
+
Requires-Dist: pygments>=2.19.2
|
|
16
|
+
Requires-Dist: parcelforce-expresslink
|
|
17
|
+
Requires-Dist: apc-hypaship
|
|
18
|
+
Requires-Dist: pydantic[email]>=2.11.9
|
|
19
|
+
Requires-Dist: pytest>=8.4.2
|
|
20
|
+
Requires-Dist: fastapi[standard]>=0.117.1
|
|
21
|
+
Requires-Dist: pawdf
|
|
22
|
+
Requires-Dist: jinja2>=3.1.6
|
|
23
|
+
Requires-Dist: hatchling>=1.27.0
|
|
24
|
+
Requires-Dist: pytest ; extra == 'dev'
|
|
25
|
+
Requires-Dist: autodoc-pydantic ; extra == 'dev'
|
|
26
|
+
Requires-Dist: sphinx ; extra == 'dev'
|
|
27
|
+
Requires-Dist: sphinx-autodoc-typehints ; extra == 'dev'
|
|
28
|
+
Requires-Dist: sphinx-rtd-theme ; extra == 'dev'
|
|
29
|
+
Requires-Dist: myst-parser ; extra == 'dev'
|
|
30
|
+
Requires-Dist: sphinx-readme ; extra == 'dev'
|
|
31
|
+
Requires-Dist: sphinx-rtd-dark-mode ; extra == 'dev'
|
|
32
|
+
Requires-Python: >=3.13
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
shipaw-0.3.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
license = "MIT"
|
|
3
|
+
name = "shipaw"
|
|
4
|
+
version = "0.3.0"
|
|
5
|
+
description = "Shipping client"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "pawrequest", email = "shipaw+pawrequest@users.noreply.github.com" }
|
|
8
|
+
]
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">= 3.13"
|
|
11
|
+
dependencies = [
|
|
12
|
+
'pawdantic', # special pydantic types eg truncated strings
|
|
13
|
+
'combadge[zeep]', # xml client
|
|
14
|
+
'thefuzz', # fuzzy search to match addresses
|
|
15
|
+
'loguru', # logging
|
|
16
|
+
'phonenumbers',
|
|
17
|
+
'pydantic-settings',
|
|
18
|
+
"httpx>=0.28.1",
|
|
19
|
+
"pygments>=2.19.2",
|
|
20
|
+
# "royal-mail-click-and-drop",
|
|
21
|
+
"parcelforce-expresslink",
|
|
22
|
+
"apc-hypaship",
|
|
23
|
+
"pydantic[email]>=2.11.9",
|
|
24
|
+
"pytest>=8.4.2",
|
|
25
|
+
"fastapi[standard]>=0.117.1",
|
|
26
|
+
"pawdf",
|
|
27
|
+
"jinja2>=3.1.6",
|
|
28
|
+
"hatchling>=1.27.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
'pytest',
|
|
34
|
+
'autodoc_pydantic',
|
|
35
|
+
"sphinx",
|
|
36
|
+
"sphinx-autodoc-typehints",
|
|
37
|
+
"sphinx-rtd-theme",
|
|
38
|
+
"myst-parser",
|
|
39
|
+
"sphinx-readme",
|
|
40
|
+
"sphinx_rtd_dark_mode",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.metadata]
|
|
44
|
+
allow-direct-references = true
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 120
|
|
48
|
+
extend-select = ["Q", "RUF100", "UP"]
|
|
49
|
+
flake8-quotes = { inline-quotes = "single", multiline-quotes = "double" }
|
|
50
|
+
format.quote-style = "single"
|
|
51
|
+
|
|
52
|
+
[tool.uv.sources]
|
|
53
|
+
pawdantic = { git = 'https://github.com/pawrequest/pawdantic' }
|
|
54
|
+
pawdf = { git = 'https://github.com/pawrequest/pawdf' }
|
|
55
|
+
royal-mail-click-and-drop = { git = "https://github.com/pawrequest/royal-mail-click-and-drop" }
|
|
56
|
+
parcelforce-expresslink = { git = "https://github.com/pawrequest/parcelforce_expresslink" }
|
|
57
|
+
apc-hypaship = { git = "https://github.com/pawrequest/apc-hypaship" }
|
|
58
|
+
#
|
|
59
|
+
|
|
60
|
+
[build-system]
|
|
61
|
+
requires = ["uv_build"]
|
|
62
|
+
build-backend = "uv_build"
|
|
63
|
+
|
|
64
|
+
[tool.uv.build-backend]
|
|
65
|
+
#data = { data = "ui" }
|
|
66
|
+
source-includes = ["src/shipaw/ui/**"]
|
|
67
|
+
|
|
68
|
+
#[tool.uv.build-backend]
|
|
69
|
+
#source-include = ["src/shipaw/ui/**"]
|
|
70
|
+
#[tool.uv.build-backend.data]
|
|
71
|
+
#["src/shipaw/ui"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
#[build-system]
|
|
75
|
+
#requires = ["hatchling"]
|
|
76
|
+
#build-backend = "hatchling.build"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
#[tool.hatch.build.targets.sdist]
|
|
80
|
+
#include = [
|
|
81
|
+
# "src/",
|
|
82
|
+
# "ui/,
|
|
83
|
+
#]
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
+
|
|
@@ -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
|