mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 12:35:46 +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:
@ -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
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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')
|
||||
|
@ -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)),
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -23,6 +23,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'unique_together': {('build', 'bom_item')},
|
||||
'verbose_name': 'Build Order Line Item',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
)
|
@ -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)
|
@ -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'),
|
||||
]),
|
||||
),
|
||||
]
|
@ -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'],
|
||||
}
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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]'),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
@ -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,
|
||||
}
|
@ -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()
|
@ -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()
|
@ -1,3 +0,0 @@
|
||||
{% extends "label/buildline/buildline_label_base.html" %}
|
||||
|
||||
<!-- Refer to the buildline_label_base template for further information -->
|
@ -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
|
@ -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)
|
@ -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.
|
||||
|
||||
|
@ -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(
|
||||
|
@ -30,6 +30,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'verbose_name': 'Purchase Order'
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
|
@ -35,6 +35,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'verbose_name': 'Sales Order',
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
@ -39,6 +39,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'verbose_name': 'Return Order',
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
@ -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:
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
|
@ -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 %}
|
||||
});
|
||||
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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)
|
||||
|
@ -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] = []
|
||||
|
@ -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:
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
@ -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))
|
||||
)
|
||||
|
@ -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 [
|
||||
|
@ -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,
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
@ -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)),
|
||||
],
|
||||
),
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
@ -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)
|
||||
]
|
@ -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',
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -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)
|
||||
]
|
||||
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
23
src/backend/InvenTree/report/mixins.py
Normal file
23
src/backend/InvenTree/report/mixins.py
Normal 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
@ -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):
|
||||
|
17
src/backend/InvenTree/report/tasks.py
Normal file
17
src/backend/InvenTree/report/tasks.py
Normal 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()
|
@ -1,3 +0,0 @@
|
||||
{% extends "report/inventree_build_order_base.html" %}
|
||||
|
||||
<!-- Refer to the inventree_build_order_base template -->
|
@ -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 %}
|
||||
|
@ -1 +0,0 @@
|
||||
{% extends "report/inventree_po_report_base.html" %}
|
@ -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 %}
|
||||
|
@ -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 %}
|
@ -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
Reference in New Issue
Block a user