# -*- coding: utf-8 -*-
# This file is part of Shuup.
#
# Copyright (c) 2012-2021, Shuup Commerce Inc. All rights reserved.
#
# This source code is licensed under the OSL-3.0 license found in the
# LICENSE file in the root directory of this source tree.
from __future__ import unicode_literals, with_statement
import calendar
import datetime
import six
from collections import defaultdict
from decimal import Decimal
from django import forms
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.template.defaultfilters import yesno
from django.utils.encoding import python_2_unicode_compatible
from django.utils.timesince import timesince
from django.utils.timezone import now
from django.utils.translation import get_language, ugettext_lazy as _
from enumfields import Enum, EnumIntegerField
from parler.managers import TranslatableQuerySet
from parler.models import TranslatableModel, TranslatedFields
from typing import Iterable, Union
from shuup.core.fields import InternalIdentifierField
from shuup.core.templatetags.shuup_common import datetime as format_datetime, number as format_number
from shuup.utils.analog import define_log_model
from shuup.utils.dates import parse_date
from shuup.utils.fields import TypedMultipleChoiceWithLimitField
from shuup.utils.numbers import parse_decimal_string
from shuup.utils.text import flatten
NoSuchAttributeHere = object()
class AttributeVisibility(Enum):
HIDDEN = 0
SHOW_ON_PRODUCT_PAGE = 1
SEARCHABLE_FIELD = 2
NOT_VISIBLE = 3
class Labels:
HIDDEN = _("hidden")
SHOW_ON_PRODUCT_PAGE = _("shown on product page")
SEARCHABLE_FIELD = _("searchable metadata")
NOT_VISIBLE = _("private metadata")
class AttributeType(Enum):
INTEGER = 1
BOOLEAN = 2
DECIMAL = 3
TIMEDELTA = 4
DATETIME = 10
DATE = 11
TRANSLATED_STRING = 20
UNTRANSLATED_STRING = 21
CHOICES = 22
class Labels:
INTEGER = _("integer")
DECIMAL = _("decimal")
BOOLEAN = _("boolean")
TIMEDELTA = _("time interval")
DATETIME = _("date and time")
DATE = _("date only")
TRANSLATED_STRING = _("translated string")
UNTRANSLATED_STRING = _("untranslated string")
CHOICES = _("choices")
ATTRIBUTE_STRING_TYPES = (
AttributeType.TRANSLATED_STRING,
AttributeType.UNTRANSLATED_STRING,
)
ATTRIBUTE_NUMERIC_TYPES = (
AttributeType.INTEGER,
AttributeType.DECIMAL,
AttributeType.BOOLEAN,
AttributeType.TIMEDELTA,
)
ATTRIBUTE_DATETIME_TYPES = (
AttributeType.DATETIME,
AttributeType.DATE,
)
class AttributeQuerySet(TranslatableQuerySet):
def visible(self):
return self.exclude(visibility_mode=AttributeVisibility.HIDDEN)
@python_2_unicode_compatible
class Attribute(TranslatableModel):
identifier = InternalIdentifierField(unique=True, blank=False, null=False, editable=True)
searchable = models.BooleanField(
default=True,
verbose_name=_("searchable"),
help_text=_("Searchable attributes will be used for product lookup when customers search in your store."),
)
type = EnumIntegerField(
AttributeType,
default=AttributeType.TRANSLATED_STRING,
verbose_name=_("type"),
help_text=_("The attribute data type. Attribute values can be set on the product editor page."),
)
min_choices = models.PositiveIntegerField(
default=0,
verbose_name=_("Minimum amount of choices"),
help_text=_(
"Minimum amount of choices that user can choose from existing options. "
"This field has affect only for choices type."
),
)
max_choices = models.PositiveIntegerField(
default=1,
verbose_name=_("Maximum amount of choices"),
help_text=_(
"Maximum amount of choices that user can choose from existing options. "
"This field has affect only for choices type."
),
)
visibility_mode = EnumIntegerField(
AttributeVisibility,
default=AttributeVisibility.SHOW_ON_PRODUCT_PAGE,
verbose_name=_("visibility mode"),
help_text=_(
"Select the attribute visibility setting. "
"Attributes can be shown on the product detail page or can be used to enhance product search results."
),
)
ordering = models.IntegerField(
default=0,
help_text=_("The ordering in which your attribute will be displayed."),
)
translations = TranslatedFields(
name=models.CharField(
max_length=256,
verbose_name=_("name"),
help_text=_(
"The attribute name. "
"Product attributes can be used to list the various features of a product and can be shown on the "
"product detail page. The product attributes for a product are determined by the product type and can "
"be set on the product editor page."
),
),
)
objects = AttributeQuerySet.as_manager()
class Meta:
verbose_name = _("attribute")
verbose_name_plural = _("attributes")
def __str__(self):
return "%s" % self.name
[docs] def save(self, *args, **kwargs):
if not self.identifier:
raise ValueError("Error! Attribute with null identifier is not allowed.")
self.identifier = flatten(("%s" % self.identifier).lower())
return super(Attribute, self).save(*args, **kwargs)
@property
def is_translated(self):
return self.type == AttributeType.TRANSLATED_STRING
@property
def is_stringy(self):
# Pun intended.
return self.type in ATTRIBUTE_STRING_TYPES
@property
def is_numeric(self):
return self.type in ATTRIBUTE_NUMERIC_TYPES
@property
def is_temporal(self):
return self.type in ATTRIBUTE_DATETIME_TYPES
@property
def is_choices(self):
return self.type == AttributeType.CHOICES
[docs] def is_null_value(self, value):
"""
Find out whether the given value is null from this attribute's point of view.
:param value: A value.
:type value: object
:return: Nulliness boolean.
:rtype: bool
"""
if self.type == AttributeType.BOOLEAN:
return value is None
return not value
class AttributeChoiceOption(TranslatableModel):
attribute = models.ForeignKey(
on_delete=models.CASCADE, to=Attribute, related_name="choices", verbose_name=_("attribute")
)
translations = TranslatedFields(
name=models.CharField(
max_length=256,
verbose_name=_("name"),
help_text=_("The attribute choice option name. "),
),
)
class AppliedAttribute(TranslatableModel):
_applied_fk_field = None # Used by the `repr` implementation
attribute = models.ForeignKey(on_delete=models.CASCADE, to=Attribute, verbose_name=_("attribute"))
chosen_options = models.ManyToManyField(to=AttributeChoiceOption, verbose_name=_("chosen options"))
numeric_value = models.DecimalField(
null=True, blank=True, max_digits=36, decimal_places=9, verbose_name=_("numeric value"), db_index=True
)
datetime_value = models.DateTimeField(
auto_now_add=False, editable=True, null=True, blank=True, verbose_name=_("datetime value"), db_index=True
)
untranslated_string_value = models.TextField(blank=True, verbose_name=_("untranslated value"))
# Concrete subclasses will require this TranslatedFields declaration:
# translations = TranslatedFields(
# translated_string_value=models.TextField(blank=True),
# )
class Meta:
abstract = True
ATTRIBUTE_TYPE_GETTERS = {
AttributeType.BOOLEAN: (lambda self: bool(int(self.numeric_value)) if self.numeric_value is not None else None),
AttributeType.INTEGER: (lambda self: int(self.numeric_value)),
AttributeType.DECIMAL: (lambda self: Decimal(self.numeric_value)),
AttributeType.TIMEDELTA: (lambda self: datetime.timedelta(seconds=float(self.numeric_value))),
AttributeType.DATETIME: (lambda self: self.datetime_value),
AttributeType.DATE: (lambda self: self.datetime_value.date()),
AttributeType.UNTRANSLATED_STRING: (lambda self: self.untranslated_string_value),
AttributeType.TRANSLATED_STRING: (lambda self: self.translated_string_value if self.has_translation() else ""),
AttributeType.CHOICES: (lambda self: "; ".join(option.name for option in self.chosen_options.all())),
}
def _get_value(self):
getter = self.ATTRIBUTE_TYPE_GETTERS.get(self.attribute.type)
if getter:
return getter(self)
raise ValueError("Error! Unknown attribute type.") # pragma: no cover
def _set_numeric_value(self, new_value):
if self.attribute.type == AttributeType.BOOLEAN and new_value is None:
"""
Shuup uses `django.forms.fields.NullBooleanField` in admin.
Which can read in the `None` value.
Note: This is being handled separately due to backwards compatibility.
TODO (2.0): Boolean should not be a special case and handling `None` should be
same for every "numeric" value.
"""
self.numeric_value = None
self.datetime_value = None
self.untranslated_string_value = ""
return
if isinstance(new_value, datetime.timedelta):
value = new_value.total_seconds()
if value == int(value):
value = int(value)
else:
value = parse_decimal_string(new_value or 0)
if self.attribute.type == AttributeType.INTEGER:
value = int(value)
if self.attribute.type == AttributeType.BOOLEAN:
value = int(bool(value))
self.numeric_value = value
self.datetime_value = None
self.untranslated_string_value = str(self.numeric_value)
return
def _set_string_value(self, new_value):
if new_value is None:
new_value = ""
new_value = six.text_type(new_value)
if self.attribute.type == AttributeType.UNTRANSLATED_STRING:
self.untranslated_string_value = new_value
elif self.attribute.type == AttributeType.TRANSLATED_STRING:
self.translated_string_value = new_value
try:
self.numeric_value = int(self.string_value, 10)
except Exception:
self.numeric_value = None
self.datetime_value = None
return
def _set_datetime_value(self, new_value):
if self.attribute.type == AttributeType.DATETIME:
# Just store datetimes
if not isinstance(new_value, datetime.datetime):
raise TypeError("Error! Can't assign `%r` to DATETIME attribute." % new_value)
self.datetime_value = new_value
self.numeric_value = calendar.timegm(self.datetime_value.timetuple())
self.untranslated_string_value = self.datetime_value.isoformat()
elif self.attribute.type == AttributeType.DATE:
# Store dates as "date at midnight"
date = parse_date(new_value)
self.datetime_value = datetime.datetime.combine(date=date, time=datetime.time())
self.numeric_value = date.toordinal() # Store date ordinal as numeric value
self.untranslated_string_value = date.isoformat() # Store date ISO format as string value
def _set_choices_value(self, choices: Union[str, Iterable[Union[str, int, AttributeChoiceOption]]]):
choices_ids = []
# in case `choices` is a comma-separated string, like `option a; option b`
if isinstance(choices, str):
choices = [choice.strip() for choice in choices.split(";")]
for choice in choices:
if isinstance(choice, AttributeChoiceOption):
choices_ids.append(choice.pk)
elif isinstance(choice, int):
choices_ids.append(choice)
elif isinstance(choice, str):
existing_choice = self.attribute.choices.filter(translations__name=choice).first()
if existing_choice:
choices_ids.append(existing_choice.pk)
# set the options
self.chosen_options.set(choices_ids)
def _set_value(self, new_value):
if self.attribute.is_numeric:
self._set_numeric_value(new_value)
return
if self.attribute.is_stringy:
self._set_string_value(new_value)
return
if self.attribute.is_temporal:
self._set_datetime_value(new_value)
return
if self.attribute.is_choices:
self._set_choices_value(new_value)
return
raise ValueError("Error! Unknown attribute type.") # pragma: no cover
value = property(_get_value, _set_value)
@property
def name(self):
"""
Get the name of the underlying attribute in the current language.
"""
return self.attribute.safe_translation_getter("name", self.attribute.identifier)
@property
def formatted_value(self):
"""
Get a human-consumable value for the attribute.
The current locale is used for formatting.
:return: Textual value.
:rtype: str
"""
try:
if self.attribute.type == AttributeType.BOOLEAN:
return yesno(self.value)
if self.attribute.type in (AttributeType.INTEGER, AttributeType.DECIMAL):
return format_number(self.value)
if self.attribute.type == AttributeType.TIMEDELTA:
a = now()
b = a + self.value
return timesince(a, b)
if self.attribute.type in (AttributeType.DATETIME, AttributeType.DATE):
return format_datetime(self.value)
except Exception: # If formatting fails, fall back to string formatting.
pass
return six.text_type(self.value)
def __repr__(self): # pragma: no cover
return "<%s of %r: %s=%r>" % (
type(self).__name__,
getattr(self, self._applied_fk_field or "", None),
self.attribute.identifier,
self.value,
)
class AttributableMixin(object):
def _set_cached_attribute(self, language, identifier, applied_attribute):
if not hasattr(self, "_attr_cache"):
self._attr_cache = {}
self._attr_cache[(language, identifier or applied_attribute.attribute.identifier)] = applied_attribute
@classmethod
def cache_attributes_for_targets(cls, applied_attr_cls, targets, attribute_identifiers, language):
if not settings.SHUUP_ENABLE_ATTRIBUTES: # pragma: no cover
return targets
applied_attrs_by_target_id = defaultdict(list)
attr_ids = set()
filter_kwargs = {
"%s_id__in" % (applied_attr_cls._applied_fk_field): (t.pk for t in targets),
"attribute__identifier__in": attribute_identifiers,
}
for applied_attr in applied_attr_cls.objects.language(language).filter(**filter_kwargs):
attr_ids.add(applied_attr.attribute_id)
applied_attrs_by_target_id[applied_attr.product_id].append(applied_attr)
attr_map = dict((attr.id, attr) for attr in Attribute.objects.language(language).filter(id__in=attr_ids))
for target in targets:
for identifier in attribute_identifiers:
target._set_cached_attribute(language, identifier, NoSuchAttributeHere)
for applied_attr in applied_attrs_by_target_id.get(target.id, ()):
setattr(
applied_attr, applied_attr.__class__.attribute.field.name, attr_map.get(applied_attr.attribute_id)
)
target._set_cached_attribute(language, applied_attr.attribute.identifier, applied_attr)
return targets
def get_available_attribute_queryset(self): # pragma: no cover
raise NotImplementedError(
"Error! Not implemented: `AttributableMixin` -> "
"`get_available_attribute_queryset()`. Must be implemented in a subclass."
)
def get_all_attribute_info(self, language=None, visibility_mode=None):
if not settings.SHUUP_ENABLE_ATTRIBUTES: # pragma: no cover
return {}
language = language or get_language()
qs = self.get_available_attribute_queryset().language(language).all()
if visibility_mode is not None:
qs = qs.filter(visibility_mode=visibility_mode)
all_attributes = dict((a.identifier, (a, None)) for a in qs)
applied_attribute_qs = self.attributes.all().select_related("attribute")
if visibility_mode is not None:
applied_attribute_qs = applied_attribute_qs.filter(attribute__visibility_mode=visibility_mode)
existing_attributes = dict(
(aa.attribute.identifier, (all_attributes.get(aa.attribute.identifier, (aa.attribute,))[0], aa))
for aa in applied_attribute_qs
)
attribute_infos = {}
attribute_infos.update(all_attributes)
attribute_infos.update(existing_attributes)
return attribute_infos
def clear_attribute_cache(self):
if hasattr(self, "_attr_cache"):
del self._attr_cache
def get_attribute_value(self, identifier, language=None, default=None):
"""
Get the value of the attribute with the identifier string `identifier`
in the given (or current) language.
If the attribute is not found, return `default`.
:param identifier: Attribute identifier.
:type identifier: str
:param language: Language identifier (or `None` for "current").
:type language: str|None
:param default: Default value to return.
:type default: object
:return: Attribute value (or fallback).
:rtype: object
"""
if not settings.SHUUP_ENABLE_ATTRIBUTES: # pragma: no cover
return ""
language = language or get_language()
applied_attr = None
_attr_cache = getattr(self, "_attr_cache", {})
if _attr_cache:
applied_attr = _attr_cache.get((language, identifier))
if applied_attr is NoSuchAttributeHere: # pragma: no cover
# cache warmed but value was not found
return default
if applied_attr is None:
try:
applied_attr = (
self.attributes.language(language).select_related("attribute").get(attribute__identifier=identifier)
)
except ObjectDoesNotExist:
applied_attr = None
if applied_attr:
self._set_cached_attribute(language, applied_attr.attribute.identifier, applied_attr)
else: # Cache the miss
self._set_cached_attribute(language, identifier, NoSuchAttributeHere)
if applied_attr:
if applied_attr.attribute.type == AttributeType.CHOICES:
return [choice for choice in applied_attr.chosen_options.all()]
else:
return applied_attr.value
return default
def set_attribute_value(self, identifier, value, language=None): # noqa (C901)
"""
Set an attribute value.
:param identifier: Attribute identifier.
:type identifier: str
:param value: The value for the attribute (should be in the correct type for the attribute).
:type value: object
:param language: Language for multi-language attributes. Not required for untranslated attributes.
:type language: str
:return: Applied attribute object or None.
:rtype: AppliedAttribute|None
"""
if not settings.SHUUP_ENABLE_ATTRIBUTES: # pragma: no cover
return
attr = self.get_available_attribute_queryset().get(identifier=identifier)
applied_attr = self.attributes.filter(attribute=attr).first()
if not applied_attr:
applied_attr = self.attributes.model(attribute=attr)
setattr(applied_attr, applied_attr._applied_fk_field, self)
else:
self.clear_attribute_cache()
if attr.is_translated:
if not language:
raise ValueError("Error! `language` must be set for translated attribute %s." % attr)
applied_attr.set_current_language(language)
if not attr.is_translated and attr.is_null_value(value):
# Trying to set a null value for an untranslated attribute,
# so we can just get rid of the applied object altogether.
# TODO: Do the same sort of cleanup for translated attributes.
if applied_attr.pk:
applied_attr.delete()
return
# Set the value and save the attribute (possibly new)
if applied_attr.attribute.type == AttributeType.CHOICES:
# `value` must be a comma-separated string or an iterable of AttributeChoiceOption, int or str
choices_ids = []
# in case `value` is a comma-separated string, like `option a; option b`
if isinstance(value, str):
value = [choice.strip() for choice in value.split(";")]
for choice in value:
if isinstance(choice, AttributeChoiceOption):
choices_ids.append(choice.pk)
elif isinstance(choice, int):
choices_ids.append(choice)
elif isinstance(choice, str):
existing_choice = AttributeChoiceOption.objects.filter(
attribute=attr, translations__name=choice
).first()
if existing_choice:
choices_ids.append(existing_choice.pk)
# make sure all choices are valid
if applied_attr.attribute.choices.filter(pk__in=choices_ids).count() != len(choices_ids):
raise ValueError("Error! Invalid options set to the attribute.")
if not applied_attr.pk:
applied_attr.save()
applied_attr.chosen_options.set(choices_ids)
else:
applied_attr.value = value
applied_attr.save()
return applied_attr
def clear_attribute_value(self, identifier, language=None):
avail_attrs = self.get_available_attribute_queryset()
attr = avail_attrs.get(identifier=identifier)
attr_val = self.attributes.filter(attribute=attr).first()
if not attr_val:
return
if language is None: # Delete all translations
attr_val.delete()
return
trans = attr_val.translations.filter(language_code=language).first()
if trans:
trans.delete()
AttributeLogEntry = define_log_model(Attribute)