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:
@ -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'),
|
||||
|
22
InvenTree/common/migrations/0020_customunit.py
Normal file
22
InvenTree/common/migrations/0020_customunit.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
@ -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()
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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
|
||||
)
|
||||
|
Reference in New Issue
Block a user