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,9 +407,11 @@ def DownloadFile(data, filename, content_type='application/text', inline=False):
def extract_serial_numbers(serials, expected_quantity, next_number: int): 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 Attempt to extract serial numbers from an input string:
- Serial numbers must be positive
Requirements:
- Serial numbers can be either strings, or integers
- Serial numbers can be split by whitespace / newline / commma chars - 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 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 defined as ~ for getting the next available serial number
@ -428,17 +430,18 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
if '~' in serials: if '~' in serials:
serials = serials.replace('~', str(next_number)) serials = serials.replace('~', str(next_number))
# Split input string by whitespace or comma (,) characters
groups = re.split("[\s,]+", serials) groups = re.split("[\s,]+", serials)
numbers = [] numbers = []
errors = [] errors = []
# helpers # Helper function to check for duplicated numbers
def number_add(n): def add_sn(sn):
if n in numbers: if sn in numbers:
errors.append(_('Duplicate serial: {n}').format(n=n)) errors.append(_('Duplicate serial: {sn}').format(sn=sn))
else: else:
numbers.append(n) numbers.append(sn)
try: try:
expected_quantity = int(expected_quantity) expected_quantity = int(expected_quantity)
@ -466,7 +469,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
if a < b: if a < b:
for n in range(a, b + 1): for n in range(a, b + 1):
number_add(n) add_sn(n)
else: else:
errors.append(_("Invalid group: {g}").format(g=group)) 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 end = start + expected_quantity
for n in range(start, end): for n in range(start, end):
number_add(n) add_sn(n)
# no case # no case
else: else:
errors.append(_("Invalid group: {g}").format(g=group)) 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: 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 # No valid input group detected
else: else:

View File

@ -309,6 +309,14 @@ if DEBUG and CONFIG.get('debug_toolbar', False): # pragma: no cover
INSTALLED_APPS.append('debug_toolbar') INSTALLED_APPS.append('debug_toolbar')
MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware') 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' ROOT_URLCONF = 'InvenTree.urls'
TEMPLATES = [ TEMPLATES = [

View File

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

View File

@ -14,6 +14,8 @@ from django_q.monitor import Stat
from django.conf import settings from django.conf import settings
import InvenTree.ready
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@ -56,6 +58,12 @@ def is_email_configured():
configured = True configured = True
if InvenTree.ready.isInTestMode():
return False
if InvenTree.ready.isImportingData():
return False
if not settings.EMAIL_HOST: if not settings.EMAIL_HOST:
configured = False configured = False
@ -89,6 +97,14 @@ def check_system_health(**kwargs):
result = True 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 if not is_worker_running(**kwargs): # pragma: no cover
result = False result = False
logger.warning(_("Background worker check failed")) 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. Passes URL lookup downstream to each app as required.
""" """
from django.conf.urls import url, include from django.conf.urls import url, include
from django.urls import path from django.urls import path
from django.contrib import admin from django.contrib import admin
@ -169,9 +168,9 @@ frontendpatterns = [
url(r'^stats/', DatabaseStatsView.as_view(), name='stats'), url(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
# admin sites # admin sites
url(r'^admin/error_log/', include('error_report.urls')), url(f'^{settings.INVENTREE_ADMIN_URL}/error_log/', include('error_report.urls')),
url(r'^admin/shell/', include('django_admin_shell.urls')), url(f'^{settings.INVENTREE_ADMIN_URL}/shell/', include('django_admin_shell.urls')),
url(r'^admin/', admin.site.urls, name='inventree-admin'), url(f'^{settings.INVENTREE_ADMIN_URL}/', admin.site.urls, name='inventree-admin'),
# DB user sessions # DB user sessions
url(r'^accounts/sessions/other/delete/$', view=CustomSessionDeleteOtherView.as_view(), name='session_delete_other', ), 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_SW_VERSION = "0.7.0 dev"
# InvenTree API version # 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 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 v26 -> 2022-02-17
- Adds API endpoint for uploading a BOM file and extracting data - 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" %} {% trans "Target Date" %}
</td> </td>
<td> <td>
{{ build.target_date }} {% render_date build.target_date %}
{% if build.is_overdue %} {% 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> <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 %} {% endif %}

View File

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

View File

@ -443,12 +443,12 @@ class BaseInvenTreeSetting(models.Model):
except self.DoesNotExist: except self.DoesNotExist:
pass pass
def choices(self): def choices(self, **kwargs):
""" """
Return the available choices for this setting (or None if no choices are defined) 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): def valid_options(self):
""" """
@ -462,6 +462,33 @@ class BaseInvenTreeSetting(models.Model):
return [opt[0] for opt in choices] 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): def is_bool(self, **kwargs):
""" """
Check if this setting is required to be a boolean value Check if this setting is required to be a boolean value
@ -1212,6 +1239,21 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'default': False, 'default': False,
'validator': bool, '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: class Meta:

View File

@ -274,7 +274,7 @@ class POLineItemFilter(rest_filters.FilterSet):
model = models.PurchaseOrderLineItem model = models.PurchaseOrderLineItem
fields = [ fields = [
'order', 'order',
'part' 'part',
] ]
pending = rest_filters.BooleanFilter(label='pending', method='filter_pending') pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
@ -391,6 +391,7 @@ class POLineItemList(generics.ListCreateAPIView):
'reference', 'reference',
'SKU', 'SKU',
'total_price', 'total_price',
'target_date',
] ]
search_fields = [ search_fields = [
@ -401,11 +402,6 @@ class POLineItemList(generics.ListCreateAPIView):
'reference', 'reference',
] ]
filter_fields = [
'order',
'part'
]
class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView): class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
""" """
@ -703,6 +699,7 @@ class SOLineItemList(generics.ListCreateAPIView):
'part__name', 'part__name',
'quantity', 'quantity',
'reference', 'reference',
'target_date',
] ]
search_fields = [ 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 return self.lines.count() > 0 and self.pending_line_items().count() == 0
@transaction.atomic @transaction.atomic
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs): def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, **kwargs):
""" Receive a line item (or partial line item) against this PO """
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', '') notes = kwargs.get('notes', '')
barcode = kwargs.get('barcode', '')
# Extract optional barcode field
barcode = kwargs.get('barcode', None)
# Prevent null values for barcode # Prevent null values for barcode
if barcode is None: if barcode is None:
@ -427,13 +437,25 @@ class PurchaseOrder(Order):
# Create a new stock item # Create a new stock item
if line.part and quantity > 0: if line.part and quantity > 0:
# 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]
for sn in serials:
stock = stock_models.StockItem( stock = stock_models.StockItem(
part=line.part.part, part=line.part.part,
supplier_part=line.part, supplier_part=line.part,
location=location, location=location,
quantity=quantity, quantity=1 if serialize else quantity,
purchase_order=self, purchase_order=self,
status=status, status=status,
batch=batch_code,
serial=sn,
purchase_price=line.purchase_price, purchase_price=line.purchase_price,
uid=barcode uid=barcode
) )
@ -794,9 +816,18 @@ class OrderLineItem(models.Model):
Attributes: Attributes:
quantity: Number of items quantity: Number of items
reference: Reference text (e.g. customer reference) for this line item
note: Annotation for the 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: class Meta:
abstract = True 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')) 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): class PurchaseOrderLineItem(OrderLineItem):
""" Model for a purchase order line item. """ Model for a purchase order line item.
@ -824,7 +861,6 @@ class PurchaseOrderLineItem(OrderLineItem):
class Meta: class Meta:
unique_together = ( unique_together = (
('order', 'part', 'quantity', 'purchase_price')
) )
@staticmethod @staticmethod

View File

@ -5,12 +5,14 @@ JSON serializers for the Order API
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from decimal import Decimal
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Case, When, Value 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 import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
@ -26,7 +28,7 @@ from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import InvenTreeMoneySerializer
from InvenTree.serializers import ReferenceIndexingSerializerMixin from InvenTree.serializers import ReferenceIndexingSerializerMixin
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus, PurchaseOrderStatus, SalesOrderStatus
import order.models import order.models
@ -126,6 +128,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
Add some extra annotations to this queryset: Add some extra annotations to this queryset:
- Total price = purchase_price * quantity - Total price = purchase_price * quantity
- "Overdue" status (boolean field)
""" """
queryset = queryset.annotate( 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 return queryset
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -155,6 +167,8 @@ class POLineItemSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField(default=1) quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0) received = serializers.FloatField(default=0)
overdue = serializers.BooleanField(required=False, read_only=True)
total_price = serializers.FloatField(read_only=True) total_price = serializers.FloatField(read_only=True)
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
@ -185,6 +199,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'notes', 'notes',
'order', 'order',
'order_detail', 'order_detail',
'overdue',
'part', 'part',
'part_detail', 'part_detail',
'supplier_part_detail', 'supplier_part_detail',
@ -194,6 +209,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'purchase_price_string', 'purchase_price_string',
'destination', 'destination',
'destination_detail', 'destination_detail',
'target_date',
'total_price', 'total_price',
] ]
@ -203,6 +219,17 @@ class POLineItemReceiveSerializer(serializers.Serializer):
A serializer for receiving a single purchase order line item against a purchase order 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( line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.PurchaseOrderLineItem.objects.all(), queryset=order.models.PurchaseOrderLineItem.objects.all(),
many=False, many=False,
@ -241,6 +268,22 @@ class POLineItemReceiveSerializer(serializers.Serializer):
return quantity 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( status = serializers.ChoiceField(
choices=list(StockStatus.items()), choices=list(StockStatus.items()),
default=StockStatus.OK, default=StockStatus.OK,
@ -270,14 +313,35 @@ class POLineItemReceiveSerializer(serializers.Serializer):
return barcode return barcode
class Meta: def validate(self, data):
fields = [
'barcode', data = super().validate(data)
'line_item',
'location', line_item = data['line_item']
'quantity', quantity = data['quantity']
'status', 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): class POReceiveSerializer(serializers.Serializer):
@ -366,6 +430,8 @@ class POReceiveSerializer(serializers.Serializer):
request.user, request.user,
status=item['status'], status=item['status'],
barcode=item.get('barcode', ''), barcode=item.get('barcode', ''),
batch_code=item.get('batch_code', ''),
serials=item.get('serials', None),
) )
except (ValidationError, DjangoValidationError) as exc: except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors # Catch model errors and re-throw as DRF errors
@ -549,6 +615,23 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
class SOLineItemSerializer(InvenTreeModelSerializer): class SOLineItemSerializer(InvenTreeModelSerializer):
""" Serializer for a SalesOrderLineItem object """ """ 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): def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False) part_detail = kwargs.pop('part_detail', False)
@ -570,6 +653,8 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
overdue = serializers.BooleanField(required=False, read_only=True)
quantity = InvenTreeDecimalField() quantity = InvenTreeDecimalField()
allocated = serializers.FloatField(source='allocated_quantity', read_only=True) allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
@ -599,12 +684,14 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
'notes', 'notes',
'order', 'order',
'order_detail', 'order_detail',
'overdue',
'part', 'part',
'part_detail', 'part_detail',
'sale_price', 'sale_price',
'sale_price_currency', 'sale_price_currency',
'sale_price_string', 'sale_price_string',
'shipped', 'shipped',
'target_date',
] ]

View File

@ -141,27 +141,27 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</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> </tr>
{% if order.issue_date %} {% if order.issue_date %}
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Issued" %}</td> <td>{% trans "Issued" %}</td>
<td>{{ order.issue_date }}</td> <td>{% render_date order.issue_date %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.target_date %} {% if order.target_date %}
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Target Date" %}</td> <td>{% trans "Target Date" %}</td>
<td>{{ order.target_date }}</td> <td>{% render_date order.target_date %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %} {% if order.status == PurchaseOrderStatus.COMPLETE %}
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</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> </tr>
{% endif %} {% endif %}
{% if order.responsible %} {% if order.responsible %}

View File

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

View File

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

View File

@ -238,6 +238,7 @@
reference: {}, reference: {},
sale_price: {}, sale_price: {},
sale_price_currency: {}, sale_price_currency: {},
target_date: {},
notes: {}, notes: {},
}, },
method: 'POST', 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-123').exists())
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').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): class SalesOrderTest(OrderTest):
""" """

View File

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

View File

@ -969,7 +969,7 @@
{% if price_history %} {% if price_history %}
var purchasepricedata = { var purchasepricedata = {
labels: [ labels: [
{% for line in price_history %}'{{ line.date }}',{% endfor %} {% for line in price_history %}'{% render_date line.date %}',{% endfor %}
], ],
datasets: [{ datasets: [{
label: '{% blocktrans %}Purchase Unit Price - {{currency}}{% endblocktrans %}', label: '{% blocktrans %}Purchase Unit Price - {{currency}}{% endblocktrans %}',
@ -1082,7 +1082,7 @@
{% if sale_history %} {% if sale_history %}
var salepricedata = { var salepricedata = {
labels: [ labels: [
{% for line in sale_history %}'{{ line.date }}',{% endfor %} {% for line in sale_history %}'{% render_date line.date %}',{% endfor %}
], ],
datasets: [{ datasets: [{
label: '{% blocktrans %}Unit Price - {{currency}}{% endblocktrans %}', label: '{% blocktrans %}Unit Price - {{currency}}{% endblocktrans %}',

View File

@ -312,7 +312,7 @@
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Creation Date" %}</td> <td>{% trans "Creation Date" %}</td>
<td> <td>
{{ part.creation_date }} {% render_date part.creation_date %}
{% if part.creation_user %} {% if part.creation_user %}
<span class='badge badge-right rounded-pill bg-dark'>{{ part.creation_user }}</span> <span class='badge badge-right rounded-pill bg-dark'>{{ part.creation_user }}</span>
{% endif %} {% endif %}

View File

@ -5,6 +5,7 @@ This module provides template tags for extra functionality,
over and above the built-in Django tags. over and above the built-in Django tags.
""" """
from datetime import date
import os import os
import sys import sys
@ -43,6 +44,52 @@ def define(value, *args, **kwargs):
return value 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() @register.simple_tag()
def decimal(x, *args, **kwargs): def decimal(x, *args, **kwargs):
""" Simplified rendering of a decimal number """ """ Simplified rendering of a decimal number """

View File

@ -5,11 +5,13 @@ import logging
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from maintenance_mode.core import set_maintenance_mode from maintenance_mode.core import set_maintenance_mode
from InvenTree.ready import isImportingData from InvenTree.ready import isImportingData
from plugin import registry from plugin import registry
from plugin.helpers import check_git_version, log_error
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -34,3 +36,8 @@ class PluginAppConfig(AppConfig):
# drop out of maintenance # drop out of maintenance
# makes sure we did not have an error in reloading and maintenance is still active # makes sure we did not have an error in reloading and maintenance is still active
set_maintenance_mode(False) 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,9 +94,12 @@ def get_git_log(path):
""" """
Get dict with info of the last commit to file named in path Get dict with info of the last commit to file named in path
""" """
from plugin import registry
output = None
if registry.git_is_modern:
path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:] 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] command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
output = None
try: try:
output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1] output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
if output: if output:
@ -106,9 +109,31 @@ def get_git_log(path):
if not output: if not output:
output = 7 * [''] # pragma: no cover 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]} 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 GitStatus:
""" """
Class for resolving git gpg singing state Class for resolving git gpg singing state

View File

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

View File

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

View File

@ -63,6 +63,43 @@ class StockLocation(InvenTreeTree):
help_text=_('Select Owner'), help_text=_('Select Owner'),
related_name='stock_locations') 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): def get_absolute_url(self):
return reverse('stock-location-detail', kwargs={'pk': self.id}) return reverse('stock-location-detail', kwargs={'pk': self.id})
@ -614,6 +651,48 @@ class StockItem(MPTTModel):
help_text=_('Select Owner'), help_text=_('Select Owner'),
related_name='stock_items') 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): def is_stale(self):
""" """
Returns True if this Stock item is "stale". Returns True if this Stock item is "stale".

View File

@ -18,18 +18,11 @@
<h4>{% trans "Stock Tracking Information" %}</h4> <h4>{% trans "Stock Tracking Information" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} {% if user_owns_item and roles.stock.change and not item.is_building %}
{% 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 %}
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'> <button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>
<span class='fas fa-plus-circle'></span> {% trans "New Entry" %} <span class='fas fa-plus-circle'></span> {% trans "New Entry" %}
</button> </button>
{% endif %} {% endif %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -59,14 +59,7 @@
</ul> </ul>
</div> </div>
<!-- Stock adjustment menu --> <!-- Stock adjustment menu -->
<!-- Check permissions and owner --> {% if user_owns_item %}
{% 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 roles.stock.change and not item.is_building %} {% if roles.stock.change and not item.is_building %}
<div class='btn-group'> <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> <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><span class='fas fa-calendar-alt{% if item.is_expired %} icon-red{% endif %}'></span></td>
<td>{% trans "Expiry Date" %}</td> <td>{% trans "Expiry Date" %}</td>
<td> <td>
{{ item.expiry_date }} {% render_date item.expiry_date %}
{% if item.is_expired %} {% 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> <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 %} {% elif item.is_stale %}
@ -212,31 +205,15 @@
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Last Stocktake" %}</td> <td>{% trans "Last Stocktake" %}</td>
{% if item.stocktake_date %} {% 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 %} {% else %}
<td><em>{% trans "No stocktake performed" %}</em></td> <td><em>{% trans "No stocktake performed" %}</em></td>
{% endif %} {% endif %}
</tr> </tr>
</table> </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'> <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 %} {% if item.is_building %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
{% trans "This stock item is in production and cannot be edited." %}<br> {% trans "This stock item is in production and cannot be edited." %}<br>
@ -419,11 +396,18 @@
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
{% if item.owner %} {% if ownership_enabled and item_owner %}
<tr> <tr>
<td><span class='fas fa-users'></span></td> <td><span class='fas fa-users'></span></td>
<td>{% trans "Owner" %}</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> </tr>
{% endif %} {% endif %}
</table> </table>

View File

@ -20,6 +20,7 @@
{% endblock %} {% endblock %}
{% block actions %} {% block actions %}
<!-- Admin view --> <!-- Admin view -->
{% if location and user.is_staff and roles.stock_location.change %} {% if location and user.is_staff and roles.stock_location.change %}
{% url 'admin:stock_stocklocation_change' location.pk as url %} {% url 'admin:stock_stocklocation_change' location.pk as url %}
@ -38,7 +39,7 @@
</ul> </ul>
</div> </div>
<!-- Check permissions and owner --> <!-- 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 %} {% if roles.stock.change %}
<div class='btn-group' role='group'> <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'> <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 %} {% 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 user_owns_location and roles.stock_location.add %}
{% if roles.stock_location.add %}
<button class='btn btn-success' id='location-create' type='button' title='{% trans "Create new stock location" %}'> <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" %} <span class='fas fa-plus-circle'></span> {% trans "New Location" %}
</button> </button>
{% endif %} {% endif %}
{% endif %}
{% endblock %} {% endblock %}
{% block details_left %} {% block details_left %}
@ -106,23 +105,23 @@
<td><em>{% trans "Top level stock location" %}</em></td> <td><em>{% trans "Top level stock location" %}</em></td>
</tr> </tr>
{% endif %} {% 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> </table>
{% endblock details_left %} {% 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 %} {% block details_right %}
{% if location %} {% if location %}
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>

View File

@ -63,6 +63,11 @@ class StockIndex(InvenTreeRoleMixin, ListView):
context['loc_count'] = StockLocation.objects.count() context['loc_count'] = StockLocation.objects.count()
context['stock_count'] = StockItem.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 return context
@ -76,6 +81,16 @@ class StockLocationDetail(InvenTreeRoleMixin, DetailView):
queryset = StockLocation.objects.all() queryset = StockLocation.objects.all()
model = StockLocation 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): class StockItemDetail(InvenTreeRoleMixin, DetailView):
""" """
@ -126,6 +141,10 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
# We only support integer serial number progression # We only support integer serial number progression
pass 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 return data
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):

View File

@ -81,7 +81,7 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ plugin.author }}</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> <td>{% if plugin.version %}{{ plugin.version }}{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -36,7 +36,7 @@
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Date" %}</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>
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
@ -101,7 +101,7 @@
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <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>
<tr> <tr>
<td><span class='fas fa-code-branch'></span></td> <td><span class='fas fa-code-branch'></span></td>

View File

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

View File

@ -15,6 +15,7 @@
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="STICKY_HEADER" icon="fa-bars" user_setting=True %} {% 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="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 %} {% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %}
</tbody> </tbody>

View File

@ -44,7 +44,7 @@
{% if commit_date %} {% if commit_date %}
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <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> </tr>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@ -7,6 +7,7 @@
clearEvents, clearEvents,
endDate, endDate,
startDate, startDate,
renderDate,
*/ */
/** /**
@ -32,3 +33,33 @@ function clearEvents(calendar) {
event.remove(); 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}/`; url = `/api/settings/user/${pk}/`;
} }
var reload_required = false;
// First, read the settings object from the server // First, read the settings object from the server
inventreeGet(url, {}, { inventreeGet(url, {}, {
success: function(response) { success: function(response) {
if (response.choices && response.choices.length > 0) { if (response.choices && response.choices.length > 0) {
response.type = 'choice'; response.type = 'choice';
reload_required = true;
} }
// Construct the field // Construct the field
@ -89,7 +92,9 @@ function editSetting(pk, options={}) {
var setting = response.key; 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'; var enabled = response.value.toString().toLowerCase() == 'true';
$(`#setting-value-${setting}`).prop('checked', enabled); $(`#setting-value-${setting}`).prop('checked', enabled);
} else { } else {

View File

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

View File

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

View File

@ -542,6 +542,11 @@ function constructFormBody(fields, options) {
insertConfirmButton(options); insertConfirmButton(options);
} }
// Insert "persist" button (if required)
if (options.persist) {
insertPersistButton(options);
}
// Display the modal // Display the modal
$(modal).modal('show'); $(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 * Extract all specified form values as a single object
*/ */
@ -934,19 +955,40 @@ function getFormFieldValue(name, field={}, options={}) {
*/ */
function handleFormSuccess(response, 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 // Display any required messages
// Should we show alerts immediately or cache them? // Should we show alerts immediately or cache them?
var cache = (options.follow && response.url) || options.redirect || options.reload; 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 // Display any messages
if (response && (response.success || options.successMessage)) { 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) { if (response && response.info) {
@ -961,6 +1003,26 @@ function handleFormSuccess(response, options) {
showAlertOrCache(response.danger, cache, {style: 'danger'}); showAlertOrCache(response.danger, cache, {style: 'danger'});
} }
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);
}
// 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) { if (options.onSuccess) {
// Callback function // Callback function
options.onSuccess(response, options); options.onSuccess(response, options);
@ -977,6 +1039,7 @@ function handleFormSuccess(response, options) {
window.location.href = options.redirect; window.location.href = options.redirect;
} }
} }
}
@ -989,6 +1052,8 @@ function clearFormErrors(options={}) {
// Remove the individual error messages // Remove the individual error messages
$(options.modal).find('.form-error-message').remove(); $(options.modal).find('.form-error-message').remove();
$(options.modal).find('.modal-content').removeClass('modal-error');
// Remove the "has error" class // Remove the "has error" class
$(options.modal).find('.form-field-error').removeClass('form-field-error'); $(options.modal).find('.form-field-error').removeClass('form-field-error');
@ -1884,7 +1949,7 @@ function getFieldName(name, options={}) {
* - Field description (help text) * - Field description (help text)
* - Field errors * - Field errors
*/ */
function constructField(name, parameters, options) { function constructField(name, parameters, options={}) {
var html = ''; var html = '';
@ -1976,7 +2041,7 @@ function constructField(name, parameters, options) {
html += `<div class='controls'>`; html += `<div class='controls'>`;
// Does this input deserve "extra" decorators? // 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 // Some fields can have 'clear' inputs associated with them
if (!parameters.required && !parameters.read_only) { if (!parameters.required && !parameters.read_only) {
@ -2001,6 +2066,10 @@ function constructField(name, parameters, options) {
if (parameters.prefix) { if (parameters.prefix) {
html += `<span class='input-group-text'>${parameters.prefix}</span>`; 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}'`); opts.push(`type='${type}'`);
if (parameters.title || parameters.help_text) {
opts.push(`title='${parameters.title || parameters.help_text}'`);
}
// Read only? // Read only?
if (parameters.read_only) { if (parameters.read_only) {
opts.push(`readonly=''`); opts.push(`readonly=''`);
@ -2192,11 +2265,6 @@ function constructInputOptions(name, classes, type, parameters, options={}) {
opts.push(`required=''`); opts.push(`required=''`);
} }
// Custom mouseover title?
if (parameters.title != null) {
opts.push(`title='${parameters.title}'`);
}
// Placeholder? // Placeholder?
if (parameters.placeholder != null) { if (parameters.placeholder != null) {
opts.push(`placeholder='${parameters.placeholder}'`); opts.push(`placeholder='${parameters.placeholder}'`);

View File

@ -116,6 +116,10 @@ function makeIconButton(icon, cls, pk, title, options={}) {
extraProps += `disabled='true' `; 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 += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}' ${extraProps}>`;
html += `<span class='fas ${icon}'></span>`; html += `<span class='fas ${icon}'></span>`;
html += `</button>`; html += `</button>`;

View File

@ -476,6 +476,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
quantity = 0; 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 // Quantity to Receive
var quantity_input = constructField( var quantity_input = constructField(
`items_quantity_${pk}`, `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 // Construct list of StockItem status codes
var choices = []; var choices = [];
@ -528,16 +571,38 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
); );
// Button to remove the row // 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', 'fa-times icon-red',
'button-row-remove', 'button-row-remove',
pk, pk,
'{% trans "Remove row" %}', '{% trans "Remove row" %}',
); );
delete_button += '</div>'; buttons += '</div>';
var html = ` var html = `
<tr id='receive_row_${pk}' class='stock-receive-row'> <tr id='receive_row_${pk}' class='stock-receive-row'>
@ -554,7 +619,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
${line_item.received} ${line_item.received}
</td> </td>
<td id='quantity_${pk}'> <td id='quantity_${pk}'>
${quantity_input} ${quantity_input_group}
</td> </td>
<td id='status_${pk}'> <td id='status_${pk}'>
${status_input} ${status_input}
@ -563,7 +628,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
${destination_input} ${destination_input}
</td> </td>
<td id='actions_${pk}'> <td id='actions_${pk}'>
${delete_button} ${buttons}
</td> </td>
</tr>`; </tr>`;
@ -587,7 +652,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
<th>{% trans "Order Code" %}</th> <th>{% trans "Order Code" %}</th>
<th>{% trans "Ordered" %}</th> <th>{% trans "Ordered" %}</th>
<th>{% trans "Received" %}</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: 150px;'>{% trans "Status" %}</th>
<th style='min-width: 300px;'>{% trans "Destination" %}</th> <th style='min-width: 300px;'>{% trans "Destination" %}</th>
<th></th> <th></th>
@ -678,13 +743,23 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
var location = getFormFieldValue(`items_location_${pk}`, {}, opts); var location = getFormFieldValue(`items_location_${pk}`, {}, opts);
if (quantity != null) { if (quantity != null) {
data.items.push({
var line = {
line_item: pk, line_item: pk,
quantity: quantity, quantity: quantity,
status: status, status: status,
location: location, 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); item_pk_values.push(pk);
} }
@ -848,11 +923,17 @@ function loadPurchaseOrderTable(table, options) {
field: 'creation_date', field: 'creation_date',
title: '{% trans "Date" %}', title: '{% trans "Date" %}',
sortable: true, sortable: true,
formatter: function(value) {
return renderDate(value);
}
}, },
{ {
field: 'target_date', field: 'target_date',
title: '{% trans "Target Date" %}', title: '{% trans "Target Date" %}',
sortable: true, sortable: true,
formatter: function(value) {
return renderDate(value);
}
}, },
{ {
field: 'line_items', field: 'line_items',
@ -930,6 +1011,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
reference: {}, reference: {},
purchase_price: {}, purchase_price: {},
purchase_price_currency: {}, purchase_price_currency: {},
target_date: {},
destination: {}, destination: {},
notes: {}, notes: {},
}, },
@ -971,7 +1053,11 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
], ],
{ {
success: function() { success: function() {
// Reload the line item table
$(table).bootstrapTable('refresh'); $(table).bootstrapTable('refresh');
// Reload the "received stock" table
$('#stock-table').bootstrapTable('refresh');
} }
} }
); );
@ -1111,6 +1197,28 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
return formatter.format(total); 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, sortable: false,
field: 'received', field: 'received',
@ -1157,15 +1265,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
var pk = row.pk; 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) { if (options.allow_edit) {
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}'); 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" %}'); 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>`; html += `</div>`;
return html; return html;
@ -1269,16 +1377,25 @@ function loadSalesOrderTable(table, options) {
sortable: true, sortable: true,
field: 'creation_date', field: 'creation_date',
title: '{% trans "Creation Date" %}', title: '{% trans "Creation Date" %}',
formatter: function(value) {
return renderDate(value);
}
}, },
{ {
sortable: true, sortable: true,
field: 'target_date', field: 'target_date',
title: '{% trans "Target Date" %}', title: '{% trans "Target Date" %}',
formatter: function(value) {
return renderDate(value);
}
}, },
{ {
sortable: true, sortable: true,
field: 'shipment_date', field: 'shipment_date',
title: '{% trans "Shipment Date" %}', title: '{% trans "Shipment Date" %}',
formatter: function(value) {
return renderDate(value);
}
}, },
{ {
sortable: true, sortable: true,
@ -1430,9 +1547,9 @@ function loadSalesOrderShipmentTable(table, options={}) {
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
if (value) { if (value) {
return value; return renderDate(value);
} else { } else {
return '{% trans "Not shipped" %}'; return '<em>{% trans "Not shipped" %}</em>';
} }
} }
}, },
@ -2208,6 +2325,28 @@ function loadSalesOrderLineItemTable(table, options={}) {
return formatter.format(total); 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) { if (pending) {
@ -2351,6 +2490,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
reference: {}, reference: {},
sale_price: {}, sale_price: {},
sale_price_currency: {}, sale_price_currency: {},
target_date: {},
notes: {}, notes: {},
}, },
title: '{% trans "Edit Line Item" %}', title: '{% trans "Edit Line Item" %}',

View File

@ -905,6 +905,28 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
field: 'quantity', field: 'quantity',
title: '{% trans "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', field: 'received',
title: '{% trans "Received" %}', title: '{% trans "Received" %}',

View File

@ -25,7 +25,6 @@
modalSetContent, modalSetContent,
modalSetTitle, modalSetTitle,
modalSubmit, modalSubmit,
moment,
openModal, openModal,
printStockItemLabels, printStockItemLabels,
printTestReports, printTestReports,
@ -1820,6 +1819,9 @@ function loadStockTable(table, options) {
col = { col = {
field: 'stocktake_date', field: 'stocktake_date',
title: '{% trans "Stocktake" %}', title: '{% trans "Stocktake" %}',
formatter: function(value) {
return renderDate(value);
}
}; };
if (!options.params.ordering) { if (!options.params.ordering) {
@ -1833,6 +1835,9 @@ function loadStockTable(table, options) {
title: '{% trans "Expiry Date" %}', title: '{% trans "Expiry Date" %}',
visible: global_settings.STOCK_ENABLE_EXPIRY, visible: global_settings.STOCK_ENABLE_EXPIRY,
switchable: global_settings.STOCK_ENABLE_EXPIRY, switchable: global_settings.STOCK_ENABLE_EXPIRY,
formatter: function(value) {
return renderDate(value);
}
}; };
if (!options.params.ordering) { if (!options.params.ordering) {
@ -1844,6 +1849,9 @@ function loadStockTable(table, options) {
col = { col = {
field: 'updated', field: 'updated',
title: '{% trans "Last Updated" %}', title: '{% trans "Last Updated" %}',
formatter: function(value) {
return renderDate(value);
}
}; };
if (!options.params.ordering) { if (!options.params.ordering) {
@ -2649,14 +2657,7 @@ function loadStockTrackingTable(table, options) {
title: '{% trans "Date" %}', title: '{% trans "Date" %}',
sortable: true, sortable: true,
formatter: function(value) { formatter: function(value) {
var m = moment(value); return renderDate(value, {showTime: true});
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>';
} }
}); });

View File

@ -108,7 +108,7 @@
<ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'> <ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'>
{% if user.is_authenticated %} {% if user.is_authenticated %}
{% if user.is_staff and not demo %} {% 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 %} {% 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 '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> <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 %} InvenTree-Version: {% inventree_version %}
Django Version: {% django_version %} Django Version: {% django_version %}
{% inventree_commit_hash as hash %}{% if hash %}Commit Hash: {{ hash }}{% endif %} {% 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 %} Database: {% inventree_db_engine %}
Debug-Mode: {% inventree_in_debug_mode %} Debug-Mode: {% inventree_in_debug_mode %}
Deployed using Docker: {% inventree_docker_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) - [**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 # Documentation
For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/). For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/).