import re
import zlib
from django.conf import settings
from django.core import exceptions
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from phonenumber_field.modelfields import PhoneNumberField
from oscar.core.compat import AUTH_USER_MODEL
from oscar.models.fields import UppercaseCharField
[docs]class AbstractAddress(models.Model):
"""
Superclass address object
This is subclassed and extended to provide models for
user, shipping and billing addresses.
"""
MR, MISS, MRS, MS, DR = ("Mr", "Miss", "Mrs", "Ms", "Dr")
TITLE_CHOICES = (
(MR, _("Mr")),
(MISS, _("Miss")),
(MRS, _("Mrs")),
(MS, _("Ms")),
(DR, _("Dr")),
)
POSTCODE_REQUIRED = "postcode" in settings.OSCAR_REQUIRED_ADDRESS_FIELDS
# Regex for each country. Not listed countries don't use postcodes
# Based on http://en.wikipedia.org/wiki/List_of_postal_codes
POSTCODES_REGEX = {
"AC": r"^[A-Z]{4}[0-9][A-Z]$",
"AD": r"^AD[0-9]{3}$",
"AF": r"^[0-9]{4}$",
"AI": r"^AI-2640$",
"AL": r"^[0-9]{4}$",
"AM": r"^[0-9]{4}$",
"AR": r"^([0-9]{4}|[A-Z][0-9]{4}[A-Z]{3})$",
"AS": r"^[0-9]{5}(-[0-9]{4}|-[0-9]{6})?$",
"AT": r"^[0-9]{4}$",
"AU": r"^[0-9]{4}$",
"AX": r"^[0-9]{5}$",
"AZ": r"^AZ[0-9]{4}$",
"BA": r"^[0-9]{5}$",
"BB": r"^BB[0-9]{5}$",
"BD": r"^[0-9]{4}$",
"BE": r"^[0-9]{4}$",
"BG": r"^[0-9]{4}$",
"BH": r"^[0-9]{3,4}$",
"BL": r"^[0-9]{5}$",
"BM": r"^[A-Z]{2}([0-9]{2}|[A-Z]{2})",
"BN": r"^[A-Z]{2}[0-9]{4}$",
"BO": r"^[0-9]{4}$",
"BR": r"^[0-9]{5}(-[0-9]{3})?$",
"BT": r"^[0-9]{3}$",
"BY": r"^[0-9]{6}$",
"CA": r"^[A-Z][0-9][A-Z][0-9][A-Z][0-9]$",
"CC": r"^[0-9]{4}$",
"CH": r"^[0-9]{4}$",
"CL": r"^([0-9]{7}|[0-9]{3}-[0-9]{4})$",
"CN": r"^[0-9]{6}$",
"CO": r"^[0-9]{6}$",
"CR": r"^[0-9]{4,5}$",
"CU": r"^[0-9]{5}$",
"CV": r"^[0-9]{4}$",
"CX": r"^[0-9]{4}$",
"CY": r"^[0-9]{4}$",
"CZ": r"^[0-9]{5}$",
"DE": r"^[0-9]{5}$",
"DK": r"^[0-9]{4}$",
"DO": r"^[0-9]{5}$",
"DZ": r"^[0-9]{5}$",
"EC": r"^EC[0-9]{6}$",
"EE": r"^[0-9]{5}$",
"EG": r"^[0-9]{5}$",
"ES": r"^[0-9]{5}$",
"ET": r"^[0-9]{4}$",
"FI": r"^[0-9]{5}$",
"FK": r"^[A-Z]{4}[0-9][A-Z]{2}$",
"FM": r"^[0-9]{5}(-[0-9]{4})?$",
"FO": r"^[0-9]{3}$",
"FR": r"^[0-9]{5}$",
"GA": r"^[0-9]{2}.*[0-9]{2}$",
"GB": r"^[A-Z][A-Z0-9]{1,3}[0-9][A-Z]{2}$",
"GE": r"^[0-9]{4}$",
"GF": r"^[0-9]{5}$",
"GG": r"^([A-Z]{2}[0-9]{2,3}[A-Z]{2})$",
"GI": r"^GX111AA$",
"GL": r"^[0-9]{4}$",
"GP": r"^[0-9]{5}$",
"GR": r"^[0-9]{5}$",
"GS": r"^SIQQ1ZZ$",
"GT": r"^[0-9]{5}$",
"GU": r"^[0-9]{5}$",
"GW": r"^[0-9]{4}$",
"HM": r"^[0-9]{4}$",
"HN": r"^[0-9]{5}$",
"HR": r"^[0-9]{5}$",
"HT": r"^[0-9]{4}$",
"HU": r"^[0-9]{4}$",
"ID": r"^[0-9]{5}$",
"IL": r"^([0-9]{5}|[0-9]{7})$",
"IM": r"^IM[0-9]{2,3}[A-Z]{2}$$",
"IN": r"^[0-9]{6}$",
"IO": r"^[A-Z]{4}[0-9][A-Z]{2}$",
"IQ": r"^[0-9]{5}$",
"IR": r"^[0-9]{5}-[0-9]{5}$",
"IS": r"^[0-9]{3}$",
"IT": r"^[0-9]{5}$",
"JE": r"^JE[0-9]{2}[A-Z]{2}$",
"JM": r"^JM[A-Z]{3}[0-9]{2}$",
"JO": r"^[0-9]{5}$",
"JP": r"^[0-9]{3}-?[0-9]{4}$",
"KE": r"^[0-9]{5}$",
"KG": r"^[0-9]{6}$",
"KH": r"^[0-9]{5}$",
"KR": r"^[0-9]{5}$",
"KY": r"^KY[0-9]-[0-9]{4}$",
"KZ": r"^[0-9]{6}$",
"LA": r"^[0-9]{5}$",
"LB": r"^[0-9]{8}$",
"LI": r"^[0-9]{4}$",
"LK": r"^[0-9]{5}$",
"LR": r"^[0-9]{4}$",
"LS": r"^[0-9]{3}$",
"LT": r"^(LT-)?[0-9]{5}$",
"LU": r"^[0-9]{4}$",
"LV": r"^LV-[0-9]{4}$",
"LY": r"^[0-9]{5}$",
"MA": r"^[0-9]{5}$",
"MC": r"^980[0-9]{2}$",
"MD": r"^MD-?[0-9]{4}$",
"ME": r"^[0-9]{5}$",
"MF": r"^[0-9]{5}$",
"MG": r"^[0-9]{3}$",
"MH": r"^[0-9]{5}$",
"MK": r"^[0-9]{4}$",
"MM": r"^[0-9]{5}$",
"MN": r"^[0-9]{5}$",
"MP": r"^[0-9]{5}$",
"MQ": r"^[0-9]{5}$",
"MT": r"^[A-Z]{3}[0-9]{4}$",
"MV": r"^[0-9]{4,5}$",
"MX": r"^[0-9]{5}$",
"MY": r"^[0-9]{5}$",
"MZ": r"^[0-9]{4}$",
"NA": r"^[0-9]{5}$",
"NC": r"^[0-9]{5}$",
"NE": r"^[0-9]{4}$",
"NF": r"^[0-9]{4}$",
"NG": r"^[0-9]{6}$",
"NI": r"^[0-9]{5}$",
"NL": r"^[0-9]{4}[A-Z]{2}$",
"NO": r"^[0-9]{4}$",
"NP": r"^[0-9]{5}$",
"NZ": r"^[0-9]{4}$",
"OM": r"^[0-9]{3}$",
"PA": r"^[0-9]{6}$",
"PE": r"^[0-9]{5}$",
"PF": r"^[0-9]{5}$",
"PG": r"^[0-9]{3}$",
"PH": r"^[0-9]{4}$",
"PK": r"^[0-9]{5}$",
"PL": r"^[0-9]{2}-?[0-9]{3}$",
"PM": r"^[0-9]{5}$",
"PN": r"^[A-Z]{4}[0-9][A-Z]{2}$",
"PR": r"^[0-9]{5}$",
"PT": r"^[0-9]{4}(-?[0-9]{3})?$",
"PW": r"^[0-9]{5}$",
"PY": r"^[0-9]{4}$",
"RE": r"^[0-9]{5}$",
"RO": r"^[0-9]{6}$",
"RS": r"^[0-9]{5}$",
"RU": r"^[0-9]{6}$",
"SA": r"^[0-9]{5}$",
"SD": r"^[0-9]{5}$",
"SE": r"^[0-9]{5}$",
"SG": r"^([0-9]{2}|[0-9]{4}|[0-9]{6})$",
"SH": r"^(STHL1ZZ|TDCU1ZZ)$",
"SI": r"^(SI-)?[0-9]{4}$",
"SK": r"^[0-9]{5}$",
"SM": r"^[0-9]{5}$",
"SN": r"^[0-9]{5}$",
"SV": r"^01101$",
"SZ": r"^[A-Z][0-9]{3}$",
"TC": r"^TKCA1ZZ$",
"TD": r"^[0-9]{5}$",
"TH": r"^[0-9]{5}$",
"TJ": r"^[0-9]{6}$",
"TM": r"^[0-9]{6}$",
"TN": r"^[0-9]{4}$",
"TR": r"^[0-9]{5}$",
"TT": r"^[0-9]{6}$",
"TW": r"^([0-9]{3}|[0-9]{5})$",
"UA": r"^[0-9]{5}$",
"US": r"^[0-9]{5}(-[0-9]{4}|-[0-9]{6})?$",
"UY": r"^[0-9]{5}$",
"UZ": r"^[0-9]{6}$",
"VA": r"^00120$",
"VC": r"^VC[0-9]{4}",
"VE": r"^[0-9]{4}[A-Z]?$",
"VG": r"^VG[0-9]{4}$",
"VI": r"^[0-9]{5}$",
"VN": r"^[0-9]{6}$",
"WF": r"^[0-9]{5}$",
"XK": r"^[0-9]{5}$",
"YT": r"^[0-9]{5}$",
"ZA": r"^[0-9]{4}$",
"ZM": r"^[0-9]{5}$",
}
title = models.CharField(
pgettext_lazy("Treatment Pronouns for the customer", "Title"),
max_length=64,
choices=TITLE_CHOICES,
blank=True,
)
first_name = models.CharField(_("First name"), max_length=255, blank=True)
last_name = models.CharField(_("Last name"), max_length=255, blank=True)
# We use quite a few lines of an address as they are often quite long and
# it's easier to just hide the unnecessary ones than add extra ones.
line1 = models.CharField(_("First line of address"), max_length=255)
line2 = models.CharField(_("Second line of address"), max_length=255, blank=True)
line3 = models.CharField(_("Third line of address"), max_length=255, blank=True)
line4 = models.CharField(_("City"), max_length=255, blank=True)
state = models.CharField(_("State/County"), max_length=255, blank=True)
postcode = UppercaseCharField(_("Post/Zip-code"), max_length=64, blank=True)
country = models.ForeignKey(
"address.Country", on_delete=models.CASCADE, verbose_name=_("Country")
)
# A field only used for searching addresses - this contains all the
# `search_fields`. This is effectively a poor man's Solr text field.
search_text = models.TextField(
_("Search text - used only for searching addresses"), editable=False
)
search_fields = [
"first_name",
"last_name",
"line1",
"line2",
"line3",
"line4",
"state",
"postcode",
"country",
]
# Fields, used for `summary` property definition and hash generation.
base_fields = hash_fields = [
"salutation",
"line1",
"line2",
"line3",
"line4",
"state",
"postcode",
"country",
]
def __str__(self):
return self.summary
class Meta:
abstract = True
verbose_name = _("Address")
verbose_name_plural = _("Addresses")
# Saving
[docs] def save(self, *args, **kwargs):
self._update_search_text()
super().save(*args, **kwargs)
[docs] def clean(self):
# Strip all whitespace
for field in [
"first_name",
"last_name",
"line1",
"line2",
"line3",
"line4",
"state",
"postcode",
]:
if self.__dict__[field]:
self.__dict__[field] = self.__dict__[field].strip()
# Ensure postcodes are valid for country
self.ensure_postcode_is_valid_for_country()
[docs] def ensure_postcode_is_valid_for_country(self):
"""
Validate postcode given the country
"""
if not self.postcode and self.POSTCODE_REQUIRED and self.country_id:
country_code = self.country.iso_3166_1_a2
regex = self.POSTCODES_REGEX.get(country_code, None)
if regex:
msg = _("Addresses in %(country)s require a valid postcode") % {
"country": self.country
}
raise exceptions.ValidationError(msg)
if self.postcode and self.country_id:
# Ensure postcodes are always uppercase
postcode = self.postcode.upper().replace(" ", "")
country_code = self.country.iso_3166_1_a2
regex = self.POSTCODES_REGEX.get(country_code, None)
# Validate postcode against regex for the country if available
if regex and not re.match(regex, postcode):
msg = _("The postcode '%(postcode)s' is not valid for %(country)s") % {
"postcode": self.postcode,
"country": self.country,
}
raise exceptions.ValidationError({"postcode": [msg]})
def _update_search_text(self):
self.search_text = self.join_fields(self.search_fields, separator=" ")
# Properties
@property
def city(self):
# Common alias
return self.line4
@property
def summary(self):
"""
Returns a single string summary of the address,
separating fields using commas.
"""
return ", ".join(self.active_address_fields())
@property
def salutation(self):
"""
Name (including title)
"""
return self.join_fields(
("title", "first_name", "last_name"), separator=" "
).strip()
@property
def name(self):
return self.join_fields(("first_name", "last_name"), separator=" ")
# Helpers
def get_field_values(self, fields):
field_values = []
for field in fields:
# Title is special case
if field == "title":
value = self.get_title_display()
elif field == "country":
try:
value = self.country.printable_name
except exceptions.ObjectDoesNotExist:
value = ""
elif field == "salutation":
value = self.salutation
else:
value = getattr(self, field)
field_values.append(value)
return field_values
[docs] def get_address_field_values(self, fields):
"""
Returns set of field values within the salutation and country.
"""
field_values = [f.strip() for f in self.get_field_values(fields) if f]
return field_values
[docs] def generate_hash(self):
"""
Returns a hash of the address, based on standard set of fields, listed
out in `hash_fields` property.
"""
field_values = self.get_address_field_values(self.hash_fields)
# Python 2 and 3 generates CRC checksum in different ranges, so
# in order to generate platform-independent value we apply
# `& 0xffffffff` expression.
return zlib.crc32(", ".join(field_values).upper().encode("UTF8")) & 0xFFFFFFFF
[docs] def join_fields(self, fields, separator=", "):
"""
Join a sequence of fields using the specified separator
"""
field_values = self.get_field_values(fields)
return separator.join(filter(bool, field_values))
[docs] def populate_alternative_model(self, address_model):
"""
For populating an address model using the matching fields
from this one.
This is used to convert a user address to a shipping address
as part of the checkout process.
"""
destination_field_names = [field.name for field in address_model._meta.fields]
for field_name in [field.name for field in self._meta.fields]:
if field_name in destination_field_names and field_name != "id":
setattr(address_model, field_name, getattr(self, field_name))
[docs] def active_address_fields(self):
"""
Returns the non-empty components of the address, but merging the
title, first_name and last_name into a single line. It uses fields
listed out in `base_fields` property.
"""
return self.get_address_field_values(self.base_fields)
[docs]class AbstractCountry(models.Model):
"""
`ISO 3166 Country Codes <https://www.iso.org/iso-3166-country-codes.html>`_
The field names are a bit awkward, but kept for backwards compatibility.
pycountry's syntax of alpha2, alpha3, name and official_name seems sane.
"""
iso_3166_1_a2 = models.CharField(
_("ISO 3166-1 alpha-2"), max_length=2, primary_key=True
)
iso_3166_1_a3 = models.CharField(_("ISO 3166-1 alpha-3"), max_length=3, blank=True)
iso_3166_1_numeric = models.CharField(
_("ISO 3166-1 numeric"), blank=True, max_length=3
)
#: The commonly used name; e.g. 'United Kingdom'
printable_name = models.CharField(_("Country name"), max_length=128, db_index=True)
#: The full official name of a country
#: e.g. 'United Kingdom of Great Britain and Northern Ireland'
name = models.CharField(_("Official name"), max_length=128)
display_order = models.PositiveSmallIntegerField(
_("Display order"),
default=0,
db_index=True,
help_text=_("Higher the number, higher the country in the list."),
)
is_shipping_country = models.BooleanField(
_("Is shipping country"), default=False, db_index=True
)
class Meta:
abstract = True
app_label = "address"
verbose_name = _("Country")
verbose_name_plural = _("Countries")
ordering = (
"-display_order",
"printable_name",
)
def __str__(self):
return self.printable_name or self.name
@property
def code(self):
"""
Shorthand for the ISO 3166 Alpha-2 code
"""
return self.iso_3166_1_a2
@property
def numeric_code(self):
"""
Shorthand for the ISO 3166 numeric code.
:py:attr:`.iso_3166_1_numeric` used to wrongly be a integer field, but has to
be padded with leading zeroes. It's since been converted to a char
field, but the database might still contain non-padded strings. That's
why the padding is kept.
"""
return "%.03d" % int(self.iso_3166_1_numeric)
[docs]class AbstractShippingAddress(AbstractAddress):
"""
A shipping address.
A shipping address should not be edited once the order has been placed -
it should be read-only after that.
NOTE:
ShippingAddress is a model of the order app. But moving it there is tricky
due to circular import issues that are amplified by get_model/get_class
calls pre-Django 1.7 to register receivers. So...
TODO: Once Django 1.6 support is dropped, move AbstractBillingAddress and
AbstractShippingAddress to the order app, and move
PartnerAddress to the partner app.
"""
phone_number = PhoneNumberField(
_("Phone number"),
blank=True,
help_text=_("In case we need to call you about your order"),
)
notes = models.TextField(
blank=True,
verbose_name=_("Instructions"),
help_text=_("Tell us anything we should know when delivering your order."),
)
class Meta:
abstract = True
# ShippingAddress is registered in order/models.py
app_label = "order"
verbose_name = _("Shipping address")
verbose_name_plural = _("Shipping addresses")
@property
def order(self):
"""
Return the order linked to this shipping address
"""
return self.order_set.first()
[docs]class AbstractUserAddress(AbstractShippingAddress):
"""
A user's address. A user can have many of these and together they form an
'address book' of sorts for the user.
We use a separate model for shipping and billing (even though there will be
some data duplication) because we don't want shipping/billing addresses
changed or deleted once an order has been placed. By having a separate
model, we allow users the ability to add/edit/delete from their address
book without affecting orders already placed.
"""
user = models.ForeignKey(
AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="addresses",
verbose_name=_("User"),
)
#: Whether this address is the default for shipping
is_default_for_shipping = models.BooleanField(
_("Default shipping address?"), default=False
)
#: Whether this address should be the default for billing.
is_default_for_billing = models.BooleanField(
_("Default billing address?"), default=False
)
#: We keep track of the number of times an address has been used
#: as a shipping address so we can show the most popular ones
#: first at the checkout.
num_orders_as_shipping_address = models.PositiveIntegerField(
_("Number of Orders as Shipping Address"), default=0
)
#: Same as previous, but for billing address.
num_orders_as_billing_address = models.PositiveIntegerField(
_("Number of Orders as Billing Address"), default=0
)
#: A hash is kept to try and avoid duplicate addresses being added
#: to the address book.
hash = models.CharField(
_("Address Hash"), max_length=255, db_index=True, editable=False
)
date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
[docs] def save(self, *args, **kwargs):
"""
Save a hash of the address fields
"""
# Save a hash of the address fields so we can check whether two
# addresses are the same to avoid saving duplicates
self.hash = self.generate_hash()
# Ensure that each user only has one default shipping address
# and billing address
self._ensure_defaults_integrity()
super().save(*args, **kwargs)
def _ensure_defaults_integrity(self):
if self.is_default_for_shipping:
self.__class__._default_manager.filter(
user=self.user, is_default_for_shipping=True
).update(is_default_for_shipping=False)
if self.is_default_for_billing:
self.__class__._default_manager.filter(
user=self.user, is_default_for_billing=True
).update(is_default_for_billing=False)
class Meta:
abstract = True
app_label = "address"
verbose_name = _("User address")
verbose_name_plural = _("User addresses")
ordering = ["-num_orders_as_shipping_address"]
unique_together = ("user", "hash")
[docs] def validate_unique(self, exclude=None):
super().validate_unique(exclude)
qs = self.__class__.objects.filter(user=self.user, hash=self.generate_hash())
if self.id:
qs = qs.exclude(id=self.id)
if qs.exists():
raise exceptions.ValidationError(
{"__all__": [_("This address is already in your address book")]}
)
[docs]class AbstractBillingAddress(AbstractAddress):
class Meta:
abstract = True
# BillingAddress is registered in order/models.py
app_label = "order"
verbose_name = _("Billing address")
verbose_name_plural = _("Billing addresses")
@property
def order(self):
"""
Return the order linked to this shipping address
"""
return self.order_set.first()
[docs]class AbstractPartnerAddress(AbstractAddress):
"""
A partner can have one or more addresses. This can be useful e.g. when
determining US tax which depends on the origin of the shipment.
"""
partner = models.ForeignKey(
"partner.Partner",
on_delete=models.CASCADE,
related_name="addresses",
verbose_name=_("Partner"),
)
class Meta:
abstract = True
app_label = "partner"
verbose_name = _("Partner address")
verbose_name_plural = _("Partner addresses")