2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-03 04:00:57 +00:00

[Feature] Add RMA support (#4488)

* Adds ReturnOrder and ReturnOrderAttachment models

* Adds new 'role' specific for return orders

* Refactor total_price into a mixin

- Required for PurchaseOrder and SalesOrder
- May not be required for ReturnOrder (remains to be seen)

* Adds API endpoints for ReturnOrder

- Add list endpoint
- Add detail endpoint
- Adds required serializer models

* Adds basic "index" page for Return Order model

* Update API version

* Update navbar text

* Add db migration for new "role"

* Add ContactList and ContactDetail API endpoints

* Adds template and JS code for manipulation of contacts

- Display a table
- Create / edit / delete

* Splits order.js into multiple files

- Javascript files was becoming extremely large
- Hard to debug and find code
- Split into purchase_order / return_order / sales_order

* Fix role name (change 'returns' to 'return_order')

- Similar to existing roles for purchase_order and sales_order

* Adds detail page for ReturnOrder

* URL cleanup

- Use <int:pk> instead of complex regex

* More URL cleanup

* Add "return orders" list to company detail page

* Break JS status codes into new javascript file

- Always difficult to track down where these are rendered
- Enough to warrant their own file now

* Add ability to edit return order from detail page

* Database migrations

- Add new ReturnOrder modeles
- Add new 'contact' field to external orders

* Adds "contact" to ReturnOrder

- Implement check to ensure that the selected "contact" matches the selected "company"

* Adjust filters to limit contact options

* Fix typo

* Expose 'contact' field for PurchaseOrder model

* Render contact information

* Add "contact" for SalesOrder

* Adds setting to enable / disable return order functionality

- Simply hides the navigation elements
- API is not disabled

* Support filtering ReturnOrder by 'status'

- Refactors existing filter into the OrderFilter class

* js linting

* More JS linting

* Adds ReturnOrderReport model

* Add serializer for the ReturnOrderReport model

- A little bit of refactoring along the way

* Admin integration for new report model

* Refactoring for report.api

- Adds generic mixins for filtering queryset (based on updates to label.api)
- Reduces repeated code a *lot*

* Exposes API endpoints for ReturnOrderReport

* Adds default example report file for ReturnOrder

- Requires some more work :)

* Refactor report printing javascript code

- Replace all existing functions with 'printReports'

* Improvements for default StockItem test report template

- Fix bug in template
- Handle potential errors in template tags
- Add more helpers to report tags
- Improve test result rendering

* Reduce logging verbosity from weasyprint

* Refactor javascript for label printing

- Consolidate into a single function
- Similar to refactor of report functions

* Add report print button to return order page

* Record user reference when creating via API

* Refactor order serializers

- Move common code into AbstractOrderSerializer class

* Adds extra line item model for the return order

- Adds serializer and API endpoints as appropriate

* Render extra line table for return order

- Refactor existing functions into a single generic function
- Reduces repeated JS code a lot

* Add ability to create a new extra line item

* Adds button for creating a new lien item

* JS linting

* Update test

* Typo fix

(cherry picked from commit 28ac2be35b)

* Enable search for return order

* Don't do pricing (yet) for returnorder extra line table

- Fixes an uncaught error

* Error catching for api.js

* Updates for order models:

- Add 'target_date' field to abstract Order model
- Add IN_PROGRESS status code for return order
- Refactor 'overdue' and 'outstanding' API queries
- Refactor OVERDUE_FILTER on order models
- Refactor is_overdue on order models
- More table filters for return order model

* JS cleanup

* Create ReturnOrderLineItem model

- New type of status label
- Add TotalPriceMixin to ReturnOrder model

* Adds an API serializer for the ReturnOrderLineItem model

* Add API endpoints for ReturnOrderLineItem model

- Including some refactoring along the way

* javascript: refactor loadTableFilters function

- Pass enforced query through to the filters
- Call Object.assign() to construct a superset query
- Removes a lot of code duplication

* Refactor hard-coded URLS to use {% url %} lookup

- Forces error if the URL is wrong
- If we ever change the URL, will still work

* Implement creation of new return order line items

* Adds 'part_detail' annotation to ReturnOrderLineItem serializer

- Required for rendering part information

* javascript: refactor method for creating a group of buttons in a table

* javascript: refactor common buttons with helper functions

* Allow edit and delete of return order line items

* Add form option to automatically reload a table on success

- Pass table name to options.refreshTable

* JS linting

* Add common function for createExtraLineItem

* Refactor loading of attachment tables

- Setup drag-and-drop as part of core function

* CI fixes

* Refactoring out some more common API endpoint code

* Update migrations

* Fix permission typo

* Refactor for unit testing code

* Add unit tests for Contact model

* Tests for returnorder list API

* Annotate 'line_items' to ReturnOrder serializer

* Driving the refactor tractor

* More unit tests for the ReturnOrder API endpoints

* Refactor "print orders" button for various order tables

- Move into "setupFilterList" code (generic)

* add generic 'label printing' button to table actions buttons

* Refactor build output table

* Refactoring icon generation for js

* Refactoring for Part API

* Fix database model type for 'received_date'

* Add API endpoint to "issue" a ReturnOrder

* Improvements for stock tracking table

- Add new status codes
- Add rendering for SalesOrder
- Add rendering for ReturnOrder
- Fix status badges

* Adds functionality to receive line items against a return order

* Add endpoints for completing and cancelling orders

* Add option to allow / prevent editing of ReturnOrder after completed

* js linting

* Wrap "add extra line" button in setting check

* Updates to order/admin.py

* Remove inline admin for returnorderline model

* Updates to pass CI

* Serializer fix

* order template fixes

* Unit test fix

* Fixes for ReturnOrder.receive_line_item

* Unit testing for receiving line items against an RMA

* Improve example report for return order

* Extend unit tests for reporting

* Cleanup here and there

* Unit testing for order views

* Clear "sales_order" field when returning against ReturnOrder

* Add 'location' to deltas when returning from customer

* Bug fix for unit test
This commit is contained in:
Oliver
2023-03-29 10:35:43 +11:00
committed by GitHub
parent d4a64b4f7d
commit 27aa16d55d
122 changed files with 10391 additions and 7053 deletions

View File

@ -3,7 +3,8 @@
from django.contrib import admin
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
ReportAsset, ReportSnippet, SalesOrderReport, TestReport)
ReportAsset, ReportSnippet, ReturnOrderReport,
SalesOrderReport, TestReport)
class ReportTemplateAdmin(admin.ModelAdmin):
@ -28,4 +29,5 @@ admin.site.register(TestReport, ReportTemplateAdmin)
admin.site.register(BuildReport, ReportTemplateAdmin)
admin.site.register(BillOfMaterialsReport, ReportTemplateAdmin)
admin.site.register(PurchaseOrderReport, ReportTemplateAdmin)
admin.site.register(ReturnOrderReport, ReportTemplateAdmin)
admin.site.register(SalesOrderReport, ReportTemplateAdmin)

View File

@ -24,9 +24,10 @@ from plugin.serializers import MetadataSerializer
from stock.models import StockItem, StockItemAttachment
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
SalesOrderReport, TestReport)
ReturnOrderReport, SalesOrderReport, TestReport)
from .serializers import (BOMReportSerializer, BuildReportSerializer,
PurchaseOrderReportSerializer,
ReturnOrderReportSerializer,
SalesOrderReportSerializer, TestReportSerializer)
@ -423,6 +424,31 @@ class SalesOrderReportPrint(SalesOrderReportMixin, ReportPrintMixin, RetrieveAPI
pass
class ReturnOrderReportMixin(ReportFilterMixin):
"""Mixin for the ReturnOrderReport report template"""
ITEM_MODEL = order.models.ReturnOrder
ITEM_KEY = 'order'
queryset = ReturnOrderReport.objects.all()
serializer_class = ReturnOrderReportSerializer
class ReturnOrderReportList(ReturnOrderReportMixin, ReportListView):
"""API list endpoint for the ReturnOrderReport model"""
pass
class ReturnOrderReportDetail(ReturnOrderReportMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single ReturnOrderReport object"""
pass
class ReturnOrderReportPrint(ReturnOrderReportMixin, ReportPrintMixin, RetrieveAPI):
"""API endpoint for printing a ReturnOrderReport object"""
pass
class ReportMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating Report metadata."""
MODEL_REF = 'reportmodel'
@ -453,7 +479,7 @@ report_api_urls = [
# Purchase order reports
re_path(r'po/', include([
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'print/', PurchaseOrderReportPrint.as_view(), name='api-po-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: PurchaseOrderReport}, name='api-po-report-metadata'),
path('', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'),
@ -466,7 +492,7 @@ report_api_urls = [
# Sales order reports
re_path(r'so/', include([
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'print/', SalesOrderReportPrint.as_view(), name='api-so-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: SalesOrderReport}, name='api-so-report-metadata'),
path('', SalesOrderReportDetail.as_view(), name='api-so-report-detail'),
@ -475,10 +501,19 @@ report_api_urls = [
path('', SalesOrderReportList.as_view(), name='api-so-report-list'),
])),
# Return order reports
re_path(r'return-order/', include([
path(r'<int:pk>/', include([
path(r'print/', ReturnOrderReportPrint.as_view(), name='api-return-order-report-print'),
path('', ReturnOrderReportDetail.as_view(), name='api-return-order-report-detail'),
])),
path('', ReturnOrderReportList.as_view(), name='api-return-order-report-list'),
])),
# Build reports
re_path(r'build/', include([
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BuildReport}, name='api-build-report-metadata'),
re_path(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'),
@ -492,7 +527,7 @@ report_api_urls = [
re_path(r'bom/', include([
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BillOfMaterialsReport}, name='api-bom-report-metadata'),
re_path(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'),
@ -505,7 +540,7 @@ report_api_urls = [
# Stock item test reports
re_path(r'test/', include([
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'print/?', StockItemTestReportPrint.as_view(), name='api-stockitem-testreport-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: TestReport}, name='api-stockitem-testreport-metadata'),
re_path(r'^.*$', StockItemTestReportDetail.as_view(), name='api-stockitem-testreport-detail'),

View File

@ -8,8 +8,6 @@ from pathlib import Path
from django.apps import AppConfig
from django.conf import settings
from InvenTree.ready import canAppAccessDatabase
logger = logging.getLogger("inventree")
@ -19,12 +17,21 @@ class ReportConfig(AppConfig):
def ready(self):
"""This function is called whenever the report app is loaded."""
from InvenTree.ready import canAppAccessDatabase
# Configure logging for PDF generation (disable "info" messages)
logging.getLogger('fontTools').setLevel(logging.WARNING)
logging.getLogger('weasyprint').setLevel(logging.WARNING)
# Create entries for default report templates
if canAppAccessDatabase(allow_test=True):
self.create_default_test_reports()
self.create_default_build_reports()
self.create_default_bill_of_materials_reports()
self.create_default_purchase_order_reports()
self.create_default_sales_order_reports()
self.create_default_return_order_reports()
def create_default_reports(self, model, reports):
"""Copy defualt report files across to the media directory."""
@ -174,3 +181,23 @@ class ReportConfig(AppConfig):
]
self.create_default_reports(SalesOrderReport, reports)
def create_default_return_order_reports(self):
"""Create database entries for the default ReturnOrderReport templates"""
try:
from report.models import ReturnOrderReport
except Exception: # pragma: no cover
# Database not yet ready
return
# List of templates to copy across
reports = [
{
'file': 'inventree_return_order_report.html',
'name': 'InvenTree Return Order',
'description': 'Return Order example report',
}
]
self.create_default_reports(ReturnOrderReport, reports)

View File

@ -0,0 +1,31 @@
# Generated by Django 3.2.18 on 2023-03-15 11:17
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
dependencies = [
('report', '0017_auto_20230317_0816'),
]
operations = [
migrations.CreateModel(
name='ReturnOrderReport',
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')),
('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')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.18 on 2023-03-23 11:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('report', '0018_returnorderreport'),
]
operations = [
migrations.AddField(
model_name='returnorderreport',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -68,6 +68,11 @@ def validate_sales_order_filters(filters):
return validateFilterString(filters, model=order.models.SalesOrder)
def validate_return_order_filters(filters):
"""Validate filter string against ReturnOrder model"""
return validateFilterString(filters, model=order.models.ReturnOrder)
class WeasyprintReportMixin(WeasyTemplateResponseMixin):
"""Class for rendering a HTML template to a PDF."""
@ -303,6 +308,30 @@ class TestReport(ReportTemplateBase):
return items.exists()
def get_test_keys(self, stock_item):
"""Construct a flattened list of test 'keys' for this StockItem:
- First, any 'required' tests
- Second, any 'non required' tests
- Finally, any test results which do not match a test
"""
keys = []
for test in stock_item.part.getTestTemplates(required=True):
if test.key not in keys:
keys.append(test.key)
for test in stock_item.part.getTestTemplates(required=False):
if test.key not in keys:
keys.append(test.key)
for result in stock_item.testResultList(include_installed=self.include_installed):
if result.key not in keys:
keys.append(result.key)
return list(keys)
def get_context_data(self, request):
"""Return custom context data for the TestReport template"""
stock_item = self.object_to_print
@ -312,6 +341,9 @@ class TestReport(ReportTemplateBase):
'serial': stock_item.serial,
'part': stock_item.part,
'parameters': stock_item.part.parameters_map(),
'test_keys': self.get_test_keys(stock_item),
'test_template_list': stock_item.part.getTestTemplates(),
'test_template_map': stock_item.part.getTestTemplateMap(),
'results': stock_item.testResultMap(include_installed=self.include_installed),
'result_list': stock_item.testResultList(include_installed=self.include_installed),
'installed_items': stock_item.get_installed_items(cascade=True),
@ -468,6 +500,45 @@ class SalesOrderReport(ReportTemplateBase):
}
class ReturnOrderReport(ReportTemplateBase):
"""Render a custom report against a ReturnOrder object"""
@staticmethod
def get_api_url():
"""Return the API URL associated with the ReturnOrderReport model"""
return reverse('api-return-order-report-list')
@classmethod
def getSubdir(cls):
"""Return the directory where the ReturnOrderReport templates are stored"""
return 'returnorder'
filters = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Filters'),
help_text=_('Return order query filters'),
validators=[
validate_return_order_filters,
]
)
def get_context_data(self, request):
"""Return custom context data for the ReturnOrderReport template"""
order = self.object_to_print
return {
'order': order,
'description': order.description,
'reference': order.reference,
'customer': order.customer,
'lines': order.lines,
'extra_lines': order.extra_lines,
'title': str(order),
}
def rename_snippet(instance, filename):
"""Function to rename a report snippet once uploaded"""

View File

@ -4,99 +4,83 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer)
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
SalesOrderReport, TestReport)
ReturnOrderReport, SalesOrderReport, TestReport)
class TestReportSerializer(InvenTreeModelSerializer):
class ReportSerializerBase(InvenTreeModelSerializer):
"""Base class for report serializer"""
template = InvenTreeAttachmentSerializerField(required=True)
@staticmethod
def report_fields():
"""Generic serializer fields for a report template"""
return [
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
class TestReportSerializer(ReportSerializerBase):
"""Serializer class for the TestReport model"""
class Meta:
"""Metaclass options."""
model = TestReport
fields = [
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
template = InvenTreeAttachmentSerializerField(required=True)
fields = ReportSerializerBase.report_fields()
class BuildReportSerializer(InvenTreeModelSerializer):
class BuildReportSerializer(ReportSerializerBase):
"""Serializer class for the BuildReport model"""
class Meta:
"""Metaclass options."""
model = BuildReport
fields = [
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
template = InvenTreeAttachmentSerializerField(required=True)
fields = ReportSerializerBase.report_fields()
class BOMReportSerializer(InvenTreeModelSerializer):
class BOMReportSerializer(ReportSerializerBase):
"""Serializer class for the BillOfMaterialsReport model"""
class Meta:
"""Metaclass options."""
model = BillOfMaterialsReport
fields = [
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
template = InvenTreeAttachmentSerializerField(required=True)
fields = ReportSerializerBase.report_fields()
class PurchaseOrderReportSerializer(InvenTreeModelSerializer):
class PurchaseOrderReportSerializer(ReportSerializerBase):
"""Serializer class for the PurchaseOrdeReport model"""
class Meta:
"""Metaclass options."""
model = PurchaseOrderReport
fields = [
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
template = InvenTreeAttachmentSerializerField(required=True)
fields = ReportSerializerBase.report_fields()
class SalesOrderReportSerializer(InvenTreeModelSerializer):
class SalesOrderReportSerializer(ReportSerializerBase):
"""Serializer class for the SalesOrderReport model"""
class Meta:
"""Metaclass options."""
model = SalesOrderReport
fields = [
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
fields = ReportSerializerBase.report_fields()
template = InvenTreeAttachmentSerializerField(required=True)
class ReturnOrderReportSerializer(ReportSerializerBase):
"""Serializer class for the ReturnOrderReport model"""
class Meta:
"""Metaclass options"""
model = ReturnOrderReport
fields = ReportSerializerBase.report_fields()

View File

@ -0,0 +1,70 @@
{% extends "report/inventree_report_base.html" %}
{% load i18n %}
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block page_margin %}
margin: 2cm;
margin-top: 4cm;
{% endblock %}
{% block bottom_left %}
content: "v{{report_revision}} - {{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% 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;
}
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%
}
{% endblock %}

View File

@ -1,72 +1,10 @@
{% extends "report/inventree_report_base.html" %}
{% extends "report/inventree_order_report_base.html" %}
{% load i18n %}
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% block page_margin %}
margin: 2cm;
margin-top: 4cm;
{% endblock %}
{% block bottom_left %}
content: "v{{report_revision}} - {{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% 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;
}
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%
}
{% endblock %}
{% load markdownify %}
{% block header_content %}

View File

@ -0,0 +1 @@
{% extends "report/inventree_return_order_report_base.html" %}

View File

@ -0,0 +1,62 @@
{% 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 %}' 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 %}

View File

@ -1,4 +1,4 @@
{% extends "report/inventree_report_base.html" %}
{% extends "report/inventree_order_report_base.html" %}
{% load i18n %}
{% load report %}
@ -6,69 +6,6 @@
{% load inventree_extras %}
{% load markdownify %}
{% block page_margin %}
margin: 2cm;
margin-top: 4cm;
{% endblock %}
{% block bottom_left %}
content: "v{{report_revision}} - {{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% 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;
}
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%
}
{% endblock %}
{% block header_content %}
<img class='logo' src='{% company_image customer %}' alt="{{ customer }}" width='150'>

View File

@ -33,6 +33,15 @@ content: "{% trans 'Stock Item Test Report' %}";
color: #F55;
}
.test-not-found {
color: #33A;
}
.required-test-not-found {
color: #EEE;
background-color: #F55;
}
.container {
padding: 5px;
border: 1px solid;
@ -84,7 +93,7 @@ content: "{% trans 'Stock Item Test Report' %}";
</div>
</div>
{% if resul_list|length > 0 %}
{% if test_keys|length > 0 %}
<h3>{% trans "Test Results" %}</h3>
<table class='table test-table'>
@ -101,22 +110,44 @@ content: "{% trans 'Stock Item Test Report' %}";
<tr>
<td colspan='5'><hr></td>
</tr>
{% for test in result_list %}
{% for key in test_keys %}
<!-- test key = {{ key }} -->
{% getkey test_template_map key as test_template %}
{% getkey results key as test_result %}
<tr class='test-row'>
<td>{{ test.test }}</td>
{% if test.result %}
<td>
{% if test_template %}
{% render_html_text test_template.test_name bold=test_template.required %}
{% elif test_result %}
{% render_html_text test_result.test italic=True %}
{% else %}
<!-- No matching test template or result for {{ key }} -->
<span style='color: red;'>{{ key }}</span>
{% endif %}
</td>
{% if test_result %}
{% if test_result.result %}
<td class='test-pass'>{% trans "Pass" %}</td>
{% else %}
<td class='test-fail'>{% trans "Fail" %}</td>
{% endif %}
<td>{{ test.value }}</td>
<td>{{ test.user.username }}</td>
<td>{{ test.date.date.isoformat }}</td>
<td>{{ test_result.value }}</td>
<td>{{ test_result.user.username }}</td>
<td>{{ test_result.date.date.isoformat }}</td>
{% else %}
{% if test_template.required %}
<td colspan='4' class='required-test-not-found'>{% trans "No result (required)" %}</td>
{% else %}
<td colspan='4' class='test-not-found'>{% trans "No result" %}</td>
{% endif %}
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<em>No tests defined for this stock item</em>
{% endif %}
{% if installed_items|length > 0 %}

View File

@ -19,17 +19,52 @@ logger = logging.getLogger('inventree')
@register.simple_tag()
def getkey(value: dict, arg):
def getindex(container: list, index: int):
"""Return the value contained at the specified index of the list.
This function is provideed to get around template rendering limitations.
Arguments:
container: A python list object
index: The index to retrieve from the list
"""
# Index *must* be an integer
try:
index = int(index)
except ValueError:
return None
if index < 0 or index >= len(container):
return None
try:
value = container[index]
except IndexError:
value = None
return value
@register.simple_tag()
def getkey(container: dict, key):
"""Perform key lookup in the provided dict object.
This function is provided to get around template rendering limitations.
Ref: https://stackoverflow.com/questions/1906129/dict-keys-with-spaces-in-django-templates
Arguments:
value: A python dict object
arg: The 'key' to be found within the dict
container: A python dict object
key: The 'key' to be found within the dict
"""
return value[arg]
if type(container) is not dict:
logger.warning("getkey() called with non-dict object")
return None
if key in container:
return container[key]
else:
return None
@register.simple_tag()
@ -215,3 +250,31 @@ def render_currency(money, **kwargs):
"""Render a currency / Money object"""
return InvenTree.helpers.render_currency(money, **kwargs)
@register.simple_tag
def render_html_text(text: str, **kwargs):
"""Render a text item with some simple html tags.
kwargs:
bold: Boolean, whether bold (or not)
italic: Boolean, whether italic (or not)
heading: str, heading level e.g. 'h3'
"""
tags = []
if kwargs.get('bold', False):
tags.append('strong')
if kwargs.get('italic', False):
tags.append('em')
if heading := kwargs.get('heading', ''):
tags.append(heading)
output = ''.join([f'<{tag}>' for tag in tags])
output += text
output += ''.join([f'</{tag}>' for tag in tags])
return mark_safe(output)

View File

@ -29,6 +29,20 @@ class ReportTagTest(TestCase):
"""Enable or disable debug mode for reports"""
InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', value, change_user=None)
def test_getindex(self):
"""Tests for the 'getindex' template tag"""
fn = report_tags.getindex
data = [1, 2, 3, 4, 5, 6]
# Out of bounds or invalid
self.assertEqual(fn(data, -1), None)
self.assertEqual(fn(data, 99), None)
self.assertEqual(fn(data, 'xx'), None)
for idx in range(len(data)):
self.assertEqual(fn(data, idx), data[idx])
def test_getkey(self):
"""Tests for the 'getkey' template tag"""
@ -419,7 +433,7 @@ class BOMReportTest(ReportTest):
class PurchaseOrderReportTest(ReportTest):
"""Unit test class fort he PurchaseOrderReport model"""
"""Unit test class for the PurchaseOrderReport model"""
model = report_models.PurchaseOrderReport
list_url = 'api-po-report-list'
@ -446,3 +460,18 @@ class SalesOrderReportTest(ReportTest):
self.copyReportTemplate('inventree_so_report.html', 'sales order report')
return super().setUp()
class ReturnOrderReportTest(ReportTest):
"""Unit tests for the ReturnOrderReport model"""
model = report_models.ReturnOrderReport
list_url = 'api-return-order-report-list'
detail_url = 'api-return-order-report-detail'
print_url = 'api-return-order-report-print'
def setUp(self):
"""Setup function for the ReturnOrderReport tests"""
self.copyReportTemplate('inventree_return_order_report.html', 'return order report')
return super().setUp()