Merge branch 'master' into scheduling

This commit is contained in:
Oliver 2022-03-01 16:50:57 +11:00
commit a278e52443
49 changed files with 999 additions and 215 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
@ -169,9 +168,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

@ -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

@ -443,12 +443,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):
"""
@ -462,6 +462,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
@ -1212,6 +1239,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

@ -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

@ -969,7 +969,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 %}',
@ -1082,7 +1082,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

@ -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

@ -63,6 +63,43 @@ class StockLocation(InvenTreeTree):
help_text=_('Select Owner'),
related_name='stock_locations')
def get_location_owner(self):
"""
Get the closest "owner" for this location.
Start at this location, and traverse "up" the location tree until we find an owner
"""
for loc in self.get_ancestors(include_self=True, ascending=True):
if loc.owner is not None:
return loc.owner
return None
def check_ownership(self, user):
"""
Check if the user "owns" (is one of the owners of) the location.
"""
# Superuser accounts automatically "own" everything
if user.is_superuser:
return True
ownership_enabled = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
if not ownership_enabled:
# Location ownership function is not enabled, so return True
return True
owner = self.get_location_owner()
if owner is None:
# No owner set, for this location or any location above
# So, no ownership checks to perform!
return True
return user in owner.get_related_owners(include_group=True)
def get_absolute_url(self):
return reverse('stock-location-detail', kwargs={'pk': self.id})
@ -614,6 +651,48 @@ class StockItem(MPTTModel):
help_text=_('Select Owner'),
related_name='stock_items')
def get_item_owner(self):
"""
Return the closest "owner" for this StockItem.
- If the item has an owner set, return that
- If the item is "in stock", check the StockLocation
- Otherwise, return None
"""
if self.owner is not None:
return self.owner
if self.in_stock and self.location is not None:
loc_owner = self.location.get_location_owner()
if loc_owner:
return loc_owner
return None
def check_ownership(self, user):
"""
Check if the user "owns" (or is one of the owners of) the item
"""
# Superuser accounts automatically "own" everything
if user.is_superuser:
return True
ownership_enabled = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
if not ownership_enabled:
# Location ownership function is not enabled, so return True
return True
owner = self.get_item_owner()
if owner is None:
return True
return user in owner.get_related_owners(include_group=True)
def is_stale(self):
"""
Returns True if this Stock item is "stale".

View File

@ -18,18 +18,11 @@
<h4>{% trans "Stock Tracking Information" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
{% if owner_control.value == "True" %}
{% authorized_owners item.owner as owners %}
{% endif %}
<!-- Check permissions and owner -->
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners %}
{% if roles.stock.change and not item.is_building %}
{% if user_owns_item and roles.stock.change and not item.is_building %}
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>
<span class='fas fa-plus-circle'></span> {% trans "New Entry" %}
</button>
{% endif %}
{% endif %}
</div>
</div>
</div>

View File

@ -59,14 +59,7 @@
</ul>
</div>
<!-- Stock adjustment menu -->
<!-- Check permissions and owner -->
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
{% if owner_control.value == "True" %}
{% authorized_owners item.owner as owners %}
{% endif %}
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}
{% if user_owns_item %}
{% if roles.stock.change and not item.is_building %}
<div class='btn-group'>
<button id='stock-actions' title='{% trans "Stock adjustment actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
@ -194,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 %}
@ -212,31 +205,15 @@
<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 %}
</tr>
</table>
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
{% if owner_control.value == "True" %}
{% authorized_owners item.owner as owners %}
{% endif %}
<div class='info-messages'>
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
{% if owner_control.value == "True" %}
{% authorized_owners item.owner as owners %}
{% if not user in owners and not user.is_superuser %}
<div class='alert alert-block alert-info'>
{% trans "You are not in the list of owners of this item. This stock item cannot be edited." %}<br>
</div>
{% endif %}
{% endif %}
{% if item.is_building %}
<div class='alert alert-block alert-info'>
{% trans "This stock item is in production and cannot be edited." %}<br>
@ -419,11 +396,18 @@
</td>
</tr>
{% endif %}
{% if item.owner %}
{% if ownership_enabled and item_owner %}
<tr>
<td><span class='fas fa-users'></span></td>
<td>{% trans "Owner" %}</td>
<td>{{ item.owner }}</td>
<td>
{{ item_owner }}
{% if not user_owns_item %}
<span class='badge rounded-pill bg-warning badge-right' title='{% trans "You are not in the list of owners of this item. This stock item cannot be edited." %}'>
{% trans "Read only" %}
</span>
{% endif %}
</td>
</tr>
{% endif %}
</table>

View File

@ -20,6 +20,7 @@
{% endblock %}
{% block actions %}
<!-- Admin view -->
{% if location and user.is_staff and roles.stock_location.change %}
{% url 'admin:stock_stocklocation_change' location.pk as url %}
@ -38,7 +39,7 @@
</ul>
</div>
<!-- Check permissions and owner -->
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}
{% if user_owns_location %}
{% if roles.stock.change %}
<div class='btn-group' role='group'>
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
@ -74,13 +75,11 @@
{% endif %}
{% endif %}
{% endif %}
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser or not location %}
{% if roles.stock_location.add %}
{% if user_owns_location and roles.stock_location.add %}
<button class='btn btn-success' id='location-create' type='button' title='{% trans "Create new stock location" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Location" %}
</button>
{% endif %}
{% endif %}
{% endblock %}
{% block details_left %}
@ -106,23 +105,23 @@
<td><em>{% trans "Top level stock location" %}</em></td>
</tr>
{% endif %}
{% if ownership_enabled and location_owner %}
<tr>
<td><span class='fas fa-users'></span></td>
<td>{% trans "Location Owner" %}</td>
<td>
{{ location_owner }}
{% if not user_owns_location %}
<span class='badge rounded-pill bg-warning badge-right' title='{% trans "You are not in the list of owners of this location. This stock location cannot be edited." %}'>
{% trans "Read only" %}
</span>
{% endif %}
</td>
</tr>
{% endif %}
</table>
{% endblock details_left %}
{% block details_below %}
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
{% if owner_control.value == "True" %}
{% authorized_owners location.owner as owners %}
{% if location and not user in owners and not user.is_superuser %}
<div class='alert alert-block alert-info'>
{% trans "You are not in the list of owners of this location. This stock location cannot be edited." %}<br>
</div>
{% endif %}
{% endif %}
{% endblock details_below %}
{% block details_right %}
{% if location %}
<table class='table table-striped table-condensed'>

View File

@ -63,6 +63,11 @@ class StockIndex(InvenTreeRoleMixin, ListView):
context['loc_count'] = StockLocation.objects.count()
context['stock_count'] = StockItem.objects.count()
# No 'ownership' checks are necessary for the top-level StockLocation view
context['user_owns_location'] = True
context['location_owner'] = None
context['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
return context
@ -76,6 +81,16 @@ class StockLocationDetail(InvenTreeRoleMixin, DetailView):
queryset = StockLocation.objects.all()
model = StockLocation
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
context['location_owner'] = context['location'].get_location_owner()
context['user_owns_location'] = context['location'].check_ownership(self.request.user)
return context
class StockItemDetail(InvenTreeRoleMixin, DetailView):
"""
@ -126,6 +141,10 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
# We only support integer serial number progression
pass
data['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
data['item_owner'] = self.object.get_item_owner()
data['user_owns_item'] = self.object.check_ownership(self.request.user)
return data
def get(self, request, *args, **kwargs):

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
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;
// Reset the status of the "submit" button
if (options.modal) {
$(options.modal).find('#modal-form-submit').prop('disabled', false);
}
// 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;
}
}
}
@ -989,6 +1052,8 @@ function clearFormErrors(options={}) {
// 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) {
@ -2001,6 +2066,10 @@ function constructField(name, parameters, options) {
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

@ -108,7 +108,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

@ -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 %}

View File

@ -37,6 +37,9 @@ InvenTree is supported by a [companion mobile app](https://inventree.readthedocs
- [**Download InvenTree from the Apple App Store**](https://apps.apple.com/au/app/inventree/id1581731101#?platform=iphone)
# Deploy to DigitalOcean
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue-ghost.svg)](https://marketplace.digitalocean.com/apps/inventree?refcode=d6172576d014)
# Documentation
For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/).