2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 20:15:44 +00:00

Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters
2022-06-10 22:05:23 +10:00
41 changed files with 26635 additions and 25493 deletions

3
.github/release.yml vendored
View File

@ -1,6 +1,9 @@
# .github/release.yml
changelog:
exclude:
labels:
- translation
categories:
- title: Breaking Changes
labels:

View File

@ -7,7 +7,7 @@ exclude: |
)$
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.2.0
rev: v4.3.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer

View File

@ -2,11 +2,14 @@
# 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
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
- Adds further improvements to BulkDelete mixin class
- Fixes multiple bugs in custom OPTIONS metadata implementation

View 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'),
),
]

View File

@ -1,6 +1,7 @@
"""Company database model definitions."""
import os
from datetime import datetime
from django.apps import apps
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).
# 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
def manufacturer_string(self):
"""Format a MPN string for this SupplierPart.

View File

@ -209,6 +209,10 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
def __init__(self, *args, **kwargs):
"""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)
supplier_detail = kwargs.pop('supplier_detail', True)
manufacturer_detail = kwargs.pop('manufacturer_detail', True)
@ -242,6 +246,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
model = SupplierPart
fields = [
'available',
'availability_updated',
'description',
'link',
'manufacturer',
@ -260,11 +266,34 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'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):
"""Extract manufacturer data and process ManufacturerPart."""
# Extract 'available' quantity from the serializer
available = validated_data.pop('available', None)
# Create SupplierPart
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)
manufacturer = self.initial_data.get('manufacturer', None)
MPN = self.initial_data.get('MPN', None)

View File

@ -30,18 +30,32 @@
{% url 'admin:company_supplierpart_change' part.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if roles.purchase_order.add %}
<button type='button' class='btn btn-outline-secondary' id='order-part' title='{% trans "Order part" %}'>
<span class='fas fa-shopping-cart'></span>
</button>
{% endif %}
<button type='button' class='btn btn-outline-secondary' id='edit-part' title='{% trans "Edit supplier part" %}'>
<span class='fas fa-edit icon-green'/>
</button>
{% if roles.purchase_order.delete %}
<button type='button' class='btn btn-outline-secondary' id='delete-part' title='{% trans "Delete supplier part" %}'>
<span class='fas fa-trash-alt icon-red'/>
</button>
{% if roles.purchase_order.change or roles.purchase_order.add or roles.purchase_order.delete %}
<div class='btn-group'>
<button id='supplier-part-actions' title='{% trans "Supplier part 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'>
{% if roles.purchase_order.add %}
<li><a class='dropdown-item' href='#' id='order-part' title='{% trans "Order Part" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Part" %}
</a></li>
{% endif %}
{% 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 %}
{% endblock actions %}
@ -74,6 +88,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ part.description }}{% include "clip.html"%}</td>
</tr>
{% 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>
{% 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 () {
editSupplierPart({{ part.pk }}, {
@ -360,6 +395,8 @@ $('#edit-part').click(function () {
});
});
{% endif %}
$('#delete-part').click(function() {
inventreeGet(
'{% url "api-supplier-part-detail" part.pk %}',

View File

@ -6,7 +6,7 @@ from rest_framework import status
from InvenTree.api_tester import InvenTreeAPITestCase
from .models import Company
from .models import Company, SupplierPart
class CompanyTest(InvenTreeAPITestCase):
@ -146,6 +146,7 @@ class ManufacturerTest(InvenTreeAPITestCase):
'location',
'company',
'manufacturer_part',
'supplier_part',
]
roles = [
@ -238,3 +239,111 @@ class ManufacturerTest(InvenTreeAPITestCase):
url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
response = self.get(url)
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

View File

@ -1010,7 +1010,7 @@ function loadBomTable(table, options={}) {
can_build = available / row.quantity;
}
return +can_build.toFixed(2);
return formatDecimal(can_build, 2);
},
sorter: function(valA, valB, rowA, rowB) {
// Function to sort the "can build" quantity

View File

@ -795,7 +795,7 @@ function sumAllocationsForBomRow(bom_row, allocations) {
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
// Prevent weird rounding issues
row.required = parseFloat(quantity.toFixed(15));
row.required = formatDecimal(quantity, 15);
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
remaining = parseFloat(remaining.toFixed(15));
remaining = formatDecimal(remaining, 15);
// We only care about entries which are not yet fully allocated
if (remaining > 0) {

View File

@ -189,14 +189,16 @@ function createSupplierPart(options={}) {
function editSupplierPart(part, options={}) {
var fields = supplierPartFields();
var fields = options.fields || supplierPartFields();
// Hide the "part" field
fields.part.hidden = true;
if (fields.part) {
fields.part.hidden = true;
}
constructForm(`/api/company/part/${part}/`, {
fields: fields,
title: '{% trans "Edit Supplier Part" %}',
title: options.title || '{% trans "Edit Supplier Part" %}',
onSuccess: options.onSuccess
});
}
@ -952,6 +954,21 @@ function loadSupplierPartTable(table, url, options) {
title: '{% trans "Packaging" %}',
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',
title: '',

View File

@ -4,6 +4,7 @@
blankImage,
deleteButton,
editButton,
formatDecimal,
imageHoverIcon,
makeIconBadge,
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() {
return `/static/img/blank_image.png`;
}

View File

@ -1191,7 +1191,7 @@ function noResultBadge() {
function formatDate(row) {
// Function for formatting date field
var html = row.date;
var html = renderDate(row.date);
if (row.user_detail) {
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;
} else if (row.quantity != available) {
// Some quantity is available, show available *and* quantity
var ava = +parseFloat(available).toFixed(5);
var tot = +parseFloat(row.quantity).toFixed(5);
var ava = formatDecimal(available);
var tot = formatDecimal(row.quantity);
val = `${ava} / ${tot}`;
} else {
// 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}/`);

View File

@ -34,6 +34,11 @@ INVENTREE_DB_PORT=5432
#INVENTREE_DB_USER=pguser
#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?
INVENTREE_PLUGINS_ENABLED=False

View File

@ -1,10 +1,11 @@
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
# - gunicorn as the InvenTree web server
# - django-q as the InvenTree background worker process
# - nginx as a reverse proxy
# - redis as the cache manager
# ---------------------
# READ BEFORE STARTING!
@ -112,6 +113,18 @@ services:
- inventree_data:/var/www
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:
# NOTE: Change /path/to/data to a directory on your local machine
# Persistent data, stored external to the container(s)

View File

@ -224,7 +224,7 @@ def update(c):
def style(c):
"""Run PEP style checks against InvenTree sourcecode"""
print("Running PEP style checks...")
c.run('flake8 InvenTree')
c.run('flake8 InvenTree tasks.py')
@task
@ -282,9 +282,30 @@ def content_excludes():
return output
@task(help={'filename': "Output filename (default = 'data.json')"})
def export_records(c, filename='data.json'):
"""Export all database records to a file"""
@task(help={
'filename': "Output filename (default = 'data.json')",
'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
if not os.path.isabs(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}'")
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 = str(response).strip().lower()
@ -313,16 +334,17 @@ def export_records(c, filename='data.json'):
with open(tmpfile, "r") as f_in:
data = json.loads(f_in.read())
for entry in data:
if "model" in entry:
if include_permissions is False:
for entry in data:
if "model" in entry:
# Clear out any permissions specified for a group
if entry["model"] == "auth.group":
entry["fields"]["permissions"] = []
# Clear out any permissions specified for a group
if entry["model"] == "auth.group":
entry["fields"]["permissions"] = []
# Clear out any permissions specified for a user
if entry["model"] == "auth.user":
entry["fields"]["user_permissions"] = []
# Clear out any permissions specified for a user
if entry["model"] == "auth.user":
entry["fields"]["user_permissions"] = []
# Write the processed data to file
with open(filename, "w") as f_out:
@ -330,6 +352,10 @@ def export_records(c, filename='data.json'):
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])
def import_records(c, filename='data.json', clear=False):