mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-02 11:40:58 +00:00
Refactor states/status (#4857)
* add file for states * move general definition out * add some tests and docs * add tests for invalid definitions * make status_label tag generic * move templatetags * remove unused tag * rename test file * make status label a lookup * rename tags * move import structure * add missing tag * collect states dynamically * fix context function * move api function out * add tests for tags * rename tests * refactor imports * Add test for API function * improve errors and add tests for imporved errors * make test calls simpler * refactor definitions to use enums * switch to enum * refactor definitions to use enums * fix lookup * fix tag name * make _TAG lookup a function * cleanup BaseEnum * make _TAG definition simpler * restructure status codes to enum * reduce LoC * type status codes as int * add specific function for template context * Add definition for lookups * fix filter lookup * TEST: "fix" action lookup * Add missing migrations * Make all group code references explict * change default on models to value * switch to IntEnum * move groups into a seperate class * only request _TAG if it exsists * use value and list * use dedicated groups * fix stock assigment * fix order code * more fixes * fix borked change * fix render lookup * add group * fix import * fix syntax * clenup * fix migrations * fix typo * fix wrong value usage * fix test * remove group section * remove group section * add more test cases * Add more docstring * move choices out of migrations * change import ordeR? * last try before I revert * Update part.migrations.0112 - Add custom migration class which handles errors * Add unit test for migration - Ensure that the new fields are added to the model * Update reference to PR --------- Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
4
InvenTree/generic/__init__.py
Normal file
4
InvenTree/generic/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""The generic module provides high-level functionality that is used in multiple places.
|
||||
|
||||
The generic module is split into sub-modules. Each sub-module provides a specific set of functionality. Each sub-module should be 100% tested within the sub-module.
|
||||
"""
|
15
InvenTree/generic/states/__init__.py
Normal file
15
InvenTree/generic/states/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""States are used to track the logical state of an object.
|
||||
|
||||
The logic value of a state is stored in the database as an integer. The logic value is used for business logic and should not be easily changed therefore.
|
||||
There is a rendered state for each state value. The rendered state is used for display purposes and can be changed easily.
|
||||
|
||||
States can be extended with custom options for each InvenTree instance - those options are stored in the database and need to link back to state values.
|
||||
"""
|
||||
|
||||
from .api import StatusView
|
||||
from .states import StatusCode
|
||||
|
||||
__all__ = [
|
||||
StatusView,
|
||||
StatusCode,
|
||||
]
|
54
InvenTree/generic/states/api.py
Normal file
54
InvenTree/generic/states/api.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""Generic implementation of status api functions for InvenTree models."""
|
||||
|
||||
import inspect
|
||||
|
||||
from rest_framework import permissions
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .states import StatusCode
|
||||
|
||||
|
||||
class StatusView(APIView):
|
||||
"""Generic API endpoint for discovering information on 'status codes' for a particular model.
|
||||
|
||||
This class should be implemented as a subclass for each type of status.
|
||||
For example, the API endpoint /stock/status/ will have information about
|
||||
all available 'StockStatus' codes
|
||||
"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
# Override status_class for implementing subclass
|
||||
MODEL_REF = 'statusmodel'
|
||||
|
||||
def get_status_model(self, *args, **kwargs):
|
||||
"""Return the StatusCode moedl based on extra parameters passed to the view"""
|
||||
|
||||
status_model = self.kwargs.get(self.MODEL_REF, None)
|
||||
|
||||
if status_model is None:
|
||||
raise ValidationError(f"StatusView view called without '{self.MODEL_REF}' parameter")
|
||||
|
||||
return status_model
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Perform a GET request to learn information about status codes"""
|
||||
|
||||
status_class = self.get_status_model()
|
||||
|
||||
if not inspect.isclass(status_class):
|
||||
raise NotImplementedError("`status_class` not a class")
|
||||
|
||||
if not issubclass(status_class, StatusCode):
|
||||
raise NotImplementedError("`status_class` not a valid StatusCode class")
|
||||
|
||||
data = {
|
||||
'class': status_class.__name__,
|
||||
'values': status_class.dict(),
|
||||
}
|
||||
|
||||
return Response(data)
|
170
InvenTree/generic/states/states.py
Normal file
170
InvenTree/generic/states/states.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""Generic implementation of status for InvenTree models."""
|
||||
import enum
|
||||
import re
|
||||
|
||||
|
||||
class BaseEnum(enum.IntEnum):
|
||||
"""An `Enum` capabile of having its members have docstrings.
|
||||
|
||||
Based on https://stackoverflow.com/questions/19330460/how-do-i-put-docstrings-on-enums
|
||||
"""
|
||||
|
||||
def __new__(cls, *args):
|
||||
"""Assign values on creation."""
|
||||
obj = object.__new__(cls)
|
||||
obj._value_ = args[0]
|
||||
return obj
|
||||
|
||||
def __eq__(self, obj):
|
||||
"""Override equality operator to allow comparison with int."""
|
||||
if type(self) == type(obj):
|
||||
return super().__eq__(obj)
|
||||
return self.value == obj
|
||||
|
||||
def __ne__(self, obj):
|
||||
"""Override inequality operator to allow comparison with int."""
|
||||
if type(self) == type(obj):
|
||||
return super().__ne__(obj)
|
||||
return self.value != obj
|
||||
|
||||
|
||||
class StatusCode(BaseEnum):
|
||||
"""Base class for representing a set of StatusCodes.
|
||||
|
||||
Use enum syntax to define the status codes, e.g.
|
||||
```python
|
||||
PENDING = 10, _("Pending"), 'secondary'
|
||||
```
|
||||
|
||||
The values of the status can be accessed with `StatusCode.PENDING.value`.
|
||||
|
||||
Additionally there are helpers to access all additional attributes `text`, `label`, `color`.
|
||||
"""
|
||||
|
||||
def __new__(cls, *args):
|
||||
"""Define object out of args."""
|
||||
obj = int.__new__(cls)
|
||||
obj._value_ = args[0]
|
||||
|
||||
# Normal item definition
|
||||
if len(args) == 1:
|
||||
obj.label = args[0]
|
||||
obj.color = 'secondary'
|
||||
else:
|
||||
obj.label = args[1]
|
||||
obj.color = args[2] if len(args) > 2 else 'secondary'
|
||||
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def _is_element(cls, d):
|
||||
"""Check if the supplied value is a valid status code."""
|
||||
if d.startswith('_'):
|
||||
return False
|
||||
if d != d.upper():
|
||||
return False
|
||||
|
||||
value = getattr(cls, d, None)
|
||||
|
||||
if value is None:
|
||||
return False
|
||||
if callable(value):
|
||||
return False
|
||||
if type(value.value) != int:
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def values(cls, key=None):
|
||||
"""Return a dict representation containing all required information"""
|
||||
elements = [itm for itm in cls if cls._is_element(itm.name)]
|
||||
if key is None:
|
||||
return elements
|
||||
|
||||
ret = [itm for itm in elements if itm.value == key]
|
||||
if ret:
|
||||
return ret[0]
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def render(cls, key, large=False):
|
||||
"""Render the value as a HTML label."""
|
||||
# If the key cannot be found, pass it back
|
||||
item = cls.values(key)
|
||||
if item is None:
|
||||
return key
|
||||
|
||||
return f"<span class='badge rounded-pill bg-{item.color}'>{item.label}</span>"
|
||||
|
||||
@classmethod
|
||||
def tag(cls):
|
||||
"""Return tag for this status code."""
|
||||
# Return the tag if it is defined
|
||||
if hasattr(cls, '_TAG') and bool(cls._TAG):
|
||||
return cls._TAG.value
|
||||
|
||||
# Try to find a default tag
|
||||
# Remove `Status` from the class name
|
||||
ref_name = cls.__name__.removesuffix('Status')
|
||||
# Convert to snake case
|
||||
return re.sub(r'(?<!^)(?=[A-Z])', '_', ref_name).lower()
|
||||
|
||||
@classmethod
|
||||
def items(cls):
|
||||
"""All status code items."""
|
||||
return [(x.value, x.label) for x in cls.values()]
|
||||
|
||||
@classmethod
|
||||
def keys(cls):
|
||||
"""All status code keys."""
|
||||
return [x.value for x in cls.values()]
|
||||
|
||||
@classmethod
|
||||
def labels(cls):
|
||||
"""All status code labels."""
|
||||
return [x.label for x in cls.values()]
|
||||
|
||||
@classmethod
|
||||
def names(cls):
|
||||
"""Return a map of all 'names' of status codes in this class."""
|
||||
return {x.name: x.value for x in cls.values()}
|
||||
|
||||
@classmethod
|
||||
def text(cls, key):
|
||||
"""Text for supplied status code."""
|
||||
filtered = cls.values(key)
|
||||
if filtered is None:
|
||||
return key
|
||||
return filtered.label
|
||||
|
||||
@classmethod
|
||||
def label(cls, key):
|
||||
"""Return the status code label associated with the provided value."""
|
||||
filtered = cls.values(key)
|
||||
if filtered is None:
|
||||
return key
|
||||
return filtered.label
|
||||
|
||||
@classmethod
|
||||
def dict(cls, key=None):
|
||||
"""Return a dict representation containing all required information"""
|
||||
return {x.name: {
|
||||
'color': x.color,
|
||||
'key': x.value,
|
||||
'label': x.label,
|
||||
'name': x.name,
|
||||
} for x in cls.values(key)}
|
||||
|
||||
@classmethod
|
||||
def list(cls):
|
||||
"""Return the StatusCode options as a list of mapped key / value items."""
|
||||
return list(cls.dict().values())
|
||||
|
||||
@classmethod
|
||||
def template_context(cls):
|
||||
"""Return a dict representation containing all required information for templates."""
|
||||
|
||||
ret = {x.name: x.value for x in cls.values()}
|
||||
ret['list'] = cls.list()
|
||||
|
||||
return ret
|
17
InvenTree/generic/states/tags.py
Normal file
17
InvenTree/generic/states/tags.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""Provide templates for the various model status codes."""
|
||||
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from generic.templatetags.generic import register
|
||||
from InvenTree.helpers import inheritors
|
||||
|
||||
from .states import StatusCode
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def status_label(typ: str, key: int, *args, **kwargs):
|
||||
"""Render a status label."""
|
||||
state = {cls.tag(): cls for cls in inheritors(StatusCode)}.get(typ, None)
|
||||
if state:
|
||||
return mark_safe(state.render(key, large=kwargs.get('large', False)))
|
||||
raise ValueError(f"Unknown status type '{typ}'")
|
110
InvenTree/generic/states/tests.py
Normal file
110
InvenTree/generic/states/tests.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""Tests for the generic states module."""
|
||||
from django.test.client import RequestFactory
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework.test import force_authenticate
|
||||
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
|
||||
from .api import StatusView
|
||||
from .states import StatusCode
|
||||
|
||||
|
||||
class GeneralStatus(StatusCode):
|
||||
"""Defines a set of status codes for tests."""
|
||||
|
||||
PENDING = 10, _("Pending"), 'secondary'
|
||||
PLACED = 20, _("Placed"), 'primary'
|
||||
COMPLETE = 30, _("Complete"), 'success'
|
||||
ABC = None # This should be ignored
|
||||
_DEF = None # This should be ignored
|
||||
jkl = None # This should be ignored
|
||||
|
||||
def GHI(self): # This should be ignored
|
||||
"""A invalid function"""
|
||||
pass
|
||||
|
||||
|
||||
class GeneralStateTest(InvenTreeTestCase):
|
||||
"""Test that the StatusCode class works."""
|
||||
def test_code_definition(self):
|
||||
"""Test that the status code class has been defined correctly."""
|
||||
self.assertEqual(GeneralStatus.PENDING, 10)
|
||||
self.assertEqual(GeneralStatus.PLACED, 20)
|
||||
self.assertEqual(GeneralStatus.COMPLETE, 30)
|
||||
|
||||
def test_code_functions(self):
|
||||
"""Test that the status code class functions work correctly"""
|
||||
# render
|
||||
self.assertEqual(GeneralStatus.render(10), "<span class='badge rounded-pill bg-secondary'>Pending</span>")
|
||||
self.assertEqual(GeneralStatus.render(20), "<span class='badge rounded-pill bg-primary'>Placed</span>")
|
||||
# render with invalid key
|
||||
self.assertEqual(GeneralStatus.render(100), 100)
|
||||
|
||||
# list
|
||||
self.assertEqual(GeneralStatus.list(), [{'color': 'secondary', 'key': 10, 'label': 'Pending', 'name': 'PENDING'}, {'color': 'primary', 'key': 20, 'label': 'Placed', 'name': 'PLACED'}, {'color': 'success', 'key': 30, 'label': 'Complete', 'name': 'COMPLETE'}])
|
||||
|
||||
# text
|
||||
self.assertEqual(GeneralStatus.text(10), 'Pending')
|
||||
self.assertEqual(GeneralStatus.text(20), 'Placed')
|
||||
|
||||
# items
|
||||
self.assertEqual(list(GeneralStatus.items()), [(10, 'Pending'), (20, 'Placed'), (30, 'Complete')])
|
||||
|
||||
# keys
|
||||
self.assertEqual(list(GeneralStatus.keys()), ([10, 20, 30]))
|
||||
|
||||
# labels
|
||||
self.assertEqual(list(GeneralStatus.labels()), ['Pending', 'Placed', 'Complete'])
|
||||
|
||||
# names
|
||||
self.assertEqual(GeneralStatus.names(), {'PENDING': 10, 'PLACED': 20, 'COMPLETE': 30})
|
||||
|
||||
# dict
|
||||
self.assertEqual(GeneralStatus.dict(), {
|
||||
'PENDING': {'key': 10, 'name': 'PENDING', 'label': 'Pending', 'color': 'secondary'},
|
||||
'PLACED': {'key': 20, 'name': 'PLACED', 'label': 'Placed', 'color': 'primary'},
|
||||
'COMPLETE': {'key': 30, 'name': 'COMPLETE', 'label': 'Complete', 'color': 'success'},
|
||||
})
|
||||
|
||||
# label
|
||||
self.assertEqual(GeneralStatus.label(10), 'Pending')
|
||||
|
||||
def test_tag_function(self):
|
||||
"""Test that the status code tag functions."""
|
||||
from .tags import status_label
|
||||
|
||||
self.assertEqual(status_label('general', 10), "<span class='badge rounded-pill bg-secondary'>Pending</span>")
|
||||
|
||||
# invalid type
|
||||
with self.assertRaises(ValueError) as e:
|
||||
status_label('invalid', 10)
|
||||
self.assertEqual(str(e.exception), "Unknown status type 'invalid'")
|
||||
|
||||
# Test non-existent key
|
||||
self.assertEqual(status_label('general', 100), '100')
|
||||
|
||||
def test_api(self):
|
||||
"""Test StatusView API view."""
|
||||
view = StatusView.as_view()
|
||||
rqst = RequestFactory().get('status/',)
|
||||
force_authenticate(rqst, user=self.user)
|
||||
|
||||
# Correct call
|
||||
resp = view(rqst, **{StatusView.MODEL_REF: GeneralStatus})
|
||||
self.assertEqual(resp.data, {'class': 'GeneralStatus', 'values': {'COMPLETE': {'key': 30, 'name': 'COMPLETE', 'label': 'Complete', 'color': 'success'}, 'PENDING': {'key': 10, 'name': 'PENDING', 'label': 'Pending', 'color': 'secondary'}, 'PLACED': {'key': 20, 'name': 'PLACED', 'label': 'Placed', 'color': 'primary'}}})
|
||||
|
||||
# No status defined
|
||||
resp = view(rqst, **{StatusView.MODEL_REF: None})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(str(resp.rendered_content, 'utf-8'), '["StatusView view called without \'statusmodel\' parameter"]')
|
||||
|
||||
# Invalid call - not a class
|
||||
with self.assertRaises(NotImplementedError) as e:
|
||||
resp = view(rqst, **{StatusView.MODEL_REF: 'invalid'})
|
||||
self.assertEqual(str(e.exception), "`status_class` not a class")
|
||||
|
||||
# Invalid call - not the right class
|
||||
with self.assertRaises(NotImplementedError) as e:
|
||||
resp = view(rqst, **{StatusView.MODEL_REF: object})
|
||||
self.assertEqual(str(e.exception), "`status_class` not a valid StatusCode class")
|
1
InvenTree/generic/templatetags/__init__.py
Normal file
1
InvenTree/generic/templatetags/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Template tags for generic *things*."""
|
10
InvenTree/generic/templatetags/generic.py
Normal file
10
InvenTree/generic/templatetags/generic.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""Template tags for generic *things*."""
|
||||
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
from generic.states.tags import status_label # noqa: E402
|
||||
|
||||
__all__ = [
|
||||
status_label,
|
||||
]
|
Reference in New Issue
Block a user