mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-08 08:01:38 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue6281
This commit is contained in:
@@ -1,27 +0,0 @@
|
||||
env:
|
||||
commonjs: false
|
||||
browser: true
|
||||
es2021: true
|
||||
jquery: true
|
||||
extends:
|
||||
- eslint:recommended
|
||||
parserOptions:
|
||||
ecmaVersion: 12
|
||||
rules:
|
||||
no-var: off
|
||||
guard-for-in: off
|
||||
no-trailing-spaces: off
|
||||
camelcase: off
|
||||
padded-blocks: off
|
||||
prefer-const: off
|
||||
max-len: off
|
||||
require-jsdoc: off
|
||||
valid-jsdoc: off
|
||||
no-multiple-empty-lines: off
|
||||
comma-dangle: off
|
||||
no-unused-vars: off
|
||||
no-useless-escape: off
|
||||
prefer-spread: off
|
||||
indent:
|
||||
- error
|
||||
- 4
|
||||
@@ -1,11 +1,28 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 186
|
||||
INVENTREE_API_VERSION = 190
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v190 - 2024-04-19 : https://github.com/inventree/InvenTree/pull/7024
|
||||
- Adds "active" field to the Company API endpoints
|
||||
- Allow company list to be filtered by "active" status
|
||||
|
||||
v189 - 2024-04-19 : https://github.com/inventree/InvenTree/pull/7066
|
||||
- Adds "currency" field to CompanyBriefSerializer class
|
||||
|
||||
v188 - 2024-04-16 : https://github.com/inventree/InvenTree/pull/6970
|
||||
- Adds session authentication support for the API
|
||||
- Improvements for login / logout endpoints for better support of React web interface
|
||||
|
||||
v187 - 2024-04-10 : https://github.com/inventree/InvenTree/pull/6985
|
||||
- Allow Part list endpoint to be sorted by pricing_min and pricing_max values
|
||||
- Allow BomItem list endpoint to be sorted by pricing_min and pricing_max values
|
||||
- Allow InternalPrice and SalePrice endpoints to be sorted by quantity
|
||||
- Adds total pricing values to BomItem serializer
|
||||
|
||||
v186 - 2024-03-26 : https://github.com/inventree/InvenTree/pull/6855
|
||||
- Adds license information to the API
|
||||
|
||||
|
||||
@@ -489,10 +489,18 @@ if DEBUG:
|
||||
'rest_framework.renderers.BrowsableAPIRenderer'
|
||||
)
|
||||
|
||||
# dj-rest-auth
|
||||
# JWT switch
|
||||
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
|
||||
REST_USE_JWT = USE_JWT
|
||||
|
||||
# dj-rest-auth
|
||||
REST_AUTH = {
|
||||
'SESSION_LOGIN': True,
|
||||
'TOKEN_MODEL': 'users.models.ApiToken',
|
||||
'TOKEN_CREATOR': 'users.models.default_create_token',
|
||||
'USE_JWT': USE_JWT,
|
||||
}
|
||||
|
||||
OLD_PASSWORD_FIELD_ENABLED = True
|
||||
REST_AUTH_REGISTER_SERIALIZERS = {
|
||||
'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'
|
||||
@@ -507,6 +515,7 @@ if USE_JWT:
|
||||
)
|
||||
INSTALLED_APPS.append('rest_framework_simplejwt')
|
||||
|
||||
|
||||
# WSGI default setting
|
||||
WSGI_APPLICATION = 'InvenTree.wsgi.application'
|
||||
|
||||
@@ -1075,20 +1084,30 @@ CSRF_TRUSTED_ORIGINS = get_setting(
|
||||
if SITE_URL and SITE_URL not in CSRF_TRUSTED_ORIGINS:
|
||||
CSRF_TRUSTED_ORIGINS.append(SITE_URL)
|
||||
|
||||
if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0:
|
||||
if DEBUG:
|
||||
logger.warning(
|
||||
'No CSRF_TRUSTED_ORIGINS specified. Defaulting to http://* for debug mode. This is not recommended for production use'
|
||||
)
|
||||
CSRF_TRUSTED_ORIGINS = ['http://*']
|
||||
if DEBUG:
|
||||
for origin in [
|
||||
'http://localhost',
|
||||
'http://*.localhost' 'http://*localhost:8000',
|
||||
'http://*localhost:5173',
|
||||
]:
|
||||
if origin not in CSRF_TRUSTED_ORIGINS:
|
||||
CSRF_TRUSTED_ORIGINS.append(origin)
|
||||
|
||||
elif isInMainThread():
|
||||
if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0:
|
||||
if isInMainThread():
|
||||
# Server thread cannot run without CSRF_TRUSTED_ORIGINS
|
||||
logger.error(
|
||||
'No CSRF_TRUSTED_ORIGINS specified. Please provide a list of trusted origins, or specify INVENTREE_SITE_URL'
|
||||
)
|
||||
sys.exit(-1)
|
||||
|
||||
# Additional CSRF settings
|
||||
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
|
||||
CSRF_COOKIE_NAME = 'csrftoken'
|
||||
CSRF_COOKIE_SAMESITE = 'Lax'
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
USE_X_FORWARDED_HOST = get_boolean_setting(
|
||||
'INVENTREE_USE_X_FORWARDED_HOST',
|
||||
config_key='use_x_forwarded_host',
|
||||
|
||||
@@ -160,6 +160,7 @@ apipatterns = [
|
||||
SocialAccountDisconnectView.as_view(),
|
||||
name='social_account_disconnect',
|
||||
),
|
||||
path('login/', users.api.Login.as_view(), name='api-login'),
|
||||
path('logout/', users.api.Logout.as_view(), name='api-logout'),
|
||||
path(
|
||||
'login-redirect/',
|
||||
|
||||
@@ -269,7 +269,7 @@ class FileManagementFormView(MultiStepFormView):
|
||||
for idx, item in row_data.items():
|
||||
column_data = {
|
||||
'name': self.column_names[idx],
|
||||
'guess': self.column_selections[idx],
|
||||
'guess': self.column_selections.get(idx, ''),
|
||||
}
|
||||
|
||||
cell_data = {'cell': item, 'idx': idx, 'column': column_data}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from django.db.models import Q
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_filters import rest_framework as rest_filters
|
||||
|
||||
@@ -58,11 +59,17 @@ class CompanyList(ListCreateAPI):
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
filterset_fields = ['is_customer', 'is_manufacturer', 'is_supplier', 'name']
|
||||
filterset_fields = [
|
||||
'is_customer',
|
||||
'is_manufacturer',
|
||||
'is_supplier',
|
||||
'name',
|
||||
'active',
|
||||
]
|
||||
|
||||
search_fields = ['name', 'description', 'website']
|
||||
|
||||
ordering_fields = ['name', 'parts_supplied', 'parts_manufactured']
|
||||
ordering_fields = ['active', 'name', 'parts_supplied', 'parts_manufactured']
|
||||
|
||||
ordering = 'name'
|
||||
|
||||
@@ -153,7 +160,13 @@ class ManufacturerPartFilter(rest_filters.FilterSet):
|
||||
fields = ['manufacturer', 'MPN', 'part', 'tags__name', 'tags__slug']
|
||||
|
||||
# Filter by 'active' status of linked part
|
||||
active = rest_filters.BooleanFilter(field_name='part__active')
|
||||
part_active = rest_filters.BooleanFilter(
|
||||
field_name='part__active', label=_('Part is Active')
|
||||
)
|
||||
|
||||
manufacturer_active = rest_filters.BooleanFilter(
|
||||
field_name='manufacturer__active', label=_('Manufacturer is Active')
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerPartList(ListCreateDestroyAPIView):
|
||||
@@ -301,8 +314,16 @@ class SupplierPartFilter(rest_filters.FilterSet):
|
||||
'tags__slug',
|
||||
]
|
||||
|
||||
active = rest_filters.BooleanFilter(label=_('Supplier Part is Active'))
|
||||
|
||||
# Filter by 'active' status of linked part
|
||||
active = rest_filters.BooleanFilter(field_name='part__active')
|
||||
part_active = rest_filters.BooleanFilter(
|
||||
field_name='part__active', label=_('Internal Part is Active')
|
||||
)
|
||||
|
||||
supplier_active = rest_filters.BooleanFilter(
|
||||
field_name='supplier__active', label=_('Supplier is Active')
|
||||
)
|
||||
|
||||
# Filter by the 'MPN' of linked manufacturer part
|
||||
MPN = rest_filters.CharFilter(
|
||||
@@ -378,6 +399,7 @@ class SupplierPartList(ListCreateDestroyAPIView):
|
||||
'part',
|
||||
'supplier',
|
||||
'manufacturer',
|
||||
'active',
|
||||
'MPN',
|
||||
'packaging',
|
||||
'pack_quantity',
|
||||
@@ -468,9 +490,13 @@ class SupplierPriceBreakList(ListCreateAPI):
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
filter_backends = ORDER_FILTER
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_fields = ['quantity']
|
||||
ordering_fields = ['quantity', 'supplier', 'SKU', 'price']
|
||||
|
||||
search_fields = ['part__SKU', 'part__supplier__name']
|
||||
|
||||
ordering_field_aliases = {'supplier': 'part__supplier__name', 'SKU': 'part__SKU'}
|
||||
|
||||
ordering = 'quantity'
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.11 on 2024-04-15 14:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0068_auto_20231120_1108'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='company',
|
||||
name='active',
|
||||
field=models.BooleanField(default=True, help_text='Is this company active?', verbose_name='Active'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='supplierpart',
|
||||
name='active',
|
||||
field=models.BooleanField(default=True, help_text='Is this supplier part active?', verbose_name='Active'),
|
||||
),
|
||||
]
|
||||
@@ -81,6 +81,7 @@ class Company(
|
||||
link: Secondary URL e.g. for link to internal Wiki page
|
||||
image: Company image / logo
|
||||
notes: Extra notes about the company
|
||||
active: boolean value, is this company active
|
||||
is_customer: boolean value, is this company a customer
|
||||
is_supplier: boolean value, is this company a supplier
|
||||
is_manufacturer: boolean value, is this company a manufacturer
|
||||
@@ -155,6 +156,10 @@ class Company(
|
||||
verbose_name=_('Image'),
|
||||
)
|
||||
|
||||
active = models.BooleanField(
|
||||
default=True, verbose_name=_('Active'), help_text=_('Is this company active?')
|
||||
)
|
||||
|
||||
is_customer = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('is customer'),
|
||||
@@ -654,6 +659,7 @@ class SupplierPart(
|
||||
part: Link to the master Part (Obsolete)
|
||||
source_item: The sourcing item linked to this SupplierPart instance
|
||||
supplier: Company that supplies this SupplierPart object
|
||||
active: Boolean value, is this supplier part active
|
||||
SKU: Stock keeping unit (supplier part number)
|
||||
link: Link to external website for this supplier part
|
||||
description: Descriptive notes field
|
||||
@@ -802,6 +808,12 @@ class SupplierPart(
|
||||
help_text=_('Supplier stock keeping unit'),
|
||||
)
|
||||
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_('Active'),
|
||||
help_text=_('Is this supplier part active?'),
|
||||
)
|
||||
|
||||
manufacturer_part = models.ForeignKey(
|
||||
ManufacturerPart,
|
||||
on_delete=models.CASCADE,
|
||||
|
||||
@@ -42,9 +42,16 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = Company
|
||||
fields = ['pk', 'url', 'name', 'description', 'image', 'thumbnail']
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
fields = [
|
||||
'pk',
|
||||
'active',
|
||||
'name',
|
||||
'description',
|
||||
'image',
|
||||
'thumbnail',
|
||||
'currency',
|
||||
]
|
||||
read_only_fields = ['currency']
|
||||
|
||||
image = InvenTreeImageSerializerField(read_only=True)
|
||||
|
||||
@@ -116,6 +123,7 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
'contact',
|
||||
'link',
|
||||
'image',
|
||||
'active',
|
||||
'is_customer',
|
||||
'is_manufacturer',
|
||||
'is_supplier',
|
||||
@@ -306,6 +314,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
'description',
|
||||
'in_stock',
|
||||
'link',
|
||||
'active',
|
||||
'manufacturer',
|
||||
'manufacturer_detail',
|
||||
'manufacturer_part',
|
||||
@@ -369,8 +378,9 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||
self.fields.pop('pretty_name')
|
||||
|
||||
# Annotated field showing total in-stock quantity
|
||||
in_stock = serializers.FloatField(read_only=True)
|
||||
available = serializers.FloatField(required=False)
|
||||
in_stock = serializers.FloatField(read_only=True, label=_('In Stock'))
|
||||
|
||||
available = serializers.FloatField(required=False, label=_('Available'))
|
||||
|
||||
pack_quantity_native = serializers.FloatField(read_only=True)
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Company" %}: {{ company.name }}
|
||||
{% if not company.active %}
|
||||
 
|
||||
<div class='badge rounded-pill bg-danger'>
|
||||
{% trans 'Inactive' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock heading %}
|
||||
|
||||
{% block actions %}
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import Part
|
||||
|
||||
from .models import Address, Company, Contact, ManufacturerPart, SupplierPart
|
||||
|
||||
@@ -131,6 +132,32 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertTrue('currency' in response.data)
|
||||
|
||||
def test_company_active(self):
|
||||
"""Test that the 'active' value and filter works."""
|
||||
Company.objects.filter(active=False).update(active=True)
|
||||
n = Company.objects.count()
|
||||
|
||||
url = reverse('api-company-list')
|
||||
|
||||
self.assertEqual(
|
||||
len(self.get(url, data={'active': True}, expected_code=200).data), n
|
||||
)
|
||||
self.assertEqual(
|
||||
len(self.get(url, data={'active': False}, expected_code=200).data), 0
|
||||
)
|
||||
|
||||
# Set one company to inactive
|
||||
c = Company.objects.first()
|
||||
c.active = False
|
||||
c.save()
|
||||
|
||||
self.assertEqual(
|
||||
len(self.get(url, data={'active': True}, expected_code=200).data), n - 1
|
||||
)
|
||||
self.assertEqual(
|
||||
len(self.get(url, data={'active': False}, expected_code=200).data), 1
|
||||
)
|
||||
|
||||
|
||||
class ContactTest(InvenTreeAPITestCase):
|
||||
"""Tests for the Contact models."""
|
||||
@@ -528,6 +555,50 @@ class SupplierPartTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(sp.available, 999)
|
||||
self.assertIsNotNone(sp.availability_updated)
|
||||
|
||||
def test_active(self):
|
||||
"""Test that 'active' status filtering works correctly."""
|
||||
url = reverse('api-supplier-part-list')
|
||||
|
||||
# Create a new company, which is inactive
|
||||
company = Company.objects.create(
|
||||
name='Inactive Company', is_supplier=True, active=False
|
||||
)
|
||||
|
||||
part = Part.objects.filter(purchaseable=True).first()
|
||||
|
||||
# Create some new supplier part objects, *some* of which are inactive
|
||||
for idx in range(10):
|
||||
SupplierPart.objects.create(
|
||||
part=part,
|
||||
supplier=company,
|
||||
SKU=f'CMP-{company.pk}-SKU-{idx}',
|
||||
active=(idx % 2 == 0),
|
||||
)
|
||||
|
||||
n = SupplierPart.objects.count()
|
||||
|
||||
# List *all* supplier parts
|
||||
self.assertEqual(len(self.get(url, data={}, expected_code=200).data), n)
|
||||
|
||||
# List only active supplier parts (all except 5 from the new supplier)
|
||||
self.assertEqual(
|
||||
len(self.get(url, data={'active': True}, expected_code=200).data), n - 5
|
||||
)
|
||||
|
||||
# List only from 'active' suppliers (all except this new supplier)
|
||||
self.assertEqual(
|
||||
len(self.get(url, data={'supplier_active': True}, expected_code=200).data),
|
||||
n - 10,
|
||||
)
|
||||
|
||||
# List active parts from inactive suppliers (only 5 from the new supplier)
|
||||
response = self.get(
|
||||
url, data={'supplier_active': False, 'active': True}, expected_code=200
|
||||
)
|
||||
self.assertEqual(len(response.data), 5)
|
||||
for result in response.data:
|
||||
self.assertEqual(result['supplier'], company.pk)
|
||||
|
||||
|
||||
class CompanyMetadataAPITest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the various metadata endpoints of API."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -154,11 +154,11 @@ class LineItemFilter(rest_filters.FilterSet):
|
||||
|
||||
# Filter by order status
|
||||
order_status = rest_filters.NumberFilter(
|
||||
label='order_status', field_name='order__status'
|
||||
label=_('Order Status'), field_name='order__status'
|
||||
)
|
||||
|
||||
has_pricing = rest_filters.BooleanFilter(
|
||||
label='Has Pricing', method='filter_has_pricing'
|
||||
label=_('Has Pricing'), method='filter_has_pricing'
|
||||
)
|
||||
|
||||
def filter_has_pricing(self, queryset, name, value):
|
||||
@@ -425,9 +425,38 @@ class PurchaseOrderLineItemFilter(LineItemFilter):
|
||||
|
||||
price_field = 'purchase_price'
|
||||
model = models.PurchaseOrderLineItem
|
||||
fields = ['order', 'part']
|
||||
fields = []
|
||||
|
||||
pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
|
||||
order = rest_filters.ModelChoiceFilter(
|
||||
queryset=models.PurchaseOrder.objects.all(),
|
||||
field_name='order',
|
||||
label=_('Order'),
|
||||
)
|
||||
|
||||
order_complete = rest_filters.BooleanFilter(
|
||||
label=_('Order Complete'), method='filter_order_complete'
|
||||
)
|
||||
|
||||
def filter_order_complete(self, queryset, name, value):
|
||||
"""Filter by whether the order is 'complete' or not."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(order__status=PurchaseOrderStatus.COMPLETE.value)
|
||||
|
||||
return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value)
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=SupplierPart.objects.all(), field_name='part', label=_('Supplier Part')
|
||||
)
|
||||
|
||||
base_part = rest_filters.ModelChoiceFilter(
|
||||
queryset=Part.objects.filter(purchaseable=True),
|
||||
field_name='part__part',
|
||||
label=_('Internal Part'),
|
||||
)
|
||||
|
||||
pending = rest_filters.BooleanFilter(
|
||||
method='filter_pending', label=_('Order Pending')
|
||||
)
|
||||
|
||||
def filter_pending(self, queryset, name, value):
|
||||
"""Filter by "pending" status (order status = pending)."""
|
||||
@@ -435,7 +464,9 @@ class PurchaseOrderLineItemFilter(LineItemFilter):
|
||||
return queryset.filter(order__status__in=PurchaseOrderStatusGroups.OPEN)
|
||||
return queryset.exclude(order__status__in=PurchaseOrderStatusGroups.OPEN)
|
||||
|
||||
received = rest_filters.BooleanFilter(label='received', method='filter_received')
|
||||
received = rest_filters.BooleanFilter(
|
||||
label=_('Items Received'), method='filter_received'
|
||||
)
|
||||
|
||||
def filter_received(self, queryset, name, value):
|
||||
"""Filter by lines which are "received" (or "not" received).
|
||||
@@ -542,25 +573,6 @@ class PurchaseOrderLineItemList(
|
||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Additional filtering options."""
|
||||
params = self.request.query_params
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
base_part = params.get('base_part', None)
|
||||
|
||||
if base_part:
|
||||
try:
|
||||
base_part = Part.objects.get(pk=base_part)
|
||||
|
||||
queryset = queryset.filter(part__part=base_part)
|
||||
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
return queryset
|
||||
|
||||
def download_queryset(self, queryset, export_format):
|
||||
"""Download the requested queryset as a file."""
|
||||
dataset = PurchaseOrderLineItemResource().export(queryset=queryset)
|
||||
@@ -577,6 +589,8 @@ class PurchaseOrderLineItemList(
|
||||
'MPN': 'part__manufacturer_part__MPN',
|
||||
'SKU': 'part__SKU',
|
||||
'part_name': 'part__part__name',
|
||||
'order': 'order__reference',
|
||||
'complete_date': 'order__complete_date',
|
||||
}
|
||||
|
||||
ordering_fields = [
|
||||
@@ -589,6 +603,8 @@ class PurchaseOrderLineItemList(
|
||||
'SKU',
|
||||
'total_price',
|
||||
'target_date',
|
||||
'order',
|
||||
'complete_date',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
@@ -791,7 +807,15 @@ class SalesOrderLineItemFilter(LineItemFilter):
|
||||
|
||||
price_field = 'sale_price'
|
||||
model = models.SalesOrderLineItem
|
||||
fields = ['order', 'part']
|
||||
fields = []
|
||||
|
||||
order = rest_filters.ModelChoiceFilter(
|
||||
queryset=models.SalesOrder.objects.all(), field_name='order', label=_('Order')
|
||||
)
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=Part.objects.all(), field_name='part', label=_('Part')
|
||||
)
|
||||
|
||||
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
|
||||
|
||||
@@ -806,6 +830,17 @@ class SalesOrderLineItemFilter(LineItemFilter):
|
||||
return queryset.filter(q)
|
||||
return queryset.exclude(q)
|
||||
|
||||
order_complete = rest_filters.BooleanFilter(
|
||||
label=_('Order Complete'), method='filter_order_complete'
|
||||
)
|
||||
|
||||
def filter_order_complete(self, queryset, name, value):
|
||||
"""Filter by whether the order is 'complete' or not."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(order__status__in=SalesOrderStatusGroups.COMPLETE)
|
||||
|
||||
return queryset.exclude(order__status__in=SalesOrderStatusGroups.COMPLETE)
|
||||
|
||||
|
||||
class SalesOrderLineItemMixin:
|
||||
"""Mixin class for SalesOrderLineItem endpoints."""
|
||||
@@ -862,9 +897,24 @@ class SalesOrderLineItemList(SalesOrderLineItemMixin, APIDownloadMixin, ListCrea
|
||||
|
||||
return DownloadFile(filedata, filename)
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_fields = ['part__name', 'quantity', 'reference', 'target_date']
|
||||
ordering_fields = [
|
||||
'customer',
|
||||
'order',
|
||||
'part',
|
||||
'part__name',
|
||||
'quantity',
|
||||
'reference',
|
||||
'sale_price',
|
||||
'target_date',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'customer': 'order__customer__name',
|
||||
'part': 'part__name',
|
||||
'order': 'order__reference',
|
||||
}
|
||||
|
||||
search_fields = ['part__name', 'quantity', 'reference']
|
||||
|
||||
|
||||
@@ -1459,9 +1459,11 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
def update_pricing(self):
|
||||
"""Update pricing information based on the supplier part data."""
|
||||
if self.part:
|
||||
price = self.part.get_price(self.quantity)
|
||||
price = self.part.get_price(
|
||||
self.quantity, currency=self.purchase_price_currency
|
||||
)
|
||||
|
||||
if price is None:
|
||||
if price is None or self.quantity == 0:
|
||||
return
|
||||
|
||||
self.purchase_price = Decimal(price) / Decimal(self.quantity)
|
||||
|
||||
@@ -38,7 +38,6 @@ from InvenTree.helpers import (
|
||||
is_ajax,
|
||||
isNull,
|
||||
str2bool,
|
||||
str2int,
|
||||
)
|
||||
from InvenTree.mixins import (
|
||||
CreateAPI,
|
||||
@@ -386,9 +385,10 @@ class PartSalePriceList(ListCreateAPI):
|
||||
queryset = PartSellPriceBreak.objects.all()
|
||||
serializer_class = part_serializers.PartSalePriceSerializer
|
||||
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
filterset_fields = ['part']
|
||||
ordering_fields = ['quantity', 'price']
|
||||
ordering = 'quantity'
|
||||
|
||||
|
||||
class PartInternalPriceDetail(RetrieveUpdateDestroyAPI):
|
||||
@@ -405,9 +405,10 @@ class PartInternalPriceList(ListCreateAPI):
|
||||
serializer_class = part_serializers.PartInternalPriceSerializer
|
||||
permission_required = 'roles.sales_order.show'
|
||||
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
filterset_fields = ['part']
|
||||
ordering_fields = ['quantity', 'price']
|
||||
ordering = 'quantity'
|
||||
|
||||
|
||||
class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
@@ -1407,8 +1408,17 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
|
||||
'category',
|
||||
'last_stocktake',
|
||||
'units',
|
||||
'pricing_min',
|
||||
'pricing_max',
|
||||
'pricing_updated',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'pricing_min': 'pricing_data__overall_min',
|
||||
'pricing_max': 'pricing_data__overall_max',
|
||||
'pricing_updated': 'pricing_data__updated',
|
||||
}
|
||||
|
||||
# Default ordering
|
||||
ordering = 'name'
|
||||
|
||||
@@ -1939,9 +1949,19 @@ class BomList(BomMixin, ListCreateDestroyAPIView):
|
||||
'inherited',
|
||||
'optional',
|
||||
'consumable',
|
||||
'pricing_min',
|
||||
'pricing_max',
|
||||
'pricing_min_total',
|
||||
'pricing_max_total',
|
||||
'pricing_updated',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {'sub_part': 'sub_part__name'}
|
||||
ordering_field_aliases = {
|
||||
'sub_part': 'sub_part__name',
|
||||
'pricing_min': 'sub_part__pricing_data__overall_min',
|
||||
'pricing_max': 'sub_part__pricing_data__overall_max',
|
||||
'pricing_updated': 'sub_part__pricing_data__updated',
|
||||
}
|
||||
|
||||
|
||||
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI):
|
||||
|
||||
@@ -616,6 +616,7 @@ class PartSerializer(
|
||||
'virtual',
|
||||
'pricing_min',
|
||||
'pricing_max',
|
||||
'pricing_updated',
|
||||
'responsible',
|
||||
# Annotated fields
|
||||
'allocated_to_build_orders',
|
||||
@@ -678,6 +679,7 @@ class PartSerializer(
|
||||
if not pricing:
|
||||
self.fields.pop('pricing_min')
|
||||
self.fields.pop('pricing_max')
|
||||
self.fields.pop('pricing_updated')
|
||||
|
||||
def get_api_url(self):
|
||||
"""Return the API url associated with this serializer."""
|
||||
@@ -843,6 +845,9 @@ class PartSerializer(
|
||||
pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
source='pricing_data.overall_max', allow_null=True, read_only=True
|
||||
)
|
||||
pricing_updated = serializers.DateTimeField(
|
||||
source='pricing_data.updated', allow_null=True, read_only=True
|
||||
)
|
||||
|
||||
parameters = PartParameterSerializer(many=True, read_only=True)
|
||||
|
||||
@@ -1413,6 +1418,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'part_detail',
|
||||
'pricing_min',
|
||||
'pricing_max',
|
||||
'pricing_min_total',
|
||||
'pricing_max_total',
|
||||
'pricing_updated',
|
||||
'quantity',
|
||||
'reference',
|
||||
'sub_part',
|
||||
@@ -1451,6 +1459,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
if not pricing:
|
||||
self.fields.pop('pricing_min')
|
||||
self.fields.pop('pricing_max')
|
||||
self.fields.pop('pricing_min_total')
|
||||
self.fields.pop('pricing_max_total')
|
||||
self.fields.pop('pricing_updated')
|
||||
|
||||
quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
|
||||
|
||||
@@ -1481,10 +1492,22 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
|
||||
# Cached pricing fields
|
||||
pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
source='sub_part.pricing.overall_min', allow_null=True, read_only=True
|
||||
source='sub_part.pricing_data.overall_min', allow_null=True, read_only=True
|
||||
)
|
||||
|
||||
pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
source='sub_part.pricing.overall_max', allow_null=True, read_only=True
|
||||
source='sub_part.pricing_data.overall_max', allow_null=True, read_only=True
|
||||
)
|
||||
|
||||
pricing_min_total = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
allow_null=True, read_only=True
|
||||
)
|
||||
pricing_max_total = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
allow_null=True, read_only=True
|
||||
)
|
||||
|
||||
pricing_updated = serializers.DateTimeField(
|
||||
source='sub_part.pricing_data.updated', allow_null=True, read_only=True
|
||||
)
|
||||
|
||||
# Annotated fields for available stock
|
||||
@@ -1504,6 +1527,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
|
||||
queryset = queryset.prefetch_related('sub_part')
|
||||
queryset = queryset.prefetch_related('sub_part__category')
|
||||
queryset = queryset.prefetch_related('sub_part__pricing_data')
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'sub_part__stock_items',
|
||||
@@ -1531,6 +1555,18 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
available_stock = total_stock - build_order_allocations - sales_order_allocations
|
||||
"""
|
||||
|
||||
# Annotate with the 'total pricing' information based on unit pricing and quantity
|
||||
queryset = queryset.annotate(
|
||||
pricing_min_total=ExpressionWrapper(
|
||||
F('quantity') * F('sub_part__pricing_data__overall_min'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
pricing_max_total=ExpressionWrapper(
|
||||
F('quantity') * F('sub_part__pricing_data__overall_max'),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
)
|
||||
|
||||
ref = 'sub_part__'
|
||||
|
||||
# Annotate with the total "on order" amount for the sub-part
|
||||
|
||||
@@ -21,12 +21,15 @@ function activatePanel(label, panel_name, options={}) {
|
||||
$('.panel-visible').hide();
|
||||
$('.panel-visible').removeClass('panel-visible');
|
||||
|
||||
// Remove illegal chars
|
||||
panel_name = panel_name.replace('/', '');
|
||||
|
||||
// Find the target panel
|
||||
var panel = `#panel-${panel_name}`;
|
||||
var select = `#select-${panel_name}`;
|
||||
|
||||
// Check that the selected panel (and select) exist
|
||||
if ($(panel).length && $(select).length) {
|
||||
if ($(panel).exists() && $(panel).length && $(select).length) {
|
||||
// Yep, both are displayed
|
||||
} else {
|
||||
// Either the select or the panel are not displayed!
|
||||
|
||||
@@ -426,7 +426,8 @@ function companyFormFields() {
|
||||
},
|
||||
is_supplier: {},
|
||||
is_manufacturer: {},
|
||||
is_customer: {}
|
||||
is_customer: {},
|
||||
active: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -517,6 +518,15 @@ function loadCompanyTable(table, url, options={}) {
|
||||
field: 'description',
|
||||
title: '{% trans "Description" %}',
|
||||
},
|
||||
{
|
||||
field: 'active',
|
||||
title: '{% trans "Active" %}',
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
formatter: function(value) {
|
||||
return yesNoLabel(value);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'website',
|
||||
title: '{% trans "Website" %}',
|
||||
|
||||
@@ -218,7 +218,7 @@ function renderStockItem(data, parameters={}) {
|
||||
}
|
||||
|
||||
if (data.quantity == 0) {
|
||||
stock_detail = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock"% }</span>`;
|
||||
stock_detail = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
|
||||
} else {
|
||||
if (data.serial && data.quantity == 1) {
|
||||
stock_detail = `{% trans "Serial Number" %}: ${data.serial}`;
|
||||
|
||||
@@ -1755,7 +1755,7 @@ function loadPurchaseOrderTable(table, options) {
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
return formatCurrency(value, {
|
||||
currency: row.order_currency,
|
||||
currency: row.order_currency ?? row.supplier_detail?.currency,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -300,7 +300,7 @@ function loadReturnOrderTable(table, options={}) {
|
||||
return '{% trans "Invalid Customer" %}';
|
||||
}
|
||||
|
||||
return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/sales-orders/`);
|
||||
return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/?display=sales-orders/`);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -384,7 +384,7 @@ function loadReturnOrderTable(table, options={}) {
|
||||
visible: false,
|
||||
formatter: function(value, row) {
|
||||
return formatCurrency(value, {
|
||||
currency: row.order_currency
|
||||
currency: row.order_currency ?? row.customer_detail?.currency,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -488,7 +488,7 @@ function receiveReturnOrderItems(order_id, line_items, options={}) {
|
||||
|
||||
if (line_items.length == 0) {
|
||||
showAlertDialog(
|
||||
'{% trans "Select Line Items"% }',
|
||||
'{% trans "Select Line Items" %}',
|
||||
'{% trans "At least one line item must be selected" %}'
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -788,7 +788,7 @@ function loadSalesOrderTable(table, options) {
|
||||
return '{% trans "Invalid Customer" %}';
|
||||
}
|
||||
|
||||
return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/sales-orders/`);
|
||||
return imageHoverIcon(row.customer_detail.image) + renderLink(row.customer_detail.name, `/company/${row.customer}/?display=sales-orders/`);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -857,7 +857,7 @@ function loadSalesOrderTable(table, options) {
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
return formatCurrency(value, {
|
||||
currency: row.order_currency,
|
||||
currency: row.order_currency ?? row.customer_detail?.currency,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -791,6 +791,10 @@ function getContactFilters() {
|
||||
// Return a dictionary of filters for the "company" table
|
||||
function getCompanyFilters() {
|
||||
return {
|
||||
active: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Active" %}'
|
||||
},
|
||||
is_manufacturer: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Manufacturer" %}',
|
||||
|
||||
@@ -8,9 +8,11 @@ from django.contrib.auth.models import Group, User
|
||||
from django.urls import include, path, re_path
|
||||
from django.views.generic.base import RedirectView
|
||||
|
||||
from dj_rest_auth.views import LogoutView
|
||||
from dj_rest_auth.views import LoginView, LogoutView
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
|
||||
from rest_framework import exceptions, permissions
|
||||
from rest_framework.authentication import BasicAuthentication
|
||||
from rest_framework.decorators import authentication_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
@@ -205,6 +207,18 @@ class GroupList(ListCreateAPI):
|
||||
ordering_fields = ['name']
|
||||
|
||||
|
||||
@authentication_classes([BasicAuthentication])
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
responses={200: OpenApiResponse(description='User successfully logged in')}
|
||||
)
|
||||
)
|
||||
class Login(LoginView):
|
||||
"""API view for logging in via API."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
responses={200: OpenApiResponse(description='User successfully logged out')}
|
||||
|
||||
@@ -56,6 +56,17 @@ def default_token_expiry():
|
||||
return InvenTree.helpers.current_date() + datetime.timedelta(days=365)
|
||||
|
||||
|
||||
def default_create_token(token_model, user, serializer):
|
||||
"""Generate a default value for the token."""
|
||||
token = token_model.objects.filter(user=user, name='', revoked=False)
|
||||
|
||||
if token.exists():
|
||||
return token.first()
|
||||
|
||||
else:
|
||||
return token_model.objects.create(user=user, name='')
|
||||
|
||||
|
||||
class ApiToken(AuthToken, InvenTree.models.MetadataMixin):
|
||||
"""Extends the default token model provided by djangorestframework.authtoken.
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// eslint.config.js
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 12,
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2021,
|
||||
...globals.jquery,
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"no-var": "off",
|
||||
"guard-for-in": "off",
|
||||
"no-trailing-spaces": "off",
|
||||
"camelcase": "off",
|
||||
"padded-blocks": "off",
|
||||
"prefer-const": "off",
|
||||
"max-len": "off",
|
||||
"require-jsdoc": "off",
|
||||
"valid-jsdoc": "off",
|
||||
"no-multiple-empty-lines": "off",
|
||||
"comma-dangle": "off",
|
||||
"no-unused-vars": "off",
|
||||
"no-useless-escape": "off",
|
||||
"prefer-spread": "off",
|
||||
"indent": ["error", 4]
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: "off"
|
||||
}
|
||||
}
|
||||
];
|
||||
Generated
+65
-162
@@ -5,7 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-google": "^0.14.0"
|
||||
}
|
||||
},
|
||||
@@ -31,6 +31,17 @@
|
||||
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
|
||||
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/regexpp": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
|
||||
@@ -40,14 +51,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
|
||||
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.0.2.tgz",
|
||||
"integrity": "sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg==",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.4",
|
||||
"debug": "^4.3.2",
|
||||
"espree": "^9.6.0",
|
||||
"globals": "^13.19.0",
|
||||
"espree": "^10.0.1",
|
||||
"globals": "^14.0.0",
|
||||
"ignore": "^5.2.0",
|
||||
"import-fresh": "^3.2.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
@@ -55,26 +66,26 @@
|
||||
"strip-json-comments": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
|
||||
"integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.0.0.tgz",
|
||||
"integrity": "sha512-RThY/MnKrhubF6+s1JflwUjPEsnCEmYCWwqa/aRISKWNXGZ9epUwft4bUMM35SdKF9xvBrLydAM1RDHd1Z//ZQ==",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.11.14",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
|
||||
"integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.12.3.tgz",
|
||||
"integrity": "sha512-jsNnTBlMWuTpDkeE3on7+dWJi0D6fdDfeANj/w7MpS8ztROCoLvIO2nG0CcFj+E4k8j4QrSTh4Oryi3i2G669g==",
|
||||
"dependencies": {
|
||||
"@humanwhocodes/object-schema": "^2.0.2",
|
||||
"@humanwhocodes/object-schema": "^2.0.3",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.0.5"
|
||||
},
|
||||
@@ -131,11 +142,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
|
||||
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.11.3",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
|
||||
@@ -289,17 +295,6 @@
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
|
||||
"dependencies": {
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -312,40 +307,36 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
|
||||
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.0.0.tgz",
|
||||
"integrity": "sha512-IMryZ5SudxzQvuod6rUdIUz29qFItWx281VhtFVc2Psy/ZhlCeD/5DT6lBIJ4H3G+iamGJoTln1v+QSuPw0p7Q==",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
"@eslint/eslintrc": "^2.1.4",
|
||||
"@eslint/js": "8.57.0",
|
||||
"@humanwhocodes/config-array": "^0.11.14",
|
||||
"@eslint/eslintrc": "^3.0.2",
|
||||
"@eslint/js": "9.0.0",
|
||||
"@humanwhocodes/config-array": "^0.12.3",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@nodelib/fs.walk": "^1.2.8",
|
||||
"@ungap/structured-clone": "^1.2.0",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.2",
|
||||
"debug": "^4.3.2",
|
||||
"doctrine": "^3.0.0",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^7.2.2",
|
||||
"eslint-visitor-keys": "^3.4.3",
|
||||
"espree": "^9.6.1",
|
||||
"eslint-scope": "^8.0.1",
|
||||
"eslint-visitor-keys": "^4.0.0",
|
||||
"espree": "^10.0.1",
|
||||
"esquery": "^1.4.2",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-entry-cache": "^6.0.1",
|
||||
"file-entry-cache": "^8.0.0",
|
||||
"find-up": "^5.0.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"globals": "^13.19.0",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^5.2.0",
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"is-path-inside": "^3.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"levn": "^0.4.1",
|
||||
"lodash.merge": "^4.6.2",
|
||||
@@ -359,7 +350,7 @@
|
||||
"eslint": "bin/eslint.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
@@ -377,42 +368,42 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.1.tgz",
|
||||
"integrity": "sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og==",
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-visitor-keys": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
|
||||
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz",
|
||||
"integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
||||
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz",
|
||||
"integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==",
|
||||
"dependencies": {
|
||||
"acorn": "^8.9.0",
|
||||
"acorn": "^8.11.3",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
"eslint-visitor-keys": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
@@ -480,14 +471,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
||||
"dependencies": {
|
||||
"flat-cache": "^3.0.4"
|
||||
"flat-cache": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
@@ -506,16 +497,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
||||
"dependencies": {
|
||||
"flatted": "^3.2.9",
|
||||
"keyv": "^4.5.3",
|
||||
"rimraf": "^3.0.2"
|
||||
"keyv": "^4.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
@@ -523,30 +513,6 @@
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
|
||||
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw=="
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -559,14 +525,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "13.24.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
||||
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.20.2"
|
||||
},
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
@@ -616,20 +579,6 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -748,14 +697,6 @@
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.3",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
|
||||
@@ -819,14 +760,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -887,20 +820,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -991,17 +910,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
@@ -1024,11 +932,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-google": "^0.14.0"
|
||||
}
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ pyyaml==6.0.1
|
||||
# via pre-commit
|
||||
requests==2.31.0
|
||||
# via coveralls
|
||||
setuptools==69.2.0
|
||||
setuptools==69.5.1
|
||||
# via
|
||||
# nodeenv
|
||||
# pip-tools
|
||||
|
||||
@@ -130,7 +130,7 @@ googleapis-common-protos==1.63.0
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
grpcio==1.62.1
|
||||
# via opentelemetry-exporter-otlp-proto-grpc
|
||||
gunicorn==21.2.0
|
||||
gunicorn==22.0.0
|
||||
html5lib==1.1
|
||||
# via weasyprint
|
||||
icalendar==5.0.12
|
||||
@@ -288,9 +288,9 @@ rpds-py==0.18.0
|
||||
# via
|
||||
# jsonschema
|
||||
# referencing
|
||||
sentry-sdk==1.44.1
|
||||
sentry-sdk==1.45.0
|
||||
# via django-q-sentry
|
||||
setuptools==69.2.0
|
||||
setuptools==69.5.1
|
||||
# via
|
||||
# django-money
|
||||
# opentelemetry-instrumentation
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@lingui/core": "^4.7.1",
|
||||
"@lingui/react": "^4.7.2",
|
||||
"@lingui/react": "^4.10.0",
|
||||
"@mantine/carousel": "<7",
|
||||
"@mantine/core": "<7",
|
||||
"@mantine/dates": "<7",
|
||||
@@ -27,6 +27,7 @@
|
||||
"@mantine/hooks": "<7",
|
||||
"@mantine/modals": "<7",
|
||||
"@mantine/notifications": "<7",
|
||||
"@mantine/spotlight": "<7",
|
||||
"@naisutech/react-tree": "^3.1.0",
|
||||
"@sentry/react": "^7.109.0",
|
||||
"@tabler/icons-react": "^3.1.0",
|
||||
@@ -37,17 +38,18 @@
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"easymde": "^2.18.0",
|
||||
"embla-carousel-react": "^8.0.0",
|
||||
"embla-carousel-react": "^8.0.2",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"mantine-datatable": "<7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-grid-layout": "^1.4.4",
|
||||
"react-hook-form": "^7.51.2",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-is": "^18.2.0",
|
||||
"react-router-dom": "^6.22.1",
|
||||
"react-select": "^5.8.0",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"recharts": "^2.12.4",
|
||||
"styled-components": "^5.3.6",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
@@ -56,8 +58,8 @@
|
||||
"@babel/preset-react": "^7.23.3",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@lingui/cli": "^4.7.2",
|
||||
"@lingui/macro": "^4.7.2",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@lingui/macro": "^4.10.0",
|
||||
"@playwright/test": "^1.43.1",
|
||||
"@types/node": "^20.12.3",
|
||||
"@types/react": "^18.2.74",
|
||||
"@types/react-dom": "^18.2.23",
|
||||
|
||||
@@ -1,40 +1,24 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
|
||||
import { getCsrfCookie } from './functions/auth';
|
||||
import { useLocalState } from './states/LocalState';
|
||||
import { useSessionState } from './states/SessionState';
|
||||
|
||||
// Global API instance
|
||||
export const api = axios.create({});
|
||||
|
||||
/*
|
||||
* Setup default settings for the Axios API instance.
|
||||
*
|
||||
* This includes:
|
||||
* - Base URL
|
||||
* - Authorization token (if available)
|
||||
* - CSRF token (if available)
|
||||
*/
|
||||
export function setApiDefaults() {
|
||||
const host = useLocalState.getState().host;
|
||||
const token = useSessionState.getState().token;
|
||||
|
||||
api.defaults.baseURL = host;
|
||||
api.defaults.timeout = 2500;
|
||||
api.defaults.headers.common['Authorization'] = token
|
||||
? `Token ${token}`
|
||||
: undefined;
|
||||
|
||||
if (!!getCsrfCookie()) {
|
||||
api.defaults.withCredentials = true;
|
||||
api.defaults.xsrfCookieName = 'csrftoken';
|
||||
api.defaults.xsrfHeaderName = 'X-CSRFToken';
|
||||
} else {
|
||||
api.defaults.withCredentials = false;
|
||||
api.defaults.xsrfCookieName = undefined;
|
||||
api.defaults.xsrfHeaderName = undefined;
|
||||
}
|
||||
api.defaults.withCredentials = true;
|
||||
api.defaults.withXSRFToken = true;
|
||||
api.defaults.xsrfCookieName = 'csrftoken';
|
||||
api.defaults.xsrfHeaderName = 'X-CSRFToken';
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { spotlight } from '@mantine/spotlight';
|
||||
import { IconCommand } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* A button which opens the quick command modal
|
||||
*/
|
||||
export function SpotlightButton() {
|
||||
return (
|
||||
<ActionIcon onClick={() => spotlight.open()} title={t`Open spotlight`}>
|
||||
<IconCommand />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const CHART_COLORS: string[] = [
|
||||
'#ffa8a8',
|
||||
'#8ce99a',
|
||||
'#74c0fc',
|
||||
'#ffe066',
|
||||
'#63e6be',
|
||||
'#ffc078',
|
||||
'#d8f5a2',
|
||||
'#66d9e8',
|
||||
'#e599f7',
|
||||
'#dee2e6'
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
|
||||
export function tooltipFormatter(label: any, currency: string) {
|
||||
return (
|
||||
formatCurrency(label, {
|
||||
currency: currency
|
||||
})?.toString() ?? ''
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
|
||||
export type DetailsBadgeProps = {
|
||||
color: string;
|
||||
label: string;
|
||||
size?: string;
|
||||
visible?: boolean;
|
||||
key?: any;
|
||||
};
|
||||
|
||||
export default function DetailsBadge(props: DetailsBadgeProps) {
|
||||
if (props.visible == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={props.key}
|
||||
color={props.color}
|
||||
variant="filled"
|
||||
size={props.size ?? 'lg'}
|
||||
>
|
||||
{props.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,7 @@ export interface ApiFormProps {
|
||||
pathParams?: PathParams;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
fields?: ApiFormFieldSet;
|
||||
focus?: string;
|
||||
initialData?: FieldValues;
|
||||
submitText?: string;
|
||||
submitColor?: string;
|
||||
@@ -106,6 +107,8 @@ export function OptionsApiForm({
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
enabled: true,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
queryKey: [
|
||||
'form-options-data',
|
||||
id,
|
||||
@@ -180,21 +183,26 @@ export function ApiForm({
|
||||
props: ApiFormProps;
|
||||
optionsLoading: boolean;
|
||||
}) {
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return props.fields ?? {};
|
||||
}, [props.fields]);
|
||||
|
||||
const defaultValues: FieldValues = useMemo(() => {
|
||||
let defaultValuesMap = mapFields(props.fields ?? {}, (_path, field) => {
|
||||
let defaultValuesMap = mapFields(fields ?? {}, (_path, field) => {
|
||||
return field.value ?? field.default ?? undefined;
|
||||
});
|
||||
|
||||
// If the user has specified initial data, use that instead
|
||||
// If the user has specified initial data, that overrides default values
|
||||
// But, *only* for the fields we have specified
|
||||
if (props.initialData) {
|
||||
defaultValuesMap = {
|
||||
...defaultValuesMap,
|
||||
...props.initialData
|
||||
};
|
||||
Object.keys(props.initialData).map((key) => {
|
||||
if (key in defaultValuesMap) {
|
||||
defaultValuesMap[key] =
|
||||
props?.initialData?.[key] ?? defaultValuesMap[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update the form values, but only for the fields specified for this form
|
||||
|
||||
return defaultValuesMap;
|
||||
}, [props.fields, props.initialData]);
|
||||
|
||||
@@ -259,14 +267,22 @@ export function ApiForm({
|
||||
};
|
||||
|
||||
// Process API response
|
||||
const initialData: any = processFields(
|
||||
props.fields ?? {},
|
||||
response.data
|
||||
);
|
||||
const initialData: any = processFields(fields, response.data);
|
||||
|
||||
// Update form values, but only for the fields specified for this form
|
||||
form.reset(initialData);
|
||||
|
||||
// Update the field references, too
|
||||
Object.keys(fields).forEach((fieldName) => {
|
||||
if (fieldName in initialData) {
|
||||
let field = fields[fieldName] ?? {};
|
||||
fields[fieldName] = {
|
||||
...field,
|
||||
value: initialData[fieldName]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error fetching initial data:', error);
|
||||
@@ -292,7 +308,48 @@ export function ApiForm({
|
||||
});
|
||||
initialDataQuery.refetch();
|
||||
}
|
||||
}, []);
|
||||
}, [props.fetchInitialData]);
|
||||
|
||||
const isLoading = useMemo(
|
||||
() =>
|
||||
isFormLoading ||
|
||||
initialDataQuery.isFetching ||
|
||||
optionsLoading ||
|
||||
isSubmitting ||
|
||||
!fields,
|
||||
[
|
||||
isFormLoading,
|
||||
initialDataQuery.isFetching,
|
||||
isSubmitting,
|
||||
fields,
|
||||
optionsLoading
|
||||
]
|
||||
);
|
||||
|
||||
const [initialFocus, setInitialFocus] = useState<string>('');
|
||||
|
||||
// Update field focus when the form is loaded
|
||||
useEffect(() => {
|
||||
let focusField = props.focus ?? '';
|
||||
|
||||
if (!focusField) {
|
||||
// If a focus field is not specified, then focus on the first available field
|
||||
Object.entries(fields).forEach(([fieldName, field]) => {
|
||||
if (focusField || field.read_only || field.disabled || field.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusField = fieldName;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading || initialFocus == focusField) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFocus(focusField);
|
||||
setInitialFocus(focusField);
|
||||
}, [props.focus, fields, form.setFocus, isLoading, initialFocus]);
|
||||
|
||||
const submitForm: SubmitHandler<FieldValues> = async (data) => {
|
||||
setNonFieldErrors([]);
|
||||
@@ -300,7 +357,7 @@ export function ApiForm({
|
||||
let method = props.method?.toLowerCase() ?? 'get';
|
||||
|
||||
let hasFiles = false;
|
||||
mapFields(props.fields ?? {}, (_path, field) => {
|
||||
mapFields(fields, (_path, field) => {
|
||||
if (field.field_type === 'file upload') {
|
||||
hasFiles = true;
|
||||
}
|
||||
@@ -392,22 +449,6 @@ export function ApiForm({
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading = useMemo(
|
||||
() =>
|
||||
isFormLoading ||
|
||||
initialDataQuery.isFetching ||
|
||||
optionsLoading ||
|
||||
isSubmitting ||
|
||||
!props.fields,
|
||||
[
|
||||
isFormLoading,
|
||||
initialDataQuery.isFetching,
|
||||
isSubmitting,
|
||||
props.fields,
|
||||
optionsLoading
|
||||
]
|
||||
);
|
||||
|
||||
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
|
||||
props.onFormError?.();
|
||||
}, [props.onFormError]);
|
||||
@@ -448,16 +489,14 @@ export function ApiForm({
|
||||
<FormProvider {...form}>
|
||||
<Stack spacing="xs">
|
||||
{!optionsLoading &&
|
||||
Object.entries(props.fields ?? {}).map(
|
||||
([fieldName, field]) => (
|
||||
<ApiFormField
|
||||
key={fieldName}
|
||||
fieldName={fieldName}
|
||||
definition={field}
|
||||
control={form.control}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
Object.entries(fields).map(([fieldName, field]) => (
|
||||
<ApiFormField
|
||||
key={fieldName}
|
||||
fieldName={fieldName}
|
||||
definition={field}
|
||||
control={form.control}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
{props.postFormContent}
|
||||
|
||||
@@ -12,16 +12,14 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { doBasicLogin, doSimpleLogin } from '../../functions/auth';
|
||||
import { doBasicLogin, doSimpleLogin, isLoggedIn } from '../../functions/auth';
|
||||
import { showLoginNotification } from '../../functions/notifications';
|
||||
import { apiUrl, useServerApiState } from '../../states/ApiState';
|
||||
import { useSessionState } from '../../states/SessionState';
|
||||
import { SsoButton } from '../buttons/SSOButton';
|
||||
|
||||
export function AuthenticationForm() {
|
||||
@@ -46,19 +44,18 @@ export function AuthenticationForm() {
|
||||
).then(() => {
|
||||
setIsLoggingIn(false);
|
||||
|
||||
if (useSessionState.getState().hasToken()) {
|
||||
notifications.show({
|
||||
if (isLoggedIn()) {
|
||||
showLoginNotification({
|
||||
title: t`Login successful`,
|
||||
message: t`Welcome back!`,
|
||||
color: 'green',
|
||||
icon: <IconCheck size="1rem" />
|
||||
message: t`Logged in successfully`
|
||||
});
|
||||
|
||||
navigate(location?.state?.redirectFrom ?? '/home');
|
||||
} else {
|
||||
notifications.show({
|
||||
showLoginNotification({
|
||||
title: t`Login failed`,
|
||||
message: t`Check your input and try again.`,
|
||||
color: 'red'
|
||||
success: false
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -67,18 +64,15 @@ export function AuthenticationForm() {
|
||||
setIsLoggingIn(false);
|
||||
|
||||
if (ret?.status === 'ok') {
|
||||
notifications.show({
|
||||
showLoginNotification({
|
||||
title: t`Mail delivery successful`,
|
||||
message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.`,
|
||||
color: 'green',
|
||||
icon: <IconCheck size="1rem" />,
|
||||
autoClose: false
|
||||
message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.`
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: t`Input error`,
|
||||
showLoginNotification({
|
||||
title: t`Mail delivery failed`,
|
||||
message: t`Check your input and try again.`,
|
||||
color: 'red'
|
||||
success: false
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -193,11 +187,9 @@ export function RegistrationForm() {
|
||||
.then((ret) => {
|
||||
if (ret?.status === 204) {
|
||||
setIsRegistering(false);
|
||||
notifications.show({
|
||||
showLoginNotification({
|
||||
title: t`Registration successful`,
|
||||
message: t`Please confirm your email address to complete the registration`,
|
||||
color: 'green',
|
||||
icon: <IconCheck size="1rem" />
|
||||
message: t`Please confirm your email address to complete the registration`
|
||||
});
|
||||
navigate('/home');
|
||||
}
|
||||
@@ -212,11 +204,10 @@ export function RegistrationForm() {
|
||||
if (err.response?.data?.non_field_errors) {
|
||||
err_msg = err.response.data.non_field_errors;
|
||||
}
|
||||
notifications.show({
|
||||
showLoginNotification({
|
||||
title: t`Input error`,
|
||||
message: t`Check your input and try again. ` + err_msg,
|
||||
color: 'red',
|
||||
autoClose: 30000
|
||||
success: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useMemo } from 'react';
|
||||
import { Control, FieldValues, useController } from 'react-hook-form';
|
||||
|
||||
import { ModelType } from '../../../enums/ModelType';
|
||||
import { isTrue } from '../../../functions/conversion';
|
||||
import { ChoiceField } from './ChoiceField';
|
||||
import DateField from './DateField';
|
||||
import { NestedObjectField } from './NestedObjectField';
|
||||
@@ -188,7 +189,7 @@ export function ApiFormField({
|
||||
return (
|
||||
<TextInput
|
||||
{...reducedDefinition}
|
||||
ref={ref}
|
||||
ref={field.ref}
|
||||
id={fieldId}
|
||||
type={definition.field_type}
|
||||
value={value || ''}
|
||||
@@ -210,7 +211,7 @@ export function ApiFormField({
|
||||
id={fieldId}
|
||||
radius="lg"
|
||||
size="sm"
|
||||
checked={value ?? false}
|
||||
checked={isTrue(value)}
|
||||
error={error?.message}
|
||||
onChange={(event) => onChange(event.currentTarget.checked)}
|
||||
/>
|
||||
@@ -226,21 +227,14 @@ export function ApiFormField({
|
||||
<NumberInput
|
||||
{...reducedDefinition}
|
||||
radius="sm"
|
||||
ref={ref}
|
||||
ref={field.ref}
|
||||
id={fieldId}
|
||||
value={numericalValue}
|
||||
error={error?.message}
|
||||
formatter={(value) => {
|
||||
let v: any = parseFloat(value);
|
||||
|
||||
if (Number.isNaN(v) || !Number.isFinite(v)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${1 * v.toFixed()}`;
|
||||
}}
|
||||
precision={definition.field_type == 'integer' ? 0 : 10}
|
||||
onChange={(value: number) => onChange(value)}
|
||||
removeTrailingZeros
|
||||
step={1}
|
||||
/>
|
||||
);
|
||||
case 'choice':
|
||||
@@ -256,7 +250,7 @@ export function ApiFormField({
|
||||
<FileInput
|
||||
{...reducedDefinition}
|
||||
id={fieldId}
|
||||
ref={ref}
|
||||
ref={field.ref}
|
||||
radius="sm"
|
||||
value={value}
|
||||
error={error?.message}
|
||||
|
||||
@@ -52,6 +52,7 @@ export default function DateField({
|
||||
<DateInput
|
||||
id={fieldId}
|
||||
radius="sm"
|
||||
ref={field.ref}
|
||||
type={undefined}
|
||||
error={error?.message}
|
||||
value={dateValue}
|
||||
|
||||
@@ -269,6 +269,7 @@ export function RelatedModelField({
|
||||
<Select
|
||||
id={fieldId}
|
||||
value={currentValue}
|
||||
ref={field.ref}
|
||||
options={data}
|
||||
filterOption={null}
|
||||
onInputChange={(value: any) => {
|
||||
|
||||
@@ -1,71 +1,27 @@
|
||||
/**
|
||||
* Component for loading an image from the InvenTree server,
|
||||
* using the API's token authentication.
|
||||
* Component for loading an image from the InvenTree server
|
||||
*
|
||||
* Image caching is handled automagically by the browsers cache
|
||||
*/
|
||||
import { Image, ImageProps, Skeleton, Stack } from '@mantine/core';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
|
||||
/**
|
||||
* Construct an image container which will load and display the image
|
||||
*/
|
||||
export function ApiImage(props: ImageProps) {
|
||||
const [image, setImage] = useState<string>('');
|
||||
const { host } = useLocalState.getState();
|
||||
|
||||
const [authorized, setAuthorized] = useState<boolean>(true);
|
||||
|
||||
const queryKey = useId();
|
||||
|
||||
const _imgQuery = useQuery({
|
||||
queryKey: ['image', queryKey, props.src],
|
||||
enabled:
|
||||
authorized &&
|
||||
props.src != undefined &&
|
||||
props.src != null &&
|
||||
props.src != '',
|
||||
queryFn: async () => {
|
||||
if (!props.src) {
|
||||
return null;
|
||||
}
|
||||
return api
|
||||
.get(props.src, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
.then((response) => {
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
let img = new Blob([response.data], {
|
||||
type: response.headers['content-type']
|
||||
});
|
||||
let url = URL.createObjectURL(img);
|
||||
setImage(url);
|
||||
break;
|
||||
default:
|
||||
// User is not authorized to view this image, or the image is not available
|
||||
setImage('');
|
||||
setAuthorized(false);
|
||||
break;
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch((_error) => {
|
||||
return null;
|
||||
});
|
||||
},
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
const imageUrl = useMemo(() => {
|
||||
return `${host}${props.src}`;
|
||||
}, [host, props.src]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{image && image.length > 0 ? (
|
||||
<Image {...props} src={image} withPlaceholder fit="contain" />
|
||||
{imageUrl ? (
|
||||
<Image {...props} src={imageUrl} withPlaceholder fit="contain" />
|
||||
) : (
|
||||
<Skeleton
|
||||
height={props?.height ?? props.width}
|
||||
|
||||
@@ -13,6 +13,7 @@ export function Thumbnail({
|
||||
src,
|
||||
alt = t`Thumbnail`,
|
||||
size = 20,
|
||||
link,
|
||||
text,
|
||||
align
|
||||
}: {
|
||||
@@ -21,9 +22,22 @@ export function Thumbnail({
|
||||
size?: number;
|
||||
text?: ReactNode;
|
||||
align?: string;
|
||||
link?: string;
|
||||
}) {
|
||||
const backup_image = '/static/img/blank_image.png';
|
||||
|
||||
const inner = useMemo(() => {
|
||||
if (link) {
|
||||
return (
|
||||
<Anchor href={link} target="_blank">
|
||||
{text}
|
||||
</Anchor>
|
||||
);
|
||||
} else {
|
||||
return text;
|
||||
}
|
||||
}, [link, text]);
|
||||
|
||||
return (
|
||||
<Group align={align ?? 'left'} spacing="xs" noWrap={true}>
|
||||
<ApiImage
|
||||
@@ -39,7 +53,7 @@ export function Thumbnail({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{text}
|
||||
{inner}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
IconFileTypeXls,
|
||||
IconFileTypeZip
|
||||
} from '@tabler/icons-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
|
||||
/**
|
||||
* Return an icon based on the provided filename
|
||||
@@ -58,10 +60,20 @@ export function AttachmentLink({
|
||||
}): ReactNode {
|
||||
let text = external ? attachment : attachment.split('/').pop();
|
||||
|
||||
const host = useLocalState((s) => s.host);
|
||||
|
||||
const url = useMemo(() => {
|
||||
if (external) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
return `${host}${attachment}`;
|
||||
}, [host, attachment, external]);
|
||||
|
||||
return (
|
||||
<Group position="left" spacing="sm">
|
||||
{external ? <IconLink /> : attachmentIcon(attachment)}
|
||||
<Anchor href={attachment} target="_blank" rel="noopener noreferrer">
|
||||
<Anchor href={url} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface MenuLinkItem {
|
||||
docchildren?: React.ReactNode;
|
||||
}
|
||||
|
||||
export type menuItemsCollection = {
|
||||
[key: string]: MenuLinkItem;
|
||||
};
|
||||
|
||||
function ConditionalDocTooltip({
|
||||
item,
|
||||
children
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ActionIcon, Container, Group, Indicator, Tabs } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconBell, IconSearch } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMatch, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { api } from '../../App';
|
||||
@@ -10,7 +10,9 @@ import { navTabs as mainNavTabs } from '../../defaults/links';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { InvenTreeStyle } from '../../globalStyle';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
import { ScanButton } from '../buttons/ScanButton';
|
||||
import { SpotlightButton } from '../buttons/SpotlightButton';
|
||||
import { MainMenu } from './MainMenu';
|
||||
import { NavHoverMenu } from './NavHoverMenu';
|
||||
import { NavigationDrawer } from './NavigationDrawer';
|
||||
@@ -19,8 +21,12 @@ import { SearchDrawer } from './SearchDrawer';
|
||||
|
||||
export function Header() {
|
||||
const { classes } = InvenTreeStyle();
|
||||
const [setNavigationOpen, navigationOpen] = useLocalState((state) => [
|
||||
state.setNavigationOpen,
|
||||
state.navigationOpen
|
||||
]);
|
||||
const [navDrawerOpened, { open: openNavDrawer, close: closeNavDrawer }] =
|
||||
useDisclosure(false);
|
||||
useDisclosure(navigationOpen);
|
||||
const [
|
||||
searchDrawerOpened,
|
||||
{ open: openSearchDrawer, close: closeSearchDrawer }
|
||||
@@ -59,6 +65,18 @@ export function Header() {
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
// Sync Navigation Drawer state with zustand
|
||||
useEffect(() => {
|
||||
if (navigationOpen === navDrawerOpened) return;
|
||||
setNavigationOpen(navDrawerOpened);
|
||||
}, [navDrawerOpened]);
|
||||
|
||||
useEffect(() => {
|
||||
if (navigationOpen === navDrawerOpened) return;
|
||||
if (navigationOpen) openNavDrawer();
|
||||
else closeNavDrawer();
|
||||
}, [navigationOpen]);
|
||||
|
||||
return (
|
||||
<div className={classes.layoutHeader}>
|
||||
<SearchDrawer opened={searchDrawerOpened} onClose={closeSearchDrawer} />
|
||||
@@ -80,6 +98,7 @@ export function Header() {
|
||||
<ActionIcon onClick={openSearchDrawer}>
|
||||
<IconSearch />
|
||||
</ActionIcon>
|
||||
<SpotlightButton />
|
||||
<ScanButton />
|
||||
<ActionIcon onClick={openNotificationDrawer}>
|
||||
<Indicator
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Container, Flex, Space } from '@mantine/core';
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { SpotlightProvider } from '@mantine/spotlight';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { getActions } from '../../defaults/actions';
|
||||
import { isLoggedIn } from '../../functions/auth';
|
||||
import { InvenTreeStyle } from '../../globalStyle';
|
||||
import { useSessionState } from '../../states/SessionState';
|
||||
import { Footer } from './Footer';
|
||||
import { Header } from './Header';
|
||||
|
||||
export const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
|
||||
const [token] = useSessionState((state) => [state.token]);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
if (!token) {
|
||||
if (!isLoggedIn()) {
|
||||
return (
|
||||
<Navigate to="/logged-in" state={{ redirectFrom: location.pathname }} />
|
||||
);
|
||||
@@ -22,17 +25,43 @@ export const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
|
||||
|
||||
export default function LayoutComponent() {
|
||||
const { classes } = InvenTreeStyle();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const defaultactions = getActions(navigate);
|
||||
const [actions, setActions] = useState(defaultactions);
|
||||
const [customActions, setCustomActions] = useState<boolean>(false);
|
||||
|
||||
function actionsAreChanging(change: []) {
|
||||
if (change.length > defaultactions.length) setCustomActions(true);
|
||||
setActions(change);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (customActions) {
|
||||
setActions(defaultactions);
|
||||
setCustomActions(false);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Flex direction="column" mih="100vh">
|
||||
<Header />
|
||||
<Container className={classes.layoutContent} size="100%">
|
||||
<Outlet />
|
||||
</Container>
|
||||
<Space h="xl" />
|
||||
<Footer />
|
||||
</Flex>
|
||||
<SpotlightProvider
|
||||
actions={actions}
|
||||
onActionsChange={actionsAreChanging}
|
||||
searchIcon={<IconSearch size="1.2rem" />}
|
||||
searchPlaceholder={t`Search...`}
|
||||
shortcut={['mod + K', '/']}
|
||||
nothingFoundMessage={t`Nothing found...`}
|
||||
>
|
||||
<Flex direction="column" mih="100vh">
|
||||
<Header />
|
||||
<Container className={classes.layoutContent} size="100%">
|
||||
<Outlet />
|
||||
</Container>
|
||||
<Space h="xl" />
|
||||
<Footer />
|
||||
</Flex>
|
||||
</SpotlightProvider>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import { useLocalState } from '../../states/LocalState';
|
||||
import { InvenTreeLogo } from '../items/InvenTreeLogo';
|
||||
import { MenuLinks } from '../items/MenuLinks';
|
||||
|
||||
const onlyItems = Object.values(menuItems);
|
||||
|
||||
export function NavHoverMenu({
|
||||
openDrawer: openDrawer
|
||||
}: {
|
||||
@@ -85,7 +87,7 @@ export function NavHoverMenu({
|
||||
mx="-md"
|
||||
color={theme.colorScheme === 'dark' ? 'dark.5' : 'gray.1'}
|
||||
/>
|
||||
<MenuLinks links={menuItems} highlighted={true} />
|
||||
<MenuLinks links={onlyItems} highlighted={true} />
|
||||
<div className={classes.headerDropdownFooter}>
|
||||
<Group position="apart">
|
||||
<div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MenuLinkItem, MenuLinks } from '../items/MenuLinks';
|
||||
|
||||
// TODO @matmair #1: implement plugin loading and menu item generation see #5269
|
||||
const plugins: MenuLinkItem[] = [];
|
||||
const onlyItems = Object.values(menuItems);
|
||||
|
||||
export function NavigationDrawer({
|
||||
opened,
|
||||
@@ -60,7 +61,7 @@ function DrawerContent() {
|
||||
<Container className={classes.layoutContent} p={0}>
|
||||
<ScrollArea h={scrollHeight} type="always" offsetScrollbars>
|
||||
<Title order={5}>{t`Pages`}</Title>
|
||||
<MenuLinks links={menuItems} />
|
||||
<MenuLinks links={onlyItems} />
|
||||
<Space h="md" />
|
||||
{plugins.length > 0 ? (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Group, Paper, Space, Stack, Text } from '@mantine/core';
|
||||
import { Fragment, ReactNode } from 'react';
|
||||
|
||||
import DetailsBadge, { DetailsBadgeProps } from '../details/DetailsBadge';
|
||||
import { ApiImage } from '../images/ApiImage';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
import { Breadcrumb, BreadcrumbList } from './BreadcrumbList';
|
||||
@@ -15,6 +16,7 @@ export function PageDetail({
|
||||
title,
|
||||
subtitle,
|
||||
detail,
|
||||
badges,
|
||||
imageUrl,
|
||||
breadcrumbs,
|
||||
breadcrumbAction,
|
||||
@@ -24,6 +26,7 @@ export function PageDetail({
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
detail?: ReactNode;
|
||||
badges?: ReactNode[];
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
breadcrumbAction?: () => void;
|
||||
actions?: ReactNode[];
|
||||
@@ -56,6 +59,9 @@ export function PageDetail({
|
||||
</Group>
|
||||
<Space />
|
||||
{detail}
|
||||
<Group position="right" spacing="xs" noWrap>
|
||||
{badges}
|
||||
</Group>
|
||||
<Space />
|
||||
{actions && (
|
||||
<Group spacing={5} position="right">
|
||||
|
||||
@@ -74,13 +74,7 @@ export const StatusRenderer = ({
|
||||
}) => {
|
||||
const statusCodeList = useGlobalStatusState.getState().status;
|
||||
|
||||
if (status === undefined) {
|
||||
console.log('StatusRenderer: status is undefined');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (statusCodeList === undefined) {
|
||||
console.log('StatusRenderer: statusCodeList is undefined');
|
||||
if (status === undefined || statusCodeList === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ export function LanguageContext({ children }: { children: JSX.Element }) {
|
||||
// Clear out cached table column names
|
||||
useLocalState.getState().clearTableColumnNames();
|
||||
})
|
||||
/* istanbul ignore next */
|
||||
.catch((err) => {
|
||||
console.error('Failed loading translations', err);
|
||||
if (isMounted.current) setLoadedState('error');
|
||||
@@ -115,6 +116,7 @@ export function LanguageContext({ children }: { children: JSX.Element }) {
|
||||
return <LoadingOverlay visible={true} />;
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (loadedState === 'error') {
|
||||
return (
|
||||
<Text>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import type { SpotlightAction } from '@mantine/spotlight';
|
||||
import { IconHome, IconLink, IconPointer } from '@tabler/icons-react';
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
import { aboutInvenTree, docLinks, licenseInfo, serverInfo } from './links';
|
||||
import { menuItems } from './menuItems';
|
||||
|
||||
export function getActions(navigate: NavigateFunction) {
|
||||
const setNavigationOpen = useLocalState((state) => state.setNavigationOpen);
|
||||
|
||||
const actions: SpotlightAction[] = [
|
||||
{
|
||||
title: t`Home`,
|
||||
description: `Go to the home page`,
|
||||
onTrigger: () => navigate(menuItems.home.link),
|
||||
icon: <IconHome size="1.2rem" />
|
||||
},
|
||||
{
|
||||
title: t`Dashboard`,
|
||||
description: t`Go to the InvenTree dashboard`,
|
||||
onTrigger: () => navigate(menuItems.dashboard.link),
|
||||
icon: <IconLink size="1.2rem" />
|
||||
},
|
||||
{
|
||||
title: t`Documentation`,
|
||||
description: t`Visit the documentation to learn more about InvenTree`,
|
||||
onTrigger: () => (window.location.href = docLinks.faq),
|
||||
icon: <IconLink size="1.2rem" />
|
||||
},
|
||||
{
|
||||
title: t`About InvenTree`,
|
||||
description: t`About the InvenTree org`,
|
||||
onTrigger: () => aboutInvenTree(),
|
||||
icon: <IconLink size="1.2rem" />
|
||||
},
|
||||
{
|
||||
title: t`Server Information`,
|
||||
description: t`About this Inventree instance`,
|
||||
onTrigger: () => serverInfo(),
|
||||
icon: <IconLink size="1.2rem" />
|
||||
},
|
||||
{
|
||||
title: t`License Information`,
|
||||
description: t`Licenses for dependencies of the service`,
|
||||
onTrigger: () => licenseInfo(),
|
||||
icon: <IconLink size="1.2rem" />
|
||||
},
|
||||
{
|
||||
title: t`Open Navigation`,
|
||||
description: t`Open the main navigation menu`,
|
||||
onTrigger: () => setNavigationOpen(true),
|
||||
icon: <IconPointer size="1.2rem" />
|
||||
}
|
||||
];
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -5,11 +5,33 @@ import {
|
||||
useUserSettingsState
|
||||
} from '../states/SettingsState';
|
||||
|
||||
interface formatDecmimalOptionsType {
|
||||
digits?: number;
|
||||
minDigits?: number;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
interface formatCurrencyOptionsType {
|
||||
digits?: number;
|
||||
minDigits?: number;
|
||||
currency?: string;
|
||||
locale?: string;
|
||||
multiplier?: number;
|
||||
}
|
||||
|
||||
export function formatDecimal(
|
||||
value: number | null | undefined,
|
||||
options: formatDecmimalOptionsType = {}
|
||||
) {
|
||||
let locale = options.locale || navigator.language || 'en-US';
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let formatter = new Intl.NumberFormat(locale);
|
||||
|
||||
return formatter.format(value);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -21,13 +43,21 @@ interface formatCurrencyOptionsType {
|
||||
* - digits: Maximum number of significant digits (default = 10)
|
||||
*/
|
||||
export function formatCurrency(
|
||||
value: number | null,
|
||||
value: number | string | null | undefined,
|
||||
options: formatCurrencyOptionsType = {}
|
||||
) {
|
||||
if (value == null) {
|
||||
if (value == null || value == undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
value = parseFloat(value.toString());
|
||||
|
||||
if (isNaN(value) || !isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
value *= options.multiplier ?? 1;
|
||||
|
||||
const global_settings = useGlobalSettingsState.getState().lookup;
|
||||
|
||||
let maxDigits = options.digits || global_settings.PRICING_DECIMAL_PLACES || 6;
|
||||
|
||||
@@ -71,7 +71,7 @@ export const navDocLinks: DocumentationLinkItem[] = [
|
||||
}
|
||||
];
|
||||
|
||||
function serverInfo() {
|
||||
export function serverInfo() {
|
||||
return openContextModal({
|
||||
modal: 'info',
|
||||
title: (
|
||||
@@ -84,7 +84,7 @@ function serverInfo() {
|
||||
});
|
||||
}
|
||||
|
||||
function aboutInvenTree() {
|
||||
export function aboutInvenTree() {
|
||||
return openContextModal({
|
||||
modal: 'about',
|
||||
title: (
|
||||
@@ -96,7 +96,8 @@ function aboutInvenTree() {
|
||||
innerProps: {}
|
||||
});
|
||||
}
|
||||
function licenseInfo() {
|
||||
|
||||
export function licenseInfo() {
|
||||
return openContextModal({
|
||||
modal: 'license',
|
||||
title: (
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { MenuLinkItem } from '../components/items/MenuLinks';
|
||||
import { menuItemsCollection } from '../components/items/MenuLinks';
|
||||
import { IS_DEV_OR_DEMO } from '../main';
|
||||
|
||||
export const menuItems: MenuLinkItem[] = [
|
||||
{
|
||||
export const menuItems: menuItemsCollection = {
|
||||
home: {
|
||||
id: 'home',
|
||||
text: <Trans>Home</Trans>,
|
||||
link: '/',
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
profile: {
|
||||
id: 'profile',
|
||||
text: <Trans>Account settings</Trans>,
|
||||
link: '/settings/user',
|
||||
doctext: <Trans>User attributes and design settings.</Trans>
|
||||
},
|
||||
{
|
||||
scan: {
|
||||
id: 'scan',
|
||||
text: <Trans>Scanning</Trans>,
|
||||
link: '/scan',
|
||||
doctext: <Trans>View for interactive scanning and multiple actions.</Trans>,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
dashboard: {
|
||||
id: 'dashboard',
|
||||
text: <Trans>Dashboard</Trans>,
|
||||
link: '/dashboard'
|
||||
},
|
||||
{
|
||||
parts: {
|
||||
id: 'parts',
|
||||
text: <Trans>Parts</Trans>,
|
||||
link: '/part/'
|
||||
},
|
||||
{
|
||||
stock: {
|
||||
id: 'stock',
|
||||
text: <Trans>Stock</Trans>,
|
||||
link: '/stock'
|
||||
},
|
||||
{
|
||||
build: {
|
||||
id: 'build',
|
||||
text: <Trans>Build</Trans>,
|
||||
link: '/build/'
|
||||
},
|
||||
{
|
||||
purchasing: {
|
||||
id: 'purchasing',
|
||||
text: <Trans>Purchasing</Trans>,
|
||||
link: '/purchasing/'
|
||||
},
|
||||
{
|
||||
sales: {
|
||||
id: 'sales',
|
||||
text: <Trans>Sales</Trans>,
|
||||
link: '/sales/'
|
||||
},
|
||||
{
|
||||
'settings-system': {
|
||||
id: 'settings-system',
|
||||
text: <Trans>System Settings</Trans>,
|
||||
link: '/settings/system'
|
||||
},
|
||||
{
|
||||
'settings-admin': {
|
||||
id: 'settings-admin',
|
||||
text: <Trans>Admin Center</Trans>,
|
||||
link: '/settings/admin'
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
if (IS_DEV_OR_DEMO) {
|
||||
menuItems.push({
|
||||
menuItems['playground'] = {
|
||||
id: 'playground',
|
||||
text: <Trans>Playground</Trans>,
|
||||
link: '/playground',
|
||||
highlight: true
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,14 +15,15 @@ export enum ApiEndpoints {
|
||||
user_roles = 'user/roles/',
|
||||
user_token = 'user/token/',
|
||||
user_simple_login = 'email/generate/',
|
||||
user_reset = 'auth/password/reset/', // Note leading prefix here
|
||||
user_reset_set = 'auth/password/reset/confirm/', // Note leading prefix here
|
||||
user_reset = 'auth/password/reset/',
|
||||
user_reset_set = 'auth/password/reset/confirm/',
|
||||
user_sso = 'auth/social/',
|
||||
user_sso_remove = 'auth/social/:id/disconnect/',
|
||||
user_emails = 'auth/emails/',
|
||||
user_email_remove = 'auth/emails/:id/remove/',
|
||||
user_email_verify = 'auth/emails/:id/verify/',
|
||||
user_email_primary = 'auth/emails/:id/primary/',
|
||||
user_login = 'auth/login/',
|
||||
user_logout = 'auth/logout/',
|
||||
user_register = 'auth/registration/',
|
||||
|
||||
@@ -61,6 +62,8 @@ export enum ApiEndpoints {
|
||||
part_parameter_template_list = 'part/parameter/template/',
|
||||
part_thumbs_list = 'part/thumbs/',
|
||||
part_pricing_get = 'part/:id/pricing/',
|
||||
part_pricing_internal = 'part/internal-price/',
|
||||
part_pricing_sale = 'part/sale-price/',
|
||||
part_stocktake_list = 'part/stocktake/',
|
||||
category_list = 'part/category/',
|
||||
category_tree = 'part/category/tree/',
|
||||
@@ -75,6 +78,7 @@ export enum ApiEndpoints {
|
||||
address_list = 'company/address/',
|
||||
company_attachment_list = 'company/attachment/',
|
||||
supplier_part_list = 'company/part/',
|
||||
supplier_part_pricing_list = 'company/price-break/',
|
||||
manufacturer_part_list = 'company/part/manufacturer/',
|
||||
manufacturer_part_attachment_list = 'company/part/manufacturer/attachment/',
|
||||
manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/',
|
||||
@@ -101,9 +105,12 @@ export enum ApiEndpoints {
|
||||
purchase_order_line_list = 'order/po-line/',
|
||||
purchase_order_attachment_list = 'order/po/attachment/',
|
||||
purchase_order_receive = 'order/po/:id/receive/',
|
||||
|
||||
sales_order_list = 'order/so/',
|
||||
sales_order_line_list = 'order/so-line/',
|
||||
sales_order_attachment_list = 'order/so/attachment/',
|
||||
sales_order_shipment_list = 'order/so/shipment/',
|
||||
|
||||
return_order_list = 'order/ro/',
|
||||
return_order_attachment_list = 'order/ro/attachment/',
|
||||
|
||||
|
||||
@@ -7,57 +7,64 @@ import {
|
||||
IconUser,
|
||||
IconUsersGroup
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
|
||||
/**
|
||||
* Field set for BuildOrder forms
|
||||
*/
|
||||
export function buildOrderFields(): ApiFormFieldSet {
|
||||
return {
|
||||
reference: {},
|
||||
part: {
|
||||
filters: {
|
||||
assembly: true,
|
||||
virtual: false
|
||||
export function useBuildOrderFields({
|
||||
create
|
||||
}: {
|
||||
create: boolean;
|
||||
}): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
reference: {},
|
||||
part: {
|
||||
filters: {
|
||||
assembly: true,
|
||||
virtual: false
|
||||
}
|
||||
},
|
||||
title: {},
|
||||
quantity: {},
|
||||
project_code: {
|
||||
icon: <IconList />
|
||||
},
|
||||
priority: {},
|
||||
parent: {
|
||||
icon: <IconSitemap />,
|
||||
filters: {
|
||||
part_detail: true
|
||||
}
|
||||
},
|
||||
sales_order: {
|
||||
icon: <IconTruckDelivery />
|
||||
},
|
||||
batch: {},
|
||||
target_date: {
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
take_from: {},
|
||||
destination: {
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
link: {
|
||||
icon: <IconLink />
|
||||
},
|
||||
issued_by: {
|
||||
icon: <IconUser />
|
||||
},
|
||||
responsible: {
|
||||
icon: <IconUsersGroup />,
|
||||
filters: {
|
||||
is_active: true
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {},
|
||||
quantity: {},
|
||||
project_code: {
|
||||
icon: <IconList />
|
||||
},
|
||||
priority: {},
|
||||
parent: {
|
||||
icon: <IconSitemap />,
|
||||
filters: {
|
||||
part_detail: true
|
||||
}
|
||||
},
|
||||
sales_order: {
|
||||
icon: <IconTruckDelivery />
|
||||
},
|
||||
batch: {},
|
||||
target_date: {
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
take_from: {},
|
||||
destination: {
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
link: {
|
||||
icon: <IconLink />
|
||||
},
|
||||
issued_by: {
|
||||
icon: <IconUser />
|
||||
},
|
||||
responsible: {
|
||||
icon: <IconUsersGroup />,
|
||||
filters: {
|
||||
is_active: true
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}, [create]);
|
||||
}
|
||||
|
||||
@@ -10,34 +10,21 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
import {
|
||||
ApiFormAdjustFilterType,
|
||||
ApiFormFieldSet
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
|
||||
/**
|
||||
* Field set for SupplierPart instance
|
||||
*/
|
||||
export function useSupplierPartFields({
|
||||
partPk,
|
||||
supplierPk,
|
||||
hidePart
|
||||
}: {
|
||||
partPk?: number;
|
||||
supplierPk?: number;
|
||||
hidePart?: boolean;
|
||||
}) {
|
||||
const [part, setPart] = useState<number | undefined>(partPk);
|
||||
|
||||
useEffect(() => {
|
||||
setPart(partPk);
|
||||
}, [partPk]);
|
||||
|
||||
export function useSupplierPartFields() {
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
part: {
|
||||
hidden: hidePart,
|
||||
value: part,
|
||||
onValueChange: setPart,
|
||||
filters: {
|
||||
purchaseable: true
|
||||
purchaseable: true,
|
||||
active: true
|
||||
}
|
||||
},
|
||||
manufacturer_part: {
|
||||
@@ -45,15 +32,18 @@ export function useSupplierPartFields({
|
||||
part_detail: true,
|
||||
manufacturer_detail: true
|
||||
},
|
||||
adjustFilters: (filters: any) => {
|
||||
if (part) {
|
||||
filters.part = part;
|
||||
}
|
||||
|
||||
return filters;
|
||||
adjustFilters: (adjust: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...adjust.filters,
|
||||
part: adjust.data.part
|
||||
};
|
||||
}
|
||||
},
|
||||
supplier: {
|
||||
filters: {
|
||||
active: true
|
||||
}
|
||||
},
|
||||
supplier: {},
|
||||
SKU: {
|
||||
icon: <IconHash />
|
||||
},
|
||||
@@ -67,15 +57,12 @@ export function useSupplierPartFields({
|
||||
pack_quantity: {},
|
||||
packaging: {
|
||||
icon: <IconPackage />
|
||||
}
|
||||
},
|
||||
active: {}
|
||||
};
|
||||
|
||||
if (supplierPk !== undefined) {
|
||||
fields.supplier.value = supplierPk;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [part]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useManufacturerPartFields() {
|
||||
@@ -125,6 +112,7 @@ export function companyFields(): ApiFormFieldSet {
|
||||
},
|
||||
is_supplier: {},
|
||||
is_manufacturer: {},
|
||||
is_customer: {}
|
||||
is_customer: {},
|
||||
active: {}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,8 +37,12 @@ import { apiUrl } from '../states/ApiState';
|
||||
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
|
||||
*/
|
||||
export function usePurchaseOrderLineItemFields({
|
||||
supplierId,
|
||||
orderId,
|
||||
create
|
||||
}: {
|
||||
supplierId?: number;
|
||||
orderId?: number;
|
||||
create?: boolean;
|
||||
}) {
|
||||
const [purchasePrice, setPurchasePrice] = useState<string>('');
|
||||
@@ -60,16 +64,20 @@ export function usePurchaseOrderLineItemFields({
|
||||
filters: {
|
||||
supplier_detail: true
|
||||
},
|
||||
hidden: true
|
||||
disabled: true
|
||||
},
|
||||
part: {
|
||||
filters: {
|
||||
part_detail: true,
|
||||
supplier_detail: true
|
||||
supplier_detail: true,
|
||||
active: true,
|
||||
part_active: true
|
||||
},
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
// TODO: Adjust part based on the supplier associated with the supplier
|
||||
return value.filters;
|
||||
adjustFilters: (adjust: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...adjust.filters,
|
||||
supplier: supplierId
|
||||
};
|
||||
}
|
||||
},
|
||||
quantity: {},
|
||||
@@ -105,7 +113,7 @@ export function usePurchaseOrderLineItemFields({
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [create, autoPricing, purchasePrice]);
|
||||
}, [create, orderId, supplierId, autoPricing, purchasePrice]);
|
||||
|
||||
return fields;
|
||||
}
|
||||
@@ -113,50 +121,53 @@ export function usePurchaseOrderLineItemFields({
|
||||
/**
|
||||
* Construct a set of fields for creating / editing a PurchaseOrder instance
|
||||
*/
|
||||
export function purchaseOrderFields(): ApiFormFieldSet {
|
||||
return {
|
||||
reference: {
|
||||
icon: <IconHash />
|
||||
},
|
||||
description: {},
|
||||
supplier: {
|
||||
filters: {
|
||||
is_supplier: true
|
||||
export function usePurchaseOrderFields(): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
reference: {
|
||||
icon: <IconHash />
|
||||
},
|
||||
description: {},
|
||||
supplier: {
|
||||
filters: {
|
||||
is_supplier: true,
|
||||
active: true
|
||||
}
|
||||
},
|
||||
supplier_reference: {},
|
||||
project_code: {
|
||||
icon: <IconList />
|
||||
},
|
||||
order_currency: {
|
||||
icon: <IconCoins />
|
||||
},
|
||||
target_date: {
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
link: {},
|
||||
contact: {
|
||||
icon: <IconUser />,
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...value.filters,
|
||||
company: value.data.supplier
|
||||
};
|
||||
}
|
||||
},
|
||||
address: {
|
||||
icon: <IconAddressBook />,
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...value.filters,
|
||||
company: value.data.supplier
|
||||
};
|
||||
}
|
||||
},
|
||||
responsible: {
|
||||
icon: <IconUsers />
|
||||
}
|
||||
},
|
||||
supplier_reference: {},
|
||||
project_code: {
|
||||
icon: <IconList />
|
||||
},
|
||||
order_currency: {
|
||||
icon: <IconCoins />
|
||||
},
|
||||
target_date: {
|
||||
icon: <IconCalendar />
|
||||
},
|
||||
link: {},
|
||||
contact: {
|
||||
icon: <IconUser />,
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...value.filters,
|
||||
company: value.data.supplier
|
||||
};
|
||||
}
|
||||
},
|
||||
address: {
|
||||
icon: <IconAddressBook />,
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...value.filters,
|
||||
company: value.data.supplier
|
||||
};
|
||||
}
|
||||
},
|
||||
responsible: {
|
||||
icon: <IconUsers />
|
||||
}
|
||||
};
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,44 +1,89 @@
|
||||
import { IconAddressBook, IconUser, IconUsers } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
ApiFormAdjustFilterType,
|
||||
ApiFormFieldSet
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
|
||||
export function salesOrderFields(): ApiFormFieldSet {
|
||||
return {
|
||||
reference: {},
|
||||
description: {},
|
||||
customer: {
|
||||
filters: {
|
||||
is_customer: true
|
||||
export function useSalesOrderFields(): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
reference: {},
|
||||
description: {},
|
||||
customer: {
|
||||
filters: {
|
||||
is_customer: true,
|
||||
active: true
|
||||
}
|
||||
},
|
||||
customer_reference: {},
|
||||
project_code: {},
|
||||
order_currency: {},
|
||||
target_date: {},
|
||||
link: {},
|
||||
contact: {
|
||||
icon: <IconUser />,
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...value.filters,
|
||||
company: value.data.customer
|
||||
};
|
||||
}
|
||||
},
|
||||
address: {
|
||||
icon: <IconAddressBook />,
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...value.filters,
|
||||
company: value.data.customer
|
||||
};
|
||||
}
|
||||
},
|
||||
responsible: {
|
||||
icon: <IconUsers />
|
||||
}
|
||||
},
|
||||
customer_reference: {},
|
||||
project_code: {},
|
||||
order_currency: {},
|
||||
target_date: {},
|
||||
link: {},
|
||||
contact: {
|
||||
icon: <IconUser />,
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...value.filters,
|
||||
company: value.data.customer
|
||||
};
|
||||
}
|
||||
},
|
||||
address: {
|
||||
icon: <IconAddressBook />,
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...value.filters,
|
||||
company: value.data.customer
|
||||
};
|
||||
}
|
||||
},
|
||||
responsible: {
|
||||
icon: <IconUsers />
|
||||
}
|
||||
};
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useReturnOrderFields(): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
reference: {},
|
||||
description: {},
|
||||
customer: {
|
||||
filters: {
|
||||
is_customer: true,
|
||||
active: true
|
||||
}
|
||||
},
|
||||
customer_reference: {},
|
||||
project_code: {},
|
||||
order_currency: {},
|
||||
target_date: {},
|
||||
link: {},
|
||||
contact: {
|
||||
icon: <IconUser />,
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...value.filters,
|
||||
company: value.data.customer
|
||||
};
|
||||
}
|
||||
},
|
||||
address: {
|
||||
icon: <IconAddressBook />,
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
return {
|
||||
...value.filters,
|
||||
company: value.data.customer
|
||||
};
|
||||
}
|
||||
},
|
||||
responsible: {
|
||||
icon: <IconUsers />
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useStockFields({
|
||||
const fields: ApiFormFieldSet = {
|
||||
part: {
|
||||
value: part,
|
||||
hidden: !create,
|
||||
disabled: !create,
|
||||
onValueChange: (change) => {
|
||||
setPart(change);
|
||||
// TODO: implement remaining functionality from old stock.py
|
||||
@@ -57,12 +57,12 @@ export function useStockFields({
|
||||
supplier_detail: true,
|
||||
...(part ? { part } : {})
|
||||
},
|
||||
adjustFilters: (value: ApiFormAdjustFilterType) => {
|
||||
if (value.data.part) {
|
||||
value.filters['part'] = value.data.part;
|
||||
adjustFilters: (adjust: ApiFormAdjustFilterType) => {
|
||||
if (adjust.data.part) {
|
||||
adjust.filters['part'] = adjust.data.part;
|
||||
}
|
||||
|
||||
return value.filters;
|
||||
return adjust.filters;
|
||||
}
|
||||
},
|
||||
use_pack_size: {
|
||||
@@ -137,29 +137,6 @@ export function useCreateStockItem() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a form to edit an existing StockItem instance
|
||||
* @param item : primary key of the StockItem to edit
|
||||
*/
|
||||
export function useEditStockItem({
|
||||
item_id,
|
||||
callback
|
||||
}: {
|
||||
item_id: number;
|
||||
callback?: () => void;
|
||||
}) {
|
||||
const fields = useStockFields({ create: false });
|
||||
|
||||
return useEditApiFormModal({
|
||||
url: ApiEndpoints.stock_item_list,
|
||||
pk: item_id,
|
||||
fields: fields,
|
||||
title: t`Edit Stock Item`,
|
||||
successMessage: t`Stock item updated`,
|
||||
onFormSuccess: callback
|
||||
});
|
||||
}
|
||||
|
||||
function StockItemDefaultMove({
|
||||
stockItem,
|
||||
value
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import axios from 'axios';
|
||||
|
||||
import { api, setApiDefaults } from '../App';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
import { useSessionState } from '../states/SessionState';
|
||||
|
||||
const tokenName: string = 'inventree-web-app';
|
||||
import { fetchGlobalStates } from '../states/states';
|
||||
import { showLoginNotification } from './notifications';
|
||||
|
||||
/**
|
||||
* Attempt to login using username:password combination.
|
||||
@@ -24,26 +22,35 @@ export const doBasicLogin = async (username: string, password: string) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// At this stage, we can assume that we are not logged in, and we have no token
|
||||
useSessionState.getState().clearToken();
|
||||
clearCsrfCookie();
|
||||
|
||||
// Request new token from the server
|
||||
await axios
|
||||
.get(apiUrl(ApiEndpoints.user_token), {
|
||||
auth: { username, password },
|
||||
baseURL: host,
|
||||
timeout: 2000,
|
||||
params: {
|
||||
name: tokenName
|
||||
const login_url = apiUrl(ApiEndpoints.user_login);
|
||||
|
||||
// Attempt login with
|
||||
await api
|
||||
.post(
|
||||
login_url,
|
||||
{
|
||||
username: username,
|
||||
password: password
|
||||
},
|
||||
{
|
||||
baseURL: host
|
||||
}
|
||||
})
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status == 200 && response.data.token) {
|
||||
// A valid token has been returned - save, and login
|
||||
useSessionState.getState().setToken(response.data.token);
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
fetchGlobalStates();
|
||||
break;
|
||||
default:
|
||||
clearCsrfCookie();
|
||||
break;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
clearCsrfCookie();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -53,27 +60,15 @@ export const doBasicLogin = async (username: string, password: string) => {
|
||||
*/
|
||||
export const doLogout = async (navigate: any) => {
|
||||
// Logout from the server session
|
||||
await api.post(apiUrl(ApiEndpoints.user_logout)).catch(() => {
|
||||
// If an error occurs here, we are likely already logged out
|
||||
await api.post(apiUrl(ApiEndpoints.user_logout)).finally(() => {
|
||||
clearCsrfCookie();
|
||||
navigate('/login');
|
||||
return;
|
||||
|
||||
showLoginNotification({
|
||||
title: t`Logged Out`,
|
||||
message: t`Successfully logged out`
|
||||
});
|
||||
});
|
||||
|
||||
// Logout from this session
|
||||
// Note that clearToken() then calls setApiDefaults()
|
||||
clearCsrfCookie();
|
||||
useSessionState.getState().clearToken();
|
||||
|
||||
notifications.hide('login');
|
||||
notifications.show({
|
||||
id: 'login',
|
||||
title: t`Logout successful`,
|
||||
message: t`You have been logged out`,
|
||||
color: 'green',
|
||||
icon: <IconCheck size="1rem" />
|
||||
});
|
||||
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
export const doSimpleLogin = async (email: string) => {
|
||||
@@ -134,55 +129,33 @@ export function checkLoginState(
|
||||
) {
|
||||
setApiDefaults();
|
||||
|
||||
if (redirect == '/') {
|
||||
redirect = '/home';
|
||||
}
|
||||
|
||||
// Callback function when login is successful
|
||||
const loginSuccess = () => {
|
||||
notifications.hide('login');
|
||||
notifications.show({
|
||||
id: 'login',
|
||||
showLoginNotification({
|
||||
title: t`Logged In`,
|
||||
message: t`Found an existing login - welcome back!`,
|
||||
color: 'green',
|
||||
icon: <IconCheck size="1rem" />
|
||||
message: t`Successfully logged in`
|
||||
});
|
||||
|
||||
navigate(redirect ?? '/home');
|
||||
};
|
||||
|
||||
// Callback function when login fails
|
||||
const loginFailure = () => {
|
||||
useSessionState.getState().clearToken();
|
||||
if (!no_redirect) {
|
||||
navigate('/login', { state: { redirectFrom: redirect } });
|
||||
}
|
||||
};
|
||||
|
||||
if (useSessionState.getState().hasToken()) {
|
||||
// An existing token is available - check if it works
|
||||
// Check the 'user_me' endpoint to see if the user is logged in
|
||||
if (isLoggedIn()) {
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.user_me), {
|
||||
timeout: 2000
|
||||
})
|
||||
.then((val) => {
|
||||
if (val.status === 200) {
|
||||
// Success: we are logged in (and we already have a token)
|
||||
loginSuccess();
|
||||
} else {
|
||||
loginFailure();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
loginFailure();
|
||||
});
|
||||
} else if (getCsrfCookie()) {
|
||||
// Try to fetch a new token using the CSRF cookie
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.user_token), {
|
||||
params: {
|
||||
name: tokenName
|
||||
}
|
||||
})
|
||||
.get(apiUrl(ApiEndpoints.user_me))
|
||||
.then((response) => {
|
||||
if (response.status == 200 && response.data.token) {
|
||||
useSessionState.getState().setToken(response.data.token);
|
||||
if (response.status == 200) {
|
||||
loginSuccess();
|
||||
} else {
|
||||
loginFailure();
|
||||
@@ -192,7 +165,6 @@ export function checkLoginState(
|
||||
loginFailure();
|
||||
});
|
||||
} else {
|
||||
// No token, no cookie - redirect to login page
|
||||
loginFailure();
|
||||
}
|
||||
}
|
||||
@@ -209,8 +181,12 @@ export function getCsrfCookie() {
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!getCsrfCookie();
|
||||
}
|
||||
|
||||
/*
|
||||
* Clear out the CSRF cookie (force session logout)
|
||||
* Clear out the CSRF and session cookies (force session logout)
|
||||
*/
|
||||
export function clearCsrfCookie() {
|
||||
document.cookie =
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconBuilding,
|
||||
IconBuildingFactory2,
|
||||
IconBuildingStore,
|
||||
IconBusinessplan,
|
||||
IconCalendar,
|
||||
IconCalendarStats,
|
||||
IconCategory,
|
||||
@@ -100,6 +101,7 @@ const icons = {
|
||||
info: IconInfoCircle,
|
||||
details: IconInfoCircle,
|
||||
parameters: IconList,
|
||||
list: IconList,
|
||||
stock: IconPackages,
|
||||
variants: IconVersions,
|
||||
allocations: IconBookmarks,
|
||||
@@ -171,6 +173,7 @@ const icons = {
|
||||
customer: IconUser,
|
||||
quantity: IconNumbers,
|
||||
progress: IconProgressCheck,
|
||||
total_cost: IconBusinessplan,
|
||||
reference: IconHash,
|
||||
serial: IconHash,
|
||||
website: IconWorld,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* Show a notification that the feature is not yet implemented
|
||||
@@ -34,3 +35,28 @@ export function invalidResponse(returnCode: number) {
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Display a login / logout notification message.
|
||||
* Any existing login notification(s) will be hidden.
|
||||
*/
|
||||
export function showLoginNotification({
|
||||
title,
|
||||
message,
|
||||
success = true
|
||||
}: {
|
||||
title: string;
|
||||
message: string;
|
||||
success?: boolean;
|
||||
}) {
|
||||
notifications.hide('login');
|
||||
|
||||
notifications.show({
|
||||
title: title,
|
||||
message: message,
|
||||
color: success ? 'green' : 'red',
|
||||
icon: success ? <IconCircleCheck /> : <IconExclamationCircle />,
|
||||
id: 'login',
|
||||
autoClose: 5000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { ModelInformationDict } from '../components/render/ModelType';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { base_url } from '../main';
|
||||
|
||||
/**
|
||||
* Returns the detail view URL for a given model type
|
||||
*/
|
||||
export function getDetailUrl(model: ModelType, pk: number | string): string {
|
||||
export function getDetailUrl(
|
||||
model: ModelType,
|
||||
pk: number | string,
|
||||
absolute?: boolean
|
||||
): string {
|
||||
const modelInfo = ModelInformationDict[model];
|
||||
|
||||
if (pk === undefined || pk === null) {
|
||||
@@ -12,7 +17,14 @@ export function getDetailUrl(model: ModelType, pk: number | string): string {
|
||||
}
|
||||
|
||||
if (!!pk && modelInfo && modelInfo.url_detail) {
|
||||
return modelInfo.url_detail.replace(':pk', pk.toString());
|
||||
let url = modelInfo.url_detail.replace(':pk', pk.toString());
|
||||
let base = base_url;
|
||||
|
||||
if (absolute && base) {
|
||||
return `/${base}${url}`;
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`No detail URL found for model ${model} <${pk}>`);
|
||||
|
||||
+1090
-722
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user