Merge branch 'inventree:master' into matmair/issue2279

This commit is contained in:
Matthias Mair 2022-03-01 20:14:25 +01:00 committed by GitHub
commit 56e6783556
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 897 additions and 181 deletions

View File

@ -407,14 +407,16 @@ def DownloadFile(data, filename, content_type='application/text', inline=False):
def extract_serial_numbers(serials, expected_quantity, next_number: int):
""" Attempt to extract serial numbers from an input string.
- Serial numbers must be integer values
- Serial numbers must be positive
- Serial numbers can be split by whitespace / newline / commma chars
- Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20
- Serial numbers can be defined as ~ for getting the next available serial number
- Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start>
- Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start>
"""
Attempt to extract serial numbers from an input string:
Requirements:
- Serial numbers can be either strings, or integers
- Serial numbers can be split by whitespace / newline / commma chars
- Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20
- Serial numbers can be defined as ~ for getting the next available serial number
- Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start>
- Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start>
Args:
serials: input string with patterns
@ -428,17 +430,18 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
if '~' in serials:
serials = serials.replace('~', str(next_number))
# Split input string by whitespace or comma (,) characters
groups = re.split("[\s,]+", serials)
numbers = []
errors = []
# helpers
def number_add(n):
if n in numbers:
errors.append(_('Duplicate serial: {n}').format(n=n))
# Helper function to check for duplicated numbers
def add_sn(sn):
if sn in numbers:
errors.append(_('Duplicate serial: {sn}').format(sn=sn))
else:
numbers.append(n)
numbers.append(sn)
try:
expected_quantity = int(expected_quantity)
@ -466,7 +469,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
if a < b:
for n in range(a, b + 1):
number_add(n)
add_sn(n)
else:
errors.append(_("Invalid group: {g}").format(g=group))
@ -495,21 +498,20 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
end = start + expected_quantity
for n in range(start, end):
number_add(n)
add_sn(n)
# no case
else:
errors.append(_("Invalid group: {g}").format(g=group))
# Group should be a number
# At this point, we assume that the "group" is just a single serial value
elif group:
# try conversion
try:
number = int(group)
except:
# seem like it is not a number
raise ValidationError(_(f"Invalid group {group}"))
number_add(number)
try:
# First attempt to add as an integer value
add_sn(int(group))
except (ValueError):
# As a backup, add as a string value
add_sn(group)
# No valid input group detected
else:

View File

@ -309,6 +309,14 @@ if DEBUG and CONFIG.get('debug_toolbar', False): # pragma: no cover
INSTALLED_APPS.append('debug_toolbar')
MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware')
# InvenTree URL configuration
# Base URL for admin pages (default="admin")
INVENTREE_ADMIN_URL = get_setting(
'INVENTREE_ADMIN_URL',
CONFIG.get('admin_url', 'admin'),
)
ROOT_URLCONF = 'InvenTree.urls'
TEMPLATES = [

View File

@ -37,7 +37,6 @@ function showAlertOrCache(message, cache, options={}) {
if (cache) {
addCachedAlert(message, options);
} else {
showMessage(message, options);
}
}
@ -82,6 +81,8 @@ function showMessage(message, options={}) {
var timeout = options.timeout || 5000;
var target = options.target || $('#alerts');
var details = '';
if (options.details) {
@ -111,7 +112,7 @@ function showMessage(message, options={}) {
</div>
`;
$('#alerts').append(html);
target.append(html);
// Remove the alert automatically after a specified period of time
$(`#alert-${id}`).delay(timeout).slideUp(200, function() {

View File

@ -14,6 +14,8 @@ from django_q.monitor import Stat
from django.conf import settings
import InvenTree.ready
logger = logging.getLogger("inventree")
@ -56,6 +58,12 @@ def is_email_configured():
configured = True
if InvenTree.ready.isInTestMode():
return False
if InvenTree.ready.isImportingData():
return False
if not settings.EMAIL_HOST:
configured = False
@ -89,6 +97,14 @@ def check_system_health(**kwargs):
result = True
if InvenTree.ready.isInTestMode():
# Do not perform further checks if we are running unit tests
return False
if InvenTree.ready.isImportingData():
# Do not perform further checks if we are importing data
return False
if not is_worker_running(**kwargs): # pragma: no cover
result = False
logger.warning(_("Background worker check failed"))

View File

@ -4,7 +4,6 @@ Top-level URL lookup for InvenTree application.
Passes URL lookup downstream to each app as required.
"""
from django.conf.urls import url, include
from django.urls import path
from django.contrib import admin
@ -178,9 +177,9 @@ frontendpatterns = [
url(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
# admin sites
url(r'^admin/error_log/', include('error_report.urls')),
url(r'^admin/shell/', include('django_admin_shell.urls')),
url(r'^admin/', admin.site.urls, name='inventree-admin'),
url(f'^{settings.INVENTREE_ADMIN_URL}/error_log/', include('error_report.urls')),
url(f'^{settings.INVENTREE_ADMIN_URL}/shell/', include('django_admin_shell.urls')),
url(f'^{settings.INVENTREE_ADMIN_URL}/', admin.site.urls, name='inventree-admin'),
# DB user sessions
url(r'^accounts/sessions/other/delete/$', view=CustomSessionDeleteOtherView.as_view(), name='session_delete_other', ),

View File

@ -12,11 +12,14 @@ import common.models
INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 26
INVENTREE_API_VERSION = 27
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v27 -> 2022-02-28
- Adds target_date field to individual line items for purchase orders and sales orders
v26 -> 2022-02-17
- Adds API endpoint for uploading a BOM file and extracting data

View File

@ -383,9 +383,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
Returns the BOM items for the part referenced by this BuildOrder
"""
return self.part.bom_items.all().prefetch_related(
'sub_part'
)
return self.part.get_bom_items()
@property
def tracked_bom_items(self):

View File

@ -151,7 +151,7 @@ src="{% static 'img/blank_image.png' %}"
{% trans "Target Date" %}
</td>
<td>
{{ build.target_date }}
{% render_date build.target_date %}
{% if build.is_overdue %}
<span title='{% blocktrans with target=build.target_date %}This build was due on {{target}}{% endblocktrans %}' class='badge badge-right rounded-pill bg-danger'>{% trans "Overdue" %}</span>
{% endif %}

View File

@ -18,7 +18,7 @@
<div class='panel-content'>
<div class='row'>
<div class='col-sm-6'>
<table class='table table-striped'>
<table class='table table-striped table-condensed'>
<col width='25'>
<tr>
<td><span class='fas fa-info'></span></td>
@ -120,19 +120,19 @@
</table>
</div>
<div class='col-sm-6'>
<table class='table table-striped'>
<table class='table table-striped table-condensed'>
<col width='25'>
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{{ build.creation_date }}</td>
<td>{% render_date build.creation_date %}</td>
</tr>
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Target Date" %}</td>
{% if build.target_date %}
<td>
{{ build.target_date }}{% if build.is_overdue %} <span class='fas fa-calendar-times icon-red'></span>{% endif %}
{% render_date build.target_date %}{% if build.is_overdue %} <span class='fas fa-calendar-times icon-red'></span>{% endif %}
</td>
{% else %}
<td><em>{% trans "No target date set" %}</em></td>
@ -142,7 +142,7 @@
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Completed" %}</td>
{% if build.completion_date %}
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge badge-right rounded-pill bg-dark'>{{ build.completed_by }}</span>{% endif %}</td>
<td>{% render_date build.completion_date %}{% if build.completed_by %}<span class='badge badge-right rounded-pill bg-dark'>{{ build.completed_by }}</span>{% endif %}</td>
{% else %}
<td><em>{% trans "Build not complete" %}</em></td>
{% endif %}

View File

@ -448,12 +448,12 @@ class BaseInvenTreeSetting(models.Model):
except self.DoesNotExist:
pass
def choices(self):
def choices(self, **kwargs):
"""
Return the available choices for this setting (or None if no choices are defined)
"""
return self.__class__.get_setting_choices(self.key)
return self.__class__.get_setting_choices(self.key, **kwargs)
def valid_options(self):
"""
@ -467,6 +467,33 @@ class BaseInvenTreeSetting(models.Model):
return [opt[0] for opt in choices]
def is_choice(self, **kwargs):
"""
Check if this setting is a "choice" field
"""
return self.__class__.get_setting_choices(self.key, **kwargs) is not None
def as_choice(self, **kwargs):
"""
Render this setting as the "display" value of a choice field,
e.g. if the choices are:
[('A4', 'A4 paper'), ('A3', 'A3 paper')],
and the value is 'A4',
then display 'A4 paper'
"""
choices = self.get_setting_choices(self.key, **kwargs)
if not choices:
return self.value
for value, display in choices:
if value == self.value:
return display
return self.value
def is_bool(self, **kwargs):
"""
Check if this setting is required to be a boolean value
@ -1217,6 +1244,21 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'DATE_DISPLAY_FORMAT': {
'name': _('Date Format'),
'description': _('Preferred format for displaying dates'),
'default': 'YYYY-MM-DD',
'choices': [
('YYYY-MM-DD', '2022-02-22'),
('YYYY/MM/DD', '2022/22/22'),
('DD-MM-YYYY', '22-02-2022'),
('DD/MM/YYYY', '22/02/2022'),
('MM-DD-YYYY', '02-22-2022'),
('MM/DD/YYYY', '02/22/2022'),
('MMM DD YYYY', 'Feb 22 2022'),
]
}
}
class Meta:

View File

@ -274,7 +274,7 @@ class POLineItemFilter(rest_filters.FilterSet):
model = models.PurchaseOrderLineItem
fields = [
'order',
'part'
'part',
]
pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
@ -391,6 +391,7 @@ class POLineItemList(generics.ListCreateAPIView):
'reference',
'SKU',
'total_price',
'target_date',
]
search_fields = [
@ -401,11 +402,6 @@ class POLineItemList(generics.ListCreateAPIView):
'reference',
]
filter_fields = [
'order',
'part'
]
class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
"""
@ -703,6 +699,7 @@ class SOLineItemList(generics.ListCreateAPIView):
'part__name',
'quantity',
'reference',
'target_date',
]
search_fields = [

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.10 on 2022-02-28 03:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339'),
]
operations = [
migrations.AddField(
model_name='purchaseorderlineitem',
name='target_date',
field=models.DateField(blank=True, help_text='Target shipping date for this line item', null=True, verbose_name='Target Date'),
),
migrations.AddField(
model_name='salesorderlineitem',
name='target_date',
field=models.DateField(blank=True, help_text='Target shipping date for this line item', null=True, verbose_name='Target Date'),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.10 on 2022-02-28 04:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('order', '0062_auto_20220228_0321'),
]
operations = [
migrations.AlterUniqueTogether(
name='purchaseorderlineitem',
unique_together=set(),
),
]

View File

@ -398,12 +398,22 @@ class PurchaseOrder(Order):
return self.lines.count() > 0 and self.pending_line_items().count() == 0
@transaction.atomic
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs):
""" Receive a line item (or partial line item) against this PO
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, **kwargs):
"""
Receive a line item (or partial line item) against this PO
"""
# Extract optional batch code for the new stock item
batch_code = kwargs.get('batch_code', '')
# Extract optional list of serial numbers
serials = kwargs.get('serials', None)
# Extract optional notes field
notes = kwargs.get('notes', '')
barcode = kwargs.get('barcode', '')
# Extract optional barcode field
barcode = kwargs.get('barcode', None)
# Prevent null values for barcode
if barcode is None:
@ -427,33 +437,45 @@ class PurchaseOrder(Order):
# Create a new stock item
if line.part and quantity > 0:
stock = stock_models.StockItem(
part=line.part.part,
supplier_part=line.part,
location=location,
quantity=quantity,
purchase_order=self,
status=status,
purchase_price=line.purchase_price,
uid=barcode
)
stock.save(add_note=False)
# Determine if we should individually serialize the items, or not
if type(serials) is list and len(serials) > 0:
serialize = True
else:
serialize = False
serials = [None]
tracking_info = {
'status': status,
'purchaseorder': self.pk,
}
for sn in serials:
stock.add_tracking_entry(
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
user,
notes=notes,
deltas=tracking_info,
location=location,
purchaseorder=self,
quantity=quantity
)
stock = stock_models.StockItem(
part=line.part.part,
supplier_part=line.part,
location=location,
quantity=1 if serialize else quantity,
purchase_order=self,
status=status,
batch=batch_code,
serial=sn,
purchase_price=line.purchase_price,
uid=barcode
)
stock.save(add_note=False)
tracking_info = {
'status': status,
'purchaseorder': self.pk,
}
stock.add_tracking_entry(
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
user,
notes=notes,
deltas=tracking_info,
location=location,
purchaseorder=self,
quantity=quantity
)
# Update the number of parts received against the particular line item
line.received += quantity
@ -794,9 +816,18 @@ class OrderLineItem(models.Model):
Attributes:
quantity: Number of items
reference: Reference text (e.g. customer reference) for this line item
note: Annotation for the item
target_date: An (optional) date for expected shipment of this line item.
"""
"""
Query filter for determining if an individual line item is "overdue":
- Amount received is less than the required quantity
- Target date is not None
- Target date is in the past
"""
OVERDUE_FILTER = Q(received__lt=F('quantity')) & ~Q(target_date=None) & Q(target_date__lt=datetime.now().date())
class Meta:
abstract = True
@ -813,6 +844,12 @@ class OrderLineItem(models.Model):
notes = models.CharField(max_length=500, blank=True, verbose_name=_('Notes'), help_text=_('Line item notes'))
target_date = models.DateField(
blank=True, null=True,
verbose_name=_('Target Date'),
help_text=_('Target shipping date for this line item'),
)
class PurchaseOrderLineItem(OrderLineItem):
""" Model for a purchase order line item.
@ -824,7 +861,6 @@ class PurchaseOrderLineItem(OrderLineItem):
class Meta:
unique_together = (
('order', 'part', 'quantity', 'purchase_price')
)
@staticmethod

View File

@ -5,12 +5,14 @@ JSON serializers for the Order API
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from decimal import Decimal
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models, transaction
from django.db.models import Case, When, Value
from django.db.models import BooleanField, ExpressionWrapper, F
from django.db.models import BooleanField, ExpressionWrapper, F, Q
from rest_framework import serializers
from rest_framework.serializers import ValidationError
@ -26,7 +28,7 @@ from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.serializers import InvenTreeMoneySerializer
from InvenTree.serializers import ReferenceIndexingSerializerMixin
from InvenTree.status_codes import StockStatus
from InvenTree.status_codes import StockStatus, PurchaseOrderStatus, SalesOrderStatus
import order.models
@ -126,6 +128,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
Add some extra annotations to this queryset:
- Total price = purchase_price * quantity
- "Overdue" status (boolean field)
"""
queryset = queryset.annotate(
@ -135,6 +138,15 @@ class POLineItemSerializer(InvenTreeModelSerializer):
)
)
queryset = queryset.annotate(
overdue=Case(
When(
Q(order__status__in=PurchaseOrderStatus.OPEN) & order.models.OrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField())
),
default=Value(False, output_field=BooleanField()),
)
)
return queryset
def __init__(self, *args, **kwargs):
@ -155,6 +167,8 @@ class POLineItemSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0)
overdue = serializers.BooleanField(required=False, read_only=True)
total_price = serializers.FloatField(read_only=True)
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
@ -185,6 +199,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'notes',
'order',
'order_detail',
'overdue',
'part',
'part_detail',
'supplier_part_detail',
@ -194,6 +209,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'purchase_price_string',
'destination',
'destination_detail',
'target_date',
'total_price',
]
@ -203,6 +219,17 @@ class POLineItemReceiveSerializer(serializers.Serializer):
A serializer for receiving a single purchase order line item against a purchase order
"""
class Meta:
fields = [
'barcode',
'line_item',
'location',
'quantity',
'status',
'batch_code'
'serial_numbers',
]
line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.PurchaseOrderLineItem.objects.all(),
many=False,
@ -241,6 +268,22 @@ class POLineItemReceiveSerializer(serializers.Serializer):
return quantity
batch_code = serializers.CharField(
label=_('Batch Code'),
help_text=_('Enter batch code for incoming stock items'),
required=False,
default='',
allow_blank=True,
)
serial_numbers = serializers.CharField(
label=_('Serial Numbers'),
help_text=_('Enter serial numbers for incoming stock items'),
required=False,
default='',
allow_blank=True,
)
status = serializers.ChoiceField(
choices=list(StockStatus.items()),
default=StockStatus.OK,
@ -270,14 +313,35 @@ class POLineItemReceiveSerializer(serializers.Serializer):
return barcode
class Meta:
fields = [
'barcode',
'line_item',
'location',
'quantity',
'status',
]
def validate(self, data):
data = super().validate(data)
line_item = data['line_item']
quantity = data['quantity']
serial_numbers = data.get('serial_numbers', '').strip()
base_part = line_item.part.part
# Does the quantity need to be "integer" (for trackable parts?)
if base_part.trackable:
if Decimal(quantity) != int(quantity):
raise ValidationError({
'quantity': _('An integer quantity must be provided for trackable parts'),
})
# If serial numbers are provided
if serial_numbers:
try:
# Pass the serial numbers through to the parent serializer once validated
data['serials'] = extract_serial_numbers(serial_numbers, quantity, base_part.getLatestSerialNumberInt())
except DjangoValidationError as e:
raise ValidationError({
'serial_numbers': e.messages,
})
return data
class POReceiveSerializer(serializers.Serializer):
@ -366,6 +430,8 @@ class POReceiveSerializer(serializers.Serializer):
request.user,
status=item['status'],
barcode=item.get('barcode', ''),
batch_code=item.get('batch_code', ''),
serials=item.get('serials', None),
)
except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors
@ -549,6 +615,23 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
class SOLineItemSerializer(InvenTreeModelSerializer):
""" Serializer for a SalesOrderLineItem object """
@staticmethod
def annotate_queryset(queryset):
"""
Add some extra annotations to this queryset:
- "Overdue" status (boolean field)
"""
queryset = queryset.annotate(
overdue=Case(
When(
Q(order__status__in=SalesOrderStatus.OPEN) & order.models.OrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField()),
)
)
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
@ -570,6 +653,8 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
overdue = serializers.BooleanField(required=False, read_only=True)
quantity = InvenTreeDecimalField()
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
@ -599,12 +684,14 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
'notes',
'order',
'order_detail',
'overdue',
'part',
'part_detail',
'sale_price',
'sale_price_currency',
'sale_price_string',
'shipped',
'target_date',
]

View File

@ -141,27 +141,27 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></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>{{ order.issue_date }}</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>{{ order.target_date }}</td>
<td>{% render_date order.target_date %}</td>
</tr>
{% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td>
<td>{% render_date order.complete_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td>
</tr>
{% endif %}
{% if order.responsible %}

View File

@ -174,6 +174,7 @@ $('#new-po-line').click(function() {
value: '{{ order.supplier.currency }}',
{% endif %}
},
target_date: {},
destination: {},
notes: {},
},
@ -210,7 +211,7 @@ $('#new-po-line').click(function() {
loadPurchaseOrderLineItemTable('#po-line-table', {
order: {{ order.pk }},
supplier: {{ order.supplier.pk }},
{% if order.status == PurchaseOrderStatus.PENDING %}
{% if roles.purchase_order.change %}
allow_edit: true,
{% else %}
allow_edit: false,

View File

@ -155,13 +155,13 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{{ order.creation_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td>
<td>{% render_date order.creation_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td>
</tr>
{% if order.target_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Target Date" %}</td>
<td>{{ order.target_date }}</td>
<td>{% render_date order.target_date %}</td>
</tr>
{% endif %}
{% if order.shipment_date %}
@ -169,7 +169,7 @@ src="{% static 'img/blank_image.png' %}"
<td><span class='fas fa-truck'></span></td>
<td>{% trans "Completed" %}</td>
<td>
{{ order.shipment_date }}
{% render_date order.shipment_date %}
{% if order.shipped_by %}
<span class='badge badge-right rounded-pill bg-dark'>{{ order.shipped_by }}</span>
{% endif %}

View File

@ -238,6 +238,7 @@
reference: {},
sale_price: {},
sale_price_currency: {},
target_date: {},
notes: {},
},
method: 'POST',

View File

@ -529,6 +529,108 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists())
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists())
def test_batch_code(self):
"""
Test that we can supply a 'batch code' when receiving items
"""
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
data = {
'items': [
{
'line_item': 1,
'quantity': 10,
'batch_code': 'abc-123',
},
{
'line_item': 2,
'quantity': 10,
'batch_code': 'xyz-789',
}
],
'location': 1,
}
n = StockItem.objects.count()
self.post(
self.url,
data,
expected_code=201,
)
# Check that two new stock items have been created!
self.assertEqual(n + 2, StockItem.objects.count())
item_1 = StockItem.objects.filter(supplier_part=line_1.part).first()
item_2 = StockItem.objects.filter(supplier_part=line_2.part).first()
self.assertEqual(item_1.batch, 'abc-123')
self.assertEqual(item_2.batch, 'xyz-789')
def test_serial_numbers(self):
"""
Test that we can supply a 'serial number' when receiving items
"""
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
data = {
'items': [
{
'line_item': 1,
'quantity': 10,
'batch_code': 'abc-123',
'serial_numbers': '100+',
},
{
'line_item': 2,
'quantity': 10,
'batch_code': 'xyz-789',
}
],
'location': 1,
}
n = StockItem.objects.count()
self.post(
self.url,
data,
expected_code=201,
)
# Check that the expected number of stock items has been created
self.assertEqual(n + 11, StockItem.objects.count())
# 10 serialized stock items created for the first line item
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 10)
# Check that the correct serial numbers have been allocated
for i in range(100, 110):
item = StockItem.objects.get(serial_int=i)
self.assertEqual(item.serial, str(i))
self.assertEqual(item.quantity, 1)
self.assertEqual(item.batch, 'abc-123')
# A single stock item (quantity 10) created for the second line item
items = StockItem.objects.filter(supplier_part=line_2.part)
self.assertEqual(items.count(), 1)
item = items.first()
self.assertEqual(item.quantity, 10)
self.assertEqual(item.batch, 'xyz-789')
class SalesOrderTest(OrderTest):
"""

View File

@ -1453,7 +1453,9 @@ class Part(MPTTModel):
By default, will include inherited BOM items
"""
return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited))
queryset = BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited))
return queryset.prefetch_related('sub_part')
def get_installed_part_options(self, include_inherited=True, include_variants=True):
"""

View File

@ -313,6 +313,10 @@
fields: fields,
groups: partGroups(),
title: '{% trans "Create Part" %}',
reloadFormAfterSuccess: true,
persist: true,
persistMessage: '{% trans "Create another part after this one" %}',
successMessage: '{% trans "Part created successfully" %}',
onSuccess: function(data) {
// Follow the new part
location.href = `/part/${data.pk}/`;

View File

@ -122,7 +122,13 @@
<h4>{% trans "Sales Order Allocations" %}</h4>
</div>
<div class='panel-content'>
<table class='table table-striped table-condensed' id='sales-order-allocation-table'></table>
<div id='sales-order-allocation-button-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="salesorderallocation" %}
</div>
</div>
<table class='table table-striped table-condensed' id='sales-order-allocation-table' data-toolbar='#sales-order-allocation-button-toolbar'></table>
</div>
</div>
@ -342,7 +348,12 @@
<h4>{% trans "Build Order Allocations" %}</h4>
</div>
<div class='panel-content'>
<table class='table table-striped table-condensed' id='build-order-allocation-table'></table>
<div id='build-allocation-button-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="buildorderallocation" %}
</div>
</div>
<table class='table table-striped table-condensed' id='build-order-allocation-table' data-toolbar='#build-allocation-button-toolbar'></table>
</div>
</div>
@ -722,6 +733,7 @@
});
// Load the BOM table data in the pricing view
{% if part.has_bom and roles.sales_order.view %}
loadBomTable($("#bom-pricing-table"), {
editable: false,
bom_url: "{% url 'api-bom-list' %}",
@ -729,6 +741,7 @@
parent_id: {{ part.id }} ,
sub_part_detail: true,
});
{% endif %}
onPanelLoad("purchase-orders", function() {
loadPartPurchaseOrderTable(
@ -952,7 +965,7 @@
{% if price_history %}
var purchasepricedata = {
labels: [
{% for line in price_history %}'{{ line.date }}',{% endfor %}
{% for line in price_history %}'{% render_date line.date %}',{% endfor %}
],
datasets: [{
label: '{% blocktrans %}Purchase Unit Price - {{currency}}{% endblocktrans %}',
@ -1065,7 +1078,7 @@
{% if sale_history %}
var salepricedata = {
labels: [
{% for line in sale_history %}'{{ line.date }}',{% endfor %}
{% for line in sale_history %}'{% render_date line.date %}',{% endfor %}
],
datasets: [{
label: '{% blocktrans %}Unit Price - {{currency}}{% endblocktrans %}',

View File

@ -59,13 +59,13 @@
<ul class='dropdown-menu'>
<li>
<a class='dropdown-item' href='#' id='part-count'>
<span class='fas fa-clipboard-list'></span>
<span class='fas fa-check-circle icon-green'></span>
{% trans "Count part stock" %}
</a>
</li>
<li>
<a class='dropdown-item' href='#' id='part-move'>
<span class='fas fa-exchange-alt'></span>
<span class='fas fa-exchange-alt icon-blue'></span>
{% trans "Transfer part stock" %}
</a>
</li>
@ -312,7 +312,7 @@
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Creation Date" %}</td>
<td>
{{ part.creation_date }}
{% render_date part.creation_date %}
{% if part.creation_user %}
<span class='badge badge-right rounded-pill bg-dark'>{{ part.creation_user }}</span>
{% endif %}

View File

@ -5,6 +5,7 @@ This module provides template tags for extra functionality,
over and above the built-in Django tags.
"""
from datetime import date
import os
import sys
@ -43,6 +44,52 @@ def define(value, *args, **kwargs):
return value
@register.simple_tag(takes_context=True)
def render_date(context, date_object):
"""
Renders a date according to the preference of the provided user
Note that the user preference is stored using the formatting adopted by moment.js,
which differs from the python formatting!
"""
if date_object is None:
return None
if type(date_object) == str:
# If a string is passed, first convert it to a datetime
date_object = date.fromisoformat(date_object)
# We may have already pre-cached the date format by calling this already!
user_date_format = context.get('user_date_format', None)
if user_date_format is None:
user = context.get('user', None)
if user:
# User is specified - look for their date display preference
user_date_format = InvenTreeUserSetting.get_setting('DATE_DISPLAY_FORMAT', user=user)
else:
user_date_format = 'YYYY-MM-DD'
# Convert the format string to Pythonic equivalent
replacements = [
('YYYY', '%Y'),
('MMM', '%b'),
('MM', '%m'),
('DD', '%d'),
]
for o, n in replacements:
user_date_format = user_date_format.replace(o, n)
# Update the context cache
context['user_date_format'] = user_date_format
return date_object.strftime(user_date_format)
@register.simple_tag()
def decimal(x, *args, **kwargs):
""" Simplified rendering of a decimal number """

View File

@ -5,11 +5,13 @@ import logging
from django.apps import AppConfig
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from maintenance_mode.core import set_maintenance_mode
from InvenTree.ready import isImportingData
from plugin import registry
from plugin.helpers import check_git_version, log_error
logger = logging.getLogger('inventree')
@ -34,3 +36,8 @@ class PluginAppConfig(AppConfig):
# drop out of maintenance
# makes sure we did not have an error in reloading and maintenance is still active
set_maintenance_mode(False)
# check git version
registry.git_is_modern = check_git_version()
if not registry.git_is_modern: # pragma: no cover # simulating old git seems not worth it for coverage
log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load')

View File

@ -94,21 +94,46 @@ def get_git_log(path):
"""
Get dict with info of the last commit to file named in path
"""
path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
from plugin import registry
output = None
try:
output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
if output:
output = output.split('\n')
except subprocess.CalledProcessError: # pragma: no cover
pass
if registry.git_is_modern:
path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
try:
output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
if output:
output = output.split('\n')
except subprocess.CalledProcessError: # pragma: no cover
pass
if not output:
output = 7 * [''] # pragma: no cover
return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
def check_git_version():
"""returns if the current git version supports modern features"""
# get version string
try:
output = str(subprocess.check_output(['git', '--version'], cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')
except subprocess.CalledProcessError: # pragma: no cover
return False
# process version string
try:
version = output[12:-1].split(".")
if len(version) > 1 and version[0] == '2':
if len(version) > 2 and int(version[1]) >= 22:
return True
except ValueError: # pragma: no cover
pass
return False
class GitStatus:
"""
Class for resolving git gpg singing state

View File

@ -52,6 +52,7 @@ class PluginsRegistry:
# flags
self.is_loading = False
self.apps_loading = True # Marks if apps were reloaded yet
self.git_is_modern = True # Is a modern version of git available
# integration specific
self.installed_apps = [] # Holds all added plugin_paths

View File

@ -120,13 +120,13 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
</tr>
<tr>
<th>{% trans "Issued" %}</th>
<td>{{ build.creation_date }}</td>
<td>{% render_date build.creation_date %}</td>
</tr>
<tr>
<th>{% trans "Target Date" %}</th>
<td>
{% if build.target_date %}
{{ build.target_date }}
{% render_date build.target_date %}
{% else %}
<em>Not specified</em>
{% endif %}

View File

@ -909,7 +909,6 @@ class StockItem(MPTTModel):
""" Can this stock item be deleted? It can NOT be deleted under the following circumstances:
- Has installed stock items
- Has a serial number and is tracked
- Is installed inside another StockItem
- It has been assigned to a SalesOrder
- It has been assigned to a BuildOrder
@ -918,9 +917,6 @@ class StockItem(MPTTModel):
if self.installed_item_count() > 0:
return False
if self.part.trackable and self.serial is not None:
return False
if self.sales_order is not None:
return False

View File

@ -66,7 +66,7 @@
<ul class='dropdown-menu' role='menu'>
{% if not item.serialized %}
{% if item.in_stock %}
<li><a class='dropdown-item' href='#' id='stock-count' title='{% trans "Count stock" %}'><span class='fas fa-clipboard-list'></span> {% trans "Count stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='stock-count' title='{% trans "Count stock" %}'><span class='fas fa-check-circle icon-green'></span> {% trans "Count stock" %}</a></li>
{% endif %}
{% if not item.customer %}
<li><a class='dropdown-item' href='#' id='stock-add' title='{% trans "Add stock" %}'><span class='fas fa-plus-circle icon-green'></span> {% trans "Add stock" %}</a></li>
@ -187,7 +187,7 @@
<td><span class='fas fa-calendar-alt{% if item.is_expired %} icon-red{% endif %}'></span></td>
<td>{% trans "Expiry Date" %}</td>
<td>
{{ item.expiry_date }}
{% render_date item.expiry_date %}
{% if item.is_expired %}
<span title='{% blocktrans %}This StockItem expired on {{ item.expiry_date }}{% endblocktrans %}' class='badge rounded-pill bg-danger badge-right'>{% trans "Expired" %}</span>
{% elif item.is_stale %}
@ -205,7 +205,7 @@
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Last Stocktake" %}</td>
{% if item.stocktake_date %}
<td>{{ item.stocktake_date }} <span class='badge badge-right rounded-pill bg-dark'>{{ item.stocktake_user }}</span></td>
<td>{% render_date item.stocktake_date %} <span class='badge badge-right rounded-pill bg-dark'>{{ item.stocktake_user }}</span></td>
{% else %}
<td><em>{% trans "No stocktake performed" %}</em></td>
{% endif %}

View File

@ -81,7 +81,7 @@
{% endif %}
</td>
<td>{{ plugin.author }}</td>
<td>{{ plugin.pub_date }}</td>
<td>{% render_date plugin.pub_date %}</td>
<td>{% if plugin.version %}{{ plugin.version }}{% endif %}</td>
</tr>
{% endfor %}

View File

@ -36,7 +36,7 @@
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Date" %}</td>
<td>{{ plugin.pub_date }}{% include "clip.html" %}</td>
<td>{% render_date plugin.pub_date %}{% include "clip.html" %}</td>
</tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
@ -101,7 +101,7 @@
</tr>
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Commit Date" %}</td><td>{{ plugin.package.date }}{% include "clip.html" %}</td>
<td>{% trans "Commit Date" %}</td><td>{% render_date plugin.package.date %}{% include "clip.html" %}</td>
</tr>
<tr>
<td><span class='fas fa-code-branch'></span></td>

View File

@ -28,7 +28,11 @@
<div id='setting-{{ setting.pk }}'>
<span id='setting-value-{{ setting.key.upper }}' fieldname='{{ setting.key.upper }}'>
{% if setting.value %}
{% if setting.is_choice %}
<strong>{{ setting.as_choice }}</strong>
{% else %}
<strong>{{ setting.value }}</strong>
{% endif %}
{% else %}
<em style='color: #855;'>{% trans "No value set" %}</em>
{% endif %}

View File

@ -15,6 +15,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="STICKY_HEADER" icon="fa-bars" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="DATE_DISPLAY_FORMAT" icon="fa-calendar-alt" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="FORMS_CLOSE_USING_ESCAPE" icon="fa-window-close" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %}
</tbody>

View File

@ -44,7 +44,7 @@
{% if commit_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Commit Date" %}</td><td>{{ commit_date }}{% include "clip.html" %}</td>
<td>{% trans "Commit Date" %}</td><td>{% render_date commit_date %}{% include "clip.html" %}</td>
</tr>
{% endif %}
{% endif %}

View File

@ -7,6 +7,7 @@
clearEvents,
endDate,
startDate,
renderDate,
*/
/**
@ -32,3 +33,33 @@ function clearEvents(calendar) {
event.remove();
});
}
/*
* Render the provided date in the user-specified format.
*
* The provided "date" variable is a string, nominally ISO format e.g. 2022-02-22
* The user-configured setting DATE_DISPLAY_FORMAT determines how the date should be displayed.
*/
function renderDate(date, options={}) {
if (!date) {
return null;
}
var fmt = user_settings.DATE_DISPLAY_FORMAT || 'YYYY-MM-DD';
if (options.showTime) {
fmt += ' HH:mm';
}
var m = moment(date);
if (m.isValid()) {
return m.format(fmt);
} else {
// Invalid input string, simply return provided value
return date;
}
}

View File

@ -40,12 +40,15 @@ function editSetting(pk, options={}) {
url = `/api/settings/user/${pk}/`;
}
var reload_required = false;
// First, read the settings object from the server
inventreeGet(url, {}, {
success: function(response) {
if (response.choices && response.choices.length > 0) {
response.type = 'choice';
reload_required = true;
}
// Construct the field
@ -89,7 +92,9 @@ function editSetting(pk, options={}) {
var setting = response.key;
if (response.type == 'boolean') {
if (reload_required) {
location.reload();
} else if (response.type == 'boolean') {
var enabled = response.value.toString().toLowerCase() == 'true';
$(`#setting-value-${setting}`).prop('checked', enabled);
} else {

View File

@ -165,6 +165,9 @@ function loadAttachmentTable(url, options) {
{
field: 'upload_date',
title: '{% trans "Upload Date" %}',
formatter: function(value) {
return renderDate(value);
}
},
{
field: 'actions',

View File

@ -1961,6 +1961,9 @@ function loadBuildTable(table, options) {
field: 'creation_date',
title: '{% trans "Created" %}',
sortable: true,
formatter: function(value) {
return renderDate(value);
}
},
{
field: 'issued_by',
@ -1990,11 +1993,17 @@ function loadBuildTable(table, options) {
field: 'target_date',
title: '{% trans "Target Date" %}',
sortable: true,
formatter: function(value) {
return renderDate(value);
}
},
{
field: 'completion_date',
title: '{% trans "Completion Date" %}',
sortable: true,
formatter: function(value) {
return renderDate(value);
}
},
],
});

View File

@ -542,6 +542,11 @@ function constructFormBody(fields, options) {
insertConfirmButton(options);
}
// Insert "persist" button (if required)
if (options.persist) {
insertPersistButton(options);
}
// Display the modal
$(modal).modal('show');
@ -616,6 +621,22 @@ function insertConfirmButton(options) {
}
/* Add a checkbox to select if the modal will stay open after success */
function insertPersistButton(options) {
var message = options.persistMessage || '{% trans "Keep this form open" %}';
var html = `
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="modal-persist">
<label class="form-check-label" for="modal-persist">${message}</label>
</div>
`;
$(options.modal).find('#modal-footer-buttons').append(html);
}
/*
* Extract all specified form values as a single object
*/
@ -934,19 +955,40 @@ function getFormFieldValue(name, field={}, options={}) {
*/
function handleFormSuccess(response, options) {
// Close the modal
if (!options.preventClose) {
// Note: The modal will be deleted automatically after closing
$(options.modal).modal('hide');
}
// Display any required messages
// Should we show alerts immediately or cache them?
var cache = (options.follow && response.url) || options.redirect || options.reload;
// Should the form "persist"?
var persist = false;
if (options.persist && options.modal) {
// Determine if this form should "persist", or be dismissed?
var chk = $(options.modal).find('#modal-persist');
persist = chk.exists() && chk.prop('checked');
}
if (persist) {
cache = false;
}
var msg_target = null;
if (persist) {
// If the modal is persistant, the target for any messages should be the modal!
msg_target = $(options.modal).find('#pre-form-content');
}
// Display any messages
if (response && (response.success || options.successMessage)) {
showAlertOrCache(response.success || options.successMessage, cache, {style: 'success'});
showAlertOrCache(
response.success || options.successMessage,
cache,
{
style: 'success',
target: msg_target,
});
}
if (response && response.info) {
@ -961,20 +1003,41 @@ function handleFormSuccess(response, options) {
showAlertOrCache(response.danger, cache, {style: 'danger'});
}
if (options.onSuccess) {
// Callback function
options.onSuccess(response, options);
}
if (persist) {
// Instead of closing the form and going somewhere else,
// reload (empty) the form so the user can input more data
// Reset the status of the "submit" button
if (options.modal) {
$(options.modal).find('#modal-form-submit').prop('disabled', false);
}
if (options.follow && response.url) {
// Follow the returned URL
window.location.href = response.url;
} else if (options.reload) {
// Reload the current page
location.reload();
} else if (options.redirect) {
// Redirect to a specified URL
window.location.href = options.redirect;
// Remove any error flags from the form
clearFormErrors(options);
} else {
// Close the modal
if (!options.preventClose) {
// Note: The modal will be deleted automatically after closing
$(options.modal).modal('hide');
}
if (options.onSuccess) {
// Callback function
options.onSuccess(response, options);
}
if (options.follow && response.url) {
// Follow the returned URL
window.location.href = response.url;
} else if (options.reload) {
// Reload the current page
location.reload();
} else if (options.redirect) {
// Redirect to a specified URL
window.location.href = options.redirect;
}
}
}
@ -988,6 +1051,8 @@ function clearFormErrors(options={}) {
if (options && options.modal) {
// Remove the individual error messages
$(options.modal).find('.form-error-message').remove();
$(options.modal).find('.modal-content').removeClass('modal-error');
// Remove the "has error" class
$(options.modal).find('.form-field-error').removeClass('form-field-error');
@ -1884,7 +1949,7 @@ function getFieldName(name, options={}) {
* - Field description (help text)
* - Field errors
*/
function constructField(name, parameters, options) {
function constructField(name, parameters, options={}) {
var html = '';
@ -1976,7 +2041,7 @@ function constructField(name, parameters, options) {
html += `<div class='controls'>`;
// Does this input deserve "extra" decorators?
var extra = parameters.prefix != null;
var extra = (parameters.icon != null) || (parameters.prefix != null) || (parameters.prefixRaw != null);
// Some fields can have 'clear' inputs associated with them
if (!parameters.required && !parameters.read_only) {
@ -1998,9 +2063,13 @@ function constructField(name, parameters, options) {
if (extra) {
html += `<div class='input-group'>`;
if (parameters.prefix) {
html += `<span class='input-group-text'>${parameters.prefix}</span>`;
} else if (parameters.prefixRaw) {
html += parameters.prefixRaw;
} else if (parameters.icon) {
html += `<span class='input-group-text'><span class='fas ${parameters.icon}'></span></span>`;
}
}
@ -2147,6 +2216,10 @@ function constructInputOptions(name, classes, type, parameters, options={}) {
opts.push(`type='${type}'`);
if (parameters.title || parameters.help_text) {
opts.push(`title='${parameters.title || parameters.help_text}'`);
}
// Read only?
if (parameters.read_only) {
opts.push(`readonly=''`);
@ -2192,11 +2265,6 @@ function constructInputOptions(name, classes, type, parameters, options={}) {
opts.push(`required=''`);
}
// Custom mouseover title?
if (parameters.title != null) {
opts.push(`title='${parameters.title}'`);
}
// Placeholder?
if (parameters.placeholder != null) {
opts.push(`placeholder='${parameters.placeholder}'`);

View File

@ -116,6 +116,10 @@ function makeIconButton(icon, cls, pk, title, options={}) {
extraProps += `disabled='true' `;
}
if (options.collapseTarget) {
extraProps += `data-bs-toggle='collapse' href='#${options.collapseTarget}'`;
}
html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}' ${extraProps}>`;
html += `<span class='fas ${icon}'></span>`;
html += `</button>`;

View File

@ -476,6 +476,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
quantity = 0;
}
// Prepend toggles to the quantity input
var toggle_batch = `
<span class='input-group-text' title='{% trans "Add batch code" %}' data-bs-toggle='collapse' href='#div-batch-${pk}'>
<span class='fas fa-layer-group'></span>
</span>
`;
var toggle_serials = `
<span class='input-group-text' title='{% trans "Add serial numbers" %}' data-bs-toggle='collapse' href='#div-serials-${pk}'>
<span class='fas fa-hashtag'></span>
</span>
`;
// Quantity to Receive
var quantity_input = constructField(
`items_quantity_${pk}`,
@ -491,6 +504,36 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
}
);
// Add in options for "batch code" and "serial numbers"
var batch_input = constructField(
`items_batch_code_${pk}`,
{
type: 'string',
required: false,
label: '{% trans "Batch Code" %}',
help_text: '{% trans "Enter batch code for incoming stock items" %}',
prefixRaw: toggle_batch,
}
);
var sn_input = constructField(
`items_serial_numbers_${pk}`,
{
type: 'string',
required: false,
label: '{% trans "Serial Numbers" %}',
help_text: '{% trans "Enter serial numbers for incoming stock items" %}',
prefixRaw: toggle_serials,
}
);
// Hidden inputs below the "quantity" field
var quantity_input_group = `${quantity_input}<div class='collapse' id='div-batch-${pk}'>${batch_input}</div>`;
if (line_item.part_detail.trackable) {
quantity_input_group += `<div class='collapse' id='div-serials-${pk}'>${sn_input}</div>`;
}
// Construct list of StockItem status codes
var choices = [];
@ -528,16 +571,38 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
);
// Button to remove the row
var delete_button = `<div class='btn-group float-right' role='group'>`;
var buttons = `<div class='btn-group float-right' role='group'>`;
delete_button += makeIconButton(
buttons += makeIconButton(
'fa-layer-group',
'button-row-add-batch',
pk,
'{% trans "Add batch code" %}',
{
collapseTarget: `div-batch-${pk}`
}
);
if (line_item.part_detail.trackable) {
buttons += makeIconButton(
'fa-hashtag',
'button-row-add-serials',
pk,
'{% trans "Add serial numbers" %}',
{
collapseTarget: `div-serials-${pk}`,
}
);
}
buttons += makeIconButton(
'fa-times icon-red',
'button-row-remove',
pk,
'{% trans "Remove row" %}',
);
delete_button += '</div>';
buttons += '</div>';
var html = `
<tr id='receive_row_${pk}' class='stock-receive-row'>
@ -554,7 +619,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
${line_item.received}
</td>
<td id='quantity_${pk}'>
${quantity_input}
${quantity_input_group}
</td>
<td id='status_${pk}'>
${status_input}
@ -563,7 +628,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
${destination_input}
</td>
<td id='actions_${pk}'>
${delete_button}
${buttons}
</td>
</tr>`;
@ -587,7 +652,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
<th>{% trans "Order Code" %}</th>
<th>{% trans "Ordered" %}</th>
<th>{% trans "Received" %}</th>
<th style='min-width: 50px;'>{% trans "Receive" %}</th>
<th style='min-width: 50px;'>{% trans "Quantity to Receive" %}</th>
<th style='min-width: 150px;'>{% trans "Status" %}</th>
<th style='min-width: 300px;'>{% trans "Destination" %}</th>
<th></th>
@ -678,13 +743,23 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
var location = getFormFieldValue(`items_location_${pk}`, {}, opts);
if (quantity != null) {
data.items.push({
var line = {
line_item: pk,
quantity: quantity,
status: status,
location: location,
});
};
if (getFormFieldElement(`items_batch_code_${pk}`).exists()) {
line.batch_code = getFormFieldValue(`items_batch_code_${pk}`);
}
if (getFormFieldElement(`items_serial_numbers_${pk}`).exists()) {
line.serial_numbers = getFormFieldValue(`items_serial_numbers_${pk}`);
}
data.items.push(line);
item_pk_values.push(pk);
}
@ -848,11 +923,17 @@ function loadPurchaseOrderTable(table, options) {
field: 'creation_date',
title: '{% trans "Date" %}',
sortable: true,
formatter: function(value) {
return renderDate(value);
}
},
{
field: 'target_date',
title: '{% trans "Target Date" %}',
sortable: true,
formatter: function(value) {
return renderDate(value);
}
},
{
field: 'line_items',
@ -930,6 +1011,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
reference: {},
purchase_price: {},
purchase_price_currency: {},
target_date: {},
destination: {},
notes: {},
},
@ -971,7 +1053,11 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
],
{
success: function() {
// Reload the line item table
$(table).bootstrapTable('refresh');
// Reload the "received stock" table
$('#stock-table').bootstrapTable('refresh');
}
}
);
@ -1111,6 +1197,28 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
return formatter.format(total);
}
},
{
sortable: true,
field: 'target_date',
switchable: true,
title: '{% trans "Target Date" %}',
formatter: function(value, row) {
if (row.target_date) {
var html = renderDate(row.target_date);
if (row.overdue) {
html += `<span class='fas fa-calendar-alt icon-red float-right' title='{% trans "This line item is overdue" %}'></span>`;
}
return html;
} else if (row.order_detail && row.order_detail.target_date) {
return `<em>${renderDate(row.order_detail.target_date)}</em>`;
} else {
return '-';
}
}
},
{
sortable: false,
field: 'received',
@ -1157,15 +1265,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
var pk = row.pk;
if (options.allow_receive && row.received < row.quantity) {
html += makeIconButton('fa-sign-in-alt icon-green', 'button-line-receive', pk, '{% trans "Receive line item" %}');
}
if (options.allow_edit) {
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
}
if (options.allow_receive && row.received < row.quantity) {
html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}');
}
html += `</div>`;
return html;
@ -1269,16 +1377,25 @@ function loadSalesOrderTable(table, options) {
sortable: true,
field: 'creation_date',
title: '{% trans "Creation Date" %}',
formatter: function(value) {
return renderDate(value);
}
},
{
sortable: true,
field: 'target_date',
title: '{% trans "Target Date" %}',
formatter: function(value) {
return renderDate(value);
}
},
{
sortable: true,
field: 'shipment_date',
title: '{% trans "Shipment Date" %}',
formatter: function(value) {
return renderDate(value);
}
},
{
sortable: true,
@ -1430,9 +1547,9 @@ function loadSalesOrderShipmentTable(table, options={}) {
sortable: true,
formatter: function(value, row) {
if (value) {
return value;
return renderDate(value);
} else {
return '{% trans "Not shipped" %}';
return '<em>{% trans "Not shipped" %}</em>';
}
}
},
@ -2208,6 +2325,28 @@ function loadSalesOrderLineItemTable(table, options={}) {
return formatter.format(total);
}
},
{
field: 'target_date',
title: '{% trans "Target Date" %}',
sortable: true,
switchable: true,
formatter: function(value, row) {
if (row.target_date) {
var html = renderDate(row.target_date);
if (row.overdue) {
html += `<span class='fas fa-calendar-alt icon-red float-right' title='{% trans "This line item is overdue" %}'></span>`;
}
return html;
} else if (row.order_detail && row.order_detail.target_date) {
return `<em>${renderDate(row.order_detail.target_date)}</em>`;
} else {
return '-';
}
}
}
];
if (pending) {
@ -2351,6 +2490,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
reference: {},
sale_price: {},
sale_price_currency: {},
target_date: {},
notes: {},
},
title: '{% trans "Edit Line Item" %}',

View File

@ -905,6 +905,28 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
field: 'quantity',
title: '{% trans "Quantity" %}',
},
{
field: 'target_date',
title: '{% trans "Target Date" %}',
switchable: true,
sortable: true,
formatter: function(value, row) {
if (row.target_date) {
var html = row.target_date;
if (row.overdue) {
html += `<span class='fas fa-calendar-alt icon-red float-right' title='{% trans "This line item is overdue" %}'></span>`;
}
return html;
} else if (row.order_detail && row.order_detail.target_date) {
return `<em>${row.order_detail.target_date}</em>`;
} else {
return '-';
}
}
},
{
field: 'received',
title: '{% trans "Received" %}',

View File

@ -25,7 +25,6 @@
modalSetContent,
modalSetTitle,
modalSubmit,
moment,
openModal,
printStockItemLabels,
printTestReports,
@ -1820,6 +1819,9 @@ function loadStockTable(table, options) {
col = {
field: 'stocktake_date',
title: '{% trans "Stocktake" %}',
formatter: function(value) {
return renderDate(value);
}
};
if (!options.params.ordering) {
@ -1833,6 +1835,9 @@ function loadStockTable(table, options) {
title: '{% trans "Expiry Date" %}',
visible: global_settings.STOCK_ENABLE_EXPIRY,
switchable: global_settings.STOCK_ENABLE_EXPIRY,
formatter: function(value) {
return renderDate(value);
}
};
if (!options.params.ordering) {
@ -1844,6 +1849,9 @@ function loadStockTable(table, options) {
col = {
field: 'updated',
title: '{% trans "Last Updated" %}',
formatter: function(value) {
return renderDate(value);
}
};
if (!options.params.ordering) {
@ -2649,14 +2657,7 @@ function loadStockTrackingTable(table, options) {
title: '{% trans "Date" %}',
sortable: true,
formatter: function(value) {
var m = moment(value);
if (m.isValid()) {
var html = m.format('dddd MMMM Do YYYY'); // + '<br>' + m.format('h:mm a');
return html;
}
return '<i>{% trans "Invalid date" %}</i>';
return renderDate(value, {showTime: true});
}
});

View File

@ -278,7 +278,7 @@ $.fn.inventreeTable = function(options) {
}
});
} else {
console.log(`Could not get list of visible columns for column '${tableName}'`);
console.log(`Could not get list of visible columns for table '${tableName}'`);
}
}

View File

@ -119,7 +119,7 @@
<ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'>
{% if user.is_authenticated %}
{% if user.is_staff and not demo %}
<li><a class='dropdown-item' href="/admin/"><span class="fas fa-user-shield"></span> {% trans "Admin" %}</a></li>
<li><a class='dropdown-item' href="{% url 'admin:index' %}"><span class="fas fa-user-shield"></span> {% trans "Admin" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href="{% url 'settings' %}"><span class="fas fa-cog"></span> {% trans "Settings" %}</a></li>
<li><a class='dropdown-item' href="{% url 'account_logout' %}"><span class="fas fa-sign-out-alt"></span> {% trans "Logout" %}</a></li>

View File

@ -46,16 +46,16 @@
<ul class="dropdown-menu">
{% if roles.stock.change %}
<li><a class='dropdown-item' href="#" id='multi-item-add' title='{% trans "Add to selected stock items" %}'><span class='fas fa-plus-circle icon-green'></span> {% trans "Add stock" %}</a></li>
<li><a class='dropdown-item' href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li>
<li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
<li><a class='dropdown-item' href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle icon-red'></span> {% trans "Remove stock" %}</a></li>
<li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle icon-green'></span> {% trans "Count stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt icon-blue'></span> {% trans "Transfer stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-merge' title='{% trans "Merge selected stock items" %}'><span class='fas fa-object-group'></span> {% trans "Merge stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>
{% endif %}
{% if roles.stock.delete %}
<li><a class='dropdown-item' href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete stock" %}</a></li>
{% endif %}
</ul>
</div>

View File

@ -2,7 +2,7 @@
InvenTree-Version: {% inventree_version %}
Django Version: {% django_version %}
{% inventree_commit_hash as hash %}{% if hash %}Commit Hash: {{ hash }}{% endif %}
{% inventree_commit_date as commit_date %}{% if commit_date %}Commit Date: {{ commit_date }}{% endif %}
{% inventree_commit_date as commit_date %}{% if commit_date %}Commit Date: {% render_date commit_date %}{% endif %}
Database: {% inventree_db_engine %}
Debug-Mode: {% inventree_in_debug_mode %}
Deployed using Docker: {% inventree_docker_mode %}