2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-02 03:30:54 +00:00

Add database model for defining custom units (#5268)

* Add database model for defining custom units

- Database model
- DRF serializer
- API endpoints

* Add validation hook

* Custom check for the 'definition' field

* Add settings page for custom units

- Table of units
- Create / edit / delete buttons

* Allow "unit" field to be empty

- Not actually required for custom unit definition

* Load custom unit definitions into global registry

* Docs: add core concepts page(s)

* Add some back links

* Update docs

* Add unit test for custom unit conversion

* More unit testing

* remove print statements

* Add missing table rule
This commit is contained in:
Oliver
2023-07-19 06:24:16 +10:00
committed by GitHub
parent 81e2c2f8fa
commit 6d3978ea28
17 changed files with 458 additions and 25 deletions

View File

@ -486,6 +486,23 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
class CustomUnitList(ListCreateAPI):
"""List view for custom units"""
queryset = common.models.CustomUnit.objects.all()
serializer_class = common.serializers.CustomUnitSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
filter_backends = SEARCH_ORDER_FILTER
class CustomUnitDetail(RetrieveUpdateDestroyAPI):
"""Detail view for a particular custom unit"""
queryset = common.models.CustomUnit.objects.all()
serializer_class = common.serializers.CustomUnitSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
class FlagList(ListAPI):
"""List view for feature flags."""
@ -554,6 +571,14 @@ common_api_urls = [
re_path(r'^.*$', ProjectCodeList.as_view(), name='api-project-code-list'),
])),
# Custom physical units
re_path(r'^units/', include([
path(r'<int:pk>/', include([
re_path(r'^.*$', CustomUnitDetail.as_view(), name='api-custom-unit-detail'),
])),
re_path(r'^.*$', CustomUnitList.as_view(), name='api-custom-unit-list'),
])),
# Currencies
re_path(r'^currency/', include([
re_path(r'^exchange/', CurrencyExchangeView.as_view(), name='api-currency-exchange'),

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.20 on 2023-07-18 11:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0019_projectcode_metadata'),
]
operations = [
migrations.CreateModel(
name='CustomUnit',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Unit name', max_length=50, unique=True, verbose_name='Name')),
('symbol', models.CharField(blank=True, help_text='Optional unit symbol', max_length=10, unique=True, verbose_name='Symbol')),
('definition', models.CharField(help_text='Unit definition', max_length=50, verbose_name='Definition')),
],
),
]

View File

@ -30,7 +30,9 @@ from django.core.exceptions import AppRegistryNotReady, ValidationError
from django.core.validators import (MaxValueValidator, MinValueValidator,
URLValidator)
from django.db import models, transaction
from django.db.models.signals import post_delete, post_save
from django.db.utils import IntegrityError, OperationalError
from django.dispatch.dispatcher import receiver
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@ -2794,3 +2796,90 @@ class NotesImage(models.Model):
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
date = models.DateTimeField(auto_now_add=True)
class CustomUnit(models.Model):
"""Model for storing custom physical unit definitions
Model Attributes:
name: Name of the unit
definition: Definition of the unit
symbol: Symbol for the unit (e.g. 'm' for 'metre') (optional)
Refer to the pint documentation for further information on unit definitions.
https://pint.readthedocs.io/en/stable/advanced/defining.html
"""
def fmt_string(self):
"""Construct a unit definition string e.g. 'dog_year = 52 * day = dy'"""
fmt = f'{self.name} = {self.definition}'
if self.symbol:
fmt += f' = {self.symbol}'
return fmt
def clean(self):
"""Validate that the provided custom unit is indeed valid"""
super().clean()
from InvenTree.conversion import get_unit_registry
registry = get_unit_registry()
# Check that the 'name' field is valid
self.name = self.name.strip()
# Cannot be zero length
if not self.name.isidentifier():
raise ValidationError({
'name': _('Unit name must be a valid identifier')
})
self.definition = self.definition.strip()
# Check that the 'definition' is valid, by itself
try:
registry.Quantity(self.definition)
except Exception as exc:
raise ValidationError({
'definition': str(exc)
})
# Finally, test that the entire custom unit definition is valid
try:
registry.define(self.fmt_string())
except Exception as exc:
raise ValidationError(str(exc))
name = models.CharField(
max_length=50,
verbose_name=_('Name'),
help_text=_('Unit name'),
unique=True, blank=False,
)
symbol = models.CharField(
max_length=10,
verbose_name=_('Symbol'),
help_text=_('Optional unit symbol'),
unique=True, blank=True,
)
definition = models.CharField(
max_length=50,
verbose_name=_('Definition'),
help_text=_('Unit definition'),
blank=False,
)
@receiver(post_save, sender=CustomUnit, dispatch_uid='custom_unit_saved')
@receiver(post_delete, sender=CustomUnit, dispatch_uid='custom_unit_deleted')
def after_custom_unit_updated(sender, instance, **kwargs):
"""Callback when a custom unit is updated or deleted"""
# Force reload of the unit registry
from InvenTree.conversion import reload_unit_registry
reload_unit_registry()

View File

@ -296,3 +296,18 @@ class FlagSerializer(serializers.Serializer):
data['conditions'] = self.instance[instance]
return data
class CustomUnitSerializer(InvenTreeModelSerializer):
"""DRF serializer for CustomUnit model."""
class Meta:
"""Meta options for CustomUnitSerializer."""
model = common_models.CustomUnit
fields = [
'pk',
'name',
'symbol',
'definition',
]

View File

@ -22,9 +22,10 @@ from plugin import registry
from plugin.models import NotificationUserSetting
from .api import WebhookView
from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting,
NotesImage, NotificationEntry, NotificationMessage,
ProjectCode, WebhookEndpoint, WebhookMessage)
from .models import (ColorTheme, CustomUnit, InvenTreeSetting,
InvenTreeUserSetting, NotesImage, NotificationEntry,
NotificationMessage, ProjectCode, WebhookEndpoint,
WebhookMessage)
CONTENT_TYPE_JSON = 'application/json'
@ -1061,7 +1062,7 @@ class NotesImageTest(InvenTreeAPITestCase):
image.save(output, format='PNG')
contents = output.getvalue()
response = self.post(
self.post(
reverse('api-notes-image-list'),
data={
'image': SimpleUploadedFile('test.png', contents, content_type='image/png'),
@ -1070,8 +1071,6 @@ class NotesImageTest(InvenTreeAPITestCase):
expected_code=201
)
print(response.data)
# Check that a new file has been created
self.assertEqual(NotesImage.objects.count(), n + 1)
@ -1184,3 +1183,90 @@ class ProjectCodesTest(InvenTreeAPITestCase):
},
expected_code=403
)
class CustomUnitAPITest(InvenTreeAPITestCase):
"""Unit tests for the CustomUnit API"""
@property
def url(self):
"""Return the API endpoint for the CustomUnit list"""
return reverse('api-custom-unit-list')
@classmethod
def setUpTestData(cls):
"""Construct some initial test fixture data"""
super().setUpTestData()
units = [
CustomUnit(name='metres_per_amp', definition='meter / ampere', symbol='m/A'),
CustomUnit(name='hectares_per_second', definition='hectares per second', symbol='ha/s'),
]
CustomUnit.objects.bulk_create(units)
def test_list(self):
"""Test API list functionality"""
response = self.get(self.url, expected_code=200)
self.assertEqual(len(response.data), CustomUnit.objects.count())
def test_edit(self):
"""Test edit permissions for CustomUnit model"""
unit = CustomUnit.objects.first()
# Try to edit without permission
self.user.is_staff = False
self.user.save()
self.patch(
reverse('api-custom-unit-detail', kwargs={'pk': unit.pk}),
{
'name': 'new_unit_name',
},
expected_code=403
)
# Ok, what if we have permission?
self.user.is_staff = True
self.user.save()
self.patch(
reverse('api-custom-unit-detail', kwargs={'pk': unit.pk}),
{
'name': 'new_unit_name',
},
# expected_code=200
)
unit.refresh_from_db()
self.assertEqual(unit.name, 'new_unit_name')
def test_validation(self):
"""Test that validation works as expected"""
unit = CustomUnit.objects.first()
self.user.is_staff = True
self.user.save()
# Test invalid 'name' values (must be valid identifier)
invalid_name_values = [
'1',
'1abc',
'abc def',
'abc-def',
'abc.def',
]
url = reverse('api-custom-unit-detail', kwargs={'pk': unit.pk})
for name in invalid_name_values:
self.patch(
url,
{
'name': name,
},
expected_code=400
)