2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 04:25:42 +00:00

Report printing refactor (#7074)

* Adds a new "generic" ReportTemplate model

* expose API endpoints

* Update model / migrations / serializer

* Add new mixin class to existing database models

* - Add detail view for report template
- Revert filters field behaviour

* Filter report list by provided item IDs

- Greatly simplify filtering logic compared to existing implemetation
- Expose to API schema

* Create data migration for converting *old* report templates

* Ignore internal reports for data migration

* Add report mixin to StockLocation model

* Provide model choices in admin interface

* Offload context data generation to the model classes

* Remove old report template models

* Refactor JS code in CUI

* Fix for API filtering

* Add data migration to delete old models

* Remove dead URL

* Updates

* Construct sample report templates on app start

* Bump API version

* Typo fix

* Fix incorrect context calls

* Add new LabelTemplate model

- ReportTemplate and LabelTemplate share common base
- Refactor previous migration

* Expose to admin interface

* Add in extra context from existing label models

* Add migration to create LabelTemplate instances from existing labels

* Add API endpoints for listing and updating LabelTemplate objects

* Adjust 'upload_to' path

* Refactor label printing

* Move default label templates

* Update API endpoints

* Update migrations

* Handle LookupError in migration

* Redirect the "label" API endpoint

* Add new model for handling result of template printing

* Refactor LabelPrinting mixin

* Unlink "labels" app entirely

* Fix typo

* Record 'plugin' used to generate a particular output

* Fix imports

* Generate label print response

- Still not good yet

* Refactoring label printing in CUI

* add "items" count to TemplateOutput model

* Fix for InvenTreeLabelSheetPlugin

* Remove old "label" app

* Make request object optional

* Fix filename generation

* Add help text for "model_type"

* Simplify TemplateTable

* Tweak TemplateTable

* Get template editor to display template data again

* Stringify template name

- Important, otherwise you get a TypeError instead of TemplateDoesNotExist

* Add hooks to reset plugin state

* fix context for StockLocation model

* Tweak log messages

* Fix incorrect serializer

* Cleanup TemplateTable

* Fix broken import

* Filter by target model type

* Remove manual file operations

* Update old migrations

- Remove references to functions that no longer exist

* Refactor asset / snippet uploading

* Update comments

* Retain original filename when editing templatese

* Cleanup

* Refactor model type filter to use new hook

* Add placeholder actions for printing labels and reports

* Improve hookiness

* Add new ReportOutput class

* Report printing works from PUI now!

* More inspired filename pattern for generated reports

* Fix template preview window

- Use new "output" response field across the board

* Remove outdated task

* Update data migration to use raw SQL

- If the 'labels' app is no longer available, this will fail
- So, use raw SQL instead

* Add more API endpoint defs

* Adds placeholder API endpoint for label printing

* Expose plugin field to the printing endpoint

* Adds plugin model type

* Hook to print labels

* Refactor action dropdown items

* Refactor report printing for CUI

* Refactor label print for CUI

- Still needs to handle custom printing options for plugin

* Fix migration

* Update ModelType dict

* playwright test fix

* Unit test fixes

* Fix model ruleset associations

* Fix for report.js

* Add support for "dynamic" fields in metadata.py

* Add in custom fields based on plugin

* Refactoring

* Reset plugin on form close

* Set custom timeout values

* Update migration

- Not atomic

* Cleanup

* Implement more printing actions

* Reduce timeout

* Unit test updates

* Fix part serializers

* Label printing works in CUI again

* js linting

* Update <ActionDropdown>

* Fix for label printing API endpoint

* Fix filterselectdrawer

* Improve button rendering

* Allow printing from StockLocationTable

* Add aria-labels to modal form fields

* Add test for printing stock item labels from table

* Add test for report printing

* Add unit testing for report template editing / preview

* Message refactor

* Refactor InvenTreeReportMixin class

* Update playwright test

* Update 'verbose_name' for a number of models

* Additional admin filtering

* Playwright test updates

* Run checks against new python lib branch

(temporary, will be reverted)

* remove old app reference

* fix testing ref

* fix app init

* remove old tests

* Revert custom target branch

* Expose label and report output objects to API

* refactor

* fix a few tests

* factor plugin_ref out

* fix options testing

* Update table field header

* re-enable full options testing

* fix missing plugin matching

* disable call assert

* Add custom related field for PluginConfig

- Uses 'key' rather than 'pk'
- Revert label print plugin to use slug

* Add support for custom pk field in metadata

* switch to labels for testing

* re-align report testing code

* disable version check

* fix url

* Implement lazy loading

* Allow blank plugin for printing

- Uses the builtin label printer if not specified

* Add printing actions for StockItem

* Fix for metadata helper

* Use key instead of pk in printing actions

* Support non-standard pk values in RelatedModelField

* pass context data to report serializers

* disable template / item discovery

* fix call

* Tweak unit test

* Run python checks against specific branch

* Add task for running docs server

- Option to compile schema as part of task

* Custom branch no longer needed

* Starting on documentation updates

* fix tests for reports

* fix label testing

* Update template context variables

* Refactor report context documentation

* Documentation cleanup

* Docs cleanup

* Include sample report files

* Fix links

* Link cleanup

* Integrate plugin example code into docs

* Code cleanup

* Fix type annotation

* Revert deleted variable

* remove templatetype

* remove unused imports

* extend context testing

* test if plg can print

* re-enable version check

* Update unit tests

* Fix test

* Adjust unit test

* Add debug statement to test

* Fix unit test

- Labels get printed against LabelTemplate items, duh

* Unit test update

* Unit test updates

* Test update

* Patch fix for <PartColumn> component

* Fix ReportSerialierBase class

- Re-initialize field options if not already set

* Fix unit test for sqlite

* Fix kwargs for non-blocking label printing

* Update playwright tests

* Tweak unit test

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2024-05-22 10:17:01 +10:00
committed by GitHub
parent d99b6ae81b
commit aa39582d89
217 changed files with 4507 additions and 6762 deletions

View File

@ -1,12 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 200
INVENTREE_API_VERSION = 201
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v201 - 2024-05-21 : https://github.com/inventree/InvenTree/pull/7074
- Major refactor of the report template / report printing interface
- This is a *breaking change* to the report template API
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

View File

@ -74,6 +74,7 @@ class InvenTreeConfig(AppConfig):
obsolete = [
'InvenTree.tasks.delete_expired_sessions',
'stock.tasks.delete_old_stock_items',
'label.tasks.cleanup_old_label_outputs',
]
try:
@ -83,7 +84,14 @@ class InvenTreeConfig(AppConfig):
# Remove any existing obsolete tasks
try:
Schedule.objects.filter(func__in=obsolete).delete()
obsolete_tasks = Schedule.objects.filter(func__in=obsolete)
if obsolete_tasks.exists():
logger.info(
'Removing %s obsolete background tasks', obsolete_tasks.count()
)
obsolete_tasks.delete()
except Exception:
logger.exception('Failed to remove obsolete tasks - database not ready')

View File

@ -121,6 +121,16 @@ class InvenTreeMetadata(SimpleMetadata):
serializer_info = super().get_serializer_info(serializer)
# Look for any dynamic fields which were not available when the serializer was instantiated
for field_name in serializer.Meta.fields:
if field_name in serializer_info:
# Already know about this one
continue
if hasattr(serializer, field_name):
field = getattr(serializer, field_name)
serializer_info[field_name] = self.get_field_info(field)
model_class = None
# Attributes to copy extra attributes from the model to the field (if they don't exist)
@ -264,7 +274,9 @@ class InvenTreeMetadata(SimpleMetadata):
# Introspect writable related fields
if field_info['type'] == 'field' and not field_info['read_only']:
# If the field is a PrimaryKeyRelatedField, we can extract the model from the queryset
if isinstance(field, serializers.PrimaryKeyRelatedField):
if isinstance(field, serializers.PrimaryKeyRelatedField) or issubclass(
field.__class__, serializers.PrimaryKeyRelatedField
):
model = field.queryset.model
else:
logger.debug(
@ -285,6 +297,9 @@ class InvenTreeMetadata(SimpleMetadata):
else:
field_info['api_url'] = model.get_api_url()
# Handle custom 'primary key' field
field_info['pk_field'] = getattr(field, 'pk_field', 'pk') or 'pk'
# Add more metadata about dependent fields
if field_info['type'] == 'dependent field':
field_info['depends_on'] = field.depends_on

View File

@ -193,7 +193,6 @@ INSTALLED_APPS = [
'common.apps.CommonConfig',
'company.apps.CompanyConfig',
'plugin.apps.PluginAppConfig', # Plugin app runs before all apps that depend on the isPluginRegistryLoaded function
'label.apps.LabelConfig',
'order.apps.OrderConfig',
'part.apps.PartConfig',
'report.apps.ReportConfig',
@ -434,12 +433,7 @@ ROOT_URLCONF = 'InvenTree.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
BASE_DIR.joinpath('templates'),
# Allow templates in the reporting directory to be accessed
MEDIA_ROOT.joinpath('report'),
MEDIA_ROOT.joinpath('label'),
],
'DIRS': [BASE_DIR.joinpath('templates'), MEDIA_ROOT.joinpath('report')],
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',

View File

@ -1065,7 +1065,8 @@ class TestVersionNumber(TestCase):
subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8'
).strip()
self.assertEqual(hash, version.inventreeCommitHash())
# On some systems the hash is a different length, so just check the first 6 characters
self.assertEqual(hash[:6], version.inventreeCommitHash()[:6])
d = (
str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8')

View File

@ -21,7 +21,6 @@ from sesame.views import LoginView
import build.api
import common.api
import company.api
import label.api
import machine.api
import order.api
import part.api
@ -104,7 +103,7 @@ apipatterns = [
path('stock/', include(stock.api.stock_api_urls)),
path('build/', include(build.api.build_api_urls)),
path('order/', include(order.api.order_api_urls)),
path('label/', include(label.api.label_api_urls)),
path('label/', include(report.api.label_api_urls)),
path('report/', include(report.api.report_api_urls)),
path('machine/', include(machine.api.machine_api_urls)),
path('user/', include(users.api.user_urls)),

View File

@ -18,7 +18,7 @@ class BuildResource(InvenTreeResource):
# TODO: 2022-05-12 - Need to investigate why this is the case!
class Meta:
"""Metaclass options"""
"""Metaclass options."""
models = Build
skip_unchanged = True
report_skipped = False

View File

@ -30,7 +30,7 @@ class BuildFilter(rest_filters.FilterSet):
"""Custom filterset for BuildList API endpoint."""
class Meta:
"""Metaclass options"""
"""Metaclass options."""
model = Build
fields = [
'parent',

View File

@ -23,6 +23,7 @@ class Migration(migrations.Migration):
],
options={
'unique_together': {('build', 'bom_item')},
'verbose_name': 'Build Order Line Item',
},
),
]

View File

@ -38,6 +38,7 @@ from common.notifications import trigger_notification, InvenTreeNotificationBodi
from plugin.events import trigger_event
import part.models
import report.mixins
import stock.models
import users.models
@ -45,7 +46,14 @@ import users.models
logger = logging.getLogger('inventree')
class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.PluginValidationMixin, InvenTree.models.ReferenceIndexingMixin, MPTTModel):
class Build(
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
InvenTree.models.ReferenceIndexingMixin,
MPTTModel):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes:
@ -139,6 +147,21 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
'part': _('Build order part cannot be changed')
})
def report_context(self) -> dict:
"""Generate custom report context data."""
return {
'bom_items': self.part.get_bom_items(),
'build': self,
'build_outputs': self.build_outputs.all(),
'line_items': self.build_lines.all(),
'part': self.part,
'quantity': self.quantity,
'reference': self.reference,
'title': str(self)
}
@staticmethod
def filterByDate(queryset, min_date, max_date):
"""Filter by 'minimum and maximum date range'.
@ -1291,7 +1314,7 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildLine(InvenTree.models.InvenTreeModel):
class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel):
"""A BuildLine object links a BOMItem to a Build.
When a new Build is created, the BuildLine objects are created automatically.
@ -1308,7 +1331,8 @@ class BuildLine(InvenTree.models.InvenTreeModel):
"""
class Meta:
"""Model meta options"""
"""Model meta options."""
verbose_name = _('Build Order Line Item')
unique_together = [
('build', 'bom_item'),
]
@ -1318,6 +1342,19 @@ class BuildLine(InvenTree.models.InvenTreeModel):
"""Return the API URL used to access this model"""
return reverse('api-build-line-list')
def report_context(self):
"""Generate custom report context for this BuildLine object."""
return {
'allocated_quantity': self.allocated_quantity,
'allocations': self.allocations,
'bom_item': self.bom_item,
'build': self.build,
'build_line': self,
'part': self.bom_item.sub_part,
'quantity': self.quantity,
}
build = models.ForeignKey(
Build, on_delete=models.CASCADE,
related_name='build_lines', help_text=_('Build object')
@ -1384,7 +1421,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
"""
class Meta:
"""Model meta options"""
"""Model meta options."""
unique_together = [
('build_line', 'stock_item', 'install_into'),
]

View File

@ -257,11 +257,7 @@ src="{% static 'img/blank_image.png' %}"
{% if report_enabled %}
$('#print-build-report').click(function() {
printReports({
items: [{{ build.pk }}],
key: 'build',
url: '{% url "api-build-report-list" %}',
});
printReports('build', [{{ build.pk }}]);
});
{% endif %}

View File

@ -55,7 +55,7 @@ def update_news_feed():
# Fetch and parse feed
try:
feed = requests.get(settings.INVENTREE_NEWS_URL)
feed = requests.get(settings.INVENTREE_NEWS_URL, timeout=30)
d = feedparser.parse(feed.content)
except Exception: # pragma: no cover
logger.warning('update_news_feed: Error parsing the newsfeed')

View File

@ -1,134 +0,0 @@
"""Shared templating code."""
import logging
import warnings
from pathlib import Path
from django.core.exceptions import AppRegistryNotReady
from django.core.files.storage import default_storage
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
import InvenTree.helpers
from InvenTree.config import ensure_dir
logger = logging.getLogger('inventree')
class TemplatingMixin:
"""Mixin that contains shared templating code."""
name: str = ''
db: str = ''
def __init__(self, *args, **kwargs):
"""Ensure that the required properties are set."""
super().__init__(*args, **kwargs)
if self.name == '':
raise NotImplementedError('ref must be set')
if self.db == '':
raise NotImplementedError('db must be set')
def create_defaults(self):
"""Function that creates all default templates for the app."""
raise NotImplementedError('create_defaults must be implemented')
def get_src_dir(self, ref_name):
"""Get the source directory for the default templates."""
raise NotImplementedError('get_src_dir must be implemented')
def get_new_obj_data(self, data, filename):
"""Get the data for a new template db object."""
raise NotImplementedError('get_new_obj_data must be implemented')
# Standardized code
def ready(self):
"""This function is called whenever the app is loaded."""
import InvenTree.ready
# skip loading if plugin registry is not loaded or we run in a background thread
if (
not InvenTree.ready.isPluginRegistryLoaded()
or not InvenTree.ready.isInMainThread()
):
return
if not InvenTree.ready.canAppAccessDatabase(allow_test=False):
return # pragma: no cover
with maintenance_mode_on():
try:
self.create_defaults()
except (
AppRegistryNotReady,
IntegrityError,
OperationalError,
ProgrammingError,
):
# Database might not yet be ready
warnings.warn(
f'Database was not ready for creating {self.name}s', stacklevel=2
)
set_maintenance_mode(False)
def create_template_dir(self, model, data):
"""Create folder and database entries for the default templates, if they do not already exist."""
ref_name = model.getSubdir()
# Create root dir for templates
src_dir = self.get_src_dir(ref_name)
ensure_dir(Path(self.name, 'inventree', ref_name), default_storage)
# Copy each template across (if required)
for entry in data:
self.create_template_file(model, src_dir, entry, ref_name)
def create_template_file(self, model, src_dir, data, ref_name):
"""Ensure a label template is in place."""
# Destination filename
filename = Path(self.name, 'inventree', ref_name, data['file'])
src_file = src_dir.joinpath(data['file'])
do_copy = False
if not default_storage.exists(filename):
logger.info("%s template '%s' is not present", self.name, filename)
do_copy = True
else:
# Check if the file contents are different
src_hash = InvenTree.helpers.hash_file(src_file)
dst_hash = InvenTree.helpers.hash_file(filename, default_storage)
if src_hash != dst_hash:
logger.info("Hash differs for '%s'", filename)
do_copy = True
if do_copy:
logger.info("Copying %s template '%s'", self.name, filename)
# Ensure destination dir exists
ensure_dir(filename.parent, default_storage)
# Copy file
default_storage.save(filename, src_file.open('rb'))
# Check if a file matching the template already exists
try:
if model.objects.filter(**{self.db: filename}).exists():
return # pragma: no cover
except Exception:
logger.exception(
"Failed to query %s for '%s' - you should run 'invoke update' first!",
self.name,
filename,
)
logger.info("Creating entry for %s '%s'", model, data.get('name'))
try:
model.objects.create(**self.get_new_obj_data(data, str(filename)))
except Exception as _e:
logger.warning(
"Failed to create %s '%s' with error '%s'", self.name, data['name'], _e
)

View File

@ -1,17 +0,0 @@
"""Admin functionality for the 'label' app."""
from django.contrib import admin
import label.models
class LabelAdmin(admin.ModelAdmin):
"""Admin class for the various label models."""
list_display = ('name', 'description', 'label', 'filters', 'enabled')
admin.site.register(label.models.StockItemLabel, LabelAdmin)
admin.site.register(label.models.StockLocationLabel, LabelAdmin)
admin.site.register(label.models.PartLabel, LabelAdmin)
admin.site.register(label.models.BuildLineLabel, LabelAdmin)

View File

@ -1,504 +0,0 @@
"""API functionality for the 'label' app."""
from django.core.exceptions import FieldError, ValidationError
from django.http import JsonResponse
from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_page, never_cache
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import serializers
from rest_framework.exceptions import NotFound
from rest_framework.request import clone_request
import build.models
import common.models
import InvenTree.exceptions
import InvenTree.helpers
import label.models
import label.serializers
from InvenTree.api import MetadataView
from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListCreateAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
from part.models import Part
from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin
from plugin.registry import registry
from stock.models import StockItem, StockLocation
class LabelFilterMixin:
"""Mixin for filtering a queryset by a list of object ID values.
Each implementing class defines a database model to lookup,
and a "key" (query parameter) for providing a list of ID (PK) values.
This mixin defines a 'get_items' method which provides a generic
implementation to return a list of matching database model instances.
"""
# Database model for instances to actually be "printed" against this label template
ITEM_MODEL = None
# Default key for looking up database model instances
ITEM_KEY = 'item'
def get_items(self):
"""Return a list of database objects from query parameter."""
ids = []
# Construct a list of possible query parameter value options
# e.g. if self.ITEM_KEY = 'part' -> ['part', 'part[]', 'parts', parts[]']
for k in [self.ITEM_KEY + x for x in ['', '[]', 's', 's[]']]:
if ids := self.request.query_params.getlist(k, []):
# Return the first list of matches
break
# Next we must validate each provided object ID
valid_ids = []
for id in ids:
try:
valid_ids.append(int(id))
except ValueError:
pass
# Filter queryset by matching ID values
return self.ITEM_MODEL.objects.filter(pk__in=valid_ids)
class LabelListView(LabelFilterMixin, ListCreateAPI):
"""Generic API class for label templates."""
def filter_queryset(self, queryset):
"""Filter the queryset based on the provided label ID values.
As each 'label' instance may optionally define its own filters,
the resulting queryset is the 'union' of the two.
"""
queryset = super().filter_queryset(queryset)
items = self.get_items()
if len(items) > 0:
"""
At this point, we are basically forced to be inefficient,
as we need to compare the 'filters' string of each label,
and see if it matches against each of the requested items.
TODO: In the future, if this becomes excessively slow, it
will need to be readdressed.
"""
valid_label_ids = set()
for lbl in queryset.all():
matches = True
try:
filters = InvenTree.helpers.validateFilterString(lbl.filters)
except ValidationError:
continue
for item in items:
item_query = self.ITEM_MODEL.objects.filter(pk=item.pk)
try:
if not item_query.filter(**filters).exists():
matches = False
break
except FieldError:
matches = False
break
# Matched all items
if matches:
valid_label_ids.add(lbl.pk)
else:
continue
# Reduce queryset to only valid matches
queryset = queryset.filter(pk__in=list(valid_label_ids))
return queryset
filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter]
filterset_fields = ['enabled']
search_fields = ['name', 'description']
@method_decorator(cache_page(5), name='dispatch')
class LabelPrintMixin(LabelFilterMixin):
"""Mixin for printing labels."""
rolemap = {'GET': 'view', 'POST': 'view'}
def check_permissions(self, request):
"""Override request method to GET so that also non superusers can print using a post request."""
if request.method == 'POST':
request = clone_request(request, 'GET')
return super().check_permissions(request)
@method_decorator(never_cache)
def dispatch(self, *args, **kwargs):
"""Prevent caching when printing report templates."""
return super().dispatch(*args, **kwargs)
def get_serializer(self, *args, **kwargs):
"""Define a get_serializer method to be discoverable by the OPTIONS request."""
# Check the request to determine if the user has selected a label printing plugin
plugin = self.get_plugin(self.request)
kwargs.setdefault('context', self.get_serializer_context())
serializer = plugin.get_printing_options_serializer(
self.request, *args, **kwargs
)
# if no serializer is defined, return an empty serializer
if not serializer:
return serializers.Serializer()
return serializer
def get(self, request, *args, **kwargs):
"""Perform a GET request against this endpoint to print labels."""
common.models.InvenTreeUserSetting.set_setting(
'DEFAULT_' + self.ITEM_KEY.upper() + '_LABEL_TEMPLATE',
self.get_object().pk,
None,
user=request.user,
)
return self.print(request, self.get_items())
def post(self, request, *args, **kwargs):
"""Perform a GET request against this endpoint to print labels."""
return self.get(request, *args, **kwargs)
def get_plugin(self, request):
"""Return the label printing plugin associated with this request.
This is provided in the url, e.g. ?plugin=myprinter
Requires:
- settings.PLUGINS_ENABLED is True
- matching plugin can be found
- matching plugin implements the 'labels' mixin
- matching plugin is enabled
"""
plugin_key = request.query_params.get('plugin', None)
# No plugin provided!
if plugin_key is None:
# Default to the builtin label printing plugin
plugin_key = InvenTreeLabelPlugin.NAME.lower()
plugin = registry.get_plugin(plugin_key)
if not plugin:
raise NotFound(f"Plugin '{plugin_key}' not found")
if not plugin.is_active():
raise ValidationError(f"Plugin '{plugin_key}' is not enabled")
if not plugin.mixin_enabled('labels'):
raise ValidationError(
f"Plugin '{plugin_key}' is not a label printing plugin"
)
# Only return the plugin if it is enabled and has the label printing mixin
return plugin
def print(self, request, items_to_print):
"""Print this label template against a number of pre-validated items."""
# Check the request to determine if the user has selected a label printing plugin
plugin = self.get_plugin(request)
if len(items_to_print) == 0:
# No valid items provided, return an error message
raise ValidationError('No valid objects provided to label template')
# Label template
label = self.get_object()
# Check the label dimensions
if label.width <= 0 or label.height <= 0:
raise ValidationError('Label has invalid dimensions')
# if the plugin returns a serializer, validate the data
if serializer := plugin.get_printing_options_serializer(
request, data=request.data, context=self.get_serializer_context()
):
serializer.is_valid(raise_exception=True)
# At this point, we offload the label(s) to the selected plugin.
# The plugin is responsible for handling the request and returning a response.
try:
result = plugin.print_labels(
label,
items_to_print,
request,
printing_options=(serializer.data if serializer else {}),
)
except ValidationError as e:
raise (e)
except Exception as e:
raise ValidationError([_('Error printing label'), str(e)])
if isinstance(result, JsonResponse):
result['plugin'] = plugin.plugin_slug()
return result
raise ValidationError(
f"Plugin '{plugin.plugin_slug()}' returned invalid response type '{type(result)}'"
)
class StockItemLabelMixin:
"""Mixin for StockItemLabel endpoints."""
queryset = label.models.StockItemLabel.objects.all()
serializer_class = label.serializers.StockItemLabelSerializer
ITEM_MODEL = StockItem
ITEM_KEY = 'item'
class StockItemLabelList(StockItemLabelMixin, LabelListView):
"""API endpoint for viewing list of StockItemLabel objects.
Filterable by:
- enabled: Filter by enabled / disabled status
- item: Filter by single stock item
- items: Filter by list of stock items
"""
pass
class StockItemLabelDetail(StockItemLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single StockItemLabel object."""
pass
class StockItemLabelPrint(StockItemLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockItemLabel object."""
pass
class StockLocationLabelMixin:
"""Mixin for StockLocationLabel endpoints."""
queryset = label.models.StockLocationLabel.objects.all()
serializer_class = label.serializers.StockLocationLabelSerializer
ITEM_MODEL = StockLocation
ITEM_KEY = 'location'
class StockLocationLabelList(StockLocationLabelMixin, LabelListView):
"""API endpoint for viewiing list of StockLocationLabel objects.
Filterable by:
- enabled: Filter by enabled / disabled status
- location: Filter by a single stock location
- locations: Filter by list of stock locations
"""
pass
class StockLocationLabelDetail(StockLocationLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single StockLocationLabel object."""
pass
class StockLocationLabelPrint(StockLocationLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockLocationLabel object."""
pass
class PartLabelMixin:
"""Mixin for PartLabel endpoints."""
queryset = label.models.PartLabel.objects.all()
serializer_class = label.serializers.PartLabelSerializer
ITEM_MODEL = Part
ITEM_KEY = 'part'
class PartLabelList(PartLabelMixin, LabelListView):
"""API endpoint for viewing list of PartLabel objects."""
pass
class PartLabelDetail(PartLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single PartLabel object."""
pass
class PartLabelPrint(PartLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a PartLabel object."""
pass
class BuildLineLabelMixin:
"""Mixin class for BuildLineLabel endpoints."""
queryset = label.models.BuildLineLabel.objects.all()
serializer_class = label.serializers.BuildLineLabelSerializer
ITEM_MODEL = build.models.BuildLine
ITEM_KEY = 'line'
class BuildLineLabelList(BuildLineLabelMixin, LabelListView):
"""API endpoint for viewing a list of BuildLineLabel objects."""
pass
class BuildLineLabelDetail(BuildLineLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single BuildLineLabel object."""
pass
class BuildLineLabelPrint(BuildLineLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a BuildLineLabel object."""
pass
label_api_urls = [
# Stock item labels
path(
'stock/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
StockItemLabelPrint.as_view(),
name='api-stockitem-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.StockItemLabel},
name='api-stockitem-label-metadata',
),
path(
'',
StockItemLabelDetail.as_view(),
name='api-stockitem-label-detail',
),
]),
),
# List view
path('', StockItemLabelList.as_view(), name='api-stockitem-label-list'),
]),
),
# Stock location labels
path(
'location/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
StockLocationLabelPrint.as_view(),
name='api-stocklocation-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.StockLocationLabel},
name='api-stocklocation-label-metadata',
),
path(
'',
StockLocationLabelDetail.as_view(),
name='api-stocklocation-label-detail',
),
]),
),
# List view
path(
'',
StockLocationLabelList.as_view(),
name='api-stocklocation-label-list',
),
]),
),
# Part labels
path(
'part/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
PartLabelPrint.as_view(),
name='api-part-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.PartLabel},
name='api-part-label-metadata',
),
path('', PartLabelDetail.as_view(), name='api-part-label-detail'),
]),
),
# List view
path('', PartLabelList.as_view(), name='api-part-label-list'),
]),
),
# BuildLine labels
path(
'buildline/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
BuildLineLabelPrint.as_view(),
name='api-buildline-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.BuildLineLabel},
name='api-buildline-label-metadata',
),
path(
'',
BuildLineLabelDetail.as_view(),
name='api-buildline-label-detail',
),
]),
),
# List view
path('', BuildLineLabelList.as_view(), name='api-buildline-label-list'),
]),
),
]

View File

@ -1,107 +0,0 @@
"""Config options for the label app."""
from pathlib import Path
from django.apps import AppConfig
from generic.templating.apps import TemplatingMixin
class LabelConfig(TemplatingMixin, AppConfig):
"""Configuration class for the "label" app."""
name = 'label'
db = 'label'
def create_defaults(self):
"""Create all default templates."""
# Test if models are ready
try:
import label.models
except Exception: # pragma: no cover
# Database is not ready yet
return
assert bool(label.models.StockLocationLabel is not None)
# Create the categories
self.create_template_dir(
label.models.StockItemLabel,
[
{
'file': 'qr.html',
'name': 'QR Code',
'description': 'Simple QR code label',
'width': 24,
'height': 24,
}
],
)
self.create_template_dir(
label.models.StockLocationLabel,
[
{
'file': 'qr.html',
'name': 'QR Code',
'description': 'Simple QR code label',
'width': 24,
'height': 24,
},
{
'file': 'qr_and_text.html',
'name': 'QR and text',
'description': 'Label with QR code and name of location',
'width': 50,
'height': 24,
},
],
)
self.create_template_dir(
label.models.PartLabel,
[
{
'file': 'part_label.html',
'name': 'Part Label',
'description': 'Simple part label',
'width': 70,
'height': 24,
},
{
'file': 'part_label_code128.html',
'name': 'Barcode Part Label',
'description': 'Simple part label with Code128 barcode',
'width': 70,
'height': 24,
},
],
)
self.create_template_dir(
label.models.BuildLineLabel,
[
{
'file': 'buildline_label.html',
'name': 'Build Line Label',
'description': 'Example build line label',
'width': 125,
'height': 48,
}
],
)
def get_src_dir(self, ref_name):
"""Get the source directory."""
return Path(__file__).parent.joinpath('templates', self.name, ref_name)
def get_new_obj_data(self, data, filename):
"""Get the data for a new template db object."""
return {
'name': data['name'],
'description': data['description'],
'label': filename,
'filters': '',
'enabled': True,
'width': data['width'],
'height': data['height'],
}

View File

@ -1,30 +0,0 @@
# Generated by Django 3.0.7 on 2020-08-15 23:27
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='StockItemLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Label name', max_length=100, unique=True)),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True)),
('label', models.FileField(help_text='Label template file', upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])])),
('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString])),
],
options={
'abstract': False,
},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.0.7 on 2020-08-22 23:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='stockitemlabel',
name='enabled',
field=models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled'),
),
]

View File

@ -1,30 +0,0 @@
# Generated by Django 3.0.7 on 2021-01-08 12:06
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0002_stockitemlabel_enabled'),
]
operations = [
migrations.CreateModel(
name='StockLocationLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Label name', max_length=100, unique=True)),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True)),
('label', models.FileField(help_text='Label template file', upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])])),
('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString])),
('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')),
],
options={
'abstract': False,
},
),
]

View File

@ -1,56 +0,0 @@
# Generated by Django 3.0.7 on 2021-01-11 12:02
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0003_stocklocationlabel'),
]
operations = [
migrations.AlterField(
model_name='stockitemlabel',
name='description',
field=models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='label',
field=models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='name',
field=models.CharField(help_text='Label name', max_length=100, verbose_name='Name'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='description',
field=models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='label',
field=models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='name',
field=models.CharField(help_text='Label name', max_length=100, verbose_name='Name'),
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 3.0.7 on 2021-01-13 12:02
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0004_auto_20210111_2302'),
]
operations = [
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[label.models.validate_stock_location_filters], verbose_name='Filters'),
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 3.0.7 on 2021-02-22 04:35
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0005_auto_20210113_2302'),
]
operations = [
migrations.AddField(
model_name='stockitemlabel',
name='height',
field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'),
),
migrations.AddField(
model_name='stockitemlabel',
name='width',
field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='height',
field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='width',
field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 3.2 on 2021-05-13 03:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0006_auto_20210222_1535'),
]
operations = [
migrations.AddField(
model_name='stockitemlabel',
name='filename_pattern',
field=models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='filename_pattern',
field=models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern'),
),
]

View File

@ -1,37 +0,0 @@
# Generated by Django 3.2.4 on 2021-07-08 11:06
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0007_auto_20210513_1327'),
]
operations = [
migrations.CreateModel(
name='PartLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Label name', max_length=100, verbose_name='Name')),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description')),
('label', models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label')),
('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')),
('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')),
('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')),
('filename_pattern', models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern')),
('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated value of key=value pairs)', max_length=250, validators=[label.models.validate_part_filters], verbose_name='Filters')),
],
options={
'abstract': False,
},
),
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs),', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'),
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 3.2.18 on 2023-03-17 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0008_auto_20210708_2106'),
]
operations = [
migrations.AddField(
model_name='partlabel',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='stockitemlabel',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -1,33 +0,0 @@
# Generated by Django 3.2.19 on 2023-06-13 11:10
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0009_auto_20230317_0816'),
]
operations = [
migrations.CreateModel(
name='BuildLineLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
('name', models.CharField(help_text='Label name', max_length=100, verbose_name='Name')),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description')),
('label', models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label')),
('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')),
('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')),
('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')),
('filename_pattern', models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern')),
('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_build_line_filters], verbose_name='Filters')),
],
options={
'abstract': False,
},
),
]

View File

@ -1,29 +0,0 @@
# Generated by Django 3.2.19 on 2023-06-23 21:58
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0010_buildlinelabel'),
]
operations = [
migrations.AlterField(
model_name='partlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_part_filters], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_stock_location_filters], verbose_name='Filters'),
),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 3.2.20 on 2023-07-14 11:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import label.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('label', '0011_auto_20230623_2158'),
]
operations = [
migrations.CreateModel(
name='LabelOutput',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('label', models.FileField(unique=True, upload_to=label.models.rename_label_output)),
('created', models.DateField(auto_now_add=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,429 +0,0 @@
"""Label printing models."""
import logging
import os
import sys
from django.conf import settings
from django.contrib.auth.models import User
from django.core.validators import FileExtensionValidator, MinValueValidator
from django.db import models
from django.template import Context, Template
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import build.models
import InvenTree.helpers
import InvenTree.models
import part.models
import stock.models
from InvenTree.helpers import normalize, validateFilterString
from InvenTree.helpers_model import get_base_url
from plugin.registry import registry
try:
from django_weasyprint import WeasyTemplateResponseMixin
except OSError as err: # pragma: no cover
print(f'OSError: {err}')
print('You may require some further system packages to be installed.')
sys.exit(1)
logger = logging.getLogger('inventree')
def rename_label(instance, filename):
"""Place the label file into the correct subdirectory."""
filename = os.path.basename(filename)
return os.path.join('label', 'template', instance.SUBDIR, filename)
def rename_label_output(instance, filename):
"""Place the label output file into the correct subdirectory."""
filename = os.path.basename(filename)
return os.path.join('label', 'output', filename)
def validate_stock_item_filters(filters):
"""Validate query filters for the StockItemLabel model."""
filters = validateFilterString(filters, model=stock.models.StockItem)
return filters
def validate_stock_location_filters(filters):
"""Validate query filters for the StockLocationLabel model."""
filters = validateFilterString(filters, model=stock.models.StockLocation)
return filters
def validate_part_filters(filters):
"""Validate query filters for the PartLabel model."""
filters = validateFilterString(filters, model=part.models.Part)
return filters
def validate_build_line_filters(filters):
"""Validate query filters for the BuildLine model."""
filters = validateFilterString(filters, model=build.models.BuildLine)
return filters
class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
"""Class for rendering a label to a PDF."""
pdf_filename = 'label.pdf'
pdf_attachment = True
def __init__(self, request, template, **kwargs):
"""Initialize a label mixin with certain properties."""
self.request = request
self.template_name = template
self.pdf_filename = kwargs.get('filename', 'label.pdf')
class LabelTemplate(InvenTree.models.InvenTreeMetadataModel):
"""Base class for generic, filterable labels."""
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
@classmethod
def getSubdir(cls) -> str:
"""Return the subdirectory for this label."""
return cls.SUBDIR
# Each class of label files will be stored in a separate subdirectory
SUBDIR: str = 'label'
# Object we will be printing against (will be filled out later)
object_to_print = None
@property
def template(self):
"""Return the file path of the template associated with this label instance."""
return self.label.path
def __str__(self):
"""Format a string representation of a label instance."""
return f'{self.name} - {self.description}'
name = models.CharField(
blank=False, max_length=100, verbose_name=_('Name'), help_text=_('Label name')
)
description = models.CharField(
max_length=250,
blank=True,
null=True,
verbose_name=_('Description'),
help_text=_('Label description'),
)
label = models.FileField(
upload_to=rename_label,
unique=True,
blank=False,
null=False,
verbose_name=_('Label'),
help_text=_('Label template file'),
validators=[FileExtensionValidator(allowed_extensions=['html'])],
)
enabled = models.BooleanField(
default=True,
verbose_name=_('Enabled'),
help_text=_('Label template is enabled'),
)
width = models.FloatField(
default=50,
verbose_name=_('Width [mm]'),
help_text=_('Label width, specified in mm'),
validators=[MinValueValidator(2)],
)
height = models.FloatField(
default=20,
verbose_name=_('Height [mm]'),
help_text=_('Label height, specified in mm'),
validators=[MinValueValidator(2)],
)
filename_pattern = models.CharField(
default='label.pdf',
verbose_name=_('Filename Pattern'),
help_text=_('Pattern for generating label filenames'),
max_length=100,
)
@property
def template_name(self):
"""Returns the file system path to the template file.
Required for passing the file to an external process
"""
template = self.label.name
template = template.replace('/', os.path.sep)
template = template.replace('\\', os.path.sep)
template = settings.MEDIA_ROOT.joinpath(template)
return template
def get_context_data(self, request):
"""Supply custom context data to the template for rendering.
Note: Override this in any subclass
"""
return {} # pragma: no cover
def generate_filename(self, request, **kwargs):
"""Generate a filename for this label."""
template_string = Template(self.filename_pattern)
ctx = self.context(request)
context = Context(ctx)
return template_string.render(context)
def generate_page_style(self, **kwargs):
"""Generate @page style for the label template.
This is inserted at the top of the style block for a given label
"""
width = kwargs.get('width', self.width)
height = kwargs.get('height', self.height)
margin = kwargs.get('margin', 0)
return f"""
@page {{
size: {width}mm {height}mm;
margin: {margin}mm;
}}
"""
def context(self, request, **kwargs):
"""Provides context data to the template.
Arguments:
request: The HTTP request object
kwargs: Additional keyword arguments
"""
context = self.get_context_data(request)
# By default, each label is supplied with '@page' data
# However, it can be excluded, e.g. when rendering a label sheet
if kwargs.get('insert_page_style', True):
context['page_style'] = self.generate_page_style()
# Add "basic" context data which gets passed to every label
context['base_url'] = get_base_url(request=request)
context['date'] = InvenTree.helpers.current_date()
context['datetime'] = InvenTree.helpers.current_time()
context['request'] = request
context['user'] = request.user
context['width'] = self.width
context['height'] = self.height
# Pass the context through to any registered plugins
plugins = registry.with_mixin('report')
for plugin in plugins:
# Let each plugin add its own context data
plugin.add_label_context(self, self.object_to_print, request, context)
return context
def render_as_string(self, request, target_object=None, **kwargs):
"""Render the label to a HTML string."""
if target_object:
self.object_to_print = target_object
context = self.context(request, **kwargs)
return render_to_string(self.template_name, context, request)
def render(self, request, target_object=None, **kwargs):
"""Render the label template to a PDF file.
Uses django-weasyprint plugin to render HTML template
"""
if target_object:
self.object_to_print = target_object
context = self.context(request, **kwargs)
wp = WeasyprintLabelMixin(
request,
self.template_name,
base_url=request.build_absolute_uri('/'),
presentational_hints=True,
filename=self.generate_filename(request),
**kwargs,
)
return wp.render_to_response(context, **kwargs)
class LabelOutput(models.Model):
"""Class representing a label output file.
'Printing' a label may generate a file object (such as PDF)
which is made available for download.
Future work will offload this task to the background worker,
and provide a 'progress' bar for the user.
"""
# File will be stored in a subdirectory
label = models.FileField(
upload_to=rename_label_output, unique=True, blank=False, null=False
)
# Creation date of label output
created = models.DateField(auto_now_add=True, editable=False)
# User who generated the label
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
class StockItemLabel(LabelTemplate):
"""Template for printing StockItem labels."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the StockItemLabel model."""
return reverse('api-stockitem-label-list') # pragma: no cover
SUBDIR = 'stockitem'
filters = models.CharField(
blank=True,
max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'),
validators=[validate_stock_item_filters],
)
def get_context_data(self, request):
"""Generate context data for each provided StockItem."""
stock_item = self.object_to_print
return {
'item': stock_item,
'part': stock_item.part,
'name': stock_item.part.full_name,
'ipn': stock_item.part.IPN,
'revision': stock_item.part.revision,
'quantity': normalize(stock_item.quantity),
'serial': stock_item.serial,
'barcode_data': stock_item.barcode_data,
'barcode_hash': stock_item.barcode_hash,
'qr_data': stock_item.format_barcode(brief=True),
'qr_url': request.build_absolute_uri(stock_item.get_absolute_url()),
'tests': stock_item.testResultMap(),
'parameters': stock_item.part.parameters_map(),
}
class StockLocationLabel(LabelTemplate):
"""Template for printing StockLocation labels."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the StockLocationLabel model."""
return reverse('api-stocklocation-label-list') # pragma: no cover
SUBDIR = 'stocklocation'
filters = models.CharField(
blank=True,
max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'),
validators=[validate_stock_location_filters],
)
def get_context_data(self, request):
"""Generate context data for each provided StockLocation."""
location = self.object_to_print
return {'location': location, 'qr_data': location.format_barcode(brief=True)}
class PartLabel(LabelTemplate):
"""Template for printing Part labels."""
@staticmethod
def get_api_url():
"""Return the API url associated with the PartLabel model."""
return reverse('api-part-label-list') # pragma: no cover
SUBDIR = 'part'
filters = models.CharField(
blank=True,
max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'),
validators=[validate_part_filters],
)
def get_context_data(self, request):
"""Generate context data for each provided Part object."""
part = self.object_to_print
return {
'part': part,
'category': part.category,
'name': part.name,
'description': part.description,
'IPN': part.IPN,
'revision': part.revision,
'qr_data': part.format_barcode(brief=True),
'qr_url': request.build_absolute_uri(part.get_absolute_url()),
'parameters': part.parameters_map(),
}
class BuildLineLabel(LabelTemplate):
"""Template for printing labels against BuildLine objects."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the BuildLineLabel model."""
return reverse('api-buildline-label-list')
SUBDIR = 'buildline'
filters = models.CharField(
blank=True,
max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'),
validators=[validate_build_line_filters],
)
def get_context_data(self, request):
"""Generate context data for each provided BuildLine object."""
build_line = self.object_to_print
return {
'build_line': build_line,
'build': build_line.build,
'bom_item': build_line.bom_item,
'part': build_line.bom_item.sub_part,
'quantity': build_line.quantity,
'allocated_quantity': build_line.allocated_quantity,
'allocations': build_line.allocations,
}

View File

@ -1,67 +0,0 @@
"""API serializers for the label app."""
import label.models
from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer,
)
class LabelSerializerBase(InvenTreeModelSerializer):
"""Base class for label serializer."""
label = InvenTreeAttachmentSerializerField(required=True)
@staticmethod
def label_fields():
"""Generic serializer fields for a label template."""
return [
'pk',
'name',
'description',
'label',
'filters',
'width',
'height',
'enabled',
]
class StockItemLabelSerializer(LabelSerializerBase):
"""Serializes a StockItemLabel object."""
class Meta:
"""Metaclass options."""
model = label.models.StockItemLabel
fields = LabelSerializerBase.label_fields()
class StockLocationLabelSerializer(LabelSerializerBase):
"""Serializes a StockLocationLabel object."""
class Meta:
"""Metaclass options."""
model = label.models.StockLocationLabel
fields = LabelSerializerBase.label_fields()
class PartLabelSerializer(LabelSerializerBase):
"""Serializes a PartLabel object."""
class Meta:
"""Metaclass options."""
model = label.models.PartLabel
fields = LabelSerializerBase.label_fields()
class BuildLineLabelSerializer(LabelSerializerBase):
"""Serializes a BuildLineLabel object."""
class Meta:
"""Metaclass options."""
model = label.models.BuildLineLabel
fields = LabelSerializerBase.label_fields()

View File

@ -1,15 +0,0 @@
"""Background tasks for the label app."""
from datetime import timedelta
from django.utils import timezone
from InvenTree.tasks import ScheduledTask, scheduled_task
from label.models import LabelOutput
@scheduled_task(ScheduledTask.DAILY)
def cleanup_old_label_outputs():
"""Remove old label outputs from the database."""
# Remove any label outputs which are older than 30 days
LabelOutput.objects.filter(created__lte=timezone.now() - timedelta(days=5)).delete()

View File

@ -1,3 +0,0 @@
{% extends "label/buildline/buildline_label_base.html" %}
<!-- Refer to the buildline_label_base template for further information -->

View File

@ -1,301 +0,0 @@
"""Unit tests for label API."""
import json
from io import StringIO
from django.core.cache import cache
from django.urls import reverse
import label.models as label_models
from build.models import BuildLine
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part
from stock.models import StockItem, StockLocation
class LabelTest(InvenTreeAPITestCase):
"""Base class for unit testing label model API endpoints."""
fixtures = ['category', 'part', 'location', 'stock', 'bom', 'build']
superuser = True
model = None
list_url = None
detail_url = None
metadata_url = None
print_url = None
print_itemname = None
print_itemmodel = None
def setUp(self):
"""Ensure cache is cleared as part of test setup."""
cache.clear()
return super().setUp()
def test_api_url(self):
"""Test returned API Url against URL tag defined in this file."""
if not self.list_url:
return
self.assertEqual(reverse(self.list_url), self.model.get_api_url())
def test_list_endpoint(self):
"""Test that the LIST endpoint works for each model."""
if not self.list_url:
return
url = reverse(self.list_url)
response = self.get(url)
self.assertEqual(response.status_code, 200)
labels = self.model.objects.all()
n = len(labels)
# API endpoint must return correct number of reports
self.assertEqual(len(response.data), n)
# Filter by "enabled" status
response = self.get(url, {'enabled': True})
self.assertEqual(len(response.data), n)
response = self.get(url, {'enabled': False})
self.assertEqual(len(response.data), 0)
# Filter by "enabled" status
response = self.get(url, {'enabled': True})
self.assertEqual(len(response.data), 0)
response = self.get(url, {'enabled': False})
self.assertEqual(len(response.data), n)
def test_create_endpoint(self):
"""Test that creating a new report works for each label."""
if not self.list_url:
return
url = reverse(self.list_url)
# Create a new label
# Django REST API "APITestCase" does not work like requests - to send a file without it existing on disk,
# create it as a StringIO object, and upload it under parameter template
filestr = StringIO(
'{% extends "label/label_base.html" %}{% block content %}<pre>TEST LABEL</pre>{% endblock content %}'
)
filestr.name = 'ExampleTemplate.html'
response = self.post(
url,
data={
'name': 'New label',
'description': 'A fancy new label created through API test',
'label': filestr,
},
format=None,
expected_code=201,
)
# Make sure the expected keys are in the response
self.assertIn('pk', response.data)
self.assertIn('name', response.data)
self.assertIn('description', response.data)
self.assertIn('label', response.data)
self.assertIn('filters', response.data)
self.assertIn('enabled', response.data)
self.assertEqual(response.data['name'], 'New label')
self.assertEqual(
response.data['description'], 'A fancy new label created through API test'
)
self.assertEqual(response.data['label'].count('ExampleTemplate'), 1)
def test_detail_endpoint(self):
"""Test that the DETAIL endpoint works for each label."""
if not self.detail_url:
return
# Create an item first
self.test_create_endpoint()
labels = self.model.objects.all()
n = len(labels)
# Make sure at least one report defined
self.assertGreaterEqual(n, 1)
# Check detail page for first report
response = self.get(
reverse(self.detail_url, kwargs={'pk': labels[0].pk}), expected_code=200
)
# Make sure the expected keys are in the response
self.assertIn('pk', response.data)
self.assertIn('name', response.data)
self.assertIn('description', response.data)
self.assertIn('label', response.data)
self.assertIn('filters', response.data)
self.assertIn('enabled', response.data)
filestr = StringIO(
'{% extends "label/label_base.html" %}{% block content %}<pre>TEST LABEL</pre>{% endblock content %}'
)
filestr.name = 'ExampleTemplate_Updated.html'
# Check PATCH method
response = self.patch(
reverse(self.detail_url, kwargs={'pk': labels[0].pk}),
{
'name': 'Changed name during test',
'description': 'New version of the template',
'label': filestr,
},
format=None,
expected_code=200,
)
# Make sure the expected keys are in the response
self.assertIn('pk', response.data)
self.assertIn('name', response.data)
self.assertIn('description', response.data)
self.assertIn('label', response.data)
self.assertIn('filters', response.data)
self.assertIn('enabled', response.data)
self.assertEqual(response.data['name'], 'Changed name during test')
self.assertEqual(response.data['description'], 'New version of the template')
self.assertEqual(response.data['label'].count('ExampleTemplate_Updated'), 1)
def test_delete(self):
"""Test deleting, after other test are done."""
if not self.detail_url:
return
# Create an item first
self.test_create_endpoint()
labels = self.model.objects.all()
n = len(labels)
# Make sure at least one label defined
self.assertGreaterEqual(n, 1)
# Delete the last report
self.delete(
reverse(self.detail_url, kwargs={'pk': labels[n - 1].pk}), expected_code=204
)
def test_print_label(self):
"""Test printing a label."""
if not self.print_url:
return
# Create an item first
self.test_create_endpoint()
labels = self.model.objects.all()
n = len(labels)
# Make sure at least one label defined
self.assertGreaterEqual(n, 1)
url = reverse(self.print_url, kwargs={'pk': labels[0].pk})
# Try to print without providing a valid item
self.get(url, expected_code=400)
# Try to print with an invalid item
self.get(url, {self.print_itemname: 9999}, expected_code=400)
# Now print with a valid item
print(f'{self.print_itemmodel = }')
print(f'{self.print_itemmodel.objects.all() = }')
item = self.print_itemmodel.objects.first()
self.assertIsNotNone(item)
response = self.get(url, {self.print_itemname: item.pk}, expected_code=200)
response_json = json.loads(response.content.decode('utf-8'))
self.assertIn('file', response_json)
self.assertIn('success', response_json)
self.assertIn('message', response_json)
self.assertTrue(response_json['success'])
def test_metadata_endpoint(self):
"""Unit tests for the metadata field."""
if not self.metadata_url:
return
# Create an item first
self.test_create_endpoint()
labels = self.model.objects.all()
n = len(labels)
# Make sure at least one label defined
self.assertGreaterEqual(n, 1)
# Test getting metadata
response = self.get(
reverse(self.metadata_url, kwargs={'pk': labels[0].pk}), expected_code=200
)
self.assertEqual(response.data, {'metadata': {}})
class TestStockItemLabel(LabelTest):
"""Unit testing class for the StockItemLabel model."""
model = label_models.StockItemLabel
list_url = 'api-stockitem-label-list'
detail_url = 'api-stockitem-label-detail'
metadata_url = 'api-stockitem-label-metadata'
print_url = 'api-stockitem-label-print'
print_itemname = 'item'
print_itemmodel = StockItem
class TestStockLocationLabel(LabelTest):
"""Unit testing class for the StockLocationLabel model."""
model = label_models.StockLocationLabel
list_url = 'api-stocklocation-label-list'
detail_url = 'api-stocklocation-label-detail'
metadata_url = 'api-stocklocation-label-metadata'
print_url = 'api-stocklocation-label-print'
print_itemname = 'location'
print_itemmodel = StockLocation
class TestPartLabel(LabelTest):
"""Unit testing class for the PartLabel model."""
model = label_models.PartLabel
list_url = 'api-part-label-list'
detail_url = 'api-part-label-detail'
metadata_url = 'api-part-label-metadata'
print_url = 'api-part-label-print'
print_itemname = 'part'
print_itemmodel = Part
class TestBuildLineLabel(LabelTest):
"""Unit testing class for the BuildLine model."""
model = label_models.BuildLineLabel
list_url = 'api-buildline-label-list'
detail_url = 'api-buildline-label-detail'
metadata_url = 'api-buildline-label-metadata'
print_url = 'api-buildline-label-print'
print_itemname = 'line'
print_itemmodel = BuildLine

View File

@ -1,166 +0,0 @@
"""Tests for labels."""
import io
import json
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.http import JsonResponse
from django.urls import reverse
from common.models import InvenTreeSetting
from InvenTree.helpers import validateFilterString
from InvenTree.unit_test import InvenTreeAPITestCase
from label.models import LabelOutput
from part.models import Part
from plugin.registry import registry
from stock.models import StockItem
from .models import PartLabel, StockItemLabel, StockLocationLabel
class LabelTest(InvenTreeAPITestCase):
"""Unit test class for label models."""
fixtures = ['category', 'part', 'location', 'stock']
@classmethod
def setUpTestData(cls):
"""Ensure that some label instances exist as part of init routine."""
super().setUpTestData()
apps.get_app_config('label').create_defaults()
def test_default_labels(self):
"""Test that the default label templates are copied across."""
labels = StockItemLabel.objects.all()
self.assertGreater(labels.count(), 0)
labels = StockLocationLabel.objects.all()
self.assertGreater(labels.count(), 0)
def test_default_files(self):
"""Test that label files exist in the MEDIA directory."""
def test_subdir(ref_name):
item_dir = settings.MEDIA_ROOT.joinpath('label', 'inventree', ref_name)
self.assertGreater(len([item_dir.iterdir()]), 0)
test_subdir('stockitem')
test_subdir('stocklocation')
test_subdir('part')
def test_filters(self):
"""Test the label filters."""
filter_string = 'part__pk=10'
filters = validateFilterString(filter_string, model=StockItem)
self.assertEqual(type(filters), dict)
bad_filter_string = 'part_pk=10'
with self.assertRaises(ValidationError):
validateFilterString(bad_filter_string, model=StockItem)
def test_label_rendering(self):
"""Test label rendering."""
labels = PartLabel.objects.all()
part = Part.objects.first()
for label in labels:
url = reverse('api-part-label-print', kwargs={'pk': label.pk})
# Check that label printing returns the correct response type
response = self.get(f'{url}?parts={part.pk}', expected_code=200)
self.assertIsInstance(response, JsonResponse)
data = json.loads(response.content)
self.assertIn('message', data)
self.assertIn('file', data)
label_file = data['file']
self.assertIn('/media/label/output/', label_file)
def test_print_part_label(self):
"""Actually 'print' a label, and ensure that the correct information is contained."""
label_data = """
{% load barcode %}
{% load report %}
<html>
<!-- Test that the part instance is supplied -->
part: {{ part.pk }} - {{ part.name }}
<!-- Test qr data -->
data: {{ qr_data|safe }}
<!-- Test InvenTree URL -->
url: {{ qr_url|safe }}
<!-- Test image URL generation -->
image: {% part_image part width=128 %}
<!-- Test InvenTree logo -->
logo: {% logo_image %}
</html>
"""
buffer = io.StringIO()
buffer.write(label_data)
template = ContentFile(buffer.getvalue(), 'label.html')
# Construct a label template
label = PartLabel.objects.create(
name='test', description='Test label', enabled=True, label=template
)
# Ensure we are in "debug" mode (so the report is generated as HTML)
InvenTreeSetting.set_setting('REPORT_ENABLE', True, None)
# Set the 'debug' setting for the plugin
plugin = registry.get_plugin('inventreelabel')
plugin.set_setting('DEBUG', True)
# Print via the API (Note: will default to the builtin plugin if no plugin supplied)
url = reverse('api-part-label-print', kwargs={'pk': label.pk})
prt = Part.objects.first()
part_pk = prt.pk
part_name = prt.name
response = self.get(f'{url}?parts={part_pk}', expected_code=200)
data = json.loads(response.content)
self.assertIn('file', data)
# Find the generated file
output = LabelOutput.objects.last()
# Open the file and read data
with open(output.label.path, 'r') as f:
content = f.read()
# Test that each element has been rendered correctly
self.assertIn(f'part: {part_pk} - {part_name}', content)
self.assertIn(f'data: {{"part": {part_pk}}}', content)
if settings.ENABLE_CLASSIC_FRONTEND:
self.assertIn(f'http://testserver/part/{part_pk}/', content)
# Check that a encoded image has been generated
self.assertIn('data:image/png;charset=utf-8;base64,', content)
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [StockItemLabel, StockLocationLabel, PartLabel]:
p = model.objects.first()
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
# Test update via the set_metadata() method
p.set_metadata('test', 3)
self.assertEqual(p.get_metadata('test'), 3)
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)

View File

@ -2,6 +2,7 @@
from typing import Union, cast
from django.db import models
from django.db.models.query import QuerySet
from django.http import HttpResponse, JsonResponse
from django.utils.translation import gettext_lazy as _
@ -10,10 +11,10 @@ from PIL.Image import Image
from rest_framework import serializers
from rest_framework.request import Request
from label.models import LabelTemplate
from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
from plugin import registry as plg_registry
from plugin.base.label.mixins import LabelItemType, LabelPrintingMixin
from plugin.base.label.mixins import LabelPrintingMixin
from report.models import LabelTemplate
from stock.models import StockLocation
@ -32,7 +33,7 @@ class LabelPrinterBaseDriver(BaseDriver):
self,
machine: 'LabelPrinterMachine',
label: LabelTemplate,
item: LabelItemType,
item: models.Model,
request: Request,
**kwargs,
) -> None:
@ -56,7 +57,7 @@ class LabelPrinterBaseDriver(BaseDriver):
self,
machine: 'LabelPrinterMachine',
label: LabelTemplate,
items: QuerySet[LabelItemType],
items: QuerySet,
request: Request,
**kwargs,
) -> Union[None, JsonResponse]:
@ -83,7 +84,7 @@ class LabelPrinterBaseDriver(BaseDriver):
self.print_label(machine, label, item, request, **kwargs)
def get_printers(
self, label: LabelTemplate, items: QuerySet[LabelItemType], **kwargs
self, label: LabelTemplate, items: QuerySet, **kwargs
) -> list['LabelPrinterMachine']:
"""Get all printers that would be available to print this job.
@ -122,7 +123,7 @@ class LabelPrinterBaseDriver(BaseDriver):
return cast(LabelPrintingMixin, plg)
def render_to_pdf(
self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
) -> HttpResponse:
"""Helper method to render a label to PDF format for a specific item.
@ -137,7 +138,7 @@ class LabelPrinterBaseDriver(BaseDriver):
return response
def render_to_pdf_data(
self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
) -> bytes:
"""Helper method to render a label to PDF and return it as bytes for a specific item.
@ -153,7 +154,7 @@ class LabelPrinterBaseDriver(BaseDriver):
)
def render_to_html(
self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
) -> str:
"""Helper method to render a label to HTML format for a specific item.
@ -168,7 +169,7 @@ class LabelPrinterBaseDriver(BaseDriver):
return html
def render_to_png(
self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
) -> Image:
"""Helper method to render a label to PNG format for a specific item.

View File

@ -10,7 +10,6 @@ from django.urls import reverse
from rest_framework import serializers
from InvenTree.unit_test import InvenTreeAPITestCase
from label.models import PartLabel
from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
from machine.machine_types.label_printer import LabelPrinterBaseDriver
from machine.models import MachineConfig
@ -18,6 +17,7 @@ from machine.registry import registry
from part.models import Part
from plugin.models import PluginConfig
from plugin.registry import registry as plg_registry
from report.models import LabelTemplate
class TestMachineRegistryMixin(TestCase):
@ -247,31 +247,33 @@ class TestLabelPrinterMachineType(TestMachineRegistryMixin, InvenTreeAPITestCase
plugin_ref = 'inventreelabelmachine'
# setup the label app
apps.get_app_config('label').create_defaults() # type: ignore
apps.get_app_config('report').create_default_labels() # type: ignore
plg_registry.reload_plugins()
config = cast(PluginConfig, plg_registry.get_plugin(plugin_ref).plugin_config()) # type: ignore
config.active = True
config.save()
parts = Part.objects.all()[:2]
label = cast(PartLabel, PartLabel.objects.first())
template = LabelTemplate.objects.filter(enabled=True, model_type='part').first()
url = reverse('api-part-label-print', kwargs={'pk': label.pk})
url += f'/?plugin={plugin_ref}&part[]={parts[0].pk}&part[]={parts[1].pk}'
url = reverse('api-label-print')
self.post(
url,
{
'plugin': config.key,
'items': [a.pk for a in parts],
'template': template.pk,
'machine': str(self.machine.pk),
'driver_options': {'copies': '1', 'test_option': '2'},
},
expected_code=200,
expected_code=201,
)
# test the print labels method call
self.print_labels.assert_called_once()
self.assertEqual(self.print_labels.call_args.args[0], self.machine.machine)
self.assertEqual(self.print_labels.call_args.args[1], label)
self.assertEqual(self.print_labels.call_args.args[1], template)
# TODO re-activate test
# self.assertQuerySetEqual(

View File

@ -30,6 +30,7 @@ class Migration(migrations.Migration):
],
options={
'abstract': False,
'verbose_name': 'Purchase Order'
},
),
migrations.CreateModel(

View File

@ -35,6 +35,7 @@ class Migration(migrations.Migration):
],
options={
'abstract': False,
'verbose_name': 'Sales Order',
},
),
migrations.AlterField(

View File

@ -39,6 +39,7 @@ class Migration(migrations.Migration):
],
options={
'abstract': False,
'verbose_name': 'Return Order',
},
),
migrations.AlterField(

View File

@ -30,6 +30,7 @@ import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
import order.validators
import report.mixins
import stock.models
import users.models as UserModels
from common.notifications import InvenTreeNotificationBodies
@ -185,6 +186,7 @@ class Order(
StateTransitionMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.ReferenceIndexingMixin,
InvenTree.models.InvenTreeModel,
@ -246,6 +248,17 @@ class Order(
'contact': _('Contact does not match selected company')
})
def report_context(self):
"""Generate context data for the reporting interface."""
return {
'description': self.description,
'extra_lines': self.extra_lines,
'lines': self.lines,
'order': self,
'reference': self.reference,
'title': str(self),
}
@classmethod
def overdue_filter(cls):
"""A generic implementation of an 'overdue' filter for the Model class.
@ -362,6 +375,15 @@ class PurchaseOrder(TotalPriceMixin, Order):
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE'
class Meta:
"""Model meta options."""
verbose_name = _('Purchase Order')
def report_context(self):
"""Return report context data for this PurchaseOrder."""
return {**super().report_context(), 'supplier': self.supplier}
def get_absolute_url(self):
"""Get the 'web' URL for this order."""
if settings.ENABLE_CLASSIC_FRONTEND:
@ -820,6 +842,15 @@ class SalesOrder(TotalPriceMixin, Order):
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE'
class Meta:
"""Model meta options."""
verbose_name = _('Sales Order')
def report_context(self):
"""Generate report context data for this SalesOrder."""
return {**super().report_context(), 'customer': self.customer}
def get_absolute_url(self):
"""Get the 'web' URL for this order."""
if settings.ENABLE_CLASSIC_FRONTEND:
@ -1977,6 +2008,15 @@ class ReturnOrder(TotalPriceMixin, Order):
REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE'
class Meta:
"""Model meta options."""
verbose_name = _('Return Order')
def report_context(self):
"""Generate report context data for this ReturnOrder."""
return {**super().report_context(), 'customer': self.customer}
def get_absolute_url(self):
"""Get the 'web' URL for this order."""
if settings.ENABLE_CLASSIC_FRONTEND:

View File

@ -253,11 +253,7 @@ $("#place-order").click(function() {
{% if report_enabled %}
$('#print-order-report').click(function() {
printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-po-report-list" %}',
});
printReports('purchaseorder', [{{ order.pk }}]);
});
{% endif %}

View File

@ -248,11 +248,7 @@ $('#cancel-order').click(function() {
{% if report_enabled %}
$('#print-order-report').click(function() {
printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-return-order-report-list" %}',
});
printReports('returnorder', [{{ order.pk }}]);
});
{% endif %}

View File

@ -310,11 +310,7 @@ $("#complete-order").click(function() {
{% if report_enabled %}
$('#print-order-report').click(function() {
printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-so-report-list" %}',
});
printReports('salesorder', [{{ order.pk }}]);
});
{% endif %}

View File

@ -7,7 +7,7 @@ import hashlib
import logging
import os
import re
from datetime import datetime, timedelta
from datetime import timedelta
from decimal import Decimal, InvalidOperation
from django.conf import settings
@ -43,6 +43,7 @@ import InvenTree.ready
import InvenTree.tasks
import part.helpers as part_helpers
import part.settings as part_settings
import report.mixins
import users.models
from build import models as BuildModels
from common.models import InvenTreeSetting
@ -340,6 +341,7 @@ class PartManager(TreeManager):
class Part(
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
MPTTModel,
@ -409,8 +411,28 @@ class Part(
"""Return API query filters for limiting field results against this instance."""
return {'variant_of': {'exclude_tree': self.pk}}
def report_context(self):
"""Return custom report context information."""
return {
'bom_items': self.get_bom_items(),
'category': self.category,
'description': self.description,
'IPN': self.IPN,
'name': self.name,
'parameters': self.parameters_map(),
'part': self,
'qr_data': self.format_barcode(brief=True),
'qr_url': self.get_absolute_url(),
'revision': self.revision,
'test_template_list': self.getTestTemplates(),
'test_templates': self.getTestTemplates(),
}
def get_context_data(self, request, **kwargs):
"""Return some useful context data about this part for template rendering."""
"""Return some useful context data about this part for template rendering.
TODO: 2024-04-21 - Remove this method once the legacy UI code is removed
"""
context = {}
context['disabled'] = not self.active

View File

@ -432,6 +432,11 @@ class DuplicatePartSerializer(serializers.Serializer):
The fields in this serializer control how the Part is duplicated.
"""
class Meta:
"""Metaclass options."""
fields = ['part', 'copy_image', 'copy_bom', 'copy_parameters', 'copy_notes']
part = serializers.PrimaryKeyRelatedField(
queryset=Part.objects.all(),
label=_('Original Part'),
@ -471,6 +476,11 @@ class DuplicatePartSerializer(serializers.Serializer):
class InitialStockSerializer(serializers.Serializer):
"""Serializer for creating initial stock quantity."""
class Meta:
"""Metaclass options."""
fields = ['quantity', 'location']
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
@ -494,6 +504,11 @@ class InitialStockSerializer(serializers.Serializer):
class InitialSupplierSerializer(serializers.Serializer):
"""Serializer for adding initial supplier / manufacturer information."""
class Meta:
"""Metaclass options."""
fields = ['supplier', 'sku', 'manufacturer', 'mpn']
supplier = serializers.PrimaryKeyRelatedField(
queryset=company.models.Company.objects.all(),
label=_('Supplier'),

View File

@ -629,11 +629,7 @@
{% if report_enabled %}
$("#print-bom-report").click(function() {
printReports({
items: [{{ part.pk }}],
key: 'part',
url: '{% url "api-bom-report-list" %}'
});
printReports('part', [{{ part.pk }}]);
});
{% endif %}
});

View File

@ -468,9 +468,8 @@
$('#print-label').click(function() {
printLabels({
items: [{{ part.pk }}],
key: 'part',
model_type: 'part',
singular_name: 'part',
url: '{% url "api-part-label-list" %}',
});
});
{% endif %}

View File

@ -47,3 +47,16 @@ class ReportMixin:
context: The context dictionary to add to
"""
pass
def report_callback(self, template, instance, report, request):
"""Callback function called after a report is generated.
Arguments:
template: The ReportTemplate model
instance: The instance of the target model
report: The generated report object
request: The initiating request object
The default implementation does nothing.
"""
pass

View File

@ -11,17 +11,12 @@ import pdf2image
from rest_framework import serializers
from rest_framework.request import Request
from build.models import BuildLine
from common.models import InvenTreeSetting
from InvenTree.exceptions import log_error
from InvenTree.tasks import offload_task
from label.models import LabelTemplate
from part.models import Part
from plugin.base.label import label as plugin_label
from plugin.helpers import MixinNotImplementedError
from stock.models import StockItem, StockLocation
LabelItemType = Union[StockItem, StockLocation, Part, BuildLine]
from report.models import LabelTemplate, TemplateOutput
class LabelPrintingMixin:
@ -34,11 +29,6 @@ class LabelPrintingMixin:
Note that the print_labels() function can also be overridden to provide custom behavior.
"""
# If True, the print_label() method will block until the label is printed
# If False, the offload_label() method will be called instead
# By default, this is False, which means that labels will be printed in the background
BLOCKING_PRINT = False
class MixinMeta:
"""Meta options for this mixin."""
@ -49,37 +39,42 @@ class LabelPrintingMixin:
super().__init__()
self.add_mixin('labels', True, __class__)
def render_to_pdf(self, label: LabelTemplate, request, **kwargs):
BLOCKING_PRINT = True
def render_to_pdf(self, label: LabelTemplate, instance, request, **kwargs):
"""Render this label to PDF format.
Arguments:
label: The LabelTemplate object to render
label: The LabelTemplate object to render against
instance: The model instance to render
request: The HTTP request object which triggered this print job
"""
try:
return label.render(request)
return label.render(instance, request)
except Exception:
log_error('label.render_to_pdf')
raise ValidationError(_('Error rendering label to PDF'))
def render_to_html(self, label: LabelTemplate, request, **kwargs):
def render_to_html(self, label: LabelTemplate, instance, request, **kwargs):
"""Render this label to HTML format.
Arguments:
label: The LabelTemplate object to render
label: The LabelTemplate object to render against
instance: The model instance to render
request: The HTTP request object which triggered this print job
"""
try:
return label.render_as_string(request)
return label.render_as_string(instance, request)
except Exception:
log_error('label.render_to_html')
raise ValidationError(_('Error rendering label to HTML'))
def render_to_png(self, label: LabelTemplate, request=None, **kwargs):
def render_to_png(self, label: LabelTemplate, instance, request=None, **kwargs):
"""Render this label to PNG format.
Arguments:
label: The LabelTemplate object to render
label: The LabelTemplate object to render against
item: The model instance to render
request: The HTTP request object which triggered this print job
Keyword Arguments:
pdf_data: The raw PDF data of the rendered label (if already rendered)
@ -94,7 +89,9 @@ class LabelPrintingMixin:
if not pdf_data:
pdf_data = (
self.render_to_pdf(label, request, **kwargs).get_document().write_pdf()
self.render_to_pdf(label, instance, request, **kwargs)
.get_document()
.write_pdf()
)
pdf2image_kwargs = {
@ -108,19 +105,21 @@ class LabelPrintingMixin:
return pdf2image.convert_from_bytes(pdf_data, **pdf2image_kwargs)[0]
except Exception:
log_error('label.render_to_png')
raise ValidationError(_('Error rendering label to PNG'))
return None
def print_labels(
self,
label: LabelTemplate,
items: QuerySet[LabelItemType],
output: TemplateOutput,
items: list,
request: Request,
**kwargs,
):
) -> None:
"""Print one or more labels with the provided template and items.
Arguments:
label: The LabelTemplate object to use for printing
output: The TemplateOutput object used to store the results
items: The list of database items to print (e.g. StockItem instances)
request: The HTTP request object which triggered this print job
@ -128,7 +127,10 @@ class LabelPrintingMixin:
printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer
Returns:
A JSONResponse object which indicates outcome to the user
None. Output data should be stored in the provided TemplateOutput object
Raises:
ValidationError if there is an error during the print process
The default implementation simply calls print_label() for each label, producing multiple single label output "jobs"
but this can be overridden by the particular plugin.
@ -138,19 +140,30 @@ class LabelPrintingMixin:
except AttributeError:
user = None
# Initial state for the output print job
output.progress = 0
output.complete = False
output.save()
N = len(items)
# Generate a label output for each provided item
for item in items:
label.object_to_print = item
filename = label.generate_filename(request)
pdf_file = self.render_to_pdf(label, request, **kwargs)
context = label.get_context(item, request)
filename = label.generate_filename(context)
pdf_file = self.render_to_pdf(label, item, request, **kwargs)
pdf_data = pdf_file.get_document().write_pdf()
png_file = self.render_to_png(label, request, pdf_data=pdf_data, **kwargs)
png_file = self.render_to_png(
label, item, request, pdf_data=pdf_data, **kwargs
)
print_args = {
'pdf_file': pdf_file,
'pdf_data': pdf_data,
'png_file': png_file,
'filename': filename,
'context': context,
'output': output,
'label_instance': label,
'item_instance': item,
'user': user,
@ -160,19 +173,34 @@ class LabelPrintingMixin:
}
if self.BLOCKING_PRINT:
# Blocking print job
# Print the label (blocking)
self.print_label(**print_args)
else:
# Non-blocking print job
# Offload the print task to the background worker
# Exclude the 'pdf_file' object - cannot be pickled
# Offload the print job to a background worker
self.offload_label(**print_args)
kwargs.pop('pdf_file', None)
offload_task(plugin_label.print_label, self.plugin_slug(), **print_args)
# Return a JSON response to the user
return JsonResponse({
'success': True,
'message': f'{len(items)} labels printed',
})
# Update the progress of the print job
output.progress += int(100 / N)
output.save()
# Mark the output as complete
output.complete = True
output.progress = 100
# Add in the generated file (if applicable)
output.output = self.get_generated_file(**print_args)
output.save()
def get_generated_file(self, **kwargs):
"""Return the generated file for download (or None, if this plugin does not generate a file output).
The default implementation returns None, but this can be overridden by the particular plugin.
"""
return None
def print_label(self, **kwargs):
"""Print a single label (blocking).
@ -183,6 +211,7 @@ class LabelPrintingMixin:
filename: The filename of this PDF label
label_instance: The instance of the label model which triggered the print_label() method
item_instance: The instance of the database model against which the label is printed
output: The TemplateOutput object used to store the results of the print job
user: The user who triggered this print job
width: The expected width of the label (in mm)
height: The expected height of the label (in mm)
@ -195,19 +224,6 @@ class LabelPrintingMixin:
'This Plugin must implement a `print_label` method'
)
def offload_label(self, **kwargs):
"""Offload a single label (non-blocking).
Instead of immediately printing the label (which is a blocking process),
this method should offload the label to a background worker process.
Offloads a call to the 'print_label' method (of this plugin) to a background worker.
"""
# Exclude the 'pdf_file' object - cannot be pickled
kwargs.pop('pdf_file', None)
offload_task(plugin_label.print_label, self.plugin_slug(), **kwargs)
def get_printing_options_serializer(
self, request: Request, *args, **kwargs
) -> Union[serializers.Serializer, None]:
@ -227,3 +243,11 @@ class LabelPrintingMixin:
return None
return serializer(*args, **kwargs)
def before_printing(self):
"""Hook method called before printing labels."""
pass
def after_printing(self):
"""Hook method called after printing labels."""
pass

View File

@ -12,62 +12,28 @@ from PIL import Image
from InvenTree.settings import BASE_DIR
from InvenTree.unit_test import InvenTreeAPITestCase
from label.models import PartLabel, StockItemLabel, StockLocationLabel
from part.models import Part
from plugin.base.label.mixins import LabelPrintingMixin
from plugin.helpers import MixinNotImplementedError
from plugin.plugin import InvenTreePlugin
from plugin.registry import registry
from report.models import LabelTemplate
from report.tests import PrintTestMixins
from stock.models import StockItem, StockLocation
class LabelMixinTests(InvenTreeAPITestCase):
class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
"""Test that the Label mixin operates correctly."""
fixtures = ['category', 'part', 'location', 'stock']
roles = 'all'
plugin_ref = 'samplelabelprinter'
def do_activate_plugin(self):
"""Activate the 'samplelabel' plugin."""
config = registry.get_plugin('samplelabelprinter').plugin_config()
config.active = True
config.save()
def do_url(
self,
parts,
plugin_ref,
label,
url_name: str = 'api-part-label-print',
url_single: str = 'part',
invalid: bool = False,
):
"""Generate an URL to print a label."""
# Construct URL
kwargs = {}
if label:
kwargs['pk'] = label.pk
url = reverse(url_name, kwargs=kwargs)
# Append part filters
if not parts:
pass
elif len(parts) == 1:
url += f'?{url_single}={parts[0].pk}'
elif len(parts) > 1:
url += '?' + '&'.join([f'{url_single}s={item.pk}' for item in parts])
# Append an invalid item
if invalid:
url += f'&{url_single}{"s" if len(parts) > 1 else ""}=abc'
# Append plugin reference
if plugin_ref:
url += f'&plugin={plugin_ref}'
return url
@property
def printing_url(self):
"""Return the label printing URL."""
return reverse('api-label-print')
def test_wrong_implementation(self):
"""Test that a wrong implementation raises an error."""
@ -121,52 +87,106 @@ class LabelMixinTests(InvenTreeAPITestCase):
def test_printing_process(self):
"""Test that a label can be printed."""
# Ensure the labels were created
apps.get_app_config('label').create_defaults()
apps.get_app_config('report').create_default_labels()
apps.get_app_config('report').create_default_reports()
test_path = BASE_DIR / '_testfolder' / 'label'
# Lookup references
part = Part.objects.first()
parts = Part.objects.all()[:2]
plugin_ref = 'samplelabelprinter'
label = PartLabel.objects.first()
url = self.do_url([part], plugin_ref, label)
template = LabelTemplate.objects.filter(enabled=True, model_type='part').first()
# Non-existing plugin
response = self.get(f'{url}123', expected_code=404)
self.assertIn(
f"Plugin '{plugin_ref}123' not found", str(response.content, 'utf8')
self.assertIsNotNone(template)
self.assertTrue(template.enabled)
url = self.printing_url
# Template does not exist
response = self.post(
url, {'template': 9999, 'plugin': 9999, 'items': []}, expected_code=400
)
# Inactive plugin
response = self.get(url, expected_code=400)
self.assertIn(
f"Plugin '{plugin_ref}' is not enabled", str(response.content, 'utf8')
self.assertIn('object does not exist', str(response.data['template']))
self.assertIn('list may not be empty', str(response.data['items']))
# Plugin is not a label plugin
no_valid_plg = registry.get_plugin('digikeyplugin').plugin_config()
response = self.post(
url,
{'template': template.pk, 'plugin': no_valid_plg.key, 'items': [1, 2, 3]},
expected_code=400,
)
self.assertIn('Plugin does not support label printing', str(response.data))
# Find available plugins
plugins = registry.with_mixin('labels')
self.assertGreater(len(plugins), 0)
plugin = registry.get_plugin('samplelabelprinter')
config = plugin.plugin_config()
# Ensure that the plugin is not active
registry.set_plugin_state(plugin.slug, False)
# Plugin is not active - should return error
response = self.post(
url,
{'template': template.pk, 'plugin': config.key, 'items': [1, 2, 3]},
expected_code=400,
)
self.assertIn('Plugin is not active', str(response.data['plugin']))
# Active plugin
self.do_activate_plugin()
# Print one part
self.get(url, expected_code=200)
response = self.post(
url,
{'template': template.pk, 'plugin': config.key, 'items': [parts[0].pk]},
expected_code=201,
)
self.assertEqual(response.data['plugin'], 'samplelabelprinter')
self.assertIsNone(response.data['output'])
# Print multiple parts
self.get(self.do_url(parts, plugin_ref, label), expected_code=200)
response = self.post(
url,
{
'template': template.pk,
'plugin': config.key,
'items': [item.pk for item in parts],
},
expected_code=201,
)
self.assertEqual(response.data['plugin'], 'samplelabelprinter')
self.assertIsNone(response.data['output'])
# Print multiple parts without a plugin
self.get(self.do_url(parts, None, label), expected_code=200)
response = self.post(
url,
{'template': template.pk, 'items': [item.pk for item in parts]},
expected_code=201,
)
# Print multiple parts without a plugin in debug mode
response = self.get(self.do_url(parts, None, label), expected_code=200)
self.assertEqual(response.data['plugin'], 'inventreelabel')
self.assertIsNotNone(response.data['output'])
data = json.loads(response.content)
self.assertIn('file', data)
self.assertIn('output', data)
# Print no part
self.get(self.do_url(None, plugin_ref, label), expected_code=400)
self.post(
url,
{'template': template.pk, 'plugin': plugin.pk, 'items': None},
expected_code=400,
)
# Test that the labels have been printed
# The sample labelling plugin simply prints to file
test_path = BASE_DIR / '_testfolder' / 'label'
self.assertTrue(os.path.exists(f'{test_path}.pdf'))
# Read the raw .pdf data - ensure it contains some sensible information
@ -183,27 +203,30 @@ class LabelMixinTests(InvenTreeAPITestCase):
def test_printing_options(self):
"""Test printing options."""
# Ensure the labels were created
apps.get_app_config('label').create_defaults()
apps.get_app_config('report').create_default_labels()
# Lookup references
parts = Part.objects.all()[:2]
plugin_ref = 'samplelabelprinter'
label = PartLabel.objects.first()
template = LabelTemplate.objects.filter(enabled=True, model_type='part').first()
self.do_activate_plugin()
plugin = registry.get_plugin(self.plugin_ref)
# test options response
options = self.options(
self.do_url(parts, plugin_ref, label), expected_code=200
self.printing_url, data={'plugin': plugin.slug}, expected_code=200
).json()
self.assertIn('amount', options['actions']['POST'])
plg = registry.get_plugin(plugin_ref)
with mock.patch.object(plg, 'print_label') as print_label:
with mock.patch.object(plugin, 'print_label') as print_label:
# wrong value type
res = self.post(
self.do_url(parts, plugin_ref, label),
data={'amount': '-no-valid-int-'},
self.printing_url,
{
'plugin': plugin.slug,
'template': template.pk,
'items': [a.pk for a in parts],
'amount': '-no-valid-int-',
},
expected_code=400,
).json()
self.assertIn('amount', res)
@ -211,9 +234,14 @@ class LabelMixinTests(InvenTreeAPITestCase):
# correct value type
self.post(
self.do_url(parts, plugin_ref, label),
data={'amount': 13},
expected_code=200,
self.printing_url,
{
'template': template.pk,
'plugin': plugin.slug,
'items': [a.pk for a in parts],
'amount': 13,
},
expected_code=201,
).json()
self.assertEqual(
print_label.call_args.kwargs['printing_options'], {'amount': 13}
@ -221,57 +249,15 @@ class LabelMixinTests(InvenTreeAPITestCase):
def test_printing_endpoints(self):
"""Cover the endpoints not covered by `test_printing_process`."""
plugin_ref = 'samplelabelprinter'
# Activate the label components
apps.get_app_config('label').create_defaults()
apps.get_app_config('report').create_default_labels()
self.do_activate_plugin()
def run_print_test(label, qs, url_name, url_single):
"""Run tests on single and multiple page printing.
# Test StockItemLabel
self.run_print_test(StockItem, 'stockitem')
Args:
label: class of the label
qs: class of the base queryset
url_name: url for endpoints
url_single: item lookup reference
"""
label = label.objects.first()
qs = qs.objects.all()
# Test StockLocationLabel
self.run_print_test(StockLocation, 'stocklocation')
# List endpoint
self.get(
self.do_url(None, None, None, f'{url_name}-list', url_single),
expected_code=200,
)
# List endpoint with filter
self.get(
self.do_url(
qs[:2], None, None, f'{url_name}-list', url_single, invalid=True
),
expected_code=200,
)
# Single page printing
self.get(
self.do_url(qs[:1], plugin_ref, label, f'{url_name}-print', url_single),
expected_code=200,
)
# Multi page printing
self.get(
self.do_url(qs[:2], plugin_ref, label, f'{url_name}-print', url_single),
expected_code=200,
)
# Test StockItemLabels
run_print_test(StockItemLabel, StockItem, 'api-stockitem-label', 'item')
# Test StockLocationLabels
run_print_test(
StockLocationLabel, StockLocation, 'api-stocklocation-label', 'location'
)
# Test PartLabels
run_print_test(PartLabel, Part, 'api-part-label', 'part')
# Test PartLabel
self.run_print_test(Part, 'part')

View File

@ -1,10 +1,9 @@
"""Default label printing plugin (supports PDF generation)."""
from django.core.files.base import ContentFile
from django.http import JsonResponse
from django.utils.translation import gettext_lazy as _
from label.models import LabelOutput, LabelTemplate
from InvenTree.helpers import str2bool
from plugin import InvenTreePlugin
from plugin.mixins import LabelPrintingMixin, SettingsMixin
@ -19,7 +18,7 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin):
NAME = 'InvenTreeLabel'
TITLE = _('InvenTree PDF label printer')
DESCRIPTION = _('Provides native support for printing PDF labels')
VERSION = '1.0.0'
VERSION = '1.1.0'
AUTHOR = _('InvenTree contributors')
BLOCKING_PRINT = True
@ -33,58 +32,57 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin):
}
}
def print_labels(self, label: LabelTemplate, items: list, request, **kwargs):
"""Handle printing of multiple labels.
# Keep track of individual label outputs
# These will be stitched together at the end of printing
outputs = []
debug = None
- Label outputs are concatenated together, and we return a single PDF file.
- If DEBUG mode is enabled, we return a single HTML file.
"""
debug = self.get_setting('DEBUG')
def before_printing(self):
"""Reset the list of label outputs."""
self.outputs = []
self.debug = None
outputs = []
output_file = None
def in_debug_mode(self):
"""Check if the plugin is printing in debug mode."""
if self.debug is None:
self.debug = str2bool(self.get_setting('DEBUG'))
for item in items:
label.object_to_print = item
return self.debug
outputs.append(self.print_label(label, request, debug=debug, **kwargs))
def print_label(self, **kwargs):
"""Print a single label."""
label = kwargs['label_instance']
instance = kwargs['item_instance']
if self.get_setting('DEBUG'):
html = '\n'.join(outputs)
output_file = ContentFile(html, 'labels.html')
if self.in_debug_mode():
# In debug mode, return raw HTML output
output = self.render_to_html(label, instance, None, **kwargs)
else:
# Output is already provided
output = kwargs['pdf_file']
self.outputs.append(output)
def get_generated_file(self, **kwargs):
"""Return the generated file, by stitching together the individual label outputs."""
if len(self.outputs) == 0:
return None
if self.in_debug_mode():
# Simple HTML output
data = '\n'.join(self.outputs)
filename = 'labels.html'
else:
# Stitch together the PDF outputs
pages = []
# Following process is required to stitch labels together into a single PDF
for output in outputs:
for output in self.outputs:
doc = output.get_document()
for page in doc.pages:
pages.append(page)
pdf = outputs[0].get_document().copy(pages).write_pdf()
data = self.outputs[0].get_document().copy(pages).write_pdf()
filename = kwargs.get('filename', 'labels.pdf')
# Create label output file
output_file = ContentFile(pdf, 'labels.pdf')
# Save the generated file to the database
output = LabelOutput.objects.create(label=output_file, user=request.user)
return JsonResponse({
'file': output.label.url,
'success': True,
'message': f'{len(items)} labels generated',
})
def print_label(self, label: LabelTemplate, request, **kwargs):
"""Handle printing of a single label.
Returns either a PDF or HTML output, depending on the DEBUG setting.
"""
debug = kwargs.get('debug', self.get_setting('DEBUG'))
if debug:
return self.render_to_html(label, request, **kwargs)
return self.render_to_pdf(label, request, **kwargs)
return ContentFile(data, name=filename)

View File

@ -10,11 +10,11 @@ from rest_framework import serializers
from common.models import InvenTreeUserSetting
from InvenTree.serializers import DependentField
from InvenTree.tasks import offload_task
from label.models import LabelTemplate
from machine.machine_types import LabelPrinterBaseDriver, LabelPrinterMachine
from plugin import InvenTreePlugin
from plugin.machine import registry
from plugin.mixins import LabelPrintingMixin
from report.models import LabelTemplate
def get_machine_and_driver(machine_pk: str):
@ -63,7 +63,7 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin):
VERSION = '1.0.0'
AUTHOR = _('InvenTree contributors')
def print_labels(self, label: LabelTemplate, items, request, **kwargs):
def print_labels(self, label: LabelTemplate, output, items, request, **kwargs):
"""Print labels implementation that calls the correct machine driver print_labels method."""
machine, driver = get_machine_and_driver(
kwargs['printing_options'].get('machine', '')
@ -111,9 +111,10 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin):
"""Custom __init__ method to dynamically override the machine choices based on the request."""
super().__init__(*args, **kwargs)
view = kwargs['context']['view']
template = view.get_object()
items_to_print = view.get_items()
# TODO @matmair Re-enable this when the need is clear
# view = kwargs['context']['view']
template = None # view.get_object()
items_to_print = None # view.get_items()
# get all available printers for each driver
machines: list[LabelPrinterMachine] = []

View File

@ -12,9 +12,9 @@ import weasyprint
from rest_framework import serializers
import report.helpers
from label.models import LabelOutput, LabelTemplate
from plugin import InvenTreePlugin
from plugin.mixins import LabelPrintingMixin, SettingsMixin
from report.models import LabelOutput, LabelTemplate
logger = logging.getLogger('inventree')
@ -68,8 +68,13 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
PrintingOptionsSerializer = LabelPrintingOptionsSerializer
def print_labels(self, label: LabelTemplate, items: list, request, **kwargs):
"""Handle printing of the provided labels."""
def print_labels(
self, label: LabelTemplate, output: LabelOutput, items: list, request, **kwargs
):
"""Handle printing of the provided labels.
Note that we override the entire print_labels method for this plugin.
"""
printing_options = kwargs['printing_options']
# Extract page size for the label sheet
@ -134,15 +139,10 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
html = weasyprint.HTML(string=html_data)
document = html.render().write_pdf()
output_file = ContentFile(document, 'labels.pdf')
output = LabelOutput.objects.create(label=output_file, user=request.user)
return JsonResponse({
'file': output.label.url,
'success': True,
'message': f'{len(items)} labels generated',
})
output.output = ContentFile(document, 'labels.pdf')
output.progress = 100
output.complete = True
output.save()
def print_page(self, label: LabelTemplate, items: list, request, **kwargs):
"""Generate a single page of labels.
@ -185,7 +185,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
# Render the individual label template
# Note that we disable @page styling for this
cell = label.render_as_string(
request, target_object=items[idx], insert_page_style=False
items[idx], request, insert_page_style=False
)
html += cell
except Exception as exc:

View File

@ -7,7 +7,7 @@ from django.conf import settings
from django.contrib import admin
from django.contrib.auth.models import User
from django.db import models
from django.db.utils import IntegrityError
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import common.models
@ -24,6 +24,11 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
active: Should the plugin be loaded?
"""
@staticmethod
def get_api_url():
"""Return the API URL associated with the PluginConfig model."""
return reverse('api-plugin-list')
class Meta:
"""Meta for PluginConfig."""

View File

@ -34,7 +34,9 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
print(f"Printing Label: {kwargs['filename']} (User: {kwargs['user']})")
pdf_data = kwargs['pdf_data']
png_file = self.render_to_png(label=None, pdf_data=pdf_data)
png_file = self.render_to_png(
kwargs['label_instance'], kwargs['item_instance'], **kwargs
)
filename = str(BASE_DIR / '_testfolder' / 'label.pdf')

View File

@ -4,7 +4,7 @@ import random
from plugin import InvenTreePlugin
from plugin.mixins import ReportMixin
from report.models import PurchaseOrderReport
from report.models import ReportTemplate
class SampleReportPlugin(ReportMixin, InvenTreePlugin):
@ -32,7 +32,7 @@ class SampleReportPlugin(ReportMixin, InvenTreePlugin):
context['random_int'] = self.some_custom_function()
# We can also add extra data to the context which is specific to the report type
context['is_purchase_order'] = isinstance(report_instance, PurchaseOrderReport)
context['is_purchase_order'] = report_instance.model_type == 'purchaseorder'
# We can also use the 'request' object to add extra context data
context['request_method'] = request.method

View File

@ -268,3 +268,26 @@ class PluginRegistryStatusSerializer(serializers.Serializer):
active_plugins = serializers.IntegerField(read_only=True)
registry_errors = serializers.ListField(child=PluginRegistryErrorSerializer())
class PluginRelationSerializer(serializers.PrimaryKeyRelatedField):
"""Serializer for a plugin field. Uses the 'slug' of the plugin as the lookup."""
def __init__(self, **kwargs):
"""Custom init routine for the serializer."""
kwargs['pk_field'] = 'key'
kwargs['queryset'] = PluginConfig.objects.all()
super().__init__(**kwargs)
def use_pk_only_optimization(self):
"""Disable the PK optimization."""
return False
def to_internal_value(self, data):
"""Lookup the PluginConfig object based on the slug."""
return PluginConfig.objects.filter(key=data).first()
def to_representation(self, value):
"""Return the 'key' of the PluginConfig object."""
return value.key

View File

@ -2,32 +2,32 @@
from django.contrib import admin
from .helpers import report_model_options
from .models import (
BillOfMaterialsReport,
BuildReport,
PurchaseOrderReport,
LabelOutput,
LabelTemplate,
ReportAsset,
ReportOutput,
ReportSnippet,
ReturnOrderReport,
SalesOrderReport,
StockLocationReport,
TestReport,
ReportTemplate,
)
@admin.register(
BillOfMaterialsReport,
BuildReport,
PurchaseOrderReport,
ReturnOrderReport,
SalesOrderReport,
StockLocationReport,
TestReport,
)
class ReportTemplateAdmin(admin.ModelAdmin):
"""Admin class for the various reporting models."""
@admin.register(LabelTemplate)
@admin.register(ReportTemplate)
class ReportAdmin(admin.ModelAdmin):
"""Admin class for the LabelTemplate and ReportTemplate models."""
list_display = ('name', 'description', 'template', 'filters', 'enabled', 'revision')
list_display = ('name', 'description', 'model_type', 'enabled')
list_filter = ('model_type', 'enabled')
def formfield_for_dbfield(self, db_field, request, **kwargs):
"""Provide custom choices for 'model_type' field."""
if db_field.name == 'model_type':
db_field.choices = report_model_options()
return super().formfield_for_dbfield(db_field, request, **kwargs)
@admin.register(ReportSnippet)
@ -42,3 +42,11 @@ class ReportAssetAdmin(admin.ModelAdmin):
"""Admin class for the ReportAsset model."""
list_display = ('id', 'asset', 'description')
@admin.register(LabelOutput)
@admin.register(ReportOutput)
class TemplateOutputAdmin(admin.ModelAdmin):
"""Admin class for the TemplateOutput model."""
list_display = ('id', 'output', 'progress', 'complete')

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,25 @@
"""Config options for the report app."""
import logging
import os
from pathlib import Path
from django.apps import AppConfig
from django.core.exceptions import AppRegistryNotReady
from django.core.files.base import ContentFile
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
from generic.templating.apps import TemplatingMixin
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
import InvenTree.ready
logger = logging.getLogger('inventree')
class ReportConfig(TemplatingMixin, AppConfig):
class ReportConfig(AppConfig):
"""Configuration class for the "report" app."""
name = 'report'
db = 'template'
def ready(self):
"""This function is called whenever the app is loaded."""
@ -22,103 +29,192 @@ class ReportConfig(TemplatingMixin, AppConfig):
super().ready()
def create_defaults(self):
"""Create all default templates."""
# skip loading if plugin registry is not loaded or we run in a background thread
if (
not InvenTree.ready.isPluginRegistryLoaded()
or not InvenTree.ready.isInMainThread()
):
return
if not InvenTree.ready.canAppAccessDatabase(allow_test=False):
return # pragma: no cover
with maintenance_mode_on():
try:
self.create_default_labels()
self.create_default_reports()
except (
AppRegistryNotReady,
IntegrityError,
OperationalError,
ProgrammingError,
):
logger.warning(
'Database not ready for creating default report templates'
)
set_maintenance_mode(False)
def create_default_labels(self):
"""Create default label templates."""
# Test if models are ready
try:
import report.models
except Exception: # pragma: no cover
# Database is not ready yet
return
assert bool(report.models.TestReport is not None)
# Create the categories
self.create_template_dir(
report.models.TestReport,
[
{
'file': 'inventree_test_report.html',
'name': 'InvenTree Test Report',
'description': 'Stock item test report',
}
],
)
assert bool(report.models.LabelTemplate is not None)
self.create_template_dir(
report.models.BuildReport,
[
{
'file': 'inventree_build_order.html',
'name': 'InvenTree Build Order',
'description': 'Build Order job sheet',
}
],
)
label_templates = [
{
'file': 'part_label.html',
'name': 'InvenTree Part Label',
'description': 'Sample part label',
'model_type': 'part',
},
{
'file': 'part_label_code128.html',
'name': 'InvenTree Part Label (Code128)',
'description': 'Sample part label with Code128 barcode',
'model_type': 'part',
},
{
'file': 'stockitem_qr.html',
'name': 'InvenTree Stock Item Label (QR)',
'description': 'Sample stock item label with QR code',
'model_type': 'stockitem',
},
{
'file': 'stocklocation_qr_and_text.html',
'name': 'InvenTree Stock Location Label (QR + Text)',
'description': 'Sample stock item label with QR code and text',
'model_type': 'stocklocation',
},
{
'file': 'stocklocation_qr.html',
'name': 'InvenTree Stock Location Label (QR)',
'description': 'Sample stock location label with QR code',
'model_type': 'stocklocation',
},
{
'file': 'buildline_label.html',
'name': 'InvenTree Build Line Label',
'description': 'Sample build line label',
'model_type': 'buildline',
},
]
self.create_template_dir(
report.models.BillOfMaterialsReport,
[
{
'file': 'inventree_bill_of_materials_report.html',
'name': 'Bill of Materials',
'description': 'Bill of Materials report',
}
],
)
for template in label_templates:
# Ignore matching templates which are already in the database
if report.models.LabelTemplate.objects.filter(
name=template['name']
).exists():
continue
self.create_template_dir(
report.models.PurchaseOrderReport,
[
{
'file': 'inventree_po_report.html',
'name': 'InvenTree Purchase Order',
'description': 'Purchase Order example report',
}
],
)
filename = template.pop('file')
self.create_template_dir(
report.models.SalesOrderReport,
[
{
'file': 'inventree_so_report.html',
'name': 'InvenTree Sales Order',
'description': 'Sales Order example report',
}
],
)
template_file = Path(__file__).parent.joinpath(
'templates', 'label', filename
)
self.create_template_dir(
report.models.ReturnOrderReport,
[
{
'file': 'inventree_return_order_report.html',
'name': 'InvenTree Return Order',
'description': 'Return Order example report',
}
],
)
if not template_file.exists():
logger.warning("Missing template file: '%s'", template['name'])
continue
self.create_template_dir(
report.models.StockLocationReport,
[
{
'file': 'inventree_slr_report.html',
'name': 'InvenTree Stock Location',
'description': 'Stock Location example report',
}
],
)
# Read the existing template file
data = template_file.open('r').read()
def get_src_dir(self, ref_name):
"""Get the source directory."""
return Path(__file__).parent.joinpath('templates', self.name)
logger.info("Creating new label template: '%s'", template['name'])
def get_new_obj_data(self, data, filename):
"""Get the data for a new template db object."""
return {
'name': data['name'],
'description': data['description'],
'template': filename,
'enabled': True,
}
# Create a new entry
report.models.LabelTemplate.objects.create(
**template, template=ContentFile(data, os.path.basename(filename))
)
def create_default_reports(self):
"""Create default report templates."""
# Test if models are ready
try:
import report.models
except Exception: # pragma: no cover
# Database is not ready yet
return
assert bool(report.models.ReportTemplate is not None)
# Construct a set of default ReportTemplate instances
report_templates = [
{
'file': 'inventree_bill_of_materials_report.html',
'name': 'InvenTree Bill of Materials',
'description': 'Sample bill of materials report',
'model_type': 'part',
},
{
'file': 'inventree_build_order_report.html',
'name': 'InvenTree Build Order',
'description': 'Sample build order report',
'model_type': 'build',
},
{
'file': 'inventree_purchase_order_report.html',
'name': 'InvenTree Purchase Order',
'description': 'Sample purchase order report',
'model_type': 'purchaseorder',
'filename_pattern': 'PurchaseOrder-{{ reference }}.pdf',
},
{
'file': 'inventree_sales_order_report.html',
'name': 'InvenTree Sales Order',
'description': 'Sample sales order report',
'model_type': 'salesorder',
'filename_pattern': 'SalesOrder-{{ reference }}.pdf',
},
{
'file': 'inventree_return_order_report.html',
'name': 'InvenTree Return Order',
'description': 'Sample return order report',
'model_type': 'returnorder',
'filename_pattern': 'ReturnOrder-{{ reference }}.pdf',
},
{
'file': 'inventree_test_report.html',
'name': 'InvenTree Test Report',
'description': 'Sample stock item test report',
'model_type': 'stockitem',
},
{
'file': 'inventree_stock_location_report.html',
'name': 'InvenTree Stock Location Report',
'description': 'Sample stock location report',
'model_type': 'stocklocation',
},
]
for template in report_templates:
# Ignore matching templates which are already in the database
if report.models.ReportTemplate.objects.filter(
name=template['name']
).exists():
continue
filename = template.pop('file')
template_file = Path(__file__).parent.joinpath(
'templates', 'report', filename
)
if not template_file.exists():
logger.warning("Missing template file: '%s'", template['name'])
continue
# Read the existing template file
data = template_file.open('r').read()
logger.info("Creating new report template: '%s'", template['name'])
# Create a new entry
report.models.ReportTemplate.objects.create(
**template, template=ContentFile(data, os.path.basename(filename))
)

View File

@ -9,6 +9,32 @@ from django.utils.translation import gettext_lazy as _
logger = logging.getLogger('inventree')
def report_model_types():
"""Return a list of database models for which reports can be generated."""
from InvenTree.helpers_model import getModelsWithMixin
from report.mixins import InvenTreeReportMixin
return list(getModelsWithMixin(InvenTreeReportMixin))
def report_model_from_name(model_name: str):
"""Returns the internal model class from the provided name."""
if not model_name:
return None
for model in report_model_types():
if model.__name__.lower() == model_name:
return model
def report_model_options():
"""Return a list of options for models which support report printing."""
return [
(model.__name__.lower(), model._meta.verbose_name)
for model in report_model_types()
]
def report_page_size_options():
"""Returns a list of page size options for PDF reports."""
return [

View File

@ -17,7 +17,7 @@ class Migration(migrations.Migration):
name='ReportAsset',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('asset', models.FileField(help_text='Report asset file', upload_to=report.models.rename_asset)),
('asset', models.FileField(help_text='Report asset file', upload_to='report/assets')),
('description', models.CharField(help_text='Asset file description', max_length=250)),
],
),
@ -26,7 +26,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, unique=True)),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])),
('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])),
('description', models.CharField(help_text='Report template description', max_length=250)),
],
options={
@ -38,9 +38,9 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, unique=True)),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])),
('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])),
('description', models.CharField(help_text='Report template description', max_length=250)),
('part_filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validateFilterString])),
('part_filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250)),
],
options={
'abstract': False,

View File

@ -2,7 +2,6 @@
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
@ -20,7 +19,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='testreport',
name='filters',
field=models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_stock_item_report_filters], verbose_name='Filters'),
field=models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250, verbose_name='Filters'),
),
migrations.AlterField(
model_name='testreport',
@ -30,6 +29,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='testreport',
name='template',
field=models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])], verbose_name='Template'),
field=models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])], verbose_name='Template'),
),
]

View File

@ -2,7 +2,6 @@
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
@ -16,7 +15,7 @@ class Migration(migrations.Migration):
name='ReportSnippet',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('snippet', models.FileField(help_text='Report snippet file', upload_to=report.models.rename_snippet, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])])),
('snippet', models.FileField(help_text='Report snippet file', upload_to='report/snippets', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])])),
('description', models.CharField(help_text='Snippet file description', max_length=250)),
],
),

View File

@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='testreport',
name='template',
field=models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template'),
field=models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template'),
),
]

View File

@ -2,7 +2,6 @@
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
@ -17,11 +16,11 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')),
('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')),
('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')),
('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs', max_length=250, validators=[report.models.validate_part_report_filters], verbose_name='Part Filters')),
('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs', max_length=250, verbose_name='Part Filters')),
],
options={
'abstract': False,
@ -30,6 +29,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='testreport',
name='filters',
field=models.CharField(blank=True, help_text='StockItem query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_stock_item_report_filters], verbose_name='Filters'),
field=models.CharField(blank=True, help_text='StockItem query filters (comma-separated list of key=value pairs)', max_length=250, verbose_name='Filters'),
),
]

View File

@ -2,7 +2,6 @@
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
@ -17,11 +16,11 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')),
('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')),
('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')),
('filters', models.CharField(blank=True, help_text='Build query filters (comma-separated list of key=value pairs', max_length=250, validators=[report.models.validate_build_report_filters], verbose_name='Build Filters')),
('filters', models.CharField(blank=True, help_text='Build query filters (comma-separated list of key=value pairs', max_length=250, verbose_name='Build Filters')),
],
options={
'abstract': False,

View File

@ -2,7 +2,6 @@
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
@ -17,11 +16,11 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')),
('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')),
('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')),
('filters', models.CharField(blank=True, help_text='Purchase order query filters', max_length=250, validators=[report.models.validate_purchase_order_filters], verbose_name='Filters')),
('filters', models.CharField(blank=True, help_text='Purchase order query filters', max_length=250, verbose_name='Filters')),
],
options={
'abstract': False,
@ -32,11 +31,11 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')),
('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')),
('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')),
('filters', models.CharField(blank=True, help_text='Sales order query filters', max_length=250, validators=[report.models.validate_sales_order_filters], verbose_name='Filters')),
('filters', models.CharField(blank=True, help_text='Sales order query filters', max_length=250, verbose_name='Filters')),
],
options={
'abstract': False,

View File

@ -15,7 +15,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='reportasset',
name='asset',
field=models.FileField(help_text='Report asset file', upload_to=report.models.rename_asset, verbose_name='Asset'),
field=models.FileField(help_text='Report asset file', upload_to='report/assets', verbose_name='Asset'),
),
migrations.AlterField(
model_name='reportasset',
@ -30,6 +30,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='reportsnippet',
name='snippet',
field=models.FileField(help_text='Report snippet file', upload_to=report.models.rename_snippet, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Snippet'),
field=models.FileField(help_text='Report snippet file', upload_to='report/snippets', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Snippet'),
),
]

View File

@ -2,7 +2,6 @@
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
@ -17,12 +16,12 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')),
('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')),
('filename_pattern', models.CharField(default='report.pdf', help_text='Pattern for generating report filenames', max_length=100, verbose_name='Filename Pattern')),
('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')),
('filters', models.CharField(blank=True, help_text='Return order query filters', max_length=250, validators=[report.models.validate_return_order_filters], verbose_name='Filters')),
('filters', models.CharField(blank=True, help_text='Return order query filters', max_length=250, verbose_name='Filters')),
],
options={
'abstract': False,

View File

@ -2,7 +2,6 @@
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
@ -18,12 +17,12 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')),
('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')),
('filename_pattern', models.CharField(default='report.pdf', help_text='Pattern for generating report filenames', max_length=100, verbose_name='Filename Pattern')),
('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')),
('filters', models.CharField(blank=True, help_text='stock location query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_stock_location_report_filters], verbose_name='Filters')),
('filters', models.CharField(blank=True, help_text='stock location query filters (comma-separated list of key=value pairs)', max_length=250, verbose_name='Filters')),
],
options={
'abstract': False,

View File

@ -0,0 +1,40 @@
# Generated by Django 4.2.11 on 2024-04-21 03:11
import InvenTree.models
import django.core.validators
from django.db import migrations, models
import report.helpers
import report.models
import report.validators
class Migration(migrations.Migration):
dependencies = [
('report', '0021_auto_20231009_0144'),
]
operations = [
migrations.CreateModel(
name='ReportTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
('name', models.CharField(help_text='Template name', unique=True, max_length=100, verbose_name='Name')),
('template', models.FileField(help_text='Template file', upload_to='report/report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('description', models.CharField(help_text='Template description', max_length=250, verbose_name='Description')),
('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Revision number (auto-increments)', verbose_name='Revision')),
('page_size', models.CharField(default=report.helpers.report_page_size_default, help_text='Page size for PDF reports', max_length=20, verbose_name='Page Size')),
('landscape', models.BooleanField(default=False, help_text='Render report in landscape orientation', verbose_name='Landscape')),
('filename_pattern', models.CharField(default='output.pdf', help_text='Pattern for generating filenames', max_length=100, verbose_name='Filename Pattern')),
('enabled', models.BooleanField(default=True, help_text='Template is enabled', verbose_name='Enabled')),
('model_type', models.CharField(max_length=100, help_text='Target model type for template', validators=[report.validators.validate_report_model_type])),
('filters', models.CharField(blank=True, help_text='Template query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.validators.validate_filters], verbose_name='Filters')),
],
options={
'abstract': False,
'unique_together': [('name', 'model_type')]
},
bases=(InvenTree.models.PluginValidationMixin, models.Model),
),
]

View File

@ -0,0 +1,102 @@
# Generated by Django 4.2.11 on 2024-04-21 04:55
import os
from django.db import migrations
from django.core.files.base import ContentFile
def report_model_map():
"""Return a map of model_type: report_type keys."""
return {
'stockitem': 'testreport',
'stocklocation': 'stocklocationreport',
'build': 'buildreport',
'part': 'billofmaterialsreport',
'purchaseorder': 'purchaseorderreport',
'salesorder': 'salesorderreport',
'returnorder': 'returnorderreport'
}
def forward(apps, schema_editor):
"""Run forwards migration.
- Create a new ReportTemplate instance for each existing report
"""
# New 'generic' report template model
ReportTemplate = apps.get_model('report', 'reporttemplate')
count = 0
for model_type, report_model in report_model_map().items():
model = apps.get_model('report', report_model)
for template in model.objects.all():
# Construct a new ReportTemplate instance
filename = template.template.path
if '/report/inventree/' in filename:
# Do not migrate internal report templates
continue
filename = os.path.basename(filename)
filedata = template.template.open('r').read()
name = template.name
offset = 1
# Prevent duplicate names during migration
while ReportTemplate.objects.filter(name=name, model_type=model_type).exists():
name = template.name + f"_{offset}"
offset += 1
ReportTemplate.objects.create(
name=name,
template=ContentFile(filedata, filename),
model_type=model_type,
description=template.description,
revision=template.revision,
filters=template.filters,
filename_pattern=template.filename_pattern,
enabled=template.enabled,
page_size=template.page_size,
landscape=template.landscape,
)
count += 1
if count > 0:
print(f"Migrated {count} report templates to new ReportTemplate model.")
def reverse(apps, schema_editor):
"""Run reverse migration.
- Delete any ReportTemplate instances in the database
"""
ReportTemplate = apps.get_model('report', 'reporttemplate')
n = ReportTemplate.objects.count()
if n > 0:
for item in ReportTemplate.objects.all():
item.template.delete()
item.delete()
print(f"Deleted {n} ReportTemplate objects and templates")
class Migration(migrations.Migration):
dependencies = [
('report', '0022_reporttemplate'),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse)
]

View File

@ -0,0 +1,34 @@
# Generated by Django 4.2.11 on 2024-04-21 14:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('report', '0023_auto_20240421_0455'),
]
operations = [
migrations.DeleteModel(
name='BillOfMaterialsReport',
),
migrations.DeleteModel(
name='BuildReport',
),
migrations.DeleteModel(
name='PurchaseOrderReport',
),
migrations.DeleteModel(
name='ReturnOrderReport',
),
migrations.DeleteModel(
name='SalesOrderReport',
),
migrations.DeleteModel(
name='StockLocationReport',
),
migrations.DeleteModel(
name='TestReport',
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 4.2.11 on 2024-04-22 12:48
import InvenTree.models
import django.core.validators
from django.db import migrations, models
import report.models
import report.validators
class Migration(migrations.Migration):
dependencies = [
('report', '0024_delete_billofmaterialsreport_delete_buildreport_and_more'),
]
operations = [
migrations.CreateModel(
name='LabelTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
('name', models.CharField(help_text='Template name', max_length=100, unique=True, verbose_name='Name')),
('description', models.CharField(help_text='Template description', max_length=250, verbose_name='Description')),
('template', models.FileField(help_text='Template file', upload_to='report/label', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Revision number (auto-increments)', verbose_name='Revision')),
('filename_pattern', models.CharField(default='output.pdf', help_text='Pattern for generating filenames', max_length=100, verbose_name='Filename Pattern')),
('enabled', models.BooleanField(default=True, help_text='Template is enabled', verbose_name='Enabled')),
('model_type', models.CharField(max_length=100, validators=[report.validators.validate_report_model_type])),
('filters', models.CharField(blank=True, help_text='Template query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.validators.validate_filters], verbose_name='Filters')),
('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')),
('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')),
],
options={
'abstract': False,
'unique_together': {('name', 'model_type')},
},
bases=(InvenTree.models.PluginValidationMixin, models.Model),
),
]

View File

@ -0,0 +1,136 @@
# Generated by Django 4.2.11 on 2024-04-22 13:01
import os
from django.db import connection, migrations
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
def label_model_map():
"""Map legacy label template models to model_type values."""
return {
"stockitemlabel": "stockitem",
"stocklocationlabel": "stocklocation",
"partlabel": "part",
"buildlinelabel": "buildline",
}
def convert_legacy_labels(table_name, model_name, template_model):
"""Map labels from an existing table to a new model type
Arguments:
table_name: The name of the existing table
model_name: The name of the new model type
template_model: The model class for the new template model
Note: We use raw SQL queries here, as the original 'label' app has been removed entirely.
"""
count = 0
fields = [
'name', 'description', 'label', 'enabled', 'height', 'width', 'filename_pattern', 'filters'
]
fieldnames = ', '.join(fields)
query = f"SELECT {fieldnames} FROM {table_name};"
with connection.cursor() as cursor:
try:
cursor.execute(query)
except Exception:
# Table likely does not exist
print(f"Legacy label table {table_name} not found - skipping migration")
return 0
rows = cursor.fetchall()
for row in rows:
data = {
fields[idx]: row[idx] for idx in range(len(fields))
}
# Skip any "builtin" labels
if 'label/inventree/' in data['label']:
continue
print(f"Creating new LabelTemplate for {model_name} - {data['name']}")
if template_model.objects.filter(name=data['name'], model_type=model_name).exists():
print(f"LabelTemplate {data['name']} already exists for {model_name} - skipping")
continue
if not default_storage.exists(data['label']):
print(f"Label template file {data['label']} does not exist - skipping")
continue
# Create a new template file object
filedata = default_storage.open(data['label']).read()
filename = os.path.basename(data['label'])
# Remove the 'label' key from the data dictionary
data.pop('label')
data['template'] = ContentFile(filedata, filename)
data['model_type'] = model_name
template_model.objects.create(**data)
count += 1
return count
def forward(apps, schema_editor):
"""Run forwards migrations.
- Create a new LabelTemplate instance for each existing legacy label template.
"""
LabelTemplate = apps.get_model('report', 'labeltemplate')
count = 0
for template_class, model_type in label_model_map().items():
table_name = f'label_{template_class}'
count += convert_legacy_labels(table_name, model_type, LabelTemplate) or 0
if count > 0:
print(f"Migrated {count} report templates to new LabelTemplate model.")
def reverse(apps, schema_editor):
"""Run reverse migrations.
- Delete any LabelTemplate instances in the database
"""
LabelTemplate = apps.get_model('report', 'labeltemplate')
n = LabelTemplate.objects.count()
if n > 0:
for item in LabelTemplate.objects.all():
item.template.delete()
item.delete()
print(f"Deleted {n} LabelTemplate objects and templates")
class Migration(migrations.Migration):
atomic = False
dependencies = [
('report', '0025_labeltemplate'),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse)
]

View File

@ -0,0 +1,77 @@
# Generated by Django 4.2.11 on 2024-04-30 09:50
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import report.models
import report.validators
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('report', '0026_auto_20240422_1301'),
]
operations = [
migrations.AlterField(
model_name='labeltemplate',
name='model_type',
field=models.CharField(help_text='Target model type for template', max_length=100, validators=[report.validators.validate_report_model_type]),
),
migrations.AlterField(
model_name='labeltemplate',
name='template',
field=models.FileField(help_text='Template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template'),
),
migrations.AlterField(
model_name='reportasset',
name='asset',
field=models.FileField(help_text='Report asset file', upload_to=report.models.rename_template, verbose_name='Asset'),
),
migrations.AlterField(
model_name='reportsnippet',
name='snippet',
field=models.FileField(help_text='Report snippet file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Snippet'),
),
migrations.AlterField(
model_name='reporttemplate',
name='template',
field=models.FileField(help_text='Template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template'),
),
migrations.CreateModel(
name='ReportOutput',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)),
('items', models.PositiveIntegerField(default=0, help_text='Number of items to process', verbose_name='Items')),
('complete', models.BooleanField(default=False, help_text='Report generation is complete', verbose_name='Complete')),
('progress', models.PositiveIntegerField(default=0, help_text='Report generation progress', verbose_name='Progress')),
('output', models.FileField(blank=True, help_text='Generated output file', null=True, upload_to='report/output', verbose_name='Output File')),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='report.reporttemplate', verbose_name='Report Template')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='LabelOutput',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)),
('items', models.PositiveIntegerField(default=0, help_text='Number of items to process', verbose_name='Items')),
('complete', models.BooleanField(default=False, help_text='Report generation is complete', verbose_name='Complete')),
('progress', models.PositiveIntegerField(default=0, help_text='Report generation progress', verbose_name='Progress')),
('output', models.FileField(blank=True, help_text='Generated output file', null=True, upload_to='label/output', verbose_name='Output File')),
('plugin', models.CharField(blank=True, help_text='Label output plugin', max_length=100, verbose_name='Plugin')),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='report.labeltemplate', verbose_name='Label Template')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,23 @@
"""Report mixin classes."""
from django.db import models
class InvenTreeReportMixin(models.Model):
"""A mixin class for adding report generation functionality to a model class.
In addition to exposing the model to the report generation interface,
this mixin provides a hook for providing extra context information to the reports.
"""
class Meta:
"""Metaclass options for this mixin."""
abstract = True
def report_context(self) -> dict:
"""Generate a dict of context data to provide to the reporting framework.
The default implementation returns an empty dict object.
"""
return {}

File diff suppressed because it is too large Load Diff

View File

@ -1,102 +1,208 @@
"""API serializers for the reporting models."""
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
import plugin.models
import plugin.serializers
import report.helpers
import report.models
from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer,
UserSerializer,
)
class ReportSerializerBase(InvenTreeModelSerializer):
"""Base class for report serializer."""
"""Base serializer class for report and label templates."""
template = InvenTreeAttachmentSerializerField(required=True)
def __init__(self, *args, **kwargs):
"""Override the constructor for the ReportSerializerBase.
The primary goal here is to ensure that the 'choices' attribute
is set correctly for the 'model_type' field.
"""
super().__init__(*args, **kwargs)
if len(self.fields['model_type'].choices) == 0:
self.fields['model_type'].choices = report.helpers.report_model_options()
@staticmethod
def report_fields():
"""Generic serializer fields for a report template."""
def base_fields():
"""Base serializer field set."""
return [
'pk',
'name',
'description',
'model_type',
'template',
'filters',
'page_size',
'landscape',
'filename_pattern',
'enabled',
'revision',
]
template = InvenTreeAttachmentSerializerField(required=True)
class TestReportSerializer(ReportSerializerBase):
"""Serializer class for the TestReport model."""
revision = serializers.IntegerField(read_only=True)
# Note: The choices are overridden at run-time
model_type = serializers.ChoiceField(
label=_('Model Type'),
choices=report.helpers.report_model_options(),
required=True,
allow_blank=False,
allow_null=False,
)
class ReportTemplateSerializer(ReportSerializerBase):
"""Serializer class for report template model."""
class Meta:
"""Metaclass options."""
model = report.models.TestReport
fields = ReportSerializerBase.report_fields()
model = report.models.ReportTemplate
fields = [*ReportSerializerBase.base_fields(), 'page_size', 'landscape']
page_size = serializers.ChoiceField(
required=False,
default=report.helpers.report_page_size_default(),
choices=report.helpers.report_page_size_options(),
)
class BuildReportSerializer(ReportSerializerBase):
"""Serializer class for the BuildReport model."""
class ReportPrintSerializer(serializers.Serializer):
"""Serializer class for printing a report."""
class Meta:
"""Metaclass options."""
model = report.models.BuildReport
fields = ReportSerializerBase.report_fields()
fields = ['template', 'items']
template = serializers.PrimaryKeyRelatedField(
queryset=report.models.ReportTemplate.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Template'),
help_text=_('Select report template'),
)
items = serializers.ListField(
child=serializers.IntegerField(),
required=True,
allow_empty=False,
label=_('Items'),
help_text=_('List of item primary keys to include in the report'),
)
class BOMReportSerializer(ReportSerializerBase):
"""Serializer class for the BillOfMaterialsReport model."""
class LabelPrintSerializer(serializers.Serializer):
"""Serializer class for printing a label."""
# List of extra plugin field names
plugin_fields = []
class Meta:
"""Metaclass options."""
model = report.models.BillOfMaterialsReport
fields = ReportSerializerBase.report_fields()
fields = ['template', 'items', 'plugin']
def __init__(self, *args, **kwargs):
"""Override the constructor to add the extra plugin fields."""
# Reset to a known state
self.Meta.fields = ['template', 'items', 'plugin']
if plugin_serializer := kwargs.pop('plugin_serializer', None):
for key, field in plugin_serializer.fields.items():
self.Meta.fields.append(key)
setattr(self, key, field)
super().__init__(*args, **kwargs)
template = serializers.PrimaryKeyRelatedField(
queryset=report.models.LabelTemplate.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Template'),
help_text=_('Select label template'),
)
# Plugin field - note that we use the 'key' (not the pk) for lookup
plugin = plugin.serializers.PluginRelationSerializer(
many=False,
required=False,
allow_null=False,
label=_('Printing Plugin'),
help_text=_('Select plugin to use for label printing'),
)
items = serializers.ListField(
child=serializers.IntegerField(),
required=True,
allow_empty=False,
label=_('Items'),
help_text=_('List of item primary keys to include in the report'),
)
class PurchaseOrderReportSerializer(ReportSerializerBase):
"""Serializer class for the PurchaseOrdeReport model."""
class LabelTemplateSerializer(ReportSerializerBase):
"""Serializer class for label template model."""
class Meta:
"""Metaclass options."""
model = report.models.PurchaseOrderReport
fields = ReportSerializerBase.report_fields()
model = report.models.LabelTemplate
fields = [*ReportSerializerBase.base_fields(), 'width', 'height']
class SalesOrderReportSerializer(ReportSerializerBase):
"""Serializer class for the SalesOrderReport model."""
class BaseOutputSerializer(InvenTreeModelSerializer):
"""Base serializer class for template output."""
@staticmethod
def base_fields():
"""Basic field set."""
return [
'pk',
'created',
'user',
'user_detail',
'model_type',
'items',
'complete',
'progress',
'output',
'template',
]
output = InvenTreeAttachmentSerializerField()
model_type = serializers.CharField(source='template.model_type', read_only=True)
user_detail = UserSerializer(source='user', read_only=True, many=False)
class LabelOutputSerializer(BaseOutputSerializer):
"""Serializer class for the LabelOutput model."""
class Meta:
"""Metaclass options."""
model = report.models.SalesOrderReport
fields = ReportSerializerBase.report_fields()
model = report.models.LabelOutput
fields = [*BaseOutputSerializer.base_fields(), 'plugin']
class ReturnOrderReportSerializer(ReportSerializerBase):
"""Serializer class for the ReturnOrderReport model."""
class ReportOutputSerializer(BaseOutputSerializer):
"""Serializer class for the ReportOutput model."""
class Meta:
"""Metaclass options."""
model = report.models.ReturnOrderReport
fields = ReportSerializerBase.report_fields()
class StockLocationReportSerializer(ReportSerializerBase):
"""Serializer class for the StockLocationReport model."""
class Meta:
"""Metaclass options."""
model = report.models.StockLocationReport
fields = ReportSerializerBase.report_fields()
model = report.models.ReportOutput
fields = BaseOutputSerializer.base_fields()
class ReportSnippetSerializer(InvenTreeModelSerializer):

View File

@ -0,0 +1,17 @@
"""Background tasks for the report app."""
from datetime import timedelta
from InvenTree.helpers import current_time
from InvenTree.tasks import ScheduledTask, scheduled_task
from report.models import LabelOutput, ReportOutput
@scheduled_task(ScheduledTask.DAILY)
def cleanup_old_report_outputs():
"""Remove old report/label outputs from the database."""
# Remove any outputs which are older than 5 days
threshold = current_time() - timedelta(days=5)
LabelOutput.objects.filter(created__lte=threshold).delete()
ReportOutput.objects.filter(created__lte=threshold).delete()

View File

@ -1,3 +0,0 @@
{% extends "report/inventree_build_order_base.html" %}
<!-- Refer to the inventree_build_order_base template -->

View File

@ -12,7 +12,7 @@ margin-top: 4cm;
{% endblock page_margin %}
{% block bottom_left %}
content: "v{{ report_revision }} - {% format_date date %}";
content: "v{{ template_revision }} - {% format_date date %}";
{% endblock bottom_left %}
{% block bottom_center %}

View File

@ -1 +0,0 @@
{% extends "report/inventree_po_report_base.html" %}

View File

@ -1 +1,62 @@
{% extends "report/inventree_return_order_report_base.html" %}
{% extends "report/inventree_order_report_base.html" %}
{% load i18n %}
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block header_content %}
<img class='logo' src='{% company_image customer %}' alt="{{ customer }}" width='150'>
<div class='header-right'>
<h3>{% trans "Return Order" %} {{ prefix }}{{ reference }}</h3>
{% if customer %}{{ customer.name }}{% endif %}
</div>
{% endblock header_content %}
{% block page_content %}
<h3>{% trans "Line Items" %}</h3>
<table class='table table-striped table-condensed'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Serial Number" %}</th>
<th>{% trans "Reference" %}</th>
<th>{% trans "Note" %}</th>
</tr>
</thead>
<tbody>
{% for line in lines.all %}
<tr>
<td>
<div class='thumb-container'>
<img src='{% part_image line.item.part height=240 %}' alt='{% trans "Image" %}' class='part-thumb'>
</div>
<div class='part-text'>
{{ line.item.part.full_name }}
</div>
</td>
<td>{{ line.item.serial }}</td>
<td>{{ line.reference }}</td>
<td>{{ line.notes }}</td>
</tr>
{% endfor %}
{% if extra_lines %}
<tr><th colspan='4'>{% trans "Extra Line Items" %}</th></tr>
{% for line in extra_lines.all %}
<tr>
<td><!-- No part --></td>
<td><!-- No serial --></td>
<td>{{ line.reference }}</td>
<td>{{ line.notes }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
{% endblock page_content %}

View File

@ -1,62 +0,0 @@
{% extends "report/inventree_order_report_base.html" %}
{% load i18n %}
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block header_content %}
<img class='logo' src='{% company_image customer %}' alt="{{ customer }}" width='150'>
<div class='header-right'>
<h3>{% trans "Return Order" %} {{ prefix }}{{ reference }}</h3>
{% if customer %}{{ customer.name }}{% endif %}
</div>
{% endblock header_content %}
{% block page_content %}
<h3>{% trans "Line Items" %}</h3>
<table class='table table-striped table-condensed'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Serial Number" %}</th>
<th>{% trans "Reference" %}</th>
<th>{% trans "Note" %}</th>
</tr>
</thead>
<tbody>
{% for line in lines.all %}
<tr>
<td>
<div class='thumb-container'>
<img src='{% part_image line.item.part height=240 %}' alt='{% trans "Image" %}' class='part-thumb'>
</div>
<div class='part-text'>
{{ line.item.part.full_name }}
</div>
</td>
<td>{{ line.item.serial }}</td>
<td>{{ line.reference }}</td>
<td>{{ line.notes }}</td>
</tr>
{% endfor %}
{% if extra_lines %}
<tr><th colspan='4'>{% trans "Extra Line Items" %}</th></tr>
{% for line in extra_lines.all %}
<tr>
<td><!-- No part --></td>
<td><!-- No serial --></td>
<td>{{ line.reference }}</td>
<td>{{ line.notes }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
{% endblock page_content %}

View File

@ -1 +0,0 @@
{% extends "report/inventree_so_report_base.html" %}

Some files were not shown because too many files have changed in this diff Show More