2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-13 12:00:51 +00:00

[feature] tags support (#12077)

* Add Tag API endpoints

* Enable filtering by model type

* Remove old tags filters against Part endpoint

* Add generic tags filter for filtering against tagged items

* Add API unit tests for the tags API endpoints

* Create generic mixin class for adding tags support

* Update existing tagged models

* Add tags to more model types

* Enable new tags API filtering for multiple models

* Add support for tag filtering in part table

* Update transfer table filters

* Add tags filter to more places

* Allow multiple values to be selected as filters

* Add a new 'tags' type form field

* Display tags on part page

* tags support for orders

* Add support for SalesOrderShipment

* build order

* Company support

* SupplierPart and ManufacturerPart

* support StockItem

* Enable tag filtering for attachments

* Make tagslist readonly

* docs

* Mark props as read only

* Update API version

* Update CHANGELOG

* force tags to be case insensitive

* Add playwright test for build order tags

* more playwright testing

* Fix docs link
This commit is contained in:
Oliver
2026-06-04 19:38:22 +10:00
committed by GitHub
parent a0b2452ba5
commit 75a08a1e06
78 changed files with 1294 additions and 263 deletions
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- [#12077](https://github.com/inventree/InvenTree/pull/12077) adds "tags" fields to multiple new model types and a /api/tag/ endpoint for fetching tags. Also adds the ability to filter various model types by tags.
- [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation. - [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation.
- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created. - [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created.
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules. - [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.
+82
View File
@@ -0,0 +1,82 @@
---
title: Tags
---
## Tags
*Tags* are short, arbitrary labels that can be attached to InvenTree objects to group or classify them in flexible ways that don't require changes to the underlying data model. Unlike [parameters](./parameters.md), tags carry no typed value — they are simply names. A tag can be applied to objects of any supported model type, and tags are shared across the entire InvenTree instance.
!!! note "Shared Tag Namespace"
Tags are global: a tag named `prototype` applied to a Part and the same tag applied to a Build Order refer to the same underlying tag record. Renaming or deleting a tag affects every object to which it is attached.
### Supported Models
Tags can be attached to the following InvenTree objects:
- [Parts](../part/index.md)
- [Supplier Parts](../purchasing/supplier.md#supplier-parts)
- [Manufacturer Parts](../purchasing/manufacturer.md#manufacturer-parts)
- [Companies](./company.md)
- [Stock Items](../stock/index.md#stock-item)
- [Stock Locations](../stock/index.md#stock-location)
- [Build Orders](../manufacturing/build.md)
- [Purchase Orders](../purchasing/purchase_order.md)
- [Sales Orders](../sales/sales_order.md)
- [Return Orders](../sales/return_order.md)
- [Sales Order Shipments](../sales/sales_order.md#shipments)
## Managing Tags
### Adding and Removing Tags
Any object that supports tags will expose a *Tags* field in its detail and edit forms. Tags are entered as a comma-separated list of names and can be freely added or removed at any time. Tag names are case-insensitive — `Prototype`, `prototype`, and `PROTOTYPE` all refer to the same tag.
### Tag Names
Tag names must be unique within the InvenTree instance (case-insensitively). If you type a name that already exists under a different capitalisation, the existing tag is assigned rather than a new one created. Tag names may contain spaces, but leading and trailing whitespace is stripped automatically.
## Filtering by Tags
Tables that support tags can be filtered by one or more tag names. When multiple tags are specified, only objects that carry **all** of the specified tags are returned (AND logic).
For example, filtering a Parts table by the tags `approved` and `prototype` returns only parts tagged with both.
## API Access
### Tag Endpoints
The tag list is available at `/api/tag/`. Individual tags can be retrieved, updated, or deleted at `/api/tag/<id>/`.
The `model_type` query parameter narrows the tag list to tags currently applied to a specific model type:
```
GET /api/tag/?model_type=part
```
### Tags on Model Endpoints
For models that support tags, the `tags` field is returned in the detail endpoint response as a list of tag name strings:
```json
{
"pk": 42,
"name": "Widget",
"tags": ["approved", "prototype"]
}
```
Tags can be updated via a `PATCH` or `POST` request by supplying a JSON-encoded list of tag name strings. The full list of tags replaces the previous set — omitting a tag removes it:
```json
{
"tags": ["approved", "production"]
}
```
Tags can also be used as a filter parameter on list endpoints. Supply a comma-separated list of tag names to the `tags` query parameter:
```
GET /api/part/?tags=approved,prototype
```
This returns only parts tagged with **both** `approved` and `prototype`.
+1
View File
@@ -102,6 +102,7 @@ nav:
- Project Codes: concepts/project_codes.md - Project Codes: concepts/project_codes.md
- Attachments: concepts/attachments.md - Attachments: concepts/attachments.md
- Parameters: concepts/parameters.md - Parameters: concepts/parameters.md
- Tags: concepts/tags.md
- Barcodes: - Barcodes:
- Barcode Support: barcodes/index.md - Barcode Support: barcodes/index.md
- Internal Barcodes: barcodes/internal.md - Internal Barcodes: barcodes/internal.md
@@ -1,11 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 499 INVENTREE_API_VERSION = 500
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v500 -> 2026-06-03 : https://github.com/inventree/InvenTree/pull/12077
- Adds "tags" fields to multiple new model types
- Adds /api/tag/ endpoint for fetching tags
- Enable filtering various model types by tags
v499 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12057 v499 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12057
- Fixes search field issues on the BarcodeScanHistory API endpoint - Fixes search field issues on the BarcodeScanHistory API endpoint
+20
View File
@@ -24,6 +24,7 @@ from error_report.models import Error
from mptt.exceptions import InvalidMove from mptt.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from stdimage.models import StdImageField from stdimage.models import StdImageField
from taggit.managers import TaggableManager
import common.settings import common.settings
import InvenTree.exceptions import InvenTree.exceptions
@@ -1250,6 +1251,25 @@ class InvenTreeNotesMixin(models.Model):
) )
class InvenTreeTagsMixin(models.Model):
"""A mixin class for adding tag functionality to a model class.
The following fields are added to any model which implements this mixin:
- tags : A text field for storing comma-separated tags
"""
class Meta:
"""Metaclass options for this mixin.
Note: abstract must be true, as this is only a mixin, not a separate table
"""
abstract = True
tags = TaggableManager(blank=True)
class InvenTreeBarcodeMixin(models.Model): class InvenTreeBarcodeMixin(models.Model):
"""A mixin class for adding barcode functionality to a model class. """A mixin class for adding barcode functionality to a model class.
@@ -699,10 +699,6 @@ class InvenTreeTaggitSerializer(TaggitSerializer):
return self._save_tags(tag_object, to_be_tagged) return self._save_tags(tag_object, to_be_tagged)
class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSerializer):
"""Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer."""
class InvenTreeAttachmentSerializerField(serializers.FileField): class InvenTreeAttachmentSerializerField(serializers.FileField):
"""Override the DRF native FileField serializer, to remove the leading server path. """Override the DRF native FileField serializer, to remove the leading server path.
@@ -1169,3 +1169,6 @@ if 'dbbackup' not in STORAGES:
if _media: if _media:
MEDIA_URL = _media MEDIA_URL = _media
PRESIGNED_URL_EXPIRATION = 600 PRESIGNED_URL_EXPIRATION = 600
# Taggit settings
TAGGIT_CASE_INSENSITIVE = True
+3
View File
@@ -16,6 +16,7 @@ from rest_framework.response import Response
import build.models as build_models import build.models as build_models
import build.serializers import build.serializers
import common.filters
import common.models import common.models
import common.serializers import common.serializers
import part.models as part_models import part.models as part_models
@@ -307,6 +308,8 @@ class BuildFilter(FilterSet):
return queryset return queryset
tags = common.filters.TagsFilter()
class BuildMixin: class BuildMixin:
"""Mixin class for Build API endpoints.""" """Mixin class for Build API endpoints."""
@@ -0,0 +1,29 @@
# Generated by Django 5.2.14 on 2026-06-03 10:07
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("build", "0058_buildline_consumed"),
(
"taggit",
"0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx",
),
]
operations = [
migrations.AddField(
model_name="build",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]
+1
View File
@@ -79,6 +79,7 @@ class Build(
InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.ReferenceIndexingMixin, InvenTree.models.ReferenceIndexingMixin,
StateTransitionMixin, StateTransitionMixin,
@@ -36,6 +36,7 @@ from InvenTree.serializers import (
FilterableSerializerMixin, FilterableSerializerMixin,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin, NotesFieldMixin,
OptionalField, OptionalField,
) )
@@ -57,6 +58,7 @@ class BuildSerializer(
CustomStatusSerializerMixin, CustomStatusSerializerMixin,
FilterableSerializerMixin, FilterableSerializerMixin,
NotesFieldMixin, NotesFieldMixin,
InvenTreeTaggitSerializer,
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
InvenTreeCustomStatusSerializerMixin, InvenTreeCustomStatusSerializerMixin,
InvenTreeModelSerializer, InvenTreeModelSerializer,
@@ -103,6 +105,7 @@ class BuildSerializer(
'parameters', 'parameters',
'priority', 'priority',
'level', 'level',
'tags',
] ]
read_only_fields = [ read_only_fields = [
'completed', 'completed',
@@ -127,6 +130,8 @@ class BuildSerializer(
parameters = common.filters.enable_parameters_filter() parameters = common.filters.enable_parameters_filter()
tags = common.filters.enable_tags_filter()
part_name = serializers.CharField( part_name = serializers.CharField(
source='part.name', read_only=True, label=_('Part Name') source='part.name', read_only=True, label=_('Part Name')
) )
+53
View File
@@ -36,6 +36,7 @@ from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
from taggit.models import Tag
import common.filters import common.filters
import common.models import common.models
@@ -504,6 +505,48 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
permission_classes = [IsStaffOrReadOnlyScope] permission_classes = [IsStaffOrReadOnlyScope]
class TagFilter(FilterSet):
"""Custom filters for the TagList API endpoint."""
class Meta:
"""Metaclass options for the filterset."""
model = Tag
fields = []
model_type = rest_filters.CharFilter(method='filter_model_type', label='Model Type')
def filter_model_type(self, queryset, name, value):
"""Filter to tags which have been applied to the given model type."""
ct = common.filters.determine_content_type(value)
if ct is None:
raise ValidationError({'model_type': f'Invalid model type: {value}'})
return queryset.filter(taggit_taggeditem_items__content_type=ct).distinct()
class TagMixin:
"""Mixin class for Tag views."""
serializer_class = common.serializers.TagSerializer
queryset = Tag.objects.all()
permission_classes = [IsStaffOrReadOnlyScope]
class TagList(TagMixin, ListCreateAPI):
"""List view for all tags."""
filterset_class = TagFilter
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['name']
search_fields = ['name']
class TagDetail(TagMixin, RetrieveUpdateDestroyAPI):
"""Detail view for a particular tag."""
class CustomUnitViewset(DataExportViewMixin, viewsets.ModelViewSet): class CustomUnitViewset(DataExportViewMixin, viewsets.ModelViewSet):
"""List view for custom units.""" """List view for custom units."""
@@ -745,6 +788,8 @@ class AttachmentFilter(FilterSet):
return queryset.exclude(attachment=None).exclude(attachment='') return queryset.exclude(attachment=None).exclude(attachment='')
return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct() return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct()
tags = common.filters.TagsFilter()
class AttachmentMixin: class AttachmentMixin:
"""Mixin class for Attachment views.""" """Mixin class for Attachment views."""
@@ -1554,6 +1599,14 @@ common_api_urls = [
path('', ProjectCodeList.as_view(), name='api-project-code-list'), path('', ProjectCodeList.as_view(), name='api-project-code-list'),
]), ]),
), ),
# Tags (via django-taggit)
path(
'tag/',
include([
path('<int:pk>/', TagDetail.as_view(), name='api-tag-detail'),
path('', TagList.as_view(), name='api-tag-list'),
]),
),
# Flags # Flags
path( path(
'flags/', 'flags/',
+31
View File
@@ -19,6 +19,7 @@ from django.db.models import (
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import django_filters.rest_framework.filters as rest_filters
from rest_framework import serializers from rest_framework import serializers
from taggit.serializers import TagListSerializerField from taggit.serializers import TagListSerializerField
@@ -93,6 +94,36 @@ def filter_content_type(
return queryset.filter(q) return queryset.filter(q)
class TagsFilter(rest_filters.CharFilter):
"""Filter which accepts a comma-separated list of tag names and returns only objects that have ALL of the specified tags.
Example usage in a FilterSet:
tags = TagsFilter(label=_('Tags'))
Example query:
?tags=apple,banana returns only items tagged with both 'apple' AND 'banana'
"""
def __init__(self, *args, **kwargs):
"""Initialize the filter."""
if 'label' not in kwargs:
kwargs['label'] = _('Tags')
super().__init__(*args, **kwargs)
def filter(self, qs, value):
"""Filter queryset to items matching all provided tag names."""
if not value:
return qs
tag_names = [t.strip() for t in value.split(',') if t.strip()]
for tag in tag_names:
qs = qs.filter(tags__name__iexact=tag)
return qs.distinct()
"""A list of valid operators for filtering part parameters.""" """A list of valid operators for filtering part parameters."""
PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains'] PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains']
+5 -4
View File
@@ -50,7 +50,6 @@ from djmoney.contrib.exchange.models import convert_money
from opentelemetry import trace from opentelemetry import trace
from PIL import Image from PIL import Image
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from taggit.managers import TaggableManager
import common.validators import common.validators
import InvenTree.conversion import InvenTree.conversion
@@ -1923,7 +1922,11 @@ def rename_attachment(instance, filename: str):
) )
class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): class Attachment(
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeModel,
):
"""Class which represents an uploaded file attachment. """Class which represents an uploaded file attachment.
An attachment can be either an uploaded file, or an external URL. An attachment can be either an uploaded file, or an external URL.
@@ -2156,8 +2159,6 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
default=0, verbose_name=_('File size'), help_text=_('File size in bytes') default=0, verbose_name=_('File size'), help_text=_('File size in bytes')
) )
tags = TaggableManager(blank=True)
@property @property
def basename(self): def basename(self):
"""Base name/path for attachment.""" """Base name/path for attachment."""
+28 -1
View File
@@ -12,6 +12,7 @@ from error_report.models import Error
from flags.state import flag_state from flags.state import flag_state
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from taggit.models import Tag
import common.filters import common.filters
import common.models as common_models import common.models as common_models
@@ -28,6 +29,7 @@ from InvenTree.serializers import (
InvenTreeAttachmentSerializerField, InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField, InvenTreeImageSerializerField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeTaggitSerializer,
OptionalField, OptionalField,
) )
from plugin import registry as plugin_registry from plugin import registry as plugin_registry
@@ -422,6 +424,29 @@ class ProjectCodeSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria
) )
@register_importer()
class TagSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the Tag model."""
class Meta:
"""Meta options for TagSerializer."""
model = Tag
fields = ['pk', 'name', 'slug']
read_only_fields = ['pk', 'slug']
def validate(self, data):
"""Slugify the received name to generate the slug."""
from django.utils.text import slugify
name = data.get('name', None)
if name is not None:
data['slug'] = slugify(name)
return data
@register_importer() @register_importer()
class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the custom state model.""" """Serializer for the custom state model."""
@@ -720,7 +745,9 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
result = serializers.CharField() result = serializers.CharField()
class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): class AttachmentSerializer(
FilterableSerializerMixin, InvenTreeTaggitSerializer, InvenTreeModelSerializer
):
"""Serializer class for the Attachment model.""" """Serializer class for the Attachment model."""
class Meta: class Meta:
+168
View File
@@ -8,6 +8,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse from django.urls import reverse
from PIL import Image from PIL import Image
from taggit.models import Tag
import common.models import common.models
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
@@ -999,3 +1000,170 @@ class AttachmentThumbnailAPITests(InvenTreeAPITestCase):
set_global_setting( set_global_setting(
'INVENTREE_UPLOAD_MAX_SIZE', original_limit, change_user=None 'INVENTREE_UPLOAD_MAX_SIZE', original_limit, change_user=None
) )
class TagAPITests(InvenTreeAPITestCase):
"""Tests for the Tag API endpoints and tag-based filtering."""
roles = 'all'
LIST_URL = 'api-tag-list'
DETAIL_URL = 'api-tag-detail'
def setUp(self):
"""Create a small set of tagged objects for filter testing."""
super().setUp()
from part.models import Part
self.part_a = Part.objects.create(
name='Tagged Part A', description='Part with apple and banana tags'
)
self.part_b = Part.objects.create(
name='Tagged Part B', description='Part with apple tag only'
)
self.part_c = Part.objects.create(
name='Untagged Part C', description='Part with no tags'
)
self.part_a.tags.add('apple', 'banana')
self.part_b.tags.add('apple')
# ------------------------------------------------------------------
# Tag list / CRUD
# ------------------------------------------------------------------
def test_tag_list(self):
"""Tag list endpoint should return all existing tags."""
url = reverse(self.LIST_URL)
response = self.get(url)
names = {t['name'] for t in response.data}
self.assertIn('apple', names)
self.assertIn('banana', names)
def test_tag_create(self):
"""Staff users should be able to create tags via POST."""
url = reverse(self.LIST_URL)
n = Tag.objects.count()
response = self.post(url, {'name': 'cherry'}, expected_code=201)
self.assertEqual(response.data['name'], 'cherry')
self.assertEqual(Tag.objects.count(), n + 1)
def test_tag_create_non_staff(self):
"""Non-staff users must not be able to create tags."""
self.user.is_staff = False
self.user.save()
url = reverse(self.LIST_URL)
self.post(url, {'name': 'forbidden'}, expected_code=403)
def test_tag_edit(self):
"""Staff users should be able to rename a tag via PATCH."""
tag = Tag.objects.get(name='banana')
url = reverse(self.DETAIL_URL, kwargs={'pk': tag.pk})
response = self.patch(url, {'name': 'blueberry'}, expected_code=200)
self.assertEqual(response.data['name'], 'blueberry')
tag.refresh_from_db()
self.assertEqual(tag.name, 'blueberry')
def test_tag_delete(self):
"""Staff users should be able to delete a tag."""
tag = Tag.objects.get(name='banana')
url = reverse(self.DETAIL_URL, kwargs={'pk': tag.pk})
self.delete(url, expected_code=204)
self.assertFalse(Tag.objects.filter(name='banana').exists())
def test_tag_search(self):
"""The list endpoint should support free-text search."""
url = reverse(self.LIST_URL)
response = self.get(url, data={'search': 'app'})
names = [t['name'] for t in response.data]
self.assertIn('apple', names)
self.assertNotIn('banana', names)
# ------------------------------------------------------------------
# Filter by model type
# ------------------------------------------------------------------
def test_tag_filter_model_type(self):
"""Tags applied to a given model type should be returned when filtering by model_type."""
url = reverse(self.LIST_URL)
# Filter for tags applied to Part objects
response = self.get(url, data={'model_type': 'part.part'})
names = {t['name'] for t in response.data}
self.assertIn('apple', names)
self.assertIn('banana', names)
def test_tag_filter_model_type_unrelated(self):
"""Filtering by a model type that has no tagged objects should return an empty list."""
url = reverse(self.LIST_URL)
# StockItem has no tagged objects in this test
response = self.get(url, data={'model_type': 'stock.stockitem'})
self.assertEqual(len(response.data), 0)
def test_tag_filter_model_type_invalid(self):
"""An unrecognised model_type value should return a 400 error."""
url = reverse(self.LIST_URL)
self.get(url, data={'model_type': 'notanapp.notamodel'}, expected_code=400)
# ------------------------------------------------------------------
# Filter Part list by tags
# ------------------------------------------------------------------
def test_part_filter_single_tag(self):
"""Filtering parts by a single tag should return only parts with that tag."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'apple'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertIn(self.part_b.pk, pks)
self.assertNotIn(self.part_c.pk, pks)
def test_part_filter_multiple_tags_and(self):
"""Filtering by comma-separated tags should return only parts that have ALL tags."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'apple,banana'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertNotIn(self.part_b.pk, pks) # only has 'apple'
self.assertNotIn(self.part_c.pk, pks) # no tags at all
def test_part_filter_tag_case_insensitive(self):
"""Tag filtering should be case-insensitive."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'APPLE'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertIn(self.part_b.pk, pks)
def test_part_filter_nonexistent_tag(self):
"""Filtering by a tag that no part has should return an empty result set."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'doesnotexist'})
self.assertEqual(len(response.data), 0)
def test_part_filter_tag_whitespace(self):
"""Whitespace around comma-separated tag names should be ignored."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': ' apple , banana '})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertNotIn(self.part_b.pk, pks)
+18 -7
View File
@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
import django_filters.rest_framework.filters as rest_filters import django_filters.rest_framework.filters as rest_filters
from django_filters.rest_framework.filterset import FilterSet from django_filters.rest_framework.filterset import FilterSet
import common.filters
import part.models import part.models
from data_exporter.mixins import DataExportViewMixin from data_exporter.mixins import DataExportViewMixin
from InvenTree.api import ListCreateDestroyAPIView, ParameterListMixin, meta_path from InvenTree.api import ListCreateDestroyAPIView, ParameterListMixin, meta_path
@@ -37,6 +38,18 @@ from .serializers import (
) )
class CompanyFilter(FilterSet):
"""Custom API filters for the CompanyList endpoint."""
class Meta:
"""Metaclass options."""
model = Company
fields = ['is_customer', 'is_manufacturer', 'is_supplier', 'name', 'active']
tags = common.filters.TagsFilter()
class CompanyMixin(OutputOptionsMixin): class CompanyMixin(OutputOptionsMixin):
"""Mixin class for Company API endpoints.""" """Mixin class for Company API endpoints."""
@@ -62,13 +75,7 @@ class CompanyList(CompanyMixin, ParameterListMixin, DataExportViewMixin, ListCre
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
filterset_fields = [ filterset_class = CompanyFilter
'is_customer',
'is_manufacturer',
'is_supplier',
'name',
'active',
]
search_fields = ['name', 'description', 'website', 'tax_id'] search_fields = ['name', 'description', 'website', 'tax_id']
@@ -145,6 +152,8 @@ class ManufacturerPartFilter(FilterSet):
field_name='manufacturer__active', label=_('Manufacturer is Active') field_name='manufacturer__active', label=_('Manufacturer is Active')
) )
tags = common.filters.TagsFilter(label=_('Tags'))
class ManufacturerOutputOptions(OutputConfiguration): class ManufacturerOutputOptions(OutputConfiguration):
"""Available output options for the ManufacturerPart endpoints.""" """Available output options for the ManufacturerPart endpoints."""
@@ -299,6 +308,8 @@ class SupplierPartFilter(FilterSet):
else: else:
return queryset.exclude(in_stock__gt=0) return queryset.exclude(in_stock__gt=0)
tags = common.filters.TagsFilter(label=_('Tags'))
class SupplierPartOutputOptions(OutputConfiguration): class SupplierPartOutputOptions(OutputConfiguration):
"""Available output options for the SupplierPart endpoints.""" """Available output options for the SupplierPart endpoints."""
@@ -0,0 +1,29 @@
# Generated by Django 5.2.14 on 2026-06-03 10:07
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("company", "0079_auto_20260212_1054"),
(
"taggit",
"0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx",
),
]
operations = [
migrations.AddField(
model_name="company",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]
+3 -5
View File
@@ -17,7 +17,6 @@ from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy as __ from django.utils.translation import pgettext_lazy as __
from moneyed import CURRENCIES from moneyed import CURRENCIES
from taggit.managers import TaggableManager
import common.currency import common.currency
import common.models import common.models
@@ -80,6 +79,7 @@ class Company(
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeImageMixin, InvenTree.models.InvenTreeImageMixin,
InvenTree.models.InvenTreeMetadataModel, InvenTree.models.InvenTreeMetadataModel,
@@ -487,6 +487,7 @@ class ManufacturerPart(
InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeMetadataModel, InvenTree.models.InvenTreeMetadataModel,
): ):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers. """Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
@@ -559,8 +560,6 @@ class ManufacturerPart(
help_text=_('Manufacturer part description'), help_text=_('Manufacturer part description'),
) )
tags = TaggableManager(blank=True)
@classmethod @classmethod
def create(cls, part, manufacturer, mpn, description, link=None): def create(cls, part, manufacturer, mpn, description, link=None):
"""Check if ManufacturerPart instance does not already exist then create it.""" """Check if ManufacturerPart instance does not already exist then create it."""
@@ -603,6 +602,7 @@ class SupplierPart(
InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.MetadataMixin, InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
common.models.MetaMixin, common.models.MetaMixin,
InvenTree.models.InvenTreeModel, InvenTree.models.InvenTreeModel,
@@ -640,8 +640,6 @@ class SupplierPart(
# This model was moved from the 'Part' app # This model was moved from the 'Part' app
db_table = 'part_supplierpart' db_table = 'part_supplierpart'
tags = TaggableManager(blank=True)
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
"""Return the API URL associated with the SupplierPart model.""" """Return the API URL associated with the SupplierPart model."""
+9 -3
View File
@@ -20,7 +20,7 @@ from InvenTree.serializers import (
InvenTreeImageSerializerField, InvenTreeImageSerializerField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer, InvenTreeMoneySerializer,
InvenTreeTagModelSerializer, InvenTreeTaggitSerializer,
NotesFieldMixin, NotesFieldMixin,
OptionalField, OptionalField,
) )
@@ -108,6 +108,7 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
class CompanySerializer( class CompanySerializer(
FilterableSerializerMixin, FilterableSerializerMixin,
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin, NotesFieldMixin,
InvenTreeModelSerializer, InvenTreeModelSerializer,
): ):
@@ -143,6 +144,7 @@ class CompanySerializer(
'primary_address', 'primary_address',
'tax_id', 'tax_id',
'parameters', 'parameters',
'tags',
] ]
@staticmethod @staticmethod
@@ -185,6 +187,8 @@ class CompanySerializer(
help_text=_('Default currency used for this supplier'), required=True help_text=_('Default currency used for this supplier'), required=True
) )
tags = common.filters.enable_tags_filter()
parameters = common.filters.enable_parameters_filter() parameters = common.filters.enable_parameters_filter()
@@ -207,8 +211,9 @@ class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerialize
class ManufacturerPartSerializer( class ManufacturerPartSerializer(
FilterableSerializerMixin, FilterableSerializerMixin,
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
InvenTreeTagModelSerializer, InvenTreeTaggitSerializer,
NotesFieldMixin, NotesFieldMixin,
InvenTreeModelSerializer,
): ):
"""Serializer for ManufacturerPart object.""" """Serializer for ManufacturerPart object."""
@@ -308,8 +313,9 @@ class SupplierPriceBreakBriefSerializer(
class SupplierPartSerializer( class SupplierPartSerializer(
FilterableSerializerMixin, FilterableSerializerMixin,
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
InvenTreeTagModelSerializer, InvenTreeTaggitSerializer,
NotesFieldMixin, NotesFieldMixin,
InvenTreeModelSerializer,
): ):
"""Serializer for SupplierPart object.""" """Serializer for SupplierPart object."""
+5
View File
@@ -22,6 +22,7 @@ from rest_framework.exceptions import NotFound
from rest_framework.response import Response from rest_framework.response import Response
import build.models import build.models
import common.filters
import common.models import common.models
import common.serializers import common.serializers
import common.settings import common.settings
@@ -277,6 +278,8 @@ class OrderFilter(FilterSet):
return queryset.filter(q1 | q2 | q3 | q4).distinct() return queryset.filter(q1 | q2 | q3 | q4).distinct()
tags = common.filters.TagsFilter()
class LineItemFilter(FilterSet): class LineItemFilter(FilterSet):
"""Base class for custom API filters for order line item list(s).""" """Base class for custom API filters for order line item list(s)."""
@@ -1448,6 +1451,8 @@ class SalesOrderShipmentFilter(FilterSet):
return queryset.filter(q1 | q2).distinct() return queryset.filter(q1 | q2).distinct()
tags = common.filters.TagsFilter()
class SalesOrderShipmentMixin: class SalesOrderShipmentMixin:
"""Mixin class for SalesOrderShipment endpoints.""" """Mixin class for SalesOrderShipment endpoints."""
@@ -0,0 +1,73 @@
# Generated by Django 5.2.14 on 2026-06-03 10:07
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("order", "0119_transferorderlineitem_line_int"),
(
"taggit",
"0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx",
),
]
operations = [
migrations.AddField(
model_name="purchaseorder",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
migrations.AddField(
model_name="returnorder",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
migrations.AddField(
model_name="salesorder",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
migrations.AddField(
model_name="salesordershipment",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
migrations.AddField(
model_name="transferorder",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]
+2
View File
@@ -300,6 +300,7 @@ class Order(
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin, InvenTree.models.MetadataMixin,
InvenTree.models.ReferenceIndexingMixin, InvenTree.models.ReferenceIndexingMixin,
@@ -2465,6 +2466,7 @@ class SalesOrderShipment(
InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin, InvenTree.models.MetadataMixin,
@@ -37,6 +37,7 @@ from InvenTree.serializers import (
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer, InvenTreeMoneySerializer,
InvenTreeTaggitSerializer,
NotesFieldMixin, NotesFieldMixin,
OptionalField, OptionalField,
) )
@@ -103,6 +104,7 @@ class DuplicateOrderSerializer(serializers.Serializer):
class AbstractOrderSerializer( class AbstractOrderSerializer(
CustomStatusSerializerMixin, CustomStatusSerializerMixin,
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
InvenTreeTaggitSerializer,
FilterableSerializerMixin, FilterableSerializerMixin,
serializers.Serializer, serializers.Serializer,
): ):
@@ -172,6 +174,8 @@ class AbstractOrderSerializer(
parameters = common.filters.enable_parameters_filter() parameters = common.filters.enable_parameters_filter()
tags = common.filters.enable_tags_filter()
# Boolean field indicating if this order is overdue (Note: must be annotated) # Boolean field indicating if this order is overdue (Note: must be annotated)
overdue = serializers.BooleanField(read_only=True, allow_null=True) overdue = serializers.BooleanField(read_only=True, allow_null=True)
@@ -236,6 +240,7 @@ class AbstractOrderSerializer(
'project_code_label', 'project_code_label',
'responsible_detail', 'responsible_detail',
'parameters', 'parameters',
'tags',
*extra_fields, *extra_fields,
] ]
@@ -1065,6 +1070,7 @@ class SalesOrderSerializer(
TotalPriceMixin, TotalPriceMixin,
InvenTreeCustomStatusSerializerMixin, InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer, AbstractOrderSerializer,
InvenTreeTaggitSerializer,
InvenTreeModelSerializer, InvenTreeModelSerializer,
): ):
"""Serializer for the SalesOrder model class.""" """Serializer for the SalesOrder model class."""
@@ -1084,6 +1090,7 @@ class SalesOrderSerializer(
'completed_shipments_count', 'completed_shipments_count',
'allocated_lines', 'allocated_lines',
'updated_at', 'updated_at',
'tags',
]) ])
read_only_fields = ['status', 'creation_date', 'shipment_date', 'updated_at'] read_only_fields = ['status', 'creation_date', 'shipment_date', 'updated_at']
extra_kwargs = {'order_currency': {'required': False}} extra_kwargs = {'order_currency': {'required': False}}
@@ -1165,6 +1172,8 @@ class SalesOrderSerializer(
read_only=True, allow_null=True, label=_('Allocated Lines') read_only=True, allow_null=True, label=_('Allocated Lines')
) )
tags = common.filters.enable_tags_filter()
class SalesOrderIssueSerializer(OrderAdjustSerializer): class SalesOrderIssueSerializer(OrderAdjustSerializer):
"""Serializer for issuing a SalesOrder.""" """Serializer for issuing a SalesOrder."""
@@ -1363,6 +1372,7 @@ class SalesOrderLineItemSerializer(
class SalesOrderShipmentSerializer( class SalesOrderShipmentSerializer(
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
FilterableSerializerMixin, FilterableSerializerMixin,
InvenTreeTaggitSerializer,
NotesFieldMixin, NotesFieldMixin,
InvenTreeModelSerializer, InvenTreeModelSerializer,
): ):
@@ -1392,6 +1402,7 @@ class SalesOrderShipmentSerializer(
'customer_detail', 'customer_detail',
'order_detail', 'order_detail',
'shipment_address_detail', 'shipment_address_detail',
'tags',
] ]
@staticmethod @staticmethod
@@ -1461,6 +1472,8 @@ class SalesOrderShipmentSerializer(
parameters = common.filters.enable_parameters_filter() parameters = common.filters.enable_parameters_filter()
tags = common.filters.enable_tags_filter()
class SalesOrderAllocationSerializer( class SalesOrderAllocationSerializer(
FilterableSerializerMixin, InvenTreeModelSerializer FilterableSerializerMixin, InvenTreeModelSerializer
+2 -3
View File
@@ -12,6 +12,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from rest_framework.response import Response from rest_framework.response import Response
import common.filters
import common.serializers import common.serializers
import part.tasks as part_tasks import part.tasks as part_tasks
from data_exporter.mixins import DataExportViewMixin from data_exporter.mixins import DataExportViewMixin
@@ -910,9 +911,7 @@ class PartFilter(FilterSet):
virtual = rest_filters.BooleanFilter() virtual = rest_filters.BooleanFilter()
tags_name = rest_filters.CharFilter(field_name='tags__name', lookup_expr='iexact') tags = common.filters.TagsFilter()
tags_slug = rest_filters.CharFilter(field_name='tags__slug', lookup_expr='iexact')
# Created date filters # Created date filters
created_before = InvenTreeDateFilter( created_before = InvenTreeDateFilter(
+1 -3
View File
@@ -32,7 +32,6 @@ from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money from djmoney.money import Money
from mptt.managers import TreeManager from mptt.managers import TreeManager
from mptt.models import TreeForeignKey from mptt.models import TreeForeignKey
from taggit.managers import TaggableManager
import common.currency import common.currency
import common.models import common.models
@@ -465,6 +464,7 @@ class Part(
InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeImageMixin, InvenTree.models.InvenTreeImageMixin,
@@ -520,8 +520,6 @@ class Part(
objects = TreeManager() objects = TreeManager()
tags = TaggableManager(blank=True)
class Meta: class Meta:
"""Metaclass defines extra model properties.""" """Metaclass defines extra model properties."""
+4
View File
@@ -373,6 +373,8 @@ class StockLocationFilter(FilterSet):
return queryset return queryset
tags = common.filters.TagsFilter(label=_('Tags'))
class StockLocationMixin(SerializerContextMixin): class StockLocationMixin(SerializerContextMixin):
"""Mixin class for StockLocation API endpoints.""" """Mixin class for StockLocation API endpoints."""
@@ -1041,6 +1043,8 @@ class StockFilter(FilterSet):
children = loc_obj.getUniqueChildren() children = loc_obj.getUniqueChildren()
return queryset.filter(location__in=children) return queryset.filter(location__in=children)
tags = common.filters.TagsFilter(label=_('Tags'))
class StockApiMixin(SerializerContextMixin): class StockApiMixin(SerializerContextMixin):
"""Mixin class for StockItem API endpoints.""" """Mixin class for StockItem API endpoints."""
+2 -5
View File
@@ -23,7 +23,6 @@ import structlog
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from mptt.managers import TreeManager from mptt.managers import TreeManager
from mptt.models import TreeForeignKey from mptt.models import TreeForeignKey
from taggit.managers import TaggableManager
import build.models import build.models
import common.models import common.models
@@ -126,6 +125,7 @@ class StockLocation(
InvenTree.models.PluginValidationMixin, InvenTree.models.PluginValidationMixin,
InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeParameterMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeTagsMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
InvenTree.models.PathStringMixin, InvenTree.models.PathStringMixin,
InvenTree.models.MetadataMixin, InvenTree.models.MetadataMixin,
@@ -149,8 +149,6 @@ class StockLocation(
verbose_name = _('Stock Location') verbose_name = _('Stock Location')
verbose_name_plural = _('Stock Locations') verbose_name_plural = _('Stock Locations')
tags = TaggableManager(blank=True)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
"""Custom model deletion routine, which updates any child locations or items. """Custom model deletion routine, which updates any child locations or items.
@@ -426,6 +424,7 @@ class StockItem(
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.InvenTreeTagsMixin,
StatusCodeMixin, StatusCodeMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
common.models.MetaMixin, common.models.MetaMixin,
@@ -619,8 +618,6 @@ class StockItem(
'test_templates': self.part.getTestTemplateMap(), 'test_templates': self.part.getTestTemplateMap(),
} }
tags = TaggableManager(blank=True)
# A Query filter which will be reused in multiple places to determine if a StockItem is actually "in stock" # A Query filter which will be reused in multiple places to determine if a StockItem is actually "in stock"
# See also: StockItem.in_stock() method # See also: StockItem.in_stock() method
IN_STOCK_FILTER = Q( IN_STOCK_FILTER = Q(
+4 -2
View File
@@ -313,7 +313,8 @@ class StockItemSerializer(
InvenTree.serializers.FilterableSerializerMixin, InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
InvenTreeCustomStatusSerializerMixin, InvenTreeCustomStatusSerializerMixin,
InvenTree.serializers.InvenTreeTagModelSerializer, InvenTree.serializers.InvenTreeTaggitSerializer,
InvenTree.serializers.InvenTreeModelSerializer,
): ):
"""Serializer for a StockItem. """Serializer for a StockItem.
@@ -1194,7 +1195,8 @@ class LocationDeleteSerializer(serializers.Serializer):
class LocationSerializer( class LocationSerializer(
InvenTree.serializers.FilterableSerializerMixin, InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
InvenTree.serializers.InvenTreeTagModelSerializer, InvenTree.serializers.InvenTreeTaggitSerializer,
InvenTree.serializers.InvenTreeModelSerializer,
): ):
"""Detailed information about a stock location.""" """Detailed information about a stock location."""
+27
View File
@@ -0,0 +1,27 @@
import { ActionIcon, Badge, Group, Paper } from '@mantine/core';
import { IconTag } from '@tabler/icons-react';
export default function TagsList({
tags
}: Readonly<{
tags: string[];
}>) {
if (!tags || tags.length === 0) {
return null;
}
return (
<Paper p='xs' shadow='xs' withBorder>
<Group gap='xs'>
<ActionIcon size='sm' variant='transparent'>
<IconTag />
</ActionIcon>
{tags.map((tag: string) => (
<Badge key={tag} variant='outline' size='sm'>
{tag}
</Badge>
))}
</Group>
</Paper>
);
}
+1
View File
@@ -259,6 +259,7 @@ export enum ApiEndpoints {
config_list = 'admin/config/', config_list = 'admin/config/',
parameter_list = 'parameter/', parameter_list = 'parameter/',
parameter_template_list = 'parameter/template/', parameter_template_list = 'parameter/template/',
tag_list = 'tag/',
// Internal system things // Internal system things
system_internal_trace_end = 'system-internal/observability/end' system_internal_trace_end = 'system-internal/observability/end'
@@ -319,5 +319,11 @@ export const ModelInformationDict: ModelDict = {
url_overview: '/settings/admin/errors', url_overview: '/settings/admin/errors',
url_detail: '/settings/admin/errors/:pk/', url_detail: '/settings/admin/errors/:pk/',
icon: 'exclamation' icon: 'exclamation'
},
tag: {
label: () => t`Tag`,
label_multiple: () => t`Tags`,
api_endpoint: ApiEndpoints.tag_list,
icon: 'tag'
} }
}; };
+2 -1
View File
@@ -38,7 +38,8 @@ export enum ModelType {
contenttype = 'contenttype', contenttype = 'contenttype',
selectionlist = 'selectionlist', selectionlist = 'selectionlist',
selectionentry = 'selectionentry', selectionentry = 'selectionentry',
error = 'error' error = 'error',
tag = 'tag'
} }
export enum PluginPanelKey { export enum PluginPanelKey {
+1
View File
@@ -110,6 +110,7 @@ export { ProgressBar } from './components/ProgressBar';
export { PassFailButton, YesNoButton } from './components/YesNoButton'; export { PassFailButton, YesNoButton } from './components/YesNoButton';
export { SearchInput } from './components/SearchInput'; export { SearchInput } from './components/SearchInput';
export { TableColumnSelect } from './components/TableColumnSelect'; export { TableColumnSelect } from './components/TableColumnSelect';
export { default as TagsList } from './components/TagsList';
export { default as InvenTreeTable } from './components/InvenTreeTable'; export { default as InvenTreeTable } from './components/InvenTreeTable';
export { export {
RowViewAction, RowViewAction,
+3
View File
@@ -41,6 +41,7 @@ export type TableFilter = {
name: string; name: string;
label?: string; label?: string;
description?: string; description?: string;
placeholder?: string;
type?: TableFilterType; type?: TableFilterType;
choices?: TableFilterChoice[]; choices?: TableFilterChoice[];
choiceFunction?: () => TableFilterChoice[]; choiceFunction?: () => TableFilterChoice[];
@@ -52,6 +53,8 @@ export type TableFilter = {
apiFilter?: Record<string, any>; apiFilter?: Record<string, any>;
model?: ModelType; model?: ModelType;
modelRenderer?: (instance: any) => string; modelRenderer?: (instance: any) => string;
transform?: (item: any) => TableFilterChoice;
multi?: boolean;
}; };
/* /*
+2 -1
View File
@@ -99,7 +99,8 @@ export type ApiFormFieldType = {
| 'file upload' | 'file upload'
| 'nested object' | 'nested object'
| 'dependent field' | 'dependent field'
| 'table'; | 'table'
| 'tags';
api_url?: string; api_url?: string;
pk_field?: string; pk_field?: string;
model?: ModelType; model?: ModelType;
@@ -17,6 +17,7 @@ import { NestedObjectField } from './NestedObjectField';
import NumberField from './NumberField'; import NumberField from './NumberField';
import { RelatedModelField } from './RelatedModelField'; import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField'; import { TableField } from './TableField';
import TagsField from './TagsField';
import TextField from './TextField'; import TextField from './TextField';
/** /**
@@ -249,6 +250,10 @@ export function ApiFormField({
control={controller} control={controller}
/> />
); );
case 'tags':
return (
<TagsField controller={controller} definition={fieldDefinition} />
);
default: default:
return ( return (
<Alert color='red' title={t`Error`}> <Alert color='red' title={t`Error`}>
@@ -34,13 +34,13 @@ export function BooleanField({
// Coerce the value to a (stringified) boolean value // Coerce the value to a (stringified) boolean value
const booleanValue: boolean = useMemo(() => { const booleanValue: boolean = useMemo(() => {
return isTrue(value); return isTrue(value ?? definition.default ?? false);
}, [value]); }, [value]);
return ( return (
<Switch <Switch
{...definition} {...definition}
defaultValue={definition.default ?? false} defaultValue={undefined}
checked={booleanValue} checked={booleanValue}
id={fieldId} id={fieldId}
aria-label={`boolean-field-${fieldName}`} aria-label={`boolean-field-${fieldName}`}
@@ -0,0 +1,81 @@
import { TagsInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldType } from '@lib/types/Forms';
import { api } from '../../../App';
export default function TagsField({
controller,
definition
}: Readonly<{
controller: UseControllerReturn<FieldValues, any>;
definition: ApiFormFieldType;
}>) {
const {
field,
fieldState: { error }
} = controller;
const [tags, setTags] = useState<string[]>(field.value ?? []);
const [searchValue, setSearchValue] = useState('');
const [debouncedSearch] = useDebouncedValue(searchValue, 250);
// Sync inbound value changes (e.g. when initial data is loaded)
useEffect(() => {
setTags(field.value ?? []);
}, [field.value]);
const onChange = useCallback(
(value: string[]) => {
setTags(value);
field.onChange(value);
definition.onValueChange?.(value);
},
[field, definition]
);
const tagQuery = useQuery({
queryKey: ['tags-autocomplete', debouncedSearch],
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.tag_list), {
params: { search: debouncedSearch, limit: 20 }
})
.then((r) => r.data)
});
const suggestions: string[] = useMemo(() => {
const results: any[] = tagQuery.data?.results ?? tagQuery.data ?? [];
return results.map((tag: any) => String(tag.name));
}, [tagQuery.data]);
const reducedDefinition: any = useMemo(() => {
return {
...definition,
allow_null: undefined,
allow_blank: undefined
};
}, [definition]);
return (
<TagsInput
{...reducedDefinition}
ref={field.ref}
placeholder={definition.placeholder}
aria-label={`tags-field-${field.name}`}
value={tags}
onChange={onChange}
data={suggestions}
searchValue={searchValue}
onSearchChange={setSearchValue}
error={definition.error ?? error?.message}
radius='sm'
splitChars={[',', '\t', '\n', ';', ':', '.', '-']}
/>
);
}
@@ -56,6 +56,12 @@ export function RenderError({
return instance && <RenderInlineModel primary={instance.name} />; return instance && <RenderInlineModel primary={instance.name} />;
} }
export function RenderTag({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return instance && <RenderInlineModel primary={instance.name} />;
}
export function RenderImportSession({ export function RenderImportSession({
instance instance
}: { }: {
@@ -55,7 +55,8 @@ import {
RenderParameterTemplate, RenderParameterTemplate,
RenderProjectCode, RenderProjectCode,
RenderSelectionEntry, RenderSelectionEntry,
RenderSelectionList RenderSelectionList,
RenderTag
} from './Generic'; } from './Generic';
import { import {
RenderPurchaseOrder, RenderPurchaseOrder,
@@ -116,7 +117,8 @@ export const RendererLookup: ModelRendererDict = {
[ModelType.contenttype]: RenderContentType, [ModelType.contenttype]: RenderContentType,
[ModelType.selectionlist]: RenderSelectionList, [ModelType.selectionlist]: RenderSelectionList,
[ModelType.selectionentry]: RenderSelectionEntry, [ModelType.selectionentry]: RenderSelectionEntry,
[ModelType.error]: RenderError [ModelType.error]: RenderError,
[ModelType.tag]: RenderTag
}; };
/** /**
+2
View File
@@ -36,6 +36,7 @@ import {
} from '../hooks/UseGenerator'; } from '../hooks/UseGenerator';
import { useGlobalSettingsState } from '../states/SettingsStates'; import { useGlobalSettingsState } from '../states/SettingsStates';
import { RenderPartColumn } from '../tables/ColumnRenderers'; import { RenderPartColumn } from '../tables/ColumnRenderers';
import { TagsField } from './CommonFields';
/** /**
* Field set for BuildOrder forms * Field set for BuildOrder forms
@@ -124,6 +125,7 @@ export function useBuildOrderFields({
}, },
value: destination value: destination
}, },
tags: TagsField({}),
link: { link: {
icon: <IconLink /> icon: <IconLink />
}, },
+19
View File
@@ -0,0 +1,19 @@
import type { ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
export function TagsField({
label,
description,
placeholder
}: Readonly<{
label?: string;
description?: string;
placeholder?: string;
}>): ApiFormFieldType {
return {
field_type: 'tags',
label: label ?? t`Tags`,
description: description ?? t`Tags for this item`,
placeholder: placeholder ?? t`Select tags`
};
}
+4
View File
@@ -13,6 +13,7 @@ import {
IconPhone IconPhone
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { TagsField } from './CommonFields';
/** /**
* Field set for SupplierPart instance * Field set for SupplierPart instance
@@ -82,6 +83,7 @@ export function useSupplierPartFields({
icon: <IconHash /> icon: <IconHash />
}, },
description: {}, description: {},
tags: TagsField({}),
link: { link: {
icon: <IconLink /> icon: <IconLink />
}, },
@@ -117,6 +119,7 @@ export function useManufacturerPartFields() {
}, },
MPN: {}, MPN: {},
description: {}, description: {},
tags: TagsField({}),
link: {} link: {}
}; };
@@ -143,6 +146,7 @@ export function companyFields(): ApiFormFieldSet {
email: { email: {
icon: <IconAt /> icon: <IconAt />
}, },
tags: TagsField({}),
tax_id: {}, tax_id: {},
is_supplier: {}, is_supplier: {},
is_manufacturer: {}, is_manufacturer: {},
+2
View File
@@ -3,6 +3,7 @@ import { t } from '@lingui/core/macro';
import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react'; import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useGlobalSettingsState } from '../states/SettingsStates'; import { useGlobalSettingsState } from '../states/SettingsStates';
import { TagsField } from './CommonFields';
/** /**
* Construct a set of fields for creating / editing a Part instance * Construct a set of fields for creating / editing a Part instance
@@ -54,6 +55,7 @@ export function usePartFields({
} }
}, },
keywords: {}, keywords: {},
tags: TagsField({}),
units: {}, units: {},
link: {}, link: {},
default_location: { default_location: {
@@ -57,6 +57,7 @@ import {
useSerialNumberGenerator useSerialNumberGenerator
} from '../hooks/UseGenerator'; } from '../hooks/UseGenerator';
import { useGlobalSettingsState } from '../states/SettingsStates'; import { useGlobalSettingsState } from '../states/SettingsStates';
import { TagsField } from './CommonFields';
/* /*
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
*/ */
@@ -287,6 +288,7 @@ export function usePurchaseOrderFields({
structural: false structural: false
} }
}, },
tags: TagsField({}),
link: {}, link: {},
contact: { contact: {
icon: <IconUser />, icon: <IconUser />,
@@ -23,6 +23,7 @@ import { Thumbnail } from '../components/images/Thumbnail';
import { useCreateApiFormModal } from '../hooks/UseForm'; import { useCreateApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates'; import { useGlobalSettingsState } from '../states/SettingsStates';
import { StatusFilterOptions } from '../tables/Filter'; import { StatusFilterOptions } from '../tables/Filter';
import { TagsField } from './CommonFields';
export function useReturnOrderFields({ export function useReturnOrderFields({
duplicateOrderId duplicateOrderId
@@ -52,6 +53,7 @@ export function useReturnOrderFields({
icon: <IconCalendar /> icon: <IconCalendar />
}, },
link: {}, link: {},
tags: TagsField({}),
contact: { contact: {
icon: <IconUser />, icon: <IconUser />,
adjustFilters: (value: ApiFormAdjustFilterType) => { adjustFilters: (value: ApiFormAdjustFilterType) => {
@@ -31,6 +31,7 @@ import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates'; import { useGlobalSettingsState } from '../states/SettingsStates';
import { useUserState } from '../states/UserState'; import { useUserState } from '../states/UserState';
import { RenderPartColumn } from '../tables/ColumnRenderers'; import { RenderPartColumn } from '../tables/ColumnRenderers';
import { TagsField } from './CommonFields';
export function useSalesOrderFields({ export function useSalesOrderFields({
duplicateOrderId duplicateOrderId
@@ -64,6 +65,7 @@ export function useSalesOrderFields({
target_date: { target_date: {
icon: <IconCalendar /> icon: <IconCalendar />
}, },
tags: TagsField({}),
link: {}, link: {},
contact: { contact: {
icon: <IconUser />, icon: <IconUser />,
@@ -537,6 +539,7 @@ export function useSalesOrderShipmentFields({
}, },
tracking_number: {}, tracking_number: {},
invoice_number: {}, invoice_number: {},
tags: TagsField({}),
link: {} link: {}
}; };
}, [customerId, pending]); }, [customerId, pending]);
+2
View File
@@ -61,6 +61,7 @@ import {
import useStatusCodes from '../hooks/UseStatusCodes'; import useStatusCodes from '../hooks/UseStatusCodes';
import { useGlobalSettingsState } from '../states/SettingsStates'; import { useGlobalSettingsState } from '../states/SettingsStates';
import { StatusFilterOptions } from '../tables/Filter'; import { StatusFilterOptions } from '../tables/Filter';
import { TagsField } from './CommonFields';
/** /**
* Construct a set of fields for creating / editing a StockItem instance * Construct a set of fields for creating / editing a StockItem instance
@@ -272,6 +273,7 @@ export function useStockFields({
packaging: { packaging: {
icon: <IconPackage /> icon: <IconPackage />
}, },
tags: TagsField({}),
link: { link: {
icon: <IconLink /> icon: <IconLink />
}, },
@@ -10,6 +10,7 @@ import type { TableFieldRowProps } from '../components/forms/fields/TableField';
import { useCreateApiFormModal } from '../hooks/UseForm'; import { useCreateApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates'; import { useGlobalSettingsState } from '../states/SettingsStates';
import { RenderPartColumn } from '../tables/ColumnRenderers'; import { RenderPartColumn } from '../tables/ColumnRenderers';
import { TagsField } from './CommonFields';
export function useTransferOrderFields({ export function useTransferOrderFields({
duplicateOrderId duplicateOrderId
@@ -36,6 +37,7 @@ export function useTransferOrderFields({
} }
}, },
consume: {}, consume: {},
tags: TagsField({}),
link: {}, link: {},
responsible: { responsible: {
filters: { filters: {
+7 -1
View File
@@ -21,6 +21,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation'; import { getDetailUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel'; import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
@@ -228,7 +229,8 @@ export default function BuildDetail() {
endpoint: ApiEndpoints.build_order_list, endpoint: ApiEndpoints.build_order_list,
pk: id, pk: id,
params: { params: {
part_detail: true part_detail: true,
tags: true
}, },
refetchOnMount: true refetchOnMount: true
}); });
@@ -438,6 +440,7 @@ export default function BuildDetail() {
return ( return (
<ItemDetailsGrid> <ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow> <Grid grow>
<DetailsImage <DetailsImage
appRole={UserRoles.part} appRole={UserRoles.part}
@@ -449,6 +452,8 @@ export default function BuildDetail() {
<DetailsTable fields={tl} item={data} /> <DetailsTable fields={tl} item={data} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<TagsList tags={build.tags} />
</Stack>
<DetailsTable fields={tr} item={data} /> <DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} /> <DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} /> <DetailsTable fields={br} item={data} />
@@ -612,6 +617,7 @@ export default function BuildDetail() {
title: t`Edit Build Order`, title: t`Edit Build Order`,
modalId: 'edit-build-order', modalId: 'edit-build-order',
fields: editBuildOrderFields, fields: editBuildOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance onFormSuccess: refreshInstance
}); });
@@ -18,6 +18,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel'; import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions'; import { PrintingActions } from '../../components/buttons/PrintingActions';
@@ -78,7 +79,9 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
} = useInstance({ } = useInstance({
endpoint: ApiEndpoints.company_list, endpoint: ApiEndpoints.company_list,
pk: id, pk: id,
params: {}, params: {
tags: true
},
refetchOnMount: true refetchOnMount: true
}); });
@@ -153,6 +156,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
return ( return (
<ItemDetailsGrid> <ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow> <Grid grow>
<DetailsImage <DetailsImage
appRole={UserRoles.purchase_order} appRole={UserRoles.purchase_order}
@@ -170,6 +174,8 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
<DetailsTable item={company} fields={tl} /> <DetailsTable item={company} fields={tl} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<TagsList tags={company.tags} />
</Stack>
<DetailsTable item={company} fields={tr} /> <DetailsTable item={company} fields={tr} />
</ItemDetailsGrid> </ItemDetailsGrid>
); );
@@ -288,6 +294,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
pk: company?.pk, pk: company?.pk,
title: t`Edit Company`, title: t`Edit Company`,
fields: companyFields(), fields: companyFields(),
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance onFormSuccess: refreshInstance
}); });
@@ -8,6 +8,7 @@ import {
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
@@ -59,7 +60,8 @@ export default function ManufacturerPartDetail() {
hasPrimaryKey: true, hasPrimaryKey: true,
params: { params: {
part_detail: true, part_detail: true,
manufacturer_detail: true manufacturer_detail: true,
tags: true
} }
}); });
@@ -133,6 +135,7 @@ export default function ManufacturerPartDetail() {
return ( return (
<ItemDetailsGrid> <ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow> <Grid grow>
<DetailsImage <DetailsImage
appRole={UserRoles.part} appRole={UserRoles.part}
@@ -147,6 +150,8 @@ export default function ManufacturerPartDetail() {
<DetailsTable title={t`Part Details`} fields={tl} item={data} /> <DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<TagsList tags={manufacturerPart.tags} />
</Stack>
<DetailsTable title={t`Manufacturer Details`} fields={tr} item={data} /> <DetailsTable title={t`Manufacturer Details`} fields={tr} item={data} />
</ItemDetailsGrid> </ItemDetailsGrid>
); );
@@ -211,6 +216,7 @@ export default function ManufacturerPartDetail() {
pk: manufacturerPart?.pk, pk: manufacturerPart?.pk,
title: t`Edit Manufacturer Part`, title: t`Edit Manufacturer Part`,
fields: editManufacturerPartFields, fields: editManufacturerPartFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance onFormSuccess: refreshInstance
}); });
@@ -9,6 +9,7 @@ import {
import { type ReactNode, useMemo } from 'react'; import { type ReactNode, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
@@ -67,7 +68,8 @@ export default function SupplierPartDetail() {
params: { params: {
part_detail: true, part_detail: true,
supplier_detail: true, supplier_detail: true,
manufacturer_detail: true manufacturer_detail: true,
tags: true
} }
}); });
@@ -221,6 +223,7 @@ export default function SupplierPartDetail() {
return ( return (
<ItemDetailsGrid> <ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow> <Grid grow>
<DetailsImage <DetailsImage
appRole={UserRoles.part} appRole={UserRoles.part}
@@ -235,6 +238,8 @@ export default function SupplierPartDetail() {
<DetailsTable title={t`Part Details`} fields={tl} item={data} /> <DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<TagsList tags={supplierPart.tags} />
</Stack>
<DetailsTable title={t`Supplier`} fields={bl} item={data} /> <DetailsTable title={t`Supplier`} fields={bl} item={data} />
<DetailsTable title={t`Packaging`} fields={br} item={data} /> <DetailsTable title={t`Packaging`} fields={br} item={data} />
<DetailsTable title={t`Availability`} fields={tr} item={data} /> <DetailsTable title={t`Availability`} fields={tr} item={data} />
@@ -339,6 +344,7 @@ export default function SupplierPartDetail() {
pk: supplierPart?.pk, pk: supplierPart?.pk,
title: t`Edit Supplier Part`, title: t`Edit Supplier Part`,
fields: supplierPartFields, fields: supplierPartFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance onFormSuccess: refreshInstance
}); });
+5 -1
View File
@@ -41,6 +41,7 @@ import { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import Select from 'react-select'; import Select from 'react-select';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
@@ -190,7 +191,8 @@ export default function PartDetail() {
endpoint: ApiEndpoints.part_list, endpoint: ApiEndpoints.part_list,
pk: id, pk: id,
params: { params: {
path_detail: true path_detail: true,
tags: true
}, },
refetchOnMount: true refetchOnMount: true
}); });
@@ -612,6 +614,7 @@ export default function PartDetail() {
<DetailsTable fields={tl} item={data} /> <DetailsTable fields={tl} item={data} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<TagsList tags={part.tags} />
{enableRevisionSelection && ( {enableRevisionSelection && (
<Paper p='sm' withBorder> <Paper p='sm' withBorder>
<Stack gap='xs'> <Stack gap='xs'>
@@ -998,6 +1001,7 @@ export default function PartDetail() {
pk: part.pk, pk: part.pk,
title: t`Edit Part`, title: t`Edit Part`,
fields: partFields, fields: partFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance onFormSuccess: refreshInstance
}); });
@@ -9,6 +9,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel'; import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
@@ -65,7 +66,8 @@ export default function PurchaseOrderDetail() {
endpoint: ApiEndpoints.purchase_order_list, endpoint: ApiEndpoints.purchase_order_list,
pk: id, pk: id,
params: { params: {
supplier_detail: true supplier_detail: true,
tags: true
}, },
refetchOnMount: true refetchOnMount: true
}); });
@@ -89,6 +91,7 @@ export default function PurchaseOrderDetail() {
pk: id, pk: id,
title: t`Edit Purchase Order`, title: t`Edit Purchase Order`,
fields: purchaseOrderFields, fields: purchaseOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => { onFormSuccess: () => {
refreshInstance(); refreshInstance();
} }
@@ -318,6 +321,7 @@ export default function PurchaseOrderDetail() {
return ( return (
<ItemDetailsGrid> <ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow> <Grid grow>
<DetailsImage <DetailsImage
appRole={UserRoles.purchase_order} appRole={UserRoles.purchase_order}
@@ -329,6 +333,8 @@ export default function PurchaseOrderDetail() {
<DetailsTable fields={tl} item={order} /> <DetailsTable fields={tl} item={order} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} /> <DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} /> <DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} /> <DetailsTable fields={br} item={order} />
@@ -9,6 +9,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel'; import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
@@ -66,7 +67,8 @@ export default function ReturnOrderDetail() {
endpoint: ApiEndpoints.return_order_list, endpoint: ApiEndpoints.return_order_list,
pk: id, pk: id,
params: { params: {
customer_detail: true customer_detail: true,
tags: true
} }
}); });
@@ -296,6 +298,7 @@ export default function ReturnOrderDetail() {
return ( return (
<ItemDetailsGrid> <ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow> <Grid grow>
<DetailsImage <DetailsImage
appRole={UserRoles.purchase_order} appRole={UserRoles.purchase_order}
@@ -307,6 +310,8 @@ export default function ReturnOrderDetail() {
<DetailsTable fields={tl} item={order} /> <DetailsTable fields={tl} item={order} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} /> <DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} /> <DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} /> <DetailsTable fields={br} item={order} />
@@ -403,6 +408,7 @@ export default function ReturnOrderDetail() {
pk: order.pk, pk: order.pk,
title: t`Edit Return Order`, title: t`Edit Return Order`,
fields: returnOrderFields, fields: returnOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => { onFormSuccess: () => {
refreshInstance(); refreshInstance();
} }
@@ -11,6 +11,7 @@ import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { StylishText } from '@lib/components/StylishText'; import { StylishText } from '@lib/components/StylishText';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
@@ -75,7 +76,8 @@ export default function SalesOrderDetail() {
endpoint: ApiEndpoints.sales_order_list, endpoint: ApiEndpoints.sales_order_list,
pk: id, pk: id,
params: { params: {
customer_detail: true customer_detail: true,
tags: true
} }
}); });
@@ -287,6 +289,7 @@ export default function SalesOrderDetail() {
return ( return (
<ItemDetailsGrid> <ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow> <Grid grow>
<DetailsImage <DetailsImage
appRole={UserRoles.purchase_order} appRole={UserRoles.purchase_order}
@@ -298,6 +301,8 @@ export default function SalesOrderDetail() {
<DetailsTable fields={tl} item={order} /> <DetailsTable fields={tl} item={order} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} /> <DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} /> <DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} /> <DetailsTable fields={br} item={order} />
@@ -325,6 +330,7 @@ export default function SalesOrderDetail() {
pk: order.pk, pk: order.pk,
title: t`Edit Sales Order`, title: t`Edit Sales Order`,
fields: salesOrderFields, fields: salesOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => { onFormSuccess: () => {
refreshInstance(); refreshInstance();
} }
@@ -13,6 +13,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { getDetailUrl } from '@lib/functions/Navigation'; import { getDetailUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel'; import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton'; import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
@@ -68,7 +69,8 @@ export default function SalesOrderShipmentDetail() {
endpoint: ApiEndpoints.sales_order_shipment_list, endpoint: ApiEndpoints.sales_order_shipment_list,
pk: id, pk: id,
params: { params: {
order_detail: true order_detail: true,
tags: true
} }
}); });
@@ -221,6 +223,7 @@ export default function SalesOrderShipmentDetail() {
return ( return (
<> <>
<ItemDetailsGrid> <ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow> <Grid grow>
<DetailsImage <DetailsImage
appRole={UserRoles.sales_order} appRole={UserRoles.sales_order}
@@ -238,6 +241,8 @@ export default function SalesOrderShipmentDetail() {
<DetailsTable fields={tl} item={data} /> <DetailsTable fields={tl} item={data} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<TagsList tags={shipment.tags} />
</Stack>
<DetailsTable fields={tr} item={data} /> <DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} /> <DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} /> <DetailsTable fields={br} item={data} />
@@ -295,6 +300,7 @@ export default function SalesOrderShipmentDetail() {
pk: shipment.pk, pk: shipment.pk,
fields: editShipmentFields, fields: editShipmentFields,
title: t`Edit Shipment`, title: t`Edit Shipment`,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshShipment onFormSuccess: refreshShipment
}); });
@@ -3,6 +3,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation'; import { getDetailUrl } from '@lib/functions/Navigation';
import type { TableFilter } from '@lib/index';
import type { StockOperationProps } from '@lib/types/Forms'; import type { StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel'; import type { PanelType } from '@lib/types/Panel';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
@@ -60,6 +61,21 @@ import { StockLocationTable } from '../../tables/stock/StockLocationTable';
import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable'; import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable';
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable'; import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
function TransferOrderCalendar() {
const calendarFilters: TableFilter[] = useMemo(() => {
return [];
}, []);
return (
<OrderCalendar
model={ModelType.transferorder}
role={UserRoles.transfer_order}
params={{ outstanding: true }}
filters={calendarFilters}
/>
);
}
export default function Stock() { export default function Stock() {
const { id: _id } = useParams(); const { id: _id } = useParams();
@@ -247,13 +263,7 @@ export default function Stock() {
value: 'calendar', value: 'calendar',
label: t`Calendar View`, label: t`Calendar View`,
icon: <IconCalendar />, icon: <IconCalendar />,
content: ( content: <TransferOrderCalendar />
<OrderCalendar
model={ModelType.transferorder}
role={UserRoles.transfer_order}
params={{ outstanding: true }}
/>
)
}, },
{ {
value: 'parametric', value: 'parametric',
+9 -2
View File
@@ -35,6 +35,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation'; import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms'; import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel'; import type { PanelType } from '@lib/types/Panel';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
@@ -118,7 +119,8 @@ export default function StockDetail() {
params: { params: {
part_detail: true, part_detail: true,
location_detail: true, location_detail: true,
path_detail: true path_detail: true,
tags: true
} }
}); });
@@ -445,12 +447,14 @@ export default function StockDetail() {
return ( return (
<ItemDetailsGrid> <ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow> <Grid grow>
<DetailsImage <DetailsImage
appRole={UserRoles.part} appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list} apiPath={ApiEndpoints.part_list}
src={ src={
stockitem.part_detail?.image ?? stockitem?.part_detail?.thumbnail stockitem.part_detail?.image ??
stockitem?.part_detail?.thumbnail
} }
pk={stockitem.part} pk={stockitem.part}
/> />
@@ -458,6 +462,8 @@ export default function StockDetail() {
<DetailsTable fields={tl} item={data} /> <DetailsTable fields={tl} item={data} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<TagsList tags={stockitem.tags} />
</Stack>
<DetailsTable fields={tr} item={data} /> <DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} /> <DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} /> <DetailsTable fields={br} item={data} />
@@ -702,6 +708,7 @@ export default function StockDetail() {
title: t`Edit Stock Item`, title: t`Edit Stock Item`,
modalId: 'edit-stock-item', modalId: 'edit-stock-item',
fields: editStockItemFields, fields: editStockItemFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance onFormSuccess: refreshInstance
}); });
@@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { type PanelType, apiUrl } from '@lib/index'; import { type PanelType, TagsList, apiUrl } from '@lib/index';
import { import {
IconBookmark, IconBookmark,
IconInfoCircle, IconInfoCircle,
@@ -62,7 +62,9 @@ export default function TransferOrderDetail() {
} = useInstance({ } = useInstance({
endpoint: ApiEndpoints.transfer_order_list, endpoint: ApiEndpoints.transfer_order_list,
pk: id, pk: id,
params: {} params: {
tags: true
}
}); });
const toStatus = useStatusCodes({ modelType: ModelType.transferorder }); const toStatus = useStatusCodes({ modelType: ModelType.transferorder });
@@ -233,6 +235,7 @@ export default function TransferOrderDetail() {
return ( return (
<ItemDetailsGrid> <ItemDetailsGrid>
<Stack gap='xs'>
<Grid grow> <Grid grow>
{/* TODO: what image do we show for a Transfer Order? */} {/* TODO: what image do we show for a Transfer Order? */}
{/* <DetailsImage {/* <DetailsImage
@@ -245,6 +248,8 @@ export default function TransferOrderDetail() {
<DetailsTable fields={tl} item={order} /> <DetailsTable fields={tl} item={order} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} /> <DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} /> <DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} /> <DetailsTable fields={br} item={order} />
@@ -369,6 +374,7 @@ export default function TransferOrderDetail() {
pk: order.pk, pk: order.pk,
title: t`Edit Transfer Order`, title: t`Edit Transfer Order`,
fields: transferOrderFields, fields: transferOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => { onFormSuccess: () => {
refreshInstance(); refreshInstance();
} }
+23
View File
@@ -399,6 +399,29 @@ export function ResponsibleFilter(): TableFilter {
}); });
} }
export function TagsFilter({
modelType
}: {
modelType?: ModelType;
}): TableFilter {
return {
name: 'tags',
label: t`Tags`,
description: t`Filter by tags`,
placeholder: t`Select tags`,
type: 'api',
multi: true,
apiUrl: apiUrl(ApiEndpoints.tag_list),
model: ModelType.tag,
modelRenderer: (instance: any) => instance.name,
apiFilter: modelType ? { model_type: modelType } : undefined,
transform: (item: any) => ({
value: item.name.toString(),
label: item.name.toString()
})
};
}
export function UserFilter({ export function UserFilter({
name, name,
label, label,
+88 -1
View File
@@ -7,6 +7,7 @@ import {
Divider, Divider,
Drawer, Drawer,
Group, Group,
MultiSelect,
Paper, Paper,
Select, Select,
Space, Space,
@@ -16,6 +17,7 @@ import {
Tooltip Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { DateInput, type DateValue } from '@mantine/dates'; import { DateInput, type DateValue } from '@mantine/dates';
import { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -27,6 +29,7 @@ import type {
TableFilterType TableFilterType
} from '@lib/types/Filters'; } from '@lib/types/Filters';
import { IconCheck } from '@tabler/icons-react'; import { IconCheck } from '@tabler/icons-react';
import { api } from '../App';
import { StandaloneField } from '../components/forms/StandaloneField'; import { StandaloneField } from '../components/forms/StandaloneField';
import { import {
filterDisplayLabel, filterDisplayLabel,
@@ -106,6 +109,74 @@ function FilterItem({
); );
} }
/*
* Multi-select element for 'api' filters with multi=true.
* Fetches all options from the API then renders a searchable MultiSelect.
* The user picks multiple values and confirms with an Apply button.
*/
function MultiApiFilterElement({
filterProps,
onValueChange
}: Readonly<{
filterProps: TableFilter;
onValueChange: (value: string | null, displayValue?: any) => void;
}>) {
const [selected, setSelected] = useState<string[]>([]);
const query = useQuery({
queryKey: ['filter-options', filterProps.apiUrl, filterProps.apiFilter],
queryFn: () =>
api
.get(filterProps.apiUrl ?? '', { params: filterProps.apiFilter })
.then((r) => r.data),
enabled: !!filterProps.apiUrl
});
const options: TableFilterChoice[] = useMemo(() => {
const results: any[] = query.data?.results ?? query.data ?? [];
if (filterProps.transform) {
return results.map(filterProps.transform);
}
return results.map((item: any) => ({
value: String(item.pk ?? item.id ?? item.slug ?? item.value),
label: filterProps.modelRenderer?.(item) ?? String(item.name ?? item.pk)
}));
}, [query.data, filterProps.transform, filterProps.modelRenderer]);
const apply = useCallback(() => {
if (!selected.length) return;
const labels = selected.map(
(v) => options.find((o) => o.value === v)?.label ?? v
);
onValueChange(selected.join(','), labels.join(', '));
}, [selected, options, onValueChange]);
return (
<MultiSelect
data={options}
value={selected}
onChange={setSelected}
searchable
label={t`Value`}
placeholder={filterProps.placeholder ?? t`Select one or more values`}
maxDropdownHeight={400}
rightSectionPointerEvents='all'
rightSection={
<ActionIcon
aria-label='apply-tags-filter'
variant='transparent'
color='green'
size='md'
disabled={!selected.length}
onClick={apply}
>
<IconCheck />
</ActionIcon>
}
/>
);
}
function FilterElement({ function FilterElement({
filterName, filterName,
filterProps, filterProps,
@@ -133,6 +204,14 @@ function FilterElement({
switch (filterProps.type) { switch (filterProps.type) {
case 'api': case 'api':
if (filterProps.multi) {
return (
<MultiApiFilterElement
filterProps={filterProps}
onValueChange={onValueChange}
/>
);
}
return ( return (
<StandaloneField <StandaloneField
fieldName={`filter-${filterName}`} fieldName={`filter-${filterName}`}
@@ -144,7 +223,15 @@ function FilterElement({
model: filterProps.model, model: filterProps.model,
label: t`Select filter value`, label: t`Select filter value`,
onValueChange: (value: any, instance: any) => { onValueChange: (value: any, instance: any) => {
onValueChange(value, filterProps.modelRenderer?.(instance)); if (filterProps.transform) {
const choice = filterProps.transform(instance);
onValueChange(choice.value, choice.label);
} else {
onValueChange(
value,
filterProps.modelRenderer?.(instance) ?? value
);
}
} }
}} }}
/> />
@@ -22,6 +22,7 @@ import {
ResponsibleFilter, ResponsibleFilter,
StartDateAfterFilter, StartDateAfterFilter,
StartDateBeforeFilter, StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter, TargetDateAfterFilter,
TargetDateBeforeFilter TargetDateBeforeFilter
} from '../Filter'; } from '../Filter';
@@ -49,7 +50,8 @@ export default function BuildOrderFilters({
HasProjectCodeFilter(), HasProjectCodeFilter(),
IssuedByFilter(), IssuedByFilter(),
ResponsibleFilter(), ResponsibleFilter(),
PartCategoryFilter() PartCategoryFilter(),
TagsFilter({ modelType: ModelType.build })
]; ];
const dateFilters: TableFilter[] = [ const dateFilters: TableFilter[] = [
@@ -22,6 +22,7 @@ import {
CompanyColumn, CompanyColumn,
DescriptionColumn DescriptionColumn
} from '../ColumnRenderers'; } from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
/** /**
@@ -115,7 +116,8 @@ export function CompanyTable({
name: 'is_customer', name: 'is_customer',
label: t`Customer`, label: t`Customer`,
description: t`Show companies which are customers` description: t`Show companies which are customers`
} },
TagsFilter({ modelType: ModelType.company })
]; ];
}, []); }, []);
@@ -1,5 +1,7 @@
import { ModelType } from '@lib/enums/ModelType';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { TagsFilter } from '../Filter';
/** /**
* Construct a set of filters for the part table * Construct a set of filters for the part table
@@ -141,6 +143,9 @@ export function PartTableFilters(): TableFilter[] {
label: t`Subscribed`, label: t`Subscribed`,
description: t`Filter by parts to which the user is subscribed`, description: t`Filter by parts to which the user is subscribed`,
type: 'boolean' type: 'boolean'
} },
TagsFilter({
modelType: ModelType.part
})
]; ];
} }
@@ -29,6 +29,7 @@ import {
LinkColumn, LinkColumn,
PartColumn PartColumn
} from '../ColumnRenderers'; } from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
/* /*
@@ -161,7 +162,8 @@ export function ManufacturerPartTable({
active: !manufacturerId, active: !manufacturerId,
description: t`Show manufacturer parts for active manufacturers.`, description: t`Show manufacturer parts for active manufacturers.`,
type: 'boolean' type: 'boolean'
} },
TagsFilter({ modelType: ModelType.manufacturerpart })
]; ];
}, [manufacturerId]); }, [manufacturerId]);
@@ -19,6 +19,7 @@ import {
ResponsibleFilter, ResponsibleFilter,
StartDateAfterFilter, StartDateAfterFilter,
StartDateBeforeFilter, StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter, TargetDateAfterFilter,
TargetDateBeforeFilter, TargetDateBeforeFilter,
UpdatedAfterFilter, UpdatedAfterFilter,
@@ -38,7 +39,8 @@ export default function PurchaseOrderFilters({
ProjectCodeFilter(), ProjectCodeFilter(),
HasProjectCodeFilter(), HasProjectCodeFilter(),
ResponsibleFilter(), ResponsibleFilter(),
CreatedByFilter() CreatedByFilter(),
TagsFilter({ modelType: ModelType.purchaseorder })
]; ];
const dateFilters: TableFilter[] = [ const dateFilters: TableFilter[] = [
@@ -37,6 +37,7 @@ import {
NoteColumn, NoteColumn,
PartColumn PartColumn
} from '../ColumnRenderers'; } from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard'; import { TableHoverCard } from '../TableHoverCard';
@@ -60,7 +61,8 @@ export function SupplierPartTable({
{ {
name: 'active', name: 'active',
value: 'true' value: 'true'
} },
TagsFilter({ modelType: ModelType.supplierpart })
]; ];
if (!supplierId) { if (!supplierId) {
@@ -20,6 +20,7 @@ import {
ResponsibleFilter, ResponsibleFilter,
StartDateAfterFilter, StartDateAfterFilter,
StartDateBeforeFilter, StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter, TargetDateAfterFilter,
TargetDateBeforeFilter, TargetDateBeforeFilter,
UpdatedAfterFilter, UpdatedAfterFilter,
@@ -41,7 +42,8 @@ export default function ReturnOrderFilters({
HasProjectCodeFilter(), HasProjectCodeFilter(),
ProjectCodeFilter(), ProjectCodeFilter(),
ResponsibleFilter(), ResponsibleFilter(),
CreatedByFilter() CreatedByFilter(),
TagsFilter({ modelType: ModelType.returnorder })
]; ];
const dateFilters: TableFilter[] = [ const dateFilters: TableFilter[] = [
@@ -20,6 +20,7 @@ import {
ResponsibleFilter, ResponsibleFilter,
StartDateAfterFilter, StartDateAfterFilter,
StartDateBeforeFilter, StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter, TargetDateAfterFilter,
TargetDateBeforeFilter, TargetDateBeforeFilter,
UpdatedAfterFilter, UpdatedAfterFilter,
@@ -41,7 +42,8 @@ export default function SalesOrderFilters({
HasProjectCodeFilter(), HasProjectCodeFilter(),
ProjectCodeFilter(), ProjectCodeFilter(),
ResponsibleFilter(), ResponsibleFilter(),
CreatedByFilter() CreatedByFilter(),
TagsFilter({ modelType: ModelType.salesorder })
]; ];
const dateFilters: TableFilter[] = [ const dateFilters: TableFilter[] = [
@@ -25,7 +25,6 @@ import type { TableColumn } from '@lib/types/Tables';
import { import {
useCheckShipmentForm, useCheckShipmentForm,
useCompleteShipmentForm, useCompleteShipmentForm,
useSalesOrderShipmentCompleteFields,
useSalesOrderShipmentFields, useSalesOrderShipmentFields,
useUncheckShipmentForm useUncheckShipmentForm
} from '../../forms/SalesOrderForms'; } from '../../forms/SalesOrderForms';
@@ -41,6 +40,7 @@ import {
LinkColumn, LinkColumn,
StatusColumn StatusColumn
} from '../ColumnRenderers'; } from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
export default function SalesOrderShipmentTable({ export default function SalesOrderShipmentTable({
@@ -71,8 +71,6 @@ export default function SalesOrderShipmentTable({
pending: !selectedShipment.shipment_date pending: !selectedShipment.shipment_date
}); });
const completeShipmentFields = useSalesOrderShipmentCompleteFields({});
const newShipment = useCreateApiFormModal({ const newShipment = useCreateApiFormModal({
url: ApiEndpoints.sales_order_shipment_list, url: ApiEndpoints.sales_order_shipment_list,
fields: newShipmentFields, fields: newShipmentFields,
@@ -303,7 +301,8 @@ export default function SalesOrderShipmentTable({
name: 'delivered', name: 'delivered',
label: t`Delivered`, label: t`Delivered`,
description: t`Show shipments which have been delivered` description: t`Show shipments which have been delivered`
} },
TagsFilter({ modelType: ModelType.salesordershipment })
]; ];
}, []); }, []);
@@ -44,6 +44,7 @@ import {
SerialLTEFilter, SerialLTEFilter,
StatusFilterOptions, StatusFilterOptions,
SupplierFilter, SupplierFilter,
TagsFilter,
UpdatedAfterFilter, UpdatedAfterFilter,
UpdatedBeforeFilter UpdatedBeforeFilter
} from '../Filter'; } from '../Filter';
@@ -306,7 +307,8 @@ function stockItemTableFilters({
name: 'external', name: 'external',
label: t`External Location`, label: t`External Location`,
description: t`Show items in an external location` description: t`Show items in an external location`
} },
TagsFilter({ modelType: ModelType.stockitem })
]; ];
} }
@@ -0,0 +1,79 @@
import { ModelType, type TableFilter } from '@lib/index';
import { t } from '@lingui/core/macro';
import {
AssignedToMeFilter,
CompletedAfterFilter,
CompletedBeforeFilter,
CreatedAfterFilter,
CreatedBeforeFilter,
CreatedByFilter,
HasProjectCodeFilter,
IncludeVariantsFilter,
MaxDateFilter,
MinDateFilter,
OrderStatusFilter,
OutstandingFilter,
OverdueFilter,
ProjectCodeFilter,
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
} from '../Filter';
export default function TransferOrderFilters({
partId,
includeDateFilters = true
}: {
partId?: number;
includeDateFilters?: boolean;
}): TableFilter[] {
const filters: TableFilter[] = [
OrderStatusFilter({ model: ModelType.transferorder }),
OutstandingFilter(),
OverdueFilter(),
AssignedToMeFilter(),
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter(),
TagsFilter({ modelType: ModelType.transferorder })
];
const dateFilters: TableFilter[] = [
MinDateFilter(),
MaxDateFilter(),
CreatedBeforeFilter(),
CreatedAfterFilter(),
TargetDateBeforeFilter(),
TargetDateAfterFilter(),
StartDateBeforeFilter(),
StartDateAfterFilter(),
{
name: 'has_target_date',
type: 'boolean',
label: t`Has Target Date`,
description: t`Show orders with a target date`
},
{
name: 'has_start_date',
type: 'boolean',
label: t`Has Start Date`,
description: t`Show orders with a start date`
},
CompletedBeforeFilter(),
CompletedAfterFilter()
];
if (includeDateFilters) {
filters.push(...dateFilters);
}
if (!!partId) {
filters.push(IncludeVariantsFilter());
}
return filters;
}
@@ -22,28 +22,8 @@ import {
StatusColumn, StatusColumn,
TargetDateColumn TargetDateColumn
} from '../ColumnRenderers'; } from '../ColumnRenderers';
import {
AssignedToMeFilter,
CompletedAfterFilter,
CompletedBeforeFilter,
CreatedAfterFilter,
CreatedBeforeFilter,
CreatedByFilter,
HasProjectCodeFilter,
IncludeVariantsFilter,
MaxDateFilter,
MinDateFilter,
OrderStatusFilter,
OutstandingFilter,
OverdueFilter,
ProjectCodeFilter,
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
} from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import TransferOrderFilters from './TransferOrderFilters';
export function TransferOrderTable({ export function TransferOrderTable({
partId partId
@@ -56,45 +36,8 @@ export function TransferOrderTable({
const user = useUserState(); const user = useUserState();
const tableFilters: TableFilter[] = useMemo(() => { const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [ return TransferOrderFilters({ includeDateFilters: true });
OrderStatusFilter({ model: ModelType.transferorder }), }, []);
OutstandingFilter(),
OverdueFilter(),
AssignedToMeFilter(),
MinDateFilter(),
MaxDateFilter(),
CreatedBeforeFilter(),
CreatedAfterFilter(),
TargetDateBeforeFilter(),
TargetDateAfterFilter(),
StartDateBeforeFilter(),
StartDateAfterFilter(),
{
name: 'has_target_date',
type: 'boolean',
label: t`Has Target Date`,
description: t`Show orders with a target date`
},
{
name: 'has_start_date',
type: 'boolean',
label: t`Has Start Date`,
description: t`Show orders with a start date`
},
CompletedBeforeFilter(),
CompletedAfterFilter(),
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
];
if (!!partId) {
filters.push(IncludeVariantsFilter());
}
return filters;
}, [partId]);
const tableColumns = useMemo(() => { const tableColumns = useMemo(() => {
return [ return [
@@ -120,6 +120,51 @@ test('Build Order - Basic Tests', async ({ browser }) => {
.waitFor(); .waitFor();
}); });
// Test tags filtering against Build Orders
test('Build Order - Tags', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'manufacturing/index/buildorders'
});
// Filter by tag
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
await clearTableFilters(page);
await page.getByRole('button', { name: 'table-select-filters' }).click();
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByRole('combobox', { name: 'Filter' }).fill('tag');
await page.getByRole('option', { name: 'Tags' }).click();
await page.getByRole('combobox', { name: 'Value' }).click();
// Check for expected tags
await page.getByRole('option', { name: 'Furniture' }).waitFor();
await page.getByRole('option', { name: 'Electronics' }).click();
await page.getByRole('option', { name: 'PCB Assembly' }).click();
// Apply the "Furniture" tag filter
await page.getByRole('button', { name: 'apply-tags-filter' }).click();
await page.getByRole('button', { name: 'filter-drawer-close' }).click();
// Check for expected results
await page.getByRole('cell', { name: 'BO0026' }).click();
await page.getByText('100 x 002.01-PCBA | Widget').waitFor();
// Check for tags displayed on BuildOrder detail page
await page.getByText('Electronics', { exact: true }).first().waitFor();
await page.getByText('PCB Assembly', { exact: true }).first().waitFor();
// Edit the build order
await page.keyboard.press('Control+E');
const tagsField = await page.getByRole('combobox', {
name: 'tags-field-tags'
});
await expect(tagsField).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
});
// Test that the build order reference field increments correctly // Test that the build order reference field increments correctly
test('Build Order - Reference', async ({ browser }) => { test('Build Order - Reference', async ({ browser }) => {
const page = await doCachedLogin(browser, { const page = await doCachedLogin(browser, {
@@ -276,6 +276,29 @@ test('Sales Orders - Shipments', async ({ browser }) => {
.click(); .click();
}); });
// Filter Shipments by tag
test('Sales Orders - Shipments - Tags', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'sales/index/shipments' });
// Filter by tag
await clearTableFilters(page);
await page.getByRole('button', { name: 'table-select-filters' }).click();
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByRole('combobox', { name: 'Filter' }).fill('tag');
await page.getByRole('option', { name: 'Tags' }).click();
await page.getByRole('combobox', { name: 'Value' }).click();
// Apply the "Requires Payment" tag filter
await page.getByRole('option', { name: 'Requires Payment' }).click();
await page.getByRole('button', { name: 'apply-tags-filter' }).click();
await page.getByRole('button', { name: 'filter-drawer-close' }).click();
// Click through to one of the selected shipments
await page.getByRole('cell', { name: 'SO0007' }).click();
await page.getByText('Sales Order: SO0007').first().waitFor();
await page.getByText('Requires Payment', { exact: true }).first().waitFor();
});
// Complete a shipment against a sales order // Complete a shipment against a sales order
test('Sales Orders - Complete Shipment', async ({ browser }) => { test('Sales Orders - Complete Shipment', async ({ browser }) => {
const page = await doCachedLogin(browser, { const page = await doCachedLogin(browser, {