Source code for shoop.core.models._attributes

# -*- coding: utf-8 -*-
# This file is part of Shoop.
#
# Copyright (c) 2012-2016, Shoop Ltd. All rights reserved.
#
# This source code is licensed under the AGPLv3 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
from collections import defaultdict
from decimal import Decimal

import six
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 ugettext_lazy as _
from django.utils.translation import get_language
from enumfields import Enum, EnumIntegerField
from parler.managers import TranslatableQuerySet
from parler.models import TranslatableModel, TranslatedFields

from shoop.core.fields import InternalIdentifierField
from shoop.core.templatetags.shoop_common import datetime as format_datetime
from shoop.core.templatetags.shoop_common import number as format_number
from shoop.utils.dates import parse_date
from shoop.utils.numbers import parse_decimal_string
from shoop.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

    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')


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"))
    type = EnumIntegerField(AttributeType, default=AttributeType.TRANSLATED_STRING, verbose_name=_("type"))
    visibility_mode = EnumIntegerField(
        AttributeVisibility,
        default=AttributeVisibility.SHOW_ON_PRODUCT_PAGE,
        verbose_name=_("visibility mode"))

    translations = TranslatedFields(
        name=models.CharField(max_length=64, verbose_name=_("name")),
    )

    objects = AttributeQuerySet.as_manager()

    class Meta:
        verbose_name = _('attribute')
        verbose_name_plural = _('attributes')

    def __str__(self):
        return u'%s' % self.name

[docs] def save(self, *args, **kwargs): if not self.identifier: raise ValueError(u"Attribute with null identifier not allowed") self.identifier = flatten(("%s" % self.identifier).lower()) return super(Attribute, self).save(*args, **kwargs)
[docs] def formfield(self, **kwargs): """ Get a form field for this attribute. :param kwargs: Kwargs to pass for the form field class. :return: Form field. :rtype: forms.Field """ kwargs.setdefault("required", False) kwargs.setdefault("label", self.safe_translation_getter("name", self.identifier)) if self.type == AttributeType.INTEGER: return forms.IntegerField(**kwargs) elif self.type == AttributeType.DECIMAL: return forms.DecimalField(**kwargs) elif self.type == AttributeType.BOOLEAN: return forms.NullBooleanField(**kwargs) elif self.type == AttributeType.TIMEDELTA: kwargs.setdefault("help_text", "(as seconds)") # TODO: This should be more user friendly return forms.DecimalField(**kwargs) elif self.type == AttributeType.DATETIME: return forms.DateTimeField(**kwargs) elif self.type == AttributeType.DATE: return forms.DateField(**kwargs) elif self.type == AttributeType.UNTRANSLATED_STRING: return forms.CharField(**kwargs) elif self.type == AttributeType.TRANSLATED_STRING: # Note: this isn't enough for actually saving multi-language entries; # the caller will have to deal with calling this function several # times for that. return forms.CharField(**kwargs) else: raise ValueError("`formfield` can't deal with fields of type %r" % self.type)
@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)
[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 AppliedAttribute(TranslatableModel): _applied_fk_field = None # Used by the `repr` implementation attribute = models.ForeignKey(Attribute, on_delete=models.CASCADE, verbose_name=_("attribute")) numeric_value = models.DecimalField( null=True, blank=True, max_digits=36, decimal_places=9, verbose_name=_("numeric value")) datetime_value = models.DateTimeField( auto_now_add=False, editable=True, null=True, blank=True, verbose_name=_("datetime value")) 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 def _get_value(self): if self.attribute.type == AttributeType.BOOLEAN: return bool(int(self.numeric_value)) if self.attribute.type == AttributeType.INTEGER: return int(self.numeric_value) if self.attribute.type == AttributeType.DECIMAL: return Decimal(self.numeric_value) if self.attribute.type == AttributeType.TIMEDELTA: return datetime.timedelta(seconds=float(self.numeric_value)) if self.attribute.type == AttributeType.DATETIME: return self.datetime_value if self.attribute.type == AttributeType.DATE: return self.datetime_value.date() if self.attribute.type == AttributeType.UNTRANSLATED_STRING: return self.untranslated_string_value if self.attribute.type == AttributeType.TRANSLATED_STRING: if self.has_translation(): return self.translated_string_value return u"" raise ValueError("Unknown attribute type.") # pragma: no cover def _set_numeric_value(self, new_value): 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: 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("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_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 raise ValueError("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: # 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.SHOOP_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, ()): attribute_descriptor = applied_attr.__class__.attribute setattr(applied_attr, attribute_descriptor.cache_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("Must be implemented in AttributableMixin subclass") def get_all_attribute_info(self, language=None, visibility_mode=None): if not settings.SHOOP_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.SHOOP_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: return applied_attr.value return default def set_attribute_value(self, identifier, value, language=None): """ 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.SHOOP_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("`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) applied_attr.value = value applied_attr.save() return applied_attr