diff --git a/InvenTree/report/admin.py b/InvenTree/report/admin.py index 94acf89e98..18b60392c5 100644 --- a/InvenTree/report/admin.py +++ b/InvenTree/report/admin.py @@ -4,7 +4,7 @@ from django.contrib import admin from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, ReportAsset, ReportSnippet, ReturnOrderReport, - SalesOrderReport, TestReport) + SalesOrderReport, StockLocationReport, TestReport) class ReportTemplateAdmin(admin.ModelAdmin): @@ -25,6 +25,7 @@ class ReportAssetAdmin(admin.ModelAdmin): admin.site.register(ReportSnippet, ReportSnippetAdmin) admin.site.register(ReportAsset, ReportAssetAdmin) +admin.site.register(StockLocationReport, ReportTemplateAdmin) admin.site.register(TestReport, ReportTemplateAdmin) admin.site.register(BuildReport, ReportTemplateAdmin) admin.site.register(BillOfMaterialsReport, ReportTemplateAdmin) diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index b84da561b7..5563b9ffa1 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -20,14 +20,16 @@ import part.models from InvenTree.api import MetadataView from InvenTree.filters import InvenTreeSearchFilter from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI -from stock.models import StockItem, StockItemAttachment +from stock.models import StockItem, StockItemAttachment, StockLocation from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, - ReturnOrderReport, SalesOrderReport, TestReport) + ReturnOrderReport, SalesOrderReport, StockLocationReport, + TestReport) from .serializers import (BOMReportSerializer, BuildReportSerializer, PurchaseOrderReportSerializer, ReturnOrderReportSerializer, - SalesOrderReportSerializer, TestReportSerializer) + SalesOrderReportSerializer, + StockLocationReportSerializer, TestReportSerializer) class ReportListView(ListAPI): @@ -448,6 +450,30 @@ class ReturnOrderReportPrint(ReturnOrderReportMixin, ReportPrintMixin, RetrieveA pass +class StockLocationReportMixin(ReportFilterMixin): + """Mixin for StockLocation report template""" + + ITEM_MODEL = StockLocation + ITEM_KEY = 'location' + queryset = StockLocationReport.objects.all() + serializer_class = StockLocationReportSerializer + + +class StockLocationReportList(StockLocationReportMixin, ReportListView): + """API list endpoint for the StockLocationReportList model""" + pass + + +class StockLocationReportDetail(StockLocationReportMixin, RetrieveUpdateDestroyAPI): + """API endpoint for a single StockLocationReportDetail object.""" + pass + + +class StockLocationReportPrint(StockLocationReportMixin, ReportPrintMixin, RetrieveAPI): + """API endpoint for printing a StockLocationReportPrint object""" + pass + + report_api_urls = [ # Purchase order reports @@ -524,4 +550,18 @@ report_api_urls = [ # List view re_path(r'^.*$', StockItemTestReportList.as_view(), name='api-stockitem-testreport-list'), ])), + + # Stock Location reports (Stock Location Reports -> sir) + re_path(r'slr/', include([ + # Detail views + path(r'/', include([ + re_path(r'print/?', StockLocationReportPrint.as_view(), name='api-stocklocation-report-print'), + re_path(r'metadata/', MetadataView.as_view(), {'report': StockLocationReport}, name='api-stocklocation-report-metadata'), + re_path(r'^.*$', StockLocationReportDetail.as_view(), name='api-stocklocation-report-detail'), + ])), + + # List view + re_path(r'^.*$', StockLocationReportList.as_view(), name='api-stocklocation-report-list'), + ])), + ] diff --git a/InvenTree/report/apps.py b/InvenTree/report/apps.py index e5eec5e736..74999d1cae 100644 --- a/InvenTree/report/apps.py +++ b/InvenTree/report/apps.py @@ -32,6 +32,7 @@ class ReportConfig(AppConfig): self.create_default_purchase_order_reports() self.create_default_sales_order_reports() self.create_default_return_order_reports() + self.create_default_stock_location_reports() def create_default_reports(self, model, reports): """Copy default report files across to the media directory.""" @@ -201,3 +202,23 @@ class ReportConfig(AppConfig): ] self.create_default_reports(ReturnOrderReport, reports) + + def create_default_stock_location_reports(self): + """Create database entries for the default StockLocationReport templates""" + + try: + from report.models import StockLocationReport + except Exception: # pragma: no cover + # Database not yet ready + return + + # List of templates to copy across + reports = [ + { + 'file': 'inventree_slr_report.html', + 'name': 'InvenTree Stock Location', + 'description': 'Stock Location example report', + } + ] + + self.create_default_reports(StockLocationReport, reports) diff --git a/InvenTree/report/migrations/0020_stocklocationreport.py b/InvenTree/report/migrations/0020_stocklocationreport.py new file mode 100644 index 0000000000..b43c414e3b --- /dev/null +++ b/InvenTree/report/migrations/0020_stocklocationreport.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.19 on 2023-06-29 14:46 + +import django.core.validators +from django.db import migrations, models +import report.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0019_returnorderreport_metadata'), + ] + + operations = [ + migrations.CreateModel( + name='StockLocationReport', + 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, 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')), + ('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')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 9674095b85..8d32096872 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -74,6 +74,11 @@ def validate_return_order_filters(filters): return validateFilterString(filters, model=order.models.ReturnOrder) +def validate_stock_location_report_filters(filters): + """Validate filter string against StockLocation model.""" + return validateFilterString(filters, model=stock.models.StockLocation) + + class WeasyprintReportMixin(WeasyTemplateResponseMixin): """Class for rendering a HTML template to a PDF.""" @@ -619,3 +624,39 @@ class ReportAsset(models.Model): verbose_name=_('Description'), help_text=_("Asset file description") ) + + +class StockLocationReport(ReportTemplateBase): + """Render a StockLocationReport against a StockLocation object.""" + + @staticmethod + def get_api_url(): + """Return the API URL associated with the StockLocationReport model""" + return reverse('api-stocklocation-report-list') + + @classmethod + def getSubdir(cls): + """Return the subdirectory where StockLocationReport templates are located""" + return 'slr' + + filters = models.CharField( + blank=True, + max_length=250, + verbose_name=_('Filters'), + help_text=_("stock location query filters (comma-separated list of key=value pairs)"), + validators=[ + validate_stock_location_report_filters + ] + ) + + def get_context_data(self, request): + """Return custom context data for the StockLocationReport template""" + stock_location = self.object_to_print + + if type(stock_location) != stock.models.StockLocation: + raise TypeError('Provided model is not a StockLocation object -> ' + str(type(stock_location))) + + return { + 'stock_location': stock_location, + 'stock_items': stock_location.get_stock_items(), + } diff --git a/InvenTree/report/serializers.py b/InvenTree/report/serializers.py index 330279834f..5c15b14e0e 100644 --- a/InvenTree/report/serializers.py +++ b/InvenTree/report/serializers.py @@ -4,7 +4,8 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField, InvenTreeModelSerializer) from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, - ReturnOrderReport, SalesOrderReport, TestReport) + ReturnOrderReport, SalesOrderReport, StockLocationReport, + TestReport) class ReportSerializerBase(InvenTreeModelSerializer): @@ -84,3 +85,13 @@ class ReturnOrderReportSerializer(ReportSerializerBase): model = ReturnOrderReport fields = ReportSerializerBase.report_fields() + + +class StockLocationReportSerializer(ReportSerializerBase): + """Serializer class for the StockLocationReport model""" + + class Meta: + """Metaclass options""" + + model = StockLocationReport + fields = ReportSerializerBase.report_fields() diff --git a/InvenTree/report/templates/report/inventree_slr_report.html b/InvenTree/report/templates/report/inventree_slr_report.html new file mode 100644 index 0000000000..f10c74d318 --- /dev/null +++ b/InvenTree/report/templates/report/inventree_slr_report.html @@ -0,0 +1,124 @@ +{% extends "report/inventree_report_base.html" %} + +{% load i18n %} +{% load report %} +{% load barcode %} +{% load inventree_extras %} + +{% block page_margin %} +margin: 2cm; +margin-top: 4cm; +{% endblock page_margin %} + +{% block bottom_left %} +content: "v{{ report_revision }} - {{ date.isoformat }}"; +{% endblock bottom_left %} + +{% block bottom_center %} +content: "{% inventree_version shortstring=True %}"; +{% endblock bottom_center %} + +{% block style %} + +.header-right { + text-align: right; + float: right; +} + +.logo { + height: 20mm; + vertical-align: middle; +} + +.thumb-container { + width: 32px; + display: inline; +} + +.part-thumb { + max-width: 32px; + max-height: 32px; + display: inline; +} + +.part-text { + display: inline; +} + +.part-logo { + max-width: 60px; + max-height: 60px; + display: inline; +} + +table { + border: 1px solid #eee; + border-radius: 3px; + border-collapse: collapse; + width: 100%; + font-size: 80%; +} + +table td { + border: 1px solid #eee; +} + +table td.shrink { + white-space: nowrap +} + +table td.expand { + width: 99% +} + +.invisible-table { + border: 0px solid transparent; + border-collapse: collapse; + width: 100%; + font-size: 80%; +} + +.invisible-table td { + border: 0px solid transparent; +} + +.main-part-text { + display: inline; +} + +.main-part-description { + display: inline; +} + +{% endblock style %} + +{% block page_content %} + +

{% trans "Stock location items" %}

+

{{ stock_location.name }}

+ + + + + + + + + + + {% for line in stock_items.all %} + + + + + + + {% endfor %} + +
{% trans "Part" %}{% trans "IPN" %}{% trans "Quantity" %}{% trans "Note" %}
+
+ {{ line.part.full_name }} +
+
{{ line.part.IPN }}{% decimal line.quantity %}{{ line.notes }}
+ +{% endblock page_content %} diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py index a652ce5f5c..f97490e228 100644 --- a/InvenTree/report/tests.py +++ b/InvenTree/report/tests.py @@ -475,3 +475,18 @@ class ReturnOrderReportTest(ReportTest): self.copyReportTemplate('inventree_return_order_report.html', 'return order report') return super().setUp() + + +class StockLocationReportTest(ReportTest): + """Unit tests for the StockLocationReport model""" + + model = report_models.StockLocationReport + list_url = 'api-stocklocation-report-list' + detail_url = 'api-stocklocation-report-detail' + print_url = 'api-stocklocation-report-print' + + def setUp(self): + """Setup function for the StockLocationReport tests""" + self.copyReportTemplate('inventree_slr_report.html', 'stock location report') + + return super().setUp() diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 60dad09d64..b9abdb040c 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -70,7 +70,8 @@ {% endif %} @@ -282,6 +283,17 @@ }); {% endif %} + {% if report_enabled %} + $('#print-location-report').click(function() { + + printReports({ + items: [{{ location.pk }}], + key: 'location', + url: '{% url "api-stocklocation-report-list" %}', + }); + }); + {% endif %} + {% if location %} $("#barcode-scan-in-items").click(function() { barcodeCheckInStockItems({{ location.id }}); diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index d6f5c05214..64ce156984 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -115,6 +115,7 @@ class RuleSet(models.Model): 'stock_location': [ 'stock_stocklocation', 'label_stocklocationlabel', + 'report_stocklocationreport' ], 'stock': [ 'stock_stockitem',