Compare commits

..

20 Commits

Author SHA1 Message Date
github-actions[bot]
e2a092ea04 Remove trailing slash from SITE_URL (#9559) (#9560)
- ref: https://github.com/inventree/InvenTree/discussions/9552
- ref: https://stackoverflow.com/questions/56404930/when-trying-set-corsheaders-in-settings-py-file

(cherry picked from commit 527652007e)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-04-22 12:50:17 +10:00
Oliver
f2770f0711 Url fix 2 (#9548) (#9550)
* Logic fix

* Playwright test

* Revert "Playwright test"

This reverts commit a63b23961e.

* Simplify test

* Cleanup test
2025-04-21 21:56:55 +10:00
github-actions[bot]
86513844f2 Fix for URL validation (#9539) (#9540)
* FIx for URL validation

* Further fixes

(cherry picked from commit 8d48f9cecd)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-04-20 08:31:42 +10:00
Oliver
230ddd926f Update version.py (#9468) 2025-04-07 10:15:39 +10:00
Oliver
8d206bd311 [UI] Row hover (#9465) (#9466)
* [UI] Row hover (#9465)

* Add red color

* Improve table cursor

- Show "pointer" (hand) icon when actions available
- Improve context menu

* Fix import
2025-04-06 15:21:17 +10:00
Oliver
cf60d809da Notification permissions (#9449) (#9464)
* Updated type hints

* Fix tooltip bug

* Check user when sending notification

* Fix test

* Update unit test

* More unit test fixes

* Tweak playwright tests
2025-04-05 22:09:35 +11:00
github-actions[bot]
b1a264bf2a Handle potential null header (#9462) (#9463)
(cherry picked from commit f66efa7733)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-04-05 09:52:47 +11:00
github-actions[bot]
1dd056fdf2 Wrap values() call in list to fix type error (#9460) (#9461)
(cherry picked from commit 721f56f36e)

Co-authored-by: Joe Rogers <1337joe@users.noreply.github.com>
2025-04-05 09:20:38 +11:00
github-actions[bot]
2a9d737157 Report cache fix (#9447) (#9448)
* Adjust allowed CORS headers

* Disable caching in template preview

(cherry picked from commit c4f98cd6a1)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-04-03 15:21:57 +11:00
github-actions[bot]
00470a844c Update overdue order notification (#9444) (#9445)
* Update overdue order notification

- Check individual line items too

* Fix typo

(cherry picked from commit 67bdf3162a)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-04-03 11:30:22 +11:00
github-actions[bot]
5e26394500 Serialize stock fix (#9441) (#9443)
* Fix bug which hid the "serialize stock" button

* Add playwright tests

* Adjust check

(cherry picked from commit a18b18a3fd)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-04-03 09:04:15 +11:00
github-actions[bot]
3c71d62d27 Docs tweak (#9422) (#9424)
(cherry picked from commit e30786b068)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-31 19:06:54 +11:00
github-actions[bot]
d881037bde [UI] Improve order parts wizard (#9389) (#9421)
* [UI] Improve order parts wizard

- Enhance placeholder text
- Precalculate order quantity

* Tweak playwright tests

* Simplify tests

(cherry picked from commit 66d5180d8f)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-31 18:47:50 +11:00
github-actions[bot]
9a58d87eec fixed small bug in doc report sample templates (#9415) (#9418)
* Add part full name to supplier part table

* Add context variables for sales order report to the docs

* Added more context variables on orders to the docs

* fixed small bug in doc report sample templates

(cherry picked from commit 7b994a3d07)

Co-authored-by: Michael <michael@buchmann.ruhr>
2025-03-31 07:59:19 +11:00
github-actions[bot]
1019e9cc8d Docker updates (#9414) (#9417)
* Typo fix

* Examples to .env file

(cherry picked from commit b116e09717)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-31 07:59:05 +11:00
Oliver
8b59d8c04a Improve custom maintenance mode backend (#9396) (#9397)
* Improve custom maintenance mode backend (#9396)

* Improve custom maintenance mode backend

- Utilizing global settings functions
- Will use global cache if available
- Fewer DB hits per request

* Twaeak query limits

* Tweak test
2025-03-27 19:53:16 +11:00
github-actions[bot]
ccaf3459ce use dayjs in datefield (#9380) (#9381)
(cherry picked from commit 2bd26c0f49)

Co-authored-by: Jacob Felknor <jacobfelknor073@gmail.com>
2025-03-26 10:56:33 +11:00
Oliver
4987149979 Bug fix for activating plugins via UI (#9338) (#9370) 2025-03-24 22:08:38 +11:00
github-actions[bot]
02876f3c54 [UI] Edit fix (#9367) (#9368)
* Fix for editing stock location

* Fix for editing part category

(cherry picked from commit 8997f193c9)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-24 20:45:58 +11:00
Oliver
ff4df83ccd Update version.py (#9325)
Bump version number to 0.17.10
2025-03-17 23:33:26 +11:00
37 changed files with 372 additions and 118 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
)}
</>
);

View File

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

View File

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

View File

@@ -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: {}
});
}

View File

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

View File

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

View File

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

View File

@@ -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: () => {

View File

@@ -0,0 +1,5 @@
/* mantine-datatable overrides */
.mantine-datatable-pointer-cursor,
.mantine-datatable-context-menu-cursor {
cursor: pointer;
}

View File

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

View File

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

View File

@@ -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');

View File

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

View File

@@ -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();
});