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
#: relevant fields. This is effectively a poor man's Solr text field.
search_text = models.TextField(
_("Search text - used only for searching addresses"), editable=False)
# 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):
search_fields = filter(
bool, [self.first_name, self.last_name,
self.line1, self.line2, self.line3, self.line4,
self.state, self.postcode, self.country.name])
self.search_text = ' '.join(search_fields)
# 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=" ")
@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")