2
0
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:
Matthias Mair
2023-06-09 02:27:26 +02:00
committed by GitHub
parent 005c8341bf
commit 5d1d8ec889
51 changed files with 677 additions and 586 deletions

View 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.
"""

View 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,
]

View 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)

View 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

View 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}'")

View 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")

View File

@ -0,0 +1 @@
"""Template tags for generic *things*."""

View 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,
]