import datetime
import typing
import warnings
from collections.abc import Callable, Sequence
from decimal import Decimal
from typing import Any, Union, cast
from django import forms
from django.contrib.admin.options import FORMFIELD_FOR_DBFIELD_DEFAULTS
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.expressions import BaseExpression
from django.utils import formats
from django.utils.translation import gettext_lazy as _
from pint import Quantity
from .helper import check_matching_unit_dimension
from .units import ureg
from .widgets import QuantityWidget
DJANGO_JSON_SERIALIZABLE_BASE = Union[ # noqa: UP007
None, bool, str, int, float, complex, datetime.datetime
]
DJANGO_JSON_SERIALIZABLE = Union[ # noqa: UP007
Sequence[DJANGO_JSON_SERIALIZABLE_BASE], dict[str, DJANGO_JSON_SERIALIZABLE_BASE]
]
NUMBER_TYPE = Union[int, float, Decimal] # noqa: UP007
[docs]
class QuantityFieldMixin:
to_number_type: Callable[[Any], NUMBER_TYPE]
# TODO: Move these stuff into an Protocol or anything
# better defining a Mixin
value_from_object: Callable[[Any], Any]
name: str
validate: Callable
run_validators: Callable
"""A Django Model Field that resolves to a pint Quantity object"""
def __init__(
self,
base_units: str,
*args,
unit_choices: typing.Iterable[str] | None = None,
**kwargs,
):
"""
Create a Quantity field
:param base_units: Unit description of base unit
:param unit_choices: If given the possible unit choices with the same
dimension like the base_unit
"""
if not isinstance(base_units, str):
raise ValueError(
'QuantityField must be defined with base units, eg: "gram"'
)
self.ureg = ureg
# we do this as a way of raising an exception if some crazy unit was supplied.
unit = getattr(self.ureg, base_units) # noqa: F841
# if we've not hit an exception here, we should be all good
self.base_units = base_units
if unit_choices is None:
self.unit_choices: list[str] = [self.base_units]
else:
self.unit_choices = list(unit_choices)
# The multi widget expects that the base unit is always present as unit
# choice.
# Otherwise we would need to handle special cases for no good reason.
if self.base_units in self.unit_choices:
self.unit_choices.remove(self.base_units)
# Base unit has to be the first choice, always as all values are saved as
# base unit within the database and this would be the first unit shown
# in the widget
self.unit_choices = [self.base_units, *self.unit_choices]
# Check if all unit_choices are valid
check_matching_unit_dimension(self.ureg, self.base_units, self.unit_choices)
super().__init__(*args, **kwargs)
@property
def units(self) -> str:
return self.base_units
[docs]
def deconstruct(
self,
) -> tuple[
str,
str,
Sequence[DJANGO_JSON_SERIALIZABLE],
dict[str, DJANGO_JSON_SERIALIZABLE],
]:
"""
Return enough information to recreate the field as a 4-tuple:
* The name of the field on the model, if contribute_to_class() has
been run.
* The import path of the field, including the class:e.g.
django.db.models.IntegerField This should be the most portable
version, so less specific may be better.
* A list of positional arguments.
* A dict of keyword arguments.
"""
super_deconstruct = getattr(super(), "deconstruct", None)
if not callable(super_deconstruct):
raise NotImplementedError(
"Tried to use Mixin on a class that has no deconstruct function. "
)
name, path, args, kwargs = super_deconstruct()
kwargs["base_units"] = self.base_units
kwargs["unit_choices"] = self.unit_choices
return name, path, args, kwargs
[docs]
def fix_unit_registry(self, value: Quantity) -> Quantity:
"""
Check if the UnitRegistry from settings is used.
If not try to fix it but give a warning.
"""
if isinstance(value, Quantity):
if not isinstance(value, self.ureg.Quantity):
# Could be fatal if different unit registers are used but we assume
# the same is used within one project
# As we warn for this behaviour, we assume that the programmer
# will fix it and do not include more checks!
warnings.warn(
"Trying to set value from a different unit register for "
"quantityfield. "
"We assume the naming is equal but best use the same register as"
" for creating the quantityfield.",
RuntimeWarning,
stacklevel=2,
)
return value.magnitude * self.ureg(str(value.units))
else:
return value
else:
raise ValueError(f"Value '{value}' ({type(value)} is not a quantity.")
[docs]
def get_prep_value(self, value: Any) -> NUMBER_TYPE | None:
"""
Perform preliminary non-db specific value checks and conversions.
Make sure that we compare/use only values without a unit
"""
# we store the value in the base units defined for this field
if value is None:
return None
if isinstance(value, Quantity):
quantity = self.fix_unit_registry(value)
magnitude = quantity.to(self.base_units).magnitude
else:
magnitude = value
try:
return self.to_number_type(magnitude)
except (TypeError, ValueError) as e:
raise e.__class__(
f"Field '{self.name}' expected a number but got {value!r}.",
) from e
[docs]
def get_db_prep_value(self, value, connection, prepared=False):
"""
Convert value to database-compatible format.
This is called for both save() operations and filter lookups.
"""
if prepared:
return value
# Use get_prep_value to convert Quantity to magnitude
return self.get_prep_value(value)
[docs]
def value_to_string(self, obj) -> str:
value = self.value_from_object(obj)
return str(self.get_prep_value(value))
[docs]
def from_db_value(self, value: Any, *args, **kwargs) -> Quantity | None:
if value is None:
return None
return self.ureg.Quantity(value, getattr(self.ureg, self.base_units))
[docs]
def to_python(self, value) -> Quantity | None:
if isinstance(value, Quantity):
return self.fix_unit_registry(value)
if value is None:
return None
to_number = super().to_python
if not callable(to_number):
raise NotImplementedError(
"Mixin not used with a class that has to_python function"
)
value = cast(NUMBER_TYPE, to_number(value))
return self.ureg.Quantity(value, getattr(self.ureg, self.base_units))
[docs]
def clean(self, value, model_instance) -> Quantity:
"""
Convert the value's type and run validation. Validation errors
from to_python() and validate() are propagated. Return the correct
value if no error is raised.
This is a copy from djangos implementation but modified so that validators
are only checked against the magnitude as otherwise the default database
validators will not fail because of comparison errors
"""
value = self.to_python(value)
check_value = self.get_prep_value(value)
self.validate(check_value, model_instance)
self.run_validators(check_value)
return value
[docs]
class QuantityField(QuantityFieldMixin, models.FloatField):
form_field_class = QuantityFormField
to_number_type = float
[docs]
class IntegerQuantityField(QuantityFieldMixin, models.IntegerField):
form_field_class = IntegerQuantityFormField
to_number_type = int
[docs]
class BigIntegerQuantityField(QuantityFieldMixin, models.BigIntegerField):
form_field_class = IntegerQuantityFormField
to_number_type = int
[docs]
class PositiveIntegerQuantityField(QuantityFieldMixin, models.PositiveIntegerField):
form_field_class = IntegerQuantityFormField
to_number_type = int
[docs]
class DecimalQuantityField(QuantityFieldMixin, models.DecimalField):
form_field_class = DecimalQuantityFormField
def __init__(
self,
base_units: str,
*args,
unit_choices: list[str] | None = None,
verbose_name: str = None,
name: str = None,
max_digits: int = None,
decimal_places: int = None,
**kwargs,
):
# We try to be friendly as default django, if there are missing argument
# we throw an error early
if not isinstance(max_digits, int) or not isinstance(decimal_places, int):
raise ValueError(
_(
"Invalid initialization for DecimalQuantityField! "
"We expect max_digits and decimal_places to be set as integers."
)
)
# and we also check the values to be sane
if decimal_places < 0 or max_digits < 1 or decimal_places > max_digits:
raise ValueError(
_(
"Invalid initialization for DecimalQuantityField! "
"max_digits and decimal_places need to positive and max_digits"
"needs to be larger than decimal_places and at least 1. "
"So max_digits=%(max_digits)s and "
"decimal_plactes=%(decimal_places)s "
"are not valid parameters."
)
% locals()
)
super().__init__(
base_units,
*args,
unit_choices=unit_choices,
verbose_name=verbose_name,
name=name,
max_digits=max_digits,
decimal_places=decimal_places,
**kwargs,
)
[docs]
def to_number_type(self, x: object) -> Decimal:
return Decimal(str(x))
[docs]
def get_db_prep_save(self, value, connection) -> Decimal:
"""
Get Value that shall be saved to database, make sure it is transformed
"""
if isinstance(value, BaseExpression):
return value
converted = self.to_python(value)
magnitude = self.get_prep_value(converted)
return connection.ops.adapt_decimalfield_value(
magnitude, self.max_digits, self.decimal_places
)