[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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
122 changed files with 10391 additions and 7053 deletions

View File

@ -242,6 +242,7 @@ class APISearchView(APIView):
'part': part.api.PartList,
'partcategory': part.api.CategoryList,
'purchaseorder': order.api.PurchaseOrderList,
'returnorder': order.api.ReturnOrderList,
'salesorder': order.api.SalesOrderList,
'stockitem': stock.api.StockList,
'stocklocation': stock.api.StockLocationList,

View File

@ -165,6 +165,26 @@ class ExchangeRateMixin:
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Base class for running InvenTree API tests."""
def checkResponse(self, url, method, expected_code, response):
"""Debug output for an unexpected response"""
# No expected code, return
if expected_code is None:
return
if expected_code != response.status_code:
print(f"Unexpected {method} response at '{url}': status_code = {response.status_code}")
if hasattr(response, 'data'):
print('data:', response.data)
if hasattr(response, 'body'):
print('body:', response.body)
if hasattr(response, 'content'):
print('content:', response.content)
self.assertEqual(expected_code, response.status_code)
def getActions(self, url):
"""Return a dict of the 'actions' available at a given endpoint.
@ -188,19 +208,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
response = self.client.get(url, data, format=format)
if expected_code is not None:
if response.status_code != expected_code:
print(f"Unexpected response at '{url}': status_code = {response.status_code}")
if hasattr(response, 'data'):
print('data:', response.data)
if hasattr(response, 'body'):
print('body:', response.body)
if hasattr(response, 'content'):
print('content:', response.content)
self.assertEqual(response.status_code, expected_code)
self.checkResponse(url, 'GET', expected_code, response)
return response
@ -213,17 +221,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
response = self.client.post(url, data=data, format=format)
if expected_code is not None:
if response.status_code != expected_code:
print(f"Unexpected response at '{url}': status code = {response.status_code}")
if hasattr(response, 'data'):
print(response.data)
else:
print(f"(response object {type(response)} has no 'data' attribute")
self.assertEqual(response.status_code, expected_code)
self.checkResponse(url, 'POST', expected_code, response)
return response
@ -235,8 +233,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
response = self.client.delete(url, data=data, format=format)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
self.checkResponse(url, 'DELETE', expected_code, response)
return response
@ -244,8 +241,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Issue a PATCH request."""
response = self.client.patch(url, data=data, format=format)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
self.checkResponse(url, 'PATCH', expected_code, response)
return response
@ -253,13 +249,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Issue a PUT request."""
response = self.client.put(url, data=data, format=format)
if expected_code is not None:
if response.status_code != expected_code:
print(f"Unexpected response at '{url}':")
print(response.data)
self.assertEqual(response.status_code, expected_code)
self.checkResponse(url, 'PUT', expected_code, response)
return response
@ -267,8 +257,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Issue an OPTIONS request."""
response = self.client.options(url, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
self.checkResponse(url, 'OPTIONS', expected_code, response)
return response

View File

@ -2,16 +2,21 @@
# InvenTree API version
INVENTREE_API_VERSION = 103
INVENTREE_API_VERSION = 104
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v104 -> 2023-03-23 : https://github.com/inventree/InvenTree/pull/4488
- Adds various endpoints for new "ReturnOrder" models
- Adds various endpoints for new "ReturnOrderReport" templates
- Exposes API endpoints for "Contact" model
v103 -> 2023-03-17 : https://github.com/inventree/InvenTree/pull/4410
- Add metadata to several more models
v102 -> 2023-03-18 : https://github.com/inventree/InvenTree/pull/4505
- Adds global search API endpoint for consolidated search results
- Adds global search API endpoint for consolidated search results
v101 -> 2023-03-07 : https://github.com/inventree/InvenTree/pull/4462
- Adds 'total_in_stock' to Part serializer, and supports API ordering

View File

@ -4,6 +4,7 @@
import InvenTree.status
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
ReturnOrderLineStatus, ReturnOrderStatus,
SalesOrderStatus, StockHistoryCode,
StockStatus)
from users.models import RuleSet, check_user_role
@ -58,6 +59,8 @@ def status_codes(request):
return {
# Expose the StatusCode classes to the templates
'ReturnOrderStatus': ReturnOrderStatus,
'ReturnOrderLineStatus': ReturnOrderLineStatus,
'SalesOrderStatus': SalesOrderStatus,
'PurchaseOrderStatus': PurchaseOrderStatus,
'BuildStatus': BuildStatus,

View File

@ -1123,7 +1123,10 @@ def render_currency(money, decimal_places=None, currency=None, include_symbol=Tr
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
"""
if money is None or money.amount is None:
if money in [None, '']:
return '-'
if type(money) is not Money:
return '-'
if currency is not None:

View File

@ -315,9 +315,7 @@ main {
}
.filter-button {
padding: 2px;
padding-left: 4px;
padding-right: 4px;
padding: 6px;
}
.filter-input {

View File

@ -155,30 +155,37 @@ function inventreeDocReady() {
}
/*
* Determine if a transfer (e.g. drag-and-drop) is a file transfer
*/
function isFileTransfer(transfer) {
/* Determine if a transfer (e.g. drag-and-drop) is a file transfer
*/
return transfer.files.length > 0;
}
function enableDragAndDrop(element, url, options) {
/* Enable drag-and-drop file uploading for a given element.
/* Enable drag-and-drop file uploading for a given element.
Params:
element - HTML element lookup string e.g. "#drop-div"
url - URL to POST the file to
options - object with following possible values:
label - Label of the file to upload (default='file')
data - Other form data to upload
success - Callback function in case of success
error - Callback function in case of error
method - HTTP method
*/
Params:
element - HTML element lookup string e.g. "#drop-div"
url - URL to POST the file to
options - object with following possible values:
label - Label of the file to upload (default='file')
data - Other form data to upload
success - Callback function in case of success
error - Callback function in case of error
method - HTTP method
*/
function enableDragAndDrop(elementId, url, options={}) {
var data = options.data || {};
let element = $(elementId);
if (!element.exists()) {
console.error(`enableDragAndDrop called with invalid target: '${elementId}'`);
return;
}
$(element).on('drop', function(event) {
var transfer = event.originalEvent.dataTransfer;
@ -200,6 +207,11 @@ function enableDragAndDrop(element, url, options) {
formData,
{
success: function(data, status, xhr) {
// Reload a table
if (options.refreshTable) {
reloadBootstrapTable(options.refreshTable);
}
if (options.success) {
options.success(data, status, xhr);
}

View File

@ -247,10 +247,14 @@ class StockHistoryCode(StatusCode):
BUILD_CONSUMED = 57
# Sales order codes
SHIPPED_AGAINST_SALES_ORDER = 60
# Purchase order codes
RECEIVED_AGAINST_PURCHASE_ORDER = 70
# Return order codes
RETURNED_AGAINST_RETURN_ORDER = 80
# Customer actions
SENT_TO_CUSTOMER = 100
RETURNED_FROM_CUSTOMER = 105
@ -289,8 +293,11 @@ class StockHistoryCode(StatusCode):
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
BUILD_CONSUMED: _('Consumed by build order'),
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order')
SHIPPED_AGAINST_SALES_ORDER: _("Shipped against Sales Order"),
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against Purchase Order'),
RETURNED_AGAINST_RETURN_ORDER: _('Returned against Return Order'),
}
@ -320,3 +327,74 @@ class BuildStatus(StatusCode):
PENDING,
PRODUCTION,
]
class ReturnOrderStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrder"""
# Order is pending, waiting for receipt of items
PENDING = 10
# Items have been received, and are being inspected
IN_PROGRESS = 20
COMPLETE = 30
CANCELLED = 40
OPEN = [
PENDING,
IN_PROGRESS,
]
options = {
PENDING: _("Pending"),
IN_PROGRESS: _("In Progress"),
COMPLETE: _("Complete"),
CANCELLED: _("Cancelled"),
}
colors = {
PENDING: 'secondary',
IN_PROGRESS: 'primary',
COMPLETE: 'success',
CANCELLED: 'danger',
}
class ReturnOrderLineStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrderLineItem"""
PENDING = 10
# Item is to be returned to customer, no other action
RETURN = 20
# Item is to be repaired, and returned to customer
REPAIR = 30
# Item is to be replaced (new item shipped)
REPLACE = 40
# Item is to be refunded (cannot be repaired)
REFUND = 50
# Item is rejected
REJECT = 60
options = {
PENDING: _('Pending'),
RETURN: _('Return'),
REPAIR: _('Repair'),
REFUND: _('Refund'),
REPLACE: _('Replace'),
REJECT: _('Reject')
}
colors = {
PENDING: 'secondary',
RETURN: 'success',
REPAIR: 'primary',
REFUND: 'info',
REPLACE: 'warning',
REJECT: 'danger',
}

View File

@ -112,9 +112,13 @@ translated_javascript_urls = [
re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
re_path(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'),
re_path(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'),
re_path(r'^purchase_order.js', DynamicJsView.as_view(template_name='js/translated/purchase_order.js'), name='purchase_order.js'),
re_path(r'^return_order.js', DynamicJsView.as_view(template_name='js/translated/return_order.js'), name='return_order.js'),
re_path(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'),
re_path(r'^sales_order.js', DynamicJsView.as_view(template_name='js/translated/sales_order.js'), name='sales_order.js'),
re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'),
re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
re_path(r'^status_codes.js', DynamicJsView.as_view(template_name='js/translated/status_codes.js'), name='status_codes.js'),
re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'),
re_path(r'^pricing.js', DynamicJsView.as_view(template_name='js/translated/pricing.js'), name='pricing.js'),
re_path(r'^news.js', DynamicJsView.as_view(template_name='js/translated/news.js'), name='news.js'),

View File

@ -1,6 +1,6 @@
"""JSON API for the Build app."""
from django.urls import include, re_path
from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
@ -509,13 +509,13 @@ build_api_urls = [
# Attachments
re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
path(r'<int:pk>/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
])),
# Build Items
re_path(r'^item/', include([
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'^metadata/', BuildItemMetadata.as_view(), name='api-build-item-metadata'),
re_path(r'^.*$', BuildItemDetail.as_view(), name='api-build-item-detail'),
])),
@ -523,7 +523,7 @@ build_api_urls = [
])),
# Build Detail
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
re_path(r'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'),
re_path(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),

View File

@ -36,9 +36,10 @@ from plugin.events import trigger_event
from plugin.models import MetadataMixin
import common.notifications
from part import models as PartModels
from stock import models as StockModels
from users import models as UserModels
import part.models
import stock.models
import users.models
class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
@ -279,7 +280,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
)
responsible = models.ForeignKey(
UserModels.Owner,
users.models.Owner,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Responsible'),
@ -395,9 +396,9 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
if in_stock is not None:
if in_stock:
outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER)
outputs = outputs.filter(stock.models.StockItem.IN_STOCK_FILTER)
else:
outputs = outputs.exclude(StockModels.StockItem.IN_STOCK_FILTER)
outputs = outputs.exclude(stock.models.StockItem.IN_STOCK_FILTER)
# Filter by 'complete' status
complete = kwargs.get('complete', None)
@ -659,7 +660,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
else:
serial = None
output = StockModels.StockItem.objects.create(
output = stock.models.StockItem.objects.create(
quantity=1,
location=location,
part=self.part,
@ -677,11 +678,11 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
parts = bom_item.get_valid_parts_for_allocation()
items = StockModels.StockItem.objects.filter(
items = stock.models.StockItem.objects.filter(
part__in=parts,
serial=str(serial),
quantity=1,
).filter(StockModels.StockItem.IN_STOCK_FILTER)
).filter(stock.models.StockItem.IN_STOCK_FILTER)
"""
Test if there is a matching serial number!
@ -701,7 +702,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
else:
"""Create a single build output of the given quantity."""
StockModels.StockItem.objects.create(
stock.models.StockItem.objects.create(
quantity=quantity,
location=location,
part=self.part,
@ -877,7 +878,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
)
# Look for available stock items
available_stock = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER)
available_stock = stock.models.StockItem.objects.filter(stock.models.StockItem.IN_STOCK_FILTER)
# Filter by list of available parts
available_stock = available_stock.filter(
@ -1220,7 +1221,7 @@ class BuildItem(MetadataMixin, models.Model):
'quantity': _('Quantity must be 1 for serialized stock')
})
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
except (stock.models.StockItem.DoesNotExist, part.models.Part.DoesNotExist):
pass
"""
@ -1259,8 +1260,8 @@ class BuildItem(MetadataMixin, models.Model):
for idx, ancestor in enumerate(ancestors):
try:
bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
except PartModels.BomItem.DoesNotExist:
bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
except part.models.BomItem.DoesNotExist:
continue
# A matching BOM item has been found!
@ -1350,7 +1351,7 @@ class BuildItem(MetadataMixin, models.Model):
# Internal model which links part <-> sub_part
# We need to track this separately, to allow for "variant' stock
bom_item = models.ForeignKey(
PartModels.BomItem,
part.models.BomItem,
on_delete=models.CASCADE,
related_name='allocate_build_items',
blank=True, null=True,

View File

@ -247,7 +247,11 @@ src="{% static 'img/blank_image.png' %}"
{% if report_enabled %}
$('#print-build-report').click(function() {
printBuildReports([{{ build.pk }}]);
printReports({
items: [{{ build.pk }}],
key: 'build',
url: '{% url "api-build-report-list" %}',
});
});
{% endif %}

View File

@ -268,19 +268,6 @@
{% endif %}
</ul>
</div>
<!-- Label Printing Actions -->
<div class='btn-group'>
<button id='output-print-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Printing Actions" %}'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='incomplete-output-print-label' title='{% trans "Print labels" %}'>
<span class='fas fa-tags'></span> {% trans "Print labels" %}
</a></li>
</ul>
</div>
{% include "filter_list.html" with id='incompletebuilditems' %}
</div>
{% endif %}
@ -367,20 +354,6 @@ onPanelLoad('children', function() {
onPanelLoad('attachments', function() {
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-build-attachment-list" %}',
{
data: {
build: {{ build.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
$('#attachment-table').bootstrapTable('refresh');
}
}
);
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
filters: {
build: {{ build.pk }},
@ -409,10 +382,6 @@ onPanelLoad('notes', function() {
);
});
function reloadTable() {
$('#allocation-table-untracked').bootstrapTable('refresh');
}
onPanelLoad('outputs', function() {
{% if build.active %}

View File

@ -26,20 +26,6 @@
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'>
{% if report_enabled %}
<div class='btn-group' role='group'>
<!-- Print actions -->
<button id='build-print-options' class='btn btn-primary dropdown-toggle' data-bs-toggle='dropdown'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='multi-build-print' title='{% trans "Print Build Orders" %}'>
<span class='fas fa-file-pdf'></span> {% trans "Print Build Orders" %}
</a></li>
</ul>
</div>
{% endif %}
{% include "filter_list.html" with id="build" %}
</div>
</div>
@ -62,17 +48,4 @@ loadBuildTable($("#build-table"), {
locale: '{{ request.LANGUAGE_CODE }}',
});
{% if report_enabled %}
$('#multi-build-print').click(function() {
var rows = getTableData("#build-table");
var build_ids = [];
rows.forEach(function(row) {
build_ids.push(row.pk);
});
printBuildReports(build_ids);
});
{% endif %}
{% endblock %}

View File

@ -1,13 +1,13 @@
"""URL lookup for Build app."""
from django.urls import include, re_path
from django.urls import include, path, re_path
from . import views
build_urls = [
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
])),

View File

@ -457,7 +457,7 @@ settings_api_urls = [
# Notification settings
re_path(r'^notification/', include([
# Notification Settings Detail
re_path(r'^(?P<pk>\d+)/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
path(r'<int:pk>/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
# Notification Settings List
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'),
@ -486,7 +486,7 @@ common_api_urls = [
# Notifications
re_path(r'^notifications/', include([
# Individual purchase order detail URLs
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'.*$', NotificationDetail.as_view(), name='api-notifications-detail'),
])),
# Read all
@ -498,7 +498,7 @@ common_api_urls = [
# News
re_path(r'^news/', include([
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'.*$', NewsFeedEntryDetail.as_view(), name='api-news-detail'),
])),
re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'),

View File

@ -1441,6 +1441,27 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': build.validators.validate_build_order_reference_pattern,
},
'RETURNORDER_ENABLED': {
'name': _('Enable Return Orders'),
'description': _('Enable return order functionality in the user interface'),
'validator': bool,
'default': False,
},
'RETURNORDER_REFERENCE_PATTERN': {
'name': _('Return Order Reference Pattern'),
'description': _('Required pattern for generating Return Order reference field'),
'default': 'RMA-{ref:04d}',
'validator': order.validators.validate_return_order_reference_pattern,
},
'RETURNORDER_EDIT_COMPLETED_ORDERS': {
'name': _('Edit Completed Return Orders'),
'description': _('Allow editing of return orders after they have been completed'),
'default': False,
'validator': bool,
},
'SALESORDER_REFERENCE_PATTERN': {
'name': _('Sales Order Reference Pattern'),
'description': _('Required pattern for generating Sales Order reference field'),
@ -1937,6 +1958,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'default': True,
},
'SEARCH_PREVIEW_SHOW_RETURN_ORDERS': {
'name': _('Search Return Orders'),
'description': _('Display return orders in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS': {
'name': _('Exclude Inactive Return Orders'),
'description': _('Exclude inactive return orders from search preview window'),
'validator': bool,
'default': True,
},
'SEARCH_PREVIEW_RESULTS': {
'name': _('Search Preview Results'),
'description': _('Number of results to show in each section of the search preview window'),

View File

@ -305,6 +305,13 @@ class InvenTreeNotificationBodies:
template='email/purchase_order_received.html',
)
ReturnOrderItemsReceived = NotificationBody(
name=_('Items Received'),
slug='return_order.items_received',
message=_('Items have been received against a return order'),
template='email/return_order_received.html',
)
def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
"""Send out a notification."""

View File

@ -1,7 +1,7 @@
"""Provides a JSON API for the Company app."""
from django.db.models import Q
from django.urls import include, re_path
from django.urls import include, path, re_path
from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
@ -15,10 +15,11 @@ from InvenTree.mixins import (ListCreateAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI)
from plugin.serializers import MetadataSerializer
from .models import (Company, CompanyAttachment, ManufacturerPart,
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak)
from .serializers import (CompanyAttachmentSerializer, CompanySerializer,
ContactSerializer,
ManufacturerPartAttachmentSerializer,
ManufacturerPartParameterSerializer,
ManufacturerPartSerializer, SupplierPartSerializer,
@ -118,6 +119,41 @@ class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
serializer_class = CompanyAttachmentSerializer
class ContactList(ListCreateDestroyAPIView):
"""API endpoint for list view of Company model"""
queryset = Contact.objects.all()
serializer_class = ContactSerializer
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = [
'company',
]
search_fields = [
'company__name',
'name',
]
ordering_fields = [
'name',
]
ordering = 'name'
class ContactDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for Company model"""
queryset = Contact.objects.all()
serializer_class = ContactSerializer
class ManufacturerPartFilter(rest_filters.FilterSet):
"""Custom API filters for the ManufacturerPart list endpoint."""
@ -519,12 +555,12 @@ manufacturer_part_api_urls = [
# Base URL for ManufacturerPartAttachment API endpoints
re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'),
path(r'<int:pk>/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'),
re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'),
])),
re_path(r'^parameter/', include([
re_path(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
path(r'<int:pk>/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
# Catch anything else
re_path(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'),
@ -570,10 +606,15 @@ company_api_urls = [
])),
re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', CompanyAttachmentDetail.as_view(), name='api-company-attachment-detail'),
path(r'<int:pk>/', CompanyAttachmentDetail.as_view(), name='api-company-attachment-detail'),
re_path(r'^$', CompanyAttachmentList.as_view(), name='api-company-attachment-list'),
])),
re_path(r'^contact/', include([
path('<int:pk>/', ContactDetail.as_view(), name='api-contact-detail'),
re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'),
])),
re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'),
]

View File

@ -235,6 +235,11 @@ class Contact(models.Model):
role: position in company
"""
@staticmethod
def get_api_url():
"""Return the API URL associated with the Contcat model"""
return reverse('api-contact-list')
company = models.ForeignKey(Company, related_name='contacts',
on_delete=models.CASCADE)

View File

@ -17,7 +17,7 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeMoneySerializer, RemoteImageMixin)
from part.serializers import PartBriefSerializer
from .models import (Company, CompanyAttachment, ManufacturerPart,
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak)
@ -132,6 +132,23 @@ class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer):
])
class ContactSerializer(InvenTreeModelSerializer):
"""Serializer class for the Contact model"""
class Meta:
"""Metaclass options"""
model = Contact
fields = [
'pk',
'company',
'name',
'phone',
'email',
'role',
]
class ManufacturerPartSerializer(InvenTreeModelSerializer):
"""Serializer for ManufacturerPart object."""

View File

@ -1,6 +1,7 @@
{% extends "company/company_base.html" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block sidebar %}
{% include 'company/sidebar.html' %}
@ -137,6 +138,8 @@
</div>
</div>
{% if company.is_customer %}
{% if roles.sales_order.view %}
<div class='panel panel-hidden' id='panel-sales-orders'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
@ -162,7 +165,9 @@
</table>
</div>
</div>
{% endif %}
{% if roles.stock.view %}
<div class='panel panel-hidden' id='panel-assigned-stock'>
<div class='panel-heading'>
<h4>{% trans "Assigned Stock" %}</h4>
@ -175,9 +180,40 @@
</div>
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
</div>
</div>
{% endif %}
{% if roles.return_order.view and return_order_enabled %}
<div class='panel panel-hidden' id='panel-return-orders'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Return Orders" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.return_order.add %}
<button class='btn btn-success' type='button' id='new-return-order' title='{% trans "Create new return order" %}'>
<div class='fas fa-plus-circle'></div> {% trans "New Return Order" %}
</button>
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
{% include "filter_list.html" with id="returnorder" %}
</div>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#table-buttons' id='return-order-table'>
</table>
</div>
</div>
{% endif %}
{% endif %}
<div class='panel panel-hidden' id='panel-company-notes'>
<div class='panel-heading'>
@ -194,6 +230,31 @@
</div>
</div>
<div class='panel panel-hidden' id='panel-company-contacts'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Company Contacts" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.purchase_order.add or roles.sales_order.add %}
<button class='btn btn-success' type='button' id='new-contact' title='{% trans "Add Contact" %}'>
<div class='fas fa-plus-circle'></div> {% trans "Add Contact" %}
</button>
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='contacts-button-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="contacts" %}
</div>
</div>
<table class='table table-striped table-condensed' id='contacts-table' data-toolbar='#contacts-button-toolbar'></table>
</div>
</div>
<div class='panel panel-hidden' id='panel-attachments'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
@ -226,22 +287,29 @@
}
}
});
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-company-attachment-list" %}',
{
data: {
company: {{ company.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
});
// Callback function when the 'contacts' panel is loaded
onPanelLoad('company-contacts', function() {
loadContactTable('#contacts-table', {
params: {
company: {{ company.pk }},
},
allow_edit: {% js_bool roles.purchase_order.change %} || {% js_bool roles.sales_order.change %},
allow_delete: {% js_bool roles.purchase_order.delete %} || {% js_bool roles.sales_order.delete %},
});
$('#new-contact').click(function() {
createContact({
company: {{ company.pk }},
onSuccess: function() {
$('#contacts-table').bootstrapTable('refresh');
}
});
});
});
// Callback function when the 'notes' panel is loaded
onPanelLoad('company-notes', function() {
setupNotesField(
@ -250,18 +318,7 @@
{
editable: true,
}
)
});
loadStockTable($("#assigned-stock-table"), {
params: {
customer: {{ company.id }},
part_detail: true,
location_detail: true,
},
url: "{% url 'api-stock-list' %}",
filterKey: "customerstock",
filterTarget: '#filter-list-customerstock',
);
});
onPanelLoad('company-stock', function() {
@ -282,20 +339,65 @@
});
{% if company.is_customer %}
{% if return_order_enabled %}
// Callback function when the 'return orders' panel is loaded
onPanelLoad('return-orders', function() {
{% if roles.return_order.view %}
loadReturnOrderTable('#return-order-table', {
params: {
customer: {{ company.pk }},
}
});
{% endif %}
{% if roles.return_order.add %}
$('#new-return-order').click(function() {
createReturnOrder({
customer: {{ company.pk }},
});
});
{% endif %}
});
{% endif %}
// Callback function when the 'assigned stock' panel is loaded
onPanelLoad('assigned-stock', function() {
{% if roles.stock.view %}
loadStockTable($("#assigned-stock-table"), {
params: {
customer: {{ company.id }},
part_detail: true,
location_detail: true,
},
url: "{% url 'api-stock-list' %}",
filterKey: "customerstock",
filterTarget: '#filter-list-customerstock',
});
{% endif %}
});
// Callback function when the 'sales orders' panel is loaded
onPanelLoad('sales-orders', function() {
{% if roles.sales_order.view %}
loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}",
params: {
customer: {{ company.id }},
}
});
{% endif %}
{% if roles.salse_order.add %}
$("#new-sales-order").click(function() {
createSalesOrder({
customer: {{ company.pk }},
});
});
{% endif %}
});
{% endif %}
@ -334,7 +436,7 @@
createManufacturerPart({
manufacturer: {{ company.pk }},
onSuccess: function() {
$("#part-table").bootstrapTable("refresh");
$("#part-table").bootstrapTable('refresh');
}
});
});
@ -356,7 +458,7 @@
deleteManufacturerParts(selections, {
success: function() {
$("#manufacturer-part-table").bootstrapTable("refresh");
$("#manufacturer-part-table").bootstrapTable('refresh');
}
});
});

View File

@ -209,26 +209,8 @@ onPanelLoad("attachments", function() {
}
}
});
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-manufacturer-part-attachment-list" %}',
{
data: {
manufacturer_part: {{ part.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
});
function reloadParameters() {
$("#parameter-table").bootstrapTable("refresh");
}
$('#parameter-create').click(function() {
constructForm('{% url "api-manufacturer-part-parameter-list" %}', {
@ -243,7 +225,7 @@ $('#parameter-create').click(function() {
}
},
title: '{% trans "Add Parameter" %}',
onSuccess: reloadParameters
refreshTable: '#parameter-table',
});
});

View File

@ -17,11 +17,21 @@
{% include "sidebar_item.html" with label='company-stock' text=text icon="fa-boxes" %}
{% endif %}
{% if company.is_customer %}
{% if roles.sales_order.view %}
{% trans "Sales Orders" as text %}
{% include "sidebar_item.html" with label='sales-orders' text=text icon="fa-truck" %}
{% endif %}
{% if roles.stock.view %}
{% trans "Assigned Stock Items" as text %}
{% include "sidebar_item.html" with label='assigned-stock' text=text icon="fa-sign-out-alt" %}
{% endif %}
{% if roles.return_order.view and return_order_enabled %}
{% trans "Return Orders" as text %}
{% include "sidebar_item.html" with label='return-orders' text=text icon="fa-undo" %}
{% endif %}
{% endif %}
{% trans "Contacts" as text %}
{% include "sidebar_item.html" with label='company-contacts' text=text icon="fa-users" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %}
{% trans "Attachments" as text %}

View File

@ -301,7 +301,7 @@ loadSupplierPriceBreakTable({
$('#new-price-break').click(function() {
createSupplierPartPriceBreak({{ part.pk }}, {
onSuccess: function() {
$("#price-break-table").bootstrapTable("refresh");
$("#price-break-table").bootstrapTable('refresh');
}
});

View File

@ -6,7 +6,7 @@ from rest_framework import status
from InvenTree.api_tester import InvenTreeAPITestCase
from .models import Company, SupplierPart
from .models import Company, Contact, SupplierPart
class CompanyTest(InvenTreeAPITestCase):
@ -140,6 +140,144 @@ class CompanyTest(InvenTreeAPITestCase):
self.assertTrue('currency' in response.data)
class ContactTest(InvenTreeAPITestCase):
"""Tests for the Contact models"""
roles = []
@classmethod
def setUpTestData(cls):
"""Perform init for this test class"""
super().setUpTestData()
# Create some companies
companies = [
Company(
name=f"Company {idx}",
description="Some company"
) for idx in range(3)
]
Company.objects.bulk_create(companies)
contacts = []
# Create some contacts
for cmp in Company.objects.all():
contacts += [
Contact(
company=cmp,
name=f"My name {idx}",
) for idx in range(3)
]
Contact.objects.bulk_create(contacts)
cls.url = reverse('api-contact-list')
def test_list(self):
"""Test company list API endpoint"""
# List all results
response = self.get(self.url, {}, expected_code=200)
self.assertEqual(len(response.data), 9)
for result in response.data:
for key in ['name', 'email', 'pk', 'company']:
self.assertIn(key, result)
# Filter by particular company
for cmp in Company.objects.all():
response = self.get(
self.url,
{
'company': cmp.pk,
},
expected_code=200
)
self.assertEqual(len(response.data), 3)
def test_create(self):
"""Test that we can create a new Contact object via the API"""
n = Contact.objects.count()
company = Company.objects.first()
# Without required permissions, creation should fail
self.post(
self.url,
{
'company': company.pk,
'name': 'Joe Bloggs',
},
expected_code=403
)
self.assignRole('return_order.add')
self.post(
self.url,
{
'company': company.pk,
'name': 'Joe Bloggs',
},
expected_code=201
)
self.assertEqual(Contact.objects.count(), n + 1)
def test_edit(self):
"""Test that we can edit a Contact via the API"""
url = reverse('api-contact-detail', kwargs={'pk': 1})
# Retrieve detail view
data = self.get(url, expected_code=200).data
for key in ['pk', 'name', 'role']:
self.assertIn(key, data)
self.patch(
url,
{
'role': 'model',
},
expected_code=403
)
self.assignRole('purchase_order.change')
self.patch(
url,
{
'role': 'x',
},
expected_code=200
)
contact = Contact.objects.get(pk=1)
self.assertEqual(contact.role, 'x')
def test_delete(self):
"""Tests that we can delete a Contact via the API"""
url = reverse('api-contact-detail', kwargs={'pk': 6})
# Delete (without required permissions)
self.delete(url, expected_code=403)
self.assignRole('sales_order.delete')
self.delete(url, expected_code=204)
# Try to access again (gone!)
self.get(url, expected_code=404)
class ManufacturerTest(InvenTreeAPITestCase):
"""Series of tests for the Manufacturer DRF API."""

View File

@ -1,13 +1,13 @@
"""URL lookup for Company app."""
from django.urls import include, re_path
from django.urls import include, path, re_path
from . import views
company_urls = [
# Detail URLs for a specific Company instance
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'),
])),
@ -21,11 +21,11 @@ company_urls = [
manufacturer_part_urls = [
re_path(r'^(?P<pk>\d+)/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'),
path(r'<int:pk>/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'),
]
supplier_part_urls = [
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
]))

View File

@ -3,7 +3,7 @@
from django.conf import settings
from django.core.exceptions import FieldError, ValidationError
from django.http import HttpResponse, JsonResponse
from django.urls import include, re_path
from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page, never_cache
@ -403,7 +403,7 @@ label_api_urls = [
# Stock item labels
re_path(r'stock/', include([
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'),
re_path(r'metadata/', StockItemLabelMetadata.as_view(), name='api-stockitem-label-metadata'),
re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'),
@ -416,7 +416,7 @@ label_api_urls = [
# Stock location labels
re_path(r'location/', include([
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'),
re_path(r'metadata/', StockLocationLabelMetadata.as_view(), name='api-stocklocation-label-metadata'),
re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'),
@ -429,7 +429,7 @@ label_api_urls = [
# Part labels
re_path(r'^part/', include([
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'),
re_path(r'^metadata/', PartLabelMetadata.as_view(), name='api-part-label-metadata'),
re_path(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'),

View File

@ -6,13 +6,9 @@ import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
import order.models as models
from InvenTree.admin import InvenTreeResource
from .models import (PurchaseOrder, PurchaseOrderExtraLine,
PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation,
SalesOrderExtraLine, SalesOrderLineItem,
SalesOrderShipment)
# region general classes
class GeneralExtraLineAdmin:
@ -42,7 +38,7 @@ class GeneralExtraLineMeta:
class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
"""Inline admin class for the PurchaseOrderLineItem model"""
model = PurchaseOrderLineItem
model = models.PurchaseOrderLineItem
extra = 0
@ -103,7 +99,7 @@ class PurchaseOrderResource(InvenTreeResource):
class Meta:
"""Metaclass"""
model = PurchaseOrder
model = models.PurchaseOrder
skip_unchanged = True
clean_model_instances = True
exclude = [
@ -122,7 +118,7 @@ class PurchaseOrderLineItemResource(InvenTreeResource):
class Meta:
"""Metaclass"""
model = PurchaseOrderLineItem
model = models.PurchaseOrderLineItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
@ -142,7 +138,7 @@ class PurchaseOrderExtraLineResource(InvenTreeResource):
class Meta(GeneralExtraLineMeta):
"""Metaclass options."""
model = PurchaseOrderExtraLine
model = models.PurchaseOrderExtraLine
class SalesOrderResource(InvenTreeResource):
@ -150,7 +146,7 @@ class SalesOrderResource(InvenTreeResource):
class Meta:
"""Metaclass options"""
model = SalesOrder
model = models.SalesOrder
skip_unchanged = True
clean_model_instances = True
exclude = [
@ -169,7 +165,7 @@ class SalesOrderLineItemResource(InvenTreeResource):
class Meta:
"""Metaclass options"""
model = SalesOrderLineItem
model = models.SalesOrderLineItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
@ -198,8 +194,7 @@ class SalesOrderExtraLineResource(InvenTreeResource):
class Meta(GeneralExtraLineMeta):
"""Metaclass options."""
model = SalesOrderExtraLine
model = models.SalesOrderExtraLine
class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
@ -281,13 +276,92 @@ class SalesOrderAllocationAdmin(ImportExportModelAdmin):
autocomplete_fields = ('line', 'shipment', 'item',)
admin.site.register(PurchaseOrder, PurchaseOrderAdmin)
admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
admin.site.register(PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin)
class ReturnOrderResource(InvenTreeResource):
"""Class for managing import / export of ReturnOrder data"""
admin.site.register(SalesOrder, SalesOrderAdmin)
admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin)
admin.site.register(SalesOrderExtraLine, SalesOrderExtraLineAdmin)
class Meta:
"""Metaclass options"""
model = models.ReturnOrder
skip_unchanged = True
clean_model_instances = True
exclude = [
'metadata',
]
admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin)
admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin)
class ReturnOrderAdmin(ImportExportModelAdmin):
"""Admin class for the ReturnOrder model"""
exclude = [
'reference_int',
]
list_display = [
'reference',
'customer',
'status',
]
search_fields = [
'reference',
'customer__name',
'description',
]
autocomplete_fields = [
'customer',
]
class ReturnOrderLineItemResource(InvenTreeResource):
"""Class for managing import / export of ReturnOrderLineItem data"""
class Meta:
"""Metaclass options"""
model = models.ReturnOrderLineItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
class ReturnOrderLineItemAdmin(ImportExportModelAdmin):
"""Admin class for ReturnOrderLine model"""
resource_class = ReturnOrderLineItemResource
list_display = [
'order',
'item',
'reference',
]
class ReturnOrderExtraLineClass(InvenTreeResource):
"""Class for managing import/export of ReturnOrderExtraLine data"""
class Meta(GeneralExtraLineMeta):
"""Metaclass options"""
model = models.ReturnOrderExtraLine
class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin):
"""Admin class for the ReturnOrderExtraLine model"""
resource_class = ReturnOrderExtraLineClass
# Purchase Order models
admin.site.register(models.PurchaseOrder, PurchaseOrderAdmin)
admin.site.register(models.PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
admin.site.register(models.PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin)
# Sales Order models
admin.site.register(models.SalesOrder, SalesOrderAdmin)
admin.site.register(models.SalesOrderLineItem, SalesOrderLineItemAdmin)
admin.site.register(models.SalesOrderExtraLine, SalesOrderExtraLineAdmin)
admin.site.register(models.SalesOrderShipment, SalesOrderShipmentAdmin)
admin.site.register(models.SalesOrderAllocation, SalesOrderAllocationAdmin)
# Return Order models
admin.site.register(models.ReturnOrder, ReturnOrderAdmin)
admin.site.register(models.ReturnOrderLineItem, ReturnOrderLineItemAdmin)
admin.site.register(models.ReturnOrderExtraLine, ReturnOrdeerExtraLineAdmin)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
- model: order.returnorder
pk: 1
fields:
reference: 'RMA-001'
reference_int: 1
description: 'RMA from a customer'
customer: 4
status: 10 # Pending
- model: order.returnorder
pk: 2
fields:
reference: 'RMA-002'
reference_int: 2
description: 'RMA from a customer'
customer: 4
status: 20 # In Progress
- model: order.returnorder
pk: 3
fields:
reference: 'RMA-003'
reference_int: 3
description: 'RMA from a customer'
customer: 4
status: 30 # Complete
- model: order.returnorder
pk: 4
fields:
reference: 'RMA-004'
reference_int: 4
description: 'RMA from a customer'
customer: 5
status: 40 # Cancelled
- model: order.returnorder
pk: 5
fields:
reference: 'RMA-005'
reference_int: 5
description: 'RMA from a customer'
customer: 5
status: 20 # In progress
- model: order.returnorder
pk: 6
fields:
reference: 'RMA-006'
reference_int: 6
description: 'RMA from a customer'
customer: 5
status: 10 # Pending

View File

@ -0,0 +1,64 @@
# Generated by Django 3.2.18 on 2023-03-14 07:25
import InvenTree.fields
import InvenTree.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import order.validators
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('company', '0054_companyattachment'),
('users', '0006_alter_ruleset_name'),
('order', '0080_auto_20230317_0816'),
]
operations = [
migrations.CreateModel(
name='ReturnOrder',
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')),
('reference_int', models.BigIntegerField(default=0)),
('description', models.CharField(help_text='Order description', max_length=250, verbose_name='Description')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link')),
('creation_date', models.DateField(blank=True, null=True, verbose_name='Creation Date')),
('notes', InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Order notes', max_length=50000, null=True, verbose_name='Notes')),
('reference', models.CharField(default=order.validators.generate_next_return_order_reference, help_text='Return Order reference', max_length=64, unique=True, validators=[order.validators.validate_return_order_reference], verbose_name='Reference')),
('status', models.PositiveIntegerField(choices=[(10, 'Pending'), (30, 'Complete'), (40, 'Cancelled')], default=10, help_text='Return order status', verbose_name='Status')),
('customer_reference', models.CharField(blank=True, help_text='Customer order reference code', max_length=64, verbose_name='Customer Reference ')),
('issue_date', models.DateField(blank=True, help_text='Date order was issued', null=True, verbose_name='Issue Date')),
('complete_date', models.DateField(blank=True, help_text='Date order was completed', null=True, verbose_name='Completion Date')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('customer', models.ForeignKey(help_text='Company from which items are being returned', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.company', verbose_name='Customer')),
('responsible', models.ForeignKey(blank=True, help_text='User or group responsible for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='users.owner', verbose_name='Responsible')),
],
options={
'abstract': False,
},
),
migrations.AlterField(
model_name='salesorder',
name='customer',
field=models.ForeignKey(help_text='Company to which the items are being sold', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='return_orders', to='company.company', verbose_name='Customer'),
),
migrations.CreateModel(
name='ReturnOrderAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.returnorder')),
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 3.2.18 on 2023-03-14 12:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0054_companyattachment'),
('order', '0081_auto_20230314_0725'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='contact',
field=models.ForeignKey(blank=True, help_text='Point of contact for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.contact', verbose_name='Contact'),
),
migrations.AddField(
model_name='returnorder',
name='contact',
field=models.ForeignKey(blank=True, help_text='Point of contact for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.contact', verbose_name='Contact'),
),
migrations.AddField(
model_name='salesorder',
name='contact',
field=models.ForeignKey(blank=True, help_text='Point of contact for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.contact', verbose_name='Contact'),
),
]

View File

@ -0,0 +1,35 @@
# Generated by Django 3.2.18 on 2023-03-16 02:52
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import djmoney.models.fields
import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('order', '0082_auto_20230314_1259'),
]
operations = [
migrations.CreateModel(
name='ReturnOrderExtraLine',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')),
('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference')),
('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes')),
('target_date', models.DateField(blank=True, help_text='Target date for this line item (leave blank to use the target date from the order)', null=True, verbose_name='Target Date')),
('context', models.JSONField(blank=True, help_text='Additional context for this line', null=True, verbose_name='Context')),
('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')),
('order', models.ForeignKey(help_text='Return Order', on_delete=django.db.models.deletion.CASCADE, related_name='extra_lines', to='order.returnorder', verbose_name='Order')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 3.2.18 on 2023-03-21 11:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0083_returnorderextraline'),
]
operations = [
migrations.AddField(
model_name='returnorder',
name='target_date',
field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Date'),
),
migrations.AlterField(
model_name='purchaseorder',
name='target_date',
field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Date'),
),
migrations.AlterField(
model_name='returnorder',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'In Progress'), (30, 'Complete'), (40, 'Cancelled')], default=10, help_text='Return order status', verbose_name='Status'),
),
migrations.AlterField(
model_name='salesorder',
name='target_date',
field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Date'),
),
]

View File

@ -0,0 +1,49 @@
# Generated by Django 3.2.18 on 2023-03-22 10:56
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import djmoney.models.fields
import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('stock', '0095_stocklocation_external'),
('order', '0084_auto_20230321_1111'),
]
operations = [
migrations.AddField(
model_name='returnorder',
name='total_price',
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Total price for this order', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Total Price'),
),
migrations.AddField(
model_name='returnorder',
name='total_price_currency',
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3),
),
migrations.CreateModel(
name='ReturnOrderLineItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')),
('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference')),
('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes')),
('target_date', models.DateField(blank=True, help_text='Target date for this line item (leave blank to use the target date from the order)', null=True, verbose_name='Target Date')),
('received_date', models.DateField(blank=True, help_text='The date this this return item was received', null=True, verbose_name='Received Date')),
('outcome', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Return'), (30, 'Repair'), (50, 'Refund'), (40, 'Replace'), (60, 'Reject')], default=10, help_text='Outcome for this line item', verbose_name='Outcome')),
('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Cost associated with return or repair for this line item', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link')),
('item', models.ForeignKey(help_text='Select item to return from customer', on_delete=django.db.models.deletion.CASCADE, related_name='return_order_lines', to='stock.stockitem', verbose_name='Item')),
('order', models.ForeignKey(help_text='Return Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.returnorder', verbose_name='Order')),
],
options={
'unique_together': {('order', 'item')},
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.18 on 2023-03-23 11:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0085_auto_20230322_1056'),
]
operations = [
migrations.AddField(
model_name='returnorderextraline',
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='returnorderlineitem',
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

@ -26,25 +26,110 @@ import InvenTree.helpers
import InvenTree.ready
import InvenTree.tasks
import order.validators
import stock.models
import users.models as UserModels
from common.notifications import InvenTreeNotificationBodies
from common.settings import currency_code_default
from company.models import Company, SupplierPart
from company.models import Company, Contact, SupplierPart
from InvenTree.exceptions import log_error
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
InvenTreeURLField, RoundingDecimalField)
from InvenTree.helpers import decimal2string, getSetting, notify_responsible
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus,
ReturnOrderStatus, SalesOrderStatus,
StockHistoryCode, StockStatus)
from part import models as PartModels
from plugin.events import trigger_event
from plugin.models import MetadataMixin
from stock import models as stock_models
from users import models as UserModels
logger = logging.getLogger('inventree')
class TotalPriceMixin(models.Model):
"""Mixin which provides 'total_price' field for an order"""
class Meta:
"""Meta for MetadataMixin."""
abstract = True
def save(self, *args, **kwargs):
"""Update the total_price field when saved"""
# Recalculate total_price for this order
self.update_total_price(commit=False)
super().save(*args, **kwargs)
total_price = InvenTreeModelMoneyField(
null=True, blank=True,
allow_negative=False,
verbose_name=_('Total Price'),
help_text=_('Total price for this order')
)
def update_total_price(self, commit=True):
"""Recalculate and save the total_price for this order"""
self.total_price = self.calculate_total_price()
if commit:
self.save()
def calculate_total_price(self, target_currency=None):
"""Calculates the total price of all order lines, and converts to the specified target currency.
If not specified, the default system currency is used.
If currency conversion fails (e.g. there are no valid conversion rates),
then we simply return zero, rather than attempting some other calculation.
"""
# Set default - see B008
if target_currency is None:
target_currency = currency_code_default()
total = Money(0, target_currency)
# order items
for line in self.lines.all():
if not line.price:
continue
try:
total += line.quantity * convert_money(line.price, target_currency)
except MissingRate:
# Record the error, try to press on
kind, info, data = sys.exc_info()
log_error('order.calculate_total_price')
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# extra items
for line in self.extra_lines.all():
if not line.price:
continue
try:
total += line.quantity * convert_money(line.price, target_currency)
except MissingRate:
# Record the error, try to press on
log_error('order.calculate_total_price')
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# set decimal-places
total.decimal_places = 4
return total
class Order(MetadataMixin, ReferenceIndexingMixin):
"""Abstract model for an order.
@ -78,15 +163,49 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
if not self.creation_date:
self.creation_date = datetime.now().date()
# Recalculate total_price for this order
self.update_total_price(commit=False)
super().save(*args, **kwargs)
def clean(self):
"""Custom clean method for the generic order class"""
super().clean()
# Check that the referenced 'contact' matches the correct 'company'
if self.company and self.contact:
if self.contact.company != self.company:
raise ValidationError({
"contact": _("Contact does not match selected company")
})
@classmethod
def overdue_filter(cls):
"""A generic implementation of an 'overdue' filter for the Model class
It requires any subclasses to implement the get_status_class() class method
"""
today = datetime.now().date()
return Q(status__in=cls.get_status_class().OPEN) & ~Q(target_date=None) & Q(target_date__lt=today)
@property
def is_overdue(self):
"""Method to determine if this order is overdue.
Makes use of the overdue_filter() method to avoid code duplication
"""
return self.__class__.objects.filter(pk=self.pk).filter(self.__class__.overdue_filter()).exists()
description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_('Order description'))
link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page'))
target_date = models.DateField(
blank=True, null=True,
verbose_name=_('Target Date'),
help_text=_('Expected date for order delivery. Order will be overdue after this date.'),
)
creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date'))
created_by = models.ForeignKey(User,
@ -105,84 +224,25 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
related_name='+',
)
notes = InvenTreeNotesField(help_text=_('Order notes'))
total_price = InvenTreeModelMoneyField(
null=True, blank=True,
allow_negative=False,
verbose_name=_('Total Price'),
help_text=_('Total price for this order')
contact = models.ForeignKey(
Contact,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Contact'),
help_text=_('Point of contact for this order'),
related_name='+',
)
def update_total_price(self, commit=True):
"""Recalculate and save the total_price for this order"""
notes = InvenTreeNotesField(help_text=_('Order notes'))
self.total_price = self.calculate_total_price()
@classmethod
def get_status_class(cls):
"""Return the enumeration class which represents the 'status' field for this model"""
if commit:
self.save()
def calculate_total_price(self, target_currency=None):
"""Calculates the total price of all order lines, and converts to the specified target currency.
If not specified, the default system currency is used.
If currency conversion fails (e.g. there are no valid conversion rates),
then we simply return zero, rather than attempting some other calculation.
"""
# Set default - see B008
if target_currency is None:
target_currency = currency_code_default()
total = Money(0, target_currency)
# gather name reference
price_ref_tag = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price'
# order items
for line in self.lines.all():
price_ref = getattr(line, price_ref_tag)
if not price_ref:
continue
try:
total += line.quantity * convert_money(price_ref, target_currency)
except MissingRate:
# Record the error, try to press on
kind, info, data = sys.exc_info()
log_error('order.calculate_total_price')
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# extra items
for line in self.extra_lines.all():
if not line.price:
continue
try:
total += line.quantity * convert_money(line.price, target_currency)
except MissingRate:
# Record the error, try to press on
log_error('order.calculate_total_price')
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# set decimal-places
total.decimal_places = 4
return total
raise NotImplementedError(f"get_status_class() not implemented for {__class__}")
class PurchaseOrder(Order):
class PurchaseOrder(TotalPriceMixin, Order):
"""A PurchaseOrder represents goods shipped inwards from an external supplier.
Attributes:
@ -192,14 +252,23 @@ class PurchaseOrder(Order):
target_date: Expected delivery target date for PurchaseOrder completion (optional)
"""
def get_absolute_url(self):
"""Get the 'web' URL for this order"""
return reverse('po-detail', kwargs={'pk': self.pk})
@staticmethod
def get_api_url():
"""Return the API URL associated with the PurchaseOrder model"""
return reverse('api-po-list')
@classmethod
def get_status_class(cls):
"""Return the PurchasOrderStatus class"""
return PurchaseOrderStatus
@classmethod
def api_defaults(cls, request):
"""Return default values for thsi model when issuing an API OPTIONS request"""
"""Return default values for this model when issuing an API OPTIONS request"""
defaults = {
'reference': order.validators.generate_next_purchase_order_reference(),
@ -207,8 +276,6 @@ class PurchaseOrder(Order):
return defaults
OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
# Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
@ -283,6 +350,11 @@ class PurchaseOrder(Order):
help_text=_('Company from which the items are being ordered')
)
@property
def company(self):
"""Accessor helper for Order base class"""
return self.supplier
supplier_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Supplier Reference'), help_text=_("Supplier order reference code"))
received_by = models.ForeignKey(
@ -299,22 +371,12 @@ class PurchaseOrder(Order):
help_text=_('Date order was issued')
)
target_date = models.DateField(
blank=True, null=True,
verbose_name=_('Target Delivery Date'),
help_text=_('Expected date for order delivery. Order will be overdue after this date.'),
)
complete_date = models.DateField(
blank=True, null=True,
verbose_name=_('Completion Date'),
help_text=_('Date order was completed')
)
def get_absolute_url(self):
"""Return the web URL of the detail view for this order"""
return reverse('po-detail', kwargs={'pk': self.id})
@transaction.atomic
def add_line_item(self, supplier_part, quantity, group: bool = True, reference: str = '', purchase_price=None):
"""Add a new line item to this purchase order.
@ -417,17 +479,6 @@ class PurchaseOrder(Order):
"""Return True if the PurchaseOrder is 'pending'"""
return self.status == PurchaseOrderStatus.PENDING
@property
def is_overdue(self):
"""Returns True if this PurchaseOrder is "overdue".
Makes use of the OVERDUE_FILTER to avoid code duplication.
"""
query = PurchaseOrder.objects.filter(pk=self.pk)
query = query.filter(PurchaseOrder.OVERDUE_FILTER)
return query.exists()
def can_cancel(self):
"""A PurchaseOrder can only be cancelled under the following circumstances.
@ -534,7 +585,7 @@ class PurchaseOrder(Order):
for sn in serials:
stock = stock_models.StockItem(
item = stock.models.StockItem(
part=line.part.part,
supplier_part=line.part,
location=location,
@ -547,14 +598,14 @@ class PurchaseOrder(Order):
barcode_hash=barcode_hash
)
stock.save(add_note=False)
item.save(add_note=False)
tracking_info = {
'status': status,
'purchaseorder': self.pk,
}
stock.add_tracking_entry(
item.add_tracking_entry(
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
user,
notes=notes,
@ -595,20 +646,23 @@ def after_save_purchase_order(sender, instance: PurchaseOrder, created: bool, **
notify_responsible(instance, sender, exclude=instance.created_by)
class SalesOrder(Order):
"""A SalesOrder represents a list of goods shipped outwards to a customer.
class SalesOrder(TotalPriceMixin, Order):
"""A SalesOrder represents a list of goods shipped outwards to a customer."""
Attributes:
customer: Reference to the company receiving the goods in the order
customer_reference: Optional field for customer order reference code
target_date: Target date for SalesOrder completion (optional)
"""
def get_absolute_url(self):
"""Get the 'web' URL for this order"""
return reverse('so-detail', kwargs={'pk': self.pk})
@staticmethod
def get_api_url():
"""Return the API URL associated with the SalesOrder model"""
return reverse('api-so-list')
@classmethod
def get_status_class(cls):
"""Return the SalesOrderStatus class"""
return SalesOrderStatus
@classmethod
def api_defaults(cls, request):
"""Return default values for this model when issuing an API OPTIONS request"""
@ -618,8 +672,6 @@ class SalesOrder(Order):
return defaults
OVERDUE_FILTER = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
# Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
@ -663,10 +715,6 @@ class SalesOrder(Order):
return f"{self.reference} - {self.customer.name if self.customer else _('deleted')}"
def get_absolute_url(self):
"""Return the web URL for the detail view of this order"""
return reverse('so-detail', kwargs={'pk': self.id})
reference = models.CharField(
unique=True,
max_length=64,
@ -684,13 +732,21 @@ class SalesOrder(Order):
on_delete=models.SET_NULL,
null=True,
limit_choices_to={'is_customer': True},
related_name='sales_orders',
related_name='return_orders',
verbose_name=_('Customer'),
help_text=_("Company to which the items are being sold"),
)
status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(),
verbose_name=_('Status'), help_text=_('Purchase order status'))
@property
def company(self):
"""Accessor helper for Order base"""
return self.customer
status = models.PositiveIntegerField(
default=SalesOrderStatus.PENDING,
choices=SalesOrderStatus.items(),
verbose_name=_('Status'), help_text=_('Purchase order status')
)
@property
def status_text(self):
@ -699,12 +755,6 @@ class SalesOrder(Order):
customer_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Customer Reference '), help_text=_("Customer order reference code"))
target_date = models.DateField(
null=True, blank=True,
verbose_name=_('Target completion date'),
help_text=_('Target date for order completion. Order will be overdue after this date.')
)
shipment_date = models.DateField(blank=True, null=True, verbose_name=_('Shipment Date'))
shipped_by = models.ForeignKey(
@ -715,17 +765,6 @@ class SalesOrder(Order):
verbose_name=_('shipped by')
)
@property
def is_overdue(self):
"""Returns true if this SalesOrder is "overdue".
Makes use of the OVERDUE_FILTER to avoid code duplication.
"""
query = SalesOrder.objects.filter(pk=self.pk)
query = query.filter(SalesOrder.OVERDUE_FILTER)
return query.exists()
@property
def is_pending(self):
"""Return True if this order is 'pending'"""
@ -1121,9 +1160,9 @@ class PurchaseOrderLineItem(OrderLineItem):
stock items location will be reported as the location for the
entire line.
"""
for stock in stock_models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order):
if stock.location:
return stock.location
for item in stock.models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order):
if item.location:
return item.location
if self.destination:
return self.destination
if self.part and self.part.part and self.part.part.default_location:
@ -1420,7 +1459,11 @@ class SalesOrderExtraLine(OrderExtraLine):
"""Return the API URL associated with the SalesOrderExtraLine model"""
return reverse('api-so-extra-line-list')
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='extra_lines', verbose_name=_('Order'), help_text=_('Sales Order'))
order = models.ForeignKey(
SalesOrder, on_delete=models.CASCADE,
related_name='extra_lines',
verbose_name=_('Order'), help_text=_('Sales Order')
)
class SalesOrderAllocation(models.Model):
@ -1455,7 +1498,7 @@ class SalesOrderAllocation(models.Model):
try:
if not self.item:
raise ValidationError({'item': _('Stock item has not been assigned')})
except stock_models.StockItem.DoesNotExist:
except stock.models.StockItem.DoesNotExist:
raise ValidationError({'item': _('Stock item has not been assigned')})
try:
@ -1547,3 +1590,299 @@ class SalesOrderAllocation(models.Model):
# (It may have changed if the stock was split)
self.item = item
self.save()
class ReturnOrder(TotalPriceMixin, Order):
"""A ReturnOrder represents goods returned from a customer, e.g. an RMA or warranty
Attributes:
customer: Reference to the customer
sales_order: Reference to an existing SalesOrder (optional)
status: The status of the order (refer to status_codes.ReturnOrderStatus)
"""
def get_absolute_url(self):
"""Get the 'web' URL for this order"""
return reverse('return-order-detail', kwargs={'pk': self.pk})
@staticmethod
def get_api_url():
"""Return the API URL associated with the ReturnOrder model"""
return reverse('api-return-order-list')
@classmethod
def get_status_class(cls):
"""Return the ReturnOrderStatus class"""
return ReturnOrderStatus
@classmethod
def api_defaults(cls, request):
"""Return default values for this model when issuing an API OPTIONS request"""
defaults = {
'reference': order.validators.generate_next_return_order_reference(),
}
return defaults
REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN'
def __str__(self):
"""Render a string representation of this ReturnOrder"""
return f"{self.reference} - {self.customer.name if self.customer else _('no customer')}"
reference = models.CharField(
unique=True,
max_length=64,
blank=False,
verbose_name=_('Reference'),
help_text=_('Return Order reference'),
default=order.validators.generate_next_return_order_reference,
validators=[
order.validators.validate_return_order_reference,
]
)
customer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
null=True,
limit_choices_to={'is_customer': True},
related_name='sales_orders',
verbose_name=_('Customer'),
help_text=_("Company from which items are being returned"),
)
@property
def company(self):
"""Accessor helper for Order base class"""
return self.customer
status = models.PositiveIntegerField(
default=ReturnOrderStatus.PENDING,
choices=ReturnOrderStatus.items(),
verbose_name=_('Status'), help_text=_('Return order status')
)
customer_reference = models.CharField(
max_length=64, blank=True,
verbose_name=_('Customer Reference '),
help_text=_("Customer order reference code")
)
issue_date = models.DateField(
blank=True, null=True,
verbose_name=_('Issue Date'),
help_text=_('Date order was issued')
)
complete_date = models.DateField(
blank=True, null=True,
verbose_name=_('Completion Date'),
help_text=_('Date order was completed')
)
@property
def is_pending(self):
"""Return True if this order is pending"""
return self.status == ReturnOrderStatus.PENDING
@property
def is_open(self):
"""Return True if this order is outstanding"""
return self.status in ReturnOrderStatus.OPEN
@property
def is_received(self):
"""Return True if this order is fully received"""
return not self.lines.filter(received_date=None).exists()
@transaction.atomic
def cancel_order(self):
"""Cancel this ReturnOrder (if not already cancelled)"""
if self.status != ReturnOrderStatus.CANCELLED:
self.status = ReturnOrderStatus.CANCELLED
self.save()
trigger_event('returnorder.cancelled', id=self.pk)
@transaction.atomic
def complete_order(self):
"""Complete this ReturnOrder (if not already completed)"""
if self.status == ReturnOrderStatus.IN_PROGRESS:
self.status = ReturnOrderStatus.COMPLETE
self.complete_date = datetime.now().date()
self.save()
trigger_event('returnorder.completed', id=self.pk)
@transaction.atomic
def place_order(self):
"""Issue this ReturnOrder (if currently pending)"""
if self.status == ReturnOrderStatus.PENDING:
self.status = ReturnOrderStatus.IN_PROGRESS
self.issue_date = datetime.now().date()
self.save()
trigger_event('returnorder.placed', id=self.pk)
@transaction.atomic
def receive_line_item(self, line, location, user, note=''):
"""Receive a line item against this ReturnOrder:
- Transfers the StockItem to the specified location
- Marks the StockItem as "quarantined"
- Adds a tracking entry to the StockItem
- Removes the 'customer' reference from the StockItem
"""
# Prevent an item from being "received" multiple times
if line.received_date is not None:
logger.warning("receive_line_item called with item already returned")
return
stock_item = line.item
deltas = {
'status': StockStatus.QUARANTINED,
'returnorder': self.pk,
'location': location.pk,
}
if stock_item.customer:
deltas['customer'] = stock_item.customer.pk
# Update the StockItem
stock_item.status = StockStatus.QUARANTINED
stock_item.location = location
stock_item.customer = None
stock_item.sales_order = None
stock_item.save(add_note=False)
# Add a tracking entry to the StockItem
stock_item.add_tracking_entry(
StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER,
user,
notes=note,
deltas=deltas,
location=location,
returnorder=self,
)
# Update the LineItem
line.received_date = datetime.now().date()
line.save()
trigger_event('returnorder.received', id=self.pk)
# Notify responsible users
notify_responsible(
self,
ReturnOrder,
exclude=user,
content=InvenTreeNotificationBodies.ReturnOrderItemsReceived,
)
class ReturnOrderLineItem(OrderLineItem):
"""Model for a single LineItem in a ReturnOrder"""
class Meta:
"""Metaclass options for this model"""
unique_together = [
('order', 'item'),
]
@staticmethod
def get_api_url():
"""Return the API URL associated with this model"""
return reverse('api-return-order-line-list')
def clean(self):
"""Perform extra validation steps for the ReturnOrderLineItem model"""
super().clean()
if self.item and not self.item.serialized:
raise ValidationError({
'item': _("Only serialized items can be assigned to a Return Order"),
})
order = models.ForeignKey(
ReturnOrder,
on_delete=models.CASCADE,
related_name='lines',
verbose_name=_('Order'),
help_text=_('Return Order'),
)
item = models.ForeignKey(
stock.models.StockItem,
on_delete=models.CASCADE,
related_name='return_order_lines',
verbose_name=_('Item'),
help_text=_('Select item to return from customer')
)
received_date = models.DateField(
null=True, blank=True,
verbose_name=_('Received Date'),
help_text=_('The date this this return item was received'),
)
@property
def received(self):
"""Return True if this item has been received"""
return self.received_date is not None
outcome = models.PositiveIntegerField(
default=ReturnOrderLineStatus.PENDING,
choices=ReturnOrderLineStatus.items(),
verbose_name=_('Outcome'), help_text=_('Outcome for this line item')
)
price = InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Price'),
help_text=_('Cost associated with return or repair for this line item'),
)
link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page'))
class ReturnOrderExtraLine(OrderExtraLine):
"""Model for a single ExtraLine in a ReturnOrder"""
@staticmethod
def get_api_url():
"""Return the API URL associated with the ReturnOrderExtraLine model"""
return reverse('api-return-order-extra-line-list')
order = models.ForeignKey(
ReturnOrder, on_delete=models.CASCADE,
related_name='extra_lines',
verbose_name=_('Order'), help_text=_('Return Order')
)
class ReturnOrderAttachment(InvenTreeAttachment):
"""Model for storing file attachments against a ReturnOrder object"""
@staticmethod
def get_api_url():
"""Return the API URL associated with the ReturnOrderAttachment class"""
return reverse('api-return-order-attachment-list')
def getSubdir(self):
"""Return the directory path where ReturnOrderAttachment files are located"""
return os.path.join('return_files', str(self.order.id))
order = models.ForeignKey(
ReturnOrder,
on_delete=models.CASCADE,
related_name='attachments',
)

View File

@ -17,21 +17,22 @@ import order.models
import part.filters
import stock.models
import stock.serializers
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from company.serializers import (CompanyBriefSerializer, ContactSerializer,
SupplierPartSerializer)
from InvenTree.helpers import extract_serial_numbers, normalize, str2bool
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer)
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
StockStatus)
from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderStatus,
SalesOrderStatus, StockStatus)
from part.serializers import PartBriefSerializer
from users.serializers import OwnerSerializer
class AbstractOrderSerializer(serializers.Serializer):
"""Abstract field definitions for OrderSerializers."""
class TotalPriceMixin(serializers.Serializer):
"""Serializer mixin which provides total price fields"""
total_price = InvenTreeMoneySerializer(
allow_null=True,
@ -41,6 +42,69 @@ class AbstractOrderSerializer(serializers.Serializer):
total_price_currency = InvenTreeCurrencySerializer(read_only=True)
class AbstractOrderSerializer(serializers.Serializer):
"""Abstract serializer class which provides fields common to all order types"""
# Number of line items in this order
line_items = serializers.IntegerField(read_only=True)
# Human-readable status text (read-only)
status_text = serializers.CharField(source='get_status_display', read_only=True)
# status field cannot be set directly
status = serializers.IntegerField(read_only=True)
# Reference string is *required*
reference = serializers.CharField(required=True)
# Detail for point-of-contact field
contact_detail = ContactSerializer(source='contact', many=False, read_only=True)
# Detail for responsible field
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
# Boolean field indicating if this order is overdue (Note: must be annotated)
overdue = serializers.BooleanField(required=False, read_only=True)
def validate_reference(self, reference):
"""Custom validation for the reference field"""
self.Meta.model.validate_reference_field(reference)
return reference
@staticmethod
def annotate_queryset(queryset):
"""Add extra information to the queryset"""
queryset = queryset.annotate(
line_items=SubqueryCount('lines')
)
return queryset
@staticmethod
def order_fields(extra_fields):
"""Construct a set of fields for this serializer"""
return [
'pk',
'creation_date',
'target_date',
'description',
'line_items',
'link',
'reference',
'responsible',
'responsible_detail',
'contact',
'contact_detail',
'status',
'status_text',
'notes',
'overdue',
] + extra_fields
class AbstractExtraLineSerializer(serializers.Serializer):
"""Abstract Serializer for a ExtraLine object."""
@ -78,7 +142,7 @@ class AbstractExtraLineMeta:
]
class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializer for a PurchaseOrder object."""
class Meta:
@ -86,31 +150,17 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
model = order.models.PurchaseOrder
fields = [
'pk',
fields = AbstractOrderSerializer.order_fields([
'issue_date',
'complete_date',
'creation_date',
'description',
'line_items',
'link',
'overdue',
'reference',
'responsible',
'responsible_detail',
'supplier',
'supplier_detail',
'supplier_reference',
'status',
'status_text',
'target_date',
'notes',
'total_price',
'total_price_currency',
]
])
read_only_fields = [
'status'
'issue_date',
'complete_date',
'creation_date',
@ -132,14 +182,13 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
- Number of lines in the PurchaseOrder
- Overdue status of the PurchaseOrder
"""
queryset = queryset.annotate(
line_items=SubqueryCount('lines')
)
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
queryset = queryset.annotate(
overdue=Case(
When(
order.models.PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
order.models.PurchaseOrder.overdue_filter(),
then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField())
)
@ -149,24 +198,6 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
line_items = serializers.IntegerField(read_only=True)
status_text = serializers.CharField(source='get_status_display', read_only=True)
overdue = serializers.BooleanField(required=False, read_only=True)
reference = serializers.CharField(required=True)
def validate_reference(self, reference):
"""Custom validation for the reference field"""
# Ensure that the reference matches the required pattern
order.models.PurchaseOrder.validate_reference_field(reference)
return reference
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
class PurchaseOrderCancelSerializer(serializers.Serializer):
"""Serializer for cancelling a PurchaseOrder."""
@ -307,7 +338,7 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
queryset = queryset.annotate(
overdue=Case(
When(
Q(order__status__in=PurchaseOrderStatus.OPEN) & order.models.PurchaseOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField())
order.models.PurchaseOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField())
),
default=Value(False, output_field=BooleanField()),
)
@ -531,7 +562,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
class PurchaseOrderReceiveSerializer(serializers.Serializer):
"""Serializer for receiving items against a purchase order."""
"""Serializer for receiving items against a PurchaseOrder."""
class Meta:
"""Metaclass options."""
@ -644,34 +675,22 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
])
class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializers for the SalesOrder object."""
class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializer for the SalesOrder model class"""
class Meta:
"""Metaclass options."""
model = order.models.SalesOrder
fields = [
'pk',
'creation_date',
fields = AbstractOrderSerializer.order_fields([
'customer',
'customer_detail',
'customer_reference',
'description',
'line_items',
'link',
'notes',
'overdue',
'reference',
'responsible',
'status',
'status_text',
'shipment_date',
'target_date',
'total_price',
'total_price_currency',
]
])
read_only_fields = [
'status',
@ -695,14 +714,13 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
- Number of line items in the SalesOrder
- Overdue status of the SalesOrder
"""
queryset = queryset.annotate(
line_items=SubqueryCount('lines')
)
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
queryset = queryset.annotate(
overdue=Case(
When(
order.models.SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
order.models.SalesOrder.overdue_filter(),
then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField())
)
@ -712,22 +730,6 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True)
line_items = serializers.IntegerField(read_only=True)
status_text = serializers.CharField(source='get_status_display', read_only=True)
overdue = serializers.BooleanField(required=False, read_only=True)
reference = serializers.CharField(required=True)
def validate_reference(self, reference):
"""Custom validation for the reference field"""
# Ensure that the reference matches the required pattern
order.models.SalesOrder.validate_reference_field(reference)
return reference
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
"""Serializer for the SalesOrderAllocation model.
@ -1379,13 +1381,13 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
"""Serializer for a SalesOrderExtraLine object."""
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
class Meta(AbstractExtraLineMeta):
"""Metaclass options."""
model = order.models.SalesOrderExtraLine
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializers for the SalesOrderAttachment model."""
@ -1398,3 +1400,253 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
fields = InvenTreeAttachmentSerializer.attachment_fields([
'order',
])
class ReturnOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializer for the ReturnOrder model class"""
class Meta:
"""Metaclass options"""
model = order.models.ReturnOrder
fields = AbstractOrderSerializer.order_fields([
'customer',
'customer_detail',
'customer_reference',
])
read_only_fields = [
'creation_date',
]
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer"""
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs)
if customer_detail is not True:
self.fields.pop('customer_detail')
@staticmethod
def annotate_queryset(queryset):
"""Custom annotation for the serializer queryset"""
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
queryset = queryset.annotate(
overdue=Case(
When(
order.models.ReturnOrder.overdue_filter(),
then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField())
)
)
return queryset
customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True)
class ReturnOrderIssueSerializer(serializers.Serializer):
"""Serializer for issuing a ReturnOrder"""
class Meta:
"""Metaclass options"""
fields = []
def save(self):
"""Save the serializer to 'issue' the order"""
order = self.context['order']
order.place_order()
class ReturnOrderCancelSerializer(serializers.Serializer):
"""Serializer for cancelling a ReturnOrder"""
class Meta:
"""Metaclass options"""
fields = []
def save(self):
"""Save the serializer to 'cancel' the order"""
order = self.context['order']
order.cancel_order()
class ReturnOrderCompleteSerializer(serializers.Serializer):
"""Serializer for completing a ReturnOrder"""
class Meta:
"""Metaclass options"""
fields = []
def save(self):
"""Save the serializer to 'complete' the order"""
order = self.context['order']
order.complete_order()
class ReturnOrderLineItemReceiveSerializer(serializers.Serializer):
"""Serializer for receiving a single line item against a ReturnOrder"""
class Meta:
"""Metaclass options"""
fields = [
'item',
]
item = serializers.PrimaryKeyRelatedField(
queryset=order.models.ReturnOrderLineItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Return order line item'),
)
def validate_line_item(self, item):
"""Validation for a single line item"""
if item.order != self.context['order']:
raise ValidationError(_("Line item does not match return order"))
if item.received:
raise ValidationError(_("Line item has already been received"))
return item
class ReturnOrderReceiveSerializer(serializers.Serializer):
"""Serializer for receiving items against a ReturnOrder"""
class Meta:
"""Metaclass options"""
fields = [
'items',
'location',
]
items = ReturnOrderLineItemReceiveSerializer(many=True)
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Location'),
help_text=_('Select destination location for received items'),
)
def validate(self, data):
"""Perform data validation for this serializer"""
order = self.context['order']
if order.status != ReturnOrderStatus.IN_PROGRESS:
raise ValidationError(_("Items can only be received against orders which are in progress"))
data = super().validate(data)
items = data.get('items', [])
if len(items) == 0:
raise ValidationError(_("Line items must be provided"))
return data
@transaction.atomic
def save(self):
"""Saving this serializer marks the returned items as received"""
order = self.context['order']
request = self.context['request']
data = self.validated_data
items = data['items']
location = data['location']
with transaction.atomic():
for item in items:
line_item = item['item']
order.receive_line_item(
line_item,
location,
request.user
)
class ReturnOrderLineItemSerializer(InvenTreeModelSerializer):
"""Serializer for a ReturnOrderLineItem object"""
class Meta:
"""Metaclass options"""
model = order.models.ReturnOrderLineItem
fields = [
'pk',
'order',
'order_detail',
'item',
'item_detail',
'received_date',
'outcome',
'part_detail',
'price',
'price_currency',
'link',
'reference',
'notes',
'target_date',
]
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer"""
order_detail = kwargs.pop('order_detail', False)
item_detail = kwargs.pop('item_detail', False)
part_detail = kwargs.pop('part_detail', False)
super().__init__(*args, **kwargs)
if not order_detail:
self.fields.pop('order_detail')
if not item_detail:
self.fields.pop('item_detail')
if not part_detail:
self.fields.pop('part_detail')
order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True)
item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True)
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
price = InvenTreeMoneySerializer(allow_null=True)
price_currency = InvenTreeCurrencySerializer(help_text=_('Line price currency'))
class ReturnOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
"""Serializer for a ReturnOrderExtraLine object"""
class Meta(AbstractExtraLineMeta):
"""Metaclass options"""
model = order.models.ReturnOrderExtraLine
order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True)
class ReturnOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the ReturnOrderAttachment model"""
class Meta:
"""Metaclass options"""
model = order.models.ReturnOrderAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields([
'order',
])

View File

@ -7,16 +7,16 @@
{% block page_title %}
{% inventree_title %} | {% trans "Purchase Order" %}
{% endblock %}
{% endblock page_title %}
{% block breadcrumbs %}
<li class='breadcrumb-item'><a href='{% url "po-index" %}'>{% trans "Purchase Orders" %}</a></li>
<li class='breadcrumb-item'><a href='{% url "purchase-order-index" %}'>{% trans "Purchase Orders" %}</a></li>
<li class="breadcrumb-item active" aria-current="page"><a href='{% url "po-detail" order.id %}'>{{ order }}</a></li>
{% endblock %}
{% endblock breadcrumbs %}
{% block heading %}
{% trans "Purchase Order" %}: {{ order.reference }}
{% endblock %}
{% endblock heading %}
{% block actions %}
{% if user.is_staff and roles.purchase_order.change %}
@ -67,8 +67,7 @@
{% trans "Receive Items" %}
</button>
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Mark order as complete" %}'>
<span class='fas fa-check-circle'></span>
{% trans "Complete Order" %}
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
</button>
{% endif %}
{% endif %}
@ -82,7 +81,7 @@ src="{{ order.supplier.image.url }}"
src="{% static 'img/blank_image.png' %}"
{% endif %}
/>
{% endblock %}
{% endblock thumbnail %}
{% block details %}
@ -111,7 +110,7 @@ src="{% static 'img/blank_image.png' %}"
</table>
{% endblock %}
{% endblock details %}
{% block details_right %}
<table class='table table-condensed table-striped'>
@ -169,7 +168,10 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Target Date" %}</td>
<td>{% render_date order.target_date %}</td>
<td>
{% render_date order.target_date %}
{% if order.is_overdue %}<span class='fas fa-calendar-times icon-red float-right'></span>{% endif %}
</td>
</tr>
{% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %}
@ -179,6 +181,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{% render_date order.complete_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td>
</tr>
{% endif %}
{% if order.contact %}
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Contact" %}</td>
<td>{{ order.contact.name }}</td>
</tr>
{% endif %}
{% if order.responsible %}
<tr>
<td><span class='fas fa-users'></span></td>
@ -201,12 +210,11 @@ src="{% static 'img/blank_image.png' %}"
</td>
</tr>
</table>
{% endblock %}
{% endblock details_right %}
{% block js_ready %}
{{ block.super }}
{% if order.status == PurchaseOrderStatus.PENDING %}
$("#place-order").click(function() {
@ -222,7 +230,11 @@ $("#place-order").click(function() {
{% if report_enabled %}
$('#print-order-report').click(function() {
printPurchaseOrderReports([{{ order.pk }}]);
printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-po-report-list" %}',
});
});
{% endif %}
@ -293,4 +305,4 @@ $("#export-order").click(function() {
});
{% endblock %}
{% endblock js_ready %}

View File

@ -8,7 +8,7 @@
{% block sidebar %}
{% include 'order/po_sidebar.html' %}
{% endblock %}
{% endblock sidebar %}
{% block page_content %}
{% settings_value "PURCHASEORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %}
@ -27,9 +27,10 @@
<button type='button' class='btn btn-success' id='new-po-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button>
{% elif order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-primary' id='receive-selected-items' title='{% trans "Receive selected items" %}'>
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Items" %}
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-primary' id='receive-selected-items' title='{% trans "Receive Line Items" %}'>
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Line Items" %}
</button>
{% endif %}
{% endif %}
@ -126,7 +127,7 @@
</div>
</div>
{% endblock %}
{% endblock page_content %}
{% block js_ready %}
@ -146,30 +147,18 @@
);
});
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-po-attachment-list" %}',
{
data: {
order: {{ order.id }},
onPanelLoad('order-attachments', function() {
loadAttachmentTable('{% url "api-po-attachment-list" %}', {
filters: {
order: {{ order.pk }},
},
label: 'attachment',
success: function(data, status, xhr) {
$('#attachment-table').bootstrapTable('refresh');
fields: {
order: {
value: {{ order.pk }},
hidden: true,
}
}
}
);
loadAttachmentTable('{% url "api-po-attachment-list" %}', {
filters: {
order: {{ order.pk }},
},
fields: {
order: {
value: {{ order.pk }},
hidden: true,
}
}
});
});
loadStockTable($("#stock-table"), {
@ -204,7 +193,7 @@ $('#new-po-line').click(function() {
{% elif order.status == PurchaseOrderStatus.PLACED %}
$('#receive-selected-items').click(function() {
var items = getTableData('#po-line-table');
let items = getTableData('#po-line-table');
receivePurchaseOrderItems(
{{ order.id }},
@ -219,59 +208,56 @@ $('#new-po-line').click(function() {
{% endif %}
loadPurchaseOrderLineItemTable('#po-line-table', {
order: {{ order.pk }},
{% if order.supplier %}
supplier: {{ order.supplier.pk }},
{% endif %}
{% if roles.purchase_order.change %}
allow_edit: true,
{% else %}
allow_edit: false,
{% endif %}
{% if order.status == PurchaseOrderStatus.PENDING %}
pending: true,
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
allow_receive: true,
{% else %}
allow_receive: false,
{% endif %}
});
$("#new-po-extra-line").click(function() {
var fields = extraLineFields({
onPanelLoad('order-items', function() {
loadPurchaseOrderLineItemTable('#po-line-table', {
order: {{ order.pk }},
});
{% if order.supplier.currency %}
fields.price_currency.value = '{{ order.supplier.currency }}';
{% endif %}
constructForm('{% url "api-po-extra-line-list" %}', {
fields: fields,
method: 'POST',
title: '{% trans "Add Order Line" %}',
onSuccess: function() {
$("#po-extra-lines-table").bootstrapTable("refresh");
},
});
});
loadPurchaseOrderExtraLineTable(
'#po-extra-lines-table',
{
order: {{ order.pk }},
status: {{ order.status }},
{% if order.is_pending %}
pending: true,
{% if order.supplier %}
supplier: {{ order.supplier.pk }},
{% endif %}
{% if roles.purchase_order.change %}
allow_edit: true,
{% else %}
allow_edit: false,
{% endif %}
}
);
{% if order.status == PurchaseOrderStatus.PENDING %}
pending: true,
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
allow_receive: true,
{% else %}
allow_receive: false,
{% endif %}
});
$("#new-po-extra-line").click(function() {
createExtraLineItem({
order: {{ order.pk }},
table: '#po-extra-lines-table',
url: '{% url "api-po-extra-line-list" %}',
{% if order.supplier.currency %}
currency: '{{ order.supplier.currency }}',
{% endif %}
});
});
loadExtraLineTable({
table: '#po-extra-lines-table',
order: {{ order.pk }},
url: '{% url "api-po-extra-line-list" %}',
name: 'purchaseorderextraline',
filtertarget: '#filter-list-purchase-order-extra-lines',
{% settings_value "PURCHASEORDER_EDIT_COMPLETED_ORDERS" as allow_edit %}
{% if order.is_pending or allow_edit %}
allow_edit: {% js_bool roles.purchase_order.change %},
allow_delete: {% js_bool roles.purchase_order.delete %},
{% else %}
allow_edit: false,
allow_delete: false,
{% endif %}
pricing: true,
});
});
loadOrderTotal(
'#poTotalPrice',

View File

@ -26,11 +26,6 @@
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'>
{% if report_enabled %}
<button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'>
<span class='fas fa-print'></span>
</button>
{% endif %}
{% include "filter_list.html" with id="purchaseorder" %}
</div>
</div>
@ -53,20 +48,6 @@
{% block js_ready %}
{{ block.super }}
{% if report_enabled %}
$("#order-print").click(function() {
var rows = getTableData('#purchase-order-table');
var orders = [];
rows.forEach(function(row) {
orders.push(row.pk);
});
printPurchaseOrderReports(orders);
})
{% endif %}
$("#po-create").click(function() {
createPurchaseOrder();
});

View File

@ -0,0 +1,235 @@
{% extends "page_base.html" %}
{% load i18n %}
{% load static %}
{% load inventree_extras %}
{% load status_codes %}
{% block page_title %}
{% inventree_title %} | {% trans "Return Order" %}
{% endblock page_title %}
{% block breadcrumbs %}
<li class='breadcrumb-item'><a href='{% url "return-order-index" %}'>{% trans "Return Orders" %}</a></li>
<li class="breadcrumb-item active" aria-current="page"><a href='{% url "return-order-detail" order.id %}'>{{ order }}</a></li>
{% endblock breadcrumbs %}
{% block thumbnail %}
<img class='part-thumb'
{% if order.customer and order.customer.image %}
src="{{ order.customer.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}
/>
{% endblock thumbnail%}
{% block heading %}
{% trans "Return Order" %} {{ order.reference }}
{% endblock heading %}
{% block actions %}
{% if user.is_staff and roles.return_order.change %}
{% url 'admin:order_returnorder_change' order.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
<!-- Printing actions -->
<div class='btn-group' role='group'>
<button id='print-options' title='{% trans "Print actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print return order report" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
<!--
<li><a class='dropdown-item' href='#' id='print-packing-list'><span class='fas fa-clipboard-list'></span>{% trans "Print packing list" %}</a></li>
-->
</ul>
</div>
{% if roles.return_order.change %}
<!-- Order actions -->
<div class='btn-group' role='group'>
<button id='order-options' title='{% trans "Order actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
<span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li>
{% if order.is_open %}
<li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
{% endif %}
</ul>
{% if order.status == ReturnOrderStatus.PENDING %}
<button type='button' class='btn btn-primary' id='submit-order' title='{% trans "Submit Order" %}'>
<span class='fas fa-paper-plane'></span> {% trans "Submit Order" %}
</button>
{% elif order.status == ReturnOrderStatus.IN_PROGRESS %}
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Mark order as complete" %}'>
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
</button>
{% endif %}
</div>
{% endif %}
{% endblock actions %}
{% block details %}
<table class='table table-striped table-condensed'>
<col width='25'>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}{% include "clip.html"%}</td>
</tr>
<tr>
<td><span class='fas fa-info-circle'></span></td>
<td>{% trans "Order Description" %}</td>
<td>{{ order.description }}{% include "clip.html" %}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td>
<td>
{% return_order_status_label order.status %}
{% if order.is_overdue %}
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
{% endif %}
</td>
</tr>
</table>
{% endblock details %}
{% block details_right %}
<table class='table table-striped table-condensed'>
<col width='25'>
{% if order.customer %}
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Customer" %}</td>
<td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a>{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if order.customer_reference %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Customer Reference" %}</td>
<td>{{ order.customer_reference }}{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if order.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>External Link</td>
<td><a href="{{ order.link }}">{{ order.link }}</a>{% include "clip.html"%}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{% render_date order.creation_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td>
</tr>
{% if order.issue_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Issued" %}</td>
<td>{% render_date order.issue_date %}</td>
</tr>
{% endif %}
{% if order.target_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Target Date" %}</td>
<td>
{% render_date order.target_date %}
{% if order.is_overdue %}<span class='fas fa-calendar-times icon-red float-right'></span>{% endif %}
</td>
</tr>
{% endif %}
{% if order.contact %}
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Contact" %}</td>
<td>{{ order.contact.name }}</td>
</tr>
{% endif %}
{% if order.responsible %}
<tr>
<td><span class='fas fa-users'></span></td>
<td>{% trans "Responsible" %}</td>
<td>{{ order.responsible }}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total Cost" %}</td>
<td id='roTotalPrice'>
{% with order.total_price as tp %}
{% if tp == None %}
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
{% else %}
{% render_currency tp currency=order.customer.currency %}
{% endif %}
{% endwith %}
</td>
</tr>
</table>
{% endblock details_right %}
{% block js_ready %}
{{ block.super }}
{% if roles.return_order.change %}
{% if order.status == ReturnOrderStatus.PENDING %}
$('#submit-order').click(function() {
issueReturnOrder({{ order.pk }}, {
reload: true,
});
});
{% elif order.status == ReturnOrderStatus.IN_PROGRESS %}
$('#complete-order').click(function() {
completeReturnOrder(
{{ order.pk }},
{
reload: true,
}
);
})
{% endif %}
$('#edit-order').click(function() {
editReturnOrder({{ order.pk }}, {
reload: true,
});
});
{% if order.is_open %}
$('#cancel-order').click(function() {
cancelReturnOrder(
{{ order.pk }},
{
reload: true
}
);
});
{% endif %}
{% endif %}
{% if report_enabled %}
$('#print-order-report').click(function() {
printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-return-order-report-list" %}',
});
});
{% endif %}
<!-- TODO: Export order callback -->
{% endblock js_ready %}

View File

@ -0,0 +1,209 @@
{% extends "order/return_order_base.html" %}
{% load inventree_extras %}
{% load status_codes %}
{% load i18n %}
{% load static %}
{% block sidebar %}
{% include "order/return_order_sidebar.html" %}
{% endblock sidebar %}
{% block page_content %}
{% settings_value "RETURNORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %}
<div class='panel panel-hidden' id='panel-order-details'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Line Items" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.return_order.add %}
{% if order.is_open or allow_extra_editing %}
<button type='button' class='btn btn-success' id='new-return-order-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button>
{% endif %}
{% if order.status == ReturnOrderStatus.IN_PROGRESS %}
<button type='button' class='btn btn-primary' id='receive-line-items' title='{% trans "Receive Line Items" %}'>
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Line Items" %}
</button>
{% endif %}
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
<div class='btn-group'>
{% include "filter_list.html" with id="returnorderlines" %}
</div>
</div>
<table class='table table-striped table-condensed' id='return-order-lines-table' data-toolbar='#order-toolbar-buttons'>
</table>
</div>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Extra Lines" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.return_order.add %}
{% if order.is_open or allow_extra_editing %}
<button type='button' class='btn btn-success' id='new-return-order-extra-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %}
</button>
{% endif %}
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='order-extra-toolbar-buttons' class='btn-group' style='float: right;'>
<div class='btn-group'>
{% include "filter_list.html" with id="return-order-extra-lines" %}
</div>
</div>
<table class='table table-striped table-condensed' id='return-order-extra-lines-table' data-toolbar='#order-extra-toolbar-buttons'>
</table>
</div>
</div>
<div class='panel panel-hidden' id='panel-order-attachments'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "attachment_button.html" %}
</div>
</div>
</div>
<div class='panel-content'>
{% include "attachment_table.html" %}
</div>
</div>
<div class='panel panel-hidden' id='panel-order-notes'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Order Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
<textarea id='order-notes'></textarea>
</div>
</div>
{% endblock page_content %}
{% block js_ready %}
{{ block.super }}
// Callback function when the 'details' panel is loaded
onPanelLoad('order-details', function() {
{% if roles.return_order.add %}
$('#receive-line-items').click(function() {
let items = getTableData('#return-order-lines-table');
receiveReturnOrderItems(
{{ order.pk }},
items,
{
onSuccess: function() {
reloadBootstrapTable('#return-order-lines-table');
}
}
);
});
$('#new-return-order-line').click(function() {
createReturnOrderLineItem({
order: {{ order.pk }},
customer: {{ order.customer.pk }},
onSuccess: function() {
reloadBootstrapTable('#return-order-lines-table');
}
});
});
$('#new-return-order-extra-line').click(function() {
createExtraLineItem({
order: {{ order.pk }},
table: '#return-order-extra-lines-table',
url: '{% url "api-return-order-extra-line-list" %}',
{% if order.customer.currency %}
currency: '{{ order.customer.currency }}',
{% endif %}
});
});
{% endif %}
{% settings_value "RETURNORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %}
loadReturnOrderLineItemTable({
table: '#return-order-lines-table',
order: {{ order.pk }},
{% if order.status == ReturnOrderStatus.IN_PROGRESS %}
allow_receive: true,
{% endif %}
{% if order.is_open or allow_extra_editing %}
allow_edit: {% js_bool roles.return_order.change %},
allow_delete: {% js_bool roles.return_order.delete %},
{% endif %}
});
loadExtraLineTable({
order: {{ order.pk }},
url: '{% url "api-return-order-extra-line-list" %}',
table: "#return-order-extra-lines-table",
name: 'returnorderextralines',
filtertarget: '#filter-list-return-order-extra-lines',
{% if order.is_open or allow_extra_editing %}
allow_edit: {% js_bool roles.return_order.change %},
allow_delete: {% js_bool roles.return_order.delete %},
{% endif %}
});
});
// Callback function when the 'notes' panel is loaded
onPanelLoad('order-notes', function() {
setupNotesField(
'order-notes',
'{% url "api-return-order-detail" order.pk %}',
{
{% if roles.purchase_order.change %}
editable: true,
{% else %}
editable: false,
{% endif %}
}
);
});
// Callback function when the 'attachments' panel is loaded
onPanelLoad('order-attachments', function() {
loadAttachmentTable('{% url "api-return-order-attachment-list" %}', {
filters: {
order: {{ order.pk }},
},
fields: {
order: {
value: {{ order.pk }},
hidden: true,
},
}
});
});
enableSidebar('returnorder');
{% endblock js_ready %}

View File

@ -0,0 +1,10 @@
{% load i18n %}
{% load static %}
{% load inventree_extras %}
{% trans "Order Details" as text %}
{% include "sidebar_item.html" with label='order-details' text=text icon="fa-info-circle" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='order-attachments' text=text icon="fa-paperclip" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label='order-notes' text=text icon="fa-clipboard" %}

View File

@ -0,0 +1,55 @@
{% extends "page_base.html" %}
{% load inventree_extras %}
{% load static %}
{% load i18n %}
{% block page_title %}
{% inventree_title %} | {% trans "Return Orders" %}
{% endblock %}
{% block breadcrumb_list %}
{% endblock %}
{% block heading %}
{% trans "Return Orders" %}
{% endblock %}
{% block actions %}
{% if roles.return_order.add %}
<button class='btn btn-success' type='button' id='return-order-create' title='{% trans "Create new return order" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Return Order" %}
</button>
{% endif %}
{% endblock actions %}
{% block page_info %}
<div class='panel-content'>
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
{% include "filter_list.html" with id="returnorder" %}
</div>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#table-buttons' id='return-order-table'>
</table>
<div id='return-order-calendar'></div>
</div>
{% endblock page_info %}
{% block js_ready %}
{{ block.super }}
loadReturnOrderTable('#return-order-table', {});
$('#return-order-create').click(function() {
createReturnOrder();
});
{% endblock js_ready %}

View File

@ -10,7 +10,7 @@
{% endblock %}
{% block breadcrumbs %}
<li class='breadcrumb-item'><a href='{% url "so-index" %}'>{% trans "Sales Orders" %}</a></li>
<li class='breadcrumb-item'><a href='{% url "sales-order-index" %}'>{% trans "Sales Orders" %}</a></li>
<li class="breadcrumb-item active" aria-current="page"><a href='{% url "so-detail" order.id %}'>{{ order }}</a></li>
{% endblock %}
@ -162,7 +162,10 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Target Date" %}</td>
<td>{% render_date order.target_date %}</td>
<td>
{% render_date order.target_date %}
{% if order.is_overdue %}<span class='fas fa-calendar-times icon-red float-right'></span>{% endif %}
</td>
</tr>
{% endif %}
{% if order.shipment_date %}
@ -177,6 +180,13 @@ src="{% static 'img/blank_image.png' %}"
</td>
</tr>
{% endif %}
{% if order.contact %}
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Contact" %}</td>
<td>{{ order.contact.name }}</td>
</tr>
{% endif %}
{% if order.responsible %}
<tr>
<td><span class='fas fa-users'></span></td>
@ -187,7 +197,7 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total cost" %}</td>
<td>{% trans "Total Cost" %}</td>
<td id="soTotalPrice">
{% with order.total_price as tp %}
{% if tp == None %}
@ -204,12 +214,13 @@ src="{% static 'img/blank_image.png' %}"
{% block js_ready %}
{{ block.super }}
{% if roles.sales_order.change %}
$("#edit-order").click(function() {
editSalesOrder({{ order.pk }}, {
reload: true,
});
});
{% endif %}
$("#complete-order-shipments").click(function() {
@ -242,7 +253,11 @@ $("#complete-order").click(function() {
{% if report_enabled %}
$('#print-order-report').click(function() {
printSalesOrderReports([{{ order.pk }}]);
printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-so-report-list" %}',
});
});
{% endif %}

View File

@ -18,7 +18,7 @@
<h4>{% trans "Sales Order Items" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.sales_order.change %}
{% if roles.sales_order.add %}
{% if order.is_pending or allow_extra_editing %}
<button type='button' class='btn btn-success' id='new-so-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
@ -209,30 +209,19 @@
);
});
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-so-attachment-list" %}',
{
data: {
order: {{ order.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
onPanelLoad('order-attachments', function() {
loadAttachmentTable('{% url "api-so-attachment-list" %}', {
filters: {
order: {{ order.pk }},
},
fields: {
order: {
value: {{ order.pk }},
hidden: true,
loadAttachmentTable('{% url "api-so-attachment-list" %}', {
filters: {
order: {{ order.pk }},
},
}
fields: {
order: {
value: {{ order.pk }},
hidden: true,
},
}
});
});
loadBuildTable($("#builds-table"), {
@ -242,60 +231,67 @@
},
});
$("#new-so-line").click(function() {
createSalesOrderLineItem({
order: {{ order.pk }},
onSuccess: function() {
$("#so-lines-table").bootstrapTable("refresh");
onPanelLoad('order-items', function() {
$("#new-so-line").click(function() {
createSalesOrderLineItem({
order: {{ order.pk }},
onSuccess: function() {
$("#so-lines-table").bootstrapTable('refresh');
}
});
// Create a new SalesOrderLine item
var fields = soLineItemFields({
order: {{ order.pk }},
});
});
loadSalesOrderLineItemTable(
'#so-lines-table',
{
order: {{ order.pk }},
reference: '{{ order.reference }}',
status: {{ order.status }},
{% if roles.sales_order.change %}
allow_edit: true,
{% endif %}
{% if order.is_pending %}
pending: true,
{% endif %}
}
);
$("#new-so-extra-line").click(function() {
createExtraLineItem({
order: {{ order.pk }},
table: '#so-extra-lines-table',
url: '{% url "api-so-extra-line-list" %}',
{% if order.customer.currency %}
currency: '{{ order.customer.currency }}',
{% endif %}
});
});
// Create a new SalesOrderLine item
var fields = soLineItemFields({
loadExtraLineTable({
order: {{ order.pk }},
table: '#so-extra-lines-table',
url: '{% url "api-so-extra-line-list" %}',
name: 'salesorderextraline',
filtertarget: '#filter-list-sales-order-extra-lines',
{% settings_value "SALESORDER_EDIT_COMPLETED_ORDERS" as allow_edit %}
{% if order.is_pending or allow_edit %}
allow_edit: {% js_bool roles.sales_order.change %},
allow_delete: {% js_bool roles.sales_order.delete %},
{% else %}
allow_edit: false,
allow_delete: false,
{% endif %}
pricing: true,
});
});
loadSalesOrderLineItemTable(
'#so-lines-table',
{
order: {{ order.pk }},
reference: '{{ order.reference }}',
status: {{ order.status }},
{% if roles.sales_order.change %}
allow_edit: true,
{% endif %}
{% if order.is_pending %}
pending: true,
{% endif %}
}
);
$("#new-so-extra-line").click(function() {
var fields = extraLineFields({
order: {{ order.pk }},
});
constructForm('{% url "api-so-extra-line-list" %}', {
fields: fields,
method: 'POST',
title: '{% trans "Add Extra Line" %}',
onSuccess: function() {
$("#so-extra-lines-table").bootstrapTable("refresh");
},
});
});
loadSalesOrderExtraLineTable(
'#so-extra-lines-table',
{
order: {{ order.pk }},
status: {{ order.status }},
{% if roles.sales_order.change %}allow_edit: true,{% endif %}
{% if order.is_pending %}pending: true,{% endif %}
}
);
loadOrderTotal(
'#soTotalPrice',

View File

@ -29,11 +29,6 @@
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
{% if report_enabled %}
<button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'>
<span class='fas fa-print'></span>
</button>
{% endif %}
{% include "filter_list.html" with id="salesorder" %}
</div>
</div>
@ -54,20 +49,6 @@ loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}",
});
{% if report_enabled %}
$("#order-print").click(function() {
var rows = getTableData('#sales-order-table');
var orders = [];
rows.forEach(function(row) {
orders.push(row.pk);
});
printSalesOrderReports(orders);
})
{% endif %}
$("#so-create").click(function() {
createSalesOrder();
});

View File

@ -17,7 +17,9 @@ import order.models as models
from common.settings import currency_codes
from company.models import Company
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus,
ReturnOrderStatus, SalesOrderStatus,
StockStatus)
from part.models import Part
from stock.models import StockItem
@ -1802,3 +1804,286 @@ class SalesOrderAllocateTest(OrderTest):
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 1 + 3 * models.SalesOrder.objects.count())
class ReturnOrderTests(InvenTreeAPITestCase):
"""Unit tests for ReturnOrder API endpoints"""
fixtures = [
'category',
'company',
'return_order',
'part',
'location',
'supplier_part',
'stock',
]
def test_options(self):
"""Test the OPTIONS endpoint"""
self.assignRole('return_order.add')
data = self.options(reverse('api-return-order-list'), expected_code=200).data
self.assertEqual(data['name'], 'Return Order List')
# Some checks on the 'reference' field
post = data['actions']['POST']
reference = post['reference']
self.assertEqual(reference['default'], 'RMA-0007')
self.assertEqual(reference['label'], 'Reference')
self.assertEqual(reference['help_text'], 'Return Order reference')
self.assertEqual(reference['required'], True)
self.assertEqual(reference['type'], 'string')
def test_list(self):
"""Tests for the list endpoint"""
url = reverse('api-return-order-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 6)
# Paginated query
data = self.get(
url,
{
'limit': 1,
'ordering': 'reference',
'customer_detail': True,
},
expected_code=200
).data
self.assertEqual(data['count'], 6)
self.assertEqual(len(data['results']), 1)
result = data['results'][0]
self.assertEqual(result['reference'], 'RMA-001')
self.assertEqual(result['customer_detail']['name'], 'A customer')
# Reverse ordering
data = self.get(
url,
{
'ordering': '-reference',
},
expected_code=200
).data
self.assertEqual(data[0]['reference'], 'RMA-006')
# Filter by customer
for cmp_id in [4, 5]:
data = self.get(
url,
{
'customer': cmp_id,
},
expected_code=200
).data
self.assertEqual(len(data), 3)
for result in data:
self.assertEqual(result['customer'], cmp_id)
# Filter by status
data = self.get(
url,
{
'status': 20,
},
expected_code=200
).data
self.assertEqual(len(data), 2)
for result in data:
self.assertEqual(result['status'], 20)
def test_create(self):
"""Test creation of ReturnOrder via the API"""
url = reverse('api-return-order-list')
# Do not have required permissions yet
self.post(
url,
{
'customer': 1,
'description': 'a return order',
},
expected_code=403
)
self.assignRole('return_order.add')
data = self.post(
url,
{
'customer': 4,
'customer_reference': 'cr',
'description': 'a return order',
},
expected_code=201
).data
# Reference automatically generated
self.assertEqual(data['reference'], 'RMA-0007')
self.assertEqual(data['customer_reference'], 'cr')
def test_update(self):
"""Test that we can update a ReturnOrder via the API"""
url = reverse('api-return-order-detail', kwargs={'pk': 1})
# Test detail endpoint
data = self.get(url, expected_code=200).data
self.assertEqual(data['reference'], 'RMA-001')
# Attempt to update, incorrect permissions
self.patch(
url,
{
'customer_reference': 'My customer reference',
},
expected_code=403
)
self.assignRole('return_order.change')
self.patch(
url,
{
'customer_reference': 'customer ref',
},
expected_code=200
)
rma = models.ReturnOrder.objects.get(pk=1)
self.assertEqual(rma.customer_reference, 'customer ref')
def test_ro_issue(self):
"""Test the 'issue' order for a ReturnOrder"""
order = models.ReturnOrder.objects.get(pk=1)
self.assertEqual(order.status, ReturnOrderStatus.PENDING)
self.assertIsNone(order.issue_date)
url = reverse('api-return-order-issue', kwargs={'pk': 1})
# POST without required permissions
self.post(url, expected_code=403)
self.assignRole('return_order.add')
self.post(url, expected_code=201)
order.refresh_from_db()
self.assertEqual(order.status, ReturnOrderStatus.IN_PROGRESS)
self.assertIsNotNone(order.issue_date)
def test_receive(self):
"""Test that we can receive items against a ReturnOrder"""
customer = Company.objects.get(pk=4)
# Create an order
rma = models.ReturnOrder.objects.create(
customer=customer,
description='A return order',
)
self.assertEqual(rma.reference, 'RMA-0007')
# Create some line items
part = Part.objects.get(pk=25)
for idx in range(3):
stock_item = StockItem.objects.create(
part=part, customer=customer,
quantity=1, serial=idx
)
line_item = models.ReturnOrderLineItem.objects.create(
order=rma,
item=stock_item,
)
self.assertEqual(line_item.outcome, ReturnOrderLineStatus.PENDING)
self.assertIsNone(line_item.received_date)
self.assertFalse(line_item.received)
self.assertEqual(rma.lines.count(), 3)
def receive(items, location=None, expected_code=400):
"""Helper function to receive items against this ReturnOrder"""
url = reverse('api-return-order-receive', kwargs={'pk': rma.pk})
response = self.post(
url,
{
'items': items,
'location': location,
},
expected_code=expected_code
)
return response.data
# Receive without required permissions
receive([], expected_code=403)
self.assignRole('return_order.add')
# Receive, without any location
data = receive([], expected_code=400)
self.assertIn('This field may not be null', str(data['location']))
# Receive, with incorrect order code
data = receive([], 1, expected_code=400)
self.assertIn('Items can only be received against orders which are in progress', str(data))
# Issue the order (via the API)
self.assertIsNone(rma.issue_date)
self.post(
reverse("api-return-order-issue", kwargs={"pk": rma.pk}),
expected_code=201,
)
rma.refresh_from_db()
self.assertIsNotNone(rma.issue_date)
self.assertEqual(rma.status, ReturnOrderStatus.IN_PROGRESS)
# Receive, without any items
data = receive([], 1, expected_code=400)
self.assertIn('Line items must be provided', str(data))
# Get a reference to one of the stock items
stock_item = rma.lines.first().item
n_tracking = stock_item.tracking_info.count()
# Receive items successfully
data = receive(
[{'item': line.pk} for line in rma.lines.all()],
1,
expected_code=201
)
# Check that all line items have been received
for line in rma.lines.all():
self.assertTrue(line.received)
self.assertIsNotNone(line.received_date)
# A single tracking entry should have been added to the item
self.assertEqual(stock_item.tracking_info.count(), n_tracking + 1)
tracking_entry = stock_item.tracking_info.last()
deltas = tracking_entry.deltas
self.assertEqual(deltas['status'], StockStatus.QUARANTINED)
self.assertEqual(deltas['customer'], customer.pk)
self.assertEqual(deltas['location'], 1)
self.assertEqual(deltas['returnorder'], rma.pk)

View File

@ -16,6 +16,8 @@ class OrderViewTestCase(InvenTreeTestCase):
'supplier_part',
'stock',
'order',
'sales_order',
'return_order',
]
roles = [
@ -25,14 +27,17 @@ class OrderViewTestCase(InvenTreeTestCase):
'sales_order.change',
'sales_order.add',
'sales_order.delete',
'return_order.change',
'return_order.add',
'return_order.delete',
]
class OrderListTest(OrderViewTestCase):
class PurchaseOrderListTest(OrderViewTestCase):
"""Unit tests for the PurchaseOrder index page"""
def test_order_list(self):
"""Tests for the PurchaseOrder index page"""
response = self.client.get(reverse('po-index'))
response = self.client.get(reverse('purchase-order-index'))
self.assertEqual(response.status_code, 200)
@ -53,3 +58,31 @@ class PurchaseOrderTests(OrderViewTestCase):
# Response should be streaming-content (file download)
self.assertIn('streaming_content', dir(response))
class SalesOrderViews(OrderViewTestCase):
"""Unit tests for the SalesOrder pages"""
def test_index(self):
"""Test the SalesOrder index page"""
response = self.client.get(reverse('sales-order-index'))
self.assertEqual(response.status_code, 200)
def test_detail(self):
"""Test SalesOrder detail view"""
response = self.client.get(reverse('so-detail', args=(1,)))
self.assertEqual(response.status_code, 200)
class ReturnOrderVIews(OrderViewTestCase):
"""Unit tests for the ReturnOrder pages"""
def test_index(self):
"""Test the ReturnOrder index page"""
response = self.client.get(reverse('return-order-index'))
self.assertEqual(response.status_code, 200)
def test_detail(self):
"""Test ReturnOrder detail view"""
response = self.client.get(reverse('return-order-detail', args=(1,)))
self.assertEqual(response.status_code, 200)

View File

@ -4,7 +4,7 @@
- Detail view of Purchase Orders
"""
from django.urls import include, re_path
from django.urls import include, path, re_path
from . import views
@ -21,10 +21,10 @@ purchase_order_urls = [
re_path(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'),
# Display detail view for a single purchase order
re_path(r'^(?P<pk>\d+)/', include(purchase_order_detail_urls)),
path(r'<int:pk>/', include(purchase_order_detail_urls)),
# Display complete list of purchase orders
re_path(r'^.*$', views.PurchaseOrderIndex.as_view(), name='po-index'),
re_path(r'^.*$', views.PurchaseOrderIndex.as_view(), name='purchase-order-index'),
]
sales_order_detail_urls = [
@ -35,13 +35,23 @@ sales_order_detail_urls = [
sales_order_urls = [
# Display detail view for a single SalesOrder
re_path(r'^(?P<pk>\d+)/', include(sales_order_detail_urls)),
path(r'<int:pk>/', include(sales_order_detail_urls)),
# Display list of all sales orders
re_path(r'^.*$', views.SalesOrderIndex.as_view(), name='so-index'),
re_path(r'^.*$', views.SalesOrderIndex.as_view(), name='sales-order-index'),
]
return_order_urls = [
path(r'<int:pk>/', views.ReturnOrderDetail.as_view(), name='return-order-detail'),
# Display list of all return orders
re_path(r'^.*$', views.ReturnOrderIndex.as_view(), name='return-order-index'),
]
order_urls = [
re_path(r'^purchase-order/', include(purchase_order_urls)),
re_path(r'^sales-order/', include(sales_order_urls)),
re_path(r'^return-order/', include(return_order_urls)),
]

View File

@ -17,6 +17,14 @@ def generate_next_purchase_order_reference():
return PurchaseOrder.generate_reference()
def generate_next_return_order_reference():
"""Generate the next available ReturnOrder reference"""
from order.models import ReturnOrder
return ReturnOrder.generate_reference()
def validate_sales_order_reference_pattern(pattern):
"""Validate the SalesOrder reference 'pattern' setting"""
@ -33,6 +41,14 @@ def validate_purchase_order_reference_pattern(pattern):
PurchaseOrder.validate_reference_pattern(pattern)
def validate_return_order_reference_pattern(pattern):
"""Validate the ReturnOrder reference 'pattern' setting"""
from order.models import ReturnOrder
ReturnOrder.validate_reference_pattern(pattern)
def validate_sales_order_reference(value):
"""Validate that the SalesOrder reference field matches the required pattern"""
@ -47,3 +63,11 @@ def validate_purchase_order_reference(value):
from order.models import PurchaseOrder
PurchaseOrder.validate_reference_field(value)
def validate_return_order_reference(value):
"""Validate that the ReturnOrder reference field matches the required pattern"""
from order.models import ReturnOrder
ReturnOrder.validate_reference_field(value)

View File

@ -24,8 +24,8 @@ from plugin.views import InvenTreePluginViewMixin
from . import forms as order_forms
from .admin import PurchaseOrderLineItemResource, SalesOrderLineItemResource
from .models import (PurchaseOrder, PurchaseOrderLineItem, SalesOrder,
SalesOrderLineItem)
from .models import (PurchaseOrder, PurchaseOrderLineItem, ReturnOrder,
SalesOrder, SalesOrderLineItem)
logger = logging.getLogger("inventree")
@ -51,6 +51,14 @@ class SalesOrderIndex(InvenTreeRoleMixin, ListView):
context_object_name = 'orders'
class ReturnOrderIndex(InvenTreeRoleMixin, ListView):
"""ReturnOrder index (list) view"""
model = ReturnOrder
template_name = 'order/return_orders.html'
context_object_name = 'orders'
class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
"""Detail view for a PurchaseOrder object."""
@ -67,6 +75,14 @@ class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView)
template_name = 'order/sales_order_detail.html'
class ReturnOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
"""Detail view for a ReturnOrder object"""
context_object_name = 'order'
queryset = ReturnOrder.objects.all()
template_name = 'order/return_order_detail.html'
class PurchaseOrderUpload(FileManagementFormView):
"""PurchaseOrder: Upload file, match to fields and parts (using multi-Step form)"""

View File

@ -39,34 +39,20 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartStocktake, PartStocktakeReport, PartTestTemplate)
class CategoryList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects
- POST: Create a new PartCategory object
"""
queryset = PartCategory.objects.all()
class CategoryMixin:
"""Mixin class for PartCategory endpoints"""
serializer_class = part_serializers.CategorySerializer
def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file"""
dataset = PartCategoryResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Categories.{export_format}"
return DownloadFile(filedata, filename)
queryset = PartCategory.objects.all()
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset for the CategoryList endpoint"""
"""Return an annotated queryset for the CategoryDetail endpoint"""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.CategorySerializer.annotate_queryset(queryset)
return queryset
def get_serializer_context(self):
"""Add extra context data to the serializer for the PartCategoryList endpoint"""
"""Add extra context to the serializer for the CategoryDetail endpoint"""
ctx = super().get_serializer_context()
try:
@ -77,6 +63,23 @@ class CategoryList(APIDownloadMixin, ListCreateAPI):
return ctx
class CategoryList(CategoryMixin, APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects
- POST: Create a new PartCategory object
"""
def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file"""
dataset = PartCategoryResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Categories.{export_format}"
return DownloadFile(filedata, filename)
def filter_queryset(self, queryset):
"""Custom filtering:
@ -184,31 +187,9 @@ class CategoryList(APIDownloadMixin, ListCreateAPI):
]
class CategoryDetail(CustomRetrieveUpdateDestroyAPI):
class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single PartCategory object."""
serializer_class = part_serializers.CategorySerializer
queryset = PartCategory.objects.all()
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset for the CategoryDetail endpoint"""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.CategorySerializer.annotate_queryset(queryset)
return queryset
def get_serializer_context(self):
"""Add extra context to the serializer for the CategoryDetail endpoint"""
ctx = super().get_serializer_context()
try:
ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
except AttributeError:
# Error is thrown if the view does not have an associated request
ctx['starred_categories'] = []
return ctx
def update(self, request, *args, **kwargs):
"""Perform 'update' function and mark this part as 'starred' (or not)"""
# Clean up input data
@ -234,6 +215,21 @@ class CategoryDetail(CustomRetrieveUpdateDestroyAPI):
delete_child_categories=delete_child_categories))
class CategoryTree(ListAPI):
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategoryTree
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
]
# Order by tree level (top levels first) and then name
ordering = ['level', 'name']
class CategoryMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating PartCategory metadata."""
@ -292,21 +288,6 @@ class CategoryParameterDetail(RetrieveUpdateDestroyAPI):
serializer_class = part_serializers.CategoryParameterTemplateSerializer
class CategoryTree(ListAPI):
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategoryTree
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
]
# Order by tree level (top levels first) and then name
ordering = ['level', 'name']
class PartSalePriceDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for PartSellPriceBreak model."""
@ -845,76 +826,6 @@ class PartValidateBOM(RetrieveUpdateAPI):
})
class PartDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single Part object."""
queryset = Part.objects.all()
serializer_class = part_serializers.PartSerializer
starred_parts = None
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset object for the PartDetail endpoint"""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return queryset
def get_serializer(self, *args, **kwargs):
"""Return a serializer instance for the PartDetail endpoint"""
# By default, include 'category_detail' information in the detail view
try:
kwargs['category_detail'] = str2bool(self.request.query_params.get('category_detail', True))
except AttributeError:
pass
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
# Pass a list of "starred" parts of the current user to the serializer
# We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None:
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
kwargs['starred_parts'] = self.starred_parts
return self.serializer_class(*args, **kwargs)
def destroy(self, request, *args, **kwargs):
"""Delete a Part instance via the API
- If the part is 'active' it cannot be deleted
- It must first be marked as 'inactive'
"""
part = Part.objects.get(pk=int(kwargs['pk']))
# Check if inactive
if not part.active:
# Delete
return super(PartDetail, self).destroy(request, *args, **kwargs)
else:
# Return 405 error
message = 'Part is active: cannot delete'
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message)
def update(self, request, *args, **kwargs):
"""Custom update functionality for Part instance.
- If the 'starred' field is provided, update the 'starred' status against current user
"""
# Clean input data
data = self.clean_data(request.data)
if 'starred' in data:
starred = str2bool(data.get('starred', False))
self.get_object().set_starred(request.user, starred)
response = super().update(request, *args, **kwargs)
return response
class PartFilter(rest_filters.FilterSet):
"""Custom filters for the PartList endpoint.
@ -1090,22 +1001,30 @@ class PartFilter(rest_filters.FilterSet):
virtual = rest_filters.BooleanFilter()
class PartList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of Part objects, or creating a new Part instance"""
class PartMixin:
"""Mixin class for Part API endpoints"""
serializer_class = part_serializers.PartSerializer
queryset = Part.objects.all()
filterset_class = PartFilter
starred_parts = None
is_create = False
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset object for the PartDetail endpoint"""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return queryset
def get_serializer(self, *args, **kwargs):
"""Return a serializer instance for this endpoint"""
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
# Indicate that we can create a new Part via this endpoint
kwargs['create'] = True
kwargs['create'] = self.is_create
# Pass a list of "starred" parts to the current user to the serializer
# We do this to reduce the number of database queries required!
@ -1132,6 +1051,13 @@ class PartList(APIDownloadMixin, ListCreateAPI):
return context
class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of Part objects, or creating a new Part instance"""
filterset_class = PartFilter
is_create = True
def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file"""
dataset = PartResource().export(queryset=queryset)
@ -1169,13 +1095,6 @@ class PartList(APIDownloadMixin, ListCreateAPI):
else:
return Response(data)
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset object"""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return queryset
def filter_queryset(self, queryset):
"""Perform custom filtering of the queryset"""
params = self.request.query_params
@ -1358,6 +1277,43 @@ class PartList(APIDownloadMixin, ListCreateAPI):
]
class PartDetail(PartMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single Part object."""
def destroy(self, request, *args, **kwargs):
"""Delete a Part instance via the API
- If the part is 'active' it cannot be deleted
- It must first be marked as 'inactive'
"""
part = Part.objects.get(pk=int(kwargs['pk']))
# Check if inactive
if not part.active:
# Delete
return super(PartDetail, self).destroy(request, *args, **kwargs)
else:
# Return 405 error
message = 'Part is active: cannot delete'
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message)
def update(self, request, *args, **kwargs):
"""Custom update functionality for Part instance.
- If the 'starred' field is provided, update the 'starred' status against current user
"""
# Clean input data
data = self.clean_data(request.data)
if 'starred' in data:
starred = str2bool(data.get('starred', False))
self.get_object().set_starred(request.user, starred)
response = super().update(request, *args, **kwargs)
return response
class PartRelatedList(ListCreateAPI):
"""API endpoint for accessing a list of PartRelated objects."""
@ -1674,42 +1630,11 @@ class BomFilter(rest_filters.FilterSet):
return queryset
class BomList(ListCreateDestroyAPIView):
"""API endpoint for accessing a list of BomItem objects.
- GET: Return list of BomItem objects
- POST: Create a new BomItem object
"""
class BomMixin:
"""Mixin class for BomItem API endpoints"""
serializer_class = part_serializers.BomItemSerializer
queryset = BomItem.objects.all()
filterset_class = BomFilter
def list(self, request, *args, **kwargs):
"""Return serialized list response for this endpoint"""
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
else:
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
"""
Determine the response type based on the request.
a) For HTTP requests (e.g. via the browseable API) return a DRF response
b) For AJAX requests, simply return a JSON rendered response.
"""
if page is not None:
return self.get_paginated_response(data)
elif request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this API endpoint
@ -1744,6 +1669,42 @@ class BomList(ListCreateDestroyAPIView):
return queryset
class BomList(BomMixin, ListCreateDestroyAPIView):
"""API endpoint for accessing a list of BomItem objects.
- GET: Return list of BomItem objects
- POST: Create a new BomItem object
"""
filterset_class = BomFilter
def list(self, request, *args, **kwargs):
"""Return serialized list response for this endpoint"""
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
else:
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
"""
Determine the response type based on the request.
a) For HTTP requests (e.g. via the browseable API) return a DRF response
b) For AJAX requests, simply return a JSON rendered response.
"""
if page is not None:
return self.get_paginated_response(data)
elif request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
def filter_queryset(self, queryset):
"""Custom query filtering for the BomItem list API"""
queryset = super().filter_queryset(queryset)
@ -1828,6 +1789,11 @@ class BomList(ListCreateDestroyAPIView):
}
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single BomItem object."""
pass
class BomImportUpload(CreateAPI):
"""API endpoint for uploading a complete Bill of Materials.
@ -1866,22 +1832,6 @@ class BomImportSubmit(CreateAPI):
serializer_class = part_serializers.BomImportSubmitSerializer
class BomDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single BomItem object."""
queryset = BomItem.objects.all()
serializer_class = part_serializers.BomItemSerializer
def get_queryset(self, *args, **kwargs):
"""Prefetch related fields for this queryset"""
queryset = super().get_queryset(*args, **kwargs)
queryset = self.get_serializer_class().setup_eager_loading(queryset)
queryset = self.get_serializer_class().annotate_queryset(queryset)
return queryset
class BomItemValidate(UpdateAPI):
"""API endpoint for validating a BomItem."""
@ -1958,7 +1908,7 @@ part_api_urls = [
])),
# Category detail endpoints
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'),
@ -1971,31 +1921,31 @@ part_api_urls = [
# Base URL for PartTestTemplate API endpoints
re_path(r'^test-template/', include([
re_path(r'^(?P<pk>\d+)/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'),
path(r'<int:pk>/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'),
path('', PartTestTemplateList.as_view(), name='api-part-test-template-list'),
])),
# Base URL for PartAttachment API endpoints
re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', PartAttachmentDetail.as_view(), name='api-part-attachment-detail'),
path(r'<int:pk>/', PartAttachmentDetail.as_view(), name='api-part-attachment-detail'),
path('', PartAttachmentList.as_view(), name='api-part-attachment-list'),
])),
# Base URL for part sale pricing
re_path(r'^sale-price/', include([
re_path(r'^(?P<pk>\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
path(r'<int:pk>/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
re_path(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
])),
# Base URL for part internal pricing
re_path(r'^internal-price/', include([
re_path(r'^(?P<pk>\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
path(r'<int:pk>/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
re_path(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])),
# Base URL for PartRelated API endpoints
re_path(r'^related/', include([
re_path(r'^(?P<pk>\d+)/', PartRelatedDetail.as_view(), name='api-part-related-detail'),
path(r'<int:pk>/', PartRelatedDetail.as_view(), name='api-part-related-detail'),
re_path(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'),
])),
@ -2009,7 +1959,7 @@ part_api_urls = [
re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
])),
re_path(r'^(?P<pk>\d+)/', PartParameterDetail.as_view(), name='api-part-parameter-detail'),
path(r'<int:pk>/', PartParameterDetail.as_view(), name='api-part-parameter-detail'),
re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'),
])),
@ -2021,7 +1971,7 @@ part_api_urls = [
re_path(r'^.*$', PartStocktakeReportList.as_view(), name='api-part-stocktake-report-list'),
])),
re_path(r'^(?P<pk>\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'),
path(r'<int:pk>/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'),
re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'),
])),
@ -2033,7 +1983,7 @@ part_api_urls = [
# BOM template
re_path(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='api-bom-upload-template'),
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
# Endpoint for extra serial number information
re_path(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'),
@ -2073,14 +2023,14 @@ bom_api_urls = [
re_path(r'^substitute/', include([
# Detail view
re_path(r'^(?P<pk>\d+)/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'),
path(r'<int:pk>/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'),
# Catch all
re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),
])),
# BOM Item Detail
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),
re_path(r'^metadata/?', BomItemMetadata.as_view(), name='api-bom-item-metadata'),
re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),

View File

@ -37,7 +37,6 @@ import common.settings
import InvenTree.fields
import InvenTree.ready
import InvenTree.tasks
import part.filters as part_filters
import part.settings as part_settings
from build import models as BuildModels
from common.models import InvenTreeSetting
@ -1223,6 +1222,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
@property
def can_build(self):
"""Return the number of units that can be build with available stock."""
import part.filters
# If this part does NOT have a BOM, result is simply the currently available stock
if not self.has_bom:
return 0
@ -1246,9 +1248,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
# Annotate the 'available stock' for each part in the BOM
ref = 'sub_part__'
queryset = queryset.alias(
total_stock=part_filters.annotate_total_stock(reference=ref),
so_allocations=part_filters.annotate_sales_order_allocations(reference=ref),
bo_allocations=part_filters.annotate_build_order_allocations(reference=ref),
total_stock=part.filters.annotate_total_stock(reference=ref),
so_allocations=part.filters.annotate_sales_order_allocations(reference=ref),
bo_allocations=part.filters.annotate_build_order_allocations(reference=ref),
)
# Calculate the 'available stock' based on previous annotations
@ -1262,9 +1264,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
# Extract similar information for any 'substitute' parts
ref = 'substitutes__part__'
queryset = queryset.alias(
sub_total_stock=part_filters.annotate_total_stock(reference=ref),
sub_so_allocations=part_filters.annotate_sales_order_allocations(reference=ref),
sub_bo_allocations=part_filters.annotate_build_order_allocations(reference=ref),
sub_total_stock=part.filters.annotate_total_stock(reference=ref),
sub_so_allocations=part.filters.annotate_sales_order_allocations(reference=ref),
sub_bo_allocations=part.filters.annotate_build_order_allocations(reference=ref),
)
queryset = queryset.annotate(
@ -1275,12 +1277,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
)
# Extract similar information for any 'variant' parts
variant_stock_query = part_filters.variant_stock_query(reference='sub_part__')
variant_stock_query = part.filters.variant_stock_query(reference='sub_part__')
queryset = queryset.alias(
var_total_stock=part_filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),
var_bo_allocations=part_filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'),
var_so_allocations=part_filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'),
var_total_stock=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),
var_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'),
var_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'),
)
queryset = queryset.annotate(
@ -2083,6 +2085,16 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
return tests
def getTestTemplateMap(self, **kwargs):
"""Return a map of all test templates associated with this Part"""
templates = {}
for template in self.getTestTemplates(**kwargs):
templates[template.key] = template
return templates
def getRequiredTests(self):
"""Return the tests which are required by this part"""
return self.getTestTemplates(required=True)

View File

@ -183,11 +183,6 @@
<li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
</a></li>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>
<span class='fas fa-tag'></span> {% trans "Print Labels" %}
</a></li>
{% endif %}
</ul>
</div>
{% include "filter_list.html" with id="parts" %}

View File

@ -548,7 +548,7 @@
deleteManufacturerParts(selections, {
success: function() {
$("#manufacturer-part-table").bootstrapTable("refresh");
$("#manufacturer-part-table").bootstrapTable('refresh');
}
});
});
@ -558,7 +558,7 @@
createManufacturerPart({
part: {{ part.pk }},
onSuccess: function() {
$("#manufacturer-part-table").bootstrapTable("refresh");
$("#manufacturer-part-table").bootstrapTable('refresh');
}
});
});
@ -677,7 +677,11 @@
{% if report_enabled %}
$("#print-bom-report").click(function() {
printBomReports([{{ part.pk }}]);
printReports({
items: [{{ part.pk }}],
key: 'part',
url: '{% url "api-bom-report-list" %}'
});
});
{% endif %}
});
@ -709,9 +713,7 @@
},
focus: 'part_2',
title: '{% trans "Add Related Part" %}',
onSuccess: function() {
$('#related-parts-table').bootstrapTable('refresh');
}
refreshTable: '#related-parts-table',
});
});
@ -797,9 +799,7 @@
part: {{ part.pk }}
}),
title: '{% trans "Add Test Result Template" %}',
onSuccess: function() {
$("#test-template-table").bootstrapTable("refresh");
}
refreshTable: '#test-template-table',
});
});
});
@ -870,9 +870,7 @@
data: {},
},
title: '{% trans "Add Parameter" %}',
onSuccess: function() {
$('#parameter-table').bootstrapTable('refresh');
}
refreshTable: '#parameter-table',
});
});
{% endif %}
@ -906,20 +904,6 @@
}
}
});
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-part-attachment-list" %}',
{
data: {
part: {{ part.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
});
onPanelLoad('pricing', function() {

View File

@ -475,7 +475,11 @@
{% if labels_enabled %}
$('#print-label').click(function() {
printPartLabels([{{ part.pk }}]);
printLabels({
items: [{{ part.pk }}],
key: 'part',
url: '{% url "api-part-label-list" %}',
});
});
{% endif %}

View File

@ -4,7 +4,8 @@ from django import template
from django.utils.safestring import mark_safe
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus, StockStatus)
ReturnOrderStatus, SalesOrderStatus,
StockStatus)
register = template.Library()
@ -21,6 +22,12 @@ def sales_order_status_label(key, *args, **kwargs):
return mark_safe(SalesOrderStatus.render(key, large=kwargs.get('large', False)))
@register.simple_tag
def return_order_status_label(key, *args, **kwargs):
"""Render a ReturnOrder status label"""
return mark_safe(ReturnOrderStatus.render(key, large=kwargs.get('large', False)))
@register.simple_tag
def stock_status_label(key, *args, **kwargs):
"""Render a StockItem status label."""

View File

@ -6,7 +6,7 @@
- Display / Create / Edit / Delete SupplierPart
"""
from django.urls import include, re_path
from django.urls import include, path, re_path
from . import views
@ -35,7 +35,7 @@ part_urls = [
re_path(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
# Individual part using pk
re_path(r'^(?P<pk>\d+)/', include(part_detail_urls)),
path(r'<int:pk>/', include(part_detail_urls)),
# Part category
re_path(r'^category/', include(category_urls)),

View File

@ -1,6 +1,6 @@
"""API for the plugin app."""
from django.urls import include, re_path
from django.urls import include, path, re_path
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions, status
@ -255,7 +255,7 @@ plugin_api_urls = [
])),
# Detail views for a single PluginConfig item
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'^settings/(?P<key>\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail-pk'),
re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-detail-activate'),
re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),

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

View File

@ -30,8 +30,10 @@ from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull,
from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
ListAPI, ListCreateAPI, RetrieveAPI,
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
from order.models import PurchaseOrder, SalesOrder, SalesOrderAllocation
from order.serializers import PurchaseOrderSerializer
from order.models import (PurchaseOrder, ReturnOrder, SalesOrder,
SalesOrderAllocation)
from order.serializers import (PurchaseOrderSerializer, ReturnOrderSerializer,
SalesOrderSerializer)
from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer
from plugin.serializers import MetadataSerializer
@ -1262,7 +1264,7 @@ class StockTrackingList(ListAPI):
except Exception:
pass
# Add purchaseorder detail
# Add PurchaseOrder detail
if 'purchaseorder' in deltas:
try:
order = PurchaseOrder.objects.get(pk=deltas['purchaseorder'])
@ -1271,6 +1273,24 @@ class StockTrackingList(ListAPI):
except Exception:
pass
# Add SalesOrder detail
if 'salesorder' in deltas:
try:
order = SalesOrder.objects.get(pk=deltas['salesorder'])
serializer = SalesOrderSerializer(order)
deltas['salesorder_detail'] = serializer.data
except Exception:
pass
# Add ReturnOrder detail
if 'returnorder' in deltas:
try:
order = ReturnOrder.objects.get(pk=deltas['returnorder'])
serializer = ReturnOrderSerializer(order)
deltas['returnorder_detail'] = serializer.data
except Exception:
pass
if request.is_ajax():
return JsonResponse(data, safe=False)
else:
@ -1368,7 +1388,7 @@ stock_api_urls = [
re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'),
# Stock location detail endpoints
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'^metadata/', LocationMetadata.as_view(), name='api-location-metadata'),
@ -1388,24 +1408,24 @@ stock_api_urls = [
# StockItemAttachment API endpoints
re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'),
path(r'<int:pk>/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'),
path('', StockAttachmentList.as_view(), name='api-stock-attachment-list'),
])),
# StockItemTestResult API endpoints
re_path(r'^test/', include([
re_path(r'^(?P<pk>\d+)/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'),
path(r'<int:pk>/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'),
re_path(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'),
])),
# StockItemTracking API endpoints
re_path(r'^track/', include([
re_path(r'^(?P<pk>\d+)/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'),
path(r'<int:pk>/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'),
re_path(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
])),
# Detail views for a single stock item
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
re_path(r'^convert/', StockItemConvert.as_view(), name='api-stock-item-convert'),
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'),

View File

@ -457,8 +457,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
if old.status != self.status:
deltas['status'] = self.status
# TODO - Other interesting changes we are interested in...
if add_note and len(deltas) > 0:
self.add_tracking_entry(
StockHistoryCode.EDITED,
@ -960,17 +958,22 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
item.customer = customer
item.location = None
item.save()
item.save(add_note=False)
# TODO - Remove any stock item allocations from this stock item
code = StockHistoryCode.SENT_TO_CUSTOMER
deltas = {
'customer': customer.pk,
'customer_name': customer.pk,
}
if order:
code = StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER
deltas['salesorder'] = order.pk
item.add_tracking_entry(
StockHistoryCode.SENT_TO_CUSTOMER,
code,
user,
{
'customer': customer.id,
'customer_name': customer.name,
},
deltas,
notes=notes,
)
@ -992,7 +995,9 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
"""
notes = kwargs.get('notes', '')
tracking_info = {}
tracking_info = {
'location': location.pk,
}
if self.customer:
tracking_info['customer'] = self.customer.id

View File

@ -222,30 +222,18 @@
);
});
enableDragAndDrop(
'#attachment-dropzone',
"{% url 'api-stock-attachment-list' %}",
{
data: {
stock_item: {{ item.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
onPanelLoad('attachments', function() {
loadAttachmentTable('{% url "api-stock-attachment-list" %}', {
filters: {
stock_item: {{ item.pk }},
},
fields: {
stock_item: {
value: {{ item.pk }},
hidden: true,
}
}
);
loadAttachmentTable('{% url "api-stock-attachment-list" %}', {
filters: {
stock_item: {{ item.pk }},
},
fields: {
stock_item: {
value: {{ item.pk }},
hidden: true,
}
}
});
});
loadStockTestResultsTable(
@ -255,12 +243,12 @@
}
);
function reloadTable() {
$("#test-result-table").bootstrapTable("refresh");
}
$("#test-report").click(function() {
printTestReports([{{ item.pk }}]);
printReports({
items: [{{ item.pk }}],
key: 'item',
url: '{% url "api-stockitem-testreport-list" %}',
});
});
{% if user.is_staff %}
@ -299,7 +287,7 @@
method: 'DELETE',
title: '{% trans "Delete Test Data" %}',
preFormContent: html,
onSuccess: reloadTable,
refreshTable: '#test-result-table',
});
}
}
@ -315,7 +303,7 @@
stock_item: {{ item.pk }},
}),
title: '{% trans "Add Test Result" %}',
onSuccess: reloadTable,
refreshTable: '#test-result-table',
});
});

View File

@ -493,11 +493,19 @@ $('#stock-uninstall').click(function() {
});
$("#stock-test-report").click(function() {
printTestReports([{{ item.pk }}]);
printReports({
items: [{{ item.pk }}],
key: 'item',
url: '{% url "api-stockitem-testreport-list" %}',
});
});
$("#print-label").click(function() {
printStockItemLabels([{{ item.pk }}]);
printLabels({
items: [{{ item.pk }}],
url: '{% url "api-stockitem-label-list" %}',
key: 'item',
});
});
{% if roles.stock.change %}

View File

@ -228,17 +228,6 @@
<div class='panel-content'>
<div id='sublocation-button-toolbar'>
<div class='btn-group' role='group'>
<!-- Printing actions menu -->
{% if labels_enabled %}
<div class='btn-group' role='group'>
<button id='location-print-options' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle="dropdown" title='{% trans "Printing Actions" %}'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='multi-location-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
</ul>
</div>
{% endif %}
{% include "filter_list.html" with id="location" %}
</div>
</div>
@ -299,21 +288,11 @@
var locs = [{{ location.pk }}];
printStockLocationLabels(locs);
});
$('#multi-location-print-label').click(function() {
var selections = getTableData('#sublocation-table');
var locations = [];
selections.forEach(function(loc) {
locations.push(loc.pk);
printLabels({
items: locs,
key: 'location',
url: '{% url "api-stocklocation-label-list" %}',
});
printStockLocationLabels(locations);
});
{% endif %}

View File

@ -491,7 +491,7 @@ class StockTest(StockTestBase):
# Check that a tracking item was added
track = StockItemTracking.objects.filter(item=ait).latest('id')
self.assertEqual(track.tracking_type, StockHistoryCode.SENT_TO_CUSTOMER)
self.assertEqual(track.tracking_type, StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER)
self.assertIn('Allocated some stock', track.notes)
def test_return_from_customer(self):

View File

@ -1,12 +1,12 @@
"""URL lookup for Stock app."""
from django.urls import include, re_path
from django.urls import include, path, re_path
from stock import views
location_urls = [
re_path(r'^(?P<pk>\d+)/', include([
path(r'<int:pk>/', include([
# Anything else - direct to the location detail view
re_path('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'),
])),

View File

@ -75,9 +75,7 @@ $('#history-delete').click(function() {
multi_delete: true,
preFormContent: html,
title: '{% trans "Delete Notifications" %}',
onSuccess: function() {
$('#history-table').bootstrapTable('refresh');
},
refreshTable: '#history-table',
form_data: {
filters: {
read: true,
@ -88,7 +86,7 @@ $('#history-delete').click(function() {
});
$("#history-table").on('click', '.notification-delete', function() {
constructForm(`/api/notifications/${$(this).attr('pk')}/`, {
constructForm(`{% url "api-notifications-list" %}${$(this).attr('pk')}/`, {
method: 'DELETE',
title: '{% trans "Delete Notification" %}',
onSuccess: function(data) {

View File

@ -0,0 +1,20 @@
{% extends "panel.html" %}
{% load i18n %}
{% block label %}return-order{% endblock %}
{% block heading %}
{% trans "Return Order Settings" %}
{% endblock %}
{% block content %}
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="RETURNORDER_ENABLED" icon="fa-check-circle" %}
{% include "InvenTree/settings/setting.html" with key="RETURNORDER_REFERENCE_PATTERN" %}
{% include "InvenTree/settings/setting.html" with key="RETURNORDER_EDIT_COMPLETED_ORDERS" icon="fa-edit" %}
</tbody>
</table>
{% endblock %}

View File

@ -42,6 +42,7 @@
{% include "InvenTree/settings/build.html" %}
{% include "InvenTree/settings/po.html" %}
{% include "InvenTree/settings/so.html" %}
{% include "InvenTree/settings/returns.html" %}
{% include "InvenTree/settings/plugin.html" %}
{% plugin_list as pl_list %}

View File

@ -284,9 +284,7 @@ onPanelLoad('parts', function() {
},
method: 'POST',
title: '{% trans "Create Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
refreshTable: '#param-table',
});
});
@ -303,9 +301,7 @@ onPanelLoad('parts', function() {
description: {},
},
title: '{% trans "Edit Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
refreshTable: '#param-table',
}
);
});
@ -325,9 +321,7 @@ onPanelLoad('parts', function() {
method: 'DELETE',
preFormContent: html,
title: '{% trans "Delete Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
refreshTable: '#param-table',
}
);
});

View File

@ -52,6 +52,8 @@
{% include "sidebar_item.html" with label='purchase-order' text=text icon="fa-shopping-cart" %}
{% trans "Sales Orders" as text %}
{% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %}
{% trans "Return Orders" as text %}
{% include "sidebar_item.html" with label='return-order' text=text icon="fa-undo" %}
{% trans "Plugin Settings" as text %}
{% include "sidebar_header.html" with text=text %}

View File

@ -28,6 +28,8 @@
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_PURCHASE_ORDERS" user_setting=True icon='fa-eye-slash' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_SALES_ORDERS" user_setting=True icon='fa-truck' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_SALES_ORDERS" user_setting=True icon='fa-eye-slash' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_RETURN_ORDERS" user_setting=True icon='fa-truck' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS" user_setting=True icon='fa-eye-slash' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %}

View File

@ -5,6 +5,7 @@
{% plugins_enabled as plugins_enabled %}
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
{% settings_value 'RETURNORDER_ENABLED' as return_order_enabled %}
{% settings_value "REPORT_ENABLE" as report_enabled %}
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
{% settings_value "LABEL_ENABLE" as labels_enabled %}
@ -164,8 +165,12 @@
<script defer type='text/javascript' src="{% i18n_static 'model_renderers.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'order.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'part.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'purchase_order.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'return_order.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'report.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'sales_order.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'search.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'status_codes.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'pricing.js' %}"></script>

View File

@ -0,0 +1,11 @@
{% extends "email/email.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block title %}
{{ message }}
{% if link %}
<p>{% trans "Click on the following link to view this order" %}: <a href='{{ link }}'>{{ link }}</a></p>
{% endif %}
{% endblock title %}

View File

@ -14,18 +14,24 @@
* Helper functions for calendar display
*/
/*
* Extract the first displayed date on the calendar
*/
function startDate(calendar) {
// Extract the first displayed date on the calendar
return calendar.currentData.dateProfile.activeRange.start.toISOString().split('T')[0];
}
/*
* Extract the last display date on the calendar
*/
function endDate(calendar) {
// Extract the last display date on the calendar
return calendar.currentData.dateProfile.activeRange.end.toISOString().split('T')[0];
}
/*
* Remove all events from the calendar
*/
function clearEvents(calendar) {
// Remove all events from the calendar
var events = calendar.getEvents();

View File

@ -40,8 +40,17 @@ function getCookie(name) {
return cookieValue;
}
/*
* Perform a GET request to the InvenTree server
*/
function inventreeGet(url, filters={}, options={}) {
if (!url) {
console.error('inventreeGet called without url');
return;
}
// Middleware token required for data update
var csrftoken = getCookie('csrftoken');
@ -78,14 +87,20 @@ function inventreeGet(url, filters={}, options={}) {
});
}
/* Upload via AJAX using the FormData approach.
*
* Note that the following AJAX parameters are required for FormData upload
*
* processData: false
* contentType: false
*/
function inventreeFormDataUpload(url, data, options={}) {
/* Upload via AJAX using the FormData approach.
*
* Note that the following AJAX parameters are required for FormData upload
*
* processData: false
* contentType: false
*/
if (!url) {
console.error('inventreeFormDataUpload called without url');
return;
}
// CSRF cookie token
var csrftoken = getCookie('csrftoken');
@ -116,8 +131,17 @@ function inventreeFormDataUpload(url, data, options={}) {
});
}
/*
* Perform a PUT or PATCH request to the InvenTree server
*/
function inventreePut(url, data={}, options={}) {
if (!url) {
console.error('inventreePut called without url');
return;
}
var method = options.method || 'PUT';
// Middleware token required for data update
@ -164,6 +188,11 @@ function inventreePut(url, data={}, options={}) {
*/
function inventreeDelete(url, options={}) {
if (!url) {
console.error('inventreeDelete called without url');
return;
}
options = options || {};
options.method = 'DELETE';

View File

@ -1,15 +1,14 @@
{% load i18n %}
/* globals
makeIconButton,
renderLink,
wrapButtons,
*/
/* exported
attachmentLink,
addAttachmentButtonCallbacks,
loadAttachmentTable,
reloadAttachmentTable,
loadAttachmentTable
*/
@ -35,7 +34,7 @@ function addAttachmentButtonCallbacks(url, fields={}) {
constructForm(url, {
fields: file_fields,
method: 'POST',
onSuccess: reloadAttachmentTable,
refreshTable: '#attachment-table',
title: '{% trans "Add Attachment" %}',
});
});
@ -57,7 +56,7 @@ function addAttachmentButtonCallbacks(url, fields={}) {
constructForm(url, {
fields: link_fields,
method: 'POST',
onSuccess: reloadAttachmentTable,
refreshTable: '#attachment-table',
title: '{% trans "Add Link" %}',
});
});
@ -79,9 +78,9 @@ function deleteAttachments(attachments, url, options={}) {
var icon = '';
if (attachment.filename) {
icon = `<span class='fas fa-file-alt'></span>`;
icon = makeIcon(attachmentIcon(attachment.filename), '');
} else if (attachment.link) {
icon = `<span class='fas fa-link'></span>`;
icon = makeIcon('fa-link', '');
}
return `
@ -123,29 +122,15 @@ function deleteAttachments(attachments, url, options={}) {
items: ids,
filters: options.filters,
},
onSuccess: function() {
// Refresh the table once all attachments are deleted
$('#attachment-table').bootstrapTable('refresh');
}
refreshTable: '#attachment-table',
});
}
function reloadAttachmentTable() {
$('#attachment-table').bootstrapTable('refresh');
}
/*
* Render a link (with icon) to an internal attachment (file)
* Return a particular icon based on filename extension
*/
function attachmentLink(filename) {
if (!filename) {
return null;
}
function attachmentIcon(filename) {
// Default file icon (if no better choice is found)
let icon = 'fa-file-alt';
let fn = filename.toLowerCase();
@ -171,10 +156,25 @@ function attachmentLink(filename) {
});
}
let split = filename.split('/');
fn = split[split.length - 1];
return icon;
}
let html = `<span class='fas ${icon}'></span> ${fn}`;
/*
* Render a link (with icon) to an internal attachment (file)
*/
function attachmentLink(filename) {
if (!filename) {
return null;
}
let split = filename.split('/');
let fn = split[split.length - 1];
let icon = attachmentIcon(filename);
let html = makeIcon(icon) + ` ${fn}`;
return renderLink(html, filename, {download: true});
@ -271,7 +271,7 @@ function loadAttachmentTable(url, options) {
delete opts.fields.link;
}
},
onSuccess: reloadAttachmentTable,
refreshTable: '#attachment-table',
title: '{% trans "Edit Attachment" %}',
});
});
@ -299,7 +299,7 @@ function loadAttachmentTable(url, options) {
if (row.attachment) {
return attachmentLink(row.attachment);
} else if (row.link) {
var html = `<span class='fas fa-link'></span> ${row.link}`;
let html = makeIcon('fa-link') + ` ${row.link}`;
return renderLink(html, row.link);
} else {
return '-';
@ -327,13 +327,10 @@ function loadAttachmentTable(url, options) {
{
field: 'actions',
formatter: function(value, row) {
var html = '';
html = `<div class='btn-group float-right' role='group'>`;
let buttons = '';
if (permissions.change) {
html += makeIconButton(
'fa-edit icon-blue',
buttons += makeEditButton(
'button-attachment-edit',
row.pk,
'{% trans "Edit attachment" %}',
@ -341,19 +338,30 @@ function loadAttachmentTable(url, options) {
}
if (permissions.delete) {
html += makeIconButton(
'fa-trash-alt icon-red',
buttons += makeDeleteButton(
'button-attachment-delete',
row.pk,
'{% trans "Delete attachment" %}',
);
}
html += `</div>`;
return html;
return wrapButtons(buttons);
}
}
]
});
// Enable drag-and-drop functionality
enableDragAndDrop(
'#attachment-dropzone',
url,
{
data: options.filters,
label: 'attachment',
method: 'POST',
success: function() {
reloadBootstrapTable('#attachment-table');
}
}
);
}

View File

@ -3,7 +3,6 @@
/* globals
imageHoverIcon,
inventreePut,
makeIconButton,
modalEnable,
modalSetContent,
modalSetTitle,
@ -43,11 +42,11 @@ function makeBarcodeInput(placeholderText='', hintText='') {
<div class='controls'>
<div class='input-group'>
<span class='input-group-text'>
<span class='fas fa-qrcode'></span>
${makeIcon('fa-qrcode')}
</span>
<input id='barcode' class='textinput textInput form-control' type='text' name='barcode' placeholder='${placeholderText}'>
<button title='{% trans "Scan barcode using connected webcam" %}' id='barcode_scan_btn' type='button' class='btn btn-secondary' onclick='onBarcodeScanClicked()' style='display: none;'>
<span class='fas fa-camera'></span>
${makeIcon('fa-camera')}
</button>
</div>
<div id='hint_barcode_data' class='help-block'>${hintText}</div>
@ -132,7 +131,7 @@ function makeNotesField(options={}) {
<div class='controls'>
<div class='input-group'>
<span class='input-group-text'>
<span class='fas fa-sticky-note'></span>
${makeIcon('fa-sticky-note')}
</span>
<input id='notes' class='textinput textInput form-control' type='text' name='notes' placeholder='${placeholder}'>
</div>
@ -149,7 +148,7 @@ function postBarcodeData(barcode_data, options={}) {
var modal = options.modal || '#modal-form';
var url = options.url || '/api/barcode/';
var url = options.url || '{% url "api-barcode-scan" %}';
var data = options.data || {};
@ -462,7 +461,7 @@ function unlinkBarcode(data, options={}) {
accept_text: '{% trans "Unlink" %}',
accept: function() {
inventreePut(
'/api/barcode/unlink/',
'{% url "api-barcode-unlink" %}',
data,
{
method: 'POST',
@ -521,7 +520,7 @@ function barcodeCheckInStockItems(location_id, options={}) {
<td>${imageHoverIcon(item.part_detail.thumbnail)} ${item.part_detail.name}</td>
<td>${location_info}</td>
<td>${item.quantity}</td>
<td>${makeIconButton('fa-times-circle icon-red', 'button-item-remove', item.pk, '{% trans "Remove stock item" %}')}</td>
<td>${makeRemoveButton('button-item-remove', item.pk, '{% trans "Remove stock item" %}')}</td>
</tr>`;
});
@ -691,7 +690,7 @@ function barcodeCheckInStockLocations(location_id, options={}) {
if ('stocklocation' in response) {
var pk = response.stocklocation.pk;
var url = `/api/stock/location/${pk}/`;
var url = `{% url "api-location-list" %}${pk}/`;
// Move the scanned location into *this* location
inventreePut(
@ -812,7 +811,7 @@ function scanItemsIntoLocation(item_list, options={}) {
var pk = response.stocklocation.pk;
inventreeGet(`/api/stock/location/${pk}/`, {}, {
inventreeGet(`{% url "api-location-list" %}${pk}/`, {}, {
success: function(response) {
stock_location = response;

View File

@ -96,12 +96,12 @@ function constructBomUploadTable(data, options={}) {
var optional = constructRowField('optional');
var note = constructRowField('note');
var buttons = `<div class='btn-group float-right' role='group'>`;
let buttons = '';
buttons += makeIconButton('fa-info-circle', 'button-row-data', idx, '{% trans "Display row data" %}');
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', idx, '{% trans "Remove row" %}');
buttons += makeInfoButton('button-row-data', idx, '{% trans "Display row data" %}');
buttons += makeRemoveButton('button-row-remove', idx, '{% trans "Remove row" %}');
buttons += `</div>`;
buttons = wrapButtons(buttons);
var html = `
<tr id='items_${idx}' class='bom-import-row' idx='${idx}'>
@ -557,7 +557,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
var buttons = '';
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove substitute part" %}');
buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove substitute part" %}');
// Render a single row
var html = `
@ -626,7 +626,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
</div>
`;
constructForm(`/api/bom/substitute/${pk}/`, {
constructForm(`{% url "api-bom-substitute-list" %}${pk}/`, {
method: 'DELETE',
title: '{% trans "Remove Substitute Part" %}',
preFormContent: pre,
@ -785,9 +785,7 @@ function loadBomTable(table, options={}) {
filters = loadTableFilters('bom');
}
for (var key in params) {
filters[key] = params[key];
}
Object.assign(filters, params);
setupFilterList('bom', $(table));
@ -1142,7 +1140,7 @@ function loadBomTable(table, options={}) {
}
if (available_stock <= 0) {
text += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "No Stock Available" %}'></span>`;
text += makeIconBadge('fa-times-circle icon-red', '{% trans "No Stock Available" %}');
} else {
var extra = '';
@ -1160,7 +1158,10 @@ function loadBomTable(table, options={}) {
}
if (row.on_order && row.on_order > 0) {
text += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`;
text += makeIconBadge(
'fa-shopping-cart',
`{% trans "On Order" %}: ${row.on_order}`,
);
}
return renderLink(text, url);
@ -1242,11 +1243,10 @@ function loadBomTable(table, options={}) {
var bSubs = makeIconButton('fa-exchange-alt icon-blue', 'bom-substitutes-button', row.pk, '{% trans "Edit substitute parts" %}');
var bEdit = makeIconButton('fa-edit icon-blue', 'bom-edit-button', row.pk, '{% trans "Edit BOM Item" %}');
var bEdit = makeEditButton('bom-edit-button', row.pk, '{% trans "Edit BOM Item" %}');
var bDelt = makeIconButton('fa-trash-alt icon-red', 'bom-delete-button', row.pk, '{% trans "Delete BOM Item" %}');
var bDelt = makeDeleteButton('bom-delete-button', row.pk, '{% trans "Delete BOM Item" %}');
var html = `<div class='btn-group float-right' role='group' style='min-width: 100px;'>`;
if (!row.validated) {
html += bValidate;
@ -1254,13 +1254,13 @@ function loadBomTable(table, options={}) {
html += bValid;
}
html += bEdit;
html += bSubs;
html += bDelt;
var buttons = '';
buttons += bEdit;
buttons += bSubs;
buttons += bDelt;
html += `</div>`;
return wrapButtons(buttons);
return html;
} else {
// Return a link to the external BOM
@ -1273,7 +1273,7 @@ function loadBomTable(table, options={}) {
footerFormatter: function(data) {
return `
<button class='btn btn-success float-right' type='button' title='{% trans "Add BOM Item" %}' id='bom-item-new-footer'>
<span class='fas fa-plus-circle'></span> {% trans "Add BOM Item" %}
${makeIcon('fa-plus-circle')} {% trans "Add BOM Item" %}
</button>
`;
}
@ -1436,7 +1436,7 @@ function loadBomTable(table, options={}) {
var fields = bomItemFields();
constructForm(`/api/bom/${pk}/`, {
constructForm(`{% url "api-bom-list" %}${pk}/`, {
fields: fields,
title: '{% trans "Edit BOM Item" %}',
focus: 'sub_part',
@ -1508,15 +1508,7 @@ function loadUsedInTable(table, part_id, options={}) {
params.part_detail = true;
params.sub_part_detail = true;
var filters = {};
if (!options.disableFilters) {
filters = loadTableFilters('usedin');
}
for (var key in params) {
filters[key] = params[key];
}
var filters = loadTableFilters('usedin', params);
setupFilterList('usedin', $(table), options.filterTarget || '#filter-list-usedin');

View File

@ -90,7 +90,7 @@ function editBuildOrder(pk) {
var fields = buildFormFields();
constructForm(`/api/build/${pk}/`, {
constructForm(`{% url "api-build-list" %}${pk}/`, {
fields: fields,
reload: true,
title: '{% trans "Edit Build Order" %}',
@ -147,7 +147,7 @@ function newBuildOrder(options={}) {
*/
function duplicateBuildOrder(build_id, options={}) {
inventreeGet(`/api/build/${build_id}/`, {}, {
inventreeGet(`{% url "api-build-list" %}${build_id}/`, {}, {
success: function(data) {
// Clear out data we do not want to be duplicated
delete data['pk'];
@ -166,7 +166,7 @@ function duplicateBuildOrder(build_id, options={}) {
function cancelBuildOrder(build_id, options={}) {
constructForm(
`/api/build/${build_id}/cancel/`,
`{% url "api-build-list" %}${build_id}/cancel/`,
{
method: 'POST',
title: '{% trans "Cancel Build Order" %}',
@ -208,7 +208,7 @@ function cancelBuildOrder(build_id, options={}) {
/* Construct a form to "complete" (finish) a build order */
function completeBuildOrder(build_id, options={}) {
constructForm(`/api/build/${build_id}/finish/`, {
constructForm(`{% url "api-build-list" %}${build_id}/finish/`, {
fieldsFunction: function(opts) {
var ctx = opts.context || {};
@ -287,7 +287,7 @@ function createBuildOutput(build_id, options) {
// Request build order information from the server
inventreeGet(
`/api/build/${build_id}/`,
`{% url "api-build-list" %}${build_id}/`,
{},
{
success: function(build) {
@ -312,7 +312,7 @@ function createBuildOutput(build_id, options) {
};
// Work out the next available serial numbers
inventreeGet(`/api/part/${build.part}/serial-numbers/`, {}, {
inventreeGet(`{% url "api-part-list" %}${build.part}/serial-numbers/`, {}, {
success: function(data) {
if (data.next) {
fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`;
@ -341,7 +341,7 @@ function createBuildOutput(build_id, options) {
`;
}
constructForm(`/api/build/${build_id}/create-output/`, {
constructForm(`{% url "api-build-list" %}${build_id}/create-output/`, {
method: 'POST',
title: '{% trans "Create Build Output" %}',
confirm: true,
@ -364,7 +364,7 @@ function createBuildOutput(build_id, options) {
*/
function makeBuildOutputButtons(output_id, build_info, options={}) {
var html = `<div class='btn-group float-right' role='group'>`;
var html = '';
// Tracked parts? Must be individually allocated
if (options.has_bom_items) {
@ -398,17 +398,13 @@ function makeBuildOutputButtons(output_id, build_info, options={}) {
);
// Add a button to "delete" this build output
html += makeIconButton(
'fa-trash-alt icon-red',
html += makeDeleteButton(
'button-output-delete',
output_id,
'{% trans "Delete build output" %}',
);
html += `</div>`;
return html;
return wrapButtons(html);
}
@ -421,7 +417,7 @@ function makeBuildOutputButtons(output_id, build_info, options={}) {
*/
function unallocateStock(build_id, options={}) {
var url = `/api/build/${build_id}/unallocate/`;
var url = `{% url "api-build-list" %}${build_id}/unallocate/`;
var html = `
<div class='alert alert-block alert-warning'>
@ -486,7 +482,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
var buttons = `<div class='btn-group float-right' role='group'>`;
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}');
buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}');
buttons += '</div>';
@ -529,7 +525,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
</tbody>
</table>`;
constructForm(`/api/build/${build_id}/complete/`, {
constructForm(`{% url "api-build-list" %}${build_id}/complete/`, {
method: 'POST',
preFormContent: html,
fields: {
@ -647,7 +643,7 @@ function deleteBuildOutputs(build_id, outputs, options={}) {
var buttons = `<div class='btn-group float-right' role='group'>`;
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}');
buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}');
buttons += '</div>';
@ -690,7 +686,7 @@ function deleteBuildOutputs(build_id, outputs, options={}) {
</tbody>
</table>`;
constructForm(`/api/build/${build_id}/delete-outputs/`, {
constructForm(`{% url "api-build-list" %}${build_id}/delete-outputs/`, {
method: 'POST',
preFormContent: html,
fields: {},
@ -768,11 +764,7 @@ function loadBuildOrderAllocationTable(table, options={}) {
options.params['location_detail'] = true;
options.params['stock_detail'] = true;
var filters = loadTableFilters('buildorderallocation');
for (var key in options.params) {
filters[key] = options.params[key];
}
var filters = loadTableFilters('buildorderallocation', options.params);
setupFilterList('buildorderallocation', $(table));
@ -893,7 +885,12 @@ function loadBuildOutputTable(build_info, options={}) {
filters[key] = params[key];
}
setupFilterList('builditems', $(table), options.filterTarget || '#filter-list-incompletebuilditems');
setupFilterList('builditems', $(table), options.filterTarget || '#filter-list-incompletebuilditems', {
labels: {
url: '{% url "api-stockitem-label-list" %}',
key: 'item',
}
});
function setupBuildOutputButtonCallbacks() {
@ -1407,19 +1404,6 @@ function loadBuildOutputTable(build_info, options={}) {
);
});
// Print stock item labels
$('#incomplete-output-print-label').click(function() {
var outputs = getTableData(table);
var stock_id_values = [];
outputs.forEach(function(output) {
stock_id_values.push(output.pk);
});
printStockItemLabels(stock_id_values);
});
$('#outputs-expand').click(function() {
$(table).bootstrapTable('expandAllRows');
});
@ -1482,13 +1466,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
}
// Filters
var filters = loadTableFilters('builditems');
var params = options.params || {};
for (var key in params) {
filters[key] = params[key];
}
let filters = loadTableFilters('builditems', options.params);
setupFilterList('builditems', $(table), options.filterTarget);
@ -1703,6 +1681,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
name: 'build-allocation',
uniqueId: 'sub_part',
search: options.search || false,
queryParams: filters,
original: options.params,
onPostBody: function(data) {
// Setup button callbacks
setupCallbacks();
@ -1796,15 +1776,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var pk = row.pk;
var html = `<div class='btn-group float-right' role='group'>`;
var html = '';
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeEditButton('button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
html += makeDeleteButton('button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
html += `</div>`;
return html;
return wrapButtons(html);
}
}
]
@ -1814,7 +1792,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
subTable.find('.button-allocation-edit').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/build/item/${pk}/`, {
constructForm(`{% url "api-build-item-list" %}${pk}/`, {
fields: {
quantity: {},
},
@ -1826,7 +1804,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
subTable.find('.button-allocation-delete').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/build/item/${pk}/`, {
constructForm(`{% url "api-build-item-list" %}${pk}/`, {
method: 'DELETE',
title: '{% trans "Remove Allocation" %}',
onSuccess: reloadAllocationData,
@ -1935,9 +1913,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
icons += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Consumable item" %}'></span>`;
} else {
if (available_stock < (required - allocated)) {
icons += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "Insufficient stock available" %}'></span>`;
icons += makeIconBadge('fa-times-circle icon-red', '{% trans "Insufficient stock available" %}');
} else {
icons += `<span class='fas fa-check-circle icon-green float-right' title='{% trans "Sufficient stock available" %}'></span>`;
icons += makeIconBadge('fa-check-circle icon-green', '{% trans "Sufficient stock available" %}');
}
if (available_stock <= 0) {
@ -1953,13 +1931,15 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
}
if (extra) {
icons += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
icons += makeInfoButton('fa-info-circle icon-blue', extra);
}
}
}
if (row.on_order && row.on_order > 0) {
icons += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`;
makeIconBadge('fa-shopping-cart', '{% trans "On Order" %}', {
content: row.on_order,
});
}
return renderLink(text, url) + icons;
@ -2027,7 +2007,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
}
// Generate action buttons for this build output
var html = `<div class='btn-group float-right' role='group'>`;
let html = '';
if (allocatedQuantity(row) < requiredQuantity(row)) {
if (row.sub_part_detail.assembly) {
@ -2041,17 +2021,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', row.sub_part, '{% trans "Allocate stock" %}');
}
html += makeIconButton(
'fa-minus-circle icon-red', 'button-unallocate', row.sub_part,
html += makeRemoveButton(
'button-unallocate',
row.sub_part,
'{% trans "Unallocate stock" %}',
{
disabled: allocatedQuantity(row) == 0,
}
);
html += '</div>';
return html;
return wrapButtons(html);
}
},
]
@ -2093,7 +2072,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
if (output_id) {
// Request information on the particular build output (stock item)
inventreeGet(`/api/stock/${output_id}/`, {}, {
inventreeGet(`{% url "api-stock-list" %}${output_id}/`, {}, {
success: function(output) {
if (output.quantity == 1 && output.serial != null) {
auto_fill_filters.serial = output.serial;
@ -2112,8 +2091,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
var delete_button = `<div class='btn-group float-right' role='group'>`;
delete_button += makeIconButton(
'fa-times icon-red',
delete_button += makeRemoveButton(
'button-row-remove',
pk,
'{% trans "Remove row" %}',
@ -2245,7 +2223,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
</table>
`;
constructForm(`/api/build/${build_id}/allocate/`, {
constructForm(`{% url "api-build-list" %}${build_id}/allocate/`, {
method: 'POST',
fields: {},
preFormContent: html,
@ -2459,7 +2437,7 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
},
};
constructForm(`/api/build/${build_id}/auto-allocate/`, {
constructForm(`{% url "api-build-list" %}${build_id}/auto-allocate/`, {
method: 'POST',
fields: fields,
title: '{% trans "Allocate Stock Items" %}',
@ -2484,21 +2462,19 @@ function loadBuildTable(table, options) {
var params = options.params || {};
var filters = {};
params['part_detail'] = true;
if (!options.disableFilters) {
filters = loadTableFilters('build');
}
for (var key in params) {
filters[key] = params[key];
}
var filters = loadTableFilters('build', params);
var filterTarget = options.filterTarget || null;
setupFilterList('build', table, filterTarget, {download: true});
setupFilterList('build', table, filterTarget, {
download: true,
report: {
url: '{% url "api-build-report-list" %}',
key: 'build',
}
});
// Which display mode to use for the build table?
var display_mode = inventreeLoad('build-table-display-mode', 'list');

View File

@ -4,23 +4,26 @@
constructForm,
imageHoverIcon,
loadTableFilters,
makeIconButton,
renderLink,
setupFilterList,
*/
/* exported
createCompany,
createContact,
createManufacturerPart,
createSupplierPart,
createSupplierPartPriceBreak,
deleteContacts,
deleteManufacturerParts,
deleteManufacturerPartParameters,
deleteSupplierParts,
duplicateSupplierPart,
editCompany,
editContact,
editSupplierPartPriceBreak,
loadCompanyTable,
loadContactTable,
loadManufacturerPartTable,
loadManufacturerPartParameterTable,
loadSupplierPartTable,
@ -197,7 +200,7 @@ function createSupplierPart(options={}) {
var header = '';
if (options.part) {
var part_model = {};
inventreeGet(`/api/part/${options.part}/.*`, {}, {
inventreeGet(`{% url "api-part-list" %}${options.part}/.*`, {}, {
async: false,
success: function(response) {
part_model = response;
@ -226,7 +229,7 @@ function duplicateSupplierPart(part, options={}) {
var fields = options.fields || supplierPartFields();
// Retrieve information for the supplied part
inventreeGet(`/api/company/part/${part}/`, {}, {
inventreeGet(`{% url "api-supplier-part-list" %}${part}/`, {}, {
success: function(data) {
// Remove fields which we do not want to duplicate
@ -234,7 +237,7 @@ function duplicateSupplierPart(part, options={}) {
delete data['available'];
delete data['availability_updated'];
constructForm(`/api/company/part/`, {
constructForm('{% url "api-supplier-part-list" %}', {
method: 'POST',
fields: fields,
title: '{% trans "Duplicate Supplier Part" %}',
@ -260,7 +263,7 @@ function editSupplierPart(part, options={}) {
fields.part.hidden = true;
}
constructForm(`/api/company/part/${part}/`, {
constructForm(`{% url "api-supplier-part-list" %}${part}/`, {
fields: fields,
title: options.title || '{% trans "Edit Supplier Part" %}',
onSuccess: options.onSuccess
@ -443,24 +446,18 @@ function createCompany(options={}) {
}
/*
* Load company listing data into specified table.
*
* Args:
* - table: Table element on the page
* - url: Base URL for the API query
* - options: table options.
*/
function loadCompanyTable(table, url, options={}) {
/*
* Load company listing data into specified table.
*
* Args:
* - table: Table element on the page
* - url: Base URL for the API query
* - options: table options.
*/
// Query parameters
var params = options.params || {};
var filters = loadTableFilters('company');
for (var key in params) {
filters[key] = params[key];
}
let params = options.params || {};
let filters = loadTableFilters('company', params);
setupFilterList('company', $(table));
@ -547,6 +544,230 @@ function loadCompanyTable(table, url, options={}) {
}
/*
* Construct a set of form fields for the Contact model
*/
function contactFields(options={}) {
let fields = {
company: {
icon: 'fa-building',
},
name: {
icon: 'fa-user',
},
phone: {
icon: 'fa-phone'
},
email: {
icon: 'fa-at',
},
role: {
icon: 'fa-user-tag',
},
};
if (options.company) {
fields.company.value = options.company;
}
return fields;
}
/*
* Launches a form to create a new Contact
*/
function createContact(options={}) {
let fields = options.fields || contactFields(options);
constructForm('{% url "api-contact-list" %}', {
method: 'POST',
fields: fields,
title: '{% trans "Create New Contact" %}',
onSuccess: function(response) {
handleFormSuccess(response, options);
}
});
}
/*
* Launches a form to edit an existing Contact
*/
function editContact(pk, options={}) {
let fields = options.fields || contactFields(options);
constructForm(`{% url "api-contact-list" %}${pk}/`, {
fields: fields,
title: '{% trans "Edit Contact" %}',
onSuccess: function(response) {
handleFormSuccess(response, options);
}
});
}
/*
* Launches a form to delete one (or more) contacts
*/
function deleteContacts(contacts, options={}) {
if (contacts.length == 0) {
return;
}
function renderContact(contact) {
return `
<tr>
<td>${contact.name}</td>
<td>${contact.email}</td>
<td>${contact.role}</td>
</tr>`;
}
let rows = '';
let ids = [];
contacts.forEach(function(contact) {
rows += renderContact(contact);
ids.push(contact.pk);
});
let html = `
<div class='alert alert-block alert-danger'>
{% trans "All selected contacts will be deleted" %}
</div>
<table class='table table-striped table-condensed'>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Role" %}</th>
</tr>
${rows}
</table>`;
constructForm('{% url "api-contact-list" %}', {
method: 'DELETE',
multi_delete: true,
title: '{% trans "Delete Contacts" %}',
preFormContent: html,
form_data: {
items: ids,
},
onSuccess: function(response) {
handleFormSuccess(response, options);
}
});
}
/*
* Load table listing company contacts
*/
function loadContactTable(table, options={}) {
var params = options.params || {};
var filters = loadTableFilters('contact', params);
setupFilterList('contact', $(table), '#filter-list-contacts');
$(table).inventreeTable({
url: '{% url "api-contact-list" %}',
queryParams: filters,
original: params,
idField: 'pk',
uniqueId: 'pk',
sidePagination: 'server',
formatNoMatches: function() {
return '{% trans "No contacts found" %}';
},
showColumns: true,
name: 'contacts',
columns: [
{
field: 'name',
title: '{% trans "Name" %}',
sortable: true,
switchable: false,
},
{
field: 'phone',
title: '{% trans "Phone Number" %}',
sortable: false,
switchable: true,
},
{
field: 'email',
title: '{% trans "Email Address" %}',
sortable: false,
switchable: true,
},
{
field: 'role',
title: '{% trans "Role" %}',
sortable: false,
switchable: false,
},
{
field: 'actions',
title: '',
sortable: false,
switchable: false,
visible: options.allow_edit || options.allow_delete,
formatter: function(value, row) {
var pk = row.pk;
let html = '';
if (options.allow_edit) {
html += makeEditButton('btn-contact-edit', pk, '{% trans "Edit Contact" %}');
}
if (options.allow_delete) {
html += makeDeleteButton('btn-contact-delete', pk, '{% trans "Delete Contact" %}');
}
return wrapButtons(html);
}
}
],
onPostBody: function() {
// Edit button callback
if (options.allow_edit) {
$(table).find('.btn-contact-edit').click(function() {
var pk = $(this).attr('pk');
editContact(pk, {
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
});
}
// Delete button callback
if (options.allow_delete) {
$(table).find('.btn-contact-delete').click(function() {
var pk = $(this).attr('pk');
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
if (row && row.pk) {
deleteContacts([row], {
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
}
});
}
}
});
}
/* Delete one or more ManufacturerPart objects from the database.
* - User will be provided with a modal form, showing all the parts to be deleted.
* - Delete operations are performed sequentialy, not simultaneously
@ -653,21 +874,16 @@ function deleteManufacturerPartParameters(selections, options={}) {
}
/*
* Load manufacturer part table
*/
function loadManufacturerPartTable(table, url, options) {
/*
* Load manufacturer part table
*
*/
// Query parameters
var params = options.params || {};
// Load filters
var filters = loadTableFilters('manufacturer-part');
for (var key in params) {
filters[key] = params[key];
}
var filters = loadTableFilters('manufacturer-part', params);
var filterTarget = options.filterTarget || '#filter-list-manufacturer-part';
@ -703,11 +919,11 @@ function loadManufacturerPartTable(table, url, options) {
var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value, url);
if (row.part_detail.is_template) {
html += `<span class='fas fa-clone float-right' title='{% trans "Template part" %}'></span>`;
html += makeIconBadge('fa-clone', '{% trans "Template part" %}');
}
if (row.part_detail.assembly) {
html += `<span class='fas fa-tools float-right' title='{% trans "Assembled part" %}'></span>`;
html += makeIconBadge('fa-tools', '{% trans "Assembled part" %}');
}
if (!row.part_detail.active) {
@ -764,16 +980,13 @@ function loadManufacturerPartTable(table, url, options) {
sortable: false,
switchable: false,
formatter: function(value, row) {
var pk = row.pk;
let pk = row.pk;
let html = '';
var html = `<div class='btn-group float-right' role='group'>`;
html += makeEditButton('button-manufacturer-part-edit', pk, '{% trans "Edit manufacturer part" %}');
html += makeDeleteButton('button-manufacturer-part-delete', pk, '{% trans "Delete manufacturer part" %}');
html += makeIconButton('fa-edit icon-blue', 'button-manufacturer-part-edit', pk, '{% trans "Edit manufacturer part" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-manufacturer-part-delete', pk, '{% trans "Delete manufacturer part" %}');
html += '</div>';
return html;
return wrapButtons(html);
}
}
],
@ -810,20 +1023,15 @@ function loadManufacturerPartTable(table, url, options) {
}
/*
* Load table of ManufacturerPartParameter objects
*/
function loadManufacturerPartParameterTable(table, url, options) {
/*
* Load table of ManufacturerPartParameter objects
*/
var params = options.params || {};
// Load filters
var filters = loadTableFilters('manufacturer-part-parameters');
// Overwrite explicit parameters
for (var key in params) {
filters[key] = params[key];
}
var filters = loadTableFilters('manufacturer-part-parameters', params);
setupFilterList('manufacturer-part-parameters', $(table));
@ -867,17 +1075,13 @@ function loadManufacturerPartParameterTable(table, url, options) {
switchable: false,
sortable: false,
formatter: function(value, row) {
let pk = row.pk;
let html = '';
var pk = row.pk;
html += makeEditButton('button-parameter-edit', pk, '{% trans "Edit parameter" %}');
html += makeDeleteButton('button-parameter-delete', pk, '{% trans "Delete parameter" %}');
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-edit icon-blue', 'button-parameter-edit', pk, '{% trans "Edit parameter" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-parameter-delete', pk, '{% trans "Delete parameter" %}');
html += `</div>`;
return html;
return wrapButtons(html);
}
}
],
@ -886,27 +1090,23 @@ function loadManufacturerPartParameterTable(table, url, options) {
$(table).find('.button-parameter-edit').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/company/part/manufacturer/parameter/${pk}/`, {
constructForm(`{% url "api-manufacturer-part-parameter-list" %}${pk}/`, {
fields: {
name: {},
value: {},
units: {},
},
title: '{% trans "Edit Parameter" %}',
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
refreshTable: table,
});
});
$(table).find('.button-parameter-delete').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/company/part/manufacturer/parameter/${pk}/`, {
constructForm(`{% url "api-manufacturer-part-parameter-list" %}${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Parameter" %}',
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
refreshTable: table,
});
});
}
@ -914,21 +1114,16 @@ function loadManufacturerPartParameterTable(table, url, options) {
}
/*
* Load supplier part table
*/
function loadSupplierPartTable(table, url, options) {
/*
* Load supplier part table
*
*/
// Query parameters
var params = options.params || {};
// Load filters
var filters = loadTableFilters('supplier-part');
for (var key in params) {
filters[key] = params[key];
}
var filters = loadTableFilters('supplier-part', params);
setupFilterList('supplier-part', $(table));
@ -964,11 +1159,11 @@ function loadSupplierPartTable(table, url, options) {
var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value, url);
if (row.part_detail.is_template) {
html += `<span class='fas fa-clone float-right' title='{% trans "Template part" %}'></span>`;
html += makeIconBadge('fa-clone', '{% trans "Template part" %}');
}
if (row.part_detail.assembly) {
html += `<span class='fas fa-tools float-right' title='{% trans "Assembled part" %}'></span>`;
html += makeIconBadge('fa-tools', '{% trans "Assembled part" %}');
}
if (!row.part_detail.active) {
@ -1088,9 +1283,13 @@ function loadSupplierPartTable(table, url, options) {
sortable: true,
formatter: function(value, row) {
if (row.availability_updated) {
var html = formatDecimal(value);
var date = renderDate(row.availability_updated, {showTime: true});
html += `<span class='fas fa-info-circle float-right' title='{% trans "Last Updated" %}: ${date}'></span>`;
let html = formatDecimal(value);
let date = renderDate(row.availability_updated, {showTime: true});
html += makeIconBadge(
'fa-info-circle',
`{% trans "Last Updated" %}: ${date}`
);
return html;
} else {
return '-';
@ -1108,16 +1307,13 @@ function loadSupplierPartTable(table, url, options) {
sortable: false,
switchable: false,
formatter: function(value, row) {
var pk = row.pk;
let pk = row.pk;
let html = '';
var html = `<div class='btn-group float-right' role='group'>`;
html += makeEditButton('button-supplier-part-edit', pk, '{% trans "Edit supplier part" %}');
html += makeDeleteButton('button-supplier-part-delete', pk, '{% trans "Delete supplier part" %}');
html += makeIconButton('fa-edit icon-blue', 'button-supplier-part-edit', pk, '{% trans "Edit supplier part" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-supplier-part-delete', pk, '{% trans "Delete supplier part" %}');
html += '</div>';
return html;
return wrapButtons(html);
}
}
],
@ -1166,24 +1362,20 @@ function loadSupplierPriceBreakTable(options={}) {
table.find('.button-price-break-delete').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/company/price-break/${pk}/`, {
constructForm(`{% url "api-part-supplier-price-list" %}${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Price Break" %}',
onSuccess: function() {
table.bootstrapTable('refresh');
},
refreshTable: table,
});
});
table.find('.button-price-break-edit').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/company/price-break/${pk}/`, {
constructForm(`{% url "api-part-supplier-price-list" %}${pk}/`, {
fields: supplierPartPriceBreakFields(),
title: '{% trans "Edit Price Break" %}',
onSuccess: function() {
table.bootstrapTable('refresh');
}
refreshTable: table,
});
});
}
@ -1231,10 +1423,12 @@ function loadSupplierPriceBreakTable(options={}) {
formatter: function(value, row) {
var html = renderDate(value);
html += `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-edit icon-blue', 'button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
html += `</div>`;
let buttons = '';
buttons += makeEditButton('button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
buttons += makeDeleteButton('button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
html += wrapButtons(buttons);
return html;
}

View File

@ -43,7 +43,7 @@ function defaultFilters() {
* @param tableKey - String key for the particular table
* @param defaults - Default filters for this table e.g. 'cascade=1&location=5'
*/
function loadTableFilters(tableKey) {
function loadTableFilters(tableKey, query={}) {
var lookup = 'table-filters-' + tableKey.toLowerCase();
@ -67,6 +67,9 @@ function loadTableFilters(tableKey) {
}
});
// Override configurable filters with hard-coded query
Object.assign(filters, query);
return filters;
}
@ -258,6 +261,18 @@ function generateFilterInput(tableKey, filterKey) {
}
/*
* Helper function to make a 'filter' style button
*/
function makeFilterButton(options={}) {
return `
<button id='${options.id}' title='${options.title}' class='btn btn-outline-secondary filter-button'>
<span class='fas ${options.icon}'></span>
</button>`;
}
/**
* Configure a filter list for a given table
*
@ -290,21 +305,58 @@ function setupFilterList(tableKey, table, target, options={}) {
// One blank slate, please
element.empty();
// Construct a set of buttons
var buttons = '';
// Add 'print reports' button
if (options.report && global_settings.REPORT_ENABLE) {
buttons += makeFilterButton({
id: `print-report-${tableKey}`,
title: options.report.title || '{% trans "Print reports for selected items" %}',
icon: 'fa-print',
});
}
// Add 'print labels' button
if (options.labels && global_settings.LABEL_ENABLE) {
buttons += makeFilterButton({
id: `print-labels-${tableKey}`,
title: options.labels.title || '{% trans "Print labels for selected items" %}',
icon: 'fa-tag',
});
}
// Add download button
if (options.download) {
buttons += `<button id='download-${tableKey}' title='{% trans "Download data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-download'></span></button>`;
buttons += makeFilterButton({
id: `download-${tableKey}`,
title: '{% trans "Download table data" %}',
icon: 'fa-download',
});
}
buttons += `<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-redo-alt'></span></button>`;
buttons += makeFilterButton({
id: `reload-${tableKey}`,
title: '{% trans "Reload table data" %}',
icon: 'fa-redo-alt',
});
// If there are filters defined for this table, add more buttons
if (!jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) {
buttons += `<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-filter'></span></button>`;
buttons += makeFilterButton({
id: add,
title: '{% trans "Add new filter" %}',
icon: 'fa-filter',
});
if (Object.keys(filters).length > 0) {
buttons += `<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-backspace icon-red'></span></button>`;
buttons += makeFilterButton({
id: clear,
title: '{% trans "Clear all filters" %}',
icon: 'fa-backspace icon-red',
});
}
}
@ -331,6 +383,42 @@ function setupFilterList(tableKey, table, target, options={}) {
element.append(filter_tag);
}
// Callback for printing reports
if (options.report && global_settings.REPORT_ENABLE) {
element.find(`#print-report-${tableKey}`).click(function() {
let data = getTableData(table);
let items = [];
data.forEach(function(row) {
items.push(row.pk);
});
printReports({
items: items,
url: options.report.url,
key: options.report.key
});
});
}
// Callback for printing labels
if (options.labels && global_settings.LABEL_ENABLE) {
element.find(`#print-labels-${tableKey}`).click(function() {
let data = getTableData(table);
let items = [];
data.forEach(function(row) {
items.push(row.pk);
});
printLabels({
items: items,
url: options.labels.url,
key: options.labels.key,
});
});
}
// Callback for reloading the table
element.find(`#reload-${tableKey}`).click(function() {
reloadTableFilters(table);

Some files were not shown because too many files have changed in this diff Show More