mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-21 06:10:28 -06:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2a092ea04 | ||
|
|
f2770f0711 | ||
|
|
86513844f2 | ||
|
|
230ddd926f | ||
|
|
8d206bd311 | ||
|
|
cf60d809da | ||
|
|
b1a264bf2a | ||
|
|
1dd056fdf2 | ||
|
|
2a9d737157 | ||
|
|
00470a844c | ||
|
|
5e26394500 | ||
|
|
3c71d62d27 | ||
|
|
d881037bde | ||
|
|
9a58d87eec | ||
|
|
1019e9cc8d | ||
|
|
8b59d8c04a | ||
|
|
ccaf3459ce | ||
|
|
4987149979 | ||
|
|
02876f3c54 | ||
|
|
ff4df83ccd |
@@ -8,8 +8,10 @@ COMPOSE_PROJECT_NAME=inventree
|
||||
# InvenTree version tag (e.g. 'stable' / 'latest' / 'x.x.x')
|
||||
INVENTREE_TAG=stable
|
||||
|
||||
# InvenTree server URL - update this to match your host
|
||||
# InvenTree server URL - update this to match your server URL
|
||||
INVENTREE_SITE_URL="http://inventree.localhost"
|
||||
#INVENTREE_SITE_URL="http://192.168.1.2" # You can specify a local IP address here
|
||||
#INVENTREE_SITE_URL="https://inventree.my-domain.com" # Or a public domain name (which you control)
|
||||
|
||||
# Specify the location of the external data volume
|
||||
# By default, placed in local directory 'inventree-data'
|
||||
@@ -25,7 +27,7 @@ INVENTREE_PLUGINS_ENABLED=True
|
||||
INVENTREE_AUTO_UPDATE=True
|
||||
|
||||
# InvenTree superuser account details
|
||||
# Un-comment (and complete) these lines to auto-create an admin acount
|
||||
# Un-comment (and complete) these lines to auto-create an admin account
|
||||
#INVENTREE_ADMIN_USER=
|
||||
#INVENTREE_ADMIN_PASSWORD=
|
||||
#INVENTREE_ADMIN_EMAIL=
|
||||
|
||||
@@ -33,6 +33,8 @@ Result: {{ myvar }}
|
||||
{% endraw %}
|
||||
```
|
||||
|
||||
Note the use of the `as` keyword to assign the output of the function to a variable. This can be used to assign the result of a function to a named variable, which can then be used later in the template.
|
||||
|
||||
## Data Structure Access
|
||||
|
||||
A number of helper functions are available for accessing data contained in a particular structure format:
|
||||
|
||||
@@ -33,7 +33,7 @@ The following report templates are provided "out of the box" and can be used as
|
||||
|
||||
### Purchase Order
|
||||
|
||||
{{ templatefile("report/inventree_bill_of_materials_report.html") }}
|
||||
{{ templatefile("report/inventree_purchase_order_report.html") }}
|
||||
|
||||
### Return Order
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""Custom backend implementations."""
|
||||
"""Custom backend implementation for maintenance-mode."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
|
||||
from maintenance_mode.backends import AbstractStateBackend
|
||||
|
||||
import common.models
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -28,15 +26,11 @@ class InvenTreeMaintenanceModeBackend(AbstractStateBackend):
|
||||
bool: True if maintenance mode is active, False otherwise.
|
||||
"""
|
||||
try:
|
||||
setting = common.models.InvenTreeSetting.objects.get(key=self.SETTING_KEY)
|
||||
value = str(setting.value).strip()
|
||||
except common.models.InvenTreeSetting.DoesNotExist:
|
||||
# Database is accessible, but setting is not available - assume False
|
||||
return False
|
||||
except (IntegrityError, OperationalError, ProgrammingError):
|
||||
value = get_global_setting(self.SETTING_KEY)
|
||||
except Exception:
|
||||
# Database is inaccessible - assume we are not in maintenance mode
|
||||
logger.debug('Failed to read maintenance mode state - assuming True')
|
||||
return True
|
||||
logger.debug('Failed to read maintenance mode state - assuming False')
|
||||
return False
|
||||
|
||||
# Extract timestamp from string
|
||||
try:
|
||||
@@ -65,21 +59,24 @@ class InvenTreeMaintenanceModeBackend(AbstractStateBackend):
|
||||
# Blank timestamp means maintenance mode is not active
|
||||
timestamp = ''
|
||||
|
||||
while retries > 0:
|
||||
try:
|
||||
common.models.InvenTreeSetting.set_setting(self.SETTING_KEY, timestamp)
|
||||
r = retries
|
||||
|
||||
while r > 0:
|
||||
try:
|
||||
set_global_setting(self.SETTING_KEY, timestamp)
|
||||
# Read the value back to confirm
|
||||
if self.get_value() == value:
|
||||
break
|
||||
except (IntegrityError, OperationalError, ProgrammingError):
|
||||
except Exception:
|
||||
# In the database is locked, then
|
||||
logger.debug(
|
||||
'Failed to set maintenance mode state (%s retries left)', retries
|
||||
'Failed to set maintenance mode state (%s retries left)', r
|
||||
)
|
||||
time.sleep(0.1)
|
||||
|
||||
retries -= 1
|
||||
r -= 1
|
||||
|
||||
if retries == 0:
|
||||
logger.warning('Failed to set maintenance mode state')
|
||||
if r == 0:
|
||||
logger.warning(
|
||||
'Failed to set maintenance mode state after %s retries', retries
|
||||
)
|
||||
|
||||
@@ -36,9 +36,11 @@ class InvenTreeRestURLField(RestURLField):
|
||||
"""Override default validation behaviour for this field type."""
|
||||
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
|
||||
|
||||
if not strict_urls and data is not empty and '://' not in data:
|
||||
# Validate as if there were a schema provided
|
||||
data = 'http://' + data
|
||||
if not strict_urls and data is not empty and data is not None:
|
||||
data = str(data).strip()
|
||||
if data and '://' not in data:
|
||||
# Validate as if there were a schema provided
|
||||
data = 'http://' + data
|
||||
|
||||
return super().run_validation(data=data)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from django.http import Http404
|
||||
|
||||
import pytz
|
||||
import structlog
|
||||
from corsheaders.defaults import default_headers as default_cors_headers
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from InvenTree.cache import get_cache_config, is_global_cache_enabled
|
||||
@@ -1094,6 +1095,7 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True
|
||||
SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None)
|
||||
|
||||
if SITE_URL:
|
||||
SITE_URL = str(SITE_URL).strip().rstrip('/')
|
||||
logger.info('Using Site URL: %s', SITE_URL)
|
||||
|
||||
# Check that the site URL is valid
|
||||
@@ -1290,6 +1292,11 @@ CORS_ALLOWED_ORIGIN_REGEXES = get_setting(
|
||||
typecast=list,
|
||||
)
|
||||
|
||||
# Allow extra CORS headers in DEBUG mode
|
||||
# Required for serving /static/ and /media/ files
|
||||
if DEBUG:
|
||||
CORS_ALLOW_HEADERS = (*default_cors_headers, 'cache-control', 'pragma', 'expires')
|
||||
|
||||
# In debug mode allow CORS requests from localhost
|
||||
# This allows connection from the frontend development server
|
||||
if DEBUG:
|
||||
|
||||
@@ -190,26 +190,41 @@ class InvenTreeTaskTests(TestCase):
|
||||
from common.models import NotificationEntry, NotificationMessage
|
||||
|
||||
# Create a staff user (to ensure notifications are sent)
|
||||
User.objects.create_user(username='staff', password='staffpass', is_staff=True)
|
||||
user = User.objects.create_user(
|
||||
username='staff', password='staffpass', is_staff=True
|
||||
)
|
||||
|
||||
n_tasks = Task.objects.count()
|
||||
n_entries = NotificationEntry.objects.count()
|
||||
n_messages = NotificationMessage.objects.count()
|
||||
|
||||
test_data = {
|
||||
'name': 'failed_task',
|
||||
'func': 'InvenTree.tasks.failed_task',
|
||||
'group': 'test',
|
||||
'success': False,
|
||||
'started': timezone.now(),
|
||||
'stopped': timezone.now(),
|
||||
'attempt_count': 10,
|
||||
}
|
||||
|
||||
# Create a 'failed' task in the database
|
||||
# Note: The 'attempt count' is set to 10 to ensure that the task is properly marked as 'failed'
|
||||
Task.objects.create(
|
||||
id=n_tasks + 1,
|
||||
name='failed_task',
|
||||
func='InvenTree.tasks.failed_task',
|
||||
group='test',
|
||||
success=False,
|
||||
started=timezone.now(),
|
||||
stopped=timezone.now(),
|
||||
attempt_count=10,
|
||||
)
|
||||
Task.objects.create(id=n_tasks + 1, **test_data)
|
||||
|
||||
# A new notification entry should be created
|
||||
# A new notification entry should NOT be created (yet) - due to lack of permission for the user
|
||||
self.assertEqual(NotificationEntry.objects.count(), n_entries + 0)
|
||||
self.assertEqual(NotificationMessage.objects.count(), n_messages + 0)
|
||||
|
||||
# Give them all the permissions
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
|
||||
# Create a 'failed' task in the database
|
||||
# Note: The 'attempt count' is set to 10 to ensure that the task is properly marked as 'failed'
|
||||
Task.objects.create(id=n_tasks + 2, **test_data)
|
||||
|
||||
# A new notification entry should be created (as the user now has permission to see it)
|
||||
self.assertEqual(NotificationEntry.objects.count(), n_entries + 1)
|
||||
self.assertEqual(NotificationMessage.objects.count(), n_messages + 1)
|
||||
|
||||
|
||||
@@ -434,17 +434,27 @@ class ValidatorTest(TestCase):
|
||||
link='www.google.com',
|
||||
)
|
||||
|
||||
# Check that a blank URL is acceptable
|
||||
Part.objects.create(
|
||||
name=f'Part {n + 1}', description='Missing link', category=cat, link=''
|
||||
)
|
||||
|
||||
# With strict URL validation
|
||||
InvenTreeSetting.set_setting('INVENTREE_STRICT_URLS', True, None)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
Part.objects.create(
|
||||
name=f'Part {n + 1}',
|
||||
name=f'Part {n + 2}',
|
||||
description='Link without schema',
|
||||
category=cat,
|
||||
link='www.google.com',
|
||||
)
|
||||
|
||||
# Check that a blank URL is acceptable
|
||||
Part.objects.create(
|
||||
name=f'Part {n + 3}', description='Missing link', category=cat, link=''
|
||||
)
|
||||
|
||||
|
||||
class FormatTest(TestCase):
|
||||
"""Unit tests for custom string formatting functionality."""
|
||||
|
||||
@@ -9,7 +9,7 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.db import connections, models
|
||||
from django.http.response import StreamingHttpResponse
|
||||
from django.test import TestCase
|
||||
@@ -23,16 +23,23 @@ from plugin import registry
|
||||
from plugin.models import PluginConfig
|
||||
|
||||
|
||||
def addUserPermission(user, permission):
|
||||
"""Shortcut function for adding a certain permission to a user."""
|
||||
perm = Permission.objects.get(codename=permission)
|
||||
user.user_permissions.add(perm)
|
||||
def addUserPermission(user: User, app_name: str, model_name: str, perm: str) -> None:
|
||||
"""Add a specific permission for the provided user.
|
||||
|
||||
Arguments:
|
||||
user: The user to add the permission to
|
||||
app_name: The name of the app (e.g. 'part')
|
||||
model_name: The name of the model (e.g. 'location')
|
||||
perm: The permission to add (e.g. 'add', 'change', 'delete', 'view')
|
||||
"""
|
||||
# Get the permission object
|
||||
permission = Permission.objects.get(
|
||||
content_type__model=model_name, codename=f'{perm}_{model_name}'
|
||||
)
|
||||
|
||||
def addUserPermissions(user, permissions):
|
||||
"""Shortcut function for adding multiple permissions to a user."""
|
||||
for permission in permissions:
|
||||
addUserPermission(user, permission)
|
||||
# Add the permission to the user
|
||||
user.user_permissions.add(permission)
|
||||
user.save()
|
||||
|
||||
|
||||
def getMigrationFileNames(app):
|
||||
|
||||
@@ -65,7 +65,10 @@ class AllowedURLValidator(validators.URLValidator):
|
||||
# Determine if 'strict' URL validation is required (i.e. if the URL must have a schema prefix)
|
||||
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
|
||||
|
||||
if not strict_urls:
|
||||
if value is not None:
|
||||
value = str(value).strip()
|
||||
|
||||
if value and not strict_urls:
|
||||
# Allow URLs which do not have a provided schema
|
||||
if '://' not in value:
|
||||
# Validate as if it were http
|
||||
|
||||
@@ -18,7 +18,7 @@ from django.conf import settings
|
||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = '0.17.9'
|
||||
INVENTREE_SW_VERSION = '0.17.11'
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -805,7 +805,7 @@ class IconList(ListAPI):
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return a list of all available icon packages."""
|
||||
return get_icon_packs().values()
|
||||
return list(get_icon_packs().values())
|
||||
|
||||
|
||||
class SelectionListList(ListCreateAPI):
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db.models import Model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import common.models
|
||||
@@ -13,7 +14,7 @@ import InvenTree.helpers
|
||||
from InvenTree.ready import isImportingData, isRebuildingData
|
||||
from plugin import registry
|
||||
from plugin.models import NotificationUserSetting, PluginConfig
|
||||
from users.models import Owner
|
||||
from users.models import Owner, check_user_permission
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -29,7 +30,7 @@ class NotificationMethod:
|
||||
GLOBAL_SETTING = None
|
||||
USER_SETTING = None
|
||||
|
||||
def __init__(self, obj, category, targets, context) -> None:
|
||||
def __init__(self, obj: Model, category: str, targets: list, context) -> None:
|
||||
"""Check that the method is read.
|
||||
|
||||
This checks that:
|
||||
@@ -355,8 +356,19 @@ class InvenTreeNotificationBodies:
|
||||
)
|
||||
|
||||
|
||||
def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||
"""Send out a notification."""
|
||||
def trigger_notification(obj: Model, category, obj_ref: str = 'pk', **kwargs):
|
||||
"""Send out a notification.
|
||||
|
||||
Args:
|
||||
obj: The object (model instance) that is triggering the notification
|
||||
category: The category (label) for the notification
|
||||
obj_ref: The reference to the object that should be used for the notification
|
||||
kwargs: Additional arguments to pass to the notification method
|
||||
"""
|
||||
# Check if data is importing currently
|
||||
if isImportingData() or isRebuildingData():
|
||||
return
|
||||
|
||||
targets = kwargs.get('targets')
|
||||
target_fnc = kwargs.get('target_fnc')
|
||||
target_args = kwargs.get('target_args', [])
|
||||
@@ -365,10 +377,6 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||
context = kwargs.get('context', {})
|
||||
delivery_methods = kwargs.get('delivery_methods')
|
||||
|
||||
# Check if data is importing currently
|
||||
if isImportingData() or isRebuildingData():
|
||||
return
|
||||
|
||||
# Resolve object reference
|
||||
refs = [obj_ref, 'pk', 'id', 'uid']
|
||||
|
||||
@@ -436,26 +444,40 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
||||
)
|
||||
|
||||
if target_users:
|
||||
logger.info("Sending notification '%s' for '%s'", category, str(obj))
|
||||
# Filter out any users who are inactive, or do not have the required model permissions
|
||||
valid_users = list(
|
||||
filter(
|
||||
lambda u: u and u.is_active and check_user_permission(u, obj, 'view'),
|
||||
list(target_users),
|
||||
)
|
||||
)
|
||||
|
||||
# Collect possible methods
|
||||
if delivery_methods is None:
|
||||
delivery_methods = storage.methods or []
|
||||
else:
|
||||
delivery_methods = delivery_methods - IGNORED_NOTIFICATION_CLS
|
||||
if len(valid_users) > 0:
|
||||
logger.info(
|
||||
"Sending notification '%s' for '%s' to %s users",
|
||||
category,
|
||||
str(obj),
|
||||
len(valid_users),
|
||||
)
|
||||
|
||||
for method in delivery_methods:
|
||||
logger.info("Triggering notification method '%s'", method.METHOD_NAME)
|
||||
try:
|
||||
deliver_notification(method, obj, category, target_users, context)
|
||||
except NotImplementedError as error:
|
||||
# Allow any single notification method to fail, without failing the others
|
||||
logger.error(error)
|
||||
except Exception as error:
|
||||
logger.error(error)
|
||||
# Collect possible methods
|
||||
if delivery_methods is None:
|
||||
delivery_methods = storage.methods or []
|
||||
else:
|
||||
delivery_methods = delivery_methods - IGNORED_NOTIFICATION_CLS
|
||||
|
||||
# Set delivery flag
|
||||
common.models.NotificationEntry.notify(category, obj_ref_value)
|
||||
for method in delivery_methods:
|
||||
logger.info("Triggering notification method '%s'", method.METHOD_NAME)
|
||||
try:
|
||||
deliver_notification(method, obj, category, valid_users, context)
|
||||
except NotImplementedError as error:
|
||||
# Allow any single notification method to fail, without failing the others
|
||||
logger.error(error)
|
||||
except Exception as error:
|
||||
logger.error(error)
|
||||
|
||||
# Set delivery flag
|
||||
common.models.NotificationEntry.notify(category, obj_ref_value)
|
||||
else:
|
||||
logger.info("No possible users for notification '%s'", category)
|
||||
|
||||
@@ -479,12 +501,18 @@ def trigger_superuser_notification(plugin: PluginConfig, msg: str):
|
||||
|
||||
|
||||
def deliver_notification(
|
||||
cls: NotificationMethod, obj, category: str, targets, context: dict
|
||||
cls: NotificationMethod, obj: Model, category: str, targets: list, context: dict
|
||||
):
|
||||
"""Send notification with the provided class.
|
||||
|
||||
This:
|
||||
- Intis the method
|
||||
Arguments:
|
||||
cls: The class that should be used to send the notification
|
||||
obj: The object (model instance) that triggered the notification
|
||||
category: The category (label) for the notification
|
||||
targets: List of users that should receive the notification
|
||||
context: Context dictionary with additional information for the notification
|
||||
|
||||
- Initializes the method
|
||||
- Checks that there are valid targets
|
||||
- Runs the delivery setup
|
||||
- Sends notifications either via `send_bulk` or send`
|
||||
|
||||
@@ -28,6 +28,7 @@ from InvenTree.unit_test import (
|
||||
InvenTreeAPITestCase,
|
||||
InvenTreeTestCase,
|
||||
PluginMixin,
|
||||
addUserPermission,
|
||||
)
|
||||
from part.models import Part, PartParameterTemplate
|
||||
from plugin import registry
|
||||
@@ -1075,6 +1076,9 @@ class NotificationTest(InvenTreeAPITestCase):
|
||||
"""Tests for bulk deletion of user notifications."""
|
||||
from error_report.models import Error
|
||||
|
||||
# Ensure *this* user has permission to view error reports
|
||||
addUserPermission(self.user, 'error_report', 'error', 'view')
|
||||
|
||||
# Create some notification messages by throwing errors
|
||||
for _ii in range(10):
|
||||
Error.objects.create()
|
||||
@@ -1086,7 +1090,7 @@ class NotificationTest(InvenTreeAPITestCase):
|
||||
# However, one user is marked as inactive
|
||||
self.assertEqual(messages.count(), 20)
|
||||
|
||||
# Only 10 messages related to *this* user
|
||||
# Only messages related to *this* user
|
||||
my_notifications = messages.filter(user=self.user)
|
||||
self.assertEqual(my_notifications.count(), 10)
|
||||
|
||||
|
||||
@@ -72,9 +72,8 @@ def extract_column_names(data_file) -> list:
|
||||
headers = []
|
||||
|
||||
for idx, header in enumerate(data.headers):
|
||||
header = header.strip()
|
||||
|
||||
if header:
|
||||
header = str(header).strip()
|
||||
headers.append(header)
|
||||
else:
|
||||
# If the header is empty, generate a default header
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import common.notifications
|
||||
@@ -60,12 +61,28 @@ def check_overdue_purchase_orders():
|
||||
"""
|
||||
yesterday = datetime.now().date() - timedelta(days=1)
|
||||
|
||||
# Check for PurchaseOrder objects that have a target date of yesterday
|
||||
overdue_orders = order.models.PurchaseOrder.objects.filter(
|
||||
target_date=yesterday, status__in=PurchaseOrderStatusGroups.OPEN
|
||||
)
|
||||
|
||||
# Check for individual line items that are overdue
|
||||
overdue_lines = order.models.PurchaseOrderLineItem.objects.filter(
|
||||
target_date=yesterday,
|
||||
order__status__in=PurchaseOrderStatusGroups.OPEN,
|
||||
received__lt=F('quantity'),
|
||||
)
|
||||
|
||||
notified_orders = set()
|
||||
|
||||
for po in overdue_orders:
|
||||
notify_overdue_purchase_order(po)
|
||||
notified_orders.add(po.pk)
|
||||
|
||||
for line in overdue_lines:
|
||||
if line.order.pk not in notified_orders:
|
||||
notify_overdue_purchase_order(line.order)
|
||||
notified_orders.add(line.order.pk)
|
||||
|
||||
|
||||
def notify_overdue_sales_order(so: order.models.SalesOrder):
|
||||
@@ -113,8 +130,22 @@ def check_overdue_sales_orders():
|
||||
target_date=yesterday, status__in=SalesOrderStatusGroups.OPEN
|
||||
)
|
||||
|
||||
overdue_lines = order.models.SalesOrderLineItem.objects.filter(
|
||||
target_date=yesterday,
|
||||
order__status__in=SalesOrderStatusGroups.OPEN,
|
||||
shipped__lt=F('quantity'),
|
||||
)
|
||||
|
||||
notified_orders = set()
|
||||
|
||||
for po in overdue_orders:
|
||||
notify_overdue_sales_order(po)
|
||||
notified_orders.add(po.pk)
|
||||
|
||||
for line in overdue_lines:
|
||||
if line.order.pk not in notified_orders:
|
||||
notify_overdue_sales_order(line.order)
|
||||
notified_orders.add(line.order.pk)
|
||||
|
||||
|
||||
def complete_sales_order_shipment(shipment_id: int, user_id: int) -> None:
|
||||
|
||||
@@ -203,8 +203,8 @@ class PurchaseOrderTest(OrderTest):
|
||||
self.LIST_URL, data={'limit': limit}, expected_code=200
|
||||
)
|
||||
|
||||
# Total database queries must be below 15, independent of the number of results
|
||||
self.assertLess(len(ctx), 15)
|
||||
# Total database queries must be below 20, independent of the number of results
|
||||
self.assertLess(len(ctx), 20)
|
||||
|
||||
for result in response.data['results']:
|
||||
self.assertIn('total_price', result)
|
||||
@@ -1340,8 +1340,8 @@ class SalesOrderTest(OrderTest):
|
||||
self.LIST_URL, data={'limit': limit}, expected_code=200
|
||||
)
|
||||
|
||||
# Total database queries must be less than 15
|
||||
self.assertLess(len(ctx), 15)
|
||||
# Total database queries must be less than 20
|
||||
self.assertLess(len(ctx), 20)
|
||||
|
||||
n = len(response.data['results'])
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import order.tasks
|
||||
from common.models import InvenTreeSetting, NotificationMessage
|
||||
from company.models import Company
|
||||
from InvenTree import status_codes as status
|
||||
from InvenTree.unit_test import addUserPermission
|
||||
from order.models import (
|
||||
SalesOrder,
|
||||
SalesOrderAllocation,
|
||||
@@ -318,7 +319,13 @@ class SalesOrderTest(TestCase):
|
||||
|
||||
def test_overdue_notification(self):
|
||||
"""Test overdue sales order notification."""
|
||||
self.order.created_by = get_user_model().objects.get(pk=3)
|
||||
user = get_user_model().objects.get(pk=3)
|
||||
|
||||
addUserPermission(user, 'order', 'salesorder', 'view')
|
||||
user.is_active = True
|
||||
user.save()
|
||||
|
||||
self.order.created_by = user
|
||||
self.order.responsible = Owner.create(obj=Group.objects.get(pk=2))
|
||||
self.order.target_date = datetime.now().date() - timedelta(days=1)
|
||||
self.order.save()
|
||||
|
||||
@@ -14,6 +14,7 @@ from djmoney.money import Money
|
||||
import common.models
|
||||
import order.tasks
|
||||
from company.models import Company, SupplierPart
|
||||
from InvenTree.unit_test import addUserPermission
|
||||
from order.status_codes import PurchaseOrderStatus
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockLocation
|
||||
@@ -320,6 +321,13 @@ class OrderTest(TestCase):
|
||||
"""
|
||||
po = PurchaseOrder.objects.get(pk=1)
|
||||
|
||||
# Ensure that the right users have the right permissions
|
||||
for user_id in [2, 4]:
|
||||
user = get_user_model().objects.get(pk=user_id)
|
||||
addUserPermission(user, 'order', 'purchaseorder', 'view')
|
||||
user.is_active = True
|
||||
user.save()
|
||||
|
||||
# Created by 'sam'
|
||||
po.created_by = get_user_model().objects.get(pk=4)
|
||||
|
||||
|
||||
@@ -498,7 +498,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
|
||||
PartCategory.objects.rebuild()
|
||||
|
||||
with self.assertNumQueriesLessThan(12):
|
||||
with self.assertNumQueriesLessThan(15):
|
||||
response = self.get(reverse('api-part-category-tree'), expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), PartCategory.objects.count())
|
||||
@@ -1821,8 +1821,8 @@ class PartListTests(PartAPITestBase):
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
self.get(url, query, expected_code=200)
|
||||
|
||||
# No more than 20 database queries
|
||||
self.assertLess(len(ctx), 20)
|
||||
# No more than 25 database queries
|
||||
self.assertLess(len(ctx), 25)
|
||||
|
||||
# Test 'category_detail' annotation
|
||||
for b in [False, True]:
|
||||
|
||||
@@ -21,7 +21,7 @@ from common.notifications import UIMessageNotification, storage
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
from InvenTree import version
|
||||
from InvenTree.templatetags import inventree_extras
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
from InvenTree.unit_test import InvenTreeTestCase, addUserPermission
|
||||
|
||||
from .models import (
|
||||
Part,
|
||||
@@ -807,6 +807,9 @@ class BaseNotificationIntegrationTest(InvenTreeTestCase):
|
||||
self.assertEqual(NotificationEntry.objects.all().count(), 0)
|
||||
|
||||
# Subscribe and run again
|
||||
addUserPermission(self.user, 'part', 'part', 'view')
|
||||
self.user.is_active = True
|
||||
self.user.save()
|
||||
self.part.set_starred(self.user, True)
|
||||
self.part.save()
|
||||
|
||||
|
||||
@@ -456,7 +456,7 @@ class StockLocationTest(StockAPITestCase):
|
||||
|
||||
StockLocation.objects.rebuild()
|
||||
|
||||
with self.assertNumQueriesLessThan(12):
|
||||
with self.assertNumQueriesLessThan(15):
|
||||
response = self.get(reverse('api-location-tree'), expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), StockLocation.objects.count())
|
||||
|
||||
@@ -681,7 +681,7 @@ def clear_user_role_cache(user: User):
|
||||
cache.delete(key)
|
||||
|
||||
|
||||
def check_user_permission(user: User, model, permission):
|
||||
def check_user_permission(user: User, model: models.Model, permission: str) -> bool:
|
||||
"""Check if the user has a particular permission against a given model type.
|
||||
|
||||
Arguments:
|
||||
@@ -696,7 +696,7 @@ def check_user_permission(user: User, model, permission):
|
||||
return user.has_perm(permission_name)
|
||||
|
||||
|
||||
def check_user_role(user: User, role, permission):
|
||||
def check_user_role(user: User, role: str, permission: str) -> bool:
|
||||
"""Check if a user has a particular role:permission combination.
|
||||
|
||||
If the user is a superuser, this will return True
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Group,
|
||||
type MantineColor,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -298,9 +299,12 @@ function TableAnchorValue(props: Readonly<FieldProps>) {
|
||||
value = data?.name;
|
||||
}
|
||||
|
||||
let color: MantineColor | undefined = undefined;
|
||||
|
||||
if (value === undefined) {
|
||||
value = data?.name ?? props.field_data?.backup_value ?? t`No name defined`;
|
||||
make_link = false;
|
||||
color = 'red';
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -310,7 +314,7 @@ function TableAnchorValue(props: Readonly<FieldProps>) {
|
||||
<Text>{value}</Text>
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text>{value}</Text>
|
||||
<Text c={color}>{value}</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -132,8 +132,17 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
||||
|
||||
api.get(templateUrl).then((response: any) => {
|
||||
if (response.data?.template) {
|
||||
// Fetch the raw template file from the server
|
||||
// Request that it is provided without any caching,
|
||||
// to ensure that we always get the latest version
|
||||
api
|
||||
.get(response.data.template)
|
||||
.get(response.data.template, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
Expires: '0'
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
codeRef.current = res.data;
|
||||
loadCodeToEditor(res.data);
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function DateField({
|
||||
let dv: Date | null = null;
|
||||
|
||||
if (field.value) {
|
||||
dv = new Date(field.value);
|
||||
dv = dayjs(field.value).toDate();
|
||||
}
|
||||
|
||||
// Ensure that the date is valid
|
||||
|
||||
@@ -38,11 +38,13 @@ interface PartOrderRecord {
|
||||
function SelectPartsStep({
|
||||
records,
|
||||
onRemovePart,
|
||||
onSelectQuantity,
|
||||
onSelectSupplierPart,
|
||||
onSelectPurchaseOrder
|
||||
}: {
|
||||
records: PartOrderRecord[];
|
||||
onRemovePart: (part: any) => void;
|
||||
onSelectQuantity: (partId: number, quantity: number) => void;
|
||||
onSelectSupplierPart: (partId: number, supplierPart: any) => void;
|
||||
onSelectPurchaseOrder: (partId: number, purchaseOrder: any) => void;
|
||||
}) {
|
||||
@@ -151,6 +153,7 @@ function SelectPartsStep({
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.supplier_part_list),
|
||||
model: ModelType.supplierpart,
|
||||
placeholder: t`Select supplier part`,
|
||||
required: true,
|
||||
value: record.supplier_part?.pk,
|
||||
onValueChange: (value, instance) => {
|
||||
@@ -189,6 +192,7 @@ function SelectPartsStep({
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.purchase_order_list),
|
||||
model: ModelType.purchaseorder,
|
||||
placeholder: t`Select purchase order`,
|
||||
disabled: !record.supplier_part?.supplier,
|
||||
value: record.purchase_order?.pk,
|
||||
filters: {
|
||||
@@ -213,6 +217,26 @@ function SelectPartsStep({
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
title: t`Quantity`,
|
||||
width: 125,
|
||||
render: (record: PartOrderRecord) => (
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
hideLabels={true}
|
||||
error={record.errors?.quantity}
|
||||
fieldDefinition={{
|
||||
field_type: 'number',
|
||||
required: true,
|
||||
value: record.quantity,
|
||||
onValueChange: (value) => {
|
||||
onSelectQuantity(record.part.pk, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessor: 'right_actions',
|
||||
title: ' ',
|
||||
@@ -288,6 +312,22 @@ export default function OrderPartsWizard({
|
||||
[selectedParts]
|
||||
);
|
||||
|
||||
// Select a quantity to order
|
||||
const selectQuantity = useCallback(
|
||||
(partId: number, quantity: number) => {
|
||||
const records = [...selectedParts];
|
||||
|
||||
records.forEach((record: PartOrderRecord, index: number) => {
|
||||
if (record.part.pk === partId) {
|
||||
records[index].quantity = quantity;
|
||||
}
|
||||
});
|
||||
|
||||
setSelectedParts(records);
|
||||
},
|
||||
[selectedParts]
|
||||
);
|
||||
|
||||
// Select a supplier part for a part
|
||||
const selectSupplierPart = useCallback(
|
||||
(partId: number, supplierPart: any) => {
|
||||
@@ -327,6 +367,7 @@ export default function OrderPartsWizard({
|
||||
<SelectPartsStep
|
||||
records={selectedParts}
|
||||
onRemovePart={removePart}
|
||||
onSelectQuantity={selectQuantity}
|
||||
onSelectSupplierPart={selectSupplierPart}
|
||||
onSelectPurchaseOrder={selectPurchaseOrder}
|
||||
/>
|
||||
@@ -400,11 +441,23 @@ export default function OrderPartsWizard({
|
||||
(record: PartOrderRecord) => record.part?.pk === part.pk
|
||||
)
|
||||
) {
|
||||
// TODO: Make this calculation generic and reusable
|
||||
// Calculate the "to order" quantity
|
||||
const required =
|
||||
(part.minimum_stock ?? 0) +
|
||||
(part.required_for_build_orders ?? 0) +
|
||||
(part.required_for_sales_orders ?? 0);
|
||||
const on_hand = part.total_in_stock ?? 0;
|
||||
const on_order = part.ordering ?? 0;
|
||||
const in_production = part.building ?? 0;
|
||||
|
||||
const to_order = required - on_hand - on_order - in_production;
|
||||
|
||||
records.push({
|
||||
part: part,
|
||||
supplier_part: undefined,
|
||||
purchase_order: undefined,
|
||||
quantity: 1,
|
||||
quantity: Math.max(to_order, 0),
|
||||
errors: {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import './styles/overrides.css';
|
||||
|
||||
import { api } from './App';
|
||||
import type { HostList } from './states/states';
|
||||
|
||||
@@ -341,7 +341,9 @@ export default function CategoryDetail() {
|
||||
}}
|
||||
actions={categoryActions}
|
||||
editAction={editCategory.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.partcategory)}
|
||||
editEnabled={
|
||||
!!category?.pk && user.hasChangePermission(ModelType.partcategory)
|
||||
}
|
||||
/>
|
||||
<PanelGroup
|
||||
pageKey='partcategory'
|
||||
|
||||
@@ -381,7 +381,10 @@ export default function Stock() {
|
||||
icon={location?.icon && <ApiIcon name={location?.icon} />}
|
||||
actions={locationActions}
|
||||
editAction={editLocation.open}
|
||||
editEnabled={user.hasChangePermission(ModelType.stocklocation)}
|
||||
editEnabled={
|
||||
!!location?.pk &&
|
||||
user.hasChangePermission(ModelType.stocklocation)
|
||||
}
|
||||
breadcrumbs={breadcrumbs}
|
||||
breadcrumbAction={() => {
|
||||
setTreeOpen(true);
|
||||
|
||||
@@ -743,10 +743,9 @@ export default function StockDetail() {
|
||||
name: t`Serialize`,
|
||||
tooltip: t`Serialize stock`,
|
||||
hidden:
|
||||
!canTransfer ||
|
||||
isBuilding ||
|
||||
serialized ||
|
||||
stockitem?.quantity != 1 ||
|
||||
stockitem?.quantity < 1 ||
|
||||
stockitem?.part_detail?.trackable != true,
|
||||
icon: <InvenTreeIcon icon='serial' iconProps={{ color: 'blue' }} />,
|
||||
onClick: () => {
|
||||
|
||||
5
src/frontend/src/styles/overrides.css
Normal file
5
src/frontend/src/styles/overrides.css
Normal file
@@ -0,0 +1,5 @@
|
||||
/* mantine-datatable overrides */
|
||||
.mantine-datatable-pointer-cursor,
|
||||
.mantine-datatable-context-menu-cursor {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Box, Stack } from '@mantine/core';
|
||||
import { Box, type MantineStyleProp, Stack } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useContextMenu } from 'mantine-contextmenu';
|
||||
import {
|
||||
type ContextMenuItemOptions,
|
||||
useContextMenu
|
||||
} from 'mantine-contextmenu';
|
||||
import {
|
||||
DataTable,
|
||||
type DataTableCellClickHandler,
|
||||
@@ -12,6 +15,7 @@ import type React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { IconArrowRight } from '@tabler/icons-react';
|
||||
import { api } from '../App';
|
||||
import { Boundary } from '../components/Boundary';
|
||||
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
@@ -86,7 +90,7 @@ export type InvenTreeTableProps<T = any> = {
|
||||
onRowClick?: (record: T, index: number, event: any) => void;
|
||||
onCellClick?: DataTableCellClickHandler<T>;
|
||||
modelType?: ModelType;
|
||||
rowStyle?: (record: T, index: number) => any;
|
||||
rowStyle?: (record: T, index: number) => MantineStyleProp | undefined;
|
||||
modelField?: string;
|
||||
onCellContextMenu?: (record: T, event: any) => void;
|
||||
minHeight?: number;
|
||||
@@ -568,6 +572,10 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
[props.onRowClick, props.onCellClick]
|
||||
);
|
||||
|
||||
const supportsContextMenu = useMemo(() => {
|
||||
return !!props.onCellContextMenu || !!props.rowActions || !!props.modelType;
|
||||
}, [props.onCellContextMenu, props.rowActions, props.modelType]);
|
||||
|
||||
// Callback when a cell is right-clicked
|
||||
const handleCellContextMenu = ({
|
||||
record,
|
||||
@@ -583,9 +591,13 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
}
|
||||
if (props.onCellContextMenu) {
|
||||
return props.onCellContextMenu(record, event);
|
||||
} else if (props.rowActions) {
|
||||
const empty = () => {};
|
||||
const items = props.rowActions(record).map((action) => ({
|
||||
}
|
||||
|
||||
const empty = () => {};
|
||||
let items: ContextMenuItemOptions[] = [];
|
||||
|
||||
if (props.rowActions) {
|
||||
items = props.rowActions(record).map((action) => ({
|
||||
key: action.title ?? '',
|
||||
title: action.title ?? '',
|
||||
color: action.color,
|
||||
@@ -594,10 +606,25 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
hidden: action.hidden,
|
||||
disabled: action.disabled
|
||||
}));
|
||||
return showContextMenu(items)(event);
|
||||
} else {
|
||||
return showContextMenu([])(event);
|
||||
}
|
||||
|
||||
if (props.modelType) {
|
||||
// Add action to navigate to the detail view
|
||||
const accessor = props.modelField ?? 'pk';
|
||||
const pk = resolveItem(record, accessor);
|
||||
const url = getDetailUrl(props.modelType, pk);
|
||||
items.push({
|
||||
key: 'detail',
|
||||
title: t`View details`,
|
||||
icon: <IconArrowRight />,
|
||||
onClick: (event: any) => {
|
||||
cancelEvent(event);
|
||||
navigateToLink(url, navigate, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return showContextMenu(items)(event);
|
||||
};
|
||||
|
||||
// pagination refresth table if pageSize changes
|
||||
@@ -643,6 +670,14 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
return optionalParamsa;
|
||||
}, [tableProps.enablePagination]);
|
||||
|
||||
const supportsCellClick = useMemo(() => {
|
||||
return !!(
|
||||
tableProps.onCellClick ||
|
||||
tableProps.onRowClick ||
|
||||
tableProps.modelType
|
||||
);
|
||||
}, [tableProps.onCellClick, tableProps.onRowClick, tableProps.modelType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap='xs'>
|
||||
@@ -683,12 +718,12 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
enableSelection ? onSelectedRecordsChange : undefined
|
||||
}
|
||||
rowExpansion={rowExpansion}
|
||||
rowStyle={tableProps.rowStyle}
|
||||
// rowStyle={rowStyleCallback}
|
||||
fetching={isFetching}
|
||||
noRecordsText={missingRecordsText}
|
||||
records={tableState.records}
|
||||
columns={dataColumns}
|
||||
onCellClick={handleCellClick}
|
||||
onCellClick={supportsCellClick ? handleCellClick : undefined}
|
||||
noHeader={tableProps.noHeader ?? false}
|
||||
defaultColumnProps={{
|
||||
noWrap: true,
|
||||
@@ -698,7 +733,9 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
overflow: 'hidden'
|
||||
})
|
||||
}}
|
||||
onCellContextMenu={handleCellContextMenu}
|
||||
onCellContextMenu={
|
||||
supportsContextMenu ? handleCellContextMenu : undefined
|
||||
}
|
||||
{...optionalParams}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -251,7 +251,7 @@ export default function PluginListTable() {
|
||||
pathParams: { key: selectedPluginKey },
|
||||
preFormContent: activateModalContent,
|
||||
fetchInitialData: false,
|
||||
method: 'POST',
|
||||
method: 'PATCH',
|
||||
successMessage: activate
|
||||
? t`The plugin was activated`
|
||||
: t`The plugin was deactivated`,
|
||||
|
||||
@@ -145,6 +145,8 @@ test('Purchase Orders - Order Parts', async ({ page }) => {
|
||||
await page.getByText('PRJ-PHO').click();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await page.getByLabel('number-field-quantity').fill('100');
|
||||
|
||||
// Add the part to the purchase order
|
||||
await page.getByLabel('action-button-add-to-selected').click();
|
||||
await page.getByLabel('number-field-quantity').fill('100');
|
||||
|
||||
@@ -152,6 +152,22 @@ test('Stock - Serial Numbers', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Stock - Serialize', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'stock/item/232/details' });
|
||||
|
||||
// Fill out with faulty serial numbers to check buttons and forms
|
||||
await page.getByLabel('action-menu-stock-operations').click();
|
||||
await page.getByLabel('action-menu-stock-operations-serialize').click();
|
||||
|
||||
await page.getByLabel('text-field-serial_numbers').fill('200-250');
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page
|
||||
.getByText('Group range 200-250 exceeds allowed quantity')
|
||||
.waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
/**
|
||||
* Test various 'actions' on the stock detail page
|
||||
*/
|
||||
|
||||
@@ -120,8 +120,6 @@ test('Forms - Supplier Validation', async ({ page, request }) => {
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Is prevented, due to uniqueness requirements
|
||||
await page
|
||||
.getByText('Company with this Company name and Email already exists')
|
||||
.waitFor();
|
||||
await page.getByText('Form Error').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user