django-pint 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_pint/__init__.py +14 -0
- django_pint-1.0.1.dist-info/METADATA +211 -0
- django_pint-1.0.1.dist-info/RECORD +12 -0
- django_pint-1.0.1.dist-info/WHEEL +5 -0
- django_pint-1.0.1.dist-info/licenses/LICENSE.txt +23 -0
- django_pint-1.0.1.dist-info/top_level.txt +2 -0
- quantityfield/__init__.py +10 -0
- quantityfield/fields.py +434 -0
- quantityfield/helper.py +22 -0
- quantityfield/settings.py +11 -0
- quantityfield/units.py +4 -0
- quantityfield/widgets.py +36 -0
django_pint/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
# Change here if project is renamed and does not equal the package name
|
|
5
|
+
dist_name = "django-pint"
|
|
6
|
+
__version__ = version(dist_name)
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover
|
|
8
|
+
__version__ = "unknown"
|
|
9
|
+
finally:
|
|
10
|
+
del version, PackageNotFoundError
|
|
11
|
+
|
|
12
|
+
from quantityfield import fields, helper, settings, units, widgets
|
|
13
|
+
|
|
14
|
+
__all__ = ["fields", "helper", "settings", "units", "widgets"]
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-pint
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Quantity Field for Django using pint library for automated unit conversions
|
|
5
|
+
Author: Ben Harling
|
|
6
|
+
Author-email: Carli* Freudenberg <kound@posteo.de>
|
|
7
|
+
Maintainer-email: Carli* Freudenberg <kound@posteo.de>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/CarliJoy/django-pint/
|
|
10
|
+
Project-URL: Documentation, https://django-pint.readthedocs.io/en/latest/
|
|
11
|
+
Project-URL: Source Code, https://github.com/CarliJoy/django-pint/
|
|
12
|
+
Project-URL: Bug Tracker, https://github.com/CarliJoy/django-pint/issues
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Framework :: Django
|
|
22
|
+
Classifier: Framework :: Django :: 5.2
|
|
23
|
+
Classifier: Framework :: Django :: 6.0
|
|
24
|
+
Classifier: Intended Audience :: Developers
|
|
25
|
+
Classifier: Intended Audience :: Science/Research
|
|
26
|
+
Classifier: Operating System :: OS Independent
|
|
27
|
+
Classifier: Topic :: Scientific/Engineering
|
|
28
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
License-File: LICENSE.txt
|
|
32
|
+
Requires-Dist: Django>=4.2
|
|
33
|
+
Requires-Dist: pint>=0.16
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
[](https://travis-ci.com/github/CarliJoy/django-pint)
|
|
38
|
+
[](https://codecov.io/gh/CarliJoy/django-pint)
|
|
39
|
+
[](https://pypistats.org/packages/django-pint)
|
|
40
|
+
[](https://pypi.org/project/django-pint/)
|
|
41
|
+
[](https://pypi.org/project/django-pint/)
|
|
42
|
+
[](https://pypi.org/project/SyncGitlab2MSProject/)
|
|
43
|
+
[](https://pypi.org/project/django-pint/)
|
|
44
|
+
[](https://github.com/psf/black)
|
|
45
|
+
[](https://github.com/pre-commit/pre-commit)
|
|
46
|
+
[](https://results.pre-commit.ci/latest/github/CarliJoy/django-pint/main)
|
|
47
|
+
[](https://opensource.org/licenses/MIT)
|
|
48
|
+
[](https://django-pint.readthedocs.io/en/latest/?badge=latest)
|
|
49
|
+
|
|
50
|
+
# Django Quantity Field
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
A Small django field extension allowing you to store quantities in certain units and perform conversions easily. Uses [pint](https://github.com/hgrecco/pint) behind the scenes. Also contains a form field class and form widget that allows a user to choose alternative units to input data. The cleaned_data will output the value in the base_units defined for the field, eg: you specify you want to store a value in grams but will allow users to input either grams or ounces.
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
## Help wanted
|
|
57
|
+
I am currently not working with Django anymore. Therefore the Maintenance of this project is not a priority for me anymore.
|
|
58
|
+
If there is anybody that could imagine helping out maintaining the project, send me a mail.
|
|
59
|
+
|
|
60
|
+
## Compatibility
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
Requires django >= 5.2, and python 3.10/3.11/3.12/3.13/3.14
|
|
64
|
+
|
|
65
|
+
Tested with the following combinations:
|
|
66
|
+
* Django 5.2 (Python 3.10, 3.11, 3.12, 3.13, 3.14)
|
|
67
|
+
* Django 6.0 (Python 3.12, 3.13, 3.14)
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
pip install django-pint
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
## Simple Example
|
|
76
|
+
|
|
77
|
+
Best way to illustrate is with an example
|
|
78
|
+
|
|
79
|
+
# app/models.py
|
|
80
|
+
|
|
81
|
+
from django.db import models
|
|
82
|
+
from quantityfield.fields import QuantityField
|
|
83
|
+
|
|
84
|
+
class HayBale(models.Model):
|
|
85
|
+
weight = QuantityField('tonne')
|
|
86
|
+
|
|
87
|
+
Quantities are stored as float (Django FloatField) and retrieved like any other field
|
|
88
|
+
|
|
89
|
+
>> bale = HayBale.objects.create(weight=1.2)
|
|
90
|
+
>> bale = HayBale.objects.first()
|
|
91
|
+
>> bale.weight
|
|
92
|
+
<Quantity(1.2, 'tonne')>
|
|
93
|
+
>> bale.weight.magnitude
|
|
94
|
+
1.2
|
|
95
|
+
>> bale.weight.units
|
|
96
|
+
'tonne'
|
|
97
|
+
>> bale.weight.to('kilogram')
|
|
98
|
+
<Quantity(1200, 'kilogram')>
|
|
99
|
+
>> bale.weight.to('pound')
|
|
100
|
+
<Quantity(2645.55, 'pound')>
|
|
101
|
+
|
|
102
|
+
If your base unit is atomic (i.e. can be represented by an integer), you may also use `IntegerQuantityField` and `BigIntegerQuantityField`.
|
|
103
|
+
|
|
104
|
+
If you prefer exact units you can use the `DecimalQuantityField`
|
|
105
|
+
|
|
106
|
+
You can also pass Quantity objects to be stored in models. These are automatically converted to the units defined for the field
|
|
107
|
+
( but can be converted to something else when retrieved of course ).
|
|
108
|
+
|
|
109
|
+
>> from quantityfield.units import ureg
|
|
110
|
+
>> Quantity = ureg.Quantity
|
|
111
|
+
>> pounds = Quantity(500 * ureg.pound)
|
|
112
|
+
>> bale = HayBale.objects.create(weight=pounds)
|
|
113
|
+
>> bale.weight
|
|
114
|
+
<Quantity(0.226796, 'tonne')>
|
|
115
|
+
|
|
116
|
+
Use the inbuilt form field and widget to allow input of quantity values in different units
|
|
117
|
+
|
|
118
|
+
from quantityfield.fields import QuantityFormField
|
|
119
|
+
|
|
120
|
+
class HayBaleForm(forms.Form):
|
|
121
|
+
weight = QuantityFormField(base_units='gram', unit_choices=['gram', 'ounce', 'milligram'])
|
|
122
|
+
|
|
123
|
+
The form will render a float input and a select widget to choose the units.
|
|
124
|
+
Whenever cleaned_data is presented from the above form the weight field value will be a
|
|
125
|
+
Quantity with the units set to grams (values are converted from the units input by the user).
|
|
126
|
+
You also can add the `unit_choices` directly to the `ModelField`. It will be propagated
|
|
127
|
+
correctly.
|
|
128
|
+
|
|
129
|
+
For comparative lookups, query values will be coerced into the correct units when comparing values,
|
|
130
|
+
this means that comparing 1 ounce to 1 tonne should yield the correct results.
|
|
131
|
+
|
|
132
|
+
less_than_a_tonne = HayBale.objects.filter(weight__lt=Quantity(2000 * ureg.pound))
|
|
133
|
+
|
|
134
|
+
You can also use a custom Pint unit registry in your project `settings.py`
|
|
135
|
+
|
|
136
|
+
# project/settings.py
|
|
137
|
+
|
|
138
|
+
from pint import UnitRegistry
|
|
139
|
+
|
|
140
|
+
# django-pint will set the DJANGO_PINT_UNIT_REGISTER automatically
|
|
141
|
+
# as application_registry
|
|
142
|
+
DJANGO_PINT_UNIT_REGISTER = UnitRegistry('your_units.txt')
|
|
143
|
+
DJANGO_PINT_UNIT_REGISTER.define('beer_bootle_weight = 0.8 * kg = beer')
|
|
144
|
+
|
|
145
|
+
# app/models.py
|
|
146
|
+
|
|
147
|
+
class HayBale(models.Model):
|
|
148
|
+
# now you can use your custom units in your models
|
|
149
|
+
custom_unit = QuantityField('beer')
|
|
150
|
+
|
|
151
|
+
Note: As the [documentation from pint](https://pint.readthedocs.io/en/latest/tutorial.html#using-pint-in-your-projects)
|
|
152
|
+
states quite clearly: For each project there should be only one unit registry.
|
|
153
|
+
Please note that if you change the unit registry for an already created project with
|
|
154
|
+
data in a database, you could invalidate your data! So be sure you know what you are
|
|
155
|
+
doing!
|
|
156
|
+
Still only adding units should be okay.
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
### Preparation
|
|
161
|
+
|
|
162
|
+
You need to install all Python Version that django-pint is compatible with.
|
|
163
|
+
In a *nix environment you best could use [pyenv](https://github.com/pyenv/pyenv) to do so.
|
|
164
|
+
|
|
165
|
+
Furthermore, you need to install [tox](https://tox.wiki/en/latest/) and [pre-commit](https://pre-commit.com/) to lint and test.
|
|
166
|
+
|
|
167
|
+
You also need docker as our tests require a postgres database to run.
|
|
168
|
+
We don't use SQL lite as some bugs only occurred using a proper database.
|
|
169
|
+
|
|
170
|
+
I recommend using [pipx](https://pypa.github.io/pipx/) to install them.
|
|
171
|
+
|
|
172
|
+
1. Install `pipx` (see pipx documentation), i.e. with `python3 -m pip install --user pipx && python3 -m pipx ensurepath`
|
|
173
|
+
2. Install `pre-commit` running `pipx install pre-commit`
|
|
174
|
+
3. Install `tox` running `pipx install tox`
|
|
175
|
+
4. Install the `tox-docker` plugin `pipx inject tox tox-docker`
|
|
176
|
+
5. Fork `django-pint` and clone your fork (see [Tutorial](https://docs.github.com/get-started/quickstart/contributing-to-projects))
|
|
177
|
+
6. Change into the repo `cd django-pint`
|
|
178
|
+
7. Activate `pre-commit` for the repo running `pre-commit install`
|
|
179
|
+
8. Check that all linter run fine with the cloned version by running `pre-commit run --all-files`
|
|
180
|
+
9. Check that all tests succeed by running `tox`
|
|
181
|
+
|
|
182
|
+
**Congratulation** you successfully cloned and tested the upstream version of `django-pint`.
|
|
183
|
+
|
|
184
|
+
Now you can work on your feature branch and test your changes using `tox`.
|
|
185
|
+
Your code will be automatically linted and formatted by `pre-commit` if you commit your changes.
|
|
186
|
+
If it fails, simply add all changes and try again.
|
|
187
|
+
If this doesn't help look at the output of your `git commit` command.
|
|
188
|
+
|
|
189
|
+
Once you are done, [create a pull request](https://docs.github.com/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork).
|
|
190
|
+
|
|
191
|
+
### Local development environment with Docker
|
|
192
|
+
|
|
193
|
+
To run a local development environment with Docker you need to run the following steps:
|
|
194
|
+
This is helpful if you have troubles installing `postgresql` or `psycopg2-binary`.
|
|
195
|
+
|
|
196
|
+
1. `git clone` your fork
|
|
197
|
+
2. run `cp .env.example .env`
|
|
198
|
+
3. edit `.env` file and change it with your credentials ( the postgres host should match the service name in docker-file so you can use "postgres" )
|
|
199
|
+
4. run `cp tests/local.py.docker-example tests/local.py`
|
|
200
|
+
5. run `docker-compose up` in the root folder, this should build and start 2 containers, one for postgres and the other one python dependencies. Note you have to be in the [docker](https://stackoverflow.com/a/47078951/3813064) group for this to work.
|
|
201
|
+
6. open a new terminal and run `docker-compose exec app bash`, this should open a ssh console in the docker container
|
|
202
|
+
7. you can run `pytest` inside the container to see the result of the tests.
|
|
203
|
+
|
|
204
|
+
### Updating the package
|
|
205
|
+
[Python](https://endoflife.date/python) and [Django](https://endoflife.date/django) major versions have defined EOL.
|
|
206
|
+
To reduce the maintenance burden and encourage users to use version still receiving security updates any `django-pint` update should match all and only these version of Python and Django that are supported.
|
|
207
|
+
Updating these dependencies have to be done in multiple places:
|
|
208
|
+
- `README.md`: Describing it to end users
|
|
209
|
+
- `tox.ini`: For local testing
|
|
210
|
+
- `pyproject.toml`: For usage with pip and displaying it in PyPi
|
|
211
|
+
- `.github/workflows/test.yaml`: For the CI/CD Definition
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
django_pint/__init__.py,sha256=rLa3Ej2DQbqPaygJUf258b7Cy5juDxvOwS8DHHwjaN8,466
|
|
2
|
+
django_pint-1.0.1.dist-info/licenses/LICENSE.txt,sha256=HMDhAOPCnlDTTF0u6fEEJz9rGCUQiS3lluNuFFvojAs,1154
|
|
3
|
+
quantityfield/__init__.py,sha256=W-x-EjdsFKJr29897UUG1XEBwS-91YxxgZCM8pbAc50,334
|
|
4
|
+
quantityfield/fields.py,sha256=62iV2Av_cpZLqSCXlSyp4RcGeRQSbOzlV0cZ_f4GlgE,15373
|
|
5
|
+
quantityfield/helper.py,sha256=6_7b8T-NKhSeoQQLyehl1NypS85UU8pGCxqUxLiM_wM,756
|
|
6
|
+
quantityfield/settings.py,sha256=XNbZdlOO_tfI1TtxR3Gc0JZSGMPGKVLn5Qob0utLzrQ,349
|
|
7
|
+
quantityfield/units.py,sha256=lIme1byRyf1lm2wfmlxLpq6Akq5pyQq3qBp4d7OfU2c,146
|
|
8
|
+
quantityfield/widgets.py,sha256=tNlszZEJ1Nq-gs9Fb4idp-SBZnW2uM6uobcfqCcK6uM,1180
|
|
9
|
+
django_pint-1.0.1.dist-info/METADATA,sha256=y2ktAr7w2FEqB7ec9t234gjuy9LVKCZDNbz2jjDTA_U,10576
|
|
10
|
+
django_pint-1.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
11
|
+
django_pint-1.0.1.dist-info/top_level.txt,sha256=oBxrgF8d06MHSvF2UQgGOvE4tOQfgG0n_eYvDPi8Jl4,26
|
|
12
|
+
django_pint-1.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (C) 2020-2022 django-pint authors (see AUTHORS file)
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2020-2022 Carli* Freudenberg
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
# Change here if project is renamed and does not equal the package name
|
|
5
|
+
dist_name = "django-pint"
|
|
6
|
+
__version__ = version(dist_name)
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover
|
|
8
|
+
__version__ = "unknown"
|
|
9
|
+
finally:
|
|
10
|
+
del version, PackageNotFoundError
|
quantityfield/fields.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import typing
|
|
3
|
+
import warnings
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from typing import Any, Union, cast
|
|
7
|
+
|
|
8
|
+
from django import forms
|
|
9
|
+
from django.contrib.admin.options import FORMFIELD_FOR_DBFIELD_DEFAULTS
|
|
10
|
+
from django.core.exceptions import ValidationError
|
|
11
|
+
from django.db import models
|
|
12
|
+
from django.utils import formats
|
|
13
|
+
from django.utils.translation import gettext_lazy as _
|
|
14
|
+
|
|
15
|
+
from pint import Quantity
|
|
16
|
+
|
|
17
|
+
from .helper import check_matching_unit_dimension
|
|
18
|
+
from .units import ureg
|
|
19
|
+
from .widgets import QuantityWidget
|
|
20
|
+
|
|
21
|
+
DJANGO_JSON_SERIALIZABLE_BASE = Union[ # noqa: UP007
|
|
22
|
+
None, bool, str, int, float, complex, datetime.datetime
|
|
23
|
+
]
|
|
24
|
+
DJANGO_JSON_SERIALIZABLE = Union[ # noqa: UP007
|
|
25
|
+
Sequence[DJANGO_JSON_SERIALIZABLE_BASE], dict[str, DJANGO_JSON_SERIALIZABLE_BASE]
|
|
26
|
+
]
|
|
27
|
+
NUMBER_TYPE = Union[int, float, Decimal] # noqa: UP007
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class QuantityFieldMixin:
|
|
31
|
+
to_number_type: Callable[[Any], NUMBER_TYPE]
|
|
32
|
+
|
|
33
|
+
# TODO: Move these stuff into an Protocol or anything
|
|
34
|
+
# better defining a Mixin
|
|
35
|
+
value_from_object: Callable[[Any], Any]
|
|
36
|
+
name: str
|
|
37
|
+
validate: Callable
|
|
38
|
+
run_validators: Callable
|
|
39
|
+
|
|
40
|
+
"""A Django Model Field that resolves to a pint Quantity object"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
base_units: str,
|
|
45
|
+
*args,
|
|
46
|
+
unit_choices: typing.Iterable[str] | None = None,
|
|
47
|
+
**kwargs,
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Create a Quantity field
|
|
51
|
+
:param base_units: Unit description of base unit
|
|
52
|
+
:param unit_choices: If given the possible unit choices with the same
|
|
53
|
+
dimension like the base_unit
|
|
54
|
+
"""
|
|
55
|
+
if not isinstance(base_units, str):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
'QuantityField must be defined with base units, eg: "gram"'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
self.ureg = ureg
|
|
61
|
+
|
|
62
|
+
# we do this as a way of raising an exception if some crazy unit was supplied.
|
|
63
|
+
unit = getattr(self.ureg, base_units) # noqa: F841
|
|
64
|
+
|
|
65
|
+
# if we've not hit an exception here, we should be all good
|
|
66
|
+
self.base_units = base_units
|
|
67
|
+
|
|
68
|
+
if unit_choices is None:
|
|
69
|
+
self.unit_choices: list[str] = [self.base_units]
|
|
70
|
+
else:
|
|
71
|
+
self.unit_choices = list(unit_choices)
|
|
72
|
+
# The multi widget expects that the base unit is always present as unit
|
|
73
|
+
# choice.
|
|
74
|
+
# Otherwise we would need to handle special cases for no good reason.
|
|
75
|
+
if self.base_units in self.unit_choices:
|
|
76
|
+
self.unit_choices.remove(self.base_units)
|
|
77
|
+
# Base unit has to be the first choice, always as all values are saved as
|
|
78
|
+
# base unit within the database and this would be the first unit shown
|
|
79
|
+
# in the widget
|
|
80
|
+
self.unit_choices = [self.base_units, *self.unit_choices]
|
|
81
|
+
|
|
82
|
+
# Check if all unit_choices are valid
|
|
83
|
+
check_matching_unit_dimension(self.ureg, self.base_units, self.unit_choices)
|
|
84
|
+
|
|
85
|
+
super().__init__(*args, **kwargs)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def units(self) -> str:
|
|
89
|
+
return self.base_units
|
|
90
|
+
|
|
91
|
+
def deconstruct(
|
|
92
|
+
self,
|
|
93
|
+
) -> tuple[
|
|
94
|
+
str,
|
|
95
|
+
str,
|
|
96
|
+
Sequence[DJANGO_JSON_SERIALIZABLE],
|
|
97
|
+
dict[str, DJANGO_JSON_SERIALIZABLE],
|
|
98
|
+
]:
|
|
99
|
+
"""
|
|
100
|
+
Return enough information to recreate the field as a 4-tuple:
|
|
101
|
+
|
|
102
|
+
* The name of the field on the model, if contribute_to_class() has
|
|
103
|
+
been run.
|
|
104
|
+
* The import path of the field, including the class:e.g.
|
|
105
|
+
django.db.models.IntegerField This should be the most portable
|
|
106
|
+
version, so less specific may be better.
|
|
107
|
+
* A list of positional arguments.
|
|
108
|
+
* A dict of keyword arguments.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
super_deconstruct = getattr(super(), "deconstruct", None)
|
|
112
|
+
if not callable(super_deconstruct):
|
|
113
|
+
raise NotImplementedError(
|
|
114
|
+
"Tried to use Mixin on a class that has no deconstruct function. "
|
|
115
|
+
)
|
|
116
|
+
name, path, args, kwargs = super_deconstruct()
|
|
117
|
+
kwargs["base_units"] = self.base_units
|
|
118
|
+
kwargs["unit_choices"] = self.unit_choices
|
|
119
|
+
return name, path, args, kwargs
|
|
120
|
+
|
|
121
|
+
def fix_unit_registry(self, value: Quantity) -> Quantity:
|
|
122
|
+
"""
|
|
123
|
+
Check if the UnitRegistry from settings is used.
|
|
124
|
+
If not try to fix it but give a warning.
|
|
125
|
+
"""
|
|
126
|
+
if isinstance(value, Quantity):
|
|
127
|
+
if not isinstance(value, self.ureg.Quantity):
|
|
128
|
+
# Could be fatal if different unit registers are used but we assume
|
|
129
|
+
# the same is used within one project
|
|
130
|
+
# As we warn for this behaviour, we assume that the programmer
|
|
131
|
+
# will fix it and do not include more checks!
|
|
132
|
+
warnings.warn(
|
|
133
|
+
"Trying to set value from a different unit register for "
|
|
134
|
+
"quantityfield. "
|
|
135
|
+
"We assume the naming is equal but best use the same register as"
|
|
136
|
+
" for creating the quantityfield.",
|
|
137
|
+
RuntimeWarning,
|
|
138
|
+
stacklevel=2,
|
|
139
|
+
)
|
|
140
|
+
return value.magnitude * self.ureg(str(value.units))
|
|
141
|
+
else:
|
|
142
|
+
return value
|
|
143
|
+
else:
|
|
144
|
+
raise ValueError(f"Value '{value}' ({type(value)} is not a quantity.")
|
|
145
|
+
|
|
146
|
+
def get_prep_value(self, value: Any) -> NUMBER_TYPE | None:
|
|
147
|
+
"""
|
|
148
|
+
Perform preliminary non-db specific value checks and conversions.
|
|
149
|
+
|
|
150
|
+
Make sure that we compare/use only values without a unit
|
|
151
|
+
"""
|
|
152
|
+
# we store the value in the base units defined for this field
|
|
153
|
+
if value is None:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
if isinstance(value, Quantity):
|
|
157
|
+
quantity = self.fix_unit_registry(value)
|
|
158
|
+
magnitude = quantity.to(self.base_units).magnitude
|
|
159
|
+
else:
|
|
160
|
+
magnitude = value
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
return self.to_number_type(magnitude)
|
|
164
|
+
except (TypeError, ValueError) as e:
|
|
165
|
+
raise e.__class__(
|
|
166
|
+
f"Field '{self.name}' expected a number but got {value!r}.",
|
|
167
|
+
) from e
|
|
168
|
+
|
|
169
|
+
def get_db_prep_value(self, value, connection, prepared=False):
|
|
170
|
+
"""
|
|
171
|
+
Convert value to database-compatible format.
|
|
172
|
+
This is called for both save() operations and filter lookups.
|
|
173
|
+
"""
|
|
174
|
+
if prepared:
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
# Use get_prep_value to convert Quantity to magnitude
|
|
178
|
+
return self.get_prep_value(value)
|
|
179
|
+
|
|
180
|
+
def value_to_string(self, obj) -> str:
|
|
181
|
+
value = self.value_from_object(obj)
|
|
182
|
+
return str(self.get_prep_value(value))
|
|
183
|
+
|
|
184
|
+
def from_db_value(self, value: Any, *args, **kwargs) -> Quantity | None:
|
|
185
|
+
if value is None:
|
|
186
|
+
return None
|
|
187
|
+
return self.ureg.Quantity(value, getattr(self.ureg, self.base_units))
|
|
188
|
+
|
|
189
|
+
def to_python(self, value) -> Quantity | None:
|
|
190
|
+
if isinstance(value, Quantity):
|
|
191
|
+
return self.fix_unit_registry(value)
|
|
192
|
+
|
|
193
|
+
if value is None:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
to_number = super().to_python
|
|
197
|
+
if not callable(to_number):
|
|
198
|
+
raise NotImplementedError(
|
|
199
|
+
"Mixin not used with a class that has to_python function"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
value = cast(NUMBER_TYPE, to_number(value))
|
|
203
|
+
|
|
204
|
+
return self.ureg.Quantity(value, getattr(self.ureg, self.base_units))
|
|
205
|
+
|
|
206
|
+
def clean(self, value, model_instance) -> Quantity:
|
|
207
|
+
"""
|
|
208
|
+
Convert the value's type and run validation. Validation errors
|
|
209
|
+
from to_python() and validate() are propagated. Return the correct
|
|
210
|
+
value if no error is raised.
|
|
211
|
+
|
|
212
|
+
This is a copy from djangos implementation but modified so that validators
|
|
213
|
+
are only checked against the magnitude as otherwise the default database
|
|
214
|
+
validators will not fail because of comparison errors
|
|
215
|
+
"""
|
|
216
|
+
value = self.to_python(value)
|
|
217
|
+
check_value = self.get_prep_value(value)
|
|
218
|
+
self.validate(check_value, model_instance)
|
|
219
|
+
self.run_validators(check_value)
|
|
220
|
+
return value
|
|
221
|
+
|
|
222
|
+
def formfield(self, **kwargs):
|
|
223
|
+
defaults = {
|
|
224
|
+
"form_class": self.form_field_class,
|
|
225
|
+
"base_units": self.base_units,
|
|
226
|
+
"unit_choices": self.unit_choices,
|
|
227
|
+
}
|
|
228
|
+
defaults.update(kwargs)
|
|
229
|
+
return super().formfield(**defaults)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class QuantityFormFieldMixin:
|
|
233
|
+
"""This formfield allows a user to choose which units they
|
|
234
|
+
wish to use to enter a value, but the value is yielded in
|
|
235
|
+
the base_units
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
to_number_type: Callable[[Any], NUMBER_TYPE]
|
|
239
|
+
|
|
240
|
+
# TODO: Move these stuff into an Protocol or anything
|
|
241
|
+
# better defining a Mixin
|
|
242
|
+
validate: Callable
|
|
243
|
+
run_validators: Callable
|
|
244
|
+
error_messages: dict[str, str]
|
|
245
|
+
empty_values: Sequence[Any]
|
|
246
|
+
localize: bool
|
|
247
|
+
|
|
248
|
+
def __init__(self, *args, **kwargs):
|
|
249
|
+
self.ureg = ureg
|
|
250
|
+
self.base_units = kwargs.pop("base_units", None)
|
|
251
|
+
if self.base_units is None:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
"QuantityFormField requires a base_units kwarg of a "
|
|
254
|
+
"single unit type (eg: grams)"
|
|
255
|
+
)
|
|
256
|
+
self.units = kwargs.pop("unit_choices", [self.base_units])
|
|
257
|
+
if self.base_units not in self.units:
|
|
258
|
+
self.units.append(self.base_units)
|
|
259
|
+
|
|
260
|
+
check_matching_unit_dimension(self.ureg, self.base_units, self.units)
|
|
261
|
+
|
|
262
|
+
def is_special_admin_widget(widget) -> bool:
|
|
263
|
+
"""
|
|
264
|
+
There are some special django admin widgets, defined
|
|
265
|
+
in django/contrib/admin/options.py in the variable
|
|
266
|
+
FORMFIELD_FOR_DBFIELD_DEFAULTS
|
|
267
|
+
The intention for Integer and BigIntegerField is only to
|
|
268
|
+
define the width.
|
|
269
|
+
|
|
270
|
+
They are set through a complicated process of the
|
|
271
|
+
modelform_factory setting formfield_callback to
|
|
272
|
+
ModelForm.formfield_to_dbfield
|
|
273
|
+
|
|
274
|
+
As they will overwrite our Widget we check for them and
|
|
275
|
+
will ignore them, if they are set as attribute.
|
|
276
|
+
|
|
277
|
+
We still will allow subclasses, so the end user has still
|
|
278
|
+
the possibility to use this widget.
|
|
279
|
+
"""
|
|
280
|
+
WIDGETS_TO_IGNORE = [
|
|
281
|
+
FORMFIELD_FOR_DBFIELD_DEFAULTS[models.IntegerField],
|
|
282
|
+
FORMFIELD_FOR_DBFIELD_DEFAULTS[models.BigIntegerField],
|
|
283
|
+
]
|
|
284
|
+
classes_to_ignore = [
|
|
285
|
+
ignored_widget["widget"].__name__
|
|
286
|
+
for ignored_widget in WIDGETS_TO_IGNORE
|
|
287
|
+
]
|
|
288
|
+
return widget.__name__ in classes_to_ignore
|
|
289
|
+
|
|
290
|
+
widget = kwargs.get("widget")
|
|
291
|
+
if widget is None or is_special_admin_widget(widget):
|
|
292
|
+
widget = QuantityWidget(
|
|
293
|
+
base_units=self.base_units, allowed_types=self.units
|
|
294
|
+
)
|
|
295
|
+
kwargs["widget"] = widget
|
|
296
|
+
super().__init__(*args, **kwargs)
|
|
297
|
+
|
|
298
|
+
def prepare_value(self, value):
|
|
299
|
+
if isinstance(value, Quantity):
|
|
300
|
+
return value.to(self.base_units)
|
|
301
|
+
else:
|
|
302
|
+
return value
|
|
303
|
+
|
|
304
|
+
def clean(self, value):
|
|
305
|
+
"""
|
|
306
|
+
General idea, first try to extract the correct number like done in the other
|
|
307
|
+
classes and then follow the same procedure as in the django default field
|
|
308
|
+
"""
|
|
309
|
+
if isinstance(value, list) or isinstance(value, tuple):
|
|
310
|
+
val = value[0]
|
|
311
|
+
units = value[1]
|
|
312
|
+
else:
|
|
313
|
+
# If no multi widget is used
|
|
314
|
+
val = value
|
|
315
|
+
units = self.base_units
|
|
316
|
+
|
|
317
|
+
if val in self.empty_values:
|
|
318
|
+
# Make sure the correct functions are called also in case of empty values
|
|
319
|
+
self.validate(None)
|
|
320
|
+
self.run_validators(None)
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
if units not in self.units:
|
|
324
|
+
raise ValidationError(_("%(units)s is not a valid choice") % locals())
|
|
325
|
+
|
|
326
|
+
if self.localize:
|
|
327
|
+
val = formats.sanitize_separators(value)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
val = self.to_number_type(val)
|
|
331
|
+
except (ValueError, TypeError):
|
|
332
|
+
raise ValidationError(
|
|
333
|
+
self.error_messages["invalid"], code="invalid"
|
|
334
|
+
) from None
|
|
335
|
+
|
|
336
|
+
val = self.ureg.Quantity(val, getattr(self.ureg, units)).to(self.base_units)
|
|
337
|
+
self.validate(val.magnitude)
|
|
338
|
+
self.run_validators(val.magnitude)
|
|
339
|
+
return val
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class QuantityFormField(QuantityFormFieldMixin, forms.FloatField):
|
|
343
|
+
to_number_type = float
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class QuantityField(QuantityFieldMixin, models.FloatField):
|
|
347
|
+
form_field_class = QuantityFormField
|
|
348
|
+
to_number_type = float
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class IntegerQuantityFormField(QuantityFormFieldMixin, forms.IntegerField):
|
|
352
|
+
to_number_type = int
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class IntegerQuantityField(QuantityFieldMixin, models.IntegerField):
|
|
356
|
+
form_field_class = IntegerQuantityFormField
|
|
357
|
+
to_number_type = int
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class BigIntegerQuantityField(QuantityFieldMixin, models.BigIntegerField):
|
|
361
|
+
form_field_class = IntegerQuantityFormField
|
|
362
|
+
to_number_type = int
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class PositiveIntegerQuantityField(QuantityFieldMixin, models.PositiveIntegerField):
|
|
366
|
+
form_field_class = IntegerQuantityFormField
|
|
367
|
+
to_number_type = int
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class DecimalQuantityFormField(QuantityFormFieldMixin, forms.DecimalField):
|
|
371
|
+
def to_number_type(self, x: object) -> Decimal:
|
|
372
|
+
return Decimal(str(x))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class DecimalQuantityField(QuantityFieldMixin, models.DecimalField):
|
|
376
|
+
form_field_class = DecimalQuantityFormField
|
|
377
|
+
|
|
378
|
+
def __init__(
|
|
379
|
+
self,
|
|
380
|
+
base_units: str,
|
|
381
|
+
*args,
|
|
382
|
+
unit_choices: list[str] | None = None,
|
|
383
|
+
verbose_name: str = None,
|
|
384
|
+
name: str = None,
|
|
385
|
+
max_digits: int = None,
|
|
386
|
+
decimal_places: int = None,
|
|
387
|
+
**kwargs,
|
|
388
|
+
):
|
|
389
|
+
# We try to be friendly as default django, if there are missing argument
|
|
390
|
+
# we throw an error early
|
|
391
|
+
if not isinstance(max_digits, int) or not isinstance(decimal_places, int):
|
|
392
|
+
raise ValueError(
|
|
393
|
+
_(
|
|
394
|
+
"Invalid initialization for DecimalQuantityField! "
|
|
395
|
+
"We expect max_digits and decimal_places to be set as integers."
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
# and we also check the values to be sane
|
|
399
|
+
if decimal_places < 0 or max_digits < 1 or decimal_places > max_digits:
|
|
400
|
+
raise ValueError(
|
|
401
|
+
_(
|
|
402
|
+
"Invalid initialization for DecimalQuantityField! "
|
|
403
|
+
"max_digits and decimal_places need to positive and max_digits"
|
|
404
|
+
"needs to be larger than decimal_places and at least 1. "
|
|
405
|
+
"So max_digits=%(max_digits)s and "
|
|
406
|
+
"decimal_plactes=%(decimal_places)s "
|
|
407
|
+
"are not valid parameters."
|
|
408
|
+
)
|
|
409
|
+
% locals()
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
super().__init__(
|
|
413
|
+
base_units,
|
|
414
|
+
*args,
|
|
415
|
+
unit_choices=unit_choices,
|
|
416
|
+
verbose_name=verbose_name,
|
|
417
|
+
name=name,
|
|
418
|
+
max_digits=max_digits,
|
|
419
|
+
decimal_places=decimal_places,
|
|
420
|
+
**kwargs,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def to_number_type(self, x: object) -> Decimal:
|
|
424
|
+
return Decimal(str(x))
|
|
425
|
+
|
|
426
|
+
def get_db_prep_save(self, value, connection) -> Decimal:
|
|
427
|
+
"""
|
|
428
|
+
Get Value that shall be saved to database, make sure it is transformed
|
|
429
|
+
"""
|
|
430
|
+
converted = self.to_python(value)
|
|
431
|
+
magnitude = self.get_prep_value(converted)
|
|
432
|
+
return connection.ops.adapt_decimalfield_value(
|
|
433
|
+
magnitude, self.max_digits, self.decimal_places
|
|
434
|
+
)
|
quantityfield/helper.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from pint import DimensionalityError, UnitRegistry
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def check_matching_unit_dimension(
|
|
5
|
+
ureg: UnitRegistry, base_units: str, units_to_check: list[str]
|
|
6
|
+
) -> None:
|
|
7
|
+
"""
|
|
8
|
+
Check if all units_to_check have the same Dimension like the base_units
|
|
9
|
+
If not
|
|
10
|
+
:raise DimensionalityError
|
|
11
|
+
"""
|
|
12
|
+
base_unit = getattr(ureg, base_units)
|
|
13
|
+
# create a pint quantity by multiplying unit with magnitude of 1
|
|
14
|
+
base_quant = 1 * base_unit
|
|
15
|
+
|
|
16
|
+
for unit_string in units_to_check:
|
|
17
|
+
unit = getattr(ureg, unit_string)
|
|
18
|
+
# try to convert base qunatity to new unit, this also work for ureg.context
|
|
19
|
+
try:
|
|
20
|
+
base_quant.to(unit)
|
|
21
|
+
except DimensionalityError as e:
|
|
22
|
+
raise DimensionalityError(base_unit, unit) from e
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
__version__ = "0.4"
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
|
|
4
|
+
from pint import UnitRegistry, set_application_registry
|
|
5
|
+
|
|
6
|
+
# Define default unit register
|
|
7
|
+
DJANGO_PINT_UNIT_REGISTER = getattr(
|
|
8
|
+
settings, "DJANGO_PINT_UNIT_REGISTER", UnitRegistry()
|
|
9
|
+
)
|
|
10
|
+
# Set as default application registry for i.e. for pickle
|
|
11
|
+
set_application_registry(DJANGO_PINT_UNIT_REGISTER)
|
quantityfield/units.py
ADDED
quantityfield/widgets.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from numbers import Number
|
|
2
|
+
|
|
3
|
+
from django.forms.widgets import MultiWidget, NumberInput, Select
|
|
4
|
+
|
|
5
|
+
import pint
|
|
6
|
+
|
|
7
|
+
from .units import ureg
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QuantityWidget(MultiWidget):
|
|
11
|
+
def __init__(self, *, attrs=None, base_units=None, allowed_types=None):
|
|
12
|
+
self.ureg = ureg
|
|
13
|
+
choices = self.get_choices(allowed_types)
|
|
14
|
+
self.base_units = base_units
|
|
15
|
+
attrs = attrs or {}
|
|
16
|
+
attrs.setdefault("step", "any")
|
|
17
|
+
widgets = (NumberInput(attrs=attrs), Select(attrs=attrs, choices=choices))
|
|
18
|
+
super().__init__(widgets, attrs)
|
|
19
|
+
|
|
20
|
+
def get_choices(self, allowed_types=None):
|
|
21
|
+
allowed_types = allowed_types or dir(self.ureg)
|
|
22
|
+
return [(x, x) for x in allowed_types]
|
|
23
|
+
|
|
24
|
+
def decompress(self, value):
|
|
25
|
+
"""This function is called during rendering
|
|
26
|
+
|
|
27
|
+
It is responsible to split values for the two widgets
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(value, Number):
|
|
30
|
+
# We assume that the given value is a proper number,
|
|
31
|
+
# ready to be rendered
|
|
32
|
+
return [value, self.base_units]
|
|
33
|
+
elif isinstance(value, pint.Quantity):
|
|
34
|
+
return [value.magnitude, value.units]
|
|
35
|
+
|
|
36
|
+
return [None, self.base_units]
|