mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-17 12:25:04 -06:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d2700fa6f | ||
|
|
9c170e6ed3 | ||
|
|
542a75ce58 | ||
|
|
06750358d6 | ||
|
|
6ccc4544be | ||
|
|
ac324cff14 | ||
|
|
98b1678402 | ||
|
|
da3ebacf6b | ||
|
|
24b2401f6a | ||
|
|
d12c335032 | ||
|
|
eae580deaf | ||
|
|
6207aa6c32 | ||
|
|
5ff7ce4703 | ||
|
|
0350623866 | ||
|
|
bb576b16d8 | ||
|
|
9705ea90c2 | ||
|
|
39dc2b17fd | ||
|
|
00646b0891 | ||
|
|
2b7627a940 | ||
|
|
048ece4797 |
@@ -237,11 +237,18 @@ class InvenTreeHostSettingsMiddleware(MiddlewareMixin):
|
||||
# Ensure that the settings are set correctly with the current request
|
||||
accessed_scheme = request._current_scheme_host
|
||||
if accessed_scheme and not accessed_scheme.startswith(settings.SITE_URL):
|
||||
msg = f'INVE-E7: The used path `{accessed_scheme}` does not match the SITE_URL `{settings.SITE_URL}`'
|
||||
logger.error(msg)
|
||||
return render(
|
||||
request, 'config_error.html', {'error_message': msg}, status=500
|
||||
)
|
||||
if (
|
||||
isinstance(settings.CSRF_TRUSTED_ORIGINS, list)
|
||||
and len(settings.CSRF_TRUSTED_ORIGINS) > 1
|
||||
):
|
||||
# The used url might not be the primary url - next check determines if in a trusted origins
|
||||
pass
|
||||
else:
|
||||
msg = f'INVE-E7: The used path `{accessed_scheme}` does not match the SITE_URL `{settings.SITE_URL}`'
|
||||
logger.error(msg)
|
||||
return render(
|
||||
request, 'config_error.html', {'error_message': msg}, status=500
|
||||
)
|
||||
|
||||
# Check trusted origins
|
||||
referer = urlsplit(accessed_scheme)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for middleware functions."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -87,36 +88,72 @@ class MiddlewareTests(InvenTreeTestCase):
|
||||
log_error('testpath')
|
||||
check(1)
|
||||
|
||||
def do_positive_test(self, response):
|
||||
"""Helper function to check for positive test results."""
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, 'INVE-E7')
|
||||
self.assertContains(response, 'window.INVENTREE_SETTINGS')
|
||||
|
||||
def test_site_url_checks(self):
|
||||
"""Test that the site URL check is correctly working."""
|
||||
# correctly set
|
||||
# simple setup
|
||||
with self.settings(
|
||||
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver']
|
||||
):
|
||||
response = self.client.get(reverse('web'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, 'INVE-E7')
|
||||
self.assertContains(response, 'window.INVENTREE_SETTINGS')
|
||||
self.do_positive_test(response)
|
||||
|
||||
# wrongly set site URL
|
||||
with self.settings(SITE_URL='https://example.com'):
|
||||
# simple setup with wildcard
|
||||
with self.settings(
|
||||
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://*.testserver']
|
||||
):
|
||||
response = self.client.get(reverse('web'))
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertContains(
|
||||
response,
|
||||
'INVE-E7: The used path `http://testserver` does not match',
|
||||
status_code=500,
|
||||
)
|
||||
self.assertNotContains(
|
||||
response, 'window.INVENTREE_SETTINGS', status_code=500
|
||||
)
|
||||
self.do_positive_test(response)
|
||||
|
||||
def test_site_url_checks_multi(self):
|
||||
"""Test that the site URL check is correctly working in a multi-site setup."""
|
||||
# multi-site setup with trusted origins
|
||||
with self.settings(
|
||||
SITE_URL='https://testserver.example.com',
|
||||
CSRF_TRUSTED_ORIGINS=[
|
||||
'http://testserver',
|
||||
'https://testserver.example.com',
|
||||
],
|
||||
):
|
||||
# this will run with testserver as host by default
|
||||
response = self.client.get(reverse('web'))
|
||||
self.do_positive_test(response)
|
||||
|
||||
# Now test with the "outside" url name
|
||||
response = self.client.get(
|
||||
'https://testserver.example.com/web/',
|
||||
SERVER_NAME='testserver.example.com',
|
||||
)
|
||||
self.do_positive_test(response)
|
||||
|
||||
# A non-trsuted origin must still fail in multi - origin setup
|
||||
response = self.client.get(
|
||||
'https://not-my-testserver.example.com/web/',
|
||||
SERVER_NAME='not-my-testserver.example.com',
|
||||
)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
# Even if it is a subdomain
|
||||
response = self.client.get(
|
||||
'https://not-my.testserver.example.com/web/',
|
||||
SERVER_NAME='not-my.testserver.example.com',
|
||||
)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
def test_site_url_checks_fails(self):
|
||||
"""Test that the site URL check is correctly failing.
|
||||
|
||||
Important for security.
|
||||
"""
|
||||
# wrongly set but in debug -> is ignored
|
||||
with self.settings(SITE_URL='https://example.com', DEBUG=True):
|
||||
response = self.client.get(reverse('web'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, 'INVE-E7')
|
||||
self.assertContains(response, 'window.INVENTREE_SETTINGS')
|
||||
self.do_positive_test(response)
|
||||
|
||||
# wrongly set cors
|
||||
with self.settings(
|
||||
@@ -133,10 +170,32 @@ class MiddlewareTests(InvenTreeTestCase):
|
||||
response, 'window.INVENTREE_SETTINGS', status_code=500
|
||||
)
|
||||
|
||||
# wrongly set site URL
|
||||
with self.settings(
|
||||
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://*.testserver']
|
||||
SITE_URL='https://example.com',
|
||||
CSRF_TRUSTED_ORIGINS=['http://localhost:8000'],
|
||||
):
|
||||
response = self.client.get(reverse('web'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, 'INVE-E7')
|
||||
self.assertContains(response, 'window.INVENTREE_SETTINGS')
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertContains(
|
||||
response, 'INVE-E7: The used path `http://testserver` ', status_code=500
|
||||
)
|
||||
self.assertNotContains(
|
||||
response, 'window.INVENTREE_SETTINGS', status_code=500
|
||||
)
|
||||
|
||||
# Log stuff # TODO remove
|
||||
print(
|
||||
'###DBG-TST###',
|
||||
'site',
|
||||
settings.SITE_URL,
|
||||
'trusted',
|
||||
settings.CSRF_TRUSTED_ORIGINS,
|
||||
)
|
||||
|
||||
# Check that the correct step triggers the error message
|
||||
self.assertContains(
|
||||
response,
|
||||
'INVE-E7: The used path `http://testserver` does not match',
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
@@ -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 = '1.0.0'
|
||||
INVENTREE_SW_VERSION = '1.0.2'
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -480,8 +480,8 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
def filter_allocated(self, queryset, name, value):
|
||||
"""Filter by whether each BuildLine is fully allocated."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(allocated__gte=F('quantity'))
|
||||
return queryset.filter(allocated__lt=F('quantity'))
|
||||
return queryset.filter(allocated__gte=F('quantity') - F('consumed'))
|
||||
return queryset.filter(allocated__lt=F('quantity') - F('consumed'))
|
||||
|
||||
consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed')
|
||||
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
"""Queryset filtering helper functions for the Build app."""
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Q, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import DecimalField, ExpressionWrapper, F, Max, Sum
|
||||
from django.db.models.functions import Coalesce, Greatest
|
||||
|
||||
|
||||
def annotate_allocated_quantity(queryset: Q) -> Q:
|
||||
"""Annotate the 'allocated' quantity for each build item in the queryset.
|
||||
|
||||
Arguments:
|
||||
queryset: The BuildLine queryset to annotate
|
||||
|
||||
"""
|
||||
queryset = queryset.prefetch_related('allocations')
|
||||
|
||||
return queryset.annotate(
|
||||
allocated=Coalesce(
|
||||
Sum('allocations__quantity'), 0, output_field=models.DecimalField()
|
||||
)
|
||||
def annotate_required_quantity():
|
||||
"""Annotate the 'required' quantity for each build item in the queryset."""
|
||||
# Note: The use of Max() here is intentional, to avoid aggregation issues in MySQL
|
||||
# Ref: https://github.com/inventree/InvenTree/pull/10398
|
||||
return Greatest(
|
||||
ExpressionWrapper(
|
||||
Max(F('quantity')) - Max(F('consumed')), output_field=DecimalField()
|
||||
),
|
||||
0,
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
|
||||
|
||||
def annotate_allocated_quantity():
|
||||
"""Annotate the 'allocated' quantity for each build item in the queryset."""
|
||||
return Coalesce(Sum('allocations__quantity'), 0, output_field=DecimalField())
|
||||
|
||||
@@ -29,7 +29,7 @@ import report.mixins
|
||||
import stock.models
|
||||
import users.models
|
||||
from build.events import BuildEvents
|
||||
from build.filters import annotate_allocated_quantity
|
||||
from build.filters import annotate_allocated_quantity, annotate_required_quantity
|
||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||
from build.validators import (
|
||||
generate_next_build_reference,
|
||||
@@ -1062,7 +1062,7 @@ class Build(
|
||||
|
||||
lines = self.untracked_line_items.all()
|
||||
lines = lines.exclude(bom_item__consumable=True)
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
lines = lines.annotate(allocated=annotate_allocated_quantity())
|
||||
|
||||
for build_line in lines:
|
||||
reduce_by = build_line.allocated - build_line.quantity
|
||||
@@ -1381,10 +1381,12 @@ class Build(
|
||||
elif tracked is False:
|
||||
lines = lines.filter(bom_item__sub_part__trackable=False)
|
||||
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
lines = lines.prefetch_related('allocations')
|
||||
|
||||
# Filter out any lines which have been fully allocated
|
||||
lines = lines.filter(allocated__lt=F('quantity'))
|
||||
lines = lines.annotate(
|
||||
allocated=annotate_allocated_quantity(),
|
||||
required=annotate_required_quantity(),
|
||||
).filter(allocated__lt=F('required'))
|
||||
|
||||
return lines
|
||||
|
||||
@@ -1436,10 +1438,14 @@ class Build(
|
||||
True if any BuildLine has been over-allocated.
|
||||
"""
|
||||
lines = self.build_lines.all().exclude(bom_item__consumable=True)
|
||||
lines = annotate_allocated_quantity(lines)
|
||||
|
||||
lines = lines.prefetch_related('allocations')
|
||||
|
||||
# Find any lines which have been over-allocated
|
||||
lines = lines.filter(allocated__gt=F('quantity'))
|
||||
lines = lines.annotate(
|
||||
allocated=annotate_allocated_quantity(),
|
||||
required=annotate_required_quantity(),
|
||||
).filter(allocated__gt=F('required'))
|
||||
|
||||
return lines.count() > 0
|
||||
|
||||
@@ -1644,19 +1650,30 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
|
||||
return allocated['q']
|
||||
|
||||
def unallocated_quantity(self):
|
||||
"""Return the unallocated quantity for this BuildLine."""
|
||||
return max(self.quantity - self.allocated_quantity(), 0)
|
||||
"""Return the unallocated quantity for this BuildLine.
|
||||
|
||||
- Start with the required quantity
|
||||
- Subtract the consumed quantity
|
||||
- Subtract the allocated quantity
|
||||
|
||||
Return the remaining quantity (or zero if negative)
|
||||
"""
|
||||
return max(self.quantity - self.consumed - self.allocated_quantity(), 0)
|
||||
|
||||
def is_fully_allocated(self):
|
||||
"""Return True if this BuildLine is fully allocated."""
|
||||
if self.bom_item.consumable:
|
||||
return True
|
||||
|
||||
return self.allocated_quantity() >= self.quantity
|
||||
required = max(0, self.quantity - self.consumed)
|
||||
|
||||
return self.allocated_quantity() >= required
|
||||
|
||||
def is_overallocated(self):
|
||||
"""Return True if this BuildLine is over-allocated."""
|
||||
return self.allocated_quantity() > self.quantity
|
||||
required = max(0, self.quantity - self.consumed)
|
||||
|
||||
return self.allocated_quantity() > required
|
||||
|
||||
def is_fully_consumed(self):
|
||||
"""Return True if this BuildLine is fully consumed."""
|
||||
|
||||
@@ -853,6 +853,78 @@ class AutoAllocationTests(BuildTestBase):
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 0)
|
||||
|
||||
def test_allocate_consumed(self):
|
||||
"""Test for auto-allocation against a build which has been fully consumed.
|
||||
|
||||
Steps:
|
||||
1. Fully allocate the build (using the auto-allocate function)
|
||||
2. Consume allocated stock
|
||||
3. Ensure that all allocations are removed
|
||||
4. Re-run the auto-allocate function
|
||||
5. Check that no new allocations have been made
|
||||
"""
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
self.assertFalse(self.build.is_fully_allocated(tracked=False))
|
||||
|
||||
# Auto allocate stock against the build order
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=True, substitutes=True, optional_items=True
|
||||
)
|
||||
|
||||
self.assertEqual(self.line_1.allocated_quantity(), 50)
|
||||
self.assertEqual(self.line_2.allocated_quantity(), 30)
|
||||
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 0)
|
||||
|
||||
self.assertTrue(self.line_1.is_fully_allocated())
|
||||
self.assertTrue(self.line_2.is_fully_allocated())
|
||||
|
||||
self.assertFalse(self.line_1.is_overallocated())
|
||||
self.assertFalse(self.line_2.is_overallocated())
|
||||
|
||||
N = self.build.allocated_stock.count()
|
||||
|
||||
self.assertEqual(self.line_1.allocations.count(), 2)
|
||||
self.assertEqual(self.line_2.allocations.count(), 6)
|
||||
|
||||
for item in self.line_1.allocations.all():
|
||||
item.complete_allocation()
|
||||
|
||||
for item in self.line_2.allocations.all():
|
||||
item.complete_allocation()
|
||||
|
||||
self.line_1.refresh_from_db()
|
||||
self.line_2.refresh_from_db()
|
||||
|
||||
self.assertTrue(self.line_1.is_fully_allocated())
|
||||
self.assertTrue(self.line_2.is_fully_allocated())
|
||||
self.assertFalse(self.line_1.is_overallocated())
|
||||
self.assertFalse(self.line_2.is_overallocated())
|
||||
|
||||
self.assertEqual(self.line_1.allocations.count(), 0)
|
||||
self.assertEqual(self.line_2.allocations.count(), 0)
|
||||
|
||||
self.assertEqual(self.line_1.quantity, self.line_1.consumed)
|
||||
self.assertEqual(self.line_2.quantity, self.line_2.consumed)
|
||||
|
||||
# Check that the "allocations" have been removed
|
||||
self.assertEqual(self.build.allocated_stock.count(), N - 8)
|
||||
|
||||
# Now, try to auto-allocate again
|
||||
self.build.auto_allocate_stock(
|
||||
interchangeable=True, substitutes=True, optional_items=True
|
||||
)
|
||||
|
||||
# Ensure that there are no "new" allocations (there should be none!)
|
||||
self.assertEqual(self.line_1.allocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.allocated_quantity(), 0)
|
||||
|
||||
self.assertEqual(self.line_1.unallocated_quantity(), 0)
|
||||
self.assertEqual(self.line_2.unallocated_quantity(), 0)
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), N - 8)
|
||||
|
||||
|
||||
class ExternalBuildTest(InvenTreeAPITestCase):
|
||||
"""Unit tests for external build order functionality."""
|
||||
|
||||
@@ -630,6 +630,38 @@ class DataImportRow(models.Model):
|
||||
if value is None and field in default_values:
|
||||
value = default_values[field]
|
||||
|
||||
# If the field provides a set of valid 'choices', use that as a lookup
|
||||
if field_type == 'choice' and 'choices' in field_def:
|
||||
choices = field_def.get('choices', None)
|
||||
|
||||
if callable(choices):
|
||||
choices = choices()
|
||||
|
||||
# Try to match the provided value against the available choices
|
||||
choice_value = None
|
||||
|
||||
for choice in choices:
|
||||
primary_value = choice['value']
|
||||
display_value = choice['display_name']
|
||||
|
||||
if primary_value == value:
|
||||
choice_value = primary_value
|
||||
# Break on first match against a primary choice value
|
||||
break
|
||||
|
||||
if display_value == value:
|
||||
choice_value = primary_value
|
||||
|
||||
elif (
|
||||
str(display_value).lower().strip() == str(value).lower().strip()
|
||||
and choice_value is None
|
||||
):
|
||||
# Case-insensitive match against display value
|
||||
choice_value = primary_value
|
||||
|
||||
if choice_value is not None:
|
||||
value = choice_value
|
||||
|
||||
data[field] = value
|
||||
|
||||
self.data = data
|
||||
|
||||
@@ -111,7 +111,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
# Request custom panel information for a part instance
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# There should be 4 active panels for the part by default
|
||||
# There should be 3 active panels for the part by default
|
||||
self.assertEqual(3, len(response.data))
|
||||
|
||||
_part.active = False
|
||||
@@ -119,8 +119,8 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||
|
||||
response = self.get(url, data=query_data)
|
||||
|
||||
# As the part is not active, only 3 panels left
|
||||
self.assertEqual(3, len(response.data))
|
||||
# As the part is not active, only 2 panels left
|
||||
self.assertEqual(2, len(response.data))
|
||||
|
||||
# Disable the "ENABLE_PART_PANELS" setting, and try again
|
||||
plugin.set_setting('ENABLE_PART_PANELS', False)
|
||||
|
||||
@@ -93,13 +93,17 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
part = None
|
||||
|
||||
panels.append({
|
||||
'key': 'part-panel',
|
||||
'title': _('Part Panel'),
|
||||
'source': self.plugin_static_file('sample_panel.js:renderPartPanel'),
|
||||
'icon': 'ti:package_outline',
|
||||
'context': {'part_name': part.name if part else ''},
|
||||
})
|
||||
# Only display this panel for "active" parts
|
||||
if part and part.active:
|
||||
panels.append({
|
||||
'key': 'part-panel',
|
||||
'title': _('Part Panel'),
|
||||
'source': self.plugin_static_file(
|
||||
'sample_panel.js:renderPartPanel'
|
||||
),
|
||||
'icon': 'ti:package:outline',
|
||||
'context': {'part_name': part.name if part else ''},
|
||||
})
|
||||
|
||||
# Next, add a custom panel which will appear on the 'purchaseorder' page
|
||||
if target_model == 'purchaseorder' and self.get_setting(
|
||||
|
||||
@@ -10,4 +10,4 @@ class VersionPlugin(InvenTreePlugin):
|
||||
NAME = 'Sample Version Plugin'
|
||||
DESCRIPTION = 'A simple plugin which shows how to use the version limits'
|
||||
MIN_VERSION = '0.1.0'
|
||||
MAX_VERSION = '1.0.0'
|
||||
MAX_VERSION = '2.0.0'
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import os
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.core.exceptions import AppRegistryNotReady, ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
@@ -52,6 +52,8 @@ class ReportConfig(AppConfig):
|
||||
try:
|
||||
self.create_default_labels()
|
||||
self.create_default_reports()
|
||||
except ValidationError:
|
||||
logger.warning('Validation error when creating default templates')
|
||||
except (
|
||||
AppRegistryNotReady,
|
||||
IntegrityError,
|
||||
@@ -162,6 +164,10 @@ class ReportConfig(AppConfig):
|
||||
**template, template=self.file_from_template('label', filename)
|
||||
)
|
||||
logger.info("Creating new label template: '%s'", template['name'])
|
||||
except ValidationError:
|
||||
logger.warning(
|
||||
"Could not create label template: '%s'", template['name']
|
||||
)
|
||||
except Exception:
|
||||
InvenTree.exceptions.log_error('create_default_labels', scope='init')
|
||||
|
||||
@@ -261,5 +267,9 @@ class ReportConfig(AppConfig):
|
||||
**template, template=self.file_from_template('report', filename)
|
||||
)
|
||||
logger.info("Created new report template: '%s'", template['name'])
|
||||
except ValidationError:
|
||||
logger.warning(
|
||||
"Could not create report template: '%s'", template['name']
|
||||
)
|
||||
except Exception:
|
||||
InvenTree.exceptions.log_error('create_default_reports', scope='init')
|
||||
|
||||
@@ -329,7 +329,7 @@ class StockItemSerializer(
|
||||
'supplier_part_detail.MPN',
|
||||
]
|
||||
|
||||
import_exclude_fields = ['use_pack_size', 'location_path']
|
||||
import_exclude_fields = ['location_path', 'serial_numbers', 'use_pack_size']
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
@@ -490,7 +490,7 @@ class StockItemSerializer(
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Custom update method to pass the user information through to the instance."""
|
||||
instance._user = self.context['user']
|
||||
instance._user = self.context.get('user', None)
|
||||
|
||||
status_custom_key = validated_data.pop('status_custom_key', None)
|
||||
status = validated_data.pop('status', None)
|
||||
|
||||
@@ -144,6 +144,14 @@ class UserDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = ExtendedUserSerializer
|
||||
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Override destroy method to ensure sessions are deleted first."""
|
||||
# Remove all sessions for this user
|
||||
if sessions := instance.usersession_set.all():
|
||||
sessions.delete()
|
||||
# Normally delete the user
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
|
||||
class UserDetailSetPassword(UpdateAPI):
|
||||
"""Allows superusers to set the password for a user."""
|
||||
|
||||
@@ -61,7 +61,10 @@ if settings.LDAP_AUTH:
|
||||
user.save()
|
||||
|
||||
# if they got an email address from LDAP, create it now and make it the primary
|
||||
if user.email:
|
||||
if (
|
||||
user.email
|
||||
and not EmailAddress.objects.filter(user=user, email=user.email).exists()
|
||||
):
|
||||
EmailAddress.objects.create(user=user, email=user.email, primary=True)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MantineSize } from '@mantine/core';
|
||||
import type { MantineRadius, MantineSize } from '@mantine/core';
|
||||
|
||||
export type UiSizeType = MantineSize | string | number;
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface UserTheme {
|
||||
primaryColor: string;
|
||||
whiteColor: string;
|
||||
blackColor: string;
|
||||
radius: UiSizeType;
|
||||
radius: MantineRadius;
|
||||
loader: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
IconDownload,
|
||||
IconFilter
|
||||
} from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import type { CalendarState } from '../../hooks/UseCalendar';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
@@ -59,6 +59,9 @@ export default function Calendar({
|
||||
|
||||
const [locale] = useLocalState(useShallow((s) => [s.language]));
|
||||
|
||||
// Ensure underscore is replaced with dash
|
||||
const calendarLocale = useMemo(() => locale.replace('_', '-'), [locale]);
|
||||
|
||||
const selectMonth = useCallback(
|
||||
(date: DateValue) => {
|
||||
state.selectMonth(date);
|
||||
@@ -186,7 +189,7 @@ export default function Calendar({
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView='dayGridMonth'
|
||||
locales={allLocales}
|
||||
locale={locale}
|
||||
locale={calendarLocale}
|
||||
headerToolbar={false}
|
||||
footerToolbar={false}
|
||||
{...calendarProps}
|
||||
|
||||
@@ -88,7 +88,14 @@ export function RenderStockItem(
|
||||
|
||||
const allocated: number = Math.max(0, instance?.allocated ?? 0);
|
||||
|
||||
if (instance?.serial !== null && instance?.serial !== undefined) {
|
||||
// Determine if this item is serialized
|
||||
const serialized: boolean =
|
||||
instance?.quantity == 1 &&
|
||||
instance?.serial !== null &&
|
||||
instance?.serial !== undefined &&
|
||||
instance?.serial !== '';
|
||||
|
||||
if (serialized) {
|
||||
quantity_string += `${t`Serial Number`}: ${instance.serial}`;
|
||||
} else if (allocated > 0) {
|
||||
const available: number = Math.max(0, instance.quantity - allocated);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MantineSize } from '@mantine/core';
|
||||
import type { MantineRadius } from '@mantine/core';
|
||||
|
||||
export const emptyServerAPI = {
|
||||
server: null,
|
||||
@@ -26,7 +26,7 @@ export const emptyServerAPI = {
|
||||
|
||||
export interface SiteMarkProps {
|
||||
value: number;
|
||||
label: MantineSize;
|
||||
label: MantineRadius;
|
||||
}
|
||||
|
||||
export const SizeMarks: SiteMarkProps[] = [
|
||||
|
||||
@@ -52,7 +52,7 @@ export function usePluginPanels({
|
||||
// API query to fetch initial information on available plugin panels
|
||||
const pluginQuery = useQuery({
|
||||
enabled: pluginPanelsEnabled && !!model && id !== undefined,
|
||||
queryKey: ['custom-plugin-panels', model, id],
|
||||
queryKey: ['custom-plugin-panels', model, id, instance],
|
||||
throwOnError: (error: any) => {
|
||||
console.error('ERR: Failed to fetch plugin panels');
|
||||
return false;
|
||||
|
||||
@@ -40,19 +40,19 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
|
||||
);
|
||||
|
||||
// radius
|
||||
function getMark(value: number) {
|
||||
function getRadiusFromValue(value: number) {
|
||||
const obj = SizeMarks.find((mark) => mark.value === value);
|
||||
if (obj) return obj;
|
||||
return SizeMarks[0];
|
||||
if (obj) return obj.label;
|
||||
return 'sm';
|
||||
}
|
||||
function getDefaultRadius() {
|
||||
const value = Number.parseInt(userTheme.radius.toString());
|
||||
return SizeMarks.some((mark) => mark.value === value) ? value : 50;
|
||||
}
|
||||
const [radius, setRadius] = useState(getDefaultRadius());
|
||||
|
||||
const [radius, setRadius] = useState(25);
|
||||
|
||||
function changeRadius(value: number) {
|
||||
const r = getRadiusFromValue(value);
|
||||
setRadius(value);
|
||||
setTheme([{ key: 'radius', value: value.toString() }]);
|
||||
|
||||
setTheme([{ key: 'radius', value: r.toString() }]);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -163,7 +163,7 @@ export function UserTheme({ height }: Readonly<{ height: number }>) {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Slider
|
||||
label={(val) => getMark(val).label}
|
||||
label={(val) => getRadiusFromValue(val)}
|
||||
defaultValue={50}
|
||||
step={25}
|
||||
marks={SizeMarks}
|
||||
|
||||
@@ -173,17 +173,12 @@ export default function StockDetail() {
|
||||
stockitem.status_custom_key == stockitem.status
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'updated',
|
||||
icon: 'calendar',
|
||||
label: t`Last Updated`
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'stocktake',
|
||||
icon: 'calendar',
|
||||
label: t`Last Stocktake`,
|
||||
hidden: !stockitem.stocktake
|
||||
type: 'link',
|
||||
name: 'link',
|
||||
label: t`Link`,
|
||||
external: true,
|
||||
copy: true,
|
||||
hidden: !stockitem.link
|
||||
}
|
||||
];
|
||||
|
||||
@@ -415,6 +410,19 @@ export default function StockDetail() {
|
||||
icon: 'part',
|
||||
label: t`Packaging`,
|
||||
hidden: !stockitem.packaging
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'updated',
|
||||
icon: 'calendar',
|
||||
label: t`Last Updated`
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'stocktake',
|
||||
icon: 'calendar',
|
||||
label: t`Last Stocktake`,
|
||||
hidden: !stockitem.stocktake
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -183,9 +183,13 @@ export default function BuildAllocatedStockTable({
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
|
||||
const itemsToConsume = useMemo(() => {
|
||||
return selectedItems.filter((item) => !item.part_detail?.trackable);
|
||||
}, [selectedItems]);
|
||||
|
||||
const consumeStock = useConsumeBuildItemsForm({
|
||||
buildId: buildId ?? 0,
|
||||
allocatedItems: selectedItems,
|
||||
allocatedItems: itemsToConsume,
|
||||
onFormSuccess: () => {
|
||||
table.clearSelectedRecords();
|
||||
table.refreshTable();
|
||||
@@ -225,13 +229,16 @@ export default function BuildAllocatedStockTable({
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
const part = record.part_detail ?? {};
|
||||
const trackable: boolean = part?.trackable ?? false;
|
||||
|
||||
return [
|
||||
{
|
||||
color: 'green',
|
||||
icon: <IconCircleDashedCheck />,
|
||||
title: t`Consume`,
|
||||
tooltip: t`Consume Stock`,
|
||||
hidden: !user.hasChangeRole(UserRoles.build),
|
||||
hidden: !buildId || trackable || !user.hasChangeRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
setSelectedItems([record]);
|
||||
consumeStock.open();
|
||||
|
||||
@@ -479,6 +479,7 @@ export default function BuildLineTable({
|
||||
);
|
||||
}
|
||||
|
||||
const allocated = record.allocatedQuantity ?? 0;
|
||||
let required = Math.max(0, record.quantity - record.consumed);
|
||||
|
||||
if (output?.pk) {
|
||||
@@ -486,7 +487,7 @@ export default function BuildLineTable({
|
||||
required = record.bom_item_detail?.quantity;
|
||||
}
|
||||
|
||||
if (required <= 0) {
|
||||
if (allocated <= 0 && required <= 0) {
|
||||
return (
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<IconCircleCheck size={16} color='green' />
|
||||
@@ -502,7 +503,7 @@ export default function BuildLineTable({
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.allocatedQuantity}
|
||||
value={allocated}
|
||||
maximum={required}
|
||||
/>
|
||||
);
|
||||
@@ -664,9 +665,10 @@ export default function BuildLineTable({
|
||||
(record: any): RowAction[] => {
|
||||
const part = record.part_detail ?? {};
|
||||
const in_production = build.status == buildStatus.PRODUCTION;
|
||||
const consumable = record.bom_item_detail?.consumable ?? false;
|
||||
const consumable: boolean = record.bom_item_detail?.consumable ?? false;
|
||||
const trackable: boolean = part?.trackable ?? false;
|
||||
|
||||
const hasOutput = !!output?.pk;
|
||||
const hasOutput: boolean = !!output?.pk;
|
||||
|
||||
const required = Math.max(
|
||||
0,
|
||||
@@ -677,6 +679,7 @@ export default function BuildLineTable({
|
||||
const canConsume =
|
||||
in_production &&
|
||||
!consumable &&
|
||||
!trackable &&
|
||||
record.allocated > 0 &&
|
||||
user.hasChangeRole(UserRoles.build);
|
||||
|
||||
@@ -952,6 +955,9 @@ export default function BuildLineTable({
|
||||
dataFormatter: formatRecords,
|
||||
enableDownload: true,
|
||||
enableSelection: true,
|
||||
enableLabels: true,
|
||||
modelType: ModelType.buildline,
|
||||
onCellClick: () => {},
|
||||
rowExpansion: rowExpansion
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -358,6 +358,7 @@ export function UserTable({
|
||||
title: t`Delete user`,
|
||||
successMessage: t`User deleted`,
|
||||
table: table,
|
||||
preFormContent: <></>,
|
||||
preFormWarning: t`Are you sure you want to delete this user?`
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user