mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' into add-changelog
This commit is contained in:
commit
692f5a32e0
@ -4,7 +4,11 @@ title: Internal Barcodes
|
||||
|
||||
## Internal Barcodes
|
||||
|
||||
InvenTree defines an internal format for generating barcodes for various items. This format uses a simple JSON-style string to uniquely identify an item in the database.
|
||||
InvenTree ships with two integrated internal formats for generating barcodes for various items which are available through the built-in InvenTree Barcode plugin. The used format can be selected through the plugin settings of the InvenTree Barcode plugin.
|
||||
|
||||
### 1. JSON-based QR Codes
|
||||
|
||||
This format uses a simple JSON-style string to uniquely identify an item in the database.
|
||||
|
||||
Some simple examples of this format are shown below:
|
||||
|
||||
@ -12,10 +16,49 @@ Some simple examples of this format are shown below:
|
||||
| --- | --- |
|
||||
| Part | `{% raw %}{"part": 10}{% endraw %}` |
|
||||
| Stock Item | `{% raw %}{"stockitem": 123}{% endraw %}` |
|
||||
| Stock Location | `{% raw %}{"stocklocation": 1}{% endraw %}` |
|
||||
| Supplier Part | `{% raw %}{"supplierpart": 99}{% endraw %}` |
|
||||
|
||||
The numerical ID value used is the *Primary Key* (PK) of the particular object in the database.
|
||||
|
||||
#### Downsides
|
||||
|
||||
1. The JSON format includes binary only characters (`{% raw %}{{% endraw %}` and `{% raw %}"{% endraw %}`) which requires unnecessary use of the binary QR code encoding which means fewer amount of chars can be encoded with the same version of QR code.
|
||||
2. The model name key has not a fixed length. Some model names are longer than others. E.g. a part QR code with the shortest possible id requires 11 chars, while a stock location QR code with the same id would already require 20 chars, which already requires QR code version 2 and quickly version 3.
|
||||
|
||||
!!! info "QR code versions"
|
||||
There are 40 different qr code versions from 1-40. They all can encode more data than the previous version, but require more "squares". E.g. a V1 QR codes has 21x21 "squares" while a V2 already has 25x25. For more information see [QR code comparison](https://www.qrcode.com/en/about/version.html).
|
||||
|
||||
For a more detailed size analysis of the JSON-based QR codes refer to [this issue](https://github.com/inventree/InvenTree/issues/6612).
|
||||
|
||||
### 2. Short alphanumeric QR Codes
|
||||
|
||||
While JSON-based QR Codes encode all necessary information, they come with the described downsides. This new, short, alphanumeric only format is build to improve those downsides. The basic format uses an alphanumeric string: `INV-??x`
|
||||
|
||||
- `INV-` is a constant prefix. This is configurable in the InvenTree Barcode plugins settings per instance to support environments that use multiple instances.
|
||||
- `??` is a two character alphanumeric (`0-9A-Z $%*+-./:` (45 chars)) code, individual to each model.
|
||||
- `x` the actual pk of the model.
|
||||
|
||||
Now with an overhead of 6 chars for every model, this format supports the following amount of model instances using the described QR code modes:
|
||||
|
||||
| QR code mode | Alphanumeric mode | Mixed mode |
|
||||
| --- | --- | --- |
|
||||
| v1 M ECL (15%) | `10**14` items (~3.170 items per sec for 1000 years) | `10**20` items (~3.170.979.198 items per sec for 1000 years) |
|
||||
| v1 Q ECL (25%) | `10**10` items (~0.317 items per sec for 1000 years) | `10**13` items (~317 items per sec for 1000 years) |
|
||||
| v1 H ECL (30%) | `10**4` items (~100 items per day for 100 days) | `10**3` items (~100 items per day for 10 days (*even worse*)) |
|
||||
|
||||
!!! info "QR code mixed mode"
|
||||
Normally the QR code data is encoded only in one format (binary, alphanumeric, numeric). But the data can also be split into multiple chunks using different formats. This is especially useful with long model ids, because the first 6 chars can be encoded using the alphanumeric mode and the id using the more efficient numeric mode. Mixed mode is used by default, because the `qrcode` template tag uses a default value for optimize of 1.
|
||||
|
||||
Some simple examples of this format are shown below:
|
||||
|
||||
| Model Type | Example Barcode |
|
||||
| --- | --- |
|
||||
| Part | `INV-PA10` |
|
||||
| Stock Item | `INV-SI123` |
|
||||
| Stock Location | `INV-SL1` |
|
||||
| Supplier Part | `INV-SP99` |
|
||||
|
||||
## Report Integration
|
||||
|
||||
This barcode format can be used to generate 1D or 2D barcodes (e.g. for [labels and reports](../report/barcodes.md))
|
||||
|
@ -4,7 +4,7 @@ title: Barcode Mixin
|
||||
|
||||
## Barcode Plugins
|
||||
|
||||
InvenTree supports decoding of arbitrary barcode data via a **Barcode Plugin** interface. Barcode data POSTed to the `/api/barcode/` endpoint will be supplied to all loaded barcode plugins, and the first plugin to successfully interpret the barcode data will return a response to the client.
|
||||
InvenTree supports decoding of arbitrary barcode data and generation of internal barcode formats via a **Barcode Plugin** interface. Barcode data POSTed to the `/api/barcode/` endpoint will be supplied to all loaded barcode plugins, and the first plugin to successfully interpret the barcode data will return a response to the client.
|
||||
|
||||
InvenTree can generate native QR codes to represent database objects (e.g. a single StockItem). This barcode can then be used to perform quick lookup of a stock item or location in the database. A client application (for example the InvenTree mobile app) scans a barcode, and sends the barcode data to the InvenTree server. The server then uses the **InvenTreeBarcodePlugin** (found at `src/backend/InvenTree/plugin/builtin/barcodes/inventree_barcode.py`) to decode the supplied barcode data.
|
||||
|
||||
@ -26,7 +26,7 @@ POST {
|
||||
|
||||
### Builtin Plugin
|
||||
|
||||
The InvenTree server includes a builtin barcode plugin which can decode QR codes generated by the server. This plugin is enabled by default.
|
||||
The InvenTree server includes a builtin barcode plugin which can generate and decode the QR codes. This plugin is enabled by default.
|
||||
|
||||
::: plugin.builtin.barcodes.inventree_barcode.InvenTreeInternalBarcodePlugin
|
||||
options:
|
||||
@ -39,14 +39,12 @@ The InvenTree server includes a builtin barcode plugin which can decode QR codes
|
||||
|
||||
### Example Plugin
|
||||
|
||||
Please find below a very simple example that is executed each time a barcode is scanned.
|
||||
Please find below a very simple example that is used to return a part if the barcode starts with `PART-`
|
||||
|
||||
```python
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from InvenTree.models import InvenTreeBarcodeMixin
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import BarcodeMixin
|
||||
from part.models import Part
|
||||
|
||||
class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
|
||||
@ -56,16 +54,39 @@ class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
VERSION = "0.0.1"
|
||||
AUTHOR = "Michael"
|
||||
|
||||
status = 0
|
||||
|
||||
def scan(self, barcode_data):
|
||||
if barcode_data.startswith("PART-"):
|
||||
try:
|
||||
pk = int(barcode_data.split("PART-")[1])
|
||||
instance = Part.objects.get(pk=pk)
|
||||
label = Part.barcode_model_type()
|
||||
|
||||
self.status = self.status+1
|
||||
print('Started barcode plugin', self.status)
|
||||
print(barcode_data)
|
||||
response = {}
|
||||
return response
|
||||
|
||||
return {label: instance.format_matched_response()}
|
||||
except Part.DoesNotExist:
|
||||
pass
|
||||
```
|
||||
|
||||
To try it just copy the file to src/InvenTree/plugins and restart the server. Open the scan barcode window and start to scan codes or type in text manually. Each time the timeout is hit the plugin will execute and printout the result. The timeout can be changed in `Settings->Barcode Support->Barcode Input Delay`.
|
||||
|
||||
### Custom Internal Format
|
||||
|
||||
To implement a custom internal barcode format, the `generate(...)` method from the Barcode Mixin needs to be overridden. Then the plugin can be selected at `System Settings > Barcodes > Barcode Generation Plugin`.
|
||||
|
||||
```python
|
||||
from InvenTree.models import InvenTreeBarcodeMixin
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import BarcodeMixin
|
||||
|
||||
class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
NAME = "MyInternalBarcode"
|
||||
TITLE = "My Internal Barcodes"
|
||||
DESCRIPTION = "support for custom internal barcodes"
|
||||
VERSION = "0.0.1"
|
||||
AUTHOR = "InvenTree contributors"
|
||||
|
||||
def generate(self, model_instance: InvenTreeBarcodeMixin):
|
||||
return f'{model_instance.barcode_model_type()}: {model_instance.pk}'
|
||||
```
|
||||
|
||||
!!! info "Scanning implementation required"
|
||||
The parsing of the custom format needs to be implemented too, so that the scanning of the generated QR codes resolves to the correct part.
|
||||
|
@ -1,12 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 225
|
||||
INVENTREE_API_VERSION = 226
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v226 - 2024-07-15 : https://github.com/inventree/InvenTree/pull/7648
|
||||
- Adds barcode generation API endpoint
|
||||
|
||||
v225 - 2024-07-17 : https://github.com/inventree/InvenTree/pull/7671
|
||||
- Adds "filters" field to DataImportSession API
|
||||
|
||||
|
@ -396,38 +396,6 @@ def WrapWithQuotes(text, quote='"'):
|
||||
return text
|
||||
|
||||
|
||||
def MakeBarcode(cls_name, object_pk: int, object_data=None, **kwargs):
|
||||
"""Generate a string for a barcode. Adds some global InvenTree parameters.
|
||||
|
||||
Args:
|
||||
cls_name: string describing the object type e.g. 'StockItem'
|
||||
object_pk (int): ID (Primary Key) of the object in the database
|
||||
object_data: Python dict object containing extra data which will be rendered to string (must only contain stringable values)
|
||||
|
||||
Returns:
|
||||
json string of the supplied data plus some other data
|
||||
"""
|
||||
if object_data is None:
|
||||
object_data = {}
|
||||
|
||||
brief = kwargs.get('brief', True)
|
||||
|
||||
data = {}
|
||||
|
||||
if brief:
|
||||
data[cls_name] = object_pk
|
||||
else:
|
||||
data['tool'] = 'InvenTree'
|
||||
data['version'] = InvenTree.version.inventreeVersion()
|
||||
data['instance'] = InvenTree.version.inventreeInstanceName()
|
||||
|
||||
# Ensure PK is included
|
||||
object_data['id'] = object_pk
|
||||
data[cls_name] = object_data
|
||||
|
||||
return str(json.dumps(data, sort_keys=True))
|
||||
|
||||
|
||||
def GetExportFormats():
|
||||
"""Return a list of allowable file formats for importing or exporting tabular data."""
|
||||
return ['csv', 'xlsx', 'tsv', 'json']
|
||||
|
@ -15,9 +15,6 @@ from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
from PIL import Image
|
||||
|
||||
import InvenTree
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.version
|
||||
from common.notifications import (
|
||||
InvenTreeNotificationBodies,
|
||||
NotificationBody,
|
||||
@ -331,9 +328,7 @@ def notify_users(
|
||||
'instance': instance,
|
||||
'name': content.name.format(**content_context),
|
||||
'message': content.message.format(**content_context),
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||
instance.get_absolute_url()
|
||||
),
|
||||
'link': construct_absolute_url(instance.get_absolute_url()),
|
||||
'template': {'subject': content.name.format(**content_context)},
|
||||
}
|
||||
|
||||
|
@ -3,9 +3,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
@ -934,6 +932,8 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
|
||||
- barcode_data : Raw data associated with an assigned barcode
|
||||
- barcode_hash : A 'hash' of the assigned barcode data used to improve matching
|
||||
|
||||
The barcode_model_type_code() classmethod must be implemented in the model class.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@ -964,11 +964,25 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
# By default, use the name of the class
|
||||
return cls.__name__.lower()
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
r"""Return a 'short' code for the model type.
|
||||
|
||||
This is used to generate a efficient QR code for the model type.
|
||||
It is expected to match this pattern: [0-9A-Z $%*+-.\/:]{2}
|
||||
|
||||
Note: Due to the shape constrains (45**2=2025 different allowed codes)
|
||||
this needs to be explicitly implemented in the model class to avoid collisions.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
'barcode_model_type_code() must be implemented in the model class'
|
||||
)
|
||||
|
||||
def format_barcode(self, **kwargs):
|
||||
"""Return a JSON string for formatting a QR code for this model instance."""
|
||||
return InvenTree.helpers.MakeBarcode(
|
||||
self.__class__.barcode_model_type(), self.pk, **kwargs
|
||||
)
|
||||
from plugin.base.barcodes.helper import generate_barcode
|
||||
|
||||
return generate_barcode(self)
|
||||
|
||||
def format_matched_response(self):
|
||||
"""Format a standard response for a matched barcode."""
|
||||
@ -986,7 +1000,7 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
@property
|
||||
def barcode(self):
|
||||
"""Format a minimal barcode string (e.g. for label printing)."""
|
||||
return self.format_barcode(brief=True)
|
||||
return self.format_barcode()
|
||||
|
||||
@classmethod
|
||||
def lookup_barcode(cls, barcode_hash):
|
||||
|
@ -62,6 +62,7 @@ class APITests(InvenTreeAPITestCase):
|
||||
"""Tests for the InvenTree API."""
|
||||
|
||||
fixtures = ['location', 'category', 'part', 'stock']
|
||||
roles = ['part.view']
|
||||
token = None
|
||||
auto_login = False
|
||||
|
||||
@ -132,6 +133,7 @@ class APITests(InvenTreeAPITestCase):
|
||||
|
||||
# Now log in!
|
||||
self.basicAuth()
|
||||
self.assignRole('part.view')
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
@ -147,12 +149,17 @@ class APITests(InvenTreeAPITestCase):
|
||||
|
||||
role_names = roles.keys()
|
||||
|
||||
# By default, 'view' permissions are provided
|
||||
# By default, no permissions are provided
|
||||
for rule in RuleSet.RULESET_NAMES:
|
||||
self.assertIn(rule, role_names)
|
||||
|
||||
self.assertIn('view', roles[rule])
|
||||
if roles[rule] is None:
|
||||
continue
|
||||
|
||||
if rule == 'part':
|
||||
self.assertIn('view', roles[rule])
|
||||
else:
|
||||
self.assertNotIn('view', roles[rule])
|
||||
self.assertNotIn('add', roles[rule])
|
||||
self.assertNotIn('change', roles[rule])
|
||||
self.assertNotIn('delete', roles[rule])
|
||||
@ -297,6 +304,7 @@ class SearchTests(InvenTreeAPITestCase):
|
||||
'order',
|
||||
'sales_order',
|
||||
]
|
||||
roles = ['build.view', 'part.view']
|
||||
|
||||
def test_empty(self):
|
||||
"""Test empty request."""
|
||||
@ -331,6 +339,19 @@ class SearchTests(InvenTreeAPITestCase):
|
||||
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
|
||||
expected_code=200,
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data['purchaseorder'],
|
||||
{'error': 'User does not have permission to view this model'},
|
||||
)
|
||||
|
||||
# Add permissions and try again
|
||||
self.assignRole('purchase_order.view')
|
||||
self.assignRole('sales_order.view')
|
||||
response = self.post(
|
||||
reverse('api-search'),
|
||||
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['purchaseorder']['count'], 1)
|
||||
self.assertEqual(response.data['salesorder']['count'], 0)
|
||||
|
@ -71,6 +71,7 @@ class MiddlewareTests(InvenTreeTestCase):
|
||||
|
||||
def test_error_exceptions(self):
|
||||
"""Test that ignored errors are not logged."""
|
||||
self.assignRole('part.view')
|
||||
|
||||
def check(excpected_nbr=0):
|
||||
# Check that errors are empty
|
||||
|
@ -789,33 +789,6 @@ class TestIncrement(TestCase):
|
||||
self.assertEqual(result, b)
|
||||
|
||||
|
||||
class TestMakeBarcode(TestCase):
|
||||
"""Tests for barcode string creation."""
|
||||
|
||||
def test_barcode_extended(self):
|
||||
"""Test creation of barcode with extended data."""
|
||||
bc = helpers.MakeBarcode(
|
||||
'part', 3, {'id': 3, 'url': 'www.google.com'}, brief=False
|
||||
)
|
||||
|
||||
self.assertIn('part', bc)
|
||||
self.assertIn('tool', bc)
|
||||
self.assertIn('"tool": "InvenTree"', bc)
|
||||
|
||||
data = json.loads(bc)
|
||||
|
||||
self.assertEqual(data['part']['id'], 3)
|
||||
self.assertEqual(data['part']['url'], 'www.google.com')
|
||||
|
||||
def test_barcode_brief(self):
|
||||
"""Test creation of simple barcode."""
|
||||
bc = helpers.MakeBarcode('stockitem', 7)
|
||||
|
||||
data = json.loads(bc)
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data['stockitem'], 7)
|
||||
|
||||
|
||||
class TestDownloadFile(TestCase):
|
||||
"""Tests for DownloadFile."""
|
||||
|
||||
|
@ -204,6 +204,7 @@ class UserMixin:
|
||||
ruleset.can_add = True
|
||||
|
||||
ruleset.save()
|
||||
if not assign_all:
|
||||
break
|
||||
|
||||
|
||||
|
@ -115,6 +115,11 @@ class Build(
|
||||
|
||||
return defaults
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return "BO"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Custom save method for the BuildOrder model"""
|
||||
self.validate_reference_field(self.reference)
|
||||
|
@ -277,7 +277,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Build Order QR Code" escape %}',
|
||||
'{"build": {{ build.pk }} }'
|
||||
'{{ build.barcode }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -9,12 +9,13 @@ import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import timedelta, timezone
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
from secrets import compare_digest
|
||||
from typing import Any, Callable, Collection, TypedDict, Union
|
||||
from typing import Any, Callable, TypedDict, Union
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings as django_settings
|
||||
@ -49,6 +50,7 @@ import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import InvenTree.validators
|
||||
import order.validators
|
||||
import plugin.base.barcodes.helper
|
||||
import report.helpers
|
||||
import users.models
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
@ -56,6 +58,17 @@ from plugin import registry
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import NotRequired
|
||||
else:
|
||||
|
||||
class NotRequired: # pragma: no cover
|
||||
"""NotRequired type helper is only supported with Python 3.11+."""
|
||||
|
||||
def __class_getitem__(cls, item):
|
||||
"""Return the item."""
|
||||
return item
|
||||
|
||||
|
||||
class MetaMixin(models.Model):
|
||||
"""A base class for InvenTree models to include shared meta fields.
|
||||
@ -1167,7 +1180,7 @@ class InvenTreeSettingsKeyType(SettingsKeyType):
|
||||
requires_restart: If True, a server restart is required after changing the setting
|
||||
"""
|
||||
|
||||
requires_restart: bool
|
||||
requires_restart: NotRequired[bool]
|
||||
|
||||
|
||||
class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
@ -1402,6 +1415,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'BARCODE_GENERATION_PLUGIN': {
|
||||
'name': _('Barcode Generation Plugin'),
|
||||
'description': _('Plugin to use for internal barcode data generation'),
|
||||
'choices': plugin.base.barcodes.helper.barcode_plugins,
|
||||
'default': 'inventreebarcode',
|
||||
},
|
||||
'PART_ENABLE_REVISION': {
|
||||
'name': _('Part Revisions'),
|
||||
'description': _('Enable revision field for Part'),
|
||||
@ -2589,14 +2608,22 @@ class ColorTheme(models.Model):
|
||||
@classmethod
|
||||
def get_color_themes_choices(cls):
|
||||
"""Get all color themes from static folder."""
|
||||
if not django_settings.STATIC_COLOR_THEMES_DIR.exists():
|
||||
logger.error('Theme directory does not exist')
|
||||
color_theme_dir = (
|
||||
django_settings.STATIC_COLOR_THEMES_DIR
|
||||
if django_settings.STATIC_COLOR_THEMES_DIR.exists()
|
||||
else django_settings.BASE_DIR.joinpath(
|
||||
'InvenTree', 'static', 'css', 'color-themes'
|
||||
)
|
||||
)
|
||||
|
||||
if not color_theme_dir.exists():
|
||||
logger.error(f'Theme directory "{color_theme_dir}" does not exist')
|
||||
return []
|
||||
|
||||
# Get files list from css/color-themes/ folder
|
||||
files_list = []
|
||||
|
||||
for file in django_settings.STATIC_COLOR_THEMES_DIR.iterdir():
|
||||
for file in color_theme_dir.iterdir():
|
||||
files_list.append([file.stem, file.suffix])
|
||||
|
||||
# Get color themes choices (CSS sheets)
|
||||
|
@ -1199,20 +1199,10 @@ class ColorThemeTest(TestCase):
|
||||
def test_choices(self):
|
||||
"""Test that default choices are returned."""
|
||||
result = ColorTheme.get_color_themes_choices()
|
||||
|
||||
# skip due to directories not being set up
|
||||
if not result:
|
||||
return # pragma: no cover
|
||||
self.assertIn(('default', 'Default'), result)
|
||||
|
||||
def test_valid_choice(self):
|
||||
"""Check that is_valid_choice works correctly."""
|
||||
result = ColorTheme.get_color_themes_choices()
|
||||
|
||||
# skip due to directories not being set up
|
||||
if not result:
|
||||
return # pragma: no cover
|
||||
|
||||
# check wrong reference
|
||||
self.assertFalse(ColorTheme.is_valid_choice('abcdd'))
|
||||
|
||||
|
@ -475,6 +475,11 @@ class ManufacturerPart(
|
||||
"""Return the API URL associated with the ManufacturerPart instance."""
|
||||
return reverse('api-manufacturer-part-list')
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'MP'
|
||||
|
||||
part = models.ForeignKey(
|
||||
'part.Part',
|
||||
on_delete=models.CASCADE,
|
||||
@ -678,6 +683,11 @@ class SupplierPart(
|
||||
"""Return custom API filters for this particular instance."""
|
||||
return {'manufacturer_part': {'part': self.part.pk}}
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'SP'
|
||||
|
||||
def clean(self):
|
||||
"""Custom clean action for the SupplierPart model.
|
||||
|
||||
|
@ -303,7 +303,7 @@ onPanelLoad('supplier-part-notes', function() {
|
||||
$("#show-qr-code").click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Supplier Part QR Code" escape %}',
|
||||
'{"supplierpart": {{ part.pk }} }'
|
||||
'{{ part.barcode }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -160,7 +160,7 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
class ContactTest(InvenTreeAPITestCase):
|
||||
"""Tests for the Contact models."""
|
||||
|
||||
roles = []
|
||||
roles = ['purchase_order.view']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -266,7 +266,7 @@ class ContactTest(InvenTreeAPITestCase):
|
||||
class AddressTest(InvenTreeAPITestCase):
|
||||
"""Test cases for Address API endpoints."""
|
||||
|
||||
roles = []
|
||||
roles = ['purchase_order.view']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
@ -408,6 +408,11 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
|
||||
return defaults
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'PO'
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by 'minimum and maximum date range'.
|
||||
@ -880,6 +885,11 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
|
||||
return defaults
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'SO'
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by "minimum and maximum date range".
|
||||
@ -2044,6 +2054,11 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
|
||||
return defaults
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'RO'
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of this ReturnOrder."""
|
||||
return f"{self.reference} - {self.customer.name if self.customer else _('no customer')}"
|
||||
|
@ -312,7 +312,7 @@ $("#export-order").click(function() {
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Purchase Order QR Code" escape %}',
|
||||
'{"purchaseorder": {{ order.pk }} }'
|
||||
'{{ order.barcode }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -257,7 +257,7 @@ $('#print-order-report').click(function() {
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Return Order QR Code" escape %}',
|
||||
'{"returnorder": {{ order.pk }} }'
|
||||
'{{ order.barcode }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -319,7 +319,7 @@ $('#print-order-report').click(function() {
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Sales Order QR Code" escape %}',
|
||||
'{"salesorder": {{ order.pk }} }'
|
||||
'{{ order.barcode }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -2010,6 +2010,7 @@ class ReturnOrderTests(InvenTreeAPITestCase):
|
||||
'supplier_part',
|
||||
'stock',
|
||||
]
|
||||
roles = ['return_order.view']
|
||||
|
||||
def test_options(self):
|
||||
"""Test the OPTIONS endpoint."""
|
||||
|
@ -416,6 +416,11 @@ class Part(
|
||||
"""Return API query filters for limiting field results against this instance."""
|
||||
return {'variant_of': {'exclude_tree': self.pk}}
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'PA'
|
||||
|
||||
def report_context(self):
|
||||
"""Return custom report context information."""
|
||||
return {
|
||||
@ -426,11 +431,11 @@ class Part(
|
||||
'name': self.name,
|
||||
'parameters': self.parameters_map(),
|
||||
'part': self,
|
||||
'qr_data': self.format_barcode(brief=True),
|
||||
'qr_data': self.barcode,
|
||||
'qr_url': self.get_absolute_url(),
|
||||
'revision': self.revision,
|
||||
'test_template_list': self.getTestTemplates(),
|
||||
'test_templates': self.getTestTemplates(),
|
||||
'test_templates': self.getTestTemplateMap(),
|
||||
}
|
||||
|
||||
def get_context_data(self, request, **kwargs):
|
||||
|
@ -451,7 +451,7 @@
|
||||
$("#show-qr-code").click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Part QR Code" escape %}',
|
||||
'{"part": {{ part.pk }} }',
|
||||
'{{ part.barcode }}',
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -512,7 +512,7 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
Ensure that the required field details are provided!
|
||||
"""
|
||||
|
||||
roles = ['part.add']
|
||||
roles = ['part.add', 'part_category.view']
|
||||
|
||||
def test_part(self):
|
||||
"""Test the Part API OPTIONS."""
|
||||
@ -2149,7 +2149,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
|
||||
fixtures = ['category', 'part', 'location', 'stock', 'bom', 'company']
|
||||
|
||||
roles = ['part.add', 'part.change', 'part.delete']
|
||||
roles = ['part.add', 'part.change', 'part.delete', 'stock.view']
|
||||
|
||||
def setUp(self):
|
||||
"""Set up the test case."""
|
||||
@ -2642,6 +2642,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):
|
||||
|
||||
superuser = False
|
||||
is_staff = False
|
||||
roles = ['stocktake.view']
|
||||
|
||||
fixtures = ['category', 'part', 'location', 'stock']
|
||||
|
||||
|
@ -169,7 +169,7 @@ class PartTest(TestCase):
|
||||
self.assertEqual(Part.barcode_model_type(), 'part')
|
||||
|
||||
p = Part.objects.get(pk=1)
|
||||
barcode = p.format_barcode(brief=True)
|
||||
barcode = p.format_barcode()
|
||||
self.assertEqual(barcode, '{"part": 1}')
|
||||
|
||||
def test_tree(self):
|
||||
@ -270,9 +270,8 @@ class PartTest(TestCase):
|
||||
|
||||
def test_barcode(self):
|
||||
"""Test barcode format functionality."""
|
||||
barcode = self.r1.format_barcode(brief=False)
|
||||
self.assertIn('InvenTree', barcode)
|
||||
self.assertIn('"part": {"id": 3}', barcode)
|
||||
barcode = self.r1.format_barcode()
|
||||
self.assertEqual('{"part": 3}', barcode)
|
||||
|
||||
def test_sell_pricing(self):
|
||||
"""Check that the sell pricebreaks were loaded."""
|
||||
|
@ -6,16 +6,17 @@ from django.db.models import F
|
||||
from django.urls import path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import permissions
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
import order.models
|
||||
import plugin.base.barcodes.helper
|
||||
import stock.models
|
||||
from InvenTree.helpers import hash_barcode
|
||||
from plugin import registry
|
||||
from plugin.builtin.barcodes.inventree_barcode import InvenTreeInternalBarcodePlugin
|
||||
from users.models import RuleSet
|
||||
|
||||
from . import serializers as barcode_serializers
|
||||
@ -129,6 +130,48 @@ class BarcodeScan(BarcodeView):
|
||||
return Response(result)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(responses={200: barcode_serializers.BarcodeSerializer})
|
||||
)
|
||||
class BarcodeGenerate(CreateAPIView):
|
||||
"""Endpoint for generating a barcode for a database object.
|
||||
|
||||
The barcode is generated by the selected barcode plugin.
|
||||
"""
|
||||
|
||||
serializer_class = barcode_serializers.BarcodeGenerateSerializer
|
||||
|
||||
def queryset(self):
|
||||
"""This API view does not have a queryset."""
|
||||
return None
|
||||
|
||||
# Default permission classes (can be overridden)
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Perform the barcode generation action."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
model = serializer.validated_data.get('model')
|
||||
pk = serializer.validated_data.get('pk')
|
||||
model_cls = plugin.base.barcodes.helper.get_supported_barcode_models_map().get(
|
||||
model, None
|
||||
)
|
||||
|
||||
if model_cls is None:
|
||||
raise ValidationError({'error': _('Model is not supported')})
|
||||
|
||||
try:
|
||||
model_instance = model_cls.objects.get(pk=pk)
|
||||
except model_cls.DoesNotExist:
|
||||
raise ValidationError({'error': _('Model instance not found')})
|
||||
|
||||
barcode_data = plugin.base.barcodes.helper.generate_barcode(model_instance)
|
||||
|
||||
return Response({'barcode': barcode_data}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class BarcodeAssign(BarcodeView):
|
||||
"""Endpoint for assigning a barcode to a stock item.
|
||||
|
||||
@ -161,7 +204,7 @@ class BarcodeAssign(BarcodeView):
|
||||
|
||||
valid_labels = []
|
||||
|
||||
for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models():
|
||||
for model in plugin.base.barcodes.helper.get_supported_barcode_models():
|
||||
label = model.barcode_model_type()
|
||||
valid_labels.append(label)
|
||||
|
||||
@ -203,7 +246,7 @@ class BarcodeUnassign(BarcodeView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
supported_models = InvenTreeInternalBarcodePlugin.get_supported_barcode_models()
|
||||
supported_models = plugin.base.barcodes.helper.get_supported_barcode_models()
|
||||
|
||||
supported_labels = [model.barcode_model_type() for model in supported_models]
|
||||
model_names = ', '.join(supported_labels)
|
||||
@ -567,6 +610,8 @@ class BarcodeSOAllocate(BarcodeView):
|
||||
|
||||
|
||||
barcode_api_urls = [
|
||||
# Generate a barcode for a database object
|
||||
path('generate/', BarcodeGenerate.as_view(), name='api-barcode-generate'),
|
||||
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)
|
||||
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
|
||||
# Unlink a third-party barcode from an item
|
||||
|
79
src/backend/InvenTree/plugin/base/barcodes/helper.py
Normal file
79
src/backend/InvenTree/plugin/base/barcodes/helper.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""Helper functions for barcode generation."""
|
||||
|
||||
import logging
|
||||
from typing import Type, cast
|
||||
|
||||
import InvenTree.helpers_model
|
||||
from InvenTree.models import InvenTreeBarcodeMixin
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def cache(func):
|
||||
"""Cache the result of a function, but do not cache falsy results."""
|
||||
cache = {}
|
||||
|
||||
def wrapper():
|
||||
"""Wrapper function for caching."""
|
||||
if 'default' not in cache:
|
||||
res = func()
|
||||
|
||||
if res:
|
||||
cache['default'] = res
|
||||
|
||||
return res
|
||||
|
||||
return cache['default']
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def barcode_plugins() -> list:
|
||||
"""Return a list of plugin choices which can be used for barcode generation."""
|
||||
try:
|
||||
from plugin import registry
|
||||
|
||||
plugins = registry.with_mixin('barcode', active=True)
|
||||
except Exception:
|
||||
plugins = []
|
||||
|
||||
return [
|
||||
(plug.slug, plug.human_name) for plug in plugins if plug.has_barcode_generation
|
||||
]
|
||||
|
||||
|
||||
def generate_barcode(model_instance: InvenTreeBarcodeMixin):
|
||||
"""Generate a barcode for a given model instance."""
|
||||
from common.settings import get_global_setting
|
||||
from plugin import registry
|
||||
from plugin.mixins import BarcodeMixin
|
||||
|
||||
# Find the selected barcode generation plugin
|
||||
slug = get_global_setting('BARCODE_GENERATION_PLUGIN', create=False)
|
||||
|
||||
plugin = cast(BarcodeMixin, registry.get_plugin(slug))
|
||||
|
||||
return plugin.generate(model_instance)
|
||||
|
||||
|
||||
@cache
|
||||
def get_supported_barcode_models() -> list[Type[InvenTreeBarcodeMixin]]:
|
||||
"""Returns a list of database models which support barcode functionality."""
|
||||
return InvenTree.helpers_model.getModelsWithMixin(InvenTreeBarcodeMixin)
|
||||
|
||||
|
||||
@cache
|
||||
def get_supported_barcode_models_map():
|
||||
"""Return a mapping of barcode model types to the model class."""
|
||||
return {
|
||||
model.barcode_model_type(): model for model in get_supported_barcode_models()
|
||||
}
|
||||
|
||||
|
||||
@cache
|
||||
def get_supported_barcode_model_codes_map():
|
||||
"""Return a mapping of barcode model type codes to the model class."""
|
||||
return {
|
||||
model.barcode_model_type_code(): model
|
||||
for model in get_supported_barcode_models()
|
||||
}
|
@ -10,6 +10,7 @@ from django.db.models import F, Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from company.models import Company, SupplierPart
|
||||
from InvenTree.models import InvenTreeBarcodeMixin
|
||||
from order.models import PurchaseOrder, PurchaseOrderStatus
|
||||
from plugin.base.integration.SettingsMixin import SettingsMixin
|
||||
from stock.models import StockLocation
|
||||
@ -53,6 +54,30 @@ class BarcodeMixin:
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def has_barcode_generation(self):
|
||||
"""Does this plugin support barcode generation."""
|
||||
try:
|
||||
# Attempt to call the generate method
|
||||
self.generate(None) # type: ignore
|
||||
except NotImplementedError:
|
||||
# If a NotImplementedError is raised, then barcode generation is not supported
|
||||
return False
|
||||
except:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def generate(self, model_instance: InvenTreeBarcodeMixin):
|
||||
"""Generate barcode data for the given model instance.
|
||||
|
||||
Arguments:
|
||||
model_instance: The model instance to generate barcode data for. It is extending the InvenTreeBarcodeMixin.
|
||||
|
||||
Returns: The generated barcode data.
|
||||
"""
|
||||
raise NotImplementedError('Generate must be implemented by a plugin')
|
||||
|
||||
|
||||
class SupplierBarcodeMixin(BarcodeMixin):
|
||||
"""Mixin that provides default implementations for scan functions for supplier barcodes.
|
||||
|
@ -6,9 +6,9 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
import order.models
|
||||
import plugin.base.barcodes.helper
|
||||
import stock.models
|
||||
from order.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||
from plugin.builtin.barcodes.inventree_barcode import InvenTreeInternalBarcodePlugin
|
||||
|
||||
|
||||
class BarcodeSerializer(serializers.Serializer):
|
||||
@ -23,6 +23,30 @@ class BarcodeSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class BarcodeGenerateSerializer(serializers.Serializer):
|
||||
"""Serializer for generating a barcode."""
|
||||
|
||||
model = serializers.CharField(
|
||||
required=True, help_text=_('Model name to generate barcode for')
|
||||
)
|
||||
|
||||
pk = serializers.IntegerField(
|
||||
required=True,
|
||||
help_text=_('Primary key of model object to generate barcode for'),
|
||||
)
|
||||
|
||||
def validate_model(self, model: str):
|
||||
"""Validate the provided model."""
|
||||
supported_models = (
|
||||
plugin.base.barcodes.helper.get_supported_barcode_models_map()
|
||||
)
|
||||
|
||||
if model not in supported_models.keys():
|
||||
raise ValidationError(_('Model is not supported'))
|
||||
|
||||
return model
|
||||
|
||||
|
||||
class BarcodeAssignMixin(serializers.Serializer):
|
||||
"""Serializer for linking and unlinking barcode to an internal class."""
|
||||
|
||||
@ -30,7 +54,7 @@ class BarcodeAssignMixin(serializers.Serializer):
|
||||
"""Generate serializer fields for each supported model type."""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models():
|
||||
for model in plugin.base.barcodes.helper.get_supported_barcode_models():
|
||||
self.fields[model.barcode_model_type()] = (
|
||||
serializers.PrimaryKeyRelatedField(
|
||||
queryset=model.objects.all(),
|
||||
@ -45,7 +69,7 @@ class BarcodeAssignMixin(serializers.Serializer):
|
||||
"""Return a list of model fields."""
|
||||
fields = [
|
||||
model.barcode_model_type()
|
||||
for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models()
|
||||
for model in plugin.base.barcodes.helper.get_supported_barcode_models()
|
||||
]
|
||||
|
||||
return fields
|
||||
|
@ -21,6 +21,7 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
super().setUp()
|
||||
|
||||
self.scan_url = reverse('api-barcode-scan')
|
||||
self.generate_url = reverse('api-barcode-generate')
|
||||
self.assign_url = reverse('api-barcode-link')
|
||||
self.unassign_url = reverse('api-barcode-unlink')
|
||||
|
||||
@ -30,6 +31,14 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
url, data={'barcode': str(barcode)}, expected_code=expected_code
|
||||
)
|
||||
|
||||
def generateBarcode(self, model: str, pk: int, expected_code: int):
|
||||
"""Post barcode generation and return barcode contents."""
|
||||
return self.post(
|
||||
self.generate_url,
|
||||
data={'model': model, 'pk': pk},
|
||||
expected_code=expected_code,
|
||||
)
|
||||
|
||||
def test_invalid(self):
|
||||
"""Test that invalid requests fail."""
|
||||
# test scan url
|
||||
@ -130,7 +139,7 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
data = response.data
|
||||
self.assertIn('error', data)
|
||||
|
||||
def test_barcode_generation(self):
|
||||
def test_barcode_scan(self):
|
||||
"""Test that a barcode is generated with a scan."""
|
||||
item = StockItem.objects.get(pk=522)
|
||||
|
||||
@ -145,6 +154,18 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(pk, item.pk)
|
||||
|
||||
def test_barcode_generation(self):
|
||||
"""Test that a barcode can be generated for a StockItem."""
|
||||
item = StockItem.objects.get(pk=522)
|
||||
|
||||
data = self.generateBarcode('stockitem', item.pk, expected_code=200).data
|
||||
self.assertEqual(data['barcode'], '{"stockitem": 522}')
|
||||
|
||||
def test_barcode_generation_invalid(self):
|
||||
"""Test barcode generation for invalid model/pk."""
|
||||
self.generateBarcode('invalidmodel', 1, expected_code=400)
|
||||
self.generateBarcode('stockitem', 99999999, expected_code=400)
|
||||
|
||||
def test_association(self):
|
||||
"""Test that a barcode can be associated with a StockItem."""
|
||||
item = StockItem.objects.get(pk=522)
|
||||
|
@ -8,29 +8,45 @@ references model objects actually exist in the database.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import cast
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import plugin.base.barcodes.helper
|
||||
from InvenTree.helpers import hash_barcode
|
||||
from InvenTree.helpers_model import getModelsWithMixin
|
||||
from InvenTree.models import InvenTreeBarcodeMixin
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import BarcodeMixin
|
||||
from plugin.mixins import BarcodeMixin, SettingsMixin
|
||||
|
||||
|
||||
class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
class InvenTreeInternalBarcodePlugin(SettingsMixin, BarcodeMixin, InvenTreePlugin):
|
||||
"""Builtin BarcodePlugin for matching and generating internal barcodes."""
|
||||
|
||||
NAME = 'InvenTreeBarcode'
|
||||
TITLE = _('InvenTree Barcodes')
|
||||
DESCRIPTION = _('Provides native support for barcodes')
|
||||
VERSION = '2.0.0'
|
||||
VERSION = '2.1.0'
|
||||
AUTHOR = _('InvenTree contributors')
|
||||
|
||||
@staticmethod
|
||||
def get_supported_barcode_models():
|
||||
"""Returns a list of database models which support barcode functionality."""
|
||||
return getModelsWithMixin(InvenTreeBarcodeMixin)
|
||||
SETTINGS = {
|
||||
'INTERNAL_BARCODE_FORMAT': {
|
||||
'name': _('Internal Barcode Format'),
|
||||
'description': _('Select an internal barcode format'),
|
||||
'choices': [
|
||||
('json', _('JSON barcodes (human readable)')),
|
||||
('short', _('Short barcodes (space optimized)')),
|
||||
],
|
||||
'default': 'json',
|
||||
},
|
||||
'SHORT_BARCODE_PREFIX': {
|
||||
'name': _('Short Barcode Prefix'),
|
||||
'description': _(
|
||||
'Customize the prefix used for short barcodes, may be useful for environments with multiple InvenTree instances'
|
||||
),
|
||||
'default': 'INV-',
|
||||
},
|
||||
}
|
||||
|
||||
def format_matched_response(self, label, model, instance):
|
||||
"""Format a response for the scanned data."""
|
||||
@ -41,8 +57,35 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
|
||||
Here we are looking for a dict object which contains a reference to a particular InvenTree database object
|
||||
"""
|
||||
# Internal Barcodes - Short Format
|
||||
# Attempt to match the barcode data against the short barcode format
|
||||
prefix = cast(str, self.get_setting('SHORT_BARCODE_PREFIX'))
|
||||
if type(barcode_data) is str and (
|
||||
m := re.match(
|
||||
f'^{re.escape(prefix)}([0-9A-Z $%*+-.\\/:]{"{2}"})(\\d+)$', barcode_data
|
||||
)
|
||||
):
|
||||
model_type_code, pk = m.groups()
|
||||
|
||||
supported_models_map = (
|
||||
plugin.base.barcodes.helper.get_supported_barcode_model_codes_map()
|
||||
)
|
||||
model = supported_models_map.get(model_type_code, None)
|
||||
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
label = model.barcode_model_type()
|
||||
|
||||
try:
|
||||
instance = model.objects.get(pk=int(pk))
|
||||
return self.format_matched_response(label, model, instance)
|
||||
except (ValueError, model.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Internal Barcodes - JSON Format
|
||||
# Attempt to coerce the barcode data into a dict object
|
||||
# This is the internal barcode representation that InvenTree uses
|
||||
# This is the internal JSON barcode representation that InvenTree uses
|
||||
barcode_dict = None
|
||||
|
||||
if type(barcode_data) is dict:
|
||||
@ -53,7 +96,7 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
supported_models = self.get_supported_barcode_models()
|
||||
supported_models = plugin.base.barcodes.helper.get_supported_barcode_models()
|
||||
|
||||
if barcode_dict is not None and type(barcode_dict) is dict:
|
||||
# Look for various matches. First good match will be returned
|
||||
@ -68,6 +111,7 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
except (ValueError, model.DoesNotExist):
|
||||
pass
|
||||
|
||||
# External Barcodes (Linked barcodes)
|
||||
# Create hash from raw barcode data
|
||||
barcode_hash = hash_barcode(barcode_data)
|
||||
|
||||
@ -79,3 +123,18 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
|
||||
if instance is not None:
|
||||
return self.format_matched_response(label, model, instance)
|
||||
|
||||
def generate(self, model_instance: InvenTreeBarcodeMixin):
|
||||
"""Generate a barcode for a given model instance."""
|
||||
barcode_format = self.get_setting('INTERNAL_BARCODE_FORMAT')
|
||||
|
||||
if barcode_format == 'json':
|
||||
return json.dumps({model_instance.barcode_model_type(): model_instance.pk})
|
||||
|
||||
if barcode_format == 'short':
|
||||
prefix = self.get_setting('SHORT_BARCODE_PREFIX')
|
||||
model_type_code = model_instance.barcode_model_type_code()
|
||||
|
||||
return f'{prefix}{model_type_code}{model_instance.pk}'
|
||||
|
||||
return None
|
||||
|
@ -52,6 +52,21 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
||||
reverse('api-barcode-scan'), data=data, expected_code=expected_code
|
||||
)
|
||||
|
||||
def generate(self, model: str, pk: int, expected_code: int):
|
||||
"""Generate a barcode for a given model instance."""
|
||||
return self.post(
|
||||
reverse('api-barcode-generate'),
|
||||
data={'model': model, 'pk': pk},
|
||||
expected_code=expected_code,
|
||||
)
|
||||
|
||||
def set_plugin_setting(self, key: str, value: str):
|
||||
"""Set the internal barcode format for the plugin."""
|
||||
from plugin import registry
|
||||
|
||||
plugin = registry.get_plugin('inventreebarcode')
|
||||
plugin.set_setting(key, value)
|
||||
|
||||
def test_unassign_errors(self):
|
||||
"""Test various error conditions for the barcode unassign endpoint."""
|
||||
# Fail without any fields provided
|
||||
@ -248,8 +263,8 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
||||
self.assertIn('success', response.data)
|
||||
self.assertEqual(response.data['stockitem']['pk'], 1)
|
||||
|
||||
def test_scan_inventree(self):
|
||||
"""Test scanning of first-party barcodes."""
|
||||
def test_scan_inventree_json(self):
|
||||
"""Test scanning of first-party json barcodes."""
|
||||
# Scan a StockItem object (which does not exist)
|
||||
response = self.scan({'barcode': '{"stockitem": 5}'}, expected_code=400)
|
||||
|
||||
@ -290,3 +305,73 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
||||
self.assertIn('success', response.data)
|
||||
self.assertIn('barcode_data', response.data)
|
||||
self.assertIn('barcode_hash', response.data)
|
||||
|
||||
def test_scan_inventree_short(self):
|
||||
"""Test scanning of first-party short barcodes."""
|
||||
# Scan a StockItem object (which does not exist)
|
||||
response = self.scan({'barcode': 'INV-SI5'}, expected_code=400)
|
||||
|
||||
self.assertIn('No match found for barcode data', str(response.data))
|
||||
|
||||
# Scan a StockItem object (which does exist)
|
||||
response = self.scan({'barcode': 'INV-SI1'}, expected_code=200)
|
||||
|
||||
self.assertIn('success', response.data)
|
||||
self.assertIn('stockitem', response.data)
|
||||
self.assertEqual(response.data['stockitem']['pk'], 1)
|
||||
|
||||
# Scan a StockLocation object
|
||||
response = self.scan({'barcode': 'INV-SL5'}, expected_code=200)
|
||||
|
||||
self.assertIn('success', response.data)
|
||||
self.assertEqual(response.data['stocklocation']['pk'], 5)
|
||||
self.assertEqual(
|
||||
response.data['stocklocation']['api_url'], '/api/stock/location/5/'
|
||||
)
|
||||
if settings.ENABLE_CLASSIC_FRONTEND:
|
||||
self.assertEqual(
|
||||
response.data['stocklocation']['web_url'], '/stock/location/5/'
|
||||
)
|
||||
self.assertEqual(response.data['plugin'], 'InvenTreeBarcode')
|
||||
|
||||
# Scan a Part object
|
||||
response = self.scan({'barcode': 'INV-PA5'}, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['part']['pk'], 5)
|
||||
|
||||
# Scan a SupplierPart instance with custom prefix
|
||||
for prefix in ['TEST', '']:
|
||||
self.set_plugin_setting('SHORT_BARCODE_PREFIX', prefix)
|
||||
response = self.scan({'barcode': f'{prefix}SP1'}, expected_code=200)
|
||||
self.assertEqual(response.data['supplierpart']['pk'], 1)
|
||||
self.assertEqual(response.data['plugin'], 'InvenTreeBarcode')
|
||||
self.assertIn('success', response.data)
|
||||
self.assertIn('barcode_data', response.data)
|
||||
self.assertIn('barcode_hash', response.data)
|
||||
|
||||
self.set_plugin_setting('SHORT_BARCODE_PREFIX', 'INV-')
|
||||
|
||||
def test_generation_inventree_json(self):
|
||||
"""Test JSON barcode generation."""
|
||||
item = stock.models.StockLocation.objects.get(pk=5)
|
||||
data = self.generate('stocklocation', item.pk, expected_code=200).data
|
||||
self.assertEqual(data['barcode'], '{"stocklocation": 5}')
|
||||
|
||||
def test_generation_inventree_short(self):
|
||||
"""Test short barcode generation."""
|
||||
self.set_plugin_setting('INTERNAL_BARCODE_FORMAT', 'short')
|
||||
|
||||
item = stock.models.StockLocation.objects.get(pk=5)
|
||||
|
||||
# test with default prefix
|
||||
data = self.generate('stocklocation', item.pk, expected_code=200).data
|
||||
self.assertEqual(data['barcode'], 'INV-SL5')
|
||||
|
||||
# test generation with custom prefix
|
||||
for prefix in ['TEST', '']:
|
||||
self.set_plugin_setting('SHORT_BARCODE_PREFIX', prefix)
|
||||
data = self.generate('stocklocation', item.pk, expected_code=200).data
|
||||
self.assertEqual(data['barcode'], f'{prefix}SL5')
|
||||
|
||||
self.set_plugin_setting('SHORT_BARCODE_PREFIX', 'INV-')
|
||||
self.set_plugin_setting('INTERNAL_BARCODE_FORMAT', 'json')
|
||||
|
@ -3,12 +3,20 @@
|
||||
from django import template
|
||||
|
||||
import barcode as python_barcode
|
||||
import qrcode as python_qrcode
|
||||
import qrcode.constants as ECL
|
||||
from qrcode.main import QRCode
|
||||
|
||||
import report.helpers
|
||||
|
||||
register = template.Library()
|
||||
|
||||
QR_ECL_LEVEL_MAP = {
|
||||
'L': ECL.ERROR_CORRECT_L,
|
||||
'M': ECL.ERROR_CORRECT_M,
|
||||
'Q': ECL.ERROR_CORRECT_Q,
|
||||
'H': ECL.ERROR_CORRECT_H,
|
||||
}
|
||||
|
||||
|
||||
def image_data(img, fmt='PNG'):
|
||||
"""Convert an image into HTML renderable data.
|
||||
@ -22,36 +30,44 @@ def image_data(img, fmt='PNG'):
|
||||
def qrcode(data, **kwargs):
|
||||
"""Return a byte-encoded QR code image.
|
||||
|
||||
kwargs:
|
||||
fill_color: Fill color (default = black)
|
||||
back_color: Background color (default = white)
|
||||
version: Default = 1
|
||||
box_size: Default = 20
|
||||
border: Default = 1
|
||||
Arguments:
|
||||
data: Data to encode
|
||||
|
||||
Keyword Arguments:
|
||||
version: QR code version, (None to auto detect) (default = None)
|
||||
error_correction: Error correction level (L: 7%, M: 15%, Q: 25%, H: 30%) (default = 'M')
|
||||
box_size: pixel dimensions for one black square pixel in the QR code (default = 20)
|
||||
border: count white QR square pixels around the qr code, needed as padding (default = 1)
|
||||
optimize: data will be split into multiple chunks of at least this length using different modes (text, alphanumeric, binary) to optimize the QR code size. Set to `0` to disable. (default = 1)
|
||||
format: Image format (default = 'PNG')
|
||||
fill_color: Fill color (default = "black")
|
||||
back_color: Background color (default = "white")
|
||||
|
||||
Returns:
|
||||
base64 encoded image data
|
||||
|
||||
"""
|
||||
# Construct "default" values
|
||||
params = {'box_size': 20, 'border': 1, 'version': 1}
|
||||
|
||||
# Extract other arguments from kwargs
|
||||
fill_color = kwargs.pop('fill_color', 'black')
|
||||
back_color = kwargs.pop('back_color', 'white')
|
||||
image_format = kwargs.pop('format', 'PNG')
|
||||
optimize = kwargs.pop('optimize', 1)
|
||||
|
||||
img_format = kwargs.pop('format', 'PNG')
|
||||
|
||||
params.update(**kwargs)
|
||||
|
||||
qr = python_qrcode.QRCode(**params)
|
||||
|
||||
qr.add_data(data, optimize=20)
|
||||
qr.make(fit=True)
|
||||
# Construct QR code object
|
||||
qr = QRCode(**{
|
||||
'box_size': 20,
|
||||
'border': 1,
|
||||
'version': None,
|
||||
**kwargs,
|
||||
'error_correction': QR_ECL_LEVEL_MAP[kwargs.get('error_correction', 'M')],
|
||||
})
|
||||
qr.add_data(data, optimize=optimize)
|
||||
qr.make(fit=False) # if version is None, it will automatically use fit=True
|
||||
|
||||
qri = qr.make_image(fill_color=fill_color, back_color=back_color)
|
||||
|
||||
# Render to byte-encoded image
|
||||
return image_data(qri, fmt=img_format)
|
||||
return image_data(qri, fmt=image_format)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
|
@ -142,11 +142,16 @@ class StockLocation(
|
||||
"""Return API url."""
|
||||
return reverse('api-location-list')
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'SL'
|
||||
|
||||
def report_context(self):
|
||||
"""Return report context data for this StockLocation."""
|
||||
return {
|
||||
'location': self,
|
||||
'qr_data': self.format_barcode(brief=True),
|
||||
'qr_data': self.barcode,
|
||||
'parent': self.parent,
|
||||
'stock_location': self,
|
||||
'stock_items': self.get_stock_items(),
|
||||
@ -367,6 +372,11 @@ class StockItem(
|
||||
"""Custom API instance filters."""
|
||||
return {'parent': {'exclude_tree': self.pk}}
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'SI'
|
||||
|
||||
def get_test_keys(self, include_installed=True):
|
||||
"""Construct a flattened list of test 'keys' for this StockItem."""
|
||||
keys = []
|
||||
@ -397,7 +407,7 @@ class StockItem(
|
||||
'item': self,
|
||||
'name': self.part.full_name,
|
||||
'part': self.part,
|
||||
'qr_data': self.format_barcode(brief=True),
|
||||
'qr_data': self.barcode,
|
||||
'qr_url': self.get_absolute_url(),
|
||||
'parameters': self.part.parameters_map(),
|
||||
'quantity': InvenTree.helpers.normalize(self.quantity),
|
||||
|
@ -534,7 +534,7 @@ $('#stock-edit-status').click(function () {
|
||||
$("#show-qr-code").click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Stock Item QR Code" escape %}',
|
||||
'{"stockitem": {{ item.pk }} }',
|
||||
'{{ item.barcode }}',
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -392,7 +392,7 @@
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Stock Location QR Code" escape %}',
|
||||
'{"stocklocation": {{ location.pk }} }'
|
||||
'{{ location.barcode }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -952,12 +952,6 @@ class StockBarcodeTest(StockTestBase):
|
||||
|
||||
self.assertEqual(StockItem.barcode_model_type(), 'stockitem')
|
||||
|
||||
# Call format_barcode method
|
||||
barcode = item.format_barcode(brief=False)
|
||||
|
||||
for key in ['tool', 'version', 'instance', 'stockitem']:
|
||||
self.assertIn(key, barcode)
|
||||
|
||||
# Render simple barcode data for the StockItem
|
||||
barcode = item.barcode
|
||||
self.assertEqual(barcode, '{"stockitem": 1}')
|
||||
@ -968,7 +962,7 @@ class StockBarcodeTest(StockTestBase):
|
||||
|
||||
loc = StockLocation.objects.get(pk=1)
|
||||
|
||||
barcode = loc.format_barcode(brief=True)
|
||||
barcode = loc.format_barcode()
|
||||
self.assertEqual('{"stocklocation": 1}', barcode)
|
||||
|
||||
|
||||
|
@ -16,6 +16,7 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="BARCODE_INPUT_DELAY" icon="fa-hourglass-half" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="BARCODE_WEBCAM_SUPPORT" icon="fa-video" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="BARCODE_SHOW_TEXT" icon="fa-closed-captioning" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="BARCODE_GENERATION_PLUGIN" icon="fa-qrcode" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
@ -162,7 +162,24 @@ class UserList(ListCreateAPI):
|
||||
filterset_fields = ['is_staff', 'is_active', 'is_superuser']
|
||||
|
||||
|
||||
class GroupDetail(RetrieveUpdateDestroyAPI):
|
||||
class GroupMixin:
|
||||
"""Mixin for Group API endpoints to add permissions filter."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return serializer instance for this endpoint."""
|
||||
# Do we wish to include extra detail?
|
||||
try:
|
||||
params = self.request.query_params
|
||||
kwargs['permission_detail'] = InvenTree.helpers.str2bool(
|
||||
params.get('permission_detail', None)
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
|
||||
class GroupDetail(GroupMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for a particular auth group."""
|
||||
|
||||
queryset = Group.objects.all()
|
||||
@ -170,7 +187,7 @@ class GroupDetail(RetrieveUpdateDestroyAPI):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class GroupList(ListCreateAPI):
|
||||
class GroupList(GroupMixin, ListCreateAPI):
|
||||
"""List endpoint for all auth groups."""
|
||||
|
||||
queryset = Group.objects.all()
|
||||
|
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.12 on 2024-07-18 21:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("users", "0011_auto_20240523_1640"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="ruleset",
|
||||
name="can_view",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Permission to view items", verbose_name="View"
|
||||
),
|
||||
),
|
||||
]
|
@ -389,7 +389,7 @@ class RuleSet(models.Model):
|
||||
)
|
||||
|
||||
can_view = models.BooleanField(
|
||||
verbose_name=_('View'), default=True, help_text=_('Permission to view items')
|
||||
verbose_name=_('View'), default=False, help_text=_('Permission to view items')
|
||||
)
|
||||
|
||||
can_add = models.BooleanField(
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""DRF API serializers for the 'users' app."""
|
||||
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.db.models import Q
|
||||
|
||||
from rest_framework import serializers
|
||||
@ -31,7 +32,25 @@ class GroupSerializer(InvenTreeModelSerializer):
|
||||
"""Metaclass defines serializer fields."""
|
||||
|
||||
model = Group
|
||||
fields = ['pk', 'name']
|
||||
fields = ['pk', 'name', 'permissions']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize this serializer with extra fields as required."""
|
||||
permission_detail = kwargs.pop('permission_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
try:
|
||||
if not permission_detail:
|
||||
self.fields.pop('permissions', None)
|
||||
except AppRegistryNotReady:
|
||||
pass
|
||||
|
||||
permissions = serializers.SerializerMethodField()
|
||||
|
||||
def get_permissions(self, group: Group):
|
||||
"""Return a list of permissions associated with the group."""
|
||||
return generate_permission_dict(group.permissions.all())
|
||||
|
||||
|
||||
class RoleSerializer(InvenTreeModelSerializer):
|
||||
@ -83,6 +102,11 @@ class RoleSerializer(InvenTreeModelSerializer):
|
||||
Q(user=user) | Q(group__user=user)
|
||||
).distinct()
|
||||
|
||||
return generate_permission_dict(permissions)
|
||||
|
||||
|
||||
def generate_permission_dict(permissions):
|
||||
"""Generate a dictionary of permissions for a given set of permissions."""
|
||||
perms = {}
|
||||
|
||||
for permission in permissions:
|
||||
|
@ -123,8 +123,8 @@ class RuleSetModelTest(TestCase):
|
||||
for model in models:
|
||||
permission_set.add(model)
|
||||
|
||||
# Every ruleset by default sets one permission, the "view" permission set
|
||||
self.assertEqual(group.permissions.count(), len(permission_set))
|
||||
# By default no permissions should be assigned
|
||||
self.assertEqual(group.permissions.count(), 0)
|
||||
|
||||
# Add some more rules
|
||||
for rule in rulesets:
|
||||
|
@ -38,7 +38,6 @@
|
||||
"@mantine/spotlight": "^7.11.0",
|
||||
"@mantine/vanilla-extract": "^7.11.0",
|
||||
"@mdxeditor/editor": "^3.6.1",
|
||||
"@naisutech/react-tree": "^3.1.0",
|
||||
"@sentry/react": "^8.13.0",
|
||||
"@tabler/icons-react": "^3.7.0",
|
||||
"@tanstack/react-query": "^5.49.2",
|
||||
@ -52,6 +51,7 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"embla-carousel-react": "^8.1.6",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"qrcode": "^1.5.3",
|
||||
"mantine-datatable": "^7.11.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@ -72,6 +72,7 @@
|
||||
"@lingui/macro": "^4.11.1",
|
||||
"@playwright/test": "^1.45.0",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
|
@ -11,7 +11,7 @@ export function ButtonMenu({
|
||||
label = ''
|
||||
}: {
|
||||
icon: any;
|
||||
actions: any[];
|
||||
actions: React.ReactNode[];
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
|
@ -1,6 +1,13 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button, CopyButton as MantineCopyButton } from '@mantine/core';
|
||||
import { IconCopy } from '@tabler/icons-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
CopyButton as MantineCopyButton,
|
||||
Text,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
|
||||
import { InvenTreeIcon } from '../../functions/icons';
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
@ -9,24 +16,27 @@ export function CopyButton({
|
||||
value: any;
|
||||
label?: JSX.Element;
|
||||
}) {
|
||||
const ButtonComponent = label ? Button : ActionIcon;
|
||||
|
||||
return (
|
||||
<MantineCopyButton value={value}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
<Tooltip label={copied ? t`Copied` : t`Copy`} withArrow>
|
||||
<ButtonComponent
|
||||
color={copied ? 'teal' : 'gray'}
|
||||
onClick={copy}
|
||||
title={t`Copy to clipboard`}
|
||||
variant="subtle"
|
||||
size="compact-md"
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
>
|
||||
<IconCopy size={10} />
|
||||
{label && (
|
||||
<>
|
||||
<div> </div>
|
||||
{label}
|
||||
</>
|
||||
{copied ? (
|
||||
<InvenTreeIcon icon="check" />
|
||||
) : (
|
||||
<InvenTreeIcon icon="copy" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{label && <Text ml={10}>{label}</Text>}
|
||||
</ButtonComponent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</MantineCopyButton>
|
||||
);
|
||||
|
@ -1,15 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
CopyButton,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { getValueAtPath } from 'mantine-datatable';
|
||||
@ -24,6 +21,7 @@ import { navigateToLink } from '../../functions/navigation';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { CopyButton } from '../buttons/CopyButton';
|
||||
import { YesNoButton } from '../buttons/YesNoButton';
|
||||
import { ProgressBar } from '../items/ProgressBar';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
@ -325,26 +323,7 @@ function StatusValue(props: Readonly<FieldProps>) {
|
||||
}
|
||||
|
||||
function CopyField({ value }: { value: string }) {
|
||||
return (
|
||||
<CopyButton value={value}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? t`Copied` : t`Copy`} withArrow>
|
||||
<ActionIcon
|
||||
color={copied ? 'teal' : 'gray'}
|
||||
onClick={copy}
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
>
|
||||
{copied ? (
|
||||
<InvenTreeIcon icon="check" />
|
||||
) : (
|
||||
<InvenTreeIcon icon="copy" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
);
|
||||
return <CopyButton value={value} />;
|
||||
}
|
||||
|
||||
export function DetailsTableField({
|
||||
|
@ -25,12 +25,18 @@ const tags: Tag[] = [
|
||||
description: 'Generate a QR code image',
|
||||
args: ['data'],
|
||||
kwargs: {
|
||||
fill_color: 'Fill color (default = black)',
|
||||
back_color: 'Background color (default = white)',
|
||||
version: 'Version (default = 1)',
|
||||
box_size: 'Box size (default = 20)',
|
||||
border: 'Border width (default = 1)',
|
||||
format: 'Format (default = PNG)'
|
||||
version: 'QR code version, (None to auto detect) (default = None)',
|
||||
error_correction:
|
||||
"Error correction level (L: 7%, M: 15%, Q: 25%, H: 30%) (default = 'Q')",
|
||||
box_size:
|
||||
'pixel dimensions for one black square pixel in the QR code (default = 20)',
|
||||
border:
|
||||
'count white QR square pixels around the qr code, needed as padding (default = 1)',
|
||||
optimize:
|
||||
'data will be split into multiple chunks of at least this length using different modes (text, alphanumeric, binary) to optimize the QR code size. Set to `0` to disable. (default = 1)',
|
||||
format: "Image format (default = 'PNG')",
|
||||
fill_color: 'Fill color (default = "black")',
|
||||
back_color: 'Background color (default = "white")'
|
||||
},
|
||||
returns: 'base64 encoded qr code image data'
|
||||
},
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
Menu,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import {
|
||||
IconCopy,
|
||||
IconEdit,
|
||||
@ -16,9 +17,11 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { identifierString } from '../../functions/conversion';
|
||||
import { InvenTreeIcon } from '../../functions/icons';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
import { InvenTreeQRCode } from './QRCode';
|
||||
|
||||
export type ActionDropdownItem = {
|
||||
icon: ReactNode;
|
||||
@ -128,11 +131,20 @@ export function BarcodeActionDropdown({
|
||||
// Common action button for viewing a barcode
|
||||
export function ViewBarcodeAction({
|
||||
hidden = false,
|
||||
onClick
|
||||
model,
|
||||
pk
|
||||
}: {
|
||||
hidden?: boolean;
|
||||
onClick?: () => void;
|
||||
model: ModelType;
|
||||
pk: number;
|
||||
}): ActionDropdownItem {
|
||||
const onClick = () => {
|
||||
modals.open({
|
||||
title: t`View Barcode`,
|
||||
children: <InvenTreeQRCode model={model} pk={pk} />
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
icon: <IconQrcode />,
|
||||
name: t`View`,
|
||||
|
130
src/frontend/src/components/items/QRCode.tsx
Normal file
130
src/frontend/src/components/items/QRCode.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
Box,
|
||||
Code,
|
||||
Group,
|
||||
Image,
|
||||
Select,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import QR from 'qrcode';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { CopyButton } from '../buttons/CopyButton';
|
||||
|
||||
type QRCodeProps = {
|
||||
ecl?: 'L' | 'M' | 'Q' | 'H';
|
||||
margin?: number;
|
||||
data?: string;
|
||||
};
|
||||
|
||||
export const QRCode = ({ data, ecl = 'Q', margin = 1 }: QRCodeProps) => {
|
||||
const [qrCode, setQRCode] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return setQRCode(undefined);
|
||||
|
||||
QR.toString(data, { errorCorrectionLevel: ecl, type: 'svg', margin }).then(
|
||||
(svg) => {
|
||||
setQRCode(`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`);
|
||||
}
|
||||
);
|
||||
}, [data, ecl]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{qrCode ? (
|
||||
<Image src={qrCode} alt="QR Code" />
|
||||
) : (
|
||||
<Skeleton height={500} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
type InvenTreeQRCodeProps = {
|
||||
model: ModelType;
|
||||
pk: number;
|
||||
showEclSelector?: boolean;
|
||||
} & Omit<QRCodeProps, 'data'>;
|
||||
|
||||
export const InvenTreeQRCode = ({
|
||||
showEclSelector = true,
|
||||
model,
|
||||
pk,
|
||||
ecl: eclProp = 'Q',
|
||||
...props
|
||||
}: InvenTreeQRCodeProps) => {
|
||||
const settings = useGlobalSettingsState();
|
||||
const [ecl, setEcl] = useState(eclProp);
|
||||
|
||||
useEffect(() => {
|
||||
if (eclProp) setEcl(eclProp);
|
||||
}, [eclProp]);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['qr-code', model, pk],
|
||||
queryFn: async () => {
|
||||
const res = await api.post(apiUrl(ApiEndpoints.generate_barcode), {
|
||||
model,
|
||||
pk
|
||||
});
|
||||
|
||||
return res.data?.barcode as string;
|
||||
}
|
||||
});
|
||||
|
||||
const eclOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'L', label: t`Low (7%)` },
|
||||
{ value: 'M', label: t`Medium (15%)` },
|
||||
{ value: 'Q', label: t`Quartile (25%)` },
|
||||
{ value: 'H', label: t`High (30%)` }
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<QRCode data={data} ecl={ecl} {...props} />
|
||||
|
||||
{data && settings.getSetting('BARCODE_SHOW_TEXT', 'false') && (
|
||||
<Group
|
||||
justify={showEclSelector ? 'space-between' : 'center'}
|
||||
align="flex-start"
|
||||
px={16}
|
||||
>
|
||||
<Stack gap={4} pt={2}>
|
||||
<Text size="sm" fw={500}>
|
||||
<Trans>Barcode Data:</Trans>
|
||||
</Text>
|
||||
<Group>
|
||||
<Code>{data}</Code>
|
||||
<CopyButton value={data} />
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{showEclSelector && (
|
||||
<Select
|
||||
allowDeselect={false}
|
||||
label={t`Select Error Correction Level`}
|
||||
value={ecl}
|
||||
onChange={(v) =>
|
||||
setEcl(v as Exclude<QRCodeProps['ecl'], undefined>)
|
||||
}
|
||||
data={eclOptions}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
@ -131,6 +131,7 @@ function BasePanelGroup({
|
||||
label={panel.label}
|
||||
key={panel.name}
|
||||
disabled={expanded}
|
||||
position="right"
|
||||
>
|
||||
<Tabs.Tab
|
||||
p="xs"
|
||||
|
@ -37,7 +37,7 @@ import {
|
||||
RenderStockLocation,
|
||||
RenderStockLocationType
|
||||
} from './Stock';
|
||||
import { RenderOwner, RenderUser } from './User';
|
||||
import { RenderGroup, RenderOwner, RenderUser } from './User';
|
||||
|
||||
type EnumDictionary<T extends string | symbol | number, U> = {
|
||||
[K in T]: U;
|
||||
@ -81,6 +81,7 @@ const RendererLookup: EnumDictionary<
|
||||
[ModelType.stockhistory]: RenderStockItem,
|
||||
[ModelType.supplierpart]: RenderSupplierPart,
|
||||
[ModelType.user]: RenderUser,
|
||||
[ModelType.group]: RenderGroup,
|
||||
[ModelType.importsession]: RenderImportSession,
|
||||
[ModelType.reporttemplate]: RenderReportTemplate,
|
||||
[ModelType.labeltemplate]: RenderLabelTemplate,
|
||||
|
@ -201,6 +201,14 @@ export const ModelInformationDict: ModelDict = {
|
||||
url_detail: '/user/:pk/',
|
||||
api_endpoint: ApiEndpoints.user_list
|
||||
},
|
||||
group: {
|
||||
label: t`Group`,
|
||||
label_multiple: t`Groups`,
|
||||
url_overview: '/user/group',
|
||||
url_detail: '/user/group-:pk',
|
||||
api_endpoint: ApiEndpoints.group_list,
|
||||
admin_url: '/auth/group/'
|
||||
},
|
||||
importsession: {
|
||||
label: t`Import Session`,
|
||||
label_multiple: t`Import Sessions`,
|
||||
|
@ -28,3 +28,9 @@ export function RenderUser({
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderGroup({
|
||||
instance
|
||||
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||
return instance && <RenderInlineModel primary={instance.name} />;
|
||||
}
|
||||
|
@ -38,6 +38,7 @@ export enum ApiEndpoints {
|
||||
settings_global_list = 'settings/global/',
|
||||
settings_user_list = 'settings/user/',
|
||||
barcode = 'barcode/',
|
||||
generate_barcode = 'barcode/generate/',
|
||||
news = 'news/',
|
||||
global_status = 'generic/status/',
|
||||
version = 'version/',
|
||||
|
@ -27,6 +27,7 @@ export enum ModelType {
|
||||
contact = 'contact',
|
||||
owner = 'owner',
|
||||
user = 'user',
|
||||
group = 'group',
|
||||
reporttemplate = 'reporttemplate',
|
||||
labeltemplate = 'labeltemplate',
|
||||
pluginconfig = 'pluginconfig'
|
||||
|
@ -98,7 +98,8 @@ export default function SystemSettings() {
|
||||
'BARCODE_ENABLE',
|
||||
'BARCODE_INPUT_DELAY',
|
||||
'BARCODE_WEBCAM_SUPPORT',
|
||||
'BARCODE_SHOW_TEXT'
|
||||
'BARCODE_SHOW_TEXT',
|
||||
'BARCODE_GENERATION_PLUGIN'
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
@ -370,7 +370,10 @@ export default function BuildDetail() {
|
||||
tooltip={t`Barcode Actions`}
|
||||
icon={<IconQrcode />}
|
||||
actions={[
|
||||
ViewBarcodeAction({}),
|
||||
ViewBarcodeAction({
|
||||
model: ModelType.build,
|
||||
pk: build.pk
|
||||
}),
|
||||
LinkBarcodeAction({
|
||||
hidden: build?.barcode_hash
|
||||
}),
|
||||
|
@ -894,7 +894,10 @@ export default function PartDetail() {
|
||||
<AdminButton model={ModelType.part} pk={part.pk} />,
|
||||
<BarcodeActionDropdown
|
||||
actions={[
|
||||
ViewBarcodeAction({}),
|
||||
ViewBarcodeAction({
|
||||
model: ModelType.part,
|
||||
pk: part.pk
|
||||
}),
|
||||
LinkBarcodeAction({
|
||||
hidden: part?.barcode_hash || !user.hasChangeRole(UserRoles.part)
|
||||
}),
|
||||
|
@ -292,7 +292,10 @@ export default function PurchaseOrderDetail() {
|
||||
<AdminButton model={ModelType.purchaseorder} pk={order.pk} />,
|
||||
<BarcodeActionDropdown
|
||||
actions={[
|
||||
ViewBarcodeAction({}),
|
||||
ViewBarcodeAction({
|
||||
model: ModelType.purchaseorder,
|
||||
pk: order.pk
|
||||
}),
|
||||
LinkBarcodeAction({
|
||||
hidden: order?.barcode_hash
|
||||
}),
|
||||
|
@ -276,9 +276,13 @@ export default function Stock() {
|
||||
variant="outline"
|
||||
size="lg"
|
||||
/>,
|
||||
location.pk ? (
|
||||
<BarcodeActionDropdown
|
||||
actions={[
|
||||
ViewBarcodeAction({}),
|
||||
ViewBarcodeAction({
|
||||
model: ModelType.stocklocation,
|
||||
pk: location.pk
|
||||
}),
|
||||
LinkBarcodeAction({}),
|
||||
UnlinkBarcodeAction({}),
|
||||
{
|
||||
@ -292,7 +296,8 @@ export default function Stock() {
|
||||
tooltip: 'Scan container'
|
||||
}
|
||||
]}
|
||||
/>,
|
||||
/>
|
||||
) : null,
|
||||
<PrintingActions
|
||||
modelType={ModelType.stocklocation}
|
||||
items={[location.pk ?? 0]}
|
||||
|
@ -428,7 +428,10 @@ export default function StockDetail() {
|
||||
<AdminButton model={ModelType.stockitem} pk={stockitem.pk} />,
|
||||
<BarcodeActionDropdown
|
||||
actions={[
|
||||
ViewBarcodeAction({}),
|
||||
ViewBarcodeAction({
|
||||
model: ModelType.stockitem,
|
||||
pk: stockitem.pk
|
||||
}),
|
||||
LinkBarcodeAction({
|
||||
hidden:
|
||||
stockitem?.barcode_hash || !user.hasChangeRole(UserRoles.stock)
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { DataTableSortStatus } from 'mantine-datatable';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
@ -28,6 +29,9 @@ interface LocalStateProps {
|
||||
setTableColumnNames: (
|
||||
tableKey: string
|
||||
) => (names: Record<string, string>) => void;
|
||||
tableSorting: Record<string, any>;
|
||||
getTableSorting: (tableKey: string) => DataTableSortStatus;
|
||||
setTableSorting: (tableKey: string) => (sorting: DataTableSortStatus) => void;
|
||||
clearTableColumnNames: () => void;
|
||||
detailDrawerStack: number;
|
||||
addDetailDrawer: (value: number | false) => void;
|
||||
@ -87,6 +91,19 @@ export const useLocalState = create<LocalStateProps>()(
|
||||
clearTableColumnNames: () => {
|
||||
set({ tableColumnNames: {} });
|
||||
},
|
||||
tableSorting: {},
|
||||
getTableSorting: (tableKey) => {
|
||||
return get().tableSorting[tableKey] || {};
|
||||
},
|
||||
setTableSorting: (tableKey) => (sorting) => {
|
||||
// Update the table sorting for the given table
|
||||
set({
|
||||
tableSorting: {
|
||||
...get().tableSorting,
|
||||
[tableKey]: sorting
|
||||
}
|
||||
});
|
||||
},
|
||||
// detail drawers
|
||||
detailDrawerStack: 0,
|
||||
addDetailDrawer: (value) => {
|
||||
|
@ -10,8 +10,6 @@ import {
|
||||
Stack,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconBarcode,
|
||||
IconFilter,
|
||||
@ -106,7 +104,7 @@ export type InvenTreeTableProps<T = any> = {
|
||||
enableReports?: boolean;
|
||||
afterBulkDelete?: () => void;
|
||||
pageSize?: number;
|
||||
barcodeActions?: any[];
|
||||
barcodeActions?: React.ReactNode[];
|
||||
tableFilters?: TableFilter[];
|
||||
tableActions?: React.ReactNode[];
|
||||
rowExpansion?: any;
|
||||
@ -157,7 +155,12 @@ export function InvenTreeTable<T = any>({
|
||||
columns: TableColumn<T>[];
|
||||
props: InvenTreeTableProps<T>;
|
||||
}) {
|
||||
const { getTableColumnNames, setTableColumnNames } = useLocalState();
|
||||
const {
|
||||
getTableColumnNames,
|
||||
setTableColumnNames,
|
||||
getTableSorting,
|
||||
setTableSorting
|
||||
} = useLocalState();
|
||||
const [fieldNames, setFieldNames] = useState<Record<string, string>>({});
|
||||
|
||||
const navigate = useNavigate();
|
||||
@ -390,6 +393,15 @@ export function InvenTreeTable<T = any>({
|
||||
direction: 'asc'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const tableKey: string = tableState.tableKey.split('-')[0];
|
||||
const sorting: DataTableSortStatus = getTableSorting(tableKey);
|
||||
|
||||
if (sorting) {
|
||||
setSortStatus(sorting);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Return the ordering parameter
|
||||
function getOrderingTerm() {
|
||||
let key = sortStatus.columnAccessor;
|
||||
@ -413,6 +425,9 @@ export function InvenTreeTable<T = any>({
|
||||
const handleSortStatusChange = (status: DataTableSortStatus) => {
|
||||
tableState.setPage(1);
|
||||
setSortStatus(status);
|
||||
|
||||
const tableKey = tableState.tableKey.split('-')[0];
|
||||
setTableSorting(tableKey)(status);
|
||||
};
|
||||
|
||||
// Function to perform API query to fetch required data
|
||||
|
@ -1,13 +1,23 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
Accordion,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Pill,
|
||||
PillGroup,
|
||||
Stack,
|
||||
Text,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import { EditApiForm } from '../../components/forms/ApiForm';
|
||||
import { PlaceholderPill } from '../../components/items/Placeholder';
|
||||
import { DetailDrawer } from '../../components/nav/DetailDrawer';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal
|
||||
@ -32,14 +42,42 @@ export function GroupDrawer({
|
||||
refreshTable: () => void;
|
||||
}) {
|
||||
const {
|
||||
instance,
|
||||
refreshInstance,
|
||||
instanceQuery: { isFetching, error }
|
||||
} = useInstance({
|
||||
endpoint: ApiEndpoints.group_list,
|
||||
pk: id,
|
||||
throwError: true
|
||||
throwError: true,
|
||||
params: {
|
||||
permission_detail: true
|
||||
}
|
||||
});
|
||||
|
||||
const permissionsAccordion = useMemo(() => {
|
||||
if (!instance?.permissions) return null;
|
||||
|
||||
const data = instance.permissions;
|
||||
return (
|
||||
<Accordion w={'100%'}>
|
||||
{Object.keys(data).map((key) => (
|
||||
<Accordion.Item key={key} value={key}>
|
||||
<Accordion.Control>
|
||||
<Pill>{instance.permissions[key].length}</Pill> {key}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<PillGroup>
|
||||
{data[key].map((perm: string) => (
|
||||
<Pill key={perm}>{perm}</Pill>
|
||||
))}
|
||||
</PillGroup>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
);
|
||||
}, [instance]);
|
||||
|
||||
if (isFetching) {
|
||||
return <LoadingOverlay visible={true} />;
|
||||
}
|
||||
@ -72,13 +110,13 @@ export function GroupDrawer({
|
||||
}}
|
||||
id={`group-detail-drawer-${id}`}
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Title order={5}>
|
||||
<Trans>Permission set</Trans>
|
||||
</Title>
|
||||
<Group>
|
||||
<PlaceholderPill />
|
||||
<AdminButton model={ModelType.group} pk={instance.pk} />
|
||||
</Group>
|
||||
<Group>{permissionsAccordion}</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -796,7 +796,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.1.tgz#4ffb0055f7ef676ebc3a5a91fb621393294e2f43"
|
||||
integrity sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==
|
||||
|
||||
"@emotion/is-prop-valid@1.2.2", "@emotion/is-prop-valid@^1.2.0":
|
||||
"@emotion/is-prop-valid@1.2.2":
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz#d4175076679c6a26faa92b03bb786f9e52612337"
|
||||
integrity sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==
|
||||
@ -1822,15 +1822,6 @@
|
||||
dependencies:
|
||||
moo "^0.5.1"
|
||||
|
||||
"@naisutech/react-tree@^3.1.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@naisutech/react-tree/-/react-tree-3.1.0.tgz#a83820425b53a1ec7a39804ff8bd9024f0a953f4"
|
||||
integrity sha512-6p1l3ZIaTmbgiAf/mpFELvqwl51LDhr+09f7L+C27DBLWjtleezCMoUuiSLhrJgpixCPNL13PuI3q2yn+0AGvA==
|
||||
dependencies:
|
||||
"@emotion/is-prop-valid" "^1.2.0"
|
||||
nanoid "^4.0.0"
|
||||
react-draggable "^4.4.5"
|
||||
|
||||
"@open-draft/deferred-promise@^2.1.0":
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd"
|
||||
@ -2598,6 +2589,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6"
|
||||
integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==
|
||||
|
||||
"@types/qrcode@^1.5.5":
|
||||
version "1.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac"
|
||||
integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/react-dom@^18.3.0":
|
||||
version "18.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0"
|
||||
@ -3455,6 +3453,11 @@ diff@^5.1.0:
|
||||
resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531"
|
||||
integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==
|
||||
|
||||
dijkstrajs@^1.0.1:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
|
||||
integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==
|
||||
|
||||
dom-helpers@^5.0.1:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
|
||||
@ -3507,6 +3510,11 @@ emoji-regex@^8.0.0:
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
|
||||
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
|
||||
|
||||
encode-utf8@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda"
|
||||
integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==
|
||||
|
||||
error-ex@^1.3.1:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
|
||||
@ -4952,11 +4960,6 @@ nanoid@^3.3.7:
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
|
||||
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
|
||||
|
||||
nanoid@^4.0.0:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e"
|
||||
integrity sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==
|
||||
|
||||
next-tick@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb"
|
||||
@ -5236,6 +5239,11 @@ playwright@1.45.0:
|
||||
optionalDependencies:
|
||||
fsevents "2.3.2"
|
||||
|
||||
pngjs@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
|
||||
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
|
||||
|
||||
pofile@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.1.4.tgz#eab7e29f5017589b2a61b2259dff608c0cad76a2"
|
||||
@ -5302,6 +5310,16 @@ punycode@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
qrcode@^1.5.3:
|
||||
version "1.5.3"
|
||||
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.3.tgz#03afa80912c0dccf12bc93f615a535aad1066170"
|
||||
integrity sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==
|
||||
dependencies:
|
||||
dijkstrajs "^1.0.1"
|
||||
encode-utf8 "^1.0.3"
|
||||
pngjs "^5.0.0"
|
||||
yargs "^15.3.1"
|
||||
|
||||
ramda@^0.27.1:
|
||||
version "0.27.2"
|
||||
resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.2.tgz#84463226f7f36dc33592f6f4ed6374c48306c3f1"
|
||||
@ -6280,7 +6298,7 @@ yargs-parser@^18.1.2:
|
||||
camelcase "^5.0.0"
|
||||
decamelize "^1.2.0"
|
||||
|
||||
yargs@^15.0.2:
|
||||
yargs@^15.0.2, yargs@^15.3.1:
|
||||
version "15.4.1"
|
||||
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
|
||||
integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
|
||||
|
Loading…
Reference in New Issue
Block a user