add report feature for stock locations (#5134)

* add report feature for stock locations

* fix flake 8 errors

* run pre-commit run --all-files to fix style errors

* add new model

* create default stock location
This commit is contained in:
Christoph 2023-07-05 02:19:13 +02:00 committed by GitHub
parent 9cf629ccc5
commit cf0d30b11c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 304 additions and 6 deletions

View File

@ -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)

View File

@ -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'<int:pk>/', 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'),
])),
]

View File

@ -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)

View File

@ -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,
},
),
]

View File

@ -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(),
}

View File

@ -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()

View File

@ -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 %}
<h3>{% trans "Stock location items" %}</h3>
<h3>{{ stock_location.name }}</h3>
<table class='table table-striped table-condensed'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "IPN" %}</th>
<th>{% trans "Quantity" %}</th>
<th>{% trans "Note" %}</th>
</tr>
</thead>
<tbody>
{% for line in stock_items.all %}
<tr>
<td>
<div class='part-text'>
{{ line.part.full_name }}
</div>
</td>
<td>{{ line.part.IPN }}</td>
<td>{% decimal line.quantity %}</td>
<td>{{ line.notes }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock page_content %}

View File

@ -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()

View File

@ -70,7 +70,8 @@
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a>
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
<li><a class='dropdown-item' href='#' id='print-location-report'><span class='fas fa-tag'></span> {% trans "Print Location Report" %}</a></li>
</ul>
</div>
{% 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 }});

View File

@ -115,6 +115,7 @@ class RuleSet(models.Model):
'stock_location': [
'stock_stocklocation',
'label_stocklocationlabel',
'report_stocklocationreport'
],
'stock': [
'stock_stockitem',