Files
notifications-api/notifications_utils/postal_address.py
Carlo Costino 99edc88197 Localize notification_utils to the API
This changeset pulls in all of the notification_utils code directly into the API and removes it as an external dependency.  We are doing this to cut down on operational maintenance of the project and will begin removing parts of it no longer needed for the API.

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-05-16 10:17:45 -04:00

186 lines
5.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
from functools import lru_cache
from notifications_utils.countries import UK, Country, CountryNotFoundError
from notifications_utils.countries.data import Postage
from notifications_utils.formatters import (
get_lines_with_normalised_whitespace,
remove_whitespace,
remove_whitespace_before_punctuation,
)
address_lines_1_to_6_keys = [
# The API only accepts snake_case placeholders
"address_line_1",
"address_line_2",
"address_line_3",
"address_line_4",
"address_line_5",
"address_line_6",
]
address_lines_1_to_6_and_postcode_keys = address_lines_1_to_6_keys + ["postcode"]
address_line_7_key = "address_line_7"
address_lines_1_to_7_keys = address_lines_1_to_6_keys + [address_line_7_key]
country_UK = Country(UK)
class PostalAddress:
MIN_LINES = 3
MAX_LINES = 7
INVALID_CHARACTERS_AT_START_OF_ADDRESS_LINE = r'[\/()@]<>",=~'
def __init__(self, raw_address, allow_international_letters=False):
self.raw_address = raw_address
self.allow_international_letters = allow_international_letters
self._lines = [
remove_whitespace_before_punctuation(line.rstrip(" ,"))
for line in get_lines_with_normalised_whitespace(self.raw_address)
if line.rstrip(" ,")
] or [""]
try:
self.country = Country(self._lines[-1])
self._lines_without_country = self._lines[:-1]
except CountryNotFoundError:
self._lines_without_country = self._lines
self.country = country_UK
def __bool__(self):
return bool(self.normalised)
def __repr__(self):
return f"{self.__class__.__name__}({repr(self.raw_address)})"
@classmethod
def from_personalisation(
cls, personalisation_dict, allow_international_letters=False
):
if address_line_7_key in personalisation_dict:
keys = address_lines_1_to_6_keys + [address_line_7_key]
else:
keys = address_lines_1_to_6_and_postcode_keys
return cls(
"\n".join(str(personalisation_dict.get(key) or "") for key in keys),
allow_international_letters=allow_international_letters,
)
@property
def as_personalisation(self):
lines = dict.fromkeys(address_lines_1_to_6_keys, "")
lines.update(
{
f"address_line_{index}": value
for index, value in enumerate(self.normalised_lines[:-1], start=1)
if index < 7
}
)
lines["postcode"] = lines["address_line_7"] = self.normalised_lines[-1]
return lines
@property
def as_single_line(self):
return ", ".join(self.normalised_lines)
@property
def line_count(self):
return len(self.normalised.splitlines())
@property
def has_enough_lines(self):
return self.line_count >= self.MIN_LINES
@property
def has_too_many_lines(self):
return self.line_count > self.MAX_LINES
@property
def has_valid_postcode(self):
return self.postcode is not None
@property
def has_valid_last_line(self):
return (
self.allow_international_letters and self.international
) or self.has_valid_postcode
@property
def has_invalid_characters(self):
return any(
line.startswith(tuple(self.INVALID_CHARACTERS_AT_START_OF_ADDRESS_LINE))
for line in self.normalised_lines
)
@property
def international(self):
return self.postage != Postage.UK
@property
def normalised(self):
return "\n".join(self.normalised_lines)
@property
def normalised_lines(self):
if self.international:
return self._lines_without_country + [self.country.canonical_name]
if self.postcode:
return self._lines_without_country[:-1] + [self.postcode]
return self._lines_without_country
@property
def postage(self):
return self.country.postage_zone
@property
def postcode(self):
if self.international:
return None
return format_postcode_or_none(self._lines_without_country[-1])
@property
def valid(self):
return (
self.has_valid_last_line
and self.has_enough_lines
and not self.has_too_many_lines
and not self.has_invalid_characters
)
def normalise_postcode(postcode):
return remove_whitespace(postcode).upper()
def is_a_real_uk_postcode(postcode):
standard = r"([A-Z]{1,2}[0-9][0-9A-Z]?[0-9][A-BD-HJLNP-UW-Z]{2})"
bfpo = r"(BFPO?(C\/O)?[0-9]{1,4})"
girobank = r"(GIR0AA)"
pattern = r"{}|{}|{}".format(standard, bfpo, girobank)
return bool(re.fullmatch(pattern, normalise_postcode(postcode)))
def format_postcode_for_printing(postcode):
"""
This function formats the postcode so that it is ready for automatic sorting by Royal Mail.
:param String postcode: A postcode that's already been validated by is_a_real_uk_postcode
"""
postcode = normalise_postcode(postcode)
if "BFPOC/O" in postcode:
return postcode[:4] + " C/O " + postcode[7:]
elif "BFPO" in postcode:
return postcode[:4] + " " + postcode[4:]
return postcode[:-3] + " " + postcode[-3:]
# When processing an address we look at the postcode twice when
# normalising it, and once when validating it. So 8 is chosen because
# its 3, doubled to give some headroom then rounded up to the nearest
# power of 2
@lru_cache(maxsize=8)
def format_postcode_or_none(postcode):
if is_a_real_uk_postcode(postcode):
return format_postcode_for_printing(postcode)