# Imports
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from json import loads as json_loads
from superdjango.db.slug.utils import unique_slugify
from .constants import DATA_TYPE
# Exports
__all__ = (
"AttributeModel",
"ValueModel",
)
# Constants
AUTH_USER_MODEL = settings.AUTH_USER_MODEL
# Models
[docs]class AttributeModel(models.Model):
"""Base model for implementing extra attributes to be associated with a model."""
choices = models.TextField(
_("choices"),
blank=True,
help_text=_("Used for the list data type. One choice per line."),
null=True
)
content_type = models.ForeignKey(
ContentType,
blank=True,
help_text=_("Prompt the user to choose options from the selected content type."),
null=True,
on_delete=models.SET_NULL,
verbose_name=_("choices from content type")
)
decimal_places = models.PositiveSmallIntegerField(
_("decimal places"),
default=2,
help_text=_("Decimal places for decimal fields.")
)
DATA_TYPE_CHOICES = (
(DATA_TYPE.BOOLEAN, _('Boolean (yes/no)')),
(DATA_TYPE.DATE, _('Date')),
(DATA_TYPE.DATETIME, _('Date & Time')),
(DATA_TYPE.DECIMAL, _('Decimal')),
(DATA_TYPE.DURATION, _("Duration")),
(DATA_TYPE.EMAIL, _('E-Mail Address')),
(DATA_TYPE.FLOAT, _("Float")),
(DATA_TYPE.INTEGER, _('Integer')),
(DATA_TYPE.LIST, _('List')),
(DATA_TYPE.IP_ADDRESS, _('IP Address')),
(DATA_TYPE.SLUG, _('Slug')),
(DATA_TYPE.TEXT, _('Text (Multi-Line)')),
(DATA_TYPE.VARCHAR, _('Text (Single Line)')),
(DATA_TYPE.TIME, _('Time')),
(DATA_TYPE.URL, _('URL')),
)
data_type = models.CharField(
_("data type"),
choices=DATA_TYPE_CHOICES,
default=DATA_TYPE.VARCHAR,
max_length=64
)
# There is currently no form support for grouping fields, but we're adding this here to (some day, optimistically)
# support field groupings so that no new migrations are required.
grouping = models.CharField(
_("grouping"),
blank=True,
help_text=_("The fieldset or tab into which the field is organized."),
max_length=128,
null=True
)
help_text = models.TextField(
_("help text"),
blank=True,
help_text=_("Help displayed to the user regarding the attribute."),
null=True
)
is_enabled = models.BooleanField(
_("enabled"),
default=True,
help_text=_("Indicates the attribute is available for use.")
)
is_hidden = models.NullBooleanField(
_("hidden"),
help_text=_("Use this to hide options that are not configurable by the user. Be sure to specify a default.")
)
is_required = models.BooleanField(
_("required"),
default=False,
help_text=_("Make input for this attribute required. Be careful!")
)
label = models.CharField(
_("label"),
help_text=_("A label or title of the attribute."),
max_length=128
)
limit_choices_to = models.CharField(
_("limit choices to"),
blank=True,
help_text=_("A filter statement in JSON format that may be used to limit content type choices."),
max_length=128,
null=True
)
# 12,345,678.90
max_digits = models.PositiveSmallIntegerField(
_("max digits"),
default=10,
help_text=_("Max number of digits for decimal fields.")
)
max_length = models.PositiveSmallIntegerField(
_("maximum length"),
default=128,
help_text=_("Max number of characters for single-line text.")
)
min_length = models.PositiveSmallIntegerField(
_("minimum length"),
default=0,
help_text=_("Minimum number of characters for single-line text.")
)
# The name is for possible internal use and may not be displayed to a user-administrator. See save().
name = models.CharField(
_("name"),
blank=True,
help_text=_("The name may be used as an identifier in reports, exports, and customizations."),
max_length=256,
unique=True
)
# As of Wagtail 1.9 there does not appear to be a way to add and use a custom field in the ExtraAttributeAdminForm.
# The default value is collected and cast to the appropriate data type.
raw_default_value = models.CharField(
_("default value"),
blank=True,
help_text=_("The default value, if any. However, it is recommended that all attributes have a default."),
max_length=256,
null=True
)
sort_order = models.SmallIntegerField(
_("sort order"),
default=1
)
class Meta:
abstract = True
def __str__(self):
return self.label
@property
def default_value(self):
"""Alias for ``get_default()``."""
return self.get_default()
[docs] def clean(self):
"""Make sure choices or content type has been provided for ``DATA_TYPE.LIST``. Hidden values also require a
default.
"""
if self.data_type == DATA_TYPE.LIST:
if not self.choices and not self.content_type:
raise ValidationError("Either choices or content type is required for the list data type.")
if self.is_hidden and not self.raw_default_value:
raise ValidationError("A default is required for hidden attributes.")
[docs] def get_choices(self):
"""Get the valid choices for the attribute.
:rtype: list
"""
choices = list()
# noinspection PyUnresolvedReferences
if self.content_type_id:
# noinspection PyUnresolvedReferences
ct = ContentType.objects.get_for_id(self.content_type_id)
model = ct.model_class()
if self.limit_choices_to:
# noinspection PyTypeChecker
criteria = json_loads(self.limit_choices_to)
qs = model.objects.filter(**criteria)
else:
qs = model.objects.all()
for row in qs:
if hasattr(row, "get_choice_name") and callable(row.get_choice_name):
label = row.get_choice_name()
else:
label = str(row)
choices.append((row.pk, label))
else:
# noinspection PyUnresolvedReferences
a = self.choices.split("\n")
choices = list()
for i in a:
choices.append((i.strip(), i.strip()))
return choices
[docs] def get_default(self):
"""Get the default value based on ``data_type``.
:rtype: int | float | str | None
"""
if not self.raw_default_value:
return None
if self.data_type == DATA_TYPE.BOOLEAN:
# noinspection PyUnresolvedReferences
value = self.raw_default_value.lower()
if value in ("1", "true", "t", "yes", "y"):
return True
else:
return False
elif self.data_type == DATA_TYPE.FLOAT:
# noinspection PyTypeChecker
return float(self.raw_default_value)
elif self.data_type == DATA_TYPE.INTEGER:
# noinspection PyTypeChecker
return int(self.raw_default_value)
else:
return self.raw_default_value
@property
def has_choices(self):
"""Indicates whether choices have been defined for the value.
:rtype: bool
"""
# noinspection PyTypeChecker,PyUnresolvedReferences
if self.content_type_id:
return True
else:
# noinspection PyTypeChecker
return len(self.choices) > 1
[docs] def save(self, *args, **kwargs):
"""Automatically generate the ``name``."""
if not self.name:
# noinspection PyTypeChecker
self.name = unique_slugify(self, self.label).replace("-", "_")
super().save(*args, **kwargs)
@property
def value(self):
"""Always returns the value of ``get_default()``. Conforms to the API for :py:class:`ValueModel`."""
return self.get_default()
[docs]class ValueModel(models.Model):
"""Base class for specifying the value of an attribute.
You *must* define the attribute foreign key as well as a foreign key to the model to which this value is added.:
.. code-block:: python
from superdjango.db.eav.models import AttributeModel, ValueModel
class Profile(models.Model):
# ...
class ExtraAttribute(AttributeModel):
# ...
class ExtraAttributeValue(ValueModel):
attribute = models.ForeignKey(
ExtraAttribute,
related_name="values"
)
profile = models.ForeignKey(
Profile,
related_name="attributes"
)
def get_attribute(self):
return self.attribute
"""
raw_value = models.TextField(
_("value"),
blank=True,
null=True
)
class Meta:
abstract = True
def __str__(self):
return self.raw_value
@property
def choices(self):
"""Alias for ``get_choices()``."""
return self.get_choices()
@property
def data_type(self):
"""Alias for the attribute's ``data_type``."""
return self.get_attribute().data_type
@property
def decimal_places(self):
"""Alias for the attribute's ``decimal_places``."""
return self.get_attribute().decimal_places
[docs] def display_value(self):
"""Display the human-friendly value the attribute.
:rtype: float | int | str
"""
attribute = self.get_attribute()
if attribute.data_type == DATA_TYPE.BOOLEAN:
if bool(self.raw_value):
return _("yes")
else:
return _("no")
else:
return self.raw_value
[docs] def get_attribute(self):
"""Get the attribute instance.
:rtype: BaseType[AttributeModel]
"""
raise NotImplementedError()
[docs] def get_choices(self):
"""Alias for the attribute's ``get_choices()``."""
return self.get_attribute().get_choices()
[docs] def get_content_object(self):
"""Get the content object associated with the value when a content type is used."""
attribute = self.get_attribute()
if not attribute.content_type_id:
return None
ct = ContentType.objects.get_for_id(attribute.content_type_id)
model = ct.model_class()
return model.objects.get(pk=self.raw_value)
[docs] def get_value(self):
"""Get the actual value of the attribute based on the ``data_type``."""
attribute = self.get_attribute()
if attribute.data_type == DATA_TYPE.BOOLEAN:
return bool(self.raw_value)
elif attribute.data_type == DATA_TYPE.FLOAT:
# noinspection PyTypeChecker
return float(self.raw_value)
elif attribute.data_type == DATA_TYPE.INTEGER:
# noinspection PyTypeChecker
return int(self.raw_value)
else:
return self.raw_value
@property
def help_text(self):
"""Alias for the attribute's ``help_text``."""
return self.get_attribute().help_text
@property
def is_required(self):
"""Alias for the attribute's ``is_required``."""
return self.get_attribute().is_required
@property
def label(self):
"""Alias for the attribute's ``label``."""
return self.get_attribute().label
@property
def max_digits(self):
"""Alias for the attribute's ``max_digits``."""
return self.get_attribute().max_digits
@property
def max_length(self):
"""Alias for the attribute's ``max_length``."""
return self.get_attribute().max_length
@property
def min_length(self):
"""Alias for the attribute's ``min_length``."""
return self.get_attribute().min_length
@property
def name(self):
"""Alias for the attribute's ``name``."""
return self.get_attribute().name
@property
def sort_order(self):
"""Alias for the attribute's ``sort_order``."""
return self.get_attribute().sort_order
@property
def value(self):
"""Alias for ``get_value()``."""
return self.get_value()