Merge remote-tracking branch 'inventree/master' into label-api

# Conflicts:
#	InvenTree/locale/de/LC_MESSAGES/django.po
#	InvenTree/locale/en/LC_MESSAGES/django.po
#	InvenTree/locale/es/LC_MESSAGES/django.po
This commit is contained in:
Oliver Walters 2021-01-14 23:56:23 +11:00
commit 0134597747
41 changed files with 2155 additions and 1811 deletions

View File

@ -11,17 +11,20 @@ database setup in this file.
""" """
import sys
import os
import logging import logging
import os
import sys
import tempfile import tempfile
import yaml
from datetime import datetime from datetime import datetime
import yaml
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
def _is_true(x):
return x in [True, "True", "true", "Y", "y", "1"]
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -36,11 +39,14 @@ with open(cfg_filename, 'r') as cfg:
# Default action is to run the system in Debug mode # Default action is to run the system in Debug mode
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = CONFIG.get('debug', True) DEBUG = _is_true(os.getenv("INVENTREE_DEBUG", CONFIG.get("debug", True)))
# Configure logging settings # Configure logging settings
log_level = CONFIG.get('log_level', 'DEBUG').upper() log_level = CONFIG.get('log_level', 'DEBUG').upper()
logging.basicConfig(
level=log_level,
format="%(asctime)s %(levelname)s %(message)s",
)
if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']: if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
log_level = 'WARNING' log_level = 'WARNING'
@ -59,20 +65,31 @@ LOGGING = {
}, },
} }
logging.basicConfig(
level=log_level,
format='%(asctime)s %(levelname)s %(message)s',
)
# Get a logger instance for this setup file # Get a logger instance for this setup file
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Read the autogenerated key-file if os.getenv("INVENTREE_SECRET_KEY"):
key_file_name = os.path.join(BASE_DIR, 'secret_key.txt') # Secret key passed in directly
logger.info(f'Loading SECRET_KEY from {key_file_name}') SECRET_KEY = os.getenv("INVENTREE_SECRET_KEY").strip()
key_file = open(key_file_name, 'r') logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY")
else:
SECRET_KEY = key_file.read().strip() # Secret key passed in by file location
key_file = os.getenv("INVENTREE_SECRET_KEY_FILE")
if key_file:
if os.path.isfile(key_file):
logger.info("SECRET_KEY loaded by INVENTREE_SECRET_KEY_FILE")
else:
logger.error(f"Secret key file {key_file} not found")
exit(-1)
else:
# default secret key location
key_file = os.path.join(BASE_DIR, "secret_key.txt")
logger.info(f"SECRET_KEY loaded from {key_file}")
try:
SECRET_KEY = open(key_file, "r").read().strip()
except Exception:
logger.exception(f"Couldn't load keyfile {key_file}")
sys.exit(-1)
# List of allowed hosts (default = allow all) # List of allowed hosts (default = allow all)
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*']) ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
@ -112,7 +129,7 @@ MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'me
if DEBUG: if DEBUG:
logger.info("InvenTree running in DEBUG mode") logger.info("InvenTree running in DEBUG mode")
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'") logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'") logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'")
@ -315,7 +332,7 @@ else:
- However there may be reason to configure the DB via environmental variables - However there may be reason to configure the DB via environmental variables
- The following code lets the user "mix and match" database configuration - The following code lets the user "mix and match" database configuration
""" """
logger.info("Configuring database backend:") logger.info("Configuring database backend:")
# Extract database configuration from the config.yaml file # Extract database configuration from the config.yaml file
@ -341,7 +358,7 @@ else:
# Check that required database configuration options are specified # Check that required database configuration options are specified
reqiured_keys = ['ENGINE', 'NAME'] reqiured_keys = ['ENGINE', 'NAME']
for key in reqiured_keys: for key in reqiured_keys:
if key not in db_config: if key not in db_config:
error_msg = f'Missing required database configuration value {key} in config.yaml' error_msg = f'Missing required database configuration value {key} in config.yaml'

View File

@ -131,8 +131,7 @@ $.fn.inventreeTable = function(options) {
// Callback when a column is changed // Callback when a column is changed
options.onColumnSwitch = function(field, checked) { options.onColumnSwitch = function(field, checked) {
console.log(`${field} -> ${checked}`);
var columns = table.bootstrapTable('getVisibleColumns'); var columns = table.bootstrapTable('getVisibleColumns');
var text = visibleColumnString(columns); var text = visibleColumnString(columns);

View File

@ -160,6 +160,13 @@ class InvenTreeSetting(models.Model):
'validator': bool, 'validator': bool,
}, },
'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'),
'default': True,
'validator': bool,
},
'STOCK_ENABLE_EXPIRY': { 'STOCK_ENABLE_EXPIRY': {
'name': _('Stock Expiry'), 'name': _('Stock Expiry'),
'description': _('Enable stock expiry functionality'), 'description': _('Enable stock expiry functionality'),

View File

@ -5,20 +5,29 @@
fields: fields:
name: ACME name: ACME
description: A Cool Military Enterprise description: A Cool Military Enterprise
- model: company.company - model: company.company
pk: 2 pk: 2
fields: fields:
name: Appel Computers name: Appel Computers
description: Think more differenter description: Think more differenter
- model: company.company - model: company.company
pk: 3 pk: 3
fields: fields:
name: Zerg Corp name: Zerg Corp
description: We eat the competition description: We eat the competition
- model: company.company - model: company.company
pk: 4 pk: 4
fields: fields:
name: A customer name: A customer
description: A company that we sell things to! description: A company that we sell things to!
is_customer: True is_customer: True
- model: company.company
pk: 5
fields:
name: Another customer!
description: Yet another company
is_customer: True

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -80,6 +80,17 @@ class POList(generics.ListCreateAPIView):
else: else:
queryset = queryset.exclude(status__in=PurchaseOrderStatus.OPEN) queryset = queryset.exclude(status__in=PurchaseOrderStatus.OPEN)
# Filter by 'overdue' status
overdue = params.get('overdue', None)
if overdue is not None:
overdue = str2bool(overdue)
if overdue:
queryset = queryset.filter(PurchaseOrder.OVERDUE_FILTER)
else:
queryset = queryset.exclude(PurchaseOrder.OVERDUE_FILTER)
# Special filtering for 'status' field # Special filtering for 'status' field
status = params.get('status', None) status = params.get('status', None)

View File

@ -7,6 +7,7 @@
reference: '0001' reference: '0001'
description: "Ordering some screws" description: "Ordering some screws"
supplier: 1 supplier: 1
status: 10 # Pending
# Ordering some screws from Zerg Corp # Ordering some screws from Zerg Corp
- model: order.purchaseorder - model: order.purchaseorder
@ -15,6 +16,39 @@
reference: '0002' reference: '0002'
description: "Ordering some more screws" description: "Ordering some more screws"
supplier: 3 supplier: 3
status: 10 # Pending
- model: order.purchaseorder
pk: 3
fields:
reference: '0003'
description: 'Another PO'
supplier: 3
status: 20 # Placed
- model: order.purchaseorder
pk: 4
fields:
reference: '0004'
description: 'Another PO'
supplier: 3
status: 20 # Placed
- model: order.purchaseorder
pk: 5
fields:
reference: '0005'
description: 'Another PO'
supplier: 3
status: 30 # Complete
- model: order.purchaseorder
pk: 6
fields:
reference: '0006'
description: 'Another PO'
supplier: 3
status: 40 # Cancelled
# Add some line items against PO 0001 # Add some line items against PO 0001

View File

@ -0,0 +1,39 @@
- model: order.salesorder
pk: 1
fields:
reference: 'ABC123'
description: "One sales order, please"
customer: 4
status: 10 # Pending
- model: order.salesorder
pk: 2
fields:
reference: 'ABC124'
description: "One sales order, please"
customer: 4
status: 10 # Pending
- model: order.salesorder
pk: 3
fields:
reference: 'ABC125'
description: "One sales order, please"
customer: 4
status: 10 # Pending
- model: order.salesorder
pk: 4
fields:
reference: 'ABC126'
description: "One sales order, please"
customer: 5
status: 20 # Shipped
- model: order.salesorder
pk: 5
fields:
reference: 'ABC127'
description: "One sales order, please"
customer: 5
status: 60 # Returned

View File

@ -94,6 +94,7 @@ class EditPurchaseOrderForm(HelperForm):
self.field_prefix = { self.field_prefix = {
'reference': 'PO', 'reference': 'PO',
'link': 'fa-link', 'link': 'fa-link',
'target_date': 'fa-calendar-alt',
} }
self.field_placeholder = { self.field_placeholder = {
@ -102,6 +103,10 @@ class EditPurchaseOrderForm(HelperForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
target_date = DatePickerFormField(
help_text=_('Target date for order delivery. Order will be overdue after this date.'),
)
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
fields = [ fields = [
@ -109,6 +114,7 @@ class EditPurchaseOrderForm(HelperForm):
'supplier', 'supplier',
'supplier_reference', 'supplier_reference',
'description', 'description',
'target_date',
'link', 'link',
] ]

View File

@ -0,0 +1,28 @@
# Generated by Django 3.0.7 on 2021-01-14 06:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0040_salesorder_target_date'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='target_date',
field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Delivery Date'),
),
migrations.AlterField(
model_name='purchaseorder',
name='complete_date',
field=models.DateField(blank=True, help_text='Date order was completed', null=True, verbose_name='Completion Date'),
),
migrations.AlterField(
model_name='purchaseorder',
name='issue_date',
field=models.DateField(blank=True, help_text='Date order was issued', null=True, verbose_name='Issue Date'),
),
]

View File

@ -119,8 +119,11 @@ class PurchaseOrder(Order):
supplier: Reference to the company supplying the goods in the order supplier: Reference to the company supplying the goods in the order
supplier_reference: Optional field for supplier order reference code supplier_reference: Optional field for supplier order reference code
received_by: User that received the goods received_by: User that received the goods
target_date: Expected delivery target date for PurchaseOrder completion (optional)
""" """
OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
@staticmethod @staticmethod
def filterByDate(queryset, min_date, max_date): def filterByDate(queryset, min_date, max_date):
""" """
@ -132,7 +135,7 @@ class PurchaseOrder(Order):
To be "interesting": To be "interesting":
- A "received" order where the received date lies within the date range - A "received" order where the received date lies within the date range
- TODO: A "pending" order where the target date lies within the date range - A "pending" order where the target date lies within the date range
- TODO: An "overdue" order where the target date is in the past - TODO: An "overdue" order where the target date is in the past
""" """
@ -149,13 +152,12 @@ class PurchaseOrder(Order):
# Construct a queryset for "received" orders within the range # Construct a queryset for "received" orders within the range
received = Q(status=PurchaseOrderStatus.COMPLETE) & Q(complete_date__gte=min_date) & Q(complete_date__lte=max_date) received = Q(status=PurchaseOrderStatus.COMPLETE) & Q(complete_date__gte=min_date) & Q(complete_date__lte=max_date)
# TODO - Construct a queryset for "pending" orders within the range # Construct a queryset for "pending" orders within the range
pending = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date)
# TODO - Construct a queryset for "overdue" orders within the range # TODO - Construct a queryset for "overdue" orders within the range
flt = received queryset = queryset.filter(received | pending)
queryset = queryset.filter(flt)
return queryset return queryset
@ -186,9 +188,23 @@ class PurchaseOrder(Order):
related_name='+' related_name='+'
) )
issue_date = models.DateField(blank=True, null=True, help_text=_('Date order was issued')) issue_date = models.DateField(
blank=True, null=True,
verbose_name=_('Issue Date'),
help_text=_('Date order was issued')
)
complete_date = models.DateField(blank=True, null=True, help_text=_('Date order was completed')) target_date = models.DateField(
blank=True, null=True,
verbose_name=_('Target Delivery Date'),
help_text=_('Expected date for order delivery. Order will be overdue after this date.'),
)
complete_date = models.DateField(
blank=True, null=True,
verbose_name=_('Completion Date'),
help_text=_('Date order was completed')
)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('po-detail', kwargs={'pk': self.id}) return reverse('po-detail', kwargs={'pk': self.id})
@ -256,8 +272,24 @@ class PurchaseOrder(Order):
self.complete_date = datetime.now().date() self.complete_date = datetime.now().date()
self.save() self.save()
def is_overdue(self):
"""
Returns True if this PurchaseOrder is "overdue"
Makes use of the OVERDUE_FILTER to avoid code duplication.
"""
query = PurchaseOrder.objects.filter(pk=self.pk)
query = query.filter(PurchaseOrder.OVERDUE_FILTER)
return query.exists()
def can_cancel(self): def can_cancel(self):
return self.status not in [ """
A PurchaseOrder can only be cancelled under the following circumstances:
"""
return self.status in [
PurchaseOrderStatus.PLACED, PurchaseOrderStatus.PLACED,
PurchaseOrderStatus.PENDING PurchaseOrderStatus.PENDING
] ]
@ -419,17 +451,13 @@ class SalesOrder(Order):
""" """
Returns true if this SalesOrder is "overdue": Returns true if this SalesOrder is "overdue":
- Not completed Makes use of the OVERDUE_FILTER to avoid code duplication.
- Target date is "in the past"
""" """
# Order cannot be deemed overdue if target_date is not set query = SalesOrder.objects.filter(pk=self.pk)
if self.target_date is None: query = query.filer(SalesOrder.OVERDUE_FILTER)
return False
today = datetime.now().date() return query.exists()
return self.is_pending and self.target_date < today
@property @property
def is_pending(self): def is_pending(self):

View File

@ -40,12 +40,24 @@ class POSerializer(InvenTreeModelSerializer):
def annotate_queryset(queryset): def annotate_queryset(queryset):
""" """
Add extra information to the queryset Add extra information to the queryset
- Number of liens in the PurchaseOrder
- Overdue status of the PurchaseOrder
""" """
queryset = queryset.annotate( queryset = queryset.annotate(
line_items=SubqueryCount('lines') line_items=SubqueryCount('lines')
) )
queryset = queryset.annotate(
overdue=Case(
When(
PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField())
)
)
return queryset return queryset
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True) supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
@ -54,6 +66,8 @@ class POSerializer(InvenTreeModelSerializer):
status_text = serializers.CharField(source='get_status_display', read_only=True) status_text = serializers.CharField(source='get_status_display', read_only=True)
overdue = serializers.BooleanField(required=False, read_only=True)
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -65,12 +79,14 @@ class POSerializer(InvenTreeModelSerializer):
'description', 'description',
'line_items', 'line_items',
'link', 'link',
'overdue',
'reference', 'reference',
'supplier', 'supplier',
'supplier_detail', 'supplier_detail',
'supplier_reference', 'supplier_reference',
'status', 'status',
'status_text', 'status_text',
'target_date',
'notes', 'notes',
] ]

View File

@ -26,7 +26,12 @@ src="{% static 'img/blank_image.png' %}"
<a href="{% url 'admin:order_purchaseorder_change' order.pk %}"><span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span></a> <a href="{% url 'admin:order_purchaseorder_change' order.pk %}"><span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span></a>
{% endif %} {% endif %}
</h3> </h3>
<h3>{% purchase_order_status_label order.status large=True %}</h3> <h3>
{% purchase_order_status_label order.status large=True %}
{% if order.is_overdue %}
<span class='label label-large label-large-red'>{% trans "Overdue" %}</span>
{% endif %}
</h3>
<hr> <hr>
<p>{{ order.description }}</p> <p>{{ order.description }}</p>
<div class='btn-row'> <div class='btn-row'>
@ -47,7 +52,7 @@ src="{% static 'img/blank_image.png' %}"
<span class='fas fa-check-circle'></span> <span class='fas fa-check-circle'></span>
</button> </button>
{% endif %} {% endif %}
{% if order.status == PurchaseOrderStatus.PENDING or order.status == PurchaseOrderStatus.PLACED %} {% if order.can_cancel %}
<button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'> <button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'>
<span class='fas fa-times-circle icon-red'></span> <span class='fas fa-times-circle icon-red'></span>
</button> </button>
@ -72,7 +77,12 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td> <td>{% trans "Order Status" %}</td>
<td>{% purchase_order_status_label order.status %}</td> <td>
{% purchase_order_status_label order.status %}
{% if order.is_overdue %}
<span class='label label-red'>{% trans "Overdue" %}</span>
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-building'></span></td> <td><span class='fas fa-building'></span></td>
@ -105,6 +115,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.issue_date }}</td> <td>{{ order.issue_date }}</td>
</tr> </tr>
{% endif %} {% 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>
</tr>
{% 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>

View File

@ -70,6 +70,8 @@ InvenTree | {% trans "Purchase Orders" %}
if (order.complete_date) { if (order.complete_date) {
date = order.complete_date; date = order.complete_date;
} else if (order.target_date) {
date = order.target_date;
} }
var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`; var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`;

View File

@ -2,12 +2,16 @@
Tests for the Order API Tests for the Order API
""" """
from datetime import datetime, timedelta
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from rest_framework import status from rest_framework import status
from django.urls import reverse from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .models import PurchaseOrder, SalesOrder
class OrderTest(APITestCase): class OrderTest(APITestCase):
@ -18,6 +22,8 @@ class OrderTest(APITestCase):
'location', 'location',
'supplier_part', 'supplier_part',
'stock', 'stock',
'order',
'sales_order',
] ]
def setUp(self): def setUp(self):
@ -26,21 +32,80 @@ class OrderTest(APITestCase):
get_user_model().objects.create_user('testuser', 'test@testing.com', 'password') get_user_model().objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password') self.client.login(username='testuser', password='password')
def doGet(self, url, options=''): def doGet(self, url, data={}):
return self.client.get(url + "?" + options, format='json') return self.client.get(url, data=data, format='json')
def doPost(self, url, data={}):
return self.client.post(url, data=data, format='json')
def filter(self, filters, count):
"""
Test API filters
"""
response = self.doGet(
self.LIST_URL,
filters
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), count)
return response
class PurchaseOrderTest(OrderTest):
"""
Tests for the PurchaseOrder API
"""
LIST_URL = reverse('api-po-list')
def test_po_list(self): def test_po_list(self):
url = reverse('api-po-list')
# List all order items # List *ALL* PO items
self.filter({}, 6)
# Filter by supplier
self.filter({'supplier': 1}, 1)
self.filter({'supplier': 3}, 5)
# Filter by "outstanding"
self.filter({'outstanding': True}, 4)
self.filter({'outstanding': False}, 2)
# Filter by "status"
self.filter({'status': 10}, 2)
self.filter({'status': 40}, 1)
def test_overdue(self):
"""
Test "overdue" status
"""
self.filter({'overdue': True}, 0)
self.filter({'overdue': False}, 6)
order = PurchaseOrder.objects.get(pk=1)
order.target_date = datetime.now().date() - timedelta(days=10)
order.save()
self.filter({'overdue': True}, 1)
self.filter({'overdue': False}, 5)
def test_po_detail(self):
url = '/api/order/po/1/'
response = self.doGet(url) response = self.doGet(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.status_code, 200)
# Filter by stuff data = response.data
response = self.doGet(url, 'status=10&part=1&supplier_part=1')
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(data['pk'], 1)
self.assertEqual(data['description'], 'Ordering some screws')
def test_po_attachments(self): def test_po_attachments(self):
@ -50,6 +115,60 @@ class OrderTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
class SalesOrderTest(OrderTest):
"""
Tests for the SalesOrder API
"""
LIST_URL = reverse('api-so-list')
def test_so_list(self):
# All orders
self.filter({}, 5)
# Filter by customer
self.filter({'customer': 4}, 3)
self.filter({'customer': 5}, 2)
# Filter by outstanding
self.filter({'outstanding': True}, 3)
self.filter({'outstanding': False}, 2)
# Filter by status
self.filter({'status': 10}, 3) # PENDING
self.filter({'status': 20}, 1) # SHIPPED
self.filter({'status': 99}, 0) # Invalid
def test_overdue(self):
"""
Test "overdue" status
"""
self.filter({'overdue': True}, 0)
self.filter({'overdue': False}, 5)
for pk in [1, 2]:
order = SalesOrder.objects.get(pk=pk)
order.target_date = datetime.now().date() - timedelta(days=10)
order.save()
self.filter({'overdue': True}, 2)
self.filter({'overdue': False}, 3)
def test_so_detail(self):
url = '/api/order/so/1/'
response = self.doGet(url)
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data['pk'], 1)
def test_so_attachments(self): def test_so_attachments(self):
url = reverse('api-so-attachment-list') url = reverse('api-so-attachment-list')

View File

@ -41,7 +41,7 @@ class OrderTest(TestCase):
next_ref = PurchaseOrder.getNextOrderNumber() next_ref = PurchaseOrder.getNextOrderNumber()
self.assertEqual(next_ref, '0003') self.assertEqual(next_ref, '0007')
def test_on_order(self): def test_on_order(self):
""" There should be 3 separate items on order for the M2x4 LPHS part """ """ There should be 3 separate items on order for the M2x4 LPHS part """

View File

@ -432,7 +432,7 @@ class PurchaseOrderCancel(AjaxUpdateView):
form.add_error('confirm', _('Confirm order cancellation')) form.add_error('confirm', _('Confirm order cancellation'))
if not order.can_cancel(): if not order.can_cancel():
form.add_error(None, _('Order cannot be cancelled as either pending or placed')) form.add_error(None, _('Order cannot be cancelled'))
def save(self, order, form, **kwargs): def save(self, order, form, **kwargs):
""" """

View File

@ -182,6 +182,10 @@ class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
queryset = PartAttachment.objects.all() queryset = PartAttachment.objects.all()
serializer_class = part_serializers.PartAttachmentSerializer serializer_class = part_serializers.PartAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filter_fields = [ filter_fields = [
'part', 'part',
] ]

View File

@ -13,6 +13,8 @@ from mptt.fields import TreeNodeChoiceField
from django import forms from django import forms
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
import common.models
from .models import Part, PartCategory, PartAttachment, PartRelated from .models import Part, PartCategory, PartAttachment, PartRelated
from .models import BomItem from .models import BomItem
from .models import PartParameterTemplate, PartParameter from .models import PartParameterTemplate, PartParameter
@ -23,8 +25,16 @@ from .models import PartSellPriceBreak
class PartModelChoiceField(forms.ModelChoiceField): class PartModelChoiceField(forms.ModelChoiceField):
""" Extending string representation of Part instance with available stock """ """ Extending string representation of Part instance with available stock """
def label_from_instance(self, part): def label_from_instance(self, part):
return f'{part} - {part.available_stock}'
label = str(part)
# Optionally display available part quantity
if common.models.InvenTreeSetting.get_setting('PART_SHOW_QUANTITY_IN_FORMS'):
label += f" - {part.available_stock}"
return label
class PartImageForm(HelperForm): class PartImageForm(HelperForm):

View File

@ -1990,7 +1990,13 @@ class BomItem(models.Model):
Return the available stock items for the referenced sub_part Return the available stock items for the referenced sub_part
""" """
query = self.sub_part.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER).aggregate( query = self.sub_part.stock_items.all()
query = query.prefetch_related([
'sub_part__stock_items',
])
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER).aggregate(
available=Coalesce(Sum('quantity'), 0) available=Coalesce(Sum('quantity'), 0)
) )

View File

@ -9,7 +9,7 @@
{% if category %} {% if category %}
<h3> <h3>
{{ category.name }} {{ category.name }}
{% if user.is_staff and roles.part.change %} {% if user.is_staff and roles.part_category.change %}
<a href="{% url 'admin:part_partcategory_change' category.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a> <a href="{% url 'admin:part_partcategory_change' category.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
{% endif %} {% endif %}
</h3> </h3>
@ -20,18 +20,18 @@
{% endif %} {% endif %}
<p> <p>
<div class='btn-group action-buttons'> <div class='btn-group action-buttons'>
{% if roles.part.add %} {% if roles.part_category.add %}
<button class='btn btn-default' id='cat-create' title='{% trans "Create new part category" %}'> <button class='btn btn-default' id='cat-create' title='{% trans "Create new part category" %}'>
<span class='fas fa-plus-circle icon-green'/> <span class='fas fa-plus-circle icon-green'/>
</button> </button>
{% endif %} {% endif %}
{% if category %} {% if category %}
{% if roles.part.change %} {% if roles.part_category.change %}
<button class='btn btn-default' id='cat-edit' title='{% trans "Edit part category" %}'> <button class='btn btn-default' id='cat-edit' title='{% trans "Edit part category" %}'>
<span class='fas fa-edit icon-blue'/> <span class='fas fa-edit icon-blue'/>
</button> </button>
{% endif %} {% endif %}
{% if roles.part.delete %} {% if roles.part_category.delete %}
<button class='btn btn-default' id='cat-delete' title='{% trans "Delete part category" %}'> <button class='btn btn-default' id='cat-delete' title='{% trans "Delete part category" %}'>
<span class='fas fa-trash-alt icon-red'/> <span class='fas fa-trash-alt icon-red'/>
</button> </button>

View File

@ -214,9 +214,9 @@
<tr> <tr>
<td> <td>
{% if part.active %} {% if part.active %}
<span class='fas fa-check-square'></span> <span class='fas fa-check-circle icon-green'></span>
{% else %} {% else %}
<span class='fas fa-times-square'></span> <span class='fas fa-times-circle icon-red'></span>
{% endif %} {% endif %}
</td> </td>
<td><b>{% trans "Active" %}</b></td> <td><b>{% trans "Active" %}</b></td>

View File

@ -37,7 +37,7 @@
{% if roles.part.change %} {% if roles.part.change %}
<button title='{% trans "Edit" %}' class='btn btn-default btn-glyph param-edit' url="{% url 'part-param-edit' param.id %}" type='button'><span class='fas fa-edit'/></button> <button title='{% trans "Edit" %}' class='btn btn-default btn-glyph param-edit' url="{% url 'part-param-edit' param.id %}" type='button'><span class='fas fa-edit'/></button>
{% endif %} {% endif %}
{% if roles.part.delete %} {% if roles.part.change %}
<button title='{% trans "Delete" %}' class='btn btn-default btn-glyph param-delete' url="{% url 'part-param-delete' param.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button> <button title='{% trans "Delete" %}' class='btn btn-default btn-glyph param-delete' url="{% url 'part-param-delete' param.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
{% endif %} {% endif %}
</div> </div>

View File

@ -231,7 +231,7 @@ class PartAttachmentDelete(AjaxDeleteView):
ajax_template_name = "attachment_delete.html" ajax_template_name = "attachment_delete.html"
context_object_name = "attachment" context_object_name = "attachment"
role_required = 'part.delete' role_required = 'part.change'
def get_data(self): def get_data(self):
return { return {
@ -1320,7 +1320,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
# Otherwise, check to see if there is a matching IPN # Otherwise, check to see if there is a matching IPN
try: try:
if row['part_ipn']: if row['part_ipn']:
part_matches = [part for part in self.allowed_parts if row['part_ipn'].lower() == part.IPN.lower()] part_matches = [part for part in self.allowed_parts if part.IPN and row['part_ipn'].lower() == str(part.IPN.lower())]
# Check for single match # Check for single match
if len(part_matches) == 1: if len(part_matches) == 1:
@ -2073,7 +2073,7 @@ class PartParameterEdit(AjaxUpdateView):
class PartParameterDelete(AjaxDeleteView): class PartParameterDelete(AjaxDeleteView):
""" View for deleting a PartParameter """ """ View for deleting a PartParameter """
role_required = 'part.delete' role_required = 'part.change'
model = PartParameter model = PartParameter
ajax_template_name = 'part/param_delete.html' ajax_template_name = 'part/param_delete.html'
@ -2088,7 +2088,7 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
queryset = PartCategory.objects.all().prefetch_related('children') queryset = PartCategory.objects.all().prefetch_related('children')
template_name = 'part/category_partlist.html' template_name = 'part/category_partlist.html'
role_required = 'part.view' role_required = ['part_category.view', 'part.view']
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -2138,7 +2138,7 @@ class CategoryEdit(AjaxUpdateView):
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit Part Category') ajax_form_title = _('Edit Part Category')
role_required = 'part.change' role_required = 'part_category.change'
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(CategoryEdit, self).get_context_data(**kwargs).copy() context = super(CategoryEdit, self).get_context_data(**kwargs).copy()
@ -2177,7 +2177,7 @@ class CategoryDelete(AjaxDeleteView):
context_object_name = 'category' context_object_name = 'category'
success_url = '/part/' success_url = '/part/'
role_required = 'part.delete' role_required = 'part_category.delete'
def get_data(self): def get_data(self):
return { return {
@ -2193,7 +2193,7 @@ class CategoryCreate(AjaxCreateView):
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
form_class = part_forms.EditCategoryForm form_class = part_forms.EditCategoryForm
role_required = 'part.add' role_required = 'part_category.add'
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
""" Add extra context data to template. """ Add extra context data to template.
@ -2233,7 +2233,7 @@ class CategoryCreate(AjaxCreateView):
class CategoryParameterTemplateCreate(AjaxCreateView): class CategoryParameterTemplateCreate(AjaxCreateView):
""" View for creating a new PartCategoryParameterTemplate """ """ View for creating a new PartCategoryParameterTemplate """
role_required = 'part.add' role_required = 'part_category.change'
model = PartCategoryParameterTemplate model = PartCategoryParameterTemplate
form_class = part_forms.EditCategoryParameterTemplateForm form_class = part_forms.EditCategoryParameterTemplateForm
@ -2336,7 +2336,7 @@ class CategoryParameterTemplateCreate(AjaxCreateView):
class CategoryParameterTemplateEdit(AjaxUpdateView): class CategoryParameterTemplateEdit(AjaxUpdateView):
""" View for editing a PartCategoryParameterTemplate """ """ View for editing a PartCategoryParameterTemplate """
role_required = 'part.change' role_required = 'part_category.change'
model = PartCategoryParameterTemplate model = PartCategoryParameterTemplate
form_class = part_forms.EditCategoryParameterTemplateForm form_class = part_forms.EditCategoryParameterTemplateForm
@ -2395,7 +2395,7 @@ class CategoryParameterTemplateEdit(AjaxUpdateView):
class CategoryParameterTemplateDelete(AjaxDeleteView): class CategoryParameterTemplateDelete(AjaxDeleteView):
""" View for deleting an existing PartCategoryParameterTemplate """ """ View for deleting an existing PartCategoryParameterTemplate """
role_required = 'part.delete' role_required = 'part_category.change'
model = PartCategoryParameterTemplate model = PartCategoryParameterTemplate
ajax_form_title = _("Delete Category Parameter Template") ajax_form_title = _("Delete Category Parameter Template")
@ -2554,7 +2554,7 @@ class BomItemDelete(AjaxDeleteView):
context_object_name = 'item' context_object_name = 'item'
ajax_form_title = _('Confim BOM item deletion') ajax_form_title = _('Confim BOM item deletion')
role_required = 'part.delete' role_required = 'part.change'
class PartSalePriceBreakCreate(AjaxCreateView): class PartSalePriceBreakCreate(AjaxCreateView):

View File

@ -208,6 +208,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'stale', 'stale',
'status', 'status',
'status_text', 'status_text',
'stocktake_date',
'supplier_part', 'supplier_part',
'supplier_part_detail', 'supplier_part_detail',
'tracking_items', 'tracking_items',

View File

@ -8,7 +8,7 @@
{% if location %} {% if location %}
<h3> <h3>
{{ location.name }} {{ location.name }}
{% if user.is_staff and roles.stock.change %} {% if user.is_staff and roles.stock_location.change %}
<a href="{% url 'admin:stock_stocklocation_change' location.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a> <a href="{% url 'admin:stock_stocklocation_change' location.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
{% endif %} {% endif %}
</h3> </h3>
@ -18,7 +18,7 @@
<p>{% trans "All stock items" %}</p> <p>{% trans "All stock items" %}</p>
{% endif %} {% endif %}
<div class='btn-group action-buttons' role='group'> <div class='btn-group action-buttons' role='group'>
{% if roles.stock.add %} {% if roles.stock_location.add %}
<button class='btn btn-default' id='location-create' title='{% trans "Create new stock location" %}'> <button class='btn btn-default' id='location-create' title='{% trans "Create new stock location" %}'>
<span class='fas fa-plus-circle icon-green'/> <span class='fas fa-plus-circle icon-green'/>
</button> </button>
@ -41,11 +41,13 @@
{% trans "Count stock" %}</a></li> {% trans "Count stock" %}</a></li>
</ul> </ul>
</div> </div>
{% endif %}
{% if roles.stock_location.change %}
<div class='btn-group'> <div class='btn-group'>
<button id='location-actions' title='{% trans "Location actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle="dropdown"><span class='fas fa-sitemap'></span> <span class='caret'></span></button> <button id='location-actions' title='{% trans "Location actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle="dropdown"><span class='fas fa-sitemap'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a href='#' id='location-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit location" %}</a></li> <li><a href='#' id='location-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit location" %}</a></li>
{% if roles.stock.delete %} {% if roles.stock_location.delete %}
<li><a href='#' id='location-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete location" %}</a></li> <li><a href='#' id='location-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete location" %}</a></li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,6 +1,6 @@
{% extends "collapse.html" %} {% extends "collapse.html" %}
{% if roles.stock.view %} {% if roles.stock_location.view or roles.stock.view %}
{% block collapse_title %} {% block collapse_title %}
Sub-Locations<span class='badge'>{{ children|length }}</span> Sub-Locations<span class='badge'>{{ children|length }}</span>
{% endblock %} {% endblock %}

View File

@ -72,7 +72,7 @@ class StockLocationDetail(InvenTreeRoleMixin, DetailView):
template_name = 'stock/location.html' template_name = 'stock/location.html'
queryset = StockLocation.objects.all() queryset = StockLocation.objects.all()
model = StockLocation model = StockLocation
role_required = 'stock.view' role_required = ['stock_location.view', 'stock.view']
class StockItemDetail(InvenTreeRoleMixin, DetailView): class StockItemDetail(InvenTreeRoleMixin, DetailView):
@ -120,7 +120,7 @@ class StockLocationEdit(AjaxUpdateView):
context_object_name = 'location' context_object_name = 'location'
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit Stock Location') ajax_form_title = _('Edit Stock Location')
role_required = 'stock.change' role_required = 'stock_location.change'
def get_form(self): def get_form(self):
""" Customize form data for StockLocation editing. """ Customize form data for StockLocation editing.
@ -145,7 +145,7 @@ class StockLocationQRCode(QRCodeView):
""" View for displaying a QR code for a StockLocation object """ """ View for displaying a QR code for a StockLocation object """
ajax_form_title = _("Stock Location QR code") ajax_form_title = _("Stock Location QR code")
role_required = 'stock.view' role_required = ['stock_location.view', 'stock.view']
def get_qr_data(self): def get_qr_data(self):
""" Generate QR code data for the StockLocation """ """ Generate QR code data for the StockLocation """
@ -1274,7 +1274,7 @@ class StockLocationCreate(AjaxCreateView):
context_object_name = 'location' context_object_name = 'location'
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = _('Create new Stock Location') ajax_form_title = _('Create new Stock Location')
role_required = 'stock.add' role_required = 'stock_location.add'
def get_initial(self): def get_initial(self):
initials = super(StockLocationCreate, self).get_initial().copy() initials = super(StockLocationCreate, self).get_initial().copy()
@ -1634,7 +1634,7 @@ class StockItemCreate(AjaxCreateView):
item = form.save(commit=False) item = form.save(commit=False)
item.user = self.request.user item.user = self.request.user
item.save() item.save(user=self.request.user)
return item return item
@ -1645,7 +1645,7 @@ class StockItemCreate(AjaxCreateView):
item = form.save(commit=False) item = form.save(commit=False)
item.user = self.request.user item.user = self.request.user
item.save() item.save(user=self.request.user)
return item return item
@ -1661,7 +1661,7 @@ class StockLocationDelete(AjaxDeleteView):
ajax_template_name = 'stock/location_delete.html' ajax_template_name = 'stock/location_delete.html'
context_object_name = 'location' context_object_name = 'location'
ajax_form_title = _('Delete Stock Location') ajax_form_title = _('Delete Stock Location')
role_required = 'stock.delete' role_required = 'stock_location.delete'
class StockItemDelete(AjaxDeleteView): class StockItemDelete(AjaxDeleteView):

View File

@ -35,6 +35,7 @@ InvenTree | {% trans "Index" %}
{% if roles.purchase_order.view %} {% if roles.purchase_order.view %}
{% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %} {% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
{% endif %} {% endif %}
{% include "InvenTree/po_overdue.html" with collapse_id="po_overdue" %}
{% if roles.sales_order.view %} {% if roles.sales_order.view %}
{% include "InvenTree/so_outstanding.html" with collapse_id="so_outstanding" %} {% include "InvenTree/so_outstanding.html" with collapse_id="so_outstanding" %}
{% include "InvenTree/so_overdue.html" with collapse_id="so_overdue" %} {% include "InvenTree/so_overdue.html" with collapse_id="so_overdue" %}
@ -130,6 +131,14 @@ loadPurchaseOrderTable("#po-outstanding-table", {
} }
}); });
loadPurchaseOrderTable("#po-overdue-table", {
url: "{% url 'api-po-list' %}",
params: {
supplier_detail: true,
overdue: true,
}
});
loadSalesOrderTable("#so-outstanding-table", { loadSalesOrderTable("#so-outstanding-table", {
url: "{% url 'api-so-list' %}", url: "{% url 'api-so-list' %}",
params: { params: {
@ -158,6 +167,7 @@ loadSalesOrderTable("#so-overdue-table", {
{% include "InvenTree/index/on_load.html" with label="stock-to-build" %} {% include "InvenTree/index/on_load.html" with label="stock-to-build" %}
{% include "InvenTree/index/on_load.html" with label="po-outstanding" %} {% include "InvenTree/index/on_load.html" with label="po-outstanding" %}
{% include "InvenTree/index/on_load.html" with label="po-overdue" %}
{% include "InvenTree/index/on_load.html" with label="so-outstanding" %} {% include "InvenTree/index/on_load.html" with label="so-outstanding" %}
{% include "InvenTree/index/on_load.html" with label="so-overdue" %} {% include "InvenTree/index/on_load.html" with label="so-overdue" %}

View File

@ -0,0 +1,15 @@
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-calendar-times icon-header'></span>
{% trans "Overdue Purchase Orders" %}<span class='badge' id='po-overdue-count'><span class='fas fa-spin fa-spinner'></span></span>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='po-overdue-table'>
</table>
{% endblock %}

View File

@ -18,6 +18,7 @@
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %} {% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %} {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
<tr><td colspan='5 '></td></tr> <tr><td colspan='5 '></td></tr>
{% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %} {% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}
{% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %} {% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %}

View File

@ -255,6 +255,38 @@ function loadBomTable(table, options) {
}); });
*/ */
} }
cols.push(
{
'field': 'can_build',
'title': '{% trans "Can Build" %}',
formatter: function(value, row, index, field) {
var can_build = 0;
if (row.quantity > 0) {
can_build = row.sub_part_detail.stock / row.quantity;
}
return +can_build.toFixed(2);
},
sorter: function(valA, valB, rowA, rowB) {
// Function to sort the "can build" quantity
var cb_a = 0;
var cb_b = 0;
if (rowA.quantity > 0) {
cb_a = rowA.sub_part_detail.stock / rowA.quantity;
}
if (rowB.quantity > 0) {
cb_b = rowB.sub_part_detail.stock / rowB.quantity;
}
return (cb_a > cb_b) ? 1 : -1;
},
sortable: true,
}
)
// Part notes // Part notes
cols.push( cols.push(

View File

@ -141,9 +141,9 @@ function loadPurchaseOrderTable(table, options) {
switchable: false, switchable: false,
}, },
{ {
sortable: true,
field: 'reference', field: 'reference',
title: '{% trans "Purchase Order" %}', title: '{% trans "Purchase Order" %}',
sortable: true,
switchable: false, switchable: false,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
@ -153,13 +153,19 @@ function loadPurchaseOrderTable(table, options) {
value = `${prefix}${value}`; value = `${prefix}${value}`;
} }
return renderLink(value, `/order/purchase-order/${row.pk}/`); var html = renderLink(value, `/order/purchase-order/${row.pk}/`);
if (row.overdue) {
html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Order is overdue" %}');
}
return html;
} }
}, },
{ {
sortable: true,
field: 'supplier_detail', field: 'supplier_detail',
title: '{% trans "Supplier" %}', title: '{% trans "Supplier" %}',
sortable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/purchase-orders/`); return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/purchase-orders/`);
} }
@ -170,27 +176,32 @@ function loadPurchaseOrderTable(table, options) {
sortable: true, sortable: true,
}, },
{ {
sortable: true,
field: 'description', field: 'description',
title: '{% trans "Description" %}', title: '{% trans "Description" %}',
sortable: true,
}, },
{ {
sortable: true,
field: 'status', field: 'status',
title: '{% trans "Status" %}', title: '{% trans "Status" %}',
sortable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return purchaseOrderStatusDisplay(row.status, row.status_text); return purchaseOrderStatusDisplay(row.status, row.status_text);
} }
}, },
{ {
sortable: true,
field: 'creation_date', field: 'creation_date',
title: '{% trans "Date" %}', title: '{% trans "Date" %}',
sortable: true,
}, },
{ {
field: 'target_date',
title: '{% trans "Target Date" %}',
sortable: true, sortable: true,
},
{
field: 'line_items', field: 'line_items',
title: '{% trans "Items" %}' title: '{% trans "Items" %}',
sortable: true,
}, },
], ],
}); });

View File

@ -6,8 +6,18 @@
* Requires api.js to be loaded first * Requires api.js to be loaded first
*/ */
/* Functions for interacting with stock management forms
*/ function stockStatusCodes() {
return [
{% for code in StockStatus.list %}
{
key: {{ code.key }},
text: "{{ code.value }}",
},
{% endfor %}
];
}
function removeStockRow(e) { function removeStockRow(e) {
// Remove a selected row from a stock modal form // Remove a selected row from a stock modal form
@ -590,6 +600,11 @@ function loadStockTable(table, options) {
return locationDetail(row); return locationDetail(row);
} }
}, },
{
field: 'stocktake_date',
title: '{% trans "Stocktake" %}',
sortable: true,
},
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %} {% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
{% if expiry %} {% if expiry %}
{ {
@ -689,6 +704,93 @@ function loadStockTable(table, options) {
}); });
}); });
$("#multi-item-set-status").click(function() {
// Select and set the STATUS field for selected stock items
var selections = $("#stock-table").bootstrapTable('getSelections');
// Select stock status
var modal = '#modal-form';
var status_list = makeOptionsList(
stockStatusCodes(),
function(item) {
return item.text;
},
function (item) {
return item.key;
}
);
// Add an empty option at the start of the list
status_list.unshift('<option value="">---------</option>');
// Construct form
var html = `
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
<div class='form-group'>
<label class='control-label requiredField' for='id_status'>
{% trans "Stock Status" %}
</label>
<div class='controls'>
<select id='id_status' class='select form-control' name='label'>
${status_list}
</select>
</div>
</div>
</form>`;
openModal({
modal: modal,
});
modalEnable(modal, true);
modalSetTitle(modal, '{% trans "Set Stock Status" %}');
modalSetContent(modal, html);
attachSelect(modal);
modalSubmit(modal, function() {
var label = $(modal).find('#id_status');
var status_code = label.val();
closeModal(modal);
if (!status_code) {
showAlertDialog(
'{% trans "Select Status Code" %}',
'{% trans "Status code must be selected" %}'
);
return;
}
var requests = [];
selections.forEach(function(item) {
var url = `/api/stock/${item.pk}/`;
requests.push(
inventreePut(
url,
{
status: status_code,
},
{
method: 'PATCH',
success: function() {
}
}
)
);
});
$.when.apply($, requests).then(function() {
$("#stock-table").bootstrapTable('refresh');
});
})
});
$("#multi-item-delete").click(function() { $("#multi-item-delete").click(function() {
var selections = $("#stock-table").bootstrapTable("getSelections"); var selections = $("#stock-table").bootstrapTable("getSelections");

View File

@ -214,6 +214,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool', type: 'bool',
title: '{% trans "Outstanding" %}', title: '{% trans "Outstanding" %}',
}, },
overdue: {
type: 'bool',
title: '{% trans "Overdue" %}',
},
}; };
} }

View File

@ -24,6 +24,7 @@
<li><a href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li> <li><a href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
<li><a href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li> <li><a href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
<li><a href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li> <li><a href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
<li><a href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>
{% endif %} {% endif %}
{% if roles.stock.delete %} {% if roles.stock.delete %}
<li><a href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'><span class='fas fa-trash-alt'></span> {% trans "Delete Stock" %}</a></li> <li><a href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'><span class='fas fa-trash-alt'></span> {% trans "Delete Stock" %}</a></li>

View File

@ -30,6 +30,8 @@ class RuleSetInline(admin.TabularInline):
max_num = len(RuleSet.RULESET_CHOICES) max_num = len(RuleSet.RULESET_CHOICES)
min_num = 1 min_num = 1
extra = 0 extra = 0
# TODO: find better way to order inlines
ordering = ['name']
class InvenTreeGroupAdminForm(forms.ModelForm): class InvenTreeGroupAdminForm(forms.ModelForm):
@ -87,7 +89,8 @@ class RoleGroupAdmin(admin.ModelAdmin):
RuleSetInline, RuleSetInline,
] ]
list_display = ('name', 'admin', 'part', 'stock', 'build', 'purchase_order', 'sales_order') list_display = ('name', 'admin', 'part_category', 'part', 'stock_location',
'stock_item', 'build', 'purchase_order', 'sales_order')
def get_rule_set(self, obj, rule_set_type): def get_rule_set(self, obj, rule_set_type):
''' Return list of permissions for the given ruleset ''' ''' Return list of permissions for the given ruleset '''
@ -130,10 +133,16 @@ class RoleGroupAdmin(admin.ModelAdmin):
def admin(self, obj): def admin(self, obj):
return self.get_rule_set(obj, 'admin') return self.get_rule_set(obj, 'admin')
def part_category(self, obj):
return self.get_rule_set(obj, 'part_category')
def part(self, obj): def part(self, obj):
return self.get_rule_set(obj, 'part') return self.get_rule_set(obj, 'part')
def stock(self, obj): def stock_location(self, obj):
return self.get_rule_set(obj, 'stock_location')
def stock_item(self, obj):
return self.get_rule_set(obj, 'stock') return self.get_rule_set(obj, 'stock')
def build(self, obj): def build(self, obj):

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2021-01-13 19:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0003_auto_20201005_2227'),
]
operations = [
migrations.AlterField(
model_name='ruleset',
name='name',
field=models.CharField(choices=[('admin', 'Admin'), ('part_category', 'Part Categories'), ('part', 'Parts'), ('stock_location', 'Stock Locations'), ('stock', 'Stock Items'), ('build', 'Build Orders'), ('purchase_order', 'Purchase Orders'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50),
),
]

View File

@ -25,8 +25,10 @@ class RuleSet(models.Model):
RULESET_CHOICES = [ RULESET_CHOICES = [
('admin', _('Admin')), ('admin', _('Admin')),
('part_category', _('Part Categories')),
('part', _('Parts')), ('part', _('Parts')),
('stock', _('Stock')), ('stock_location', _('Stock Locations')),
('stock', _('Stock Items')),
('build', _('Build Orders')), ('build', _('Build Orders')),
('purchase_order', _('Purchase Orders')), ('purchase_order', _('Purchase Orders')),
('sales_order', _('Sales Orders')), ('sales_order', _('Sales Orders')),
@ -48,21 +50,25 @@ class RuleSet(models.Model):
'authtoken_token', 'authtoken_token',
'users_ruleset', 'users_ruleset',
], ],
'part_category': [
'part_partcategory',
'part_partcategoryparametertemplate',
],
'part': [ 'part': [
'part_part', 'part_part',
'part_bomitem', 'part_bomitem',
'part_partcategory',
'part_partattachment', 'part_partattachment',
'part_partsellpricebreak', 'part_partsellpricebreak',
'part_parttesttemplate', 'part_parttesttemplate',
'part_partparametertemplate', 'part_partparametertemplate',
'part_partparameter', 'part_partparameter',
'part_partrelated', 'part_partrelated',
'part_partcategoryparametertemplate', ],
'stock_location': [
'stock_stocklocation',
], ],
'stock': [ 'stock': [
'stock_stockitem', 'stock_stockitem',
'stock_stocklocation',
'stock_stockitemattachment', 'stock_stockitemattachment',
'stock_stockitemtracking', 'stock_stockitemtracking',
'stock_stockitemtestresult', 'stock_stockitemtestresult',