mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 13:05:42 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
3
.github/release.yml
vendored
3
.github/release.yml
vendored
@ -1,6 +1,9 @@
|
|||||||
# .github/release.yml
|
# .github/release.yml
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
|
exclude:
|
||||||
|
labels:
|
||||||
|
- translation
|
||||||
categories:
|
categories:
|
||||||
- title: Breaking Changes
|
- title: Breaking Changes
|
||||||
labels:
|
labels:
|
||||||
|
@ -7,7 +7,7 @@ exclude: |
|
|||||||
)$
|
)$
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.2.0
|
rev: v4.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 59
|
INVENTREE_API_VERSION = 60
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v60 -> 2022-06-08 : https://github.com/inventree/InvenTree/pull/3148
|
||||||
|
- Add availability data fields to the SupplierPart model
|
||||||
|
|
||||||
v59 -> 2022-06-07 : https://github.com/inventree/InvenTree/pull/3154
|
v59 -> 2022-06-07 : https://github.com/inventree/InvenTree/pull/3154
|
||||||
- Adds further improvements to BulkDelete mixin class
|
- Adds further improvements to BulkDelete mixin class
|
||||||
- Fixes multiple bugs in custom OPTIONS metadata implementation
|
- Fixes multiple bugs in custom OPTIONS metadata implementation
|
||||||
|
24
InvenTree/company/migrations/0044_auto_20220607_2204.py
Normal file
24
InvenTree/company/migrations/0044_auto_20220607_2204.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-07 22:04
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('company', '0043_manufacturerpartattachment'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='supplierpart',
|
||||||
|
name='availability_updated',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Date of last update of availability data', null=True, verbose_name='Availability Updated'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='supplierpart',
|
||||||
|
name='available',
|
||||||
|
field=models.DecimalField(decimal_places=3, default=0, help_text='Quantity available from supplier', max_digits=10, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Available'),
|
||||||
|
),
|
||||||
|
]
|
@ -1,6 +1,7 @@
|
|||||||
"""Company database model definitions."""
|
"""Company database model definitions."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@ -528,6 +529,25 @@ class SupplierPart(models.Model):
|
|||||||
# TODO - Reimplement lead-time as a charfield with special validation (pattern matching).
|
# TODO - Reimplement lead-time as a charfield with special validation (pattern matching).
|
||||||
# lead_time = models.DurationField(blank=True, null=True)
|
# lead_time = models.DurationField(blank=True, null=True)
|
||||||
|
|
||||||
|
available = models.DecimalField(
|
||||||
|
max_digits=10, decimal_places=3, default=0,
|
||||||
|
validators=[MinValueValidator(0)],
|
||||||
|
verbose_name=_('Available'),
|
||||||
|
help_text=_('Quantity available from supplier'),
|
||||||
|
)
|
||||||
|
|
||||||
|
availability_updated = models.DateTimeField(
|
||||||
|
null=True, blank=True, verbose_name=_('Availability Updated'),
|
||||||
|
help_text=_('Date of last update of availability data'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_available_quantity(self, quantity):
|
||||||
|
"""Update the available quantity for this SupplierPart"""
|
||||||
|
|
||||||
|
self.available = quantity
|
||||||
|
self.availability_updated = datetime.now()
|
||||||
|
self.save()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def manufacturer_string(self):
|
def manufacturer_string(self):
|
||||||
"""Format a MPN string for this SupplierPart.
|
"""Format a MPN string for this SupplierPart.
|
||||||
|
@ -209,6 +209,10 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Initialize this serializer with extra detail fields as required"""
|
"""Initialize this serializer with extra detail fields as required"""
|
||||||
|
|
||||||
|
# Check if 'available' quantity was supplied
|
||||||
|
self.has_available_quantity = 'available' in kwargs.get('data', {})
|
||||||
|
|
||||||
part_detail = kwargs.pop('part_detail', True)
|
part_detail = kwargs.pop('part_detail', True)
|
||||||
supplier_detail = kwargs.pop('supplier_detail', True)
|
supplier_detail = kwargs.pop('supplier_detail', True)
|
||||||
manufacturer_detail = kwargs.pop('manufacturer_detail', True)
|
manufacturer_detail = kwargs.pop('manufacturer_detail', True)
|
||||||
@ -242,6 +246,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
model = SupplierPart
|
model = SupplierPart
|
||||||
fields = [
|
fields = [
|
||||||
|
'available',
|
||||||
|
'availability_updated',
|
||||||
'description',
|
'description',
|
||||||
'link',
|
'link',
|
||||||
'manufacturer',
|
'manufacturer',
|
||||||
@ -260,11 +266,34 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
'supplier_detail',
|
'supplier_detail',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
read_only_fields = [
|
||||||
|
'availability_updated',
|
||||||
|
]
|
||||||
|
|
||||||
|
def update(self, supplier_part, data):
|
||||||
|
"""Custom update functionality for the serializer"""
|
||||||
|
|
||||||
|
available = data.pop('available', None)
|
||||||
|
|
||||||
|
response = super().update(supplier_part, data)
|
||||||
|
|
||||||
|
if available is not None and self.has_available_quantity:
|
||||||
|
supplier_part.update_available_quantity(available)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
"""Extract manufacturer data and process ManufacturerPart."""
|
"""Extract manufacturer data and process ManufacturerPart."""
|
||||||
|
|
||||||
|
# Extract 'available' quantity from the serializer
|
||||||
|
available = validated_data.pop('available', None)
|
||||||
|
|
||||||
# Create SupplierPart
|
# Create SupplierPart
|
||||||
supplier_part = super().create(validated_data)
|
supplier_part = super().create(validated_data)
|
||||||
|
|
||||||
|
if available is not None and self.has_available_quantity:
|
||||||
|
supplier_part.update_available_quantity(available)
|
||||||
|
|
||||||
# Get ManufacturerPart raw data (unvalidated)
|
# Get ManufacturerPart raw data (unvalidated)
|
||||||
manufacturer = self.initial_data.get('manufacturer', None)
|
manufacturer = self.initial_data.get('manufacturer', None)
|
||||||
MPN = self.initial_data.get('MPN', None)
|
MPN = self.initial_data.get('MPN', None)
|
||||||
|
@ -30,18 +30,32 @@
|
|||||||
{% url 'admin:company_supplierpart_change' part.pk as url %}
|
{% url 'admin:company_supplierpart_change' part.pk as url %}
|
||||||
{% include "admin_button.html" with url=url %}
|
{% include "admin_button.html" with url=url %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if roles.purchase_order.add %}
|
{% if roles.purchase_order.change or roles.purchase_order.add or roles.purchase_order.delete %}
|
||||||
<button type='button' class='btn btn-outline-secondary' id='order-part' title='{% trans "Order part" %}'>
|
<div class='btn-group'>
|
||||||
<span class='fas fa-shopping-cart'></span>
|
<button id='supplier-part-actions' title='{% trans "Supplier part actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
|
||||||
</button>
|
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||||
{% endif %}
|
</button>
|
||||||
<button type='button' class='btn btn-outline-secondary' id='edit-part' title='{% trans "Edit supplier part" %}'>
|
<ul class='dropdown-menu'>
|
||||||
<span class='fas fa-edit icon-green'/>
|
{% if roles.purchase_order.add %}
|
||||||
</button>
|
<li><a class='dropdown-item' href='#' id='order-part' title='{% trans "Order Part" %}'>
|
||||||
{% if roles.purchase_order.delete %}
|
<span class='fas fa-shopping-cart'></span> {% trans "Order Part" %}
|
||||||
<button type='button' class='btn btn-outline-secondary' id='delete-part' title='{% trans "Delete supplier part" %}'>
|
</a></li>
|
||||||
<span class='fas fa-trash-alt icon-red'/>
|
{% endif %}
|
||||||
</button>
|
{% if roles.purchase_order.change %}
|
||||||
|
<li><a class='dropdown-item' href='#' id='update-part-availability' title='{% trans "Update Availability" %}'>
|
||||||
|
<span class='fas fa-building'></span> {% trans "Update Availability" %}
|
||||||
|
</a></li>
|
||||||
|
<li><a class='dropdown-item' href='#' id='edit-part' title='{% trans "Edit Supplier Part" %}'>
|
||||||
|
<span class='fas fa-edit icon-green'></span> {% trans "Edit Supplier Part" %}
|
||||||
|
</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.purchase_order.delete %}
|
||||||
|
<li><a class='dropdown-item' href='#' id='delete-part' title='{% trans "Delete Supplier Part" %}'>
|
||||||
|
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Supplier Part" %}
|
||||||
|
</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
@ -74,6 +88,13 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<td>{{ part.description }}{% include "clip.html"%}</td>
|
<td>{{ part.description }}{% include "clip.html"%}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if part.availability_updated %}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>{% trans "Available" %}</td>
|
||||||
|
<td>{% decimal part.available %}<span class='badge bg-dark rounded-pill float-right'>{% render_date part.availability_updated %}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{% endblock details %}
|
{% endblock details %}
|
||||||
@ -351,6 +372,20 @@ $('#order-part, #order-part2').click(function() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% if roles.purchase_order.change %}
|
||||||
|
|
||||||
|
$('#update-part-availability').click(function() {
|
||||||
|
editSupplierPart({{ part.pk }}, {
|
||||||
|
fields: {
|
||||||
|
available: {},
|
||||||
|
},
|
||||||
|
title: '{% trans "Update Part Availability" %}',
|
||||||
|
onSuccess: function() {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$('#edit-part').click(function () {
|
$('#edit-part').click(function () {
|
||||||
|
|
||||||
editSupplierPart({{ part.pk }}, {
|
editSupplierPart({{ part.pk }}, {
|
||||||
@ -360,6 +395,8 @@ $('#edit-part').click(function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
$('#delete-part').click(function() {
|
$('#delete-part').click(function() {
|
||||||
inventreeGet(
|
inventreeGet(
|
||||||
'{% url "api-supplier-part-detail" part.pk %}',
|
'{% url "api-supplier-part-detail" part.pk %}',
|
||||||
|
@ -6,7 +6,7 @@ from rest_framework import status
|
|||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
|
|
||||||
from .models import Company
|
from .models import Company, SupplierPart
|
||||||
|
|
||||||
|
|
||||||
class CompanyTest(InvenTreeAPITestCase):
|
class CompanyTest(InvenTreeAPITestCase):
|
||||||
@ -146,6 +146,7 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
|||||||
'location',
|
'location',
|
||||||
'company',
|
'company',
|
||||||
'manufacturer_part',
|
'manufacturer_part',
|
||||||
|
'supplier_part',
|
||||||
]
|
]
|
||||||
|
|
||||||
roles = [
|
roles = [
|
||||||
@ -238,3 +239,111 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
|||||||
url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
|
url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
|
||||||
response = self.get(url)
|
response = self.get(url)
|
||||||
self.assertEqual(response.data['MPN'], 'PART_NUMBER')
|
self.assertEqual(response.data['MPN'], 'PART_NUMBER')
|
||||||
|
|
||||||
|
|
||||||
|
class SupplierPartTest(InvenTreeAPITestCase):
|
||||||
|
"""Unit tests for the SupplierPart API endpoints"""
|
||||||
|
|
||||||
|
fixtures = [
|
||||||
|
'category',
|
||||||
|
'part',
|
||||||
|
'location',
|
||||||
|
'company',
|
||||||
|
'manufacturer_part',
|
||||||
|
'supplier_part',
|
||||||
|
]
|
||||||
|
|
||||||
|
roles = [
|
||||||
|
'part.add',
|
||||||
|
'part.change',
|
||||||
|
'part.add',
|
||||||
|
'purchase_order.change',
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_supplier_part_list(self):
|
||||||
|
"""Test the SupplierPart API list functionality"""
|
||||||
|
url = reverse('api-supplier-part-list')
|
||||||
|
|
||||||
|
# Return *all* SupplierParts
|
||||||
|
response = self.get(url, {}, expected_code=200)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), SupplierPart.objects.count())
|
||||||
|
|
||||||
|
# Filter by Supplier reference
|
||||||
|
for supplier in Company.objects.filter(is_supplier=True):
|
||||||
|
response = self.get(url, {'supplier': supplier.pk}, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), supplier.supplied_parts.count())
|
||||||
|
|
||||||
|
# Filter by Part reference
|
||||||
|
expected = {
|
||||||
|
1: 4,
|
||||||
|
25: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
for pk, n in expected.items():
|
||||||
|
response = self.get(url, {'part': pk}, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), n)
|
||||||
|
|
||||||
|
def test_available(self):
|
||||||
|
"""Tests for updating the 'available' field"""
|
||||||
|
|
||||||
|
url = reverse('api-supplier-part-list')
|
||||||
|
|
||||||
|
# Should fail when sending an invalid 'available' field
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'part': 1,
|
||||||
|
'supplier': 2,
|
||||||
|
'SKU': 'QQ',
|
||||||
|
'available': 'not a number',
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('A valid number is required', str(response.data))
|
||||||
|
|
||||||
|
# Create a SupplierPart without specifying available quantity
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'part': 1,
|
||||||
|
'supplier': 2,
|
||||||
|
'SKU': 'QQ',
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
sp = SupplierPart.objects.get(pk=response.data['pk'])
|
||||||
|
|
||||||
|
self.assertIsNone(sp.availability_updated)
|
||||||
|
self.assertEqual(sp.available, 0)
|
||||||
|
|
||||||
|
# Now, *update* the availabile quantity via the API
|
||||||
|
self.patch(
|
||||||
|
reverse('api-supplier-part-detail', kwargs={'pk': sp.pk}),
|
||||||
|
{
|
||||||
|
'available': 1234,
|
||||||
|
},
|
||||||
|
expected_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
sp.refresh_from_db()
|
||||||
|
self.assertIsNotNone(sp.availability_updated)
|
||||||
|
self.assertEqual(sp.available, 1234)
|
||||||
|
|
||||||
|
# We should also be able to create a SupplierPart with initial 'available' quantity
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'part': 1,
|
||||||
|
'supplier': 2,
|
||||||
|
'SKU': 'QQQ',
|
||||||
|
'available': 999,
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
sp = SupplierPart.objects.get(pk=response.data['pk'])
|
||||||
|
self.assertEqual(sp.available, 999)
|
||||||
|
self.assertIsNotNone(sp.availability_updated)
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1010,7 +1010,7 @@ function loadBomTable(table, options={}) {
|
|||||||
can_build = available / row.quantity;
|
can_build = available / row.quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
return +can_build.toFixed(2);
|
return formatDecimal(can_build, 2);
|
||||||
},
|
},
|
||||||
sorter: function(valA, valB, rowA, rowB) {
|
sorter: function(valA, valB, rowA, rowB) {
|
||||||
// Function to sort the "can build" quantity
|
// Function to sort the "can build" quantity
|
||||||
|
@ -795,7 +795,7 @@ function sumAllocationsForBomRow(bom_row, allocations) {
|
|||||||
quantity += allocation.quantity;
|
quantity += allocation.quantity;
|
||||||
});
|
});
|
||||||
|
|
||||||
return parseFloat(quantity).toFixed(15);
|
return formatDecimal(quantity, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1490,8 +1490,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
|
|
||||||
// Store the required quantity in the row data
|
// Store the required quantity in the row data
|
||||||
// Prevent weird rounding issues
|
// Prevent weird rounding issues
|
||||||
row.required = parseFloat(quantity.toFixed(15));
|
row.required = formatDecimal(quantity, 15);
|
||||||
|
|
||||||
return row.required;
|
return row.required;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2043,7 +2042,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the quantity sent to the form field is correctly formatted
|
// Ensure the quantity sent to the form field is correctly formatted
|
||||||
remaining = parseFloat(remaining.toFixed(15));
|
remaining = formatDecimal(remaining, 15);
|
||||||
|
|
||||||
// We only care about entries which are not yet fully allocated
|
// We only care about entries which are not yet fully allocated
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
|
@ -189,14 +189,16 @@ function createSupplierPart(options={}) {
|
|||||||
|
|
||||||
function editSupplierPart(part, options={}) {
|
function editSupplierPart(part, options={}) {
|
||||||
|
|
||||||
var fields = supplierPartFields();
|
var fields = options.fields || supplierPartFields();
|
||||||
|
|
||||||
// Hide the "part" field
|
// Hide the "part" field
|
||||||
fields.part.hidden = true;
|
if (fields.part) {
|
||||||
|
fields.part.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
constructForm(`/api/company/part/${part}/`, {
|
constructForm(`/api/company/part/${part}/`, {
|
||||||
fields: fields,
|
fields: fields,
|
||||||
title: '{% trans "Edit Supplier Part" %}',
|
title: options.title || '{% trans "Edit Supplier Part" %}',
|
||||||
onSuccess: options.onSuccess
|
onSuccess: options.onSuccess
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -952,6 +954,21 @@ function loadSupplierPartTable(table, url, options) {
|
|||||||
title: '{% trans "Packaging" %}',
|
title: '{% trans "Packaging" %}',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'available',
|
||||||
|
title: '{% trans "Available" %}',
|
||||||
|
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>`;
|
||||||
|
return html;
|
||||||
|
} else {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'actions',
|
field: 'actions',
|
||||||
title: '',
|
title: '',
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
blankImage,
|
blankImage,
|
||||||
deleteButton,
|
deleteButton,
|
||||||
editButton,
|
editButton,
|
||||||
|
formatDecimal,
|
||||||
imageHoverIcon,
|
imageHoverIcon,
|
||||||
makeIconBadge,
|
makeIconBadge,
|
||||||
makeIconButton,
|
makeIconButton,
|
||||||
@ -34,6 +35,13 @@ function deleteButton(url, text='{% trans "Delete" %}') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Format a decimal (floating point) number, to strip trailing zeros
|
||||||
|
*/
|
||||||
|
function formatDecimal(number, places=5) {
|
||||||
|
return +parseFloat(number).toFixed(places);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function blankImage() {
|
function blankImage() {
|
||||||
return `/static/img/blank_image.png`;
|
return `/static/img/blank_image.png`;
|
||||||
}
|
}
|
||||||
|
@ -1191,7 +1191,7 @@ function noResultBadge() {
|
|||||||
|
|
||||||
function formatDate(row) {
|
function formatDate(row) {
|
||||||
// Function for formatting date field
|
// Function for formatting date field
|
||||||
var html = row.date;
|
var html = renderDate(row.date);
|
||||||
|
|
||||||
if (row.user_detail) {
|
if (row.user_detail) {
|
||||||
html += `<span class='badge badge-right rounded-pill bg-secondary'>${row.user_detail.username}</span>`;
|
html += `<span class='badge badge-right rounded-pill bg-secondary'>${row.user_detail.username}</span>`;
|
||||||
@ -1707,13 +1707,13 @@ function loadStockTable(table, options) {
|
|||||||
val = '# ' + row.serial;
|
val = '# ' + row.serial;
|
||||||
} else if (row.quantity != available) {
|
} else if (row.quantity != available) {
|
||||||
// Some quantity is available, show available *and* quantity
|
// Some quantity is available, show available *and* quantity
|
||||||
var ava = +parseFloat(available).toFixed(5);
|
var ava = formatDecimal(available);
|
||||||
var tot = +parseFloat(row.quantity).toFixed(5);
|
var tot = formatDecimal(row.quantity);
|
||||||
|
|
||||||
val = `${ava} / ${tot}`;
|
val = `${ava} / ${tot}`;
|
||||||
} else {
|
} else {
|
||||||
// Format floating point numbers with this one weird trick
|
// Format floating point numbers with this one weird trick
|
||||||
val = +parseFloat(value).toFixed(5);
|
val = formatDecimal(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = renderLink(val, `/stock/item/${row.pk}/`);
|
var html = renderLink(val, `/stock/item/${row.pk}/`);
|
||||||
|
@ -34,6 +34,11 @@ INVENTREE_DB_PORT=5432
|
|||||||
#INVENTREE_DB_USER=pguser
|
#INVENTREE_DB_USER=pguser
|
||||||
#INVENTREE_DB_PASSWORD=pgpassword
|
#INVENTREE_DB_PASSWORD=pgpassword
|
||||||
|
|
||||||
|
# Redis cache setup
|
||||||
|
# Refer to settings.py for other cache options
|
||||||
|
INVENTREE_CACHE_HOST=inventree-cache
|
||||||
|
INVENTREE_CACHE_PORT=6379
|
||||||
|
|
||||||
# Enable plugins?
|
# Enable plugins?
|
||||||
INVENTREE_PLUGINS_ENABLED=False
|
INVENTREE_PLUGINS_ENABLED=False
|
||||||
|
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
# Docker compose recipe for InvenTree production server
|
# Docker compose recipe for InvenTree production server, with the following containerized processes
|
||||||
# - PostgreSQL as the database backend
|
# - PostgreSQL as the database backend
|
||||||
# - gunicorn as the InvenTree web server
|
# - gunicorn as the InvenTree web server
|
||||||
# - django-q as the InvenTree background worker process
|
# - django-q as the InvenTree background worker process
|
||||||
# - nginx as a reverse proxy
|
# - nginx as a reverse proxy
|
||||||
|
# - redis as the cache manager
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# READ BEFORE STARTING!
|
# READ BEFORE STARTING!
|
||||||
@ -112,6 +113,18 @@ services:
|
|||||||
- inventree_data:/var/www
|
- inventree_data:/var/www
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# redis acts as database cache manager
|
||||||
|
inventree-cache:
|
||||||
|
container_name: inventree-cache
|
||||||
|
image: redis:7.0
|
||||||
|
depends_on:
|
||||||
|
- inventree-db
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- ${INVENTREE_CACHE_PORT:-6379}:6379
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# NOTE: Change /path/to/data to a directory on your local machine
|
# NOTE: Change /path/to/data to a directory on your local machine
|
||||||
# Persistent data, stored external to the container(s)
|
# Persistent data, stored external to the container(s)
|
||||||
|
52
tasks.py
52
tasks.py
@ -224,7 +224,7 @@ def update(c):
|
|||||||
def style(c):
|
def style(c):
|
||||||
"""Run PEP style checks against InvenTree sourcecode"""
|
"""Run PEP style checks against InvenTree sourcecode"""
|
||||||
print("Running PEP style checks...")
|
print("Running PEP style checks...")
|
||||||
c.run('flake8 InvenTree')
|
c.run('flake8 InvenTree tasks.py')
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
@ -282,9 +282,30 @@ def content_excludes():
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
@task(help={'filename': "Output filename (default = 'data.json')"})
|
@task(help={
|
||||||
def export_records(c, filename='data.json'):
|
'filename': "Output filename (default = 'data.json')",
|
||||||
"""Export all database records to a file"""
|
'overwrite': "Overwrite existing files without asking first (default = off/False)",
|
||||||
|
'include_permissions': "Include user and group permissions in the output file (filename) (default = off/False)",
|
||||||
|
'delete_temp': "Delete temporary files (containing permissions) at end of run. Note that this will delete temporary files from previous runs as well. (default = off/False)"
|
||||||
|
})
|
||||||
|
def export_records(c, filename='data.json', overwrite=False, include_permissions=False, delete_temp=False):
|
||||||
|
"""Export all database records to a file.
|
||||||
|
|
||||||
|
Write data to the file defined by filename.
|
||||||
|
If --overwrite is not set, the user will be prompted about overwriting an existing files.
|
||||||
|
If --include-permissions is not set, the file defined by filename will have permissions specified for a user or group removed.
|
||||||
|
If --delete-temp is not set, the temporary file (which includes permissions) will not be deleted. This file is named filename.tmp
|
||||||
|
|
||||||
|
For historical reasons, calling this function without any arguments will thus result in two files:
|
||||||
|
- data.json: does not include permissions
|
||||||
|
- data.json.tmp: includes permissions
|
||||||
|
|
||||||
|
If you want the script to overwrite any existing files without asking, add argument -o / --overwrite.
|
||||||
|
|
||||||
|
If you only want one file, add argument - d / --delete-temp.
|
||||||
|
|
||||||
|
If you want only one file, with permissions, then additionally add argument -i / --include-permissions
|
||||||
|
"""
|
||||||
# Get an absolute path to the file
|
# Get an absolute path to the file
|
||||||
if not os.path.isabs(filename):
|
if not os.path.isabs(filename):
|
||||||
filename = os.path.join(localDir(), filename)
|
filename = os.path.join(localDir(), filename)
|
||||||
@ -292,7 +313,7 @@ def export_records(c, filename='data.json'):
|
|||||||
|
|
||||||
print(f"Exporting database records to file '{filename}'")
|
print(f"Exporting database records to file '{filename}'")
|
||||||
|
|
||||||
if os.path.exists(filename):
|
if os.path.exists(filename) and overwrite is False:
|
||||||
response = input("Warning: file already exists. Do you want to overwrite? [y/N]: ")
|
response = input("Warning: file already exists. Do you want to overwrite? [y/N]: ")
|
||||||
response = str(response).strip().lower()
|
response = str(response).strip().lower()
|
||||||
|
|
||||||
@ -313,16 +334,17 @@ def export_records(c, filename='data.json'):
|
|||||||
with open(tmpfile, "r") as f_in:
|
with open(tmpfile, "r") as f_in:
|
||||||
data = json.loads(f_in.read())
|
data = json.loads(f_in.read())
|
||||||
|
|
||||||
for entry in data:
|
if include_permissions is False:
|
||||||
if "model" in entry:
|
for entry in data:
|
||||||
|
if "model" in entry:
|
||||||
|
|
||||||
# Clear out any permissions specified for a group
|
# Clear out any permissions specified for a group
|
||||||
if entry["model"] == "auth.group":
|
if entry["model"] == "auth.group":
|
||||||
entry["fields"]["permissions"] = []
|
entry["fields"]["permissions"] = []
|
||||||
|
|
||||||
# Clear out any permissions specified for a user
|
# Clear out any permissions specified for a user
|
||||||
if entry["model"] == "auth.user":
|
if entry["model"] == "auth.user":
|
||||||
entry["fields"]["user_permissions"] = []
|
entry["fields"]["user_permissions"] = []
|
||||||
|
|
||||||
# Write the processed data to file
|
# Write the processed data to file
|
||||||
with open(filename, "w") as f_out:
|
with open(filename, "w") as f_out:
|
||||||
@ -330,6 +352,10 @@ def export_records(c, filename='data.json'):
|
|||||||
|
|
||||||
print("Data export completed")
|
print("Data export completed")
|
||||||
|
|
||||||
|
if delete_temp is True:
|
||||||
|
print("Removing temporary file")
|
||||||
|
os.remove(tmpfile)
|
||||||
|
|
||||||
|
|
||||||
@task(help={'filename': 'Input filename', 'clear': 'Clear existing data before import'}, post=[rebuild_models, rebuild_thumbnails])
|
@task(help={'filename': 'Input filename', 'clear': 'Clear existing data before import'}, post=[rebuild_models, rebuild_thumbnails])
|
||||||
def import_records(c, filename='data.json', clear=False):
|
def import_records(c, filename='data.json', clear=False):
|
||||||
|
Reference in New Issue
Block a user