2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-13 18:43:08 +00:00

[WIP] Background reports (#9199)

* Update report generation progress

* Add shim task for offloading report printing

* Cleanup

* Add detail endpoints for label and report outputs

* Display report printing progress in UI

* Implement similar for label printing

* Reduce output for CI

* Add plugin slug

* Bump API version

* Ensure it works with machine printing

* Fix null comparison

* Fix SKU link

* Update playwright tests

* Massively reduce log output when printing
This commit is contained in:
Oliver 2025-03-04 23:40:54 +11:00 committed by GitHub
parent d5a176c121
commit d822b9b574
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 407 additions and 130 deletions

View File

@ -273,7 +273,7 @@ jobs:
INVENTREE_PYTHON_TEST_PASSWORD: testpassword INVENTREE_PYTHON_TEST_PASSWORD: testpassword
INVENTREE_SITE_URL: http://127.0.0.1:12345 INVENTREE_SITE_URL: http://127.0.0.1:12345
INVENTREE_DEBUG: true INVENTREE_DEBUG: true
INVENTREE_LOG_LEVEL: INFO INVENTREE_LOG_LEVEL: WARNING
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2
@ -358,7 +358,7 @@ jobs:
INVENTREE_DB_HOST: "127.0.0.1" INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432 INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: true INVENTREE_DEBUG: true
INVENTREE_LOG_LEVEL: INFO INVENTREE_LOG_LEVEL: WARNING
INVENTREE_CONSOLE_LOG: false INVENTREE_CONSOLE_LOG: false
INVENTREE_CACHE_HOST: localhost INVENTREE_CACHE_HOST: localhost
INVENTREE_PLUGINS_ENABLED: true INVENTREE_PLUGINS_ENABLED: true
@ -408,7 +408,7 @@ jobs:
INVENTREE_DB_HOST: "127.0.0.1" INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 3306 INVENTREE_DB_PORT: 3306
INVENTREE_DEBUG: true INVENTREE_DEBUG: true
INVENTREE_LOG_LEVEL: INFO INVENTREE_LOG_LEVEL: WARNING
INVENTREE_CONSOLE_LOG: false INVENTREE_CONSOLE_LOG: false
INVENTREE_PLUGINS_ENABLED: true INVENTREE_PLUGINS_ENABLED: true
@ -455,7 +455,7 @@ jobs:
INVENTREE_DB_HOST: "127.0.0.1" INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432 INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: true INVENTREE_DEBUG: true
INVENTREE_LOG_LEVEL: INFO INVENTREE_LOG_LEVEL: WARNING
INVENTREE_PLUGINS_ENABLED: false INVENTREE_PLUGINS_ENABLED: false
services: services:
@ -498,7 +498,7 @@ jobs:
INVENTREE_DB_ENGINE: sqlite3 INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3 INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
INVENTREE_DEBUG: true INVENTREE_DEBUG: true
INVENTREE_LOG_LEVEL: INFO INVENTREE_LOG_LEVEL: WARNING
INVENTREE_PLUGINS_ENABLED: false INVENTREE_PLUGINS_ENABLED: false
steps: steps:

View File

@ -1,13 +1,17 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 318 INVENTREE_API_VERSION = 319
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v319 - 2025-03-04 : https://github.com/inventree/InvenTree/pull/9199
- Add detail API endpoint for the LabelOutput model
- Add detail API endpoint for the ReportOutput model
v318 - 2025-02-25 : https://github.com/inventree/InvenTree/pull/9116 v318 - 2025-02-25 : https://github.com/inventree/InvenTree/pull/9116
- Adds user profile API endpoints - Adds user profile API endpoints

View File

@ -193,12 +193,12 @@ class LabelPrintingMixin:
) )
# Update the progress of the print job # Update the progress of the print job
output.progress += int(100 / N) output.progress += 1
output.save() output.save()
# Mark the output as complete # Mark the output as complete
output.complete = True output.complete = True
output.progress = 100 output.progress = N
# Add in the generated file (if applicable) # Add in the generated file (if applicable)
output.output = self.get_generated_file(**print_args) output.output = self.get_generated_file(**print_args)

View File

@ -3,7 +3,6 @@
from typing import cast from typing import cast
from django.conf import settings from django.conf import settings
from django.http import JsonResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
@ -79,18 +78,26 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin):
'printing_options': kwargs['printing_options'].get('driver_options', {}), 'printing_options': kwargs['printing_options'].get('driver_options', {}),
} }
# save the current used printer as last used printer if output:
# only the last ten used printers are saved so that this list doesn't grow infinitely user = output.user
last_used_printers = get_last_used_printers(request.user) elif request:
machine_pk = str(machine.pk) user = request.user
if machine_pk in last_used_printers: else:
last_used_printers.remove(machine_pk) user = None
last_used_printers.insert(0, machine_pk)
InvenTreeUserSetting.set_setting( # Save the current used printer as last used printer
'LAST_USED_PRINTING_MACHINES', # Only the last ten used printers are saved so that this list doesn't grow infinitely
','.join(last_used_printers[:10]), if user and user.is_authenticated:
user=request.user, last_used_printers = get_last_used_printers(user)
) machine_pk = str(machine.pk)
if machine_pk in last_used_printers:
last_used_printers.remove(machine_pk)
last_used_printers.insert(0, machine_pk)
InvenTreeUserSetting.set_setting(
'LAST_USED_PRINTING_MACHINES',
','.join(last_used_printers[:10]),
user=user,
)
offload_task( offload_task(
call_machine_function, call_machine_function,
@ -98,15 +105,17 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin):
'print_labels', 'print_labels',
label, label,
items, items,
output=output,
force_sync=settings.TESTING or driver.USE_BACKGROUND_WORKER, force_sync=settings.TESTING or driver.USE_BACKGROUND_WORKER,
group='plugin', group='plugin',
**print_kwargs, **print_kwargs,
) )
return JsonResponse({ # Inform the user that the process has been offloaded to the printer
'success': True, if output:
'message': f'{len(items)} labels printed', output.output = None
}) output.complete = True
output.save()
class PrintingOptionsSerializer(serializers.Serializer): class PrintingOptionsSerializer(serializers.Serializer):
"""Printing options serializer that adds a machine select and the machines options.""" """Printing options serializer that adds a machine select and the machines options."""

View File

@ -136,6 +136,10 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
idx += n_cells idx += n_cells
# Update printing progress
output.progress += 1
output.save()
if len(pages) == 0: if len(pages) == 0:
raise ValidationError(_('No labels were generated')) raise ValidationError(_('No labels were generated'))
@ -152,7 +156,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
output.output = ContentFile(document, 'labels.pdf') output.output = ContentFile(document, 'labels.pdf')
output.progress = 100 output.progress = n_labels
output.complete = True output.complete = True
output.save() output.save()

View File

@ -194,16 +194,32 @@ class LabelPrint(GenericAPIView):
def print(self, template, items_to_print, plugin, request): def print(self, template, items_to_print, plugin, request):
"""Print this label template against a number of provided items.""" """Print this label template against a number of provided items."""
import report.tasks
from InvenTree.tasks import offload_task
if plugin_serializer := plugin.get_printing_options_serializer( if plugin_serializer := plugin.get_printing_options_serializer(
request, data=request.data, context=self.get_serializer_context() request, data=request.data, context=self.get_serializer_context()
): ):
plugin_serializer.is_valid(raise_exception=True) plugin_serializer.is_valid(raise_exception=True)
output = template.print( # Generate a new LabelOutput object to print against
items_to_print, output = report.models.LabelOutput.objects.create(
plugin, template=template,
plugin=plugin.slug,
user=request.user,
progress=0,
items=len(items_to_print),
complete=False,
output=None,
)
offload_task(
report.tasks.print_labels,
template.pk,
[item.pk for item in items_to_print],
output.pk,
plugin.slug,
options=(plugin_serializer.data if plugin_serializer else {}), options=(plugin_serializer.data if plugin_serializer else {}),
request=request,
) )
output.refresh_from_db() output.refresh_from_db()
@ -255,8 +271,30 @@ class ReportPrint(GenericAPIView):
return self.print(template, instances, request) return self.print(template, instances, request)
def print(self, template, items_to_print, request): def print(self, template, items_to_print, request):
"""Print this report template against a number of provided items.""" """Print this report template against a number of provided items.
output = template.print(items_to_print, request)
This functionality is offloaded to the background worker process,
which will update the status of the ReportOutput object as it progresses.
"""
import report.tasks
from InvenTree.tasks import offload_task
# Generate a new ReportOutput object
output = report.models.ReportOutput.objects.create(
template=template,
user=request.user,
progress=0,
items=len(items_to_print),
complete=False,
output=None,
)
item_ids = [item.pk for item in items_to_print]
# Offload the task to the background worker
offload_task(report.tasks.print_reports, template.pk, item_ids, output.pk)
output.refresh_from_db()
return Response( return Response(
report.serializers.ReportOutputSerializer(output).data, status=201 report.serializers.ReportOutputSerializer(output).data, status=201
@ -317,24 +355,36 @@ class TemplateOutputMixin:
ordering_field_aliases = {'model_type': 'template__model_type'} ordering_field_aliases = {'model_type': 'template__model_type'}
class LabelOutputList( class LabelOutputMixin(TemplatePermissionMixin, TemplateOutputMixin):
TemplatePermissionMixin, TemplateOutputMixin, BulkDeleteMixin, ListAPI """Mixin class for a label output API endpoint."""
):
"""List endpoint for LabelOutput objects."""
queryset = report.models.LabelOutput.objects.all() queryset = report.models.LabelOutput.objects.all()
serializer_class = report.serializers.LabelOutputSerializer serializer_class = report.serializers.LabelOutputSerializer
class ReportOutputList( class LabelOutputList(LabelOutputMixin, BulkDeleteMixin, ListAPI):
TemplatePermissionMixin, TemplateOutputMixin, BulkDeleteMixin, ListAPI """List endpoint for LabelOutput objects."""
):
"""List endpoint for ReportOutput objects."""
class LabelOutputDetail(LabelOutputMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for LabelOutput objects."""
class ReportOutputMixin(TemplatePermissionMixin, TemplateOutputMixin):
"""Mixin class for a report output API endpoint."""
queryset = report.models.ReportOutput.objects.all() queryset = report.models.ReportOutput.objects.all()
serializer_class = report.serializers.ReportOutputSerializer serializer_class = report.serializers.ReportOutputSerializer
class ReportOutputList(ReportOutputMixin, BulkDeleteMixin, ListAPI):
"""List endpoint for ReportOutput objects."""
class ReportOutputDetail(ReportOutputMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for ReportOutput objects."""
label_api_urls = [ label_api_urls = [
# Printing endpoint # Printing endpoint
path('print/', LabelPrint.as_view(), name='api-label-print'), path('print/', LabelPrint.as_view(), name='api-label-print'),
@ -364,7 +414,12 @@ label_api_urls = [
# Label outputs # Label outputs
path( path(
'output/', 'output/',
include([path('', LabelOutputList.as_view(), name='api-label-output-list')]), include([
path(
'<int:pk>/', LabelOutputDetail.as_view(), name='api-label-output-detail'
),
path('', LabelOutputList.as_view(), name='api-label-output-list'),
]),
), ),
] ]
@ -397,7 +452,14 @@ report_api_urls = [
# Generated report outputs # Generated report outputs
path( path(
'output/', 'output/',
include([path('', ReportOutputList.as_view(), name='api-report-output-list')]), include([
path(
'<int:pk>/',
ReportOutputDetail.as_view(),
name='api-report-output-detail',
),
path('', ReportOutputList.as_view(), name='api-report-output-list'),
]),
), ),
# Report assets # Report assets
path( path(

View File

@ -10,12 +10,13 @@ from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.db.utils import IntegrityError, OperationalError, ProgrammingError from django.db.utils import IntegrityError, OperationalError, ProgrammingError
import structlog
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
import InvenTree.exceptions import InvenTree.exceptions
import InvenTree.ready import InvenTree.ready
logger = logging.getLogger('inventree') logger = structlog.getLogger('inventree')
class ReportConfig(AppConfig): class ReportConfig(AppConfig):
@ -26,8 +27,12 @@ class ReportConfig(AppConfig):
def ready(self): def ready(self):
"""This function is called whenever the app is loaded.""" """This function is called whenever the app is loaded."""
# Configure logging for PDF generation (disable "info" messages) # Configure logging for PDF generation (disable "info" messages)
logging.getLogger('fontTools').setLevel(logging.WARNING)
logging.getLogger('weasyprint').setLevel(logging.WARNING) # Reduce log output for fontTools and weasyprint
for name, log_manager in logging.root.manager.loggerDict.items():
if name.lower().startswith(('fonttools', 'weasyprint')):
if hasattr(log_manager, 'setLevel'):
log_manager.setLevel(logging.WARNING)
super().ready() super().ready()

View File

@ -1,6 +1,5 @@
"""Report template model definitions.""" """Report template model definitions."""
import logging
import os import os
import sys import sys
@ -19,6 +18,8 @@ from django.test.client import RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import structlog
import InvenTree.exceptions import InvenTree.exceptions
import InvenTree.helpers import InvenTree.helpers
import InvenTree.models import InvenTree.models
@ -39,7 +40,7 @@ except OSError as err: # pragma: no cover
sys.exit(1) sys.exit(1)
logger = logging.getLogger('inventree') logger = structlog.getLogger('inventree')
def dummy_print_request() -> HttpRequest: def dummy_print_request() -> HttpRequest:
@ -367,11 +368,12 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
return context return context
def print(self, items: list, request=None, **kwargs) -> 'ReportOutput': def print(self, items: list, request=None, output=None, **kwargs) -> 'ReportOutput':
"""Print reports for a list of items against this template. """Print reports for a list of items against this template.
Arguments: Arguments:
items: A list of items to print reports for (model instance) items: A list of items to print reports for (model instance)
output: The ReportOutput object to use (if provided)
request: The request object (optional) request: The request object (optional)
Returns: Returns:
@ -390,6 +392,8 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
- Render using background worker, provide progress updates to UI - Render using background worker, provide progress updates to UI
- Run report generation in the background worker process - Run report generation in the background worker process
""" """
logger.info("Printing %s reports against template '%s'", len(items), self.name)
outputs = [] outputs = []
debug_mode = get_global_setting('REPORT_DEBUG_MODE', False) debug_mode = get_global_setting('REPORT_DEBUG_MODE', False)
@ -403,6 +407,23 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
report_plugins = registry.with_mixin('report') report_plugins = registry.with_mixin('report')
# If a ReportOutput object is not provided, create a new one
if not output:
output = ReportOutput.objects.create(
template=self,
items=len(items),
user=request.user
if request.user and request.user.is_authenticated
else None,
progress=0,
complete=False,
output=None,
)
if output.progress != 0:
output.progress = 0
output.save()
try: try:
for instance in items: for instance in items:
context = self.get_context(instance, request) context = self.get_context(instance, request)
@ -413,18 +434,18 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
# Render the report output # Render the report output
try: try:
if debug_mode: if debug_mode:
output = self.render_as_string(instance, request) report = self.render_as_string(instance, request)
else: else:
output = self.render(instance, request) report = self.render(instance, request)
except TemplateDoesNotExist as e: except TemplateDoesNotExist as e:
t_name = str(e) or self.template t_name = str(e) or self.template
raise ValidationError(f'Template file {t_name} does not exist') raise ValidationError(f'Template file {t_name} does not exist')
outputs.append(output) outputs.append(report)
# Attach the generated report to the model instance (if required) # Attach the generated report to the model instance (if required)
if self.attach_to_model and not debug_mode: if self.attach_to_model and not debug_mode:
data = output.get_document().write_pdf() data = report.get_document().write_pdf()
instance.create_attachment( instance.create_attachment(
attachment=ContentFile(data, report_name), attachment=ContentFile(data, report_name),
comment=_(f'Report generated from template {self.name}'), comment=_(f'Report generated from template {self.name}'),
@ -436,11 +457,16 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
# Provide generated report to any interested plugins # Provide generated report to any interested plugins
for plugin in report_plugins: for plugin in report_plugins:
try: try:
plugin.report_callback(self, instance, output, request) plugin.report_callback(self, instance, report, request)
except Exception: except Exception:
InvenTree.exceptions.log_error( InvenTree.exceptions.log_error(
f'plugins.{plugin.slug}.report_callback' f'plugins.{plugin.slug}.report_callback'
) )
# Update the progress of the report generation
output.progress += 1
output.save()
except Exception as exc: except Exception as exc:
# Something went wrong during the report generation process # Something went wrong during the report generation process
if get_global_setting('REPORT_LOG_ERRORS', backup_value=True): if get_global_setting('REPORT_LOG_ERRORS', backup_value=True):
@ -463,8 +489,8 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
pages = [] pages = []
try: try:
for output in outputs: for report in outputs:
doc = output.get_document() doc = report.get_document()
for page in doc.pages: for page in doc.pages:
pages.append(page) pages.append(page)
@ -474,14 +500,9 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
raise ValidationError(f'Template file {t_name} does not exist') raise ValidationError(f'Template file {t_name} does not exist')
# Save the generated report to the database # Save the generated report to the database
output = ReportOutput.objects.create( output.complete = True
template=self, output.output = ContentFile(data, report_name)
items=len(items), output.save()
user=request.user if request.user.is_authenticated else None,
progress=100,
complete=True,
output=ContentFile(data, report_name),
)
return output return output
@ -560,13 +581,20 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
return context return context
def print( def print(
self, items: list, plugin: InvenTreePlugin, options=None, request=None, **kwargs self,
items: list,
plugin: InvenTreePlugin,
output=None,
options=None,
request=None,
**kwargs,
) -> 'LabelOutput': ) -> 'LabelOutput':
"""Print labels for a list of items against this template. """Print labels for a list of items against this template.
Arguments: Arguments:
items: A list of items to print labels for (model instance) items: A list of items to print labels for (model instance)
plugin: The plugin to use for label rendering plugin: The plugin to use for label rendering
output: The LabelOutput object to use (if provided)
options: Additional options for the label printing plugin (optional) options: Additional options for the label printing plugin (optional)
request: The request object (optional) request: The request object (optional)
@ -576,15 +604,23 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
Raises: Raises:
ValidationError: If there is an error during label printing ValidationError: If there is an error during label printing
""" """
output = LabelOutput.objects.create( logger.info(
template=self, "Printing %s labels against template '%s' using plugin '%s'",
items=len(items), len(items),
plugin=plugin.slug, plugin.slug,
user=request.user if request else None, self.name,
progress=0,
complete=False,
) )
if not output:
output = LabelOutput.objects.create(
template=self,
items=len(items),
plugin=plugin.slug,
user=request.user if request else None,
progress=0,
complete=False,
)
if options is None: if options is None:
options = {} options = {}
@ -609,8 +645,7 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
InvenTree.exceptions.log_error(f'plugins.{plugin.slug}.print_labels') InvenTree.exceptions.log_error(f'plugins.{plugin.slug}.print_labels')
raise ValidationError([_('Error printing labels'), str(e)]) raise ValidationError([_('Error printing labels'), str(e)])
output.complete = True output.refresh_from_db()
output.save()
# Return the output object # Return the output object
return output return output

View File

@ -2,10 +2,15 @@
from datetime import timedelta from datetime import timedelta
import structlog
from InvenTree.exceptions import log_error
from InvenTree.helpers import current_time from InvenTree.helpers import current_time
from InvenTree.tasks import ScheduledTask, scheduled_task from InvenTree.tasks import ScheduledTask, scheduled_task
from report.models import LabelOutput, ReportOutput from report.models import LabelOutput, ReportOutput
logger = structlog.get_logger('inventree')
@scheduled_task(ScheduledTask.DAILY) @scheduled_task(ScheduledTask.DAILY)
def cleanup_old_report_outputs(): def cleanup_old_report_outputs():
@ -15,3 +20,70 @@ def cleanup_old_report_outputs():
LabelOutput.objects.filter(created__lte=threshold).delete() LabelOutput.objects.filter(created__lte=threshold).delete()
ReportOutput.objects.filter(created__lte=threshold).delete() ReportOutput.objects.filter(created__lte=threshold).delete()
def print_reports(template_id: int, item_ids: list[int], output_id: int, **kwargs):
"""Print multiple reports against the provided template.
Arguments:
template_id: The ID of the ReportTemplate to use
item_ids: List of item IDs to generate the report against
output_id: The ID of the ReportOutput to use (if provided)
This function is intended to be called by the background worker,
and will continuously update the status of the ReportOutput object.
"""
from report.models import ReportOutput, ReportTemplate
try:
template = ReportTemplate.objects.get(pk=template_id)
output = ReportOutput.objects.get(pk=output_id)
except Exception:
log_error('report.tasks.print_reports')
return
# Fetch the items to be included in the report
model = template.get_model()
items = model.objects.filter(pk__in=item_ids)
template.print(items, output=output)
def print_labels(
template_id: int, item_ids: list[int], output_id: int, plugin_slug: str, **kwargs
):
"""Print multiple labels against the provided template.
Arguments:
template_id: The ID of the LabelTemplate to use
item_ids: List of item IDs to generate the labels against
output_id: The ID of the LabelOutput to use (if provided)
plugin_slug: The ID of the LabelPlugin to use (if provided)
This function is intended to be called by the background worker,
and will continuously update the status of the LabelOutput object.
"""
from plugin.registry import registry
from report.models import LabelOutput, LabelTemplate
try:
template = LabelTemplate.objects.get(pk=template_id)
output = LabelOutput.objects.get(pk=output_id)
except Exception:
log_error('report.tasks.print_labels')
return
# Fetch the items to be included in the report
model = template.get_model()
items = model.objects.filter(pk__in=item_ids)
plugin = registry.get_plugin(plugin_slug)
if not plugin:
logger.warning("Label printing plugin '%s' not found", plugin_slug)
return
# Extract optional arguments for label printing
options = kwargs.pop('options') or {}
template.print(items, plugin, output=output, options=options)

View File

@ -1,10 +1,15 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications'; import { notifications, showNotification } from '@mantine/notifications';
import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react'; import {
IconCircleCheck,
IconPrinter,
IconReport,
IconTags
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { api } from '../../App'; import { api } from '../../App';
import { useApi } from '../../contexts/ApiContext';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import type { ModelType } from '../../enums/ModelType'; import type { ModelType } from '../../enums/ModelType';
import { extractAvailableFields } from '../../functions/forms'; import { extractAvailableFields } from '../../functions/forms';
@ -17,6 +22,94 @@ import {
} from '../../states/SettingsState'; } from '../../states/SettingsState';
import type { ApiFormFieldSet } from '../forms/fields/ApiFormField'; import type { ApiFormFieldSet } from '../forms/fields/ApiFormField';
import { ActionDropdown } from '../items/ActionDropdown'; import { ActionDropdown } from '../items/ActionDropdown';
import { ProgressBar } from '../items/ProgressBar';
/**
* Hook to track the progress of a printing operation
*/
function usePrintingProgress({
title,
outputId,
endpoint
}: {
title: string;
outputId?: number;
endpoint: ApiEndpoints;
}) {
const api = useApi();
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
if (!!outputId) {
setLoading(true);
showNotification({
id: `printing-progress-${endpoint}-${outputId}`,
title: title,
loading: true,
autoClose: false,
withCloseButton: false,
message: <ProgressBar size='lg' value={0} progressLabel />
});
} else {
setLoading(false);
}
}, [outputId, endpoint, title]);
const progress = useQuery({
enabled: !!outputId && loading,
refetchInterval: 750,
queryKey: ['printingProgress', endpoint, outputId],
queryFn: () =>
api
.get(apiUrl(endpoint, outputId))
.then((response) => {
const data = response?.data ?? {};
if (data.pk && data.pk == outputId) {
if (data.complete) {
setLoading(false);
notifications.hide(`printing-progress-${endpoint}-${outputId}`);
notifications.hide('print-success');
notifications.show({
id: 'print-success',
title: t`Printing`,
message: t`Printing completed successfully`,
color: 'green',
icon: <IconCircleCheck />
});
if (data.output) {
const url = generateUrl(data.output);
window.open(url.toString(), '_blank');
}
} else {
notifications.update({
id: `printing-progress-${endpoint}-${outputId}`,
autoClose: false,
withCloseButton: false,
message: (
<ProgressBar
size='lg'
value={data.progress}
maximum={data.items}
progressLabel
/>
)
});
}
}
return data;
})
.catch(() => {
notifications.hide(`printing-progress-${endpoint}-${outputId}`);
setLoading(false);
return {};
})
});
}
export function PrintingActions({ export function PrintingActions({
items, items,
@ -46,6 +139,21 @@ export function PrintingActions({
return enableReports && globalSettings.isSet('REPORT_ENABLE'); return enableReports && globalSettings.isSet('REPORT_ENABLE');
}, [enableReports, globalSettings]); }, [enableReports, globalSettings]);
const [labelId, setLabelId] = useState<number | undefined>(undefined);
const [reportId, setReportId] = useState<number | undefined>(undefined);
const labelProgress = usePrintingProgress({
title: t`Printing Labels`,
outputId: labelId,
endpoint: ApiEndpoints.label_output
});
const reportProgress = usePrintingProgress({
title: t`Printing Reports`,
outputId: reportId,
endpoint: ApiEndpoints.report_output
});
// Fetch available printing fields via OPTIONS request // Fetch available printing fields via OPTIONS request
const printingFields = useQuery({ const printingFields = useQuery({
enabled: labelPrintingEnabled, enabled: labelPrintingEnabled,
@ -106,36 +214,22 @@ export function PrintingActions({
url: apiUrl(ApiEndpoints.label_print), url: apiUrl(ApiEndpoints.label_print),
title: t`Print Label`, title: t`Print Label`,
fields: labelFields, fields: labelFields,
timeout: (items.length + 1) * 5000, timeout: 5000,
onClose: () => { onClose: () => {
setPluginKey(''); setPluginKey('');
}, },
submitText: t`Print`, submitText: t`Print`,
successMessage: t`Label printing completed successfully`, successMessage: null,
onFormSuccess: (response: any) => { onFormSuccess: (response: any) => {
setPluginKey(''); setPluginKey('');
if (!response.complete) { setLabelId(response.pk);
// TODO: Periodically check for completion (requires server-side changes)
notifications.show({
title: t`Error`,
message: t`The label could not be generated`,
color: 'red'
});
return;
}
if (response.output) {
// An output file was generated
const url = generateUrl(response.output);
window.open(url.toString(), '_blank');
}
} }
}); });
const reportModal = useCreateApiFormModal({ const reportModal = useCreateApiFormModal({
title: t`Print Report`, title: t`Print Report`,
url: apiUrl(ApiEndpoints.report_print), url: apiUrl(ApiEndpoints.report_print),
timeout: (items.length + 1) * 5000, timeout: 5000,
fields: { fields: {
template: { template: {
filters: { filters: {
@ -149,24 +243,10 @@ export function PrintingActions({
value: items value: items
} }
}, },
submitText: t`Generate`, submitText: t`Print`,
successMessage: t`Report printing completed successfully`, successMessage: null,
onFormSuccess: (response: any) => { onFormSuccess: (response: any) => {
if (!response.complete) { setReportId(response.pk);
// TODO: Periodically check for completion (requires server-side changes)
notifications.show({
title: t`Error`,
message: t`The report could not be generated`,
color: 'red'
});
return;
}
if (response.output) {
// An output file was generated
const url = generateUrl(response.output);
window.open(url.toString(), '_blank');
}
} }
}); });

View File

@ -92,7 +92,7 @@ export interface ApiFormProps {
preFormWarning?: string; preFormWarning?: string;
preFormSuccess?: string; preFormSuccess?: string;
postFormContent?: JSX.Element; postFormContent?: JSX.Element;
successMessage?: string; successMessage?: string | null;
onFormSuccess?: (data: any) => void; onFormSuccess?: (data: any) => void;
onFormError?: (response: any) => void; onFormError?: (response: any) => void;
processFormData?: (data: any) => any; processFormData?: (data: any) => any;

View File

@ -84,7 +84,10 @@ export function useCreateApiFormModal(props: ApiFormModalProps) {
() => ({ () => ({
...props, ...props,
fetchInitialData: props.fetchInitialData ?? false, fetchInitialData: props.fetchInitialData ?? false,
successMessage: props.successMessage ?? t`Item Created`, successMessage:
props.successMessage === null
? null
: (props.successMessage ?? t`Item Created`),
method: 'POST' method: 'POST'
}), }),
[props] [props]
@ -101,7 +104,10 @@ export function useEditApiFormModal(props: ApiFormModalProps) {
() => ({ () => ({
...props, ...props,
fetchInitialData: props.fetchInitialData ?? true, fetchInitialData: props.fetchInitialData ?? true,
successMessage: props.successMessage ?? t`Item Updated`, successMessage:
props.successMessage === null
? null
: (props.successMessage ?? t`Item Updated`),
method: 'PATCH' method: 'PATCH'
}), }),
[props] [props]
@ -120,7 +126,10 @@ export function useDeleteApiFormModal(props: ApiFormModalProps) {
method: 'DELETE', method: 'DELETE',
submitText: t`Delete`, submitText: t`Delete`,
submitColor: 'red', submitColor: 'red',
successMessage: props.successMessage ?? t`Item Deleted`, successMessage:
props.successMessage === null
? null
: (props.successMessage ?? t`Item Deleted`),
preFormContent: props.preFormContent ?? ( preFormContent: props.preFormContent ?? (
<Alert <Alert
color={'red'} color={'red'}

View File

@ -122,7 +122,7 @@ export default function SupplierPartDetail() {
} }
]; ];
const tr: DetailsField[] = [ const bl: DetailsField[] = [
{ {
type: 'link', type: 'link',
name: 'supplier', name: 'supplier',
@ -165,7 +165,7 @@ export default function SupplierPartDetail() {
} }
]; ];
const bl: DetailsField[] = [ const br: DetailsField[] = [
{ {
type: 'string', type: 'string',
name: 'packaging', name: 'packaging',
@ -183,7 +183,7 @@ export default function SupplierPartDetail() {
} }
]; ];
const br: DetailsField[] = [ const tr: DetailsField[] = [
{ {
type: 'string', type: 'string',
name: 'in_stock', name: 'in_stock',
@ -232,9 +232,9 @@ export default function SupplierPartDetail() {
<DetailsTable title={t`Part Details`} fields={tl} item={data} /> <DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<DetailsTable title={t`Supplier`} fields={tr} item={data} /> <DetailsTable title={t`Supplier`} fields={bl} item={data} />
<DetailsTable title={t`Packaging`} fields={bl} item={data} /> <DetailsTable title={t`Packaging`} fields={br} item={data} />
<DetailsTable title={t`Availability`} fields={br} item={data} /> <DetailsTable title={t`Availability`} fields={tr} item={data} />
</ItemDetailsGrid> </ItemDetailsGrid>
); );
}, [supplierPart, instanceQuery.isFetching]); }, [supplierPart, instanceQuery.isFetching]);

View File

@ -212,6 +212,7 @@ export default function StockDetail() {
name: 'supplier_part', name: 'supplier_part',
label: t`Supplier Part`, label: t`Supplier Part`,
type: 'link', type: 'link',
model_field: 'SKU',
model: ModelType.supplierpart, model: ModelType.supplierpart,
hidden: !stockitem.supplier_part hidden: !stockitem.supplier_part
}, },

View File

@ -41,9 +41,7 @@ test('Label Printing', async ({ page }) => {
await page.getByRole('button', { name: 'Print', exact: true }).isEnabled(); await page.getByRole('button', { name: 'Print', exact: true }).isEnabled();
await page.getByRole('button', { name: 'Print', exact: true }).click(); await page.getByRole('button', { name: 'Print', exact: true }).click();
await page.locator('#form-success').waitFor(); await page.getByText('Printing completed successfully').first().waitFor();
await page.getByText('Label printing completed').waitFor();
await page.context().close(); await page.context().close();
}); });
@ -75,12 +73,10 @@ test('Report Printing', async ({ page }) => {
await page.waitForTimeout(100); await page.waitForTimeout(100);
// Submit the print form (should result in success) // Submit the print form (should result in success)
await page.getByRole('button', { name: 'Generate', exact: true }).isEnabled(); await page.getByRole('button', { name: 'Print', exact: true }).isEnabled();
await page.getByRole('button', { name: 'Generate', exact: true }).click(); await page.getByRole('button', { name: 'Print', exact: true }).click();
await page.locator('#form-success').waitFor();
await page.getByText('Report printing completed').waitFor();
await page.getByText('Printing completed successfully').first().waitFor();
await page.context().close(); await page.context().close();
}); });