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:
parent
d5a176c121
commit
d822b9b574
.github/workflows
src
backend/InvenTree
InvenTree
plugin
report
frontend
src
components
hooks
pages
tests
10
.github/workflows/qc_checks.yaml
vendored
10
.github/workflows/qc_checks.yaml
vendored
@ -273,7 +273,7 @@ jobs:
|
||||
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
|
||||
INVENTREE_SITE_URL: http://127.0.0.1:12345
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: INFO
|
||||
INVENTREE_LOG_LEVEL: WARNING
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2
|
||||
@ -358,7 +358,7 @@ jobs:
|
||||
INVENTREE_DB_HOST: "127.0.0.1"
|
||||
INVENTREE_DB_PORT: 5432
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: INFO
|
||||
INVENTREE_LOG_LEVEL: WARNING
|
||||
INVENTREE_CONSOLE_LOG: false
|
||||
INVENTREE_CACHE_HOST: localhost
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
@ -408,7 +408,7 @@ jobs:
|
||||
INVENTREE_DB_HOST: "127.0.0.1"
|
||||
INVENTREE_DB_PORT: 3306
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: INFO
|
||||
INVENTREE_LOG_LEVEL: WARNING
|
||||
INVENTREE_CONSOLE_LOG: false
|
||||
INVENTREE_PLUGINS_ENABLED: true
|
||||
|
||||
@ -455,7 +455,7 @@ jobs:
|
||||
INVENTREE_DB_HOST: "127.0.0.1"
|
||||
INVENTREE_DB_PORT: 5432
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: INFO
|
||||
INVENTREE_LOG_LEVEL: WARNING
|
||||
INVENTREE_PLUGINS_ENABLED: false
|
||||
|
||||
services:
|
||||
@ -498,7 +498,7 @@ jobs:
|
||||
INVENTREE_DB_ENGINE: sqlite3
|
||||
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
|
||||
INVENTREE_DEBUG: true
|
||||
INVENTREE_LOG_LEVEL: INFO
|
||||
INVENTREE_LOG_LEVEL: WARNING
|
||||
INVENTREE_PLUGINS_ENABLED: false
|
||||
|
||||
steps:
|
||||
|
@ -1,13 +1,17 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# 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."""
|
||||
|
||||
|
||||
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
|
||||
- Adds user profile API endpoints
|
||||
|
||||
|
@ -193,12 +193,12 @@ class LabelPrintingMixin:
|
||||
)
|
||||
|
||||
# Update the progress of the print job
|
||||
output.progress += int(100 / N)
|
||||
output.progress += 1
|
||||
output.save()
|
||||
|
||||
# Mark the output as complete
|
||||
output.complete = True
|
||||
output.progress = 100
|
||||
output.progress = N
|
||||
|
||||
# Add in the generated file (if applicable)
|
||||
output.output = self.get_generated_file(**print_args)
|
||||
|
@ -3,7 +3,6 @@
|
||||
from typing import cast
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
@ -79,18 +78,26 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin):
|
||||
'printing_options': kwargs['printing_options'].get('driver_options', {}),
|
||||
}
|
||||
|
||||
# save the current used printer as last used printer
|
||||
# only the last ten used printers are saved so that this list doesn't grow infinitely
|
||||
last_used_printers = get_last_used_printers(request.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=request.user,
|
||||
)
|
||||
if output:
|
||||
user = output.user
|
||||
elif request:
|
||||
user = request.user
|
||||
else:
|
||||
user = None
|
||||
|
||||
# Save the current used printer as last used printer
|
||||
# Only the last ten used printers are saved so that this list doesn't grow infinitely
|
||||
if user and user.is_authenticated:
|
||||
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(
|
||||
call_machine_function,
|
||||
@ -98,15 +105,17 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin):
|
||||
'print_labels',
|
||||
label,
|
||||
items,
|
||||
output=output,
|
||||
force_sync=settings.TESTING or driver.USE_BACKGROUND_WORKER,
|
||||
group='plugin',
|
||||
**print_kwargs,
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': f'{len(items)} labels printed',
|
||||
})
|
||||
# Inform the user that the process has been offloaded to the printer
|
||||
if output:
|
||||
output.output = None
|
||||
output.complete = True
|
||||
output.save()
|
||||
|
||||
class PrintingOptionsSerializer(serializers.Serializer):
|
||||
"""Printing options serializer that adds a machine select and the machines options."""
|
||||
|
@ -136,6 +136,10 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
|
||||
|
||||
idx += n_cells
|
||||
|
||||
# Update printing progress
|
||||
output.progress += 1
|
||||
output.save()
|
||||
|
||||
if len(pages) == 0:
|
||||
raise ValidationError(_('No labels were generated'))
|
||||
|
||||
@ -152,7 +156,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
|
||||
|
||||
output.output = ContentFile(document, 'labels.pdf')
|
||||
|
||||
output.progress = 100
|
||||
output.progress = n_labels
|
||||
output.complete = True
|
||||
output.save()
|
||||
|
||||
|
@ -194,16 +194,32 @@ class LabelPrint(GenericAPIView):
|
||||
|
||||
def print(self, template, items_to_print, plugin, request):
|
||||
"""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(
|
||||
request, data=request.data, context=self.get_serializer_context()
|
||||
):
|
||||
plugin_serializer.is_valid(raise_exception=True)
|
||||
|
||||
output = template.print(
|
||||
items_to_print,
|
||||
plugin,
|
||||
# Generate a new LabelOutput object to print against
|
||||
output = report.models.LabelOutput.objects.create(
|
||||
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 {}),
|
||||
request=request,
|
||||
)
|
||||
|
||||
output.refresh_from_db()
|
||||
@ -255,8 +271,30 @@ class ReportPrint(GenericAPIView):
|
||||
return self.print(template, instances, request)
|
||||
|
||||
def print(self, template, items_to_print, request):
|
||||
"""Print this report template against a number of provided items."""
|
||||
output = template.print(items_to_print, request)
|
||||
"""Print this report template against a number of provided items.
|
||||
|
||||
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(
|
||||
report.serializers.ReportOutputSerializer(output).data, status=201
|
||||
@ -317,24 +355,36 @@ class TemplateOutputMixin:
|
||||
ordering_field_aliases = {'model_type': 'template__model_type'}
|
||||
|
||||
|
||||
class LabelOutputList(
|
||||
TemplatePermissionMixin, TemplateOutputMixin, BulkDeleteMixin, ListAPI
|
||||
):
|
||||
"""List endpoint for LabelOutput objects."""
|
||||
class LabelOutputMixin(TemplatePermissionMixin, TemplateOutputMixin):
|
||||
"""Mixin class for a label output API endpoint."""
|
||||
|
||||
queryset = report.models.LabelOutput.objects.all()
|
||||
serializer_class = report.serializers.LabelOutputSerializer
|
||||
|
||||
|
||||
class ReportOutputList(
|
||||
TemplatePermissionMixin, TemplateOutputMixin, BulkDeleteMixin, ListAPI
|
||||
):
|
||||
"""List endpoint for ReportOutput objects."""
|
||||
class LabelOutputList(LabelOutputMixin, BulkDeleteMixin, ListAPI):
|
||||
"""List endpoint for LabelOutput 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()
|
||||
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 = [
|
||||
# Printing endpoint
|
||||
path('print/', LabelPrint.as_view(), name='api-label-print'),
|
||||
@ -364,7 +414,12 @@ label_api_urls = [
|
||||
# Label outputs
|
||||
path(
|
||||
'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
|
||||
path(
|
||||
'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
|
||||
path(
|
||||
|
@ -10,12 +10,13 @@ from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
|
||||
import structlog
|
||||
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
|
||||
|
||||
import InvenTree.exceptions
|
||||
import InvenTree.ready
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
logger = structlog.getLogger('inventree')
|
||||
|
||||
|
||||
class ReportConfig(AppConfig):
|
||||
@ -26,8 +27,12 @@ class ReportConfig(AppConfig):
|
||||
def ready(self):
|
||||
"""This function is called whenever the app is loaded."""
|
||||
# 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()
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
"""Report template model definitions."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
@ -19,6 +18,8 @@ from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import structlog
|
||||
|
||||
import InvenTree.exceptions
|
||||
import InvenTree.helpers
|
||||
import InvenTree.models
|
||||
@ -39,7 +40,7 @@ except OSError as err: # pragma: no cover
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
logger = structlog.getLogger('inventree')
|
||||
|
||||
|
||||
def dummy_print_request() -> HttpRequest:
|
||||
@ -367,11 +368,12 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
|
||||
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.
|
||||
|
||||
Arguments:
|
||||
items: A list of items to print reports for (model instance)
|
||||
output: The ReportOutput object to use (if provided)
|
||||
request: The request object (optional)
|
||||
|
||||
Returns:
|
||||
@ -390,6 +392,8 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
- Render using background worker, provide progress updates to UI
|
||||
- Run report generation in the background worker process
|
||||
"""
|
||||
logger.info("Printing %s reports against template '%s'", len(items), self.name)
|
||||
|
||||
outputs = []
|
||||
|
||||
debug_mode = get_global_setting('REPORT_DEBUG_MODE', False)
|
||||
@ -403,6 +407,23 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
|
||||
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:
|
||||
for instance in items:
|
||||
context = self.get_context(instance, request)
|
||||
@ -413,18 +434,18 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
# Render the report output
|
||||
try:
|
||||
if debug_mode:
|
||||
output = self.render_as_string(instance, request)
|
||||
report = self.render_as_string(instance, request)
|
||||
else:
|
||||
output = self.render(instance, request)
|
||||
report = self.render(instance, request)
|
||||
except TemplateDoesNotExist as e:
|
||||
t_name = str(e) or self.template
|
||||
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)
|
||||
if self.attach_to_model and not debug_mode:
|
||||
data = output.get_document().write_pdf()
|
||||
data = report.get_document().write_pdf()
|
||||
instance.create_attachment(
|
||||
attachment=ContentFile(data, report_name),
|
||||
comment=_(f'Report generated from template {self.name}'),
|
||||
@ -436,11 +457,16 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
# Provide generated report to any interested plugins
|
||||
for plugin in report_plugins:
|
||||
try:
|
||||
plugin.report_callback(self, instance, output, request)
|
||||
plugin.report_callback(self, instance, report, request)
|
||||
except Exception:
|
||||
InvenTree.exceptions.log_error(
|
||||
f'plugins.{plugin.slug}.report_callback'
|
||||
)
|
||||
|
||||
# Update the progress of the report generation
|
||||
output.progress += 1
|
||||
output.save()
|
||||
|
||||
except Exception as exc:
|
||||
# Something went wrong during the report generation process
|
||||
if get_global_setting('REPORT_LOG_ERRORS', backup_value=True):
|
||||
@ -463,8 +489,8 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
pages = []
|
||||
|
||||
try:
|
||||
for output in outputs:
|
||||
doc = output.get_document()
|
||||
for report in outputs:
|
||||
doc = report.get_document()
|
||||
for page in doc.pages:
|
||||
pages.append(page)
|
||||
|
||||
@ -474,14 +500,9 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
raise ValidationError(f'Template file {t_name} does not exist')
|
||||
|
||||
# Save the generated report to the database
|
||||
output = ReportOutput.objects.create(
|
||||
template=self,
|
||||
items=len(items),
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
progress=100,
|
||||
complete=True,
|
||||
output=ContentFile(data, report_name),
|
||||
)
|
||||
output.complete = True
|
||||
output.output = ContentFile(data, report_name)
|
||||
output.save()
|
||||
|
||||
return output
|
||||
|
||||
@ -560,13 +581,20 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
return context
|
||||
|
||||
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':
|
||||
"""Print labels for a list of items against this template.
|
||||
|
||||
Arguments:
|
||||
items: A list of items to print labels for (model instance)
|
||||
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)
|
||||
request: The request object (optional)
|
||||
|
||||
@ -576,15 +604,23 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
Raises:
|
||||
ValidationError: If there is an error during label printing
|
||||
"""
|
||||
output = LabelOutput.objects.create(
|
||||
template=self,
|
||||
items=len(items),
|
||||
plugin=plugin.slug,
|
||||
user=request.user if request else None,
|
||||
progress=0,
|
||||
complete=False,
|
||||
logger.info(
|
||||
"Printing %s labels against template '%s' using plugin '%s'",
|
||||
len(items),
|
||||
plugin.slug,
|
||||
self.name,
|
||||
)
|
||||
|
||||
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:
|
||||
options = {}
|
||||
|
||||
@ -609,8 +645,7 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
InvenTree.exceptions.log_error(f'plugins.{plugin.slug}.print_labels')
|
||||
raise ValidationError([_('Error printing labels'), str(e)])
|
||||
|
||||
output.complete = True
|
||||
output.save()
|
||||
output.refresh_from_db()
|
||||
|
||||
# Return the output object
|
||||
return output
|
||||
|
@ -2,10 +2,15 @@
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import structlog
|
||||
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.helpers import current_time
|
||||
from InvenTree.tasks import ScheduledTask, scheduled_task
|
||||
from report.models import LabelOutput, ReportOutput
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
def cleanup_old_report_outputs():
|
||||
@ -15,3 +20,70 @@ def cleanup_old_report_outputs():
|
||||
|
||||
LabelOutput.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)
|
||||
|
@ -1,10 +1,15 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconPrinter,
|
||||
IconReport,
|
||||
IconTags
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../../App';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import type { ModelType } from '../../enums/ModelType';
|
||||
import { extractAvailableFields } from '../../functions/forms';
|
||||
@ -17,6 +22,94 @@ import {
|
||||
} from '../../states/SettingsState';
|
||||
import type { ApiFormFieldSet } from '../forms/fields/ApiFormField';
|
||||
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({
|
||||
items,
|
||||
@ -46,6 +139,21 @@ export function PrintingActions({
|
||||
return enableReports && globalSettings.isSet('REPORT_ENABLE');
|
||||
}, [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
|
||||
const printingFields = useQuery({
|
||||
enabled: labelPrintingEnabled,
|
||||
@ -106,36 +214,22 @@ export function PrintingActions({
|
||||
url: apiUrl(ApiEndpoints.label_print),
|
||||
title: t`Print Label`,
|
||||
fields: labelFields,
|
||||
timeout: (items.length + 1) * 5000,
|
||||
timeout: 5000,
|
||||
onClose: () => {
|
||||
setPluginKey('');
|
||||
},
|
||||
submitText: t`Print`,
|
||||
successMessage: t`Label printing completed successfully`,
|
||||
successMessage: null,
|
||||
onFormSuccess: (response: any) => {
|
||||
setPluginKey('');
|
||||
if (!response.complete) {
|
||||
// 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');
|
||||
}
|
||||
setLabelId(response.pk);
|
||||
}
|
||||
});
|
||||
|
||||
const reportModal = useCreateApiFormModal({
|
||||
title: t`Print Report`,
|
||||
url: apiUrl(ApiEndpoints.report_print),
|
||||
timeout: (items.length + 1) * 5000,
|
||||
timeout: 5000,
|
||||
fields: {
|
||||
template: {
|
||||
filters: {
|
||||
@ -149,24 +243,10 @@ export function PrintingActions({
|
||||
value: items
|
||||
}
|
||||
},
|
||||
submitText: t`Generate`,
|
||||
successMessage: t`Report printing completed successfully`,
|
||||
submitText: t`Print`,
|
||||
successMessage: null,
|
||||
onFormSuccess: (response: any) => {
|
||||
if (!response.complete) {
|
||||
// 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');
|
||||
}
|
||||
setReportId(response.pk);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -92,7 +92,7 @@ export interface ApiFormProps {
|
||||
preFormWarning?: string;
|
||||
preFormSuccess?: string;
|
||||
postFormContent?: JSX.Element;
|
||||
successMessage?: string;
|
||||
successMessage?: string | null;
|
||||
onFormSuccess?: (data: any) => void;
|
||||
onFormError?: (response: any) => void;
|
||||
processFormData?: (data: any) => any;
|
||||
|
@ -84,7 +84,10 @@ export function useCreateApiFormModal(props: ApiFormModalProps) {
|
||||
() => ({
|
||||
...props,
|
||||
fetchInitialData: props.fetchInitialData ?? false,
|
||||
successMessage: props.successMessage ?? t`Item Created`,
|
||||
successMessage:
|
||||
props.successMessage === null
|
||||
? null
|
||||
: (props.successMessage ?? t`Item Created`),
|
||||
method: 'POST'
|
||||
}),
|
||||
[props]
|
||||
@ -101,7 +104,10 @@ export function useEditApiFormModal(props: ApiFormModalProps) {
|
||||
() => ({
|
||||
...props,
|
||||
fetchInitialData: props.fetchInitialData ?? true,
|
||||
successMessage: props.successMessage ?? t`Item Updated`,
|
||||
successMessage:
|
||||
props.successMessage === null
|
||||
? null
|
||||
: (props.successMessage ?? t`Item Updated`),
|
||||
method: 'PATCH'
|
||||
}),
|
||||
[props]
|
||||
@ -120,7 +126,10 @@ export function useDeleteApiFormModal(props: ApiFormModalProps) {
|
||||
method: 'DELETE',
|
||||
submitText: t`Delete`,
|
||||
submitColor: 'red',
|
||||
successMessage: props.successMessage ?? t`Item Deleted`,
|
||||
successMessage:
|
||||
props.successMessage === null
|
||||
? null
|
||||
: (props.successMessage ?? t`Item Deleted`),
|
||||
preFormContent: props.preFormContent ?? (
|
||||
<Alert
|
||||
color={'red'}
|
||||
|
@ -122,7 +122,7 @@ export default function SupplierPartDetail() {
|
||||
}
|
||||
];
|
||||
|
||||
const tr: DetailsField[] = [
|
||||
const bl: DetailsField[] = [
|
||||
{
|
||||
type: 'link',
|
||||
name: 'supplier',
|
||||
@ -165,7 +165,7 @@ export default function SupplierPartDetail() {
|
||||
}
|
||||
];
|
||||
|
||||
const bl: DetailsField[] = [
|
||||
const br: DetailsField[] = [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'packaging',
|
||||
@ -183,7 +183,7 @@ export default function SupplierPartDetail() {
|
||||
}
|
||||
];
|
||||
|
||||
const br: DetailsField[] = [
|
||||
const tr: DetailsField[] = [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'in_stock',
|
||||
@ -232,9 +232,9 @@ export default function SupplierPartDetail() {
|
||||
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<DetailsTable title={t`Supplier`} fields={tr} item={data} />
|
||||
<DetailsTable title={t`Packaging`} fields={bl} item={data} />
|
||||
<DetailsTable title={t`Availability`} fields={br} item={data} />
|
||||
<DetailsTable title={t`Supplier`} fields={bl} item={data} />
|
||||
<DetailsTable title={t`Packaging`} fields={br} item={data} />
|
||||
<DetailsTable title={t`Availability`} fields={tr} item={data} />
|
||||
</ItemDetailsGrid>
|
||||
);
|
||||
}, [supplierPart, instanceQuery.isFetching]);
|
||||
|
@ -212,6 +212,7 @@ export default function StockDetail() {
|
||||
name: 'supplier_part',
|
||||
label: t`Supplier Part`,
|
||||
type: 'link',
|
||||
model_field: 'SKU',
|
||||
model: ModelType.supplierpart,
|
||||
hidden: !stockitem.supplier_part
|
||||
},
|
||||
|
@ -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 }).click();
|
||||
|
||||
await page.locator('#form-success').waitFor();
|
||||
await page.getByText('Label printing completed').waitFor();
|
||||
|
||||
await page.getByText('Printing completed successfully').first().waitFor();
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
@ -75,12 +73,10 @@ test('Report Printing', async ({ page }) => {
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Submit the print form (should result in success)
|
||||
await page.getByRole('button', { name: 'Generate', exact: true }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Generate', exact: true }).click();
|
||||
|
||||
await page.locator('#form-success').waitFor();
|
||||
await page.getByText('Report printing completed').waitFor();
|
||||
await page.getByRole('button', { name: 'Print', exact: true }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Print', exact: true }).click();
|
||||
|
||||
await page.getByText('Printing completed successfully').first().waitFor();
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user