Repository URL to install this package:
|
Version:
0.4.198 ▾
|
lib-py-b2b
/
address.py
|
|---|
import textwrap
from datetime import datetime, timedelta
from pprint import pformat
import re
from lib_b2b.persistent import Persistable
from .errors import InvalidAddressError
from enum import Enum
class AddressEqualityLevel(Enum):
CITY_STATE_ZIP = 1
STREET_CITY_STATE_ZIP = 2
class Address:
@staticmethod
def from_dict(addr_dict):
if not addr_dict:
return None
return Address(address1=addr_dict.get('address1', None),
city=addr_dict.get('city', None),
state=addr_dict.get('state', None),
postalCode=addr_dict.get('postalCode', None) or addr_dict.get('zip', None),
country=addr_dict.get('country', None),
phone=addr_dict.get('phone', None),
address2=addr_dict.get('address2', None),
name=addr_dict.get('name', None),
company=addr_dict.get('company', None)
)
def validate(self, customer_edi_id):
# TODO: - Check to see if we can insert comments in Shopify as a notification mechanism
if not self.phone:
raise InvalidAddressError("The order shipping address is missing a phone number and cannot be shipped.")
if not self.address1:
raise InvalidAddressError("The order shipping address is missing a street address and cannot be shipped.")
if not self.city:
raise InvalidAddressError("The order shipping address is missing a city and cannot be shipped.")
if not self.state:
raise InvalidAddressError("The order shipping address is missing a state and cannot be shipped.")
if not self.postalCode:
raise InvalidAddressError("The order shipping address is missing a postal code and cannot be shipped.")
if not self.name and not self.company:
raise InvalidAddressError("The order shipping address is missing a recipient name or company "
"and cannot be shipped.")
if self.address1 and len(self.address1) > 35:
raise InvalidAddressError("The first address line cannot be more than 35 characters.")
if self.address2 and len(self.address2) > 35:
raise InvalidAddressError("The second address line cannot be more than 35 characters.")
if self.city and len(self.city) > 20:
raise InvalidAddressError("The city cannot be more than 20 characters.")
if self.state and len(self.state) > 2:
raise InvalidAddressError("The state cannot be more than 2 characters.")
if self.name and len(self.name) > 35:
raise InvalidAddressError("The name cannot be more than 35 characters.")
if self.company and len(self.company) > 35:
raise InvalidAddressError("The company name cannot be more than 35 characters.")
if self.postalCode and not re.search(r'^[0-9]{5}(?:-[0-9]{4})?$', self.postalCode):
raise InvalidAddressError("Invalid postal code. The postal code doesn't match the standard "
"USPS zip or zip+4 format.")
from .profile import Profile
profile = Profile.profile_for(customer=customer_edi_id)
if profile.fulfillment_config.validate_address:
from .carrier import CarrierType
carrier_type = CarrierType(profile.fulfillment_config.carrier_type)
if carrier_type == CarrierType.FEDEX_API:
from .fedex_api import FedexAPI
fapi = FedexAPI(profile)
validation_result = fapi.validate_address(self)
return validation_result
else:
return None
def __init__(self, address1, city, state, postalCode, country, phone=None, address2=None, name=None, company=None):
self.city = city
self.state = state
self.postalCode = postalCode
self.zip = postalCode
self.country = country
self.phone = phone
self.name = name
self.company = company
self.address1 = address1
self.address2 = address2
self.__wrap_address_lines()
def __wrap_address_lines(self):
_address_lines = []
if not self.address1 and self.address2:
self.address1 = self.address2
self.address2 = None
if self.address1:
if len(self.address1) > 35:
_line = self.address1 + ' ' + (self.address2 or '')
_wrapped = textwrap.wrap(_line, 35, break_long_words=False, max_lines=2)
_address_lines.extend(_wrapped)
else:
_address_lines.append(self.address1)
_address_lines.append(self.address2)
self.address1 = _address_lines[0] if _address_lines else None
self.address2 = _address_lines[1] if _address_lines and len(_address_lines) > 1 else None
@property
def postalCode5(self) -> str:
"""
Get the first five digits of the postal code
:return: first five zip code
:rtype: str
"""
return self.postalCode[:5]
def merge(self, an_address):
"""
merges any attributes from the parameter, that are missing in self, into self
:param an_address: Address
:return:
"""
for v in vars(self):
if not getattr(self, v, None) and getattr(an_address, v, None):
setattr(self, v, getattr(an_address, v, None))
def mostly_equals(self, other: 'Address', equality_level: AddressEqualityLevel, ignore_case: bool = True) -> bool:
"""
Compares the address part excluding names, company, phone, etc
:param equality_level: The level of the comparison
:type equality_level: AddressEqualityLevel
:param other: the address to compare with self
:type other: Address
:param ignore_case: ignore case when comparing the strings in the address
:type ignore_case: bol
:return: boolean
:rtype: bool
"""
if equality_level is AddressEqualityLevel.STREET_CITY_STATE_ZIP:
self_comparable = f"{self.address1 or ''}{self.address2 or ''}{self.city or ''}{self.state or ''}{self.postalCode5 or ''}"
other_comparable = f"{other.address1 or ''}{other.address2 or ''}{other.city or ''}{other.state or ''}{other.postalCode5 or ''}"
elif equality_level is AddressEqualityLevel.CITY_STATE_ZIP:
self_comparable = f"{self.city or ''}{self.state or ''}{self.postalCode5 or ''}"
other_comparable = f"{other.city or ''}{other.state or ''}{other.postalCode5 or ''}"
else:
raise NotImplementedError
if ignore_case:
self_comparable = self_comparable.lower()
other_comparable = other_comparable.lower()
return self_comparable == other_comparable
def __eq__(self, other):
if isinstance(other, self.__class__):
return vars(self) == vars(other)
def __repr__(self):
return "<" + type(self).__name__ + "> " + pformat(vars(self), indent=2, width=1)
def __str__(self):
return pformat(vars(self), indent=2, width=1)
def as_dict(self):
return vars(self)
class ValidationAddressType(Enum):
RAW = "RAW"
NORMALIZED = "NORMALIZED"
STANDARDIZED = "STANDARDIZED"
UNKNOWN = "UNKNOWN"
class AddressValidationResult(Persistable):
# Wrapped this expecting to find future functionality for address validation and error propagation
def __init__(self, original_address: Address, effective_address: Address, address_type: ValidationAddressType, residential: bool,
attributes: dict, estimated_transit_time: int = -1):
self.original_address = original_address
self.estimated_transit_time = estimated_transit_time
self.residential = residential
self.effective_address = effective_address
self.attributes = attributes
self.address_type = address_type
def is_valid(self):
resolved = self.attributes.get('Resolved', False)
pobox = self.attributes.get('POBox', False)
return self.address_type == ValidationAddressType.STANDARDIZED and resolved and not pobox
def should_warn(self):
return self.is_valid() and (self.street_modified or self.city_modified or not self.delivery_point_valid())
def delivery_point_valid(self):
return self.attributes.get('DPV', False)
def is_usable(self):
return self.is_valid() or (
self.address_type in [ValidationAddressType.STANDARDIZED, ValidationAddressType.NORMALIZED])
def is_residential(self):
return self.residential
@property
def street_modified(self):
return not self.original_address.mostly_equals(self.effective_address, AddressEqualityLevel.STREET_CITY_STATE_ZIP)
@property
def city_modified(self):
return not self.original_address.mostly_equals(self.effective_address, AddressEqualityLevel.CITY_STATE_ZIP)
def __messages_for_attributes(self):
_messages = []
if not self.attributes.get('DPV', False):
_messages.append("Invalid delivery point. Carrier may not be able to "
"deliver to the address as currently entered.")
if not self.attributes.get('CountrySupported', True):
_messages.append("Country not supported.")
if not self.attributes.get('PostalValidated', True):
_messages.append("Unable to validate the postal code.")
if not self.attributes.get('CityStateValidated', True):
_messages.append("City/State are not valid for the postal code.")
if self.attributes.get('POBox', False):
_messages.append("Ground delivery is unavailable for a PO Box.")
if self.attributes.get('SuiteRequiredButMissing', False):
_messages.append("Missing suite number.")
if not self.attributes.get('StreetValidated', True):
_messages.append("Unable to match the street address.")
if not self.attributes.get('StreetRangeValidated', True):
_messages.append("Unable to validate the street range.")
if self.attributes.get('InvalidSuiteNumber', False):
_messages.append("Invalid suite number.")
if self.attributes.get('MissingOrAmbiguousDirectional', False):
_messages.append("Missing or ambiguous directional.")
if self.attributes.get('MultipleMatches', False):
_messages.append("Multiple address matches.")
if self.city_modified:
_messages.append("City, State, or Postal Code corrected or abbreviated by carrier.")
if self.street_modified and not self.city_modified:
_messages.append("Street may have been corrected or abbreviated by the carrier.")
return _messages
def get_estimated_delivery(self, ship_date: datetime):
if self.estimated_transit_time < 0:
return ship_date
else:
return ship_date + timedelta(days=int(self.estimated_transit_time))
@property
def messages(self) -> [str]:
if self.is_valid() and not self.should_warn():
return ["Address is valid"]
else:
return self.__messages_for_attributes() or ["Unable to match address"]
def as_dict(self):
return dict(residential=self.residential,
original_address=self.original_address.as_dict(),
effective_address=self.effective_address.as_dict(),
attributes=self.attributes,
address_type=self.address_type.value,
estimated_transit_time=self.estimated_transit_time)
@staticmethod
def from_dict(data: dict):
return AddressValidationResult(
original_address=Address.from_dict(data.get('original_address')),
effective_address=Address.from_dict(data.get('effective_address')),
address_type=ValidationAddressType(data.get('address_type')),
residential=data.get('residential'),
attributes=data.get('attributes'),
estimated_transit_time=data.get('estimated_transit_time', -1)
)