Skip to content

Commit

Permalink
Batch code generation (#7000)
Browse files Browse the repository at this point in the history
* Refactor framework for generating batch codes

- Provide additional kwargs to plugin
- Move into new file
- Error handling

* Implement API endpoint for generating a new batch code

* Fixes

* Refactor into stock.generators

* Fix API endpoint

* Pass time context through to plugins

* Generate batch code when receiving items

* Create useGenerator hook

- Build up a dataset and query server whenever it changes
- Look for result in response data
- For now, just used for generating batch codes
- may be used for more in the future

* Refactor PurchaseOrderForms to use new generator hook

* Refactor StockForms implementation

* Remove dead code

* add OAS diff

* fix ref

* fix ref again

* wrong branch, sorry

* Update src/frontend/src/hooks/UseGenerator.tsx

Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>

* Bump API version

* Do not override batch code if already generated

* Add serial number generator

- Move to /generate/ API endpoint
- Move batch code generator too

* Update PUI endpoints

* Add debouncing to useGenerator hook

* Refactor useGenerator func

* Add serial number generator to stock form

* Add batch code genereator to build order form

* Update buildfields

* Use build batch code when creating new output

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
  • Loading branch information
3 people committed May 20, 2024
1 parent 5cb61d5 commit e93d9c4
Show file tree
Hide file tree
Showing 21 changed files with 513 additions and 64 deletions.
2 changes: 1 addition & 1 deletion docs/docs/extend/plugins/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Validation of the Part IPN (Internal Part Number) field is exposed to custom plu

The `validate_batch_code` method allows plugins to raise an error if a batch code input by the user does not meet a particular pattern.

The `generate_batch_code` method can be implemented to generate a new batch code.
The `generate_batch_code` method can be implemented to generate a new batch code, based on a set of provided information.

### Serial Numbers

Expand Down
1 change: 1 addition & 0 deletions src/backend/InvenTree/InvenTree/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.conf import settings
from django.db import transaction
from django.http import JsonResponse
from django.urls import include, path
from django.utils.translation import gettext_lazy as _

from django_q.models import OrmQ
Expand Down
7 changes: 6 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 199
INVENTREE_API_VERSION = 200

"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """
v200 - 2024-05-20 : https://github.com/inventree/InvenTree/pull/7000
- Adds API endpoint for generating custom batch codes
- Adds API endpoint for generating custom serial numbers
v199 - 2024-05-20 : https://github.com/inventree/InvenTree/pull/7264
- Expose "bom_valid" filter for the Part API
- Expose "starred" filter for the Part API
Expand Down
4 changes: 2 additions & 2 deletions src/backend/InvenTree/InvenTree/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,13 +293,13 @@ def increment(value):
QQQ -> QQQ
"""
value = str(value).strip()

# Ignore empty strings
if value in ['', None]:
# Provide a default value if provided with a null input
return '1'

value = str(value).strip()

pattern = r'(.*?)(\d+)?$'

result = re.search(pattern, value)
Expand Down
15 changes: 15 additions & 0 deletions src/backend/InvenTree/InvenTree/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,21 @@
path('part/', include(part.api.part_api_urls)),
path('bom/', include(part.api.bom_api_urls)),
path('company/', include(company.api.company_api_urls)),
path(
'generate/',
include([
path(
'batch-code/',
stock.api.GenerateBatchCode.as_view(),
name='api-generate-batch-code',
),
path(
'serial-number/',
stock.api.GenerateSerialNumber.as_view(),
name='api-generate-serial-number',
),
]),
),
path('stock/', include(stock.api.stock_api_urls)),
path('build/', include(build.api.build_api_urls)),
path('order/', include(order.api.order_api_urls)),
Expand Down
5 changes: 2 additions & 3 deletions src/backend/InvenTree/build/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""JSON serializers for Build API."""

from decimal import Decimal

from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _
Expand All @@ -22,7 +20,8 @@
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.status_codes import StockStatus

from stock.models import generate_batch_code, StockItem, StockLocation
from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer

import common.models
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,14 @@ def validate_batch_code(self, batch_code: str, item: stock.models.StockItem):
"""
return None

def generate_batch_code(self):
def generate_batch_code(self, **kwargs):
"""Generate a new batch code.
This method is called when a new batch code is required.
kwargs:
Any additional keyword arguments which are passed through to the plugin, based on the context of the caller
Returns:
A new batch code (string) or None
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,17 @@ def validate_batch_code(self, batch_code: str, item):
if len(batch_code) > 0 and prefix and not batch_code.startswith(prefix):
self.raise_error(f"Batch code must start with '{prefix}'")

def generate_batch_code(self):
def generate_batch_code(self, **kwargs):
"""Generate a new batch code."""
now = datetime.now()
return f'BATCH-{now.year}:{now.month}:{now.day}'
batch = f'SAMPLE-BATCH-{now.year}:{now.month}:{now.day}'

# If a Part instance is provided, prepend the part name to the batch code
if part := kwargs.get('part', None):
batch = f'{part.name}-{batch}'

# If a Build instance is provided, prepend the build number to the batch code
if build := kwargs.get('build_order', None):
batch = f'{build.reference}-{batch}'

return batch
37 changes: 36 additions & 1 deletion src/backend/InvenTree/stock/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""JSON API for the Stock app."""

import json
from collections import OrderedDict
from datetime import timedelta

Expand All @@ -13,7 +14,8 @@
from django_filters import rest_framework as rest_filters
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import status
from rest_framework import permissions, status
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.serializers import ValidationError

Expand Down Expand Up @@ -64,6 +66,7 @@
from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer
from stock.admin import LocationResource, StockItemResource
from stock.generators import generate_batch_code, generate_serial_number
from stock.models import (
StockItem,
StockItemAttachment,
Expand All @@ -74,6 +77,38 @@
)


class GenerateBatchCode(GenericAPIView):
"""API endpoint for generating batch codes."""

permission_classes = [permissions.IsAuthenticated]
serializer_class = StockSerializers.GenerateBatchCodeSerializer

def post(self, request, *args, **kwargs):
"""Generate a new batch code."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)

data = {'batch_code': generate_batch_code(**serializer.validated_data)}

return Response(data, status=status.HTTP_201_CREATED)


class GenerateSerialNumber(GenericAPIView):
"""API endpoint for generating serial numbers."""

permission_classes = [permissions.IsAuthenticated]
serializer_class = StockSerializers.GenerateSerialNumberSerializer

def post(self, request, *args, **kwargs):
"""Generate a new serial number."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)

data = {'serial_number': generate_serial_number(**serializer.validated_data)}

return Response(data, status=status.HTTP_201_CREATED)


class StockDetail(RetrieveUpdateDestroyAPI):
"""API detail endpoint for Stock object.
Expand Down
113 changes: 113 additions & 0 deletions src/backend/InvenTree/stock/generators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Generator functions for the stock app."""

from inspect import signature

from django.core.exceptions import ValidationError

from jinja2 import Template

import common.models
import InvenTree.exceptions
import InvenTree.helpers


def generate_batch_code(**kwargs):
"""Generate a default 'batch code' for a new StockItem.
By default, this uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured),
which can be passed through a simple template.
Also, this function is exposed to the ValidationMixin plugin class,
allowing custom plugins to be used to generate new batch code values.
Various kwargs can be passed to the function, which will be passed through to the plugin functions.
"""
# First, check if any plugins can generate batch codes
from plugin.registry import registry

now = InvenTree.helpers.current_time()

context = {
'date': now,
'year': now.year,
'month': now.month,
'day': now.day,
'hour': now.hour,
'minute': now.minute,
'week': now.isocalendar()[1],
**kwargs,
}

for plugin in registry.with_mixin('validation'):
generate = getattr(plugin, 'generate_batch_code', None)

if not generate:
continue

# Check if the function signature accepts kwargs
sig = signature(generate)

if 'kwargs' in sig.parameters:
# Pass the kwargs through to the plugin
try:
batch = generate(**context)
except Exception:
InvenTree.exceptions.log_error('plugin.generate_batch_code')
continue
else:
# Ignore the kwargs (legacy plugin)
try:
batch = generate()
except Exception:
InvenTree.exceptions.log_error('plugin.generate_batch_code')
continue

# Return the first non-null value generated by a plugin
if batch is not None:
return batch

# If we get to this point, no plugin was able to generate a new batch code
batch_template = common.models.InvenTreeSetting.get_setting(
'STOCK_BATCH_CODE_TEMPLATE', ''
)

return Template(batch_template).render(context)


def generate_serial_number(part=None, quantity=1, **kwargs) -> str:
"""Generate a default 'serial number' for a new StockItem."""
from plugin.registry import registry

quantity = quantity or 1

if part is None:
# Cannot generate a serial number without a part
return None

try:
quantity = int(quantity)
except Exception:
raise ValidationError({'quantity': 'Invalid quantity value'})

if quantity < 1:
raise ValidationError({'quantity': 'Quantity must be greater than zero'})

# If we are here, no plugins were available to generate a serial number
# In this case, we will generate a simple serial number based on the provided part
sn = part.get_latest_serial_number()

serials = []

# Generate the required quantity of serial numbers
# Note that this call gets passed through to the plugin system
while quantity > 0:
sn = InvenTree.helpers.increment_serial_number(sn)

# Exit if an empty or duplicated serial is generated
if not sn or sn in serials:
break

serials.append(sn)
quantity -= 1

return ','.join(serials)
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Generated by Django 3.2.12 on 2022-04-26 10:19

from django.db import migrations, models
import stock.generators
import stock.models


Expand All @@ -14,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='stockitem',
name='batch',
field=models.CharField(blank=True, default=stock.models.generate_batch_code, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'),
field=models.CharField(blank=True, default=stock.generators.generate_batch_code, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'),
),
]
43 changes: 1 addition & 42 deletions src/backend/InvenTree/stock/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

from jinja2 import Template
from mptt.managers import TreeManager
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
Expand All @@ -43,6 +42,7 @@
)
from part import models as PartModels
from plugin.events import trigger_event
from stock.generators import generate_batch_code
from users.models import Owner

logger = logging.getLogger('inventree')
Expand Down Expand Up @@ -295,47 +295,6 @@ def get_items(self, cascade=False):
return self.get_stock_items(cascade=cascade)


def generate_batch_code():
"""Generate a default 'batch code' for a new StockItem.
By default, this uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured),
which can be passed through a simple template.
Also, this function is exposed to the ValidationMixin plugin class,
allowing custom plugins to be used to generate new batch code values
"""
# First, check if any plugins can generate batch codes
from plugin.registry import registry

for plugin in registry.with_mixin('validation'):
batch = plugin.generate_batch_code()

if batch is not None:
# Return the first non-null value generated by a plugin
return batch

# If we get to this point, no plugin was able to generate a new batch code
batch_template = common.models.InvenTreeSetting.get_setting(
'STOCK_BATCH_CODE_TEMPLATE', ''
)

now = InvenTree.helpers.current_time()

# Pass context data through to the template rendering.
# The following context variables are available for custom batch code generation
context = {
'date': now,
'year': now.year,
'month': now.month,
'day': now.day,
'hour': now.hour,
'minute': now.minute,
'week': now.isocalendar()[1],
}

return Template(batch_template).render(context)


def default_delete_on_deplete():
"""Return a default value for the 'delete_on_deplete' field.
Expand Down

0 comments on commit e93d9c4

Please sign in to comment.