mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-10 00:50:57 +00:00
Merge branch 'master' into custom-states
This commit is contained in:
@@ -1,13 +1,29 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 283
|
||||
INVENTREE_API_VERSION = 287
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v287 - 2024-11-27 : https://github.com/inventree/InvenTree/pull/8571
|
||||
- Adds ability to set stock status when returning items from a customer
|
||||
|
||||
v286 - 2024-11-26 : https://github.com/inventree/InvenTree/pull/8054
|
||||
- Adds "SelectionList" and "SelectionListEntry" API endpoints
|
||||
|
||||
v285 - 2024-11-25 : https://github.com/inventree/InvenTree/pull/8559
|
||||
- Adds better description for registration endpoints
|
||||
|
||||
v284 - 2024-11-25 : https://github.com/inventree/InvenTree/pull/8544
|
||||
- Adds new date filters to the StockItem API
|
||||
- Adds new date filters to the BuildOrder API
|
||||
- Adds new date filters to the SalesOrder API
|
||||
- Adds new date filters to the PurchaseOrder API
|
||||
- Adds new date filters to the ReturnOrder API
|
||||
|
||||
v283 - 2024-11-20 : https://github.com/inventree/InvenTree/pull/8524
|
||||
- Adds "note" field to the PartRelated API endpoint
|
||||
|
||||
|
||||
@@ -23,10 +23,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import bleach
|
||||
import pytz
|
||||
import regex
|
||||
from bleach import clean
|
||||
from djmoney.money import Money
|
||||
from PIL import Image
|
||||
|
||||
from common.currency import currency_code_default
|
||||
|
||||
@@ -140,6 +138,8 @@ def getStaticUrl(filename):
|
||||
|
||||
def TestIfImage(img):
|
||||
"""Test if an image file is indeed an image."""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
Image.open(img).verify()
|
||||
return True
|
||||
@@ -781,6 +781,8 @@ def strip_html_tags(value: str, raise_error=True, field_name=None):
|
||||
|
||||
If raise_error is True, a ValidationError will be thrown if HTML tags are detected
|
||||
"""
|
||||
value = str(value).strip()
|
||||
|
||||
cleaned = clean(value, strip=True, tags=[], attributes=[])
|
||||
|
||||
# Add escaped characters back in
|
||||
@@ -792,39 +794,32 @@ def strip_html_tags(value: str, raise_error=True, field_name=None):
|
||||
# If the length changed, it means that HTML tags were removed!
|
||||
if len(cleaned) != len(value) and raise_error:
|
||||
field = field_name or 'non_field_errors'
|
||||
|
||||
raise ValidationError({field: [_('Remove HTML tags from this value')]})
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def remove_non_printable_characters(
|
||||
value: str, remove_newline=True, remove_ascii=True, remove_unicode=True
|
||||
):
|
||||
def remove_non_printable_characters(value: str, remove_newline=True) -> str:
|
||||
"""Remove non-printable / control characters from the provided string."""
|
||||
cleaned = value
|
||||
|
||||
if remove_ascii:
|
||||
# Remove ASCII control characters
|
||||
# Note that we do not sub out 0x0A (\n) here, it is done separately below
|
||||
cleaned = regex.sub('[\x00-\x09]+', '', cleaned)
|
||||
cleaned = regex.sub('[\x0b-\x1f\x7f]+', '', cleaned)
|
||||
# Remove ASCII control characters
|
||||
# Note that we do not sub out 0x0A (\n) here, it is done separately below
|
||||
regex = re.compile(r'[\u0000-\u0009\u000B-\u001F\u007F-\u009F]')
|
||||
cleaned = regex.sub('', cleaned)
|
||||
|
||||
# Remove Unicode control characters
|
||||
regex = re.compile(r'[\u200E\u200F\u202A-\u202E]')
|
||||
cleaned = regex.sub('', cleaned)
|
||||
|
||||
if remove_newline:
|
||||
cleaned = regex.sub('[\x0a]+', '', cleaned)
|
||||
|
||||
if remove_unicode:
|
||||
# Remove Unicode control characters
|
||||
if remove_newline:
|
||||
cleaned = regex.sub(r'[^\P{C}]+', '', cleaned)
|
||||
else:
|
||||
# Use 'negative-lookahead' to exclude newline character
|
||||
cleaned = regex.sub('(?![\x0a])[^\\P{C}]+', '', cleaned)
|
||||
regex = re.compile(r'[\x0A]')
|
||||
cleaned = regex.sub('', cleaned)
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def clean_markdown(value: str):
|
||||
def clean_markdown(value: str) -> str:
|
||||
"""Clean a markdown string.
|
||||
|
||||
This function will remove javascript and other potentially harmful content from the markdown string.
|
||||
@@ -883,7 +878,7 @@ def clean_markdown(value: str):
|
||||
return value
|
||||
|
||||
|
||||
def hash_barcode(barcode_data):
|
||||
def hash_barcode(barcode_data: str) -> str:
|
||||
"""Calculate a 'unique' hash for a barcode string.
|
||||
|
||||
This hash is used for comparison / lookup.
|
||||
|
||||
@@ -99,7 +99,7 @@ class ClassProviderMixin:
|
||||
|
||||
@classmethod
|
||||
def get_is_builtin(cls):
|
||||
"""Is this Class build in the Inventree source code?"""
|
||||
"""Is this Class build in the InvenTree source code?"""
|
||||
try:
|
||||
Path(cls.get_provider_file()).relative_to(settings.BASE_DIR)
|
||||
return True
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Custom management command to prerender files."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
@@ -9,6 +10,8 @@ from django.template.loader import render_to_string
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import override as lang_over
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def render_file(file_name, source, target, locales, ctx):
|
||||
"""Renders a file into all provided locales."""
|
||||
@@ -31,6 +34,10 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""Django command to prerender files."""
|
||||
if not settings.ENABLE_CLASSIC_FRONTEND:
|
||||
logger.info('Classic frontend is disabled. Skipping prerendering.')
|
||||
return
|
||||
|
||||
# static directories
|
||||
LC_DIR = settings.LOCALE_PATHS[0]
|
||||
SOURCE_DIR = settings.STATICFILES_I18_SRC
|
||||
|
||||
@@ -57,7 +57,7 @@ class CleanMixin:
|
||||
Ref: https://github.com/mozilla/bleach/issues/192
|
||||
|
||||
"""
|
||||
cleaned = strip_html_tags(data, field_name=field)
|
||||
cleaned = data
|
||||
|
||||
# By default, newline characters are removed
|
||||
remove_newline = True
|
||||
@@ -66,13 +66,13 @@ class CleanMixin:
|
||||
try:
|
||||
if hasattr(self, 'serializer_class'):
|
||||
model = self.serializer_class.Meta.model
|
||||
field = model._meta.get_field(field)
|
||||
field_base = model._meta.get_field(field)
|
||||
|
||||
# The following field types allow newline characters
|
||||
allow_newline = [(InvenTreeNotesField, True)]
|
||||
|
||||
for field_type in allow_newline:
|
||||
if issubclass(type(field), field_type[0]):
|
||||
if issubclass(type(field_base), field_type[0]):
|
||||
remove_newline = False
|
||||
is_markdown = field_type[1]
|
||||
break
|
||||
@@ -86,6 +86,8 @@ class CleanMixin:
|
||||
cleaned, remove_newline=remove_newline
|
||||
)
|
||||
|
||||
cleaned = strip_html_tags(cleaned, field_name=field)
|
||||
|
||||
if is_markdown:
|
||||
cleaned = clean_markdown(cleaned)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from importlib import import_module
|
||||
from django.conf import settings
|
||||
from django.urls import NoReverseMatch, include, path, reverse
|
||||
|
||||
import allauth.socialaccount.providers.openid_connect.views as oidc_views
|
||||
from allauth.account.models import EmailAddress
|
||||
from allauth.socialaccount import providers
|
||||
from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView
|
||||
@@ -44,7 +45,7 @@ class GenericOAuth2ApiConnectView(GenericOAuth2ApiLoginView):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
def handle_oauth2(adapter: OAuth2Adapter):
|
||||
def handle_oauth2(adapter: OAuth2Adapter, provider=None):
|
||||
"""Define urls for oauth2 endpoints."""
|
||||
return [
|
||||
path(
|
||||
@@ -60,6 +61,22 @@ def handle_oauth2(adapter: OAuth2Adapter):
|
||||
]
|
||||
|
||||
|
||||
def handle_oidc(provider):
|
||||
"""Define urls for oidc endpoints."""
|
||||
return [
|
||||
path(
|
||||
'login/',
|
||||
lambda x: oidc_views.login(x, provider.id),
|
||||
name=f'{provider.id}_api_login',
|
||||
),
|
||||
path(
|
||||
'connect/',
|
||||
lambda x: oidc_views.callback(x, provider.id),
|
||||
name=f'{provider.id}_api_connect',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
legacy = {
|
||||
'twitter': 'twitter_oauth2',
|
||||
'bitbucket': 'bitbucket_oauth2',
|
||||
@@ -70,47 +87,53 @@ legacy = {
|
||||
|
||||
|
||||
# Collect urls for all loaded providers
|
||||
social_auth_urlpatterns = []
|
||||
def get_provider_urls() -> list:
|
||||
"""Collect urls for all loaded providers.
|
||||
|
||||
provider_urlpatterns = []
|
||||
Returns:
|
||||
list: List of urls for all loaded providers.
|
||||
"""
|
||||
auth_provider_routes = []
|
||||
|
||||
for name, provider in providers.registry.provider_map.items():
|
||||
try:
|
||||
prov_mod = import_module(provider.get_package() + '.views')
|
||||
except ImportError:
|
||||
logger.exception('Could not import authentication provider %s', name)
|
||||
continue
|
||||
for name, provider in providers.registry.provider_map.items():
|
||||
try:
|
||||
prov_mod = import_module(provider.get_package() + '.views')
|
||||
except ImportError:
|
||||
logger.exception('Could not import authentication provider %s', name)
|
||||
continue
|
||||
|
||||
# Try to extract the adapter class
|
||||
adapters = [
|
||||
cls
|
||||
for cls in prov_mod.__dict__.values()
|
||||
if isinstance(cls, type)
|
||||
and cls != OAuth2Adapter
|
||||
and issubclass(cls, OAuth2Adapter)
|
||||
]
|
||||
# Try to extract the adapter class
|
||||
adapters = [
|
||||
cls
|
||||
for cls in prov_mod.__dict__.values()
|
||||
if isinstance(cls, type)
|
||||
and cls != OAuth2Adapter
|
||||
and issubclass(cls, OAuth2Adapter)
|
||||
]
|
||||
|
||||
# Get urls
|
||||
urls = []
|
||||
if len(adapters) == 1:
|
||||
urls = handle_oauth2(adapter=adapters[0])
|
||||
elif provider.id in legacy:
|
||||
logger.warning(
|
||||
'`%s` is not supported on platform UI. Use `%s` instead.',
|
||||
provider.id,
|
||||
legacy[provider.id],
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
'Found handler that is not yet ready for platform UI: `%s`. Open an feature request on GitHub if you need it implemented.',
|
||||
provider.id,
|
||||
)
|
||||
continue
|
||||
provider_urlpatterns += [path(f'{provider.id}/', include(urls))]
|
||||
# Get urls
|
||||
urls = []
|
||||
if len(adapters) == 1:
|
||||
if provider.id == 'openid_connect':
|
||||
urls = handle_oidc(provider)
|
||||
else:
|
||||
urls = handle_oauth2(adapter=adapters[0], provider=provider)
|
||||
elif provider.id in legacy:
|
||||
logger.warning(
|
||||
'`%s` is not supported on platform UI. Use `%s` instead.',
|
||||
provider.id,
|
||||
legacy[provider.id],
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
'Found handler that is not yet ready for platform UI: `%s`. Open an feature request on GitHub if you need it implemented.',
|
||||
provider.id,
|
||||
)
|
||||
continue
|
||||
auth_provider_routes += [path(f'{provider.id}/', include(urls))]
|
||||
|
||||
|
||||
social_auth_urlpatterns += provider_urlpatterns
|
||||
return auth_provider_routes
|
||||
|
||||
|
||||
class SocialProviderListResponseSerializer(serializers.Serializer):
|
||||
|
||||
@@ -54,7 +54,7 @@ from .social_auth_urls import (
|
||||
EmailRemoveView,
|
||||
EmailVerifyView,
|
||||
SocialProviderListView,
|
||||
social_auth_urlpatterns,
|
||||
get_provider_urls,
|
||||
)
|
||||
from .views import (
|
||||
AboutView,
|
||||
@@ -78,6 +78,71 @@ from .views import (
|
||||
|
||||
admin.site.site_header = 'InvenTree Admin'
|
||||
|
||||
settings_urls = [
|
||||
path('i18n/', include('django.conf.urls.i18n')),
|
||||
path('appearance/', AppearanceSelectView.as_view(), name='settings-appearance'),
|
||||
# Catch any other urls
|
||||
path(
|
||||
'',
|
||||
SettingsView.as_view(template_name='InvenTree/settings/settings.html'),
|
||||
name='settings',
|
||||
),
|
||||
]
|
||||
|
||||
notifications_urls = [
|
||||
# Catch any other urls
|
||||
path('', NotificationsView.as_view(), name='notifications')
|
||||
]
|
||||
|
||||
classic_frontendpatterns = [
|
||||
# Apps
|
||||
#
|
||||
path('build/', include(build_urls)),
|
||||
path('common/', include(common_urls)),
|
||||
path('company/', include(company_urls)),
|
||||
path('order/', include(order_urls)),
|
||||
path('manufacturer-part/', include(manufacturer_part_urls)),
|
||||
path('part/', include(part_urls)),
|
||||
path('stock/', include(stock_urls)),
|
||||
path('supplier-part/', include(supplier_part_urls)),
|
||||
path('edit-user/', EditUserView.as_view(), name='edit-user'),
|
||||
path('set-password/', SetPasswordView.as_view(), name='set-password'),
|
||||
path('index/', IndexView.as_view(), name='index'),
|
||||
path('notifications/', include(notifications_urls)),
|
||||
path('search/', SearchView.as_view(), name='search'),
|
||||
path('settings/', include(settings_urls)),
|
||||
path('about/', AboutView.as_view(), name='about'),
|
||||
path('stats/', DatabaseStatsView.as_view(), name='stats'),
|
||||
# DB user sessions
|
||||
path(
|
||||
'accounts/sessions/other/delete/',
|
||||
view=CustomSessionDeleteOtherView.as_view(),
|
||||
name='session_delete_other',
|
||||
),
|
||||
re_path(
|
||||
r'^accounts/sessions/(?P<pk>\w+)/delete/$',
|
||||
view=CustomSessionDeleteView.as_view(),
|
||||
name='session_delete',
|
||||
),
|
||||
# Single Sign On / allauth
|
||||
# overrides of urlpatterns
|
||||
path('accounts/email/', CustomEmailView.as_view(), name='account_email'),
|
||||
path(
|
||||
'accounts/social/connections/',
|
||||
CustomConnectionsView.as_view(),
|
||||
name='socialaccount_connections',
|
||||
),
|
||||
re_path(
|
||||
r'^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$',
|
||||
CustomPasswordResetFromKeyView.as_view(),
|
||||
name='account_reset_password_from_key',
|
||||
),
|
||||
# Override login page
|
||||
path('accounts/login/', CustomLoginView.as_view(), name='account_login'),
|
||||
path('accounts/', include('allauth_2fa.urls')), # MFA support
|
||||
path('accounts/', include('allauth.urls')), # included urlpatterns
|
||||
]
|
||||
|
||||
|
||||
apipatterns = [
|
||||
# Global search
|
||||
@@ -167,7 +232,7 @@ apipatterns = [
|
||||
path('', EmailListView.as_view(), name='email-list'),
|
||||
]),
|
||||
),
|
||||
path('social/', include(social_auth_urlpatterns)),
|
||||
path('social/', include(get_provider_urls())),
|
||||
path(
|
||||
'social/', SocialAccountListView.as_view(), name='social_account_list'
|
||||
),
|
||||
@@ -197,21 +262,6 @@ apipatterns = [
|
||||
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
|
||||
]
|
||||
|
||||
settings_urls = [
|
||||
path('i18n/', include('django.conf.urls.i18n')),
|
||||
path('appearance/', AppearanceSelectView.as_view(), name='settings-appearance'),
|
||||
# Catch any other urls
|
||||
path(
|
||||
'',
|
||||
SettingsView.as_view(template_name='InvenTree/settings/settings.html'),
|
||||
name='settings',
|
||||
),
|
||||
]
|
||||
|
||||
notifications_urls = [
|
||||
# Catch any other urls
|
||||
path('', NotificationsView.as_view(), name='notifications')
|
||||
]
|
||||
|
||||
# These javascript files are served "dynamically" - i.e. rendered on demand
|
||||
dynamic_javascript_urls = [
|
||||
@@ -400,54 +450,6 @@ if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
re_path(r'^js/i18n/', include(translated_javascript_urls)),
|
||||
]
|
||||
|
||||
classic_frontendpatterns = [
|
||||
# Apps
|
||||
#
|
||||
path('build/', include(build_urls)),
|
||||
path('common/', include(common_urls)),
|
||||
path('company/', include(company_urls)),
|
||||
path('order/', include(order_urls)),
|
||||
path('manufacturer-part/', include(manufacturer_part_urls)),
|
||||
path('part/', include(part_urls)),
|
||||
path('stock/', include(stock_urls)),
|
||||
path('supplier-part/', include(supplier_part_urls)),
|
||||
path('edit-user/', EditUserView.as_view(), name='edit-user'),
|
||||
path('set-password/', SetPasswordView.as_view(), name='set-password'),
|
||||
path('index/', IndexView.as_view(), name='index'),
|
||||
path('notifications/', include(notifications_urls)),
|
||||
path('search/', SearchView.as_view(), name='search'),
|
||||
path('settings/', include(settings_urls)),
|
||||
path('about/', AboutView.as_view(), name='about'),
|
||||
path('stats/', DatabaseStatsView.as_view(), name='stats'),
|
||||
# DB user sessions
|
||||
path(
|
||||
'accounts/sessions/other/delete/',
|
||||
view=CustomSessionDeleteOtherView.as_view(),
|
||||
name='session_delete_other',
|
||||
),
|
||||
re_path(
|
||||
r'^accounts/sessions/(?P<pk>\w+)/delete/$',
|
||||
view=CustomSessionDeleteView.as_view(),
|
||||
name='session_delete',
|
||||
),
|
||||
# Single Sign On / allauth
|
||||
# overrides of urlpatterns
|
||||
path('accounts/email/', CustomEmailView.as_view(), name='account_email'),
|
||||
path(
|
||||
'accounts/social/connections/',
|
||||
CustomConnectionsView.as_view(),
|
||||
name='socialaccount_connections',
|
||||
),
|
||||
re_path(
|
||||
r'^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$',
|
||||
CustomPasswordResetFromKeyView.as_view(),
|
||||
name='account_reset_password_from_key',
|
||||
),
|
||||
# Override login page
|
||||
path('accounts/login/', CustomLoginView.as_view(), name='account_login'),
|
||||
path('accounts/', include('allauth_2fa.urls')), # MFA support
|
||||
path('accounts/', include('allauth.urls')), # included urlpatterns
|
||||
]
|
||||
|
||||
urlpatterns = []
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import build.serializers
|
||||
from build.models import Build, BuildLine, BuildItem
|
||||
import part.models
|
||||
from users.models import Owner
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
|
||||
from InvenTree.filters import InvenTreeDateFilter, SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
|
||||
class BuildFilter(rest_filters.FilterSet):
|
||||
@@ -179,6 +179,36 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
return queryset.exclude(project_code=None)
|
||||
return queryset.filter(project_code=None)
|
||||
|
||||
created_before = InvenTreeDateFilter(
|
||||
label=_('Created before'),
|
||||
field_name='creation_date', lookup_expr='lt'\
|
||||
)
|
||||
|
||||
created_after = InvenTreeDateFilter(
|
||||
label=_('Created after'),
|
||||
field_name='creation_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
target_date_before = InvenTreeDateFilter(
|
||||
label=_('Target date before'),
|
||||
field_name='target_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
target_date_after = InvenTreeDateFilter(
|
||||
label=_('Target date after'),
|
||||
field_name='target_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
completed_before = InvenTreeDateFilter(
|
||||
label=_('Completed before'),
|
||||
field_name='completion_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
completed_after = InvenTreeDateFilter(
|
||||
label=_('Completed after'),
|
||||
field_name='completion_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
|
||||
class BuildMixin:
|
||||
"""Mixin class for Build API endpoints."""
|
||||
|
||||
@@ -871,6 +871,7 @@ class Build(
|
||||
part=self.part,
|
||||
build=self,
|
||||
batch=batch,
|
||||
location=location,
|
||||
is_building=True
|
||||
)
|
||||
|
||||
@@ -1749,6 +1750,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
else:
|
||||
# Mark the item as "consumed" by the build order
|
||||
item.consumed_by = self.build
|
||||
item.location = None
|
||||
item.save(add_note=False)
|
||||
|
||||
item.add_tracking_entry(
|
||||
|
||||
@@ -808,6 +808,82 @@ class IconList(ListAPI):
|
||||
return get_icon_packs().values()
|
||||
|
||||
|
||||
class SelectionListList(ListCreateAPI):
|
||||
"""List view for SelectionList objects."""
|
||||
|
||||
queryset = common.models.SelectionList.objects.all()
|
||||
serializer_class = common.serializers.SelectionListSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method to include entry count."""
|
||||
return self.serializer_class.annotate_queryset(super().get_queryset())
|
||||
|
||||
|
||||
class SelectionListDetail(RetrieveUpdateDestroyAPI):
|
||||
"""Detail view for a SelectionList object."""
|
||||
|
||||
queryset = common.models.SelectionList.objects.all()
|
||||
serializer_class = common.serializers.SelectionListSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class EntryMixin:
|
||||
"""Mixin for SelectionEntry views."""
|
||||
|
||||
queryset = common.models.SelectionListEntry.objects.all()
|
||||
serializer_class = common.serializers.SelectionEntrySerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
lookup_url_kwarg = 'entrypk'
|
||||
|
||||
def get_queryset(self):
|
||||
"""Prefetch related fields."""
|
||||
pk = self.kwargs.get('pk', None)
|
||||
queryset = super().get_queryset().filter(list=pk)
|
||||
queryset = queryset.prefetch_related('list')
|
||||
return queryset
|
||||
|
||||
|
||||
class SelectionEntryList(EntryMixin, ListCreateAPI):
|
||||
"""List view for SelectionEntry objects."""
|
||||
|
||||
|
||||
class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail view for a SelectionEntry object."""
|
||||
|
||||
|
||||
selection_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
# Entries
|
||||
path(
|
||||
'entry/',
|
||||
include([
|
||||
path(
|
||||
'<int:entrypk>/',
|
||||
include([
|
||||
path(
|
||||
'',
|
||||
SelectionEntryDetail.as_view(),
|
||||
name='api-selectionlistentry-detail',
|
||||
)
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'',
|
||||
SelectionEntryList.as_view(),
|
||||
name='api-selectionlistentry-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
path('', SelectionListDetail.as_view(), name='api-selectionlist-detail'),
|
||||
]),
|
||||
),
|
||||
path('', SelectionListList.as_view(), name='api-selectionlist-list'),
|
||||
]
|
||||
|
||||
# API URL patterns
|
||||
settings_api_urls = [
|
||||
# User settings
|
||||
path(
|
||||
@@ -1016,6 +1092,8 @@ common_api_urls = [
|
||||
),
|
||||
# Icons
|
||||
path('icons/', IconList.as_view(), name='api-icon-list'),
|
||||
# Selection lists
|
||||
path('selection/', include(selection_urls)),
|
||||
]
|
||||
|
||||
admin_api_urls = [
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
# Generated by Django 4.2.16 on 2024-11-24 12:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import InvenTree.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('plugin', '0009_alter_pluginconfig_key'),
|
||||
('common', '0031_auto_20241026_0024'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SelectionList',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
),
|
||||
),
|
||||
(
|
||||
'metadata',
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text='JSON metadata field, for use by external plugins',
|
||||
null=True,
|
||||
verbose_name='Plugin Metadata',
|
||||
),
|
||||
),
|
||||
(
|
||||
'name',
|
||||
models.CharField(
|
||||
help_text='Name of the selection list',
|
||||
max_length=100,
|
||||
unique=True,
|
||||
verbose_name='Name',
|
||||
),
|
||||
),
|
||||
(
|
||||
'description',
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text='Description of the selection list',
|
||||
max_length=250,
|
||||
verbose_name='Description',
|
||||
),
|
||||
),
|
||||
(
|
||||
'locked',
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text='Is this selection list locked?',
|
||||
verbose_name='Locked',
|
||||
),
|
||||
),
|
||||
(
|
||||
'active',
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text='Can this selection list be used?',
|
||||
verbose_name='Active',
|
||||
),
|
||||
),
|
||||
(
|
||||
'source_string',
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text='Optional string identifying the source used for this list',
|
||||
max_length=1000,
|
||||
verbose_name='Source String',
|
||||
),
|
||||
),
|
||||
(
|
||||
'created',
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text='Date and time that the selection list was created',
|
||||
verbose_name='Created',
|
||||
),
|
||||
),
|
||||
(
|
||||
'last_updated',
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text='Date and time that the selection list was last updated',
|
||||
verbose_name='Last Updated',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Selection List',
|
||||
'verbose_name_plural': 'Selection Lists',
|
||||
},
|
||||
bases=(InvenTree.models.PluginValidationMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SelectionListEntry',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
),
|
||||
),
|
||||
(
|
||||
'value',
|
||||
models.CharField(
|
||||
help_text='Value of the selection list entry',
|
||||
max_length=255,
|
||||
verbose_name='Value',
|
||||
),
|
||||
),
|
||||
(
|
||||
'label',
|
||||
models.CharField(
|
||||
help_text='Label for the selection list entry',
|
||||
max_length=255,
|
||||
verbose_name='Label',
|
||||
),
|
||||
),
|
||||
(
|
||||
'description',
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text='Description of the selection list entry',
|
||||
max_length=250,
|
||||
verbose_name='Description',
|
||||
),
|
||||
),
|
||||
(
|
||||
'active',
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text='Is this selection list entry active?',
|
||||
verbose_name='Active',
|
||||
),
|
||||
),
|
||||
(
|
||||
'list',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Selection list to which this entry belongs',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='entries',
|
||||
to='common.selectionlist',
|
||||
verbose_name='Selection List',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Selection List Entry',
|
||||
'verbose_name_plural': 'Selection List Entries',
|
||||
'unique_together': {('list', 'value')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='selectionlist',
|
||||
name='default',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Default entry for this selection list',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to='common.selectionlistentry',
|
||||
verbose_name='Default Entry',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='selectionlist',
|
||||
name='source_plugin',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Plugin which provides the selection list',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to='plugin.pluginconfig',
|
||||
verbose_name='Source Plugin',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -3542,6 +3542,169 @@ class InvenTreeCustomUserStateModel(models.Model):
|
||||
return cls
|
||||
|
||||
|
||||
class SelectionList(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
"""Class which represents a list of selectable items for parameters.
|
||||
|
||||
A lists selection options can be either manually defined, or sourced from a plugin.
|
||||
|
||||
Attributes:
|
||||
name: The name of the selection list
|
||||
description: A description of the selection list
|
||||
locked: Is this selection list locked (i.e. cannot be modified)?
|
||||
active: Is this selection list active?
|
||||
source_plugin: The plugin which provides the selection list
|
||||
source_string: The string representation of the selection list
|
||||
default: The default value for the selection list
|
||||
created: The date/time that the selection list was created
|
||||
last_updated: The date/time that the selection list was last updated
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for SelectionList."""
|
||||
|
||||
verbose_name = _('Selection List')
|
||||
verbose_name_plural = _('Selection Lists')
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name=_('Name'),
|
||||
help_text=_('Name of the selection list'),
|
||||
unique=True,
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
max_length=250,
|
||||
verbose_name=_('Description'),
|
||||
help_text=_('Description of the selection list'),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
locked = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Locked'),
|
||||
help_text=_('Is this selection list locked?'),
|
||||
)
|
||||
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_('Active'),
|
||||
help_text=_('Can this selection list be used?'),
|
||||
)
|
||||
|
||||
source_plugin = models.ForeignKey(
|
||||
'plugin.PluginConfig',
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Source Plugin'),
|
||||
help_text=_('Plugin which provides the selection list'),
|
||||
)
|
||||
|
||||
source_string = models.CharField(
|
||||
max_length=1000,
|
||||
verbose_name=_('Source String'),
|
||||
help_text=_('Optional string identifying the source used for this list'),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
default = models.ForeignKey(
|
||||
'SelectionListEntry',
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Default Entry'),
|
||||
help_text=_('Default entry for this selection list'),
|
||||
)
|
||||
|
||||
created = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name=_('Created'),
|
||||
help_text=_('Date and time that the selection list was created'),
|
||||
)
|
||||
|
||||
last_updated = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name=_('Last Updated'),
|
||||
help_text=_('Date and time that the selection list was last updated'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation of the selection list."""
|
||||
if not self.active:
|
||||
return f'{self.name} (Inactive)'
|
||||
return self.name
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the SelectionList model."""
|
||||
return reverse('api-selectionlist-list')
|
||||
|
||||
def get_choices(self):
|
||||
"""Return the choices for the selection list."""
|
||||
choices = self.entries.filter(active=True)
|
||||
return [c.value for c in choices]
|
||||
|
||||
|
||||
class SelectionListEntry(models.Model):
|
||||
"""Class which represents a single entry in a SelectionList.
|
||||
|
||||
Attributes:
|
||||
list: The SelectionList to which this entry belongs
|
||||
value: The value of the selection list entry
|
||||
label: The label for the selection list entry
|
||||
description: A description of the selection list entry
|
||||
active: Is this selection list entry active?
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for SelectionListEntry."""
|
||||
|
||||
verbose_name = _('Selection List Entry')
|
||||
verbose_name_plural = _('Selection List Entries')
|
||||
unique_together = [['list', 'value']]
|
||||
|
||||
list = models.ForeignKey(
|
||||
SelectionList,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='entries',
|
||||
verbose_name=_('Selection List'),
|
||||
help_text=_('Selection list to which this entry belongs'),
|
||||
)
|
||||
|
||||
value = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_('Value'),
|
||||
help_text=_('Value of the selection list entry'),
|
||||
)
|
||||
|
||||
label = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_('Label'),
|
||||
help_text=_('Label for the selection list entry'),
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
max_length=250,
|
||||
verbose_name=_('Description'),
|
||||
help_text=_('Description of the selection list entry'),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_('Active'),
|
||||
help_text=_('Is this selection list entry active?'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation of the selection list entry."""
|
||||
if not self.active:
|
||||
return f'{self.label} (Inactive)'
|
||||
return self.label
|
||||
|
||||
|
||||
class BarcodeScanResult(InvenTree.models.InvenTreeModel):
|
||||
"""Model for storing barcode scans results."""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""JSON serializers for common components."""
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -639,3 +639,123 @@ class IconPackageSerializer(serializers.Serializer):
|
||||
prefix = serializers.CharField()
|
||||
fonts = serializers.DictField(child=serializers.CharField())
|
||||
icons = serializers.DictField(child=IconSerializer())
|
||||
|
||||
|
||||
class SelectionEntrySerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for a selection entry."""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for SelectionEntrySerializer."""
|
||||
|
||||
model = common_models.SelectionListEntry
|
||||
fields = '__all__'
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Ensure that the selection list is not locked."""
|
||||
ret = super().validate(attrs)
|
||||
if self.instance and self.instance.list.locked:
|
||||
raise serializers.ValidationError({'list': _('Selection list is locked')})
|
||||
return ret
|
||||
|
||||
|
||||
class SelectionListSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for a selection list."""
|
||||
|
||||
_choices_validated: dict = {}
|
||||
|
||||
class Meta:
|
||||
"""Meta options for SelectionListSerializer."""
|
||||
|
||||
model = common_models.SelectionList
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
'description',
|
||||
'active',
|
||||
'locked',
|
||||
'source_plugin',
|
||||
'source_string',
|
||||
'default',
|
||||
'created',
|
||||
'last_updated',
|
||||
'choices',
|
||||
'entry_count',
|
||||
]
|
||||
|
||||
default = SelectionEntrySerializer(read_only=True, many=False)
|
||||
choices = SelectionEntrySerializer(source='entries', many=True, required=False)
|
||||
entry_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Add count of entries for each selection list."""
|
||||
return queryset.annotate(entry_count=Count('entries'))
|
||||
|
||||
def is_valid(self, *, raise_exception=False):
|
||||
"""Validate the selection list. Choices are validated separately."""
|
||||
choices = (
|
||||
self.initial_data.pop('choices')
|
||||
if self.initial_data.get('choices') is not None
|
||||
else []
|
||||
)
|
||||
|
||||
# Validate the choices
|
||||
_choices_validated = []
|
||||
db_entries = (
|
||||
{a.id: a for a in self.instance.entries.all()} if self.instance else {}
|
||||
)
|
||||
|
||||
for choice in choices:
|
||||
current_inst = db_entries.get(choice.get('id'))
|
||||
serializer = SelectionEntrySerializer(
|
||||
instance=current_inst,
|
||||
data={'list': current_inst.list.pk if current_inst else None, **choice},
|
||||
)
|
||||
serializer.is_valid(raise_exception=raise_exception)
|
||||
_choices_validated.append({
|
||||
**serializer.validated_data,
|
||||
'id': choice.get('id'),
|
||||
})
|
||||
self._choices_validated = _choices_validated
|
||||
|
||||
return super().is_valid(raise_exception=raise_exception)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a new selection list. Save the choices separately."""
|
||||
list_entry = common_models.SelectionList.objects.create(**validated_data)
|
||||
for choice_data in self._choices_validated:
|
||||
common_models.SelectionListEntry.objects.create(**{
|
||||
**choice_data,
|
||||
'list': list_entry,
|
||||
})
|
||||
return list_entry
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Update an existing selection list. Save the choices separately."""
|
||||
inst_mapping = {inst.id: inst for inst in instance.entries.all()}
|
||||
exsising_ids = {a.get('id') for a in self._choices_validated}
|
||||
|
||||
# Perform creations and updates.
|
||||
ret = []
|
||||
for data in self._choices_validated:
|
||||
list_inst = data.get('list', None)
|
||||
inst = inst_mapping.get(data.get('id'))
|
||||
if inst is None:
|
||||
if list_inst is None:
|
||||
data['list'] = instance
|
||||
ret.append(SelectionEntrySerializer().create(data))
|
||||
else:
|
||||
ret.append(SelectionEntrySerializer().update(inst, data))
|
||||
|
||||
# Perform deletions.
|
||||
for entry_id in inst_mapping.keys() - exsising_ids:
|
||||
inst_mapping[entry_id].delete()
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Ensure that the selection list is not locked."""
|
||||
ret = super().validate(attrs)
|
||||
if self.instance and self.instance.locked:
|
||||
raise serializers.ValidationError({'locked': _('Selection list is locked')})
|
||||
return ret
|
||||
|
||||
@@ -29,7 +29,7 @@ from InvenTree.unit_test import (
|
||||
InvenTreeTestCase,
|
||||
PluginMixin,
|
||||
)
|
||||
from part.models import Part
|
||||
from part.models import Part, PartParameterTemplate
|
||||
from plugin import registry
|
||||
from plugin.models import NotificationUserSetting
|
||||
|
||||
@@ -45,6 +45,8 @@ from .models import (
|
||||
NotificationEntry,
|
||||
NotificationMessage,
|
||||
ProjectCode,
|
||||
SelectionList,
|
||||
SelectionListEntry,
|
||||
WebhookEndpoint,
|
||||
WebhookMessage,
|
||||
)
|
||||
@@ -434,7 +436,7 @@ class SettingsTest(InvenTreeTestCase):
|
||||
|
||||
try:
|
||||
InvenTreeSetting.set_setting(key, value, change_user=self.user)
|
||||
except Exception as exc:
|
||||
except Exception as exc: # pragma: no cover
|
||||
print(f"test_defaults: Failed to set default value for setting '{key}'")
|
||||
raise exc
|
||||
|
||||
@@ -1252,7 +1254,9 @@ class CurrencyAPITests(InvenTreeAPITestCase):
|
||||
|
||||
# Updating via the external exchange may not work every time
|
||||
for _idx in range(5):
|
||||
self.post(reverse('api-currency-refresh'), expected_code=200)
|
||||
self.post(
|
||||
reverse('api-currency-refresh'), expected_code=200, max_query_time=30
|
||||
)
|
||||
|
||||
# There should be some new exchange rate objects now
|
||||
if Rate.objects.all().exists():
|
||||
@@ -1683,6 +1687,161 @@ class CustomStatusTest(TestCase):
|
||||
)
|
||||
|
||||
|
||||
class SelectionListTest(InvenTreeAPITestCase):
|
||||
"""Tests for the SelectionList and SelectionListEntry model and API endpoints."""
|
||||
|
||||
fixtures = ['category', 'part', 'location', 'params', 'test_templates']
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for all tests."""
|
||||
super().setUp()
|
||||
|
||||
self.list = SelectionList.objects.create(name='Test List')
|
||||
self.entry1 = SelectionListEntry.objects.create(
|
||||
list=self.list,
|
||||
value='test1',
|
||||
label='Test Entry',
|
||||
description='Test Description',
|
||||
)
|
||||
self.entry2 = SelectionListEntry.objects.create(
|
||||
list=self.list,
|
||||
value='test2',
|
||||
label='Test Entry 2',
|
||||
description='Test Description 2',
|
||||
active=False,
|
||||
)
|
||||
self.list2 = SelectionList.objects.create(name='Test List 2', active=False)
|
||||
|
||||
# Urls
|
||||
self.list_url = reverse('api-selectionlist-detail', kwargs={'pk': self.list.pk})
|
||||
self.entry_url = reverse(
|
||||
'api-selectionlistentry-detail',
|
||||
kwargs={'entrypk': self.entry1.pk, 'pk': self.list.pk},
|
||||
)
|
||||
|
||||
def test_api(self):
|
||||
"""Test the SelectionList and SelctionListEntry API endpoints."""
|
||||
url = reverse('api-selectionlist-list')
|
||||
response = self.get(url, expected_code=200)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
response = self.get(self.list_url, expected_code=200)
|
||||
self.assertEqual(response.data['name'], 'Test List')
|
||||
self.assertEqual(len(response.data['choices']), 2)
|
||||
self.assertEqual(response.data['choices'][0]['value'], 'test1')
|
||||
self.assertEqual(response.data['choices'][0]['label'], 'Test Entry')
|
||||
|
||||
response = self.get(self.entry_url, expected_code=200)
|
||||
self.assertEqual(response.data['value'], 'test1')
|
||||
self.assertEqual(response.data['label'], 'Test Entry')
|
||||
self.assertEqual(response.data['description'], 'Test Description')
|
||||
|
||||
def test_api_update(self):
|
||||
"""Test adding and editing via the SelectionList."""
|
||||
# Test adding a new list via the API
|
||||
response = self.post(
|
||||
reverse('api-selectionlist-list'),
|
||||
{
|
||||
'name': 'New List',
|
||||
'active': True,
|
||||
'choices': [{'value': '1', 'label': 'Test Entry'}],
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
list_pk = response.data['pk']
|
||||
self.assertEqual(response.data['name'], 'New List')
|
||||
self.assertTrue(response.data['active'])
|
||||
self.assertEqual(len(response.data['choices']), 1)
|
||||
self.assertEqual(response.data['choices'][0]['value'], '1')
|
||||
|
||||
# Test editing the list choices via the API (remove and add in same call)
|
||||
response = self.patch(
|
||||
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
|
||||
{'choices': [{'value': '2', 'label': 'New Label'}]},
|
||||
expected_code=200,
|
||||
)
|
||||
self.assertEqual(response.data['name'], 'New List')
|
||||
self.assertTrue(response.data['active'])
|
||||
self.assertEqual(len(response.data['choices']), 1)
|
||||
self.assertEqual(response.data['choices'][0]['value'], '2')
|
||||
self.assertEqual(response.data['choices'][0]['label'], 'New Label')
|
||||
entry_id = response.data['choices'][0]['id']
|
||||
|
||||
# Test changing an entry via list API
|
||||
response = self.patch(
|
||||
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
|
||||
{'choices': [{'id': entry_id, 'value': '2', 'label': 'New Label Text'}]},
|
||||
expected_code=200,
|
||||
)
|
||||
self.assertEqual(response.data['name'], 'New List')
|
||||
self.assertTrue(response.data['active'])
|
||||
self.assertEqual(len(response.data['choices']), 1)
|
||||
self.assertEqual(response.data['choices'][0]['value'], '2')
|
||||
self.assertEqual(response.data['choices'][0]['label'], 'New Label Text')
|
||||
|
||||
def test_api_locked(self):
|
||||
"""Test editing with locked/unlocked list."""
|
||||
# Lock list
|
||||
self.list.locked = True
|
||||
self.list.save()
|
||||
response = self.patch(self.entry_url, {'label': 'New Label'}, expected_code=400)
|
||||
self.assertIn('Selection list is locked', response.data['list'])
|
||||
response = self.patch(self.list_url, {'name': 'New Name'}, expected_code=400)
|
||||
self.assertIn('Selection list is locked', response.data['locked'])
|
||||
|
||||
# Unlock the list
|
||||
self.list.locked = False
|
||||
self.list.save()
|
||||
response = self.patch(self.entry_url, {'label': 'New Label'}, expected_code=200)
|
||||
self.assertEqual(response.data['label'], 'New Label')
|
||||
response = self.patch(self.list_url, {'name': 'New Name'}, expected_code=200)
|
||||
self.assertEqual(response.data['name'], 'New Name')
|
||||
|
||||
def test_model_meta(self):
|
||||
"""Test model meta functions."""
|
||||
# Models str
|
||||
self.assertEqual(str(self.list), 'Test List')
|
||||
self.assertEqual(str(self.list2), 'Test List 2 (Inactive)')
|
||||
self.assertEqual(str(self.entry1), 'Test Entry')
|
||||
self.assertEqual(str(self.entry2), 'Test Entry 2 (Inactive)')
|
||||
|
||||
# API urls
|
||||
self.assertEqual(self.list.get_api_url(), '/api/selection/')
|
||||
|
||||
def test_parameter(self):
|
||||
"""Test the SelectionList parameter."""
|
||||
self.assertEqual(self.list.get_choices(), ['test1'])
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
|
||||
# Add to parameter
|
||||
part = Part.objects.get(pk=1)
|
||||
template = PartParameterTemplate.objects.create(
|
||||
name='test_parameter', units='', selectionlist=self.list
|
||||
)
|
||||
rsp = self.get(
|
||||
reverse('api-part-parameter-template-detail', kwargs={'pk': template.pk})
|
||||
)
|
||||
self.assertEqual(rsp.data['name'], 'test_parameter')
|
||||
self.assertEqual(rsp.data['choices'], '')
|
||||
|
||||
# Add to part
|
||||
url = reverse('api-part-parameter-list')
|
||||
response = self.post(
|
||||
url,
|
||||
{'part': part.pk, 'template': template.pk, 'data': 70},
|
||||
expected_code=400,
|
||||
)
|
||||
self.assertIn('Invalid choice for parameter value', response.data['data'])
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
{'part': part.pk, 'template': template.pk, 'data': self.entry1.value},
|
||||
expected_code=201,
|
||||
)
|
||||
self.assertEqual(response.data['data'], self.entry1.value)
|
||||
|
||||
|
||||
class AdminTest(AdminTestCase):
|
||||
"""Tests for the admin interface integration."""
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ import company.models
|
||||
from generic.states.api import StatusView
|
||||
from importer.mixins import DataExportViewMixin
|
||||
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
|
||||
from InvenTree.filters import (
|
||||
SEARCH_ORDER_FILTER,
|
||||
SEARCH_ORDER_FILTER_ALIAS,
|
||||
InvenTreeDateFilter,
|
||||
)
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.helpers_model import construct_absolute_url, get_base_url
|
||||
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||
@@ -140,6 +144,22 @@ class OrderFilter(rest_filters.FilterSet):
|
||||
queryset=Owner.objects.all(), field_name='responsible', label=_('Responsible')
|
||||
)
|
||||
|
||||
created_before = InvenTreeDateFilter(
|
||||
label=_('Created Before'), field_name='creation_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
created_after = InvenTreeDateFilter(
|
||||
label=_('Created After'), field_name='creation_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
target_date_before = InvenTreeDateFilter(
|
||||
label=_('Target Date Before'), field_name='target_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
target_date_after = InvenTreeDateFilter(
|
||||
label=_('Target Date After'), field_name='target_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
|
||||
class LineItemFilter(rest_filters.FilterSet):
|
||||
"""Base class for custom API filters for order line item list(s)."""
|
||||
@@ -171,6 +191,41 @@ class PurchaseOrderFilter(OrderFilter):
|
||||
model = models.PurchaseOrder
|
||||
fields = ['supplier']
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=Part.objects.all(),
|
||||
field_name='part',
|
||||
label=_('Part'),
|
||||
method='filter_part',
|
||||
)
|
||||
|
||||
def filter_part(self, queryset, name, part: Part):
|
||||
"""Filter by provided Part instance."""
|
||||
orders = part.purchase_orders()
|
||||
|
||||
return queryset.filter(pk__in=[o.pk for o in orders])
|
||||
|
||||
supplier_part = rest_filters.ModelChoiceFilter(
|
||||
queryset=company.models.SupplierPart.objects.all(),
|
||||
label=_('Supplier Part'),
|
||||
method='filter_supplier_part',
|
||||
)
|
||||
|
||||
def filter_supplier_part(
|
||||
self, queryset, name, supplier_part: company.models.SupplierPart
|
||||
):
|
||||
"""Filter by provided SupplierPart instance."""
|
||||
orders = supplier_part.purchase_orders()
|
||||
|
||||
return queryset.filter(pk__in=[o.pk for o in orders])
|
||||
|
||||
completed_before = InvenTreeDateFilter(
|
||||
label=_('Completed Before'), field_name='complete_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
completed_after = InvenTreeDateFilter(
|
||||
label=_('Completed After'), field_name='complete_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
|
||||
class PurchaseOrderMixin:
|
||||
"""Mixin class for PurchaseOrder endpoints."""
|
||||
@@ -221,32 +276,6 @@ class PurchaseOrderList(PurchaseOrderMixin, DataExportViewMixin, ListCreateAPI):
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Attempt to filter by part
|
||||
part = params.get('part', None)
|
||||
|
||||
if part is not None:
|
||||
try:
|
||||
part = Part.objects.get(pk=part)
|
||||
queryset = queryset.filter(
|
||||
id__in=[p.id for p in part.purchase_orders()]
|
||||
)
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
# Attempt to filter by supplier part
|
||||
supplier_part = params.get('supplier_part', None)
|
||||
|
||||
if supplier_part is not None:
|
||||
try:
|
||||
supplier_part = company.models.SupplierPart.objects.get(
|
||||
pk=supplier_part
|
||||
)
|
||||
queryset = queryset.filter(
|
||||
id__in=[p.id for p in supplier_part.purchase_orders()]
|
||||
)
|
||||
except (ValueError, company.models.SupplierPart.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by 'date range'
|
||||
min_date = params.get('min_date', None)
|
||||
max_date = params.get('max_date', None)
|
||||
@@ -276,6 +305,7 @@ class PurchaseOrderList(PurchaseOrderMixin, DataExportViewMixin, ListCreateAPI):
|
||||
'reference',
|
||||
'supplier__name',
|
||||
'target_date',
|
||||
'complete_date',
|
||||
'line_items',
|
||||
'status',
|
||||
'responsible',
|
||||
@@ -648,6 +678,14 @@ class SalesOrderFilter(OrderFilter):
|
||||
# Now we have a list of matching IDs, filter the queryset
|
||||
return queryset.filter(pk__in=sales_orders)
|
||||
|
||||
completed_before = InvenTreeDateFilter(
|
||||
label=_('Completed Before'), field_name='shipment_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
completed_after = InvenTreeDateFilter(
|
||||
label=_('Completed After'), field_name='shipment_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderMixin:
|
||||
"""Mixin class for SalesOrder endpoints."""
|
||||
@@ -1257,6 +1295,14 @@ class ReturnOrderFilter(OrderFilter):
|
||||
# Now we have a list of matching IDs, filter the queryset
|
||||
return queryset.filter(pk__in=return_orders)
|
||||
|
||||
completed_before = InvenTreeDateFilter(
|
||||
label=_('Completed Before'), field_name='complete_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
completed_after = InvenTreeDateFilter(
|
||||
label=_('Completed After'), field_name='complete_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
|
||||
class ReturnOrderMixin:
|
||||
"""Mixin class for ReturnOrder endpoints."""
|
||||
@@ -1325,6 +1371,7 @@ class ReturnOrderList(ReturnOrderMixin, DataExportViewMixin, ListCreateAPI):
|
||||
'line_items',
|
||||
'status',
|
||||
'target_date',
|
||||
'complete_date',
|
||||
'project_code',
|
||||
]
|
||||
|
||||
|
||||
@@ -2363,14 +2363,23 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
# endregion
|
||||
|
||||
@transaction.atomic
|
||||
def receive_line_item(self, line, location, user, note='', **kwargs):
|
||||
def receive_line_item(self, line, location, user, **kwargs):
|
||||
"""Receive a line item against this ReturnOrder.
|
||||
|
||||
Rules:
|
||||
- Transfers the StockItem to the specified location
|
||||
- Marks the StockItem as "quarantined"
|
||||
- Adds a tracking entry to the StockItem
|
||||
- Removes the 'customer' reference from the StockItem
|
||||
Arguments:
|
||||
line: ReturnOrderLineItem to receive
|
||||
location: StockLocation to receive the item to
|
||||
user: User performing the action
|
||||
|
||||
Keyword Arguments:
|
||||
note: Additional notes to add to the tracking entry
|
||||
status: Status to set the StockItem to (default: StockStatus.QUARANTINED)
|
||||
|
||||
Performs the following actions:
|
||||
- Transfers the StockItem to the specified location
|
||||
- Marks the StockItem as "quarantined"
|
||||
- Adds a tracking entry to the StockItem
|
||||
- Removes the 'customer' reference from the StockItem
|
||||
"""
|
||||
# Prevent an item from being "received" multiple times
|
||||
if line.received_date is not None:
|
||||
@@ -2379,17 +2388,18 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
|
||||
stock_item = line.item
|
||||
|
||||
deltas = {
|
||||
'status': StockStatus.QUARANTINED.value,
|
||||
'returnorder': self.pk,
|
||||
'location': location.pk,
|
||||
}
|
||||
status = kwargs.get('status')
|
||||
|
||||
if status is None:
|
||||
status = StockStatus.QUARANTINED.value
|
||||
|
||||
deltas = {'status': status, 'returnorder': self.pk, 'location': location.pk}
|
||||
|
||||
if stock_item.customer:
|
||||
deltas['customer'] = stock_item.customer.pk
|
||||
|
||||
# Update the StockItem
|
||||
stock_item.status = kwargs.get('status', StockStatus.QUARANTINED.value)
|
||||
stock_item.status = status
|
||||
stock_item.location = location
|
||||
stock_item.customer = None
|
||||
stock_item.sales_order = None
|
||||
@@ -2400,7 +2410,7 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
stock_item.add_tracking_entry(
|
||||
StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER,
|
||||
user,
|
||||
notes=note,
|
||||
notes=kwargs.get('note', ''),
|
||||
deltas=deltas,
|
||||
location=location,
|
||||
returnorder=self,
|
||||
|
||||
@@ -26,6 +26,7 @@ import part.filters as part_filters
|
||||
import part.models as part_models
|
||||
import stock.models
|
||||
import stock.serializers
|
||||
import stock.status_codes
|
||||
from common.serializers import ProjectCodeSerializer
|
||||
from company.serializers import (
|
||||
AddressBriefSerializer,
|
||||
@@ -1923,7 +1924,7 @@ class ReturnOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['item']
|
||||
fields = ['item', 'status']
|
||||
|
||||
item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=order.models.ReturnOrderLineItem.objects.all(),
|
||||
@@ -1933,6 +1934,15 @@ class ReturnOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
label=_('Return order line item'),
|
||||
)
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=stock.status_codes.StockStatus.items(),
|
||||
default=None,
|
||||
label=_('Status'),
|
||||
help_text=_('Stock item status code'),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
def validate_line_item(self, item):
|
||||
"""Validation for a single line item."""
|
||||
if item.order != self.context['order']:
|
||||
@@ -1950,7 +1960,7 @@ class ReturnOrderReceiveSerializer(serializers.Serializer):
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['items', 'location']
|
||||
fields = ['items', 'location', 'note']
|
||||
|
||||
items = ReturnOrderLineItemReceiveSerializer(many=True)
|
||||
|
||||
@@ -1963,6 +1973,14 @@ class ReturnOrderReceiveSerializer(serializers.Serializer):
|
||||
help_text=_('Select destination location for received items'),
|
||||
)
|
||||
|
||||
note = serializers.CharField(
|
||||
label=_('Note'),
|
||||
help_text=_('Additional note for incoming stock items'),
|
||||
required=False,
|
||||
default='',
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Perform data validation for this serializer."""
|
||||
order = self.context['order']
|
||||
@@ -1993,7 +2011,14 @@ class ReturnOrderReceiveSerializer(serializers.Serializer):
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
line_item = item['item']
|
||||
order.receive_line_item(line_item, location, request.user)
|
||||
|
||||
order.receive_line_item(
|
||||
line_item,
|
||||
location,
|
||||
request.user,
|
||||
note=data.get('note', ''),
|
||||
status=item.get('status', None),
|
||||
)
|
||||
|
||||
|
||||
@register_importer()
|
||||
|
||||
@@ -1159,10 +1159,10 @@ class PartFilter(rest_filters.FilterSet):
|
||||
|
||||
# Created date filters
|
||||
created_before = InvenTreeDateFilter(
|
||||
label='Updated before', field_name='creation_date', lookup_expr='lte'
|
||||
label='Updated before', field_name='creation_date', lookup_expr='lt'
|
||||
)
|
||||
created_after = InvenTreeDateFilter(
|
||||
label='Updated after', field_name='creation_date', lookup_expr='gte'
|
||||
label='Updated after', field_name='creation_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 4.2.16 on 2024-11-24 12:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('common', '0032_selectionlist_selectionlistentry_and_more'),
|
||||
('part', '0131_partrelated_note'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='partparametertemplate',
|
||||
name='selectionlist',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Selection list for this parameter',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='parameter_templates',
|
||||
to='common.selectionlist',
|
||||
verbose_name='Selection List',
|
||||
),
|
||||
)
|
||||
]
|
||||
@@ -3724,6 +3724,7 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
|
||||
description: Description of the parameter [string]
|
||||
checkbox: Boolean flag to indicate whether the parameter is a checkbox [bool]
|
||||
choices: List of valid choices for the parameter [string]
|
||||
selectionlist: SelectionList that should be used for choices [selectionlist]
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@@ -3805,6 +3806,9 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
|
||||
|
||||
def get_choices(self):
|
||||
"""Return a list of choices for this parameter template."""
|
||||
if self.selectionlist:
|
||||
return self.selectionlist.get_choices()
|
||||
|
||||
if not self.choices:
|
||||
return []
|
||||
|
||||
@@ -3845,6 +3849,16 @@ class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
selectionlist = models.ForeignKey(
|
||||
common.models.SelectionList,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='parameter_templates',
|
||||
verbose_name=_('Selection List'),
|
||||
help_text=_('Selection list for this parameter'),
|
||||
)
|
||||
|
||||
|
||||
@receiver(
|
||||
post_save,
|
||||
|
||||
@@ -316,7 +316,16 @@ class PartParameterTemplateSerializer(
|
||||
"""Metaclass defining serializer fields."""
|
||||
|
||||
model = PartParameterTemplate
|
||||
fields = ['pk', 'name', 'units', 'description', 'parts', 'checkbox', 'choices']
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
'units',
|
||||
'description',
|
||||
'parts',
|
||||
'checkbox',
|
||||
'choices',
|
||||
'selectionlist',
|
||||
]
|
||||
|
||||
parts = serializers.IntegerField(
|
||||
read_only=True,
|
||||
|
||||
@@ -281,6 +281,15 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
'A\t part\t category\t',
|
||||
'A pa\rrt cat\r\r\regory',
|
||||
'A part\u200e catego\u200fry\u202e',
|
||||
'A\u0000 part\u0000 category',
|
||||
'A part\u0007 category',
|
||||
'A\u001f part category',
|
||||
'A part\u007f category',
|
||||
'\u0001A part category',
|
||||
'A part\u0085 category',
|
||||
'A part category\u200e',
|
||||
'A part cat\u200fegory',
|
||||
'A\u0006 part\u007f categ\nory\r',
|
||||
]
|
||||
|
||||
for val in values:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The DigiKeyPlugin is meant to integrate the DigiKey API into Inventree.
|
||||
"""The DigiKeyPlugin is meant to integrate the DigiKey API into InvenTree.
|
||||
|
||||
This plugin can currently only match DigiKey barcodes to supplier parts.
|
||||
"""
|
||||
@@ -10,7 +10,7 @@ from plugin.mixins import SettingsMixin, SupplierBarcodeMixin
|
||||
|
||||
|
||||
class DigiKeyPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
|
||||
"""Plugin to integrate the DigiKey API into Inventree."""
|
||||
"""Plugin to integrate the DigiKey API into InvenTree."""
|
||||
|
||||
NAME = 'DigiKeyPlugin'
|
||||
TITLE = _('Supplier Integration - DigiKey')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The LCSCPlugin is meant to integrate the LCSC API into Inventree.
|
||||
"""The LCSCPlugin is meant to integrate the LCSC API into InvenTree.
|
||||
|
||||
This plugin can currently only match LCSC barcodes to supplier parts.
|
||||
"""
|
||||
@@ -12,7 +12,7 @@ from plugin.mixins import SettingsMixin, SupplierBarcodeMixin
|
||||
|
||||
|
||||
class LCSCPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
|
||||
"""Plugin to integrate the LCSC API into Inventree."""
|
||||
"""Plugin to integrate the LCSC API into InvenTree."""
|
||||
|
||||
NAME = 'LCSCPlugin'
|
||||
TITLE = _('Supplier Integration - LCSC')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The MouserPlugin is meant to integrate the Mouser API into Inventree.
|
||||
"""The MouserPlugin is meant to integrate the Mouser API into InvenTree.
|
||||
|
||||
This plugin currently only match Mouser barcodes to supplier parts.
|
||||
"""
|
||||
@@ -10,7 +10,7 @@ from plugin.mixins import SettingsMixin, SupplierBarcodeMixin
|
||||
|
||||
|
||||
class MouserPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
|
||||
"""Plugin to integrate the Mouser API into Inventree."""
|
||||
"""Plugin to integrate the Mouser API into InvenTree."""
|
||||
|
||||
NAME = 'MouserPlugin'
|
||||
TITLE = _('Supplier Integration - Mouser')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The TMEPlugin is meant to integrate the TME API into Inventree.
|
||||
"""The TMEPlugin is meant to integrate the TME API into InvenTree.
|
||||
|
||||
This plugin can currently only match TME barcodes to supplier parts.
|
||||
"""
|
||||
@@ -12,7 +12,7 @@ from plugin.mixins import SettingsMixin, SupplierBarcodeMixin
|
||||
|
||||
|
||||
class TMEPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
|
||||
"""Plugin to integrate the TME API into Inventree."""
|
||||
"""Plugin to integrate the TME API into InvenTree."""
|
||||
|
||||
NAME = 'TMEPlugin'
|
||||
TITLE = _('Supplier Integration - TME')
|
||||
|
||||
@@ -8,8 +8,10 @@ from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
|
||||
from django import template
|
||||
from django.apps.registry import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models.query import QuerySet
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -29,6 +31,50 @@ register = template.Library()
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def filter_queryset(queryset: QuerySet, **kwargs) -> QuerySet:
|
||||
"""Filter a database queryset based on the provided keyword arguments.
|
||||
|
||||
Arguments:
|
||||
queryset: The queryset to filter
|
||||
|
||||
Keyword Arguments:
|
||||
field (any): Filter the queryset based on the provided field
|
||||
|
||||
Example:
|
||||
{% filter_queryset companies is_supplier=True as suppliers %}
|
||||
"""
|
||||
return queryset.filter(**kwargs)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def filter_db_model(model_name: str, **kwargs) -> QuerySet:
|
||||
"""Filter a database model based on the provided keyword arguments.
|
||||
|
||||
Arguments:
|
||||
model_name: The name of the Django model - including app name (e.g. 'part.partcategory')
|
||||
|
||||
Keyword Arguments:
|
||||
field (any): Filter the queryset based on the provided field
|
||||
|
||||
Example:
|
||||
{% filter_db_model 'part.partcategory' is_template=True as template_parts %}
|
||||
"""
|
||||
app_name, model_name = model_name.split('.')
|
||||
|
||||
try:
|
||||
model = apps.get_model(app_name, model_name)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
queryset = model.objects.all()
|
||||
|
||||
return filter_queryset(queryset, **kwargs)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def getindex(container: list, index: int) -> Any:
|
||||
"""Return the value contained at the specified index of the list.
|
||||
|
||||
@@ -807,19 +807,28 @@ class StockFilter(rest_filters.FilterSet):
|
||||
|
||||
# Update date filters
|
||||
updated_before = InvenTreeDateFilter(
|
||||
label='Updated before', field_name='updated', lookup_expr='lte'
|
||||
label=_('Updated before'), field_name='updated', lookup_expr='lt'
|
||||
)
|
||||
|
||||
updated_after = InvenTreeDateFilter(
|
||||
label='Updated after', field_name='updated', lookup_expr='gte'
|
||||
label=_('Updated after'), field_name='updated', lookup_expr='gt'
|
||||
)
|
||||
|
||||
stocktake_before = InvenTreeDateFilter(
|
||||
label=_('Stocktake Before'), field_name='stocktake_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
stocktake_after = InvenTreeDateFilter(
|
||||
label=_('Stocktake After'), field_name='stocktake_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
# Stock "expiry" filters
|
||||
expiry_date_lte = InvenTreeDateFilter(
|
||||
label=_('Expiry date before'), field_name='expiry_date', lookup_expr='lte'
|
||||
expiry_before = InvenTreeDateFilter(
|
||||
label=_('Expiry date before'), field_name='expiry_date', lookup_expr='lt'
|
||||
)
|
||||
|
||||
expiry_date_gte = InvenTreeDateFilter(
|
||||
label=_('Expiry date after'), field_name='expiry_date', lookup_expr='gte'
|
||||
expiry_after = InvenTreeDateFilter(
|
||||
label=_('Expiry date after'), field_name='expiry_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
stale = rest_filters.BooleanFilter(label=_('Stale'), method='filter_stale')
|
||||
|
||||
@@ -1217,8 +1217,16 @@ class StockItem(
|
||||
def return_from_customer(self, location, user=None, **kwargs):
|
||||
"""Return stock item from customer, back into the specified location.
|
||||
|
||||
Arguments:
|
||||
location: The location to return the stock item to
|
||||
user: The user performing the action
|
||||
|
||||
Keyword Arguments:
|
||||
notes: Additional notes to add to the tracking entry
|
||||
status: Optionally set the status of the stock item
|
||||
|
||||
If the selected location is the same as the parent, merge stock back into the parent.
|
||||
Otherwise create the stock in the new location
|
||||
Otherwise create the stock in the new location.
|
||||
"""
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
@@ -1228,6 +1236,17 @@ class StockItem(
|
||||
tracking_info['customer'] = self.customer.id
|
||||
tracking_info['customer_name'] = self.customer.name
|
||||
|
||||
# Clear out allocation information for the stock item
|
||||
self.customer = None
|
||||
self.belongs_to = None
|
||||
self.sales_order = None
|
||||
self.location = location
|
||||
self.clearAllocations()
|
||||
|
||||
if status := kwargs.get('status'):
|
||||
self.status = status
|
||||
tracking_info['status'] = status
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.RETURNED_FROM_CUSTOMER,
|
||||
user,
|
||||
@@ -1236,13 +1255,6 @@ class StockItem(
|
||||
location=location,
|
||||
)
|
||||
|
||||
# Clear out allocation information for the stock item
|
||||
self.customer = None
|
||||
self.belongs_to = None
|
||||
self.sales_order = None
|
||||
self.location = location
|
||||
self.clearAllocations()
|
||||
|
||||
trigger_event('stockitem.returnedfromcustomer', id=self.id)
|
||||
|
||||
"""If new location is the same as the parent location, merge this stock back in the parent"""
|
||||
@@ -1403,6 +1415,7 @@ class StockItem(
|
||||
# Assign the other stock item into this one
|
||||
stock_item.belongs_to = self
|
||||
stock_item.consumed_by = build
|
||||
stock_item.location = None
|
||||
stock_item.save(add_note=False)
|
||||
|
||||
deltas = {'stockitem': self.pk}
|
||||
@@ -2420,7 +2433,7 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
|
||||
"""Hook function to be executed after StockItem object is saved/updated."""
|
||||
from part import tasks as part_tasks
|
||||
|
||||
if created and not InvenTree.ready.isImportingData():
|
||||
if not InvenTree.ready.isImportingData():
|
||||
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
||||
InvenTree.tasks.offload_task(
|
||||
part_tasks.notify_low_stock_if_required,
|
||||
|
||||
@@ -968,7 +968,7 @@ class ReturnStockItemSerializer(serializers.Serializer):
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['location', 'note']
|
||||
fields = ['location', 'status', 'notes']
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
@@ -979,6 +979,15 @@ class ReturnStockItemSerializer(serializers.Serializer):
|
||||
help_text=_('Destination location for returned item'),
|
||||
)
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=stock.status_codes.StockStatus.items(),
|
||||
default=None,
|
||||
label=_('Status'),
|
||||
help_text=_('Stock item status code'),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
notes = serializers.CharField(
|
||||
label=_('Notes'),
|
||||
help_text=_('Add transaction note (optional)'),
|
||||
@@ -994,9 +1003,13 @@ class ReturnStockItemSerializer(serializers.Serializer):
|
||||
data = self.validated_data
|
||||
|
||||
location = data['location']
|
||||
notes = data.get('notes', '')
|
||||
|
||||
item.return_from_customer(location, user=request.user, notes=notes)
|
||||
item.return_from_customer(
|
||||
location,
|
||||
user=request.user,
|
||||
notes=data.get('notes', ''),
|
||||
status=data.get('status', None),
|
||||
)
|
||||
|
||||
|
||||
class StockChangeStatusSerializer(serializers.Serializer):
|
||||
|
||||
@@ -951,6 +951,45 @@ class StockTest(StockTestBase):
|
||||
# Final purchase price should be the weighted average
|
||||
self.assertAlmostEqual(s1.purchase_price.amount, 16.875, places=3)
|
||||
|
||||
def test_notify_low_stock(self):
|
||||
"""Test that the 'notify_low_stock' task is triggered correctly."""
|
||||
FUNC_NAME = 'part.tasks.notify_low_stock_if_required'
|
||||
|
||||
from django_q.models import OrmQ
|
||||
|
||||
# Start from a blank slate
|
||||
OrmQ.objects.all().delete()
|
||||
|
||||
def check_func() -> bool:
|
||||
"""Check that the 'notify_low_stock_if_required' task has been triggered."""
|
||||
found = False
|
||||
for task in OrmQ.objects.all():
|
||||
if task.func() == FUNC_NAME:
|
||||
found = True
|
||||
break
|
||||
|
||||
# Clear the task queue (for the next test)
|
||||
OrmQ.objects.all().delete()
|
||||
|
||||
return found
|
||||
|
||||
self.assertFalse(check_func())
|
||||
|
||||
part = Part.objects.first()
|
||||
|
||||
# Create a new stock item for this part
|
||||
item = StockItem.objects.create(
|
||||
part=part, quantity=100, location=StockLocation.objects.first()
|
||||
)
|
||||
|
||||
self.assertTrue(check_func())
|
||||
self.assertFalse(check_func())
|
||||
|
||||
# Re-count the stock item
|
||||
item.stocktake(99, None)
|
||||
|
||||
self.assertTrue(check_func())
|
||||
|
||||
|
||||
class StockBarcodeTest(StockTestBase):
|
||||
"""Run barcode tests for the stock app."""
|
||||
|
||||
@@ -102,6 +102,8 @@ function getModelRenderer(model) {
|
||||
return renderReportTemplate;
|
||||
case 'pluginconfig':
|
||||
return renderPluginConfig;
|
||||
case 'selectionlist':
|
||||
return renderSelectionList;
|
||||
default:
|
||||
// Un-handled model type
|
||||
console.error(`Rendering not implemented for model '${model}'`);
|
||||
@@ -589,3 +591,15 @@ function renderPluginConfig(data, parameters={}) {
|
||||
parameters
|
||||
);
|
||||
}
|
||||
|
||||
// Render for "SelectionList" model
|
||||
function renderSelectionList(data, parameters={}) {
|
||||
|
||||
return renderModel(
|
||||
{
|
||||
text: data.name,
|
||||
textSecondary: data.description,
|
||||
},
|
||||
parameters
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1356,6 +1356,19 @@ function partParameterFields(options={}) {
|
||||
display_name: choice,
|
||||
});
|
||||
});
|
||||
} else if (response.selectionlist) {
|
||||
// Selection list - get choices from the API
|
||||
inventreeGet(`{% url "api-selectionlist-list" %}${response.selectionlist}/`, {}, {
|
||||
async: false,
|
||||
success: function(data) {
|
||||
data.choices.forEach(function(item) {
|
||||
choices.push({
|
||||
value: item.value,
|
||||
display_name: item.label,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1576,6 +1589,7 @@ function partParameterTemplateFields() {
|
||||
icon: 'fa-th-list',
|
||||
},
|
||||
checkbox: {},
|
||||
selectionlist: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -421,11 +421,11 @@ function getStockTableFilters() {
|
||||
title: '{% trans "Has purchase price" %}',
|
||||
description: '{% trans "Show stock items which have a purchase price set" %}',
|
||||
},
|
||||
expiry_date_lte: {
|
||||
expiry_before: {
|
||||
type: 'date',
|
||||
title: '{% trans "Expiry Date before" %}',
|
||||
},
|
||||
expiry_date_gte: {
|
||||
expiry_after: {
|
||||
type: 'date',
|
||||
title: '{% trans "Expiry Date after" %}',
|
||||
},
|
||||
|
||||
@@ -347,6 +347,8 @@ class RuleSet(models.Model):
|
||||
'common_webhookendpoint',
|
||||
'common_webhookmessage',
|
||||
'common_inventreecustomuserstatemodel',
|
||||
'common_selectionlistentry',
|
||||
'common_selectionlist',
|
||||
'users_owner',
|
||||
# Third-party tables
|
||||
'error_report_error',
|
||||
|
||||
+124
-122
@@ -198,98 +198,98 @@ click==8.1.7 \
|
||||
--hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \
|
||||
--hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de
|
||||
# via pip-tools
|
||||
coverage[toml]==7.6.4 \
|
||||
--hash=sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376 \
|
||||
--hash=sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9 \
|
||||
--hash=sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111 \
|
||||
--hash=sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172 \
|
||||
--hash=sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491 \
|
||||
--hash=sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546 \
|
||||
--hash=sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2 \
|
||||
--hash=sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11 \
|
||||
--hash=sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08 \
|
||||
--hash=sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c \
|
||||
--hash=sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2 \
|
||||
--hash=sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963 \
|
||||
--hash=sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613 \
|
||||
--hash=sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0 \
|
||||
--hash=sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db \
|
||||
--hash=sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf \
|
||||
--hash=sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73 \
|
||||
--hash=sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117 \
|
||||
--hash=sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1 \
|
||||
--hash=sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e \
|
||||
--hash=sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522 \
|
||||
--hash=sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25 \
|
||||
--hash=sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc \
|
||||
--hash=sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea \
|
||||
--hash=sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52 \
|
||||
--hash=sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a \
|
||||
--hash=sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07 \
|
||||
--hash=sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06 \
|
||||
--hash=sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa \
|
||||
--hash=sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901 \
|
||||
--hash=sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b \
|
||||
--hash=sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17 \
|
||||
--hash=sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0 \
|
||||
--hash=sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21 \
|
||||
--hash=sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19 \
|
||||
--hash=sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5 \
|
||||
--hash=sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51 \
|
||||
--hash=sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3 \
|
||||
--hash=sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3 \
|
||||
--hash=sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f \
|
||||
--hash=sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076 \
|
||||
--hash=sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a \
|
||||
--hash=sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718 \
|
||||
--hash=sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba \
|
||||
--hash=sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e \
|
||||
--hash=sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27 \
|
||||
--hash=sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e \
|
||||
--hash=sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09 \
|
||||
--hash=sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e \
|
||||
--hash=sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70 \
|
||||
--hash=sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f \
|
||||
--hash=sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72 \
|
||||
--hash=sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a \
|
||||
--hash=sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef \
|
||||
--hash=sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b \
|
||||
--hash=sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b \
|
||||
--hash=sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f \
|
||||
--hash=sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806 \
|
||||
--hash=sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b \
|
||||
--hash=sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1 \
|
||||
--hash=sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c \
|
||||
--hash=sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858
|
||||
coverage[toml]==7.6.8 \
|
||||
--hash=sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5 \
|
||||
--hash=sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf \
|
||||
--hash=sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb \
|
||||
--hash=sha256:13618bed0c38acc418896005732e565b317aa9e98d855a0e9f211a7ffc2d6638 \
|
||||
--hash=sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4 \
|
||||
--hash=sha256:177f01eeaa3aee4a5ffb0d1439c5952b53d5010f86e9d2667963e632e30082cc \
|
||||
--hash=sha256:193e3bffca48ad74b8c764fb4492dd875038a2f9925530cb094db92bb5e47bed \
|
||||
--hash=sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a \
|
||||
--hash=sha256:1f188a2402f8359cf0c4b1fe89eea40dc13b52e7b4fd4812450da9fcd210181d \
|
||||
--hash=sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649 \
|
||||
--hash=sha256:24eda3a24a38157eee639ca9afe45eefa8d2420d49468819ac5f88b10de84f4c \
|
||||
--hash=sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b \
|
||||
--hash=sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4 \
|
||||
--hash=sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443 \
|
||||
--hash=sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83 \
|
||||
--hash=sha256:3985b9be361d8fb6b2d1adc9924d01dec575a1d7453a14cccd73225cb79243ee \
|
||||
--hash=sha256:3988665ee376abce49613701336544041f2117de7b7fbfe91b93d8ff8b151c8e \
|
||||
--hash=sha256:3ac47fa29d8d41059ea3df65bd3ade92f97ee4910ed638e87075b8e8ce69599e \
|
||||
--hash=sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3 \
|
||||
--hash=sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0 \
|
||||
--hash=sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb \
|
||||
--hash=sha256:428ac484592f780e8cd7b6b14eb568f7c85460c92e2a37cb0c0e5186e1a0d076 \
|
||||
--hash=sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb \
|
||||
--hash=sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787 \
|
||||
--hash=sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1 \
|
||||
--hash=sha256:4db3ed6a907b555e57cc2e6f14dc3a4c2458cdad8919e40b5357ab9b6db6c43e \
|
||||
--hash=sha256:5c52a036535d12590c32c49209e79cabaad9f9ad8aa4cbd875b68c4d67a9cbce \
|
||||
--hash=sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801 \
|
||||
--hash=sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764 \
|
||||
--hash=sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365 \
|
||||
--hash=sha256:63c19702db10ad79151a059d2d6336fe0c470f2e18d0d4d1a57f7f9713875dcf \
|
||||
--hash=sha256:644ec81edec0f4ad17d51c838a7d01e42811054543b76d4ba2c5d6af741ce2a6 \
|
||||
--hash=sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71 \
|
||||
--hash=sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002 \
|
||||
--hash=sha256:716a78a342679cd1177bc8c2fe957e0ab91405bd43a17094324845200b2fddf4 \
|
||||
--hash=sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c \
|
||||
--hash=sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8 \
|
||||
--hash=sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4 \
|
||||
--hash=sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146 \
|
||||
--hash=sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc \
|
||||
--hash=sha256:912e95017ff51dc3d7b6e2be158dedc889d9a5cc3382445589ce554f1a34c0ea \
|
||||
--hash=sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4 \
|
||||
--hash=sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad \
|
||||
--hash=sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28 \
|
||||
--hash=sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451 \
|
||||
--hash=sha256:b39e6011cd06822eb964d038d5dff5da5d98652b81f5ecd439277b32361a3a50 \
|
||||
--hash=sha256:bd55f8fc8fa494958772a2a7302b0354ab16e0b9272b3c3d83cdb5bec5bd1779 \
|
||||
--hash=sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63 \
|
||||
--hash=sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e \
|
||||
--hash=sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc \
|
||||
--hash=sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022 \
|
||||
--hash=sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d \
|
||||
--hash=sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94 \
|
||||
--hash=sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b \
|
||||
--hash=sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d \
|
||||
--hash=sha256:e19122296822deafce89a0c5e8685704c067ae65d45e79718c92df7b3ec3d331 \
|
||||
--hash=sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a \
|
||||
--hash=sha256:e4c81ed2820b9023a9a90717020315e63b17b18c274a332e3b6437d7ff70abe0 \
|
||||
--hash=sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee \
|
||||
--hash=sha256:f39e2f3530ed1626c66e7493be7a8423b023ca852aacdc91fb30162c350d2a92 \
|
||||
--hash=sha256:f56f49b2553d7dd85fd86e029515a221e5c1f8cb3d9c38b470bc38bde7b8445a \
|
||||
--hash=sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9
|
||||
# via -r src/backend/requirements-dev.in
|
||||
cryptography==43.0.1 \
|
||||
--hash=sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494 \
|
||||
--hash=sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806 \
|
||||
--hash=sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d \
|
||||
--hash=sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062 \
|
||||
--hash=sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2 \
|
||||
--hash=sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4 \
|
||||
--hash=sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1 \
|
||||
--hash=sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85 \
|
||||
--hash=sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84 \
|
||||
--hash=sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042 \
|
||||
--hash=sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d \
|
||||
--hash=sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962 \
|
||||
--hash=sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2 \
|
||||
--hash=sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa \
|
||||
--hash=sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d \
|
||||
--hash=sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365 \
|
||||
--hash=sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96 \
|
||||
--hash=sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47 \
|
||||
--hash=sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d \
|
||||
--hash=sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d \
|
||||
--hash=sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c \
|
||||
--hash=sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb \
|
||||
--hash=sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277 \
|
||||
--hash=sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172 \
|
||||
--hash=sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034 \
|
||||
--hash=sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a \
|
||||
--hash=sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289
|
||||
cryptography==43.0.3 \
|
||||
--hash=sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362 \
|
||||
--hash=sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4 \
|
||||
--hash=sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa \
|
||||
--hash=sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83 \
|
||||
--hash=sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff \
|
||||
--hash=sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805 \
|
||||
--hash=sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6 \
|
||||
--hash=sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664 \
|
||||
--hash=sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08 \
|
||||
--hash=sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e \
|
||||
--hash=sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18 \
|
||||
--hash=sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f \
|
||||
--hash=sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73 \
|
||||
--hash=sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5 \
|
||||
--hash=sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984 \
|
||||
--hash=sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd \
|
||||
--hash=sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3 \
|
||||
--hash=sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e \
|
||||
--hash=sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405 \
|
||||
--hash=sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2 \
|
||||
--hash=sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c \
|
||||
--hash=sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995 \
|
||||
--hash=sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73 \
|
||||
--hash=sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16 \
|
||||
--hash=sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7 \
|
||||
--hash=sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd \
|
||||
--hash=sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# pdfminer-six
|
||||
@@ -322,13 +322,13 @@ filelock==3.16.1 \
|
||||
--hash=sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0 \
|
||||
--hash=sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435
|
||||
# via virtualenv
|
||||
identify==2.6.1 \
|
||||
--hash=sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0 \
|
||||
--hash=sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98
|
||||
identify==2.6.2 \
|
||||
--hash=sha256:c097384259f49e372f4ea00a19719d95ae27dd5ff0fd77ad630aa891306b82f3 \
|
||||
--hash=sha256:fab5c716c24d7a789775228823797296a2994b075fb6080ac83a102772a98cbd
|
||||
# via pre-commit
|
||||
importlib-metadata==8.4.0 \
|
||||
--hash=sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1 \
|
||||
--hash=sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5
|
||||
importlib-metadata==8.5.0 \
|
||||
--hash=sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b \
|
||||
--hash=sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# build
|
||||
@@ -340,9 +340,9 @@ nodeenv==1.9.1 \
|
||||
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
|
||||
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
|
||||
# via pre-commit
|
||||
packaging==24.1 \
|
||||
--hash=sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002 \
|
||||
--hash=sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124
|
||||
packaging==24.2 \
|
||||
--hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 \
|
||||
--hash=sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# build
|
||||
@@ -350,9 +350,9 @@ pdfminer-six==20240706 \
|
||||
--hash=sha256:c631a46d5da957a9ffe4460c5dce21e8431dabb615fee5f9f4400603a58d95a6 \
|
||||
--hash=sha256:f4f70e74174b4b3542fcb8406a210b6e2e27cd0f0b5fd04534a8cc0d8951e38c
|
||||
# via -r src/backend/requirements-dev.in
|
||||
pip==24.2 \
|
||||
--hash=sha256:2cd581cf58ab7fcfca4ce8efa6dcacd0de5bf8d0a3eb9ec927e07405f4d9e2a2 \
|
||||
--hash=sha256:5b5e490b5e9cb275c879595064adce9ebd31b854e3e803740b72f9ccf34a45b8
|
||||
pip==24.3.1 \
|
||||
--hash=sha256:3790624780082365f47549d032f3770eeb2b1e8bd1f7b2e02dace1afa361b4ed \
|
||||
--hash=sha256:ebcb60557f2aefabc2e0f918751cd24ea0d56d8ec5445fe1807f1d2109660b99
|
||||
# via pip-tools
|
||||
pip-tools==7.4.1 \
|
||||
--hash=sha256:4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9 \
|
||||
@@ -361,7 +361,9 @@ pip-tools==7.4.1 \
|
||||
platformdirs==4.3.6 \
|
||||
--hash=sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907 \
|
||||
--hash=sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb
|
||||
# via virtualenv
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# virtualenv
|
||||
pre-commit==4.0.1 \
|
||||
--hash=sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2 \
|
||||
--hash=sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878
|
||||
@@ -435,22 +437,22 @@ pyyaml==6.0.2 \
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# pre-commit
|
||||
setuptools==75.1.0 \
|
||||
--hash=sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2 \
|
||||
--hash=sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538
|
||||
setuptools==75.6.0 \
|
||||
--hash=sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6 \
|
||||
--hash=sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements-dev.in
|
||||
# pip-tools
|
||||
sqlparse==0.5.1 \
|
||||
--hash=sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4 \
|
||||
--hash=sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e
|
||||
sqlparse==0.5.2 \
|
||||
--hash=sha256:9e37b35e16d1cc652a2545f0997c1deb23ea28fa1f3eefe609eee3063c3b105f \
|
||||
--hash=sha256:e99bc85c78160918c3e1d9230834ab8d80fc06c59d03f8db2618f65f65dda55e
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# django
|
||||
tomli==2.0.2 \
|
||||
--hash=sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38 \
|
||||
--hash=sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed
|
||||
tomli==2.1.0 \
|
||||
--hash=sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8 \
|
||||
--hash=sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# build
|
||||
@@ -463,17 +465,17 @@ typing-extensions==4.12.2 \
|
||||
# -c src/backend/requirements.txt
|
||||
# asgiref
|
||||
# django-test-migrations
|
||||
virtualenv==20.27.0 \
|
||||
--hash=sha256:2ca56a68ed615b8fe4326d11a0dca5dfbe8fd68510fb6c6349163bed3c15f2b2 \
|
||||
--hash=sha256:44a72c29cceb0ee08f300b314848c86e57bf8d1f13107a5e671fb9274138d655
|
||||
virtualenv==20.27.1 \
|
||||
--hash=sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba \
|
||||
--hash=sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4
|
||||
# via pre-commit
|
||||
wheel==0.44.0 \
|
||||
--hash=sha256:2376a90c98cc337d18623527a97c31797bd02bad0033d41547043a1cbfbe448f \
|
||||
--hash=sha256:a29c3f2817e95ab89aa4660681ad547c0e9547f20e75b0562fe7723c9a2a9d49
|
||||
wheel==0.45.1 \
|
||||
--hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
|
||||
--hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248
|
||||
# via pip-tools
|
||||
zipp==3.20.2 \
|
||||
--hash=sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350 \
|
||||
--hash=sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29
|
||||
zipp==3.21.0 \
|
||||
--hash=sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4 \
|
||||
--hash=sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# importlib-metadata
|
||||
|
||||
@@ -48,7 +48,6 @@ python-dotenv # Environment variable management
|
||||
pyyaml>=6.0.1 # YAML parsing
|
||||
qrcode[pil] # QR code generator
|
||||
rapidfuzz # Fuzzy string matching
|
||||
regex # Advanced regular expressions
|
||||
sentry-sdk # Error reporting (optional)
|
||||
setuptools # Standard dependency
|
||||
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
|
||||
|
||||
+818
-886
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,8 @@ export default defineConfig({
|
||||
fullyParallel: true,
|
||||
timeout: 90000,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 3 : undefined,
|
||||
reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@mantine/core';
|
||||
import { Button, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
IconBrandAzure,
|
||||
IconBrandBitbucket,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IconLogin
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
@@ -49,14 +50,23 @@ export function SsoButton({ provider }: Readonly<{ provider: Provider }>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
leftSection={getBrandIcon(provider)}
|
||||
radius='xl'
|
||||
component='a'
|
||||
onClick={login}
|
||||
<Tooltip
|
||||
label={
|
||||
provider.login
|
||||
? t`You will be redirected to the provider for further actions.`
|
||||
: t`This provider is not full set up.`
|
||||
}
|
||||
>
|
||||
{provider.display_name}{' '}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={getBrandIcon(provider)}
|
||||
radius='xl'
|
||||
component='a'
|
||||
onClick={login}
|
||||
disabled={!provider.login}
|
||||
>
|
||||
{provider.display_name}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
function getBrandIcon(provider: Provider) {
|
||||
|
||||
@@ -219,7 +219,7 @@ export function ApiForm({
|
||||
// If the user has specified initial data, that overrides default values
|
||||
// But, *only* for the fields we have specified
|
||||
if (props.initialData) {
|
||||
Object.keys(props.initialData).map((key) => {
|
||||
Object.keys(props.initialData).forEach((key) => {
|
||||
if (key in defaultValuesMap) {
|
||||
defaultValuesMap[key] =
|
||||
props?.initialData?.[key] ?? defaultValuesMap[key];
|
||||
|
||||
@@ -107,12 +107,14 @@ export function AuthenticationForm() {
|
||||
<TextInput
|
||||
required
|
||||
label={t`Username`}
|
||||
aria-label='login-username'
|
||||
placeholder={t`Your username`}
|
||||
{...classicForm.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
required
|
||||
label={t`Password`}
|
||||
aria-label='login-password'
|
||||
placeholder={t`Your password`}
|
||||
{...classicForm.getInputProps('password')}
|
||||
/>
|
||||
@@ -228,12 +230,14 @@ export function RegistrationForm() {
|
||||
<TextInput
|
||||
required
|
||||
label={t`Username`}
|
||||
aria-label='register-username'
|
||||
placeholder={t`Your username`}
|
||||
{...registrationForm.getInputProps('username')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label={t`Email`}
|
||||
aria-label='register-email'
|
||||
description={t`This will be used for a confirmation`}
|
||||
placeholder='email@example.org'
|
||||
{...registrationForm.getInputProps('email')}
|
||||
@@ -241,12 +245,14 @@ export function RegistrationForm() {
|
||||
<PasswordInput
|
||||
required
|
||||
label={t`Password`}
|
||||
aria-label='register-password'
|
||||
placeholder={t`Your password`}
|
||||
{...registrationForm.getInputProps('password1')}
|
||||
/>
|
||||
<PasswordInput
|
||||
required
|
||||
label={t`Password repeat`}
|
||||
aria-label='register-password-repeat'
|
||||
placeholder={t`Repeat password`}
|
||||
{...registrationForm.getInputProps('password2')}
|
||||
/>
|
||||
|
||||
@@ -49,6 +49,7 @@ export type ApiFormAdjustFilterType = {
|
||||
* @param onValueChange : Callback function to call when the field value changes
|
||||
* @param adjustFilters : Callback function to adjust the filters for a related field before a query is made
|
||||
* @param adjustValue : Callback function to adjust the value of the field before it is sent to the API
|
||||
* @param addRow : Callback function to add a new row to a table field
|
||||
* @param onKeyDown : Callback function to get which key was pressed in the form to handle submission on enter
|
||||
*/
|
||||
export type ApiFormFieldType = {
|
||||
@@ -94,6 +95,7 @@ export type ApiFormFieldType = {
|
||||
adjustValue?: (value: any) => any;
|
||||
onValueChange?: (value: any, record?: any) => void;
|
||||
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
|
||||
addRow?: () => any;
|
||||
headers?: string[];
|
||||
depends_on?: string[];
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function DateField({
|
||||
let dv: Date | null = null;
|
||||
|
||||
if (field.value) {
|
||||
dv = new Date(field.value) ?? null;
|
||||
dv = new Date(field.value);
|
||||
}
|
||||
|
||||
// Ensure that the date is valid
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
import { identifierString } from '../../../functions/conversion';
|
||||
import { InvenTreeIcon } from '../../../functions/icons';
|
||||
import { AddItemButton } from '../../buttons/AddItemButton';
|
||||
import { StandaloneField } from '../StandaloneField';
|
||||
import type { ApiFormFieldType } from './ApiFormField';
|
||||
|
||||
@@ -109,6 +110,17 @@ export function TableField({
|
||||
field.onChange(val);
|
||||
};
|
||||
|
||||
const fieldDefinition = useMemo(() => {
|
||||
return {
|
||||
...definition,
|
||||
modelRenderer: undefined,
|
||||
onValueChange: undefined,
|
||||
adjustFilters: undefined,
|
||||
read_only: undefined,
|
||||
addRow: undefined
|
||||
};
|
||||
}, [definition]);
|
||||
|
||||
// Extract errors associated with the current row
|
||||
const rowErrors: any = useCallback(
|
||||
(idx: number) => {
|
||||
@@ -134,6 +146,7 @@ export function TableField({
|
||||
})}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{value.length > 0 ? (
|
||||
value.map((item: any, idx: number) => {
|
||||
@@ -170,6 +183,26 @@ export function TableField({
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
{definition.addRow && (
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={definition.headers?.length}>
|
||||
<AddItemButton
|
||||
tooltip={t`Add new row`}
|
||||
onClick={() => {
|
||||
if (definition.addRow === undefined) return;
|
||||
const ret = definition.addRow();
|
||||
if (ret) {
|
||||
const val = field.value;
|
||||
val.push(ret);
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ export default function ImporterDrawer({
|
||||
// Map from import steps to stepper steps
|
||||
const currentStep = useMemo(() => {
|
||||
switch (session.status) {
|
||||
default:
|
||||
case importSessionStatus.INITIAL:
|
||||
return 0;
|
||||
case importSessionStatus.MAPPING:
|
||||
@@ -81,6 +80,8 @@ export default function ImporterDrawer({
|
||||
return 3;
|
||||
case importSessionStatus.COMPLETE:
|
||||
return 4;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}, [session.status]);
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export const getPluginTemplateEditor = (
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await func({
|
||||
func({
|
||||
ref: elRef.current!,
|
||||
registerHandlers: ({ getCode, setCode }) => {
|
||||
setCodeRef.current = setCode;
|
||||
@@ -136,7 +136,7 @@ export const getPluginTemplatePreview = (
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await func({
|
||||
func({
|
||||
ref: elRef.current!,
|
||||
registerHandlers: ({ updatePreview }) => {
|
||||
updatePreviewRef.current = updatePreview;
|
||||
|
||||
@@ -34,3 +34,16 @@ export function RenderImportSession({
|
||||
}): ReactNode {
|
||||
return instance && <RenderInlineModel primary={instance.data_file} />;
|
||||
}
|
||||
|
||||
export function RenderSelectionList({
|
||||
instance
|
||||
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||
return (
|
||||
instance && (
|
||||
<RenderInlineModel
|
||||
primary={instance.name}
|
||||
secondary={instance.description}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
RenderContentType,
|
||||
RenderError,
|
||||
RenderImportSession,
|
||||
RenderProjectCode
|
||||
RenderProjectCode,
|
||||
RenderSelectionList
|
||||
} from './Generic';
|
||||
import { ModelInformationDict } from './ModelType';
|
||||
import {
|
||||
@@ -94,6 +95,7 @@ const RendererLookup: EnumDictionary<
|
||||
[ModelType.labeltemplate]: RenderLabelTemplate,
|
||||
[ModelType.pluginconfig]: RenderPlugin,
|
||||
[ModelType.contenttype]: RenderContentType,
|
||||
[ModelType.selectionlist]: RenderSelectionList,
|
||||
[ModelType.error]: RenderError
|
||||
};
|
||||
|
||||
|
||||
@@ -286,6 +286,12 @@ export const ModelInformationDict: ModelDict = {
|
||||
api_endpoint: ApiEndpoints.content_type_list,
|
||||
icon: 'list_details'
|
||||
},
|
||||
selectionlist: {
|
||||
label: () => t`Selection List`,
|
||||
label_multiple: () => t`Selection Lists`,
|
||||
api_endpoint: ApiEndpoints.selectionlist_list,
|
||||
icon: 'list_details'
|
||||
},
|
||||
error: {
|
||||
label: () => t`Error`,
|
||||
label_multiple: () => t`Errors`,
|
||||
|
||||
@@ -38,7 +38,7 @@ export function getActions(navigate: NavigateFunction) {
|
||||
{
|
||||
id: 'server-info',
|
||||
label: t`Server Information`,
|
||||
description: t`About this Inventree instance`,
|
||||
description: t`About this InvenTree instance`,
|
||||
onClick: () => serverInfo(),
|
||||
leftSection: <IconLink size='1.2rem' />
|
||||
},
|
||||
|
||||
@@ -115,7 +115,7 @@ export function AboutLinks(): MenuLinkItem[] {
|
||||
{
|
||||
id: 'instance',
|
||||
title: t`System Information`,
|
||||
description: t`About this Inventree instance`,
|
||||
description: t`About this InvenTree instance`,
|
||||
icon: 'info',
|
||||
action: serverInfo
|
||||
},
|
||||
|
||||
@@ -49,6 +49,8 @@ export enum ApiEndpoints {
|
||||
owner_list = 'user/owner/',
|
||||
content_type_list = 'contenttype/',
|
||||
icons = 'icons/',
|
||||
selectionlist_list = 'selection/',
|
||||
selectionlist_detail = 'selection/:id/',
|
||||
|
||||
// Barcode API endpoints
|
||||
barcode = 'barcode/',
|
||||
|
||||
@@ -33,5 +33,6 @@ export enum ModelType {
|
||||
labeltemplate = 'labeltemplate',
|
||||
pluginconfig = 'pluginconfig',
|
||||
contenttype = 'contenttype',
|
||||
selectionlist = 'selectionlist',
|
||||
error = 'error'
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { t } from '@lingui/macro';
|
||||
import { IconPackages } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../App';
|
||||
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||
|
||||
/**
|
||||
@@ -204,7 +207,7 @@ export function usePartParameterFields({
|
||||
setChoices(
|
||||
_choices.map((choice) => {
|
||||
return {
|
||||
label: choice.trim(),
|
||||
display_name: choice.trim(),
|
||||
value: choice.trim()
|
||||
};
|
||||
})
|
||||
@@ -214,6 +217,22 @@ export function usePartParameterFields({
|
||||
setChoices([]);
|
||||
setFieldType('string');
|
||||
}
|
||||
} else if (record?.selectionlist) {
|
||||
api
|
||||
.get(
|
||||
apiUrl(ApiEndpoints.selectionlist_detail, record.selectionlist)
|
||||
)
|
||||
.then((res) => {
|
||||
setChoices(
|
||||
res.data.choices.map((item: any) => {
|
||||
return {
|
||||
value: item.value,
|
||||
display_name: item.label
|
||||
};
|
||||
})
|
||||
);
|
||||
setFieldType('choice');
|
||||
});
|
||||
} else {
|
||||
setChoices([]);
|
||||
setFieldType('string');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IconAddressBook, IconUser, IconUsers } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
import type {
|
||||
ApiFormAdjustFilterType,
|
||||
ApiFormFieldSet
|
||||
@@ -11,8 +12,10 @@ import type {
|
||||
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||
import { Thumbnail } from '../components/images/Thumbnail';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { StatusFilterOptions } from '../tables/Filter';
|
||||
|
||||
export function useReturnOrderFields({
|
||||
duplicateOrderId
|
||||
@@ -133,6 +136,17 @@ function ReturnOrderLineItemFormRow({
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
}>) {
|
||||
const statusOptions = useMemo(() => {
|
||||
return (
|
||||
StatusFilterOptions(ModelType.stockitem)()?.map((choice) => {
|
||||
return {
|
||||
value: choice.value,
|
||||
display_name: choice.label
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.Tr>
|
||||
@@ -146,7 +160,21 @@ function ReturnOrderLineItemFormRow({
|
||||
<div>{record.part_detail.name}</div>
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
<Table.Td>{record.item_detail.serial}</Table.Td>
|
||||
<Table.Td># {record.item_detail.serial}</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldDefinition={{
|
||||
field_type: 'choice',
|
||||
label: t`Status`,
|
||||
choices: statusOptions,
|
||||
onValueChange: (value) => {
|
||||
props.changeFn(props.idx, 'status', value);
|
||||
}
|
||||
}}
|
||||
defaultValue={record.item_detail?.status}
|
||||
error={props.rowErrors?.status?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
</Table.Td>
|
||||
@@ -181,7 +209,7 @@ export function useReceiveReturnOrderLineItems(
|
||||
/>
|
||||
);
|
||||
},
|
||||
headers: [t`Part`, t`Serial Number`]
|
||||
headers: [t`Part`, t`Stock Item`, t`Status`]
|
||||
},
|
||||
location: {
|
||||
filters: {
|
||||
|
||||
@@ -159,7 +159,7 @@ export function useStockFields({
|
||||
hidden:
|
||||
create ||
|
||||
partInstance.trackable == false ||
|
||||
(!stockItem?.quantity != undefined && stockItem?.quantity != 1)
|
||||
(stockItem?.quantity != undefined && stockItem?.quantity != 1)
|
||||
},
|
||||
batch: {
|
||||
placeholder: nextBatchCode
|
||||
@@ -870,6 +870,7 @@ function stockOperationModal({
|
||||
endpoint,
|
||||
filters,
|
||||
title,
|
||||
successMessage,
|
||||
modalFunc = useCreateApiFormModal
|
||||
}: {
|
||||
items?: object;
|
||||
@@ -880,6 +881,7 @@ function stockOperationModal({
|
||||
fieldGenerator: (items: any[]) => ApiFormFieldSet;
|
||||
endpoint: ApiEndpoints;
|
||||
title: string;
|
||||
successMessage?: string;
|
||||
modalFunc?: apiModalFunc;
|
||||
}) {
|
||||
const baseParams: any = {
|
||||
@@ -932,12 +934,13 @@ function stockOperationModal({
|
||||
fields: fields,
|
||||
title: title,
|
||||
size: '80%',
|
||||
successMessage: successMessage,
|
||||
onFormSuccess: () => refresh()
|
||||
});
|
||||
}
|
||||
|
||||
export type StockOperationProps = {
|
||||
items?: object;
|
||||
items?: any[];
|
||||
pk?: number;
|
||||
filters?: any;
|
||||
model: ModelType.stockitem | 'location' | ModelType.part;
|
||||
@@ -949,7 +952,8 @@ export function useAddStockItem(props: StockOperationProps) {
|
||||
...props,
|
||||
fieldGenerator: stockAddFields,
|
||||
endpoint: ApiEndpoints.stock_add,
|
||||
title: t`Add Stock`
|
||||
title: t`Add Stock`,
|
||||
successMessage: t`Stock added`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -958,7 +962,8 @@ export function useRemoveStockItem(props: StockOperationProps) {
|
||||
...props,
|
||||
fieldGenerator: stockRemoveFields,
|
||||
endpoint: ApiEndpoints.stock_remove,
|
||||
title: t`Remove Stock`
|
||||
title: t`Remove Stock`,
|
||||
successMessage: t`Stock removed`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -967,7 +972,8 @@ export function useTransferStockItem(props: StockOperationProps) {
|
||||
...props,
|
||||
fieldGenerator: stockTransferFields,
|
||||
endpoint: ApiEndpoints.stock_transfer,
|
||||
title: t`Transfer Stock`
|
||||
title: t`Transfer Stock`,
|
||||
successMessage: t`Stock transferred`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -976,7 +982,8 @@ export function useCountStockItem(props: StockOperationProps) {
|
||||
...props,
|
||||
fieldGenerator: stockCountFields,
|
||||
endpoint: ApiEndpoints.stock_count,
|
||||
title: t`Count Stock`
|
||||
title: t`Count Stock`,
|
||||
successMessage: t`Stock counted`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -985,7 +992,8 @@ export function useChangeStockStatus(props: StockOperationProps) {
|
||||
...props,
|
||||
fieldGenerator: stockChangeStatusFields,
|
||||
endpoint: ApiEndpoints.stock_change_status,
|
||||
title: t`Change Stock Status`
|
||||
title: t`Change Stock Status`,
|
||||
successMessage: t`Stock status changed`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -994,16 +1002,24 @@ export function useMergeStockItem(props: StockOperationProps) {
|
||||
...props,
|
||||
fieldGenerator: stockMergeFields,
|
||||
endpoint: ApiEndpoints.stock_merge,
|
||||
title: t`Merge Stock`
|
||||
title: t`Merge Stock`,
|
||||
successMessage: t`Stock merged`
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignStockItem(props: StockOperationProps) {
|
||||
// Filter items - only allow 'salable' items
|
||||
const items = useMemo(() => {
|
||||
return props.items?.filter((item) => item?.part_detail?.salable);
|
||||
}, [props.items]);
|
||||
|
||||
return stockOperationModal({
|
||||
...props,
|
||||
items: items,
|
||||
fieldGenerator: stockAssignFields,
|
||||
endpoint: ApiEndpoints.stock_assign,
|
||||
title: t`Assign Stock to Customer`
|
||||
title: t`Assign Stock to Customer`,
|
||||
successMessage: t`Stock assigned to customer`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1013,7 +1029,8 @@ export function useDeleteStockItem(props: StockOperationProps) {
|
||||
fieldGenerator: stockDeleteFields,
|
||||
endpoint: ApiEndpoints.stock_item_list,
|
||||
modalFunc: useDeleteApiFormModal,
|
||||
title: t`Delete Stock Items`
|
||||
title: t`Delete Stock Items`,
|
||||
successMessage: t`Stock deleted`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Table } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
import type {
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
|
||||
|
||||
function BuildAllocateLineRow({
|
||||
props
|
||||
}: Readonly<{
|
||||
props: TableFieldRowProps;
|
||||
}>) {
|
||||
const valueField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'string',
|
||||
name: 'value',
|
||||
required: true,
|
||||
value: props.item.value,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'value', value);
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
const labelField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'string',
|
||||
name: 'label',
|
||||
required: true,
|
||||
value: props.item.label,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'label', value);
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
const descriptionField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'string',
|
||||
name: 'description',
|
||||
required: true,
|
||||
value: props.item.description,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'description', value);
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
const activeField: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
field_type: 'boolean',
|
||||
name: 'active',
|
||||
required: true,
|
||||
value: props.item.active,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'active', value);
|
||||
}
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`table-row-${props.item.pk}`}>
|
||||
<Table.Td>
|
||||
<StandaloneField fieldName='value' fieldDefinition={valueField} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField fieldName='label' fieldDefinition={labelField} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='description'
|
||||
fieldDefinition={descriptionField}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField fieldName='active' fieldDefinition={activeField} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function selectionListFields(): ApiFormFieldSet {
|
||||
return {
|
||||
name: {},
|
||||
description: {},
|
||||
active: {},
|
||||
locked: {},
|
||||
source_plugin: {},
|
||||
source_string: {},
|
||||
choices: {
|
||||
label: t`Entries`,
|
||||
description: t`List of entries to choose from`,
|
||||
field_type: 'table',
|
||||
value: [],
|
||||
headers: [t`Value`, t`Label`, t`Description`, t`Active`],
|
||||
modelRenderer: (row: TableFieldRowProps) => (
|
||||
<BuildAllocateLineRow props={row} />
|
||||
),
|
||||
addRow: () => {
|
||||
return {
|
||||
value: '',
|
||||
label: '',
|
||||
description: '',
|
||||
active: true
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -66,6 +66,8 @@ const MachineManagementPanel = Loadable(
|
||||
lazy(() => import('./MachineManagementPanel'))
|
||||
);
|
||||
|
||||
const PartParameterPanel = Loadable(lazy(() => import('./PartParameterPanel')));
|
||||
|
||||
const ErrorReportTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/ErrorTable'))
|
||||
);
|
||||
@@ -86,6 +88,10 @@ const CustomStateTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/CustomStateTable'))
|
||||
);
|
||||
|
||||
const CustomUnitsTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/CustomUnitsTable'))
|
||||
);
|
||||
|
||||
const PartParameterTemplateTable = Loadable(
|
||||
lazy(() => import('../../../../tables/part/PartParameterTemplateTable'))
|
||||
);
|
||||
@@ -169,7 +175,7 @@ export default function AdminCenter() {
|
||||
name: 'part-parameters',
|
||||
label: t`Part Parameters`,
|
||||
icon: <IconList />,
|
||||
content: <PartParameterTemplateTable />
|
||||
content: <PartParameterPanel />
|
||||
},
|
||||
{
|
||||
name: 'category-parameters',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Accordion } from '@mantine/core';
|
||||
|
||||
import { StylishText } from '../../../../components/items/StylishText';
|
||||
import PartParameterTemplateTable from '../../../../tables/part/PartParameterTemplateTable';
|
||||
import SelectionListTable from '../../../../tables/part/SelectionListTable';
|
||||
|
||||
export default function PartParameterPanel() {
|
||||
return (
|
||||
<Accordion defaultValue='parametertemplate'>
|
||||
<Accordion.Item value='parametertemplate' key='parametertemplate'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Part Parameter Template`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<PartParameterTemplateTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='selectionlist' key='selectionlist'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Selection Lists`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SelectionListTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { Group, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import { PrintingActions } from '../../components/buttons/PrintingActions';
|
||||
import {
|
||||
@@ -278,12 +276,6 @@ export default function Stock() {
|
||||
() => [
|
||||
<AdminButton model={ModelType.stocklocation} id={location.pk} />,
|
||||
<LocateItemButton locationId={location.pk} />,
|
||||
<ActionButton
|
||||
icon={<InvenTreeIcon icon='stocktake' />}
|
||||
onClick={notYetImplemented}
|
||||
variant='outline'
|
||||
size='lg'
|
||||
/>,
|
||||
location.pk ? (
|
||||
<BarcodeActionDropdown
|
||||
model={ModelType.stocklocation}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { UserRoles } from '../../enums/Roles';
|
||||
import {
|
||||
type StockOperationProps,
|
||||
useAddStockItem,
|
||||
useAssignStockItem,
|
||||
useCountStockItem,
|
||||
useRemoveStockItem,
|
||||
useStockFields,
|
||||
@@ -591,7 +592,7 @@ export default function StockDetail() {
|
||||
|
||||
const stockActionProps: StockOperationProps = useMemo(() => {
|
||||
return {
|
||||
items: stockitem,
|
||||
items: [stockitem],
|
||||
model: ModelType.stockitem,
|
||||
refresh: refreshInstance,
|
||||
filters: {
|
||||
@@ -604,6 +605,7 @@ export default function StockDetail() {
|
||||
const addStockItem = useAddStockItem(stockActionProps);
|
||||
const removeStockItem = useRemoveStockItem(stockActionProps);
|
||||
const transferStockItem = useTransferStockItem(stockActionProps);
|
||||
const assignToCustomer = useAssignStockItem(stockActionProps);
|
||||
|
||||
const serializeStockFields = useStockItemSerializeFields({
|
||||
partId: stockitem.part,
|
||||
@@ -640,10 +642,12 @@ export default function StockDetail() {
|
||||
),
|
||||
fields: {
|
||||
location: {},
|
||||
status: {},
|
||||
notes: {}
|
||||
},
|
||||
initialData: {
|
||||
location: stockitem.location ?? stockitem.part_detail?.default_location
|
||||
location: stockitem.location ?? stockitem.part_detail?.default_location,
|
||||
status: stockitem.status_custom_key ?? stockitem.status
|
||||
},
|
||||
successMessage: t`Item returned to stock`,
|
||||
onFormSuccess: () => {
|
||||
@@ -734,7 +738,7 @@ export default function StockDetail() {
|
||||
{
|
||||
name: t`Return`,
|
||||
tooltip: t`Return from customer`,
|
||||
hidden: !stockitem.sales_order,
|
||||
hidden: !stockitem.customer,
|
||||
icon: (
|
||||
<InvenTreeIcon
|
||||
icon='return_orders'
|
||||
@@ -744,6 +748,17 @@ export default function StockDetail() {
|
||||
onClick: () => {
|
||||
stockitem.pk && returnStockItem.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t`Assign to Customer`,
|
||||
tooltip: t`Assign to a customer`,
|
||||
hidden: !!stockitem.customer,
|
||||
icon: (
|
||||
<InvenTreeIcon icon='customer' iconProps={{ color: 'blue' }} />
|
||||
),
|
||||
onClick: () => {
|
||||
stockitem.pk && assignToCustomer.open();
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>,
|
||||
@@ -876,6 +891,7 @@ export default function StockDetail() {
|
||||
{transferStockItem.modal}
|
||||
{serializeStockItem.modal}
|
||||
{returnStockItem.modal}
|
||||
{assignToCustomer.modal}
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
);
|
||||
|
||||
@@ -92,7 +92,7 @@ export interface SettingChoice {
|
||||
}
|
||||
|
||||
export enum SettingTyp {
|
||||
Inventree = 'inventree',
|
||||
InvenTree = 'inventree',
|
||||
Plugin = 'plugin',
|
||||
User = 'user',
|
||||
Notification = 'notification'
|
||||
|
||||
@@ -242,6 +242,14 @@ export function CreationDateColumn(props: TableColumnProps): TableColumn {
|
||||
});
|
||||
}
|
||||
|
||||
export function CompletionDateColumn(props: TableColumnProps): TableColumn {
|
||||
return DateColumn({
|
||||
accessor: 'completion_date',
|
||||
title: t`Completion Date`,
|
||||
...props
|
||||
});
|
||||
}
|
||||
|
||||
export function ShipmentDateColumn(props: TableColumnProps): TableColumn {
|
||||
return DateColumn({
|
||||
accessor: 'shipment_date',
|
||||
|
||||
@@ -21,8 +21,9 @@ export type TableFilterChoice = {
|
||||
* boolean: A simple true/false filter
|
||||
* choice: A filter which allows selection from a list of (supplied)
|
||||
* date: A filter which allows selection from a date input
|
||||
* text: A filter which allows raw text input
|
||||
*/
|
||||
export type TableFilterType = 'boolean' | 'choice' | 'date';
|
||||
export type TableFilterType = 'boolean' | 'choice' | 'date' | 'text';
|
||||
|
||||
/**
|
||||
* Interface for the table filter type. Provides a number of options for selecting filter value:
|
||||
@@ -142,6 +143,60 @@ export function MaxDateFilter(): TableFilter {
|
||||
};
|
||||
}
|
||||
|
||||
export function CreatedBeforeFilter(): TableFilter {
|
||||
return {
|
||||
name: 'created_before',
|
||||
label: t`Created Before`,
|
||||
description: t`Show items created before this date`,
|
||||
type: 'date'
|
||||
};
|
||||
}
|
||||
|
||||
export function CreatedAfterFilter(): TableFilter {
|
||||
return {
|
||||
name: 'created_after',
|
||||
label: t`Created After`,
|
||||
description: t`Show items created after this date`,
|
||||
type: 'date'
|
||||
};
|
||||
}
|
||||
|
||||
export function TargetDateBeforeFilter(): TableFilter {
|
||||
return {
|
||||
name: 'target_date_before',
|
||||
label: t`Target Date Before`,
|
||||
description: t`Show items with a target date before this date`,
|
||||
type: 'date'
|
||||
};
|
||||
}
|
||||
|
||||
export function TargetDateAfterFilter(): TableFilter {
|
||||
return {
|
||||
name: 'target_date_after',
|
||||
label: t`Target Date After`,
|
||||
description: t`Show items with a target date after this date`,
|
||||
type: 'date'
|
||||
};
|
||||
}
|
||||
|
||||
export function CompletedBeforeFilter(): TableFilter {
|
||||
return {
|
||||
name: 'completed_before',
|
||||
label: t`Completed Before`,
|
||||
description: t`Show items completed before this date`,
|
||||
type: 'date'
|
||||
};
|
||||
}
|
||||
|
||||
export function CompletedAfterFilter(): TableFilter {
|
||||
return {
|
||||
name: 'completed_after',
|
||||
label: t`Completed After`,
|
||||
description: t`Show items completed after this date`,
|
||||
type: 'date'
|
||||
};
|
||||
}
|
||||
|
||||
export function HasProjectCodeFilter(): TableFilter {
|
||||
return {
|
||||
name: 'has_project_code',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
CloseButton,
|
||||
@@ -10,12 +11,14 @@ import {
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { DateInput, type DateValue } from '@mantine/dates';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import type { TableState } from '../hooks/UseTable';
|
||||
import {
|
||||
@@ -60,6 +63,77 @@ function FilterItem({
|
||||
);
|
||||
}
|
||||
|
||||
function FilterElement({
|
||||
filterType,
|
||||
valueOptions,
|
||||
onValueChange
|
||||
}: {
|
||||
filterType: TableFilterType;
|
||||
valueOptions: TableFilterChoice[];
|
||||
onValueChange: (value: string | null) => void;
|
||||
}) {
|
||||
const setDateValue = useCallback(
|
||||
(value: DateValue) => {
|
||||
if (value) {
|
||||
const date = value.toString();
|
||||
onValueChange(dayjs(date).format('YYYY-MM-DD'));
|
||||
} else {
|
||||
onValueChange('');
|
||||
}
|
||||
},
|
||||
[onValueChange]
|
||||
);
|
||||
|
||||
const [textValue, setTextValue] = useState<string>('');
|
||||
|
||||
switch (filterType) {
|
||||
case 'text':
|
||||
return (
|
||||
<TextInput
|
||||
label={t`Value`}
|
||||
value={textValue}
|
||||
placeholder={t`Enter filter value`}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
aria-label='apply-text-filter'
|
||||
variant='transparent'
|
||||
onClick={() => onValueChange(textValue)}
|
||||
>
|
||||
<IconCheck />
|
||||
</ActionIcon>
|
||||
}
|
||||
onChange={(e) => setTextValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onValueChange(textValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
return (
|
||||
<DateInput
|
||||
label={t`Value`}
|
||||
placeholder={t`Select date value`}
|
||||
onChange={setDateValue}
|
||||
/>
|
||||
);
|
||||
case 'choice':
|
||||
case 'boolean':
|
||||
default:
|
||||
return (
|
||||
<Select
|
||||
data={valueOptions}
|
||||
searchable={filterType != 'boolean'}
|
||||
label={t`Value`}
|
||||
placeholder={t`Select filter value`}
|
||||
onChange={(value: string | null) => onValueChange(value)}
|
||||
maxDropdownHeight={800}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function FilterAddGroup({
|
||||
tableState,
|
||||
availableFilters
|
||||
@@ -79,6 +153,7 @@ function FilterAddGroup({
|
||||
return (
|
||||
availableFilters
|
||||
?.filter((flt) => !activeFilterNames.includes(flt.name))
|
||||
?.sort((a, b) => a.label.localeCompare(b.label))
|
||||
?.map((flt) => ({
|
||||
value: flt.name,
|
||||
label: flt.label,
|
||||
@@ -133,22 +208,13 @@ function FilterAddGroup({
|
||||
};
|
||||
|
||||
tableState.setActiveFilters([...filters, newFilter]);
|
||||
|
||||
// Clear selected filter
|
||||
setSelectedFilter(null);
|
||||
},
|
||||
[selectedFilter]
|
||||
);
|
||||
|
||||
const setDateValue = useCallback(
|
||||
(value: DateValue) => {
|
||||
if (value) {
|
||||
const date = value.toString();
|
||||
setSelectedValue(dayjs(date).format('YYYY-MM-DD'));
|
||||
} else {
|
||||
setSelectedValue('');
|
||||
}
|
||||
},
|
||||
[setSelectedValue]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap='xs'>
|
||||
<Divider />
|
||||
@@ -160,23 +226,13 @@ function FilterAddGroup({
|
||||
onChange={(value: string | null) => setSelectedFilter(value)}
|
||||
maxDropdownHeight={800}
|
||||
/>
|
||||
{selectedFilter &&
|
||||
(filterType === 'date' ? (
|
||||
<DateInput
|
||||
label={t`Value`}
|
||||
placeholder={t`Select date value`}
|
||||
onChange={setDateValue}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
data={valueOptions}
|
||||
label={t`Value`}
|
||||
searchable={true}
|
||||
placeholder={t`Select filter value`}
|
||||
onChange={(value: string | null) => setSelectedValue(value)}
|
||||
maxDropdownHeight={800}
|
||||
/>
|
||||
))}
|
||||
{selectedFilter && (
|
||||
<FilterElement
|
||||
filterType={filterType}
|
||||
valueOptions={valueOptions}
|
||||
onValueChange={setSelectedValue}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,12 +25,18 @@ import {
|
||||
} from '../ColumnRenderers';
|
||||
import {
|
||||
AssignedToMeFilter,
|
||||
CompletedAfterFilter,
|
||||
CompletedBeforeFilter,
|
||||
CreatedAfterFilter,
|
||||
CreatedBeforeFilter,
|
||||
HasProjectCodeFilter,
|
||||
MaxDateFilter,
|
||||
MinDateFilter,
|
||||
OverdueFilter,
|
||||
StatusFilterOptions,
|
||||
type TableFilter
|
||||
type TableFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter
|
||||
} from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
@@ -130,6 +136,12 @@ export function BuildOrderTable({
|
||||
AssignedToMeFilter(),
|
||||
MinDateFilter(),
|
||||
MaxDateFilter(),
|
||||
CreatedBeforeFilter(),
|
||||
CreatedAfterFilter(),
|
||||
TargetDateBeforeFilter(),
|
||||
TargetDateAfterFilter(),
|
||||
CompletedBeforeFilter(),
|
||||
CompletedAfterFilter(),
|
||||
{
|
||||
name: 'project_code',
|
||||
label: t`Project Code`,
|
||||
|
||||
@@ -283,12 +283,10 @@ export default function ParametricPartTable({
|
||||
if (column?.accessor?.toString()?.startsWith('parameter_')) {
|
||||
const col = column as any;
|
||||
onParameterClick(col.extra.template, record);
|
||||
} else {
|
||||
} else if (record?.pk) {
|
||||
// Navigate through to the part detail page
|
||||
if (record?.pk) {
|
||||
const url = getDetailUrl(ModelType.part, record.pk);
|
||||
navigateToLink(url, navigate, event);
|
||||
}
|
||||
const url = getDetailUrl(ModelType.part, record.pk);
|
||||
navigateToLink(url, navigate, event);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -76,7 +76,8 @@ export default function PartParameterTemplateTable() {
|
||||
description: {},
|
||||
units: {},
|
||||
choices: {},
|
||||
checkbox: {}
|
||||
checkbox: {},
|
||||
selectionlist: {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { selectionListFields } from '../../forms/selectionListFields';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import type { TableColumn } from '../Column';
|
||||
import { BooleanColumn } from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||
|
||||
/**
|
||||
* Table for displaying list of selectionlist items
|
||||
*/
|
||||
export default function SelectionListTable() {
|
||||
const table = useTable('selectionlist');
|
||||
|
||||
const user = useUserState();
|
||||
|
||||
const columns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'name',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'description',
|
||||
sortable: true
|
||||
},
|
||||
BooleanColumn({
|
||||
accessor: 'active'
|
||||
}),
|
||||
BooleanColumn({
|
||||
accessor: 'locked'
|
||||
}),
|
||||
{
|
||||
accessor: 'source_plugin',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'source_string',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'entry_count'
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const newSelectionList = useCreateApiFormModal({
|
||||
url: ApiEndpoints.selectionlist_list,
|
||||
title: t`Add Selection List`,
|
||||
fields: selectionListFields(),
|
||||
table: table
|
||||
});
|
||||
|
||||
const [selectedSelectionList, setSelectedSelectionList] = useState<
|
||||
number | undefined
|
||||
>(undefined);
|
||||
|
||||
const editSelectionList = useEditApiFormModal({
|
||||
url: ApiEndpoints.selectionlist_list,
|
||||
pk: selectedSelectionList,
|
||||
title: t`Edit Selection List`,
|
||||
fields: selectionListFields(),
|
||||
table: table
|
||||
});
|
||||
|
||||
const deleteSelectionList = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.selectionlist_list,
|
||||
pk: selectedSelectionList,
|
||||
title: t`Delete Selection List`,
|
||||
table: table
|
||||
});
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
return [
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.admin),
|
||||
onClick: () => {
|
||||
setSelectedSelectionList(record.pk);
|
||||
editSelectionList.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !user.hasDeleteRole(UserRoles.admin),
|
||||
onClick: () => {
|
||||
setSelectedSelectionList(record.pk);
|
||||
deleteSelectionList.open();
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
key='add-selection-list'
|
||||
onClick={() => newSelectionList.open()}
|
||||
tooltip={t`Add Selection List`}
|
||||
/>
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{newSelectionList.modal}
|
||||
{editSelectionList.modal}
|
||||
{deleteSelectionList.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.selectionlist_list)}
|
||||
tableState={table}
|
||||
columns={columns}
|
||||
props={{
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
enableDownload: true
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
CompletionDateColumn,
|
||||
CreationDateColumn,
|
||||
DescriptionColumn,
|
||||
LineItemsProgressColumn,
|
||||
@@ -25,13 +26,19 @@ import {
|
||||
} from '../ColumnRenderers';
|
||||
import {
|
||||
AssignedToMeFilter,
|
||||
CompletedAfterFilter,
|
||||
CompletedBeforeFilter,
|
||||
CreatedAfterFilter,
|
||||
CreatedBeforeFilter,
|
||||
HasProjectCodeFilter,
|
||||
MaxDateFilter,
|
||||
MinDateFilter,
|
||||
OutstandingFilter,
|
||||
OverdueFilter,
|
||||
StatusFilterOptions,
|
||||
type TableFilter
|
||||
type TableFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter
|
||||
} from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
@@ -64,6 +71,12 @@ export function PurchaseOrderTable({
|
||||
AssignedToMeFilter(),
|
||||
MinDateFilter(),
|
||||
MaxDateFilter(),
|
||||
CreatedBeforeFilter(),
|
||||
CreatedAfterFilter(),
|
||||
TargetDateBeforeFilter(),
|
||||
TargetDateAfterFilter(),
|
||||
CompletedBeforeFilter(),
|
||||
CompletedAfterFilter(),
|
||||
{
|
||||
name: 'project_code',
|
||||
label: t`Project Code`,
|
||||
@@ -108,6 +121,9 @@ export function PurchaseOrderTable({
|
||||
ProjectCodeColumn({}),
|
||||
CreationDateColumn({}),
|
||||
TargetDateColumn({}),
|
||||
CompletionDateColumn({
|
||||
accessor: 'complete_date'
|
||||
}),
|
||||
{
|
||||
accessor: 'total_price',
|
||||
title: t`Total Price`,
|
||||
|
||||
@@ -14,8 +14,8 @@ import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
CompletionDateColumn,
|
||||
CreationDateColumn,
|
||||
DateColumn,
|
||||
DescriptionColumn,
|
||||
LineItemsProgressColumn,
|
||||
ProjectCodeColumn,
|
||||
@@ -26,13 +26,19 @@ import {
|
||||
} from '../ColumnRenderers';
|
||||
import {
|
||||
AssignedToMeFilter,
|
||||
CompletedAfterFilter,
|
||||
CompletedBeforeFilter,
|
||||
CreatedAfterFilter,
|
||||
CreatedBeforeFilter,
|
||||
HasProjectCodeFilter,
|
||||
MaxDateFilter,
|
||||
MinDateFilter,
|
||||
OutstandingFilter,
|
||||
OverdueFilter,
|
||||
StatusFilterOptions,
|
||||
type TableFilter
|
||||
type TableFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter
|
||||
} from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
@@ -62,6 +68,12 @@ export function ReturnOrderTable({
|
||||
AssignedToMeFilter(),
|
||||
MinDateFilter(),
|
||||
MaxDateFilter(),
|
||||
CreatedBeforeFilter(),
|
||||
CreatedAfterFilter(),
|
||||
TargetDateBeforeFilter(),
|
||||
TargetDateAfterFilter(),
|
||||
CompletedBeforeFilter(),
|
||||
CompletedAfterFilter(),
|
||||
{
|
||||
name: 'project_code',
|
||||
label: t`Project Code`,
|
||||
@@ -117,9 +129,8 @@ export function ReturnOrderTable({
|
||||
ProjectCodeColumn({}),
|
||||
CreationDateColumn({}),
|
||||
TargetDateColumn({}),
|
||||
DateColumn({
|
||||
accessor: 'complete_date',
|
||||
title: t`Completion Date`
|
||||
CompletionDateColumn({
|
||||
accessor: 'complete_date'
|
||||
}),
|
||||
ResponsibleColumn({}),
|
||||
{
|
||||
@@ -141,6 +152,9 @@ export function ReturnOrderTable({
|
||||
url: ApiEndpoints.return_order_list,
|
||||
title: t`Add Return Order`,
|
||||
fields: returnOrderFields,
|
||||
initialData: {
|
||||
customer: customerId
|
||||
},
|
||||
follow: true,
|
||||
modelType: ModelType.returnorder
|
||||
});
|
||||
|
||||
@@ -27,13 +27,19 @@ import {
|
||||
} from '../ColumnRenderers';
|
||||
import {
|
||||
AssignedToMeFilter,
|
||||
CompletedAfterFilter,
|
||||
CompletedBeforeFilter,
|
||||
CreatedAfterFilter,
|
||||
CreatedBeforeFilter,
|
||||
HasProjectCodeFilter,
|
||||
MaxDateFilter,
|
||||
MinDateFilter,
|
||||
OutstandingFilter,
|
||||
OverdueFilter,
|
||||
StatusFilterOptions,
|
||||
type TableFilter
|
||||
type TableFilter,
|
||||
TargetDateAfterFilter,
|
||||
TargetDateBeforeFilter
|
||||
} from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
@@ -63,6 +69,12 @@ export function SalesOrderTable({
|
||||
AssignedToMeFilter(),
|
||||
MinDateFilter(),
|
||||
MaxDateFilter(),
|
||||
CreatedBeforeFilter(),
|
||||
CreatedAfterFilter(),
|
||||
TargetDateBeforeFilter(),
|
||||
TargetDateAfterFilter(),
|
||||
CompletedBeforeFilter(),
|
||||
CompletedAfterFilter(),
|
||||
{
|
||||
name: 'project_code',
|
||||
label: t`Project Code`,
|
||||
|
||||
@@ -236,7 +236,7 @@ export function TemplateTable({
|
||||
sortable: false,
|
||||
switchable: true
|
||||
},
|
||||
...Object.entries(additionalFormFields || {})?.map(([key, field]) => ({
|
||||
...Object.entries(additionalFormFields || {}).map(([key, field]) => ({
|
||||
accessor: key,
|
||||
...field,
|
||||
title: field.label,
|
||||
|
||||
@@ -286,7 +286,11 @@ function stockItemTableColumns(): TableColumn[] {
|
||||
/**
|
||||
* Construct a list of available filters for the stock item table
|
||||
*/
|
||||
function stockItemTableFilters(): TableFilter[] {
|
||||
function stockItemTableFilters({
|
||||
enableExpiry
|
||||
}: {
|
||||
enableExpiry: boolean;
|
||||
}): TableFilter[] {
|
||||
return [
|
||||
{
|
||||
name: 'active',
|
||||
@@ -354,15 +358,35 @@ function stockItemTableFilters(): TableFilter[] {
|
||||
label: t`Is Serialized`,
|
||||
description: t`Show items which have a serial number`
|
||||
},
|
||||
// TODO: serial
|
||||
// TODO: serial_gte
|
||||
// TODO: serial_lte
|
||||
{
|
||||
name: 'batch',
|
||||
label: t`Batch Code`,
|
||||
description: t`Filter items by batch code`,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'serial',
|
||||
label: t`Serial Number`,
|
||||
description: t`Filter items by serial number`,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'serial_lte',
|
||||
label: t`Serial Number LTE`,
|
||||
description: t`Show items with serial numbers less than or equal to a given value`,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'serial_gte',
|
||||
label: t`Serial Number GTE`,
|
||||
description: t`Show items with serial numbers greater than or equal to a given value`,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'has_batch',
|
||||
label: t`Has Batch Code`,
|
||||
description: t`Show items which have a batch code`
|
||||
},
|
||||
// TODO: batch
|
||||
{
|
||||
name: 'tracked',
|
||||
label: t`Tracked`,
|
||||
@@ -373,10 +397,56 @@ function stockItemTableFilters(): TableFilter[] {
|
||||
label: t`Has Purchase Price`,
|
||||
description: t`Show items which have a purchase price`
|
||||
},
|
||||
// TODO: Expired
|
||||
// TODO: stale
|
||||
// TODO: expiry_date_lte
|
||||
// TODO: expiry_date_gte
|
||||
{
|
||||
name: 'expired',
|
||||
label: t`Expired`,
|
||||
description: t`Show items which have expired`,
|
||||
active: enableExpiry
|
||||
},
|
||||
{
|
||||
name: 'stale',
|
||||
label: t`Stale`,
|
||||
description: t`Show items which are stale`,
|
||||
active: enableExpiry
|
||||
},
|
||||
{
|
||||
name: 'expiry_before',
|
||||
label: t`Expired Before`,
|
||||
description: t`Show items which expired before this date`,
|
||||
type: 'date',
|
||||
active: enableExpiry
|
||||
},
|
||||
{
|
||||
name: 'expiry_after',
|
||||
label: t`Expired After`,
|
||||
description: t`Show items which expired after this date`,
|
||||
type: 'date',
|
||||
active: enableExpiry
|
||||
},
|
||||
{
|
||||
name: 'updated_before',
|
||||
label: t`Updated Before`,
|
||||
description: t`Show items updated before this date`,
|
||||
type: 'date'
|
||||
},
|
||||
{
|
||||
name: 'updated_after',
|
||||
label: t`Updated After`,
|
||||
description: t`Show items updated after this date`,
|
||||
type: 'date'
|
||||
},
|
||||
{
|
||||
name: 'stocktake_before',
|
||||
label: t`Stocktake Before`,
|
||||
description: t`Show items counted before this date`,
|
||||
type: 'date'
|
||||
},
|
||||
{
|
||||
name: 'stocktake_after',
|
||||
label: t`Stocktake After`,
|
||||
description: t`Show items counted after this date`,
|
||||
type: 'date'
|
||||
},
|
||||
{
|
||||
name: 'external',
|
||||
label: t`External Location`,
|
||||
@@ -397,12 +467,25 @@ export function StockItemTable({
|
||||
allowAdd?: boolean;
|
||||
tableName: string;
|
||||
}>) {
|
||||
const tableColumns = useMemo(() => stockItemTableColumns(), []);
|
||||
const tableFilters = useMemo(() => stockItemTableFilters(), []);
|
||||
|
||||
const table = useTable(tableName);
|
||||
const user = useUserState();
|
||||
|
||||
const settings = useGlobalSettingsState();
|
||||
|
||||
const stockExpiryEnabled = useMemo(
|
||||
() => settings.isSet('STOCK_ENABLE_EXPIRY'),
|
||||
[settings]
|
||||
);
|
||||
|
||||
const tableColumns = useMemo(() => stockItemTableColumns(), []);
|
||||
const tableFilters = useMemo(
|
||||
() =>
|
||||
stockItemTableFilters({
|
||||
enableExpiry: stockExpiryEnabled
|
||||
}),
|
||||
[stockExpiryEnabled]
|
||||
);
|
||||
|
||||
const tableActionParams: StockOperationProps = useMemo(() => {
|
||||
return {
|
||||
items: table.selectedRecords,
|
||||
@@ -521,7 +604,7 @@ export function StockItemTable({
|
||||
{
|
||||
name: t`Assign to customer`,
|
||||
icon: <InvenTreeIcon icon='customer' />,
|
||||
tooltip: t`Order new stock`,
|
||||
tooltip: t`Assign items to a customer`,
|
||||
disabled: !can_add_stock,
|
||||
onClick: () => {
|
||||
assignStock.open();
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Open the filter drawer for the currently visible table
|
||||
* @param page - The page object
|
||||
*/
|
||||
export const openFilterDrawer = async (page) => {
|
||||
await page.getByLabel('table-select-filters').click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Close the filter drawer for the currently visible table
|
||||
* @param page - The page object
|
||||
*/
|
||||
export const closeFilterDrawer = async (page) => {
|
||||
await page.getByLabel('filter-drawer-close').click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Click the specified button (if it is visible)
|
||||
* @param page - The page object
|
||||
* @param name - The name of the button to click
|
||||
*/
|
||||
export const clickButtonIfVisible = async (page, name, timeout = 500) => {
|
||||
await page.waitForTimeout(timeout);
|
||||
|
||||
if (await page.getByRole('button', { name }).isVisible()) {
|
||||
await page.getByRole('button', { name }).click();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all filters from the currently visible table
|
||||
* @param page - The page object
|
||||
*/
|
||||
export const clearTableFilters = async (page) => {
|
||||
await openFilterDrawer(page);
|
||||
await clickButtonIfVisible(page, 'Clear Filters');
|
||||
await page.getByLabel('filter-drawer-close').click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the parent 'row' element for a given 'cell' element
|
||||
* @param cell - The cell element
|
||||
*/
|
||||
export const getRowFromCell = async (cell) => {
|
||||
return cell.locator('xpath=ancestor::tr').first();
|
||||
};
|
||||
@@ -8,7 +8,7 @@ test('Modals as admin', async ({ page }) => {
|
||||
await page.getByLabel('open-spotlight').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Server Information About this Inventree instance'
|
||||
name: 'Server Information About this InvenTree instance'
|
||||
})
|
||||
.click();
|
||||
await page.getByRole('cell', { name: 'Instance Name' }).waitFor();
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { test } from '../baseFixtures.ts';
|
||||
import { baseUrl } from '../defaults.ts';
|
||||
import {
|
||||
clickButtonIfVisible,
|
||||
getRowFromCell,
|
||||
openFilterDrawer
|
||||
} from '../helpers.ts';
|
||||
import { doQuickLogin } from '../login.ts';
|
||||
|
||||
test('Pages - Build Order', async ({ page }) => {
|
||||
test('Build Order - Basic Tests', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/`);
|
||||
@@ -82,7 +87,7 @@ test('Pages - Build Order', async ({ page }) => {
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
test('Pages - Build Order - Build Outputs', async ({ page }) => {
|
||||
test('Build Order - Build Outputs', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/`);
|
||||
@@ -140,7 +145,7 @@ test('Pages - Build Order - Build Outputs', async ({ page }) => {
|
||||
|
||||
// Cancel one of the newly created outputs
|
||||
const cell = await page.getByRole('cell', { name: `# ${sn}` });
|
||||
const row = await cell.locator('xpath=ancestor::tr').first();
|
||||
const row = await getRowFromCell(cell);
|
||||
await row.getByLabel(/row-action-menu-/i).click();
|
||||
await page.getByRole('menuitem', { name: 'Cancel' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
@@ -148,7 +153,7 @@ test('Pages - Build Order - Build Outputs', async ({ page }) => {
|
||||
|
||||
// Complete the other output
|
||||
const cell2 = await page.getByRole('cell', { name: `# ${sn + 1}` });
|
||||
const row2 = await cell2.locator('xpath=ancestor::tr').first();
|
||||
const row2 = await getRowFromCell(cell2);
|
||||
await row2.getByLabel(/row-action-menu-/i).click();
|
||||
await page.getByRole('menuitem', { name: 'Complete' }).click();
|
||||
await page.getByLabel('related-field-location').click();
|
||||
@@ -158,7 +163,7 @@ test('Pages - Build Order - Build Outputs', async ({ page }) => {
|
||||
await page.getByText('Build outputs have been completed').waitFor();
|
||||
});
|
||||
|
||||
test('Pages - Build Order - Allocation', async ({ page }) => {
|
||||
test('Build Order - Allocation', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/manufacturing/build-order/1/line-items`);
|
||||
@@ -170,7 +175,7 @@ test('Pages - Build Order - Allocation', async ({ page }) => {
|
||||
|
||||
// The capacitor stock should be fully allocated
|
||||
const cell = await page.getByRole('cell', { name: /C_1uF_0805/ });
|
||||
const row = await cell.locator('xpath=ancestor::tr').first();
|
||||
const row = await getRowFromCell(cell);
|
||||
|
||||
await row.getByText(/150 \/ 150/).waitFor();
|
||||
|
||||
@@ -237,7 +242,7 @@ test('Pages - Build Order - Allocation', async ({ page }) => {
|
||||
const item = data[idx];
|
||||
|
||||
const cell = await page.getByRole('cell', { name: item.name });
|
||||
const row = await cell.locator('xpath=ancestor::tr').first();
|
||||
const row = await getRowFromCell(cell);
|
||||
const progress = `${item.allocated} / ${item.required}`;
|
||||
|
||||
await row.getByRole('cell', { name: item.ipn }).first().waitFor();
|
||||
@@ -257,3 +262,14 @@ test('Pages - Build Order - Allocation', async ({ page }) => {
|
||||
.getByRole('menuitem', { name: 'Deallocate Stock', exact: true })
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
test('Build Order - Filters', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/manufacturing/index/buildorders`);
|
||||
|
||||
await openFilterDrawer(page);
|
||||
await clickButtonIfVisible(page, 'Clear Filters');
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test } from '../baseFixtures.js';
|
||||
import { doQuickLogin } from '../login.js';
|
||||
import { setPluginState } from '../settings.js';
|
||||
|
||||
test('Pages - Dashboard - Basic', async ({ page }) => {
|
||||
test('Dashboard - Basic', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.getByText('Use the menu to add widgets').waitFor();
|
||||
@@ -35,7 +35,7 @@ test('Pages - Dashboard - Basic', async ({ page }) => {
|
||||
await page.getByLabel('dashboard-accept-layout').click();
|
||||
});
|
||||
|
||||
test('Pages - Dashboard - Plugins', async ({ page, request }) => {
|
||||
test('Dashboard - Plugins', async ({ page, request }) => {
|
||||
// Ensure that the "SampleUI" plugin is enabled
|
||||
await setPluginState({
|
||||
request,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test } from '../baseFixtures';
|
||||
import { baseUrl } from '../defaults';
|
||||
import { getRowFromCell } from '../helpers';
|
||||
import { doQuickLogin } from '../login';
|
||||
|
||||
/**
|
||||
@@ -129,9 +130,7 @@ test('Parts - Allocations', async ({ page }) => {
|
||||
|
||||
// Check "progress" bar of BO0001
|
||||
const build_order_cell = await page.getByRole('cell', { name: 'BO0001' });
|
||||
const build_order_row = await build_order_cell
|
||||
.locator('xpath=ancestor::tr')
|
||||
.first();
|
||||
const build_order_row = await getRowFromCell(build_order_cell);
|
||||
await build_order_row.getByText('11 / 75').waitFor();
|
||||
|
||||
// Expand allocations against BO0001
|
||||
@@ -147,9 +146,7 @@ test('Parts - Allocations', async ({ page }) => {
|
||||
|
||||
// Check "progress" bar of SO0025
|
||||
const sales_order_cell = await page.getByRole('cell', { name: 'SO0025' });
|
||||
const sales_order_row = await sales_order_cell
|
||||
.locator('xpath=ancestor::tr')
|
||||
.first();
|
||||
const sales_order_row = await getRowFromCell(sales_order_cell);
|
||||
await sales_order_row.getByText('3 / 10').waitFor();
|
||||
|
||||
// Expand allocations against SO0025
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test } from '../baseFixtures.ts';
|
||||
import { clickButtonIfVisible, openFilterDrawer } from '../helpers.ts';
|
||||
import { doQuickLogin } from '../login.ts';
|
||||
|
||||
test('Purchase Orders - General', async ({ page }) => {
|
||||
@@ -51,6 +52,30 @@ test('Purchase Orders - General', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: 'Details' }).waitFor();
|
||||
});
|
||||
|
||||
test('Purchase Orders - Filters', async ({ page }) => {
|
||||
await doQuickLogin(page, 'reader', 'readonly');
|
||||
|
||||
await page.getByRole('tab', { name: 'Purchasing' }).click();
|
||||
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||
|
||||
// Open filters drawer
|
||||
await openFilterDrawer(page);
|
||||
await clickButtonIfVisible(page, 'Clear Filters');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||
|
||||
// Check for expected filter options
|
||||
await page.getByPlaceholder('Select filter').fill('before');
|
||||
await page.getByRole('option', { name: 'Created Before' }).waitFor();
|
||||
await page.getByRole('option', { name: 'Completed Before' }).waitFor();
|
||||
await page.getByRole('option', { name: 'Target Date Before' }).waitFor();
|
||||
|
||||
await page.getByPlaceholder('Select filter').fill('after');
|
||||
await page.getByRole('option', { name: 'Created After' }).waitFor();
|
||||
await page.getByRole('option', { name: 'Completed After' }).waitFor();
|
||||
await page.getByRole('option', { name: 'Target Date After' }).waitFor();
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for receiving items against a purchase order
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { test } from '../baseFixtures.js';
|
||||
import { baseUrl } from '../defaults.js';
|
||||
import { clickButtonIfVisible, openFilterDrawer } from '../helpers.js';
|
||||
import { doQuickLogin } from '../login.js';
|
||||
|
||||
test('Stock', async ({ page }) => {
|
||||
test('Stock - Basic Tests', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/stock/location/index/`);
|
||||
@@ -49,6 +50,45 @@ test('Stock - Location Tree', async ({ page }) => {
|
||||
await page.getByRole('cell', { name: 'Factory' }).first().waitFor();
|
||||
});
|
||||
|
||||
test('Stock - Filters', async ({ page }) => {
|
||||
await doQuickLogin(page, 'steven', 'wizardstaff');
|
||||
|
||||
await page.goto(`${baseUrl}/stock/location/index/`);
|
||||
await page.getByRole('tab', { name: 'Stock Items' }).click();
|
||||
|
||||
await openFilterDrawer(page);
|
||||
await clickButtonIfVisible(page, 'Clear Filters');
|
||||
|
||||
// Filter by updated date
|
||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||
await page.getByPlaceholder('Select filter').fill('updated');
|
||||
await page.getByText('Updated After').click();
|
||||
await page.getByPlaceholder('Select date value').fill('2010-01-01');
|
||||
await page.getByText('Show items updated after this date').waitFor();
|
||||
|
||||
// Filter by batch code
|
||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||
await page.getByPlaceholder('Select filter').fill('batch');
|
||||
await page
|
||||
.getByRole('option', { name: 'Batch Code', exact: true })
|
||||
.locator('span')
|
||||
.click();
|
||||
await page.getByPlaceholder('Enter filter value').fill('TABLE-B02');
|
||||
await page.getByLabel('apply-text-filter').click();
|
||||
|
||||
// Close dialog
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Ensure correct result is displayed
|
||||
await page
|
||||
.getByRole('cell', { name: 'A round table - with blue paint' })
|
||||
.waitFor();
|
||||
|
||||
// Clear filters (ready for next set of tests)
|
||||
await openFilterDrawer(page);
|
||||
await clickButtonIfVisible(page, 'Clear Filters');
|
||||
});
|
||||
|
||||
test('Stock - Serial Numbers', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
@@ -149,7 +189,7 @@ test('Stock - Stock Actions', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Find an item which has been sent to a customer
|
||||
await page.goto(`${baseUrl}/stock/item/1012/details`);
|
||||
await page.goto(`${baseUrl}/stock/item/1014/details`);
|
||||
await page.getByText('Batch Code: 2022-11-12').waitFor();
|
||||
await page.getByText('Unavailable').waitFor();
|
||||
await page.getByLabel('action-menu-stock-operations').click();
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { expect, test } from './baseFixtures.js';
|
||||
import { baseUrl, user } from './defaults.js';
|
||||
import { doLogin, doQuickLogin } from './login.js';
|
||||
|
||||
test('Basic Login Test', async ({ page }) => {
|
||||
await doLogin(page);
|
||||
|
||||
// Check that the username is provided
|
||||
await page.getByText(user.username);
|
||||
|
||||
await expect(page).toHaveTitle(/^InvenTree/);
|
||||
|
||||
// Go to the dashboard
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
await page.getByText('InvenTree Demo Server -').waitFor();
|
||||
|
||||
// Check that the username is provided
|
||||
await page.getByText(user.username);
|
||||
|
||||
await expect(page).toHaveTitle(/^InvenTree/);
|
||||
|
||||
// Go to the dashboard
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
// Logout (via menu)
|
||||
await page.getByRole('button', { name: 'Ally Access' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Logout' }).click();
|
||||
|
||||
await page.waitForURL('**/platform/login');
|
||||
await page.getByLabel('username');
|
||||
});
|
||||
|
||||
test('Quick Login Test', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Check that the username is provided
|
||||
await page.getByText(user.username);
|
||||
|
||||
await expect(page).toHaveTitle(/^InvenTree/);
|
||||
|
||||
// Go to the dashboard
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
await page.getByText('InvenTree Demo Server - ').waitFor();
|
||||
|
||||
// Logout (via URL)
|
||||
await page.goto(`${baseUrl}/logout/`);
|
||||
await page.waitForURL('**/platform/login');
|
||||
await page.getByLabel('username');
|
||||
});
|
||||
@@ -49,7 +49,7 @@ test('Quick Command - No Keys', async ({ page }) => {
|
||||
await page.getByLabel('open-spotlight').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Server Information About this Inventree instance'
|
||||
name: 'Server Information About this InvenTree instance'
|
||||
})
|
||||
.click();
|
||||
await page.getByRole('cell', { name: 'Instance Name' }).waitFor();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from './baseFixtures.js';
|
||||
import { baseUrl, logoutUrl, user } from './defaults.js';
|
||||
import { doLogin, doQuickLogin } from './login.js';
|
||||
|
||||
test('Login - Basic Test', async ({ page }) => {
|
||||
await doLogin(page);
|
||||
|
||||
// Check that the username is provided
|
||||
await page.getByText(user.username);
|
||||
|
||||
await expect(page).toHaveTitle(/^InvenTree/);
|
||||
|
||||
// Go to the dashboard
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
await page.getByText('InvenTree Demo Server -').waitFor();
|
||||
|
||||
// Check that the username is provided
|
||||
await page.getByText(user.username);
|
||||
|
||||
await expect(page).toHaveTitle(/^InvenTree/);
|
||||
|
||||
// Go to the dashboard
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
// Logout (via menu)
|
||||
await page.getByRole('button', { name: 'Ally Access' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Logout' }).click();
|
||||
|
||||
await page.waitForURL('**/platform/login');
|
||||
await page.getByLabel('username');
|
||||
});
|
||||
|
||||
test('Login - Quick Test', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
// Check that the username is provided
|
||||
await page.getByText(user.username);
|
||||
|
||||
await expect(page).toHaveTitle(/^InvenTree/);
|
||||
|
||||
// Go to the dashboard
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForURL('**/platform');
|
||||
|
||||
await page.getByText('InvenTree Demo Server - ').waitFor();
|
||||
|
||||
// Logout (via URL)
|
||||
await page.goto(`${baseUrl}/logout/`);
|
||||
await page.waitForURL('**/platform/login');
|
||||
await page.getByLabel('username');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test various types of login failure
|
||||
*/
|
||||
test('Login - Failures', async ({ page }) => {
|
||||
const loginWithError = async () => {
|
||||
await page.getByRole('button', { name: 'Log In' }).click();
|
||||
await page.getByText('Login failed').waitFor();
|
||||
await page.getByText('Check your input and try again').waitFor();
|
||||
await page.locator('#login').getByRole('button').click();
|
||||
};
|
||||
|
||||
// Navigate to the 'login' page
|
||||
await page.goto(logoutUrl);
|
||||
await expect(page).toHaveTitle(/^InvenTree.*$/);
|
||||
await page.waitForURL('**/platform/login');
|
||||
|
||||
// Attempt login with invalid credentials
|
||||
await page.getByLabel('login-username').fill('invalid user');
|
||||
await page.getByLabel('login-password').fill('invalid password');
|
||||
|
||||
await loginWithError();
|
||||
|
||||
// Attempt login with valid (but disabled) user
|
||||
await page.getByLabel('login-username').fill('ian');
|
||||
await page.getByLabel('login-password').fill('inactive');
|
||||
|
||||
await loginWithError();
|
||||
|
||||
// Attempt login with no username
|
||||
await page.getByLabel('login-username').fill('');
|
||||
await page.getByLabel('login-password').fill('hunter2');
|
||||
|
||||
await loginWithError();
|
||||
|
||||
// Attempt login with no password
|
||||
await page.getByLabel('login-username').fill('ian');
|
||||
await page.getByLabel('login-password').fill('');
|
||||
|
||||
await loginWithError();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
@@ -1,23 +1,23 @@
|
||||
import { test } from './baseFixtures.js';
|
||||
import { baseUrl } from './defaults.js';
|
||||
import {
|
||||
clearTableFilters,
|
||||
closeFilterDrawer,
|
||||
openFilterDrawer
|
||||
} from './helpers.js';
|
||||
import { doQuickLogin } from './login.js';
|
||||
|
||||
// Helper function to set the value of a specific table filter
|
||||
const setFilter = async (page, name: string, value: string) => {
|
||||
await page.getByLabel('table-select-filters').click();
|
||||
await openFilterDrawer(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||
await page.getByPlaceholder('Select filter').click();
|
||||
await page.getByRole('option', { name: name, exact: true }).click();
|
||||
await page.getByPlaceholder('Select filter value').click();
|
||||
await page.getByRole('option', { name: value, exact: true }).click();
|
||||
await page.getByLabel('filter-drawer-close').click();
|
||||
};
|
||||
|
||||
// Helper function to clear table filters
|
||||
const clearFilters = async (page) => {
|
||||
await page.getByLabel('table-select-filters').click();
|
||||
await page.getByRole('button', { name: 'Clear Filters' }).click();
|
||||
await page.getByLabel('filter-drawer-close').click();
|
||||
await closeFilterDrawer(page);
|
||||
};
|
||||
|
||||
test('Tables - Filters', async ({ page }) => {
|
||||
@@ -30,14 +30,14 @@ test('Tables - Filters', async ({ page }) => {
|
||||
await setFilter(page, 'Responsible', 'allaccess');
|
||||
await setFilter(page, 'Project Code', 'PRJ-NIM');
|
||||
|
||||
await clearFilters(page);
|
||||
await clearTableFilters(page);
|
||||
|
||||
// Head to the "part list" page
|
||||
await page.goto(`${baseUrl}/part/category/index/parts/`);
|
||||
|
||||
await setFilter(page, 'Assembly', 'Yes');
|
||||
|
||||
await clearFilters(page);
|
||||
await clearTableFilters(page);
|
||||
|
||||
// Head to the "purchase order list" page
|
||||
await page.goto(`${baseUrl}/purchasing/index/purchaseorders/`);
|
||||
@@ -47,7 +47,7 @@ test('Tables - Filters', async ({ page }) => {
|
||||
await setFilter(page, 'Assigned to me', 'No');
|
||||
await setFilter(page, 'Project Code', 'PRO-ZEN');
|
||||
|
||||
await clearFilters(page);
|
||||
await clearTableFilters(page);
|
||||
});
|
||||
|
||||
test('Tables - Columns', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { test } from '../baseFixtures';
|
||||
import { baseUrl } from '../defaults';
|
||||
import { doQuickLogin } from '../login';
|
||||
|
||||
test('PUI - Admin - Parameter', async ({ page }) => {
|
||||
await doQuickLogin(page, 'admin', 'inventree');
|
||||
await page.getByRole('button', { name: 'admin' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
|
||||
await page.getByRole('tab', { name: 'Part Parameters' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Selection Lists' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// clean old data if exists
|
||||
await page
|
||||
.getByRole('cell', { name: 'some list' })
|
||||
.waitFor({ timeout: 200 })
|
||||
.then(async (cell) => {
|
||||
await page
|
||||
.getByRole('cell', { name: 'some list' })
|
||||
.locator('..')
|
||||
.getByLabel('row-action-menu-')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// clean old data if exists
|
||||
await page.getByRole('button', { name: 'Part Parameter Template' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page
|
||||
.getByRole('cell', { name: 'my custom parameter' })
|
||||
.waitFor({ timeout: 200 })
|
||||
.then(async (cell) => {
|
||||
await page
|
||||
.getByRole('cell', { name: 'my custom parameter' })
|
||||
.locator('..')
|
||||
.getByLabel('row-action-menu-')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Add selection list
|
||||
await page.getByRole('button', { name: 'Selection Lists' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByLabel('action-button-add-selection-').waitFor();
|
||||
await page.getByLabel('action-button-add-selection-').click();
|
||||
await page.getByLabel('text-field-name').fill('some list');
|
||||
await page.getByLabel('text-field-description').fill('Listdescription');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('cell', { name: 'some list' }).waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Add parameter
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByRole('button', { name: 'Part Parameter Template' }).click();
|
||||
await page.getByLabel('action-button-add-parameter').waitFor();
|
||||
await page.getByLabel('action-button-add-parameter').click();
|
||||
await page.getByLabel('text-field-name').fill('my custom parameter');
|
||||
await page.getByLabel('text-field-description').fill('description');
|
||||
await page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Search\.\.\.$/ })
|
||||
.nth(2)
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'some list' })
|
||||
.locator('div')
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('cell', { name: 'my custom parameter' }).click();
|
||||
|
||||
// Fill parameter
|
||||
await page.goto(`${baseUrl}/part/104/parameters/`);
|
||||
await page.getByLabel('Parameters').getByText('Parameters').waitFor();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByLabel('action-button-add-parameter').waitFor();
|
||||
await page.getByLabel('action-button-add-parameter').click();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByText('New Part Parameter').waitFor();
|
||||
await page
|
||||
.getByText('Template *Parameter')
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Search\.\.\.$/ })
|
||||
.nth(2)
|
||||
.click();
|
||||
await page
|
||||
.getByText('Template *Parameter')
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Search\.\.\.$/ })
|
||||
.locator('input')
|
||||
.fill('my custom parameter');
|
||||
await page.getByRole('option', { name: 'my custom parameter' }).click();
|
||||
await page.getByLabel('choice-field-data').fill('2');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
});
|
||||
Reference in New Issue
Block a user