mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 12:05:53 +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:
@ -20,9 +20,9 @@ def get_unit_registry():
|
||||
|
||||
# Cache the unit registry for speedier access
|
||||
if _unit_registry is None:
|
||||
reload_unit_registry()
|
||||
|
||||
return _unit_registry
|
||||
return reload_unit_registry()
|
||||
else:
|
||||
return _unit_registry
|
||||
|
||||
|
||||
def reload_unit_registry():
|
||||
@ -36,20 +36,38 @@ def reload_unit_registry():
|
||||
|
||||
global _unit_registry
|
||||
|
||||
_unit_registry = pint.UnitRegistry()
|
||||
_unit_registry = None
|
||||
|
||||
reg = pint.UnitRegistry()
|
||||
|
||||
# Define some "standard" additional units
|
||||
_unit_registry.define('piece = 1')
|
||||
_unit_registry.define('each = 1 = ea')
|
||||
_unit_registry.define('dozen = 12 = dz')
|
||||
_unit_registry.define('hundred = 100')
|
||||
_unit_registry.define('thousand = 1000')
|
||||
reg.define('piece = 1')
|
||||
reg.define('each = 1 = ea')
|
||||
reg.define('dozen = 12 = dz')
|
||||
reg.define('hundred = 100')
|
||||
reg.define('thousand = 1000')
|
||||
|
||||
# TODO: Allow for custom units to be defined in the database
|
||||
# Allow for custom units to be defined in the database
|
||||
try:
|
||||
from common.models import CustomUnit
|
||||
|
||||
for cu in CustomUnit.objects.all():
|
||||
try:
|
||||
reg.define(cu.fmt_string())
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to load custom unit: {cu.fmt_string()} - {e}')
|
||||
|
||||
# Once custom units are loaded, save registry
|
||||
_unit_registry = reg
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to load custom units: {e}')
|
||||
|
||||
dt = time.time() - t_start
|
||||
logger.debug(f'Loaded unit registry in {dt:.3f}s')
|
||||
|
||||
return reg
|
||||
|
||||
|
||||
def convert_physical_value(value: str, unit: str = None):
|
||||
"""Validate that the provided value is a valid physical quantity.
|
||||
|
@ -16,6 +16,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
import pint.errors
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import Rate, convert_money
|
||||
from djmoney.money import Money
|
||||
@ -26,7 +27,7 @@ import InvenTree.format
|
||||
import InvenTree.helpers
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.tasks
|
||||
from common.models import InvenTreeSetting
|
||||
from common.models import CustomUnit, InvenTreeSetting
|
||||
from common.settings import currency_codes
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
@ -76,6 +77,36 @@ class ConversionTest(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTree.conversion.convert_physical_value(val)
|
||||
|
||||
def test_custom_units(self):
|
||||
"""Tests for custom unit conversion"""
|
||||
|
||||
# Start with an empty set of units
|
||||
CustomUnit.objects.all().delete()
|
||||
InvenTree.conversion.reload_unit_registry()
|
||||
|
||||
# Ensure that the custom unit does *not* exist to start with
|
||||
reg = InvenTree.conversion.get_unit_registry()
|
||||
|
||||
with self.assertRaises(pint.errors.UndefinedUnitError):
|
||||
reg['hpmm']
|
||||
|
||||
# Create a new custom unit
|
||||
CustomUnit.objects.create(
|
||||
name='fanciful_unit',
|
||||
definition='henry / mm',
|
||||
symbol='hpmm',
|
||||
)
|
||||
|
||||
# Reload registry
|
||||
reg = InvenTree.conversion.get_unit_registry()
|
||||
|
||||
# Ensure that the custom unit is now available
|
||||
reg['hpmm']
|
||||
|
||||
# Convert some values
|
||||
q = InvenTree.conversion.convert_physical_value('1 hpmm', 'henry / km')
|
||||
self.assertEqual(q.magnitude, 1000000)
|
||||
|
||||
|
||||
class ValidatorTest(TestCase):
|
||||
"""Simple tests for custom field validators."""
|
||||
|
@ -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
|
||||
)
|
||||
|
21
InvenTree/templates/InvenTree/settings/physical_units.html
Normal file
21
InvenTree/templates/InvenTree/settings/physical_units.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% extends "panel.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block label %}units{% endblock label %}
|
||||
|
||||
{% block heading %}{% trans "Physical Units" %}{% endblock heading %}
|
||||
{% block actions %}
|
||||
<button class='btn btn-success' id='custom-unit-create'>
|
||||
<span class='fas fa-plus-circle'></span>
|
||||
{% trans "Add Unit" %}
|
||||
</button>
|
||||
{% endblock actions %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<table class='table table-striped table-condensed' id='physical-units-table'>
|
||||
</table>
|
||||
|
||||
{% endblock content %}
|
@ -32,6 +32,7 @@
|
||||
{% include "InvenTree/settings/login.html" %}
|
||||
{% include "InvenTree/settings/barcode.html" %}
|
||||
{% include "InvenTree/settings/project_codes.html" %}
|
||||
{% include "InvenTree/settings/physical_units.html" %}
|
||||
{% include "InvenTree/settings/notifications.html" %}
|
||||
{% include "InvenTree/settings/label.html" %}
|
||||
{% include "InvenTree/settings/report.html" %}
|
||||
|
@ -52,6 +52,82 @@ onPanelLoad('pricing', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// Javascript for units panel
|
||||
onPanelLoad('units', function() {
|
||||
|
||||
console.log("units panel");
|
||||
|
||||
// Construct the "units" table
|
||||
$('#physical-units-table').bootstrapTable({
|
||||
url: '{% url "api-custom-unit-list" %}',
|
||||
search: true,
|
||||
columns: [
|
||||
{
|
||||
field: 'name',
|
||||
title: '{% trans "Name" %}',
|
||||
},
|
||||
{
|
||||
field: 'definition',
|
||||
title: '{% trans "Definition" %}',
|
||||
},
|
||||
{
|
||||
field: 'symbol',
|
||||
title: '{% trans "Symbol" %}',
|
||||
formatter: function(value, row) {
|
||||
let html = value;
|
||||
let buttons = '';
|
||||
|
||||
buttons += makeEditButton('button-units-edit', row.pk, '{% trans "Edit" %}');
|
||||
buttons += makeDeleteButton('button-units-delete', row.pk, '{% trans "Delete" %}');
|
||||
|
||||
html += wrapButtons(buttons);
|
||||
return html;
|
||||
}
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
// Callback to edit a custom unit
|
||||
$('#physical-units-table').on('click', '.button-units-edit', function() {
|
||||
let pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`{% url "api-custom-unit-list" %}${pk}/`, {
|
||||
title: '{% trans "Edit Custom Unit" %}',
|
||||
fields: {
|
||||
name: {},
|
||||
definition: {},
|
||||
symbol: {},
|
||||
},
|
||||
refreshTable: '#physical-units-table',
|
||||
});
|
||||
});
|
||||
|
||||
// Callback to delete a custom unit
|
||||
$('#physical-units-table').on('click', '.button-units-delete', function() {
|
||||
let pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`{% url "api-custom-unit-list" %}${pk}/`, {
|
||||
title: '{% trans "Delete Custom Unit" %}',
|
||||
method: 'DELETE',
|
||||
refreshTable: '#physical-units-table',
|
||||
});
|
||||
});
|
||||
|
||||
// Callback to create a new custom unit
|
||||
$('#custom-unit-create').click(function() {
|
||||
constructForm('{% url "api-custom-unit-list" %}', {
|
||||
fields: {
|
||||
name: {},
|
||||
definition: {},
|
||||
symbol: {},
|
||||
},
|
||||
title: '{% trans "New Custom Unit" %}',
|
||||
method: 'POST',
|
||||
refreshTable: '#physical-units-table',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Javascript for project codes panel
|
||||
onPanelLoad('project-codes', function() {
|
||||
|
||||
|
@ -32,6 +32,8 @@
|
||||
{% include "sidebar_item.html" with label='barcodes' text=text icon="fa-qrcode" %}
|
||||
{% trans "Project Codes" as text %}
|
||||
{% include "sidebar_item.html" with label='project-codes' text=text icon="fa-list" %}
|
||||
{% trans "Physical Units" as text %}
|
||||
{% include "sidebar_item.html" with label='units' text=text icon="fa-balance-scale" %}
|
||||
{% trans "Notifications" as text %}
|
||||
{% include "sidebar_item.html" with label='global-notifications' text=text icon="fa-bell" %}
|
||||
{% trans "Pricing" as text %}
|
||||
|
@ -188,6 +188,7 @@ class RuleSet(models.Model):
|
||||
|
||||
# Models which currently do not require permissions
|
||||
'common_colortheme',
|
||||
'common_customunit',
|
||||
'common_inventreesetting',
|
||||
'common_inventreeusersetting',
|
||||
'common_notificationentry',
|
||||
|
Reference in New Issue
Block a user