mirror of
https://github.com/inventree/InvenTree.git
synced 2026-06-12 11:38:47 +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:
@@ -1,11 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# 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."""
|
||||
|
||||
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
|
||||
- Fixes search field issues on the BarcodeScanHistory API endpoint
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from error_report.models import Error
|
||||
from mptt.exceptions import InvalidMove
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from stdimage.models import StdImageField
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
import common.settings
|
||||
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):
|
||||
"""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)
|
||||
|
||||
|
||||
class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSerializer):
|
||||
"""Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer."""
|
||||
|
||||
|
||||
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||
"""Override the DRF native FileField serializer, to remove the leading server path.
|
||||
|
||||
|
||||
@@ -1169,3 +1169,6 @@ if 'dbbackup' not in STORAGES:
|
||||
if _media:
|
||||
MEDIA_URL = _media
|
||||
PRESIGNED_URL_EXPIRATION = 600
|
||||
|
||||
# Taggit settings
|
||||
TAGGIT_CASE_INSENSITIVE = True
|
||||
|
||||
@@ -16,6 +16,7 @@ from rest_framework.response import Response
|
||||
|
||||
import build.models as build_models
|
||||
import build.serializers
|
||||
import common.filters
|
||||
import common.models
|
||||
import common.serializers
|
||||
import part.models as part_models
|
||||
@@ -307,6 +308,8 @@ class BuildFilter(FilterSet):
|
||||
|
||||
return queryset
|
||||
|
||||
tags = common.filters.TagsFilter()
|
||||
|
||||
|
||||
class BuildMixin:
|
||||
"""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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -79,6 +79,7 @@ class Build(
|
||||
InvenTree.models.InvenTreeParameterMixin,
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeTagsMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
InvenTree.models.ReferenceIndexingMixin,
|
||||
StateTransitionMixin,
|
||||
|
||||
@@ -36,6 +36,7 @@ from InvenTree.serializers import (
|
||||
FilterableSerializerMixin,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeTaggitSerializer,
|
||||
NotesFieldMixin,
|
||||
OptionalField,
|
||||
)
|
||||
@@ -57,6 +58,7 @@ class BuildSerializer(
|
||||
CustomStatusSerializerMixin,
|
||||
FilterableSerializerMixin,
|
||||
NotesFieldMixin,
|
||||
InvenTreeTaggitSerializer,
|
||||
DataImportExportSerializerMixin,
|
||||
InvenTreeCustomStatusSerializerMixin,
|
||||
InvenTreeModelSerializer,
|
||||
@@ -103,6 +105,7 @@ class BuildSerializer(
|
||||
'parameters',
|
||||
'priority',
|
||||
'level',
|
||||
'tags',
|
||||
]
|
||||
read_only_fields = [
|
||||
'completed',
|
||||
@@ -127,6 +130,8 @@ class BuildSerializer(
|
||||
|
||||
parameters = common.filters.enable_parameters_filter()
|
||||
|
||||
tags = common.filters.enable_tags_filter()
|
||||
|
||||
part_name = serializers.CharField(
|
||||
source='part.name', read_only=True, label=_('Part Name')
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from sql_util.utils import SubqueryCount
|
||||
from taggit.models import Tag
|
||||
|
||||
import common.filters
|
||||
import common.models
|
||||
@@ -504,6 +505,48 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
|
||||
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):
|
||||
"""List view for custom units."""
|
||||
|
||||
@@ -745,6 +788,8 @@ class AttachmentFilter(FilterSet):
|
||||
return queryset.exclude(attachment=None).exclude(attachment='')
|
||||
return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct()
|
||||
|
||||
tags = common.filters.TagsFilter()
|
||||
|
||||
|
||||
class AttachmentMixin:
|
||||
"""Mixin class for Attachment views."""
|
||||
@@ -1554,6 +1599,14 @@ common_api_urls = [
|
||||
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
|
||||
path(
|
||||
'flags/',
|
||||
|
||||
@@ -19,6 +19,7 @@ from django.db.models import (
|
||||
from django.db.models.query import QuerySet
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import django_filters.rest_framework.filters as rest_filters
|
||||
from rest_framework import serializers
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
@@ -93,6 +94,36 @@ def filter_content_type(
|
||||
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."""
|
||||
PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains']
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ from djmoney.contrib.exchange.models import convert_money
|
||||
from opentelemetry import trace
|
||||
from PIL import Image
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
import common.validators
|
||||
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.
|
||||
|
||||
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')
|
||||
)
|
||||
|
||||
tags = TaggableManager(blank=True)
|
||||
|
||||
@property
|
||||
def basename(self):
|
||||
"""Base name/path for attachment."""
|
||||
|
||||
@@ -12,6 +12,7 @@ from error_report.models import Error
|
||||
from flags.state import flag_state
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.models import Tag
|
||||
|
||||
import common.filters
|
||||
import common.models as common_models
|
||||
@@ -28,6 +29,7 @@ from InvenTree.serializers import (
|
||||
InvenTreeAttachmentSerializerField,
|
||||
InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeTaggitSerializer,
|
||||
OptionalField,
|
||||
)
|
||||
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()
|
||||
class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for the custom state model."""
|
||||
@@ -720,7 +745,9 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
|
||||
result = serializers.CharField()
|
||||
|
||||
|
||||
class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
|
||||
class AttachmentSerializer(
|
||||
FilterableSerializerMixin, InvenTreeTaggitSerializer, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serializer class for the Attachment model."""
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
|
||||
from PIL import Image
|
||||
from taggit.models import Tag
|
||||
|
||||
import common.models
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
@@ -999,3 +1000,170 @@ class AttachmentThumbnailAPITests(InvenTreeAPITestCase):
|
||||
set_global_setting(
|
||||
'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)
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
import django_filters.rest_framework.filters as rest_filters
|
||||
from django_filters.rest_framework.filterset import FilterSet
|
||||
|
||||
import common.filters
|
||||
import part.models
|
||||
from data_exporter.mixins import DataExportViewMixin
|
||||
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):
|
||||
"""Mixin class for Company API endpoints."""
|
||||
|
||||
@@ -62,13 +75,7 @@ class CompanyList(CompanyMixin, ParameterListMixin, DataExportViewMixin, ListCre
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
filterset_fields = [
|
||||
'is_customer',
|
||||
'is_manufacturer',
|
||||
'is_supplier',
|
||||
'name',
|
||||
'active',
|
||||
]
|
||||
filterset_class = CompanyFilter
|
||||
|
||||
search_fields = ['name', 'description', 'website', 'tax_id']
|
||||
|
||||
@@ -145,6 +152,8 @@ class ManufacturerPartFilter(FilterSet):
|
||||
field_name='manufacturer__active', label=_('Manufacturer is Active')
|
||||
)
|
||||
|
||||
tags = common.filters.TagsFilter(label=_('Tags'))
|
||||
|
||||
|
||||
class ManufacturerOutputOptions(OutputConfiguration):
|
||||
"""Available output options for the ManufacturerPart endpoints."""
|
||||
@@ -299,6 +308,8 @@ class SupplierPartFilter(FilterSet):
|
||||
else:
|
||||
return queryset.exclude(in_stock__gt=0)
|
||||
|
||||
tags = common.filters.TagsFilter(label=_('Tags'))
|
||||
|
||||
|
||||
class SupplierPartOutputOptions(OutputConfiguration):
|
||||
"""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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -17,7 +17,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy as __
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
import common.currency
|
||||
import common.models
|
||||
@@ -80,6 +79,7 @@ class Company(
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeParameterMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
InvenTree.models.InvenTreeTagsMixin,
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
InvenTree.models.InvenTreeImageMixin,
|
||||
InvenTree.models.InvenTreeMetadataModel,
|
||||
@@ -487,6 +487,7 @@ class ManufacturerPart(
|
||||
InvenTree.models.InvenTreeParameterMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
InvenTree.models.InvenTreeTagsMixin,
|
||||
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.
|
||||
@@ -559,8 +560,6 @@ class ManufacturerPart(
|
||||
help_text=_('Manufacturer part description'),
|
||||
)
|
||||
|
||||
tags = TaggableManager(blank=True)
|
||||
|
||||
@classmethod
|
||||
def create(cls, part, manufacturer, mpn, description, link=None):
|
||||
"""Check if ManufacturerPart instance does not already exist then create it."""
|
||||
@@ -603,6 +602,7 @@ class SupplierPart(
|
||||
InvenTree.models.InvenTreeParameterMixin,
|
||||
InvenTree.models.MetadataMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeTagsMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
common.models.MetaMixin,
|
||||
InvenTree.models.InvenTreeModel,
|
||||
@@ -640,8 +640,6 @@ class SupplierPart(
|
||||
# This model was moved from the 'Part' app
|
||||
db_table = 'part_supplierpart'
|
||||
|
||||
tags = TaggableManager(blank=True)
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the SupplierPart model."""
|
||||
|
||||
@@ -20,7 +20,7 @@ from InvenTree.serializers import (
|
||||
InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer,
|
||||
InvenTreeTagModelSerializer,
|
||||
InvenTreeTaggitSerializer,
|
||||
NotesFieldMixin,
|
||||
OptionalField,
|
||||
)
|
||||
@@ -108,6 +108,7 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
|
||||
class CompanySerializer(
|
||||
FilterableSerializerMixin,
|
||||
DataImportExportSerializerMixin,
|
||||
InvenTreeTaggitSerializer,
|
||||
NotesFieldMixin,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
@@ -143,6 +144,7 @@ class CompanySerializer(
|
||||
'primary_address',
|
||||
'tax_id',
|
||||
'parameters',
|
||||
'tags',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -185,6 +187,8 @@ class CompanySerializer(
|
||||
help_text=_('Default currency used for this supplier'), required=True
|
||||
)
|
||||
|
||||
tags = common.filters.enable_tags_filter()
|
||||
|
||||
parameters = common.filters.enable_parameters_filter()
|
||||
|
||||
|
||||
@@ -207,8 +211,9 @@ class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerialize
|
||||
class ManufacturerPartSerializer(
|
||||
FilterableSerializerMixin,
|
||||
DataImportExportSerializerMixin,
|
||||
InvenTreeTagModelSerializer,
|
||||
InvenTreeTaggitSerializer,
|
||||
NotesFieldMixin,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for ManufacturerPart object."""
|
||||
|
||||
@@ -308,8 +313,9 @@ class SupplierPriceBreakBriefSerializer(
|
||||
class SupplierPartSerializer(
|
||||
FilterableSerializerMixin,
|
||||
DataImportExportSerializerMixin,
|
||||
InvenTreeTagModelSerializer,
|
||||
InvenTreeTaggitSerializer,
|
||||
NotesFieldMixin,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for SupplierPart object."""
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from rest_framework.exceptions import NotFound
|
||||
from rest_framework.response import Response
|
||||
|
||||
import build.models
|
||||
import common.filters
|
||||
import common.models
|
||||
import common.serializers
|
||||
import common.settings
|
||||
@@ -277,6 +278,8 @@ class OrderFilter(FilterSet):
|
||||
|
||||
return queryset.filter(q1 | q2 | q3 | q4).distinct()
|
||||
|
||||
tags = common.filters.TagsFilter()
|
||||
|
||||
|
||||
class LineItemFilter(FilterSet):
|
||||
"""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()
|
||||
|
||||
tags = common.filters.TagsFilter()
|
||||
|
||||
|
||||
class SalesOrderShipmentMixin:
|
||||
"""Mixin class for SalesOrderShipment endpoints."""
|
||||
|
||||
+73
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -300,6 +300,7 @@ class Order(
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
InvenTree.models.InvenTreeTagsMixin,
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
InvenTree.models.MetadataMixin,
|
||||
InvenTree.models.ReferenceIndexingMixin,
|
||||
@@ -2465,6 +2466,7 @@ class SalesOrderShipment(
|
||||
InvenTree.models.InvenTreeParameterMixin,
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeTagsMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
InvenTree.models.MetadataMixin,
|
||||
|
||||
@@ -37,6 +37,7 @@ from InvenTree.serializers import (
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer,
|
||||
InvenTreeTaggitSerializer,
|
||||
NotesFieldMixin,
|
||||
OptionalField,
|
||||
)
|
||||
@@ -103,6 +104,7 @@ class DuplicateOrderSerializer(serializers.Serializer):
|
||||
class AbstractOrderSerializer(
|
||||
CustomStatusSerializerMixin,
|
||||
DataImportExportSerializerMixin,
|
||||
InvenTreeTaggitSerializer,
|
||||
FilterableSerializerMixin,
|
||||
serializers.Serializer,
|
||||
):
|
||||
@@ -172,6 +174,8 @@ class AbstractOrderSerializer(
|
||||
|
||||
parameters = common.filters.enable_parameters_filter()
|
||||
|
||||
tags = common.filters.enable_tags_filter()
|
||||
|
||||
# Boolean field indicating if this order is overdue (Note: must be annotated)
|
||||
overdue = serializers.BooleanField(read_only=True, allow_null=True)
|
||||
|
||||
@@ -236,6 +240,7 @@ class AbstractOrderSerializer(
|
||||
'project_code_label',
|
||||
'responsible_detail',
|
||||
'parameters',
|
||||
'tags',
|
||||
*extra_fields,
|
||||
]
|
||||
|
||||
@@ -1065,6 +1070,7 @@ class SalesOrderSerializer(
|
||||
TotalPriceMixin,
|
||||
InvenTreeCustomStatusSerializerMixin,
|
||||
AbstractOrderSerializer,
|
||||
InvenTreeTaggitSerializer,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for the SalesOrder model class."""
|
||||
@@ -1084,6 +1090,7 @@ class SalesOrderSerializer(
|
||||
'completed_shipments_count',
|
||||
'allocated_lines',
|
||||
'updated_at',
|
||||
'tags',
|
||||
])
|
||||
read_only_fields = ['status', 'creation_date', 'shipment_date', 'updated_at']
|
||||
extra_kwargs = {'order_currency': {'required': False}}
|
||||
@@ -1165,6 +1172,8 @@ class SalesOrderSerializer(
|
||||
read_only=True, allow_null=True, label=_('Allocated Lines')
|
||||
)
|
||||
|
||||
tags = common.filters.enable_tags_filter()
|
||||
|
||||
|
||||
class SalesOrderIssueSerializer(OrderAdjustSerializer):
|
||||
"""Serializer for issuing a SalesOrder."""
|
||||
@@ -1363,6 +1372,7 @@ class SalesOrderLineItemSerializer(
|
||||
class SalesOrderShipmentSerializer(
|
||||
DataImportExportSerializerMixin,
|
||||
FilterableSerializerMixin,
|
||||
InvenTreeTaggitSerializer,
|
||||
NotesFieldMixin,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
@@ -1392,6 +1402,7 @@ class SalesOrderShipmentSerializer(
|
||||
'customer_detail',
|
||||
'order_detail',
|
||||
'shipment_address_detail',
|
||||
'tags',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -1461,6 +1472,8 @@ class SalesOrderShipmentSerializer(
|
||||
|
||||
parameters = common.filters.enable_parameters_filter()
|
||||
|
||||
tags = common.filters.enable_tags_filter()
|
||||
|
||||
|
||||
class SalesOrderAllocationSerializer(
|
||||
FilterableSerializerMixin, InvenTreeModelSerializer
|
||||
|
||||
@@ -12,6 +12,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.response import Response
|
||||
|
||||
import common.filters
|
||||
import common.serializers
|
||||
import part.tasks as part_tasks
|
||||
from data_exporter.mixins import DataExportViewMixin
|
||||
@@ -910,9 +911,7 @@ class PartFilter(FilterSet):
|
||||
|
||||
virtual = rest_filters.BooleanFilter()
|
||||
|
||||
tags_name = rest_filters.CharFilter(field_name='tags__name', lookup_expr='iexact')
|
||||
|
||||
tags_slug = rest_filters.CharFilter(field_name='tags__slug', lookup_expr='iexact')
|
||||
tags = common.filters.TagsFilter()
|
||||
|
||||
# Created date filters
|
||||
created_before = InvenTreeDateFilter(
|
||||
|
||||
@@ -32,7 +32,6 @@ from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
from mptt.managers import TreeManager
|
||||
from mptt.models import TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
import common.currency
|
||||
import common.models
|
||||
@@ -465,6 +464,7 @@ class Part(
|
||||
InvenTree.models.InvenTreeParameterMixin,
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeTagsMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
InvenTree.models.InvenTreeImageMixin,
|
||||
@@ -520,8 +520,6 @@ class Part(
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
tags = TaggableManager(blank=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model properties."""
|
||||
|
||||
|
||||
@@ -373,6 +373,8 @@ class StockLocationFilter(FilterSet):
|
||||
|
||||
return queryset
|
||||
|
||||
tags = common.filters.TagsFilter(label=_('Tags'))
|
||||
|
||||
|
||||
class StockLocationMixin(SerializerContextMixin):
|
||||
"""Mixin class for StockLocation API endpoints."""
|
||||
@@ -1041,6 +1043,8 @@ class StockFilter(FilterSet):
|
||||
children = loc_obj.getUniqueChildren()
|
||||
return queryset.filter(location__in=children)
|
||||
|
||||
tags = common.filters.TagsFilter(label=_('Tags'))
|
||||
|
||||
|
||||
class StockApiMixin(SerializerContextMixin):
|
||||
"""Mixin class for StockItem API endpoints."""
|
||||
|
||||
@@ -23,7 +23,6 @@ import structlog
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from mptt.managers import TreeManager
|
||||
from mptt.models import TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
import build.models
|
||||
import common.models
|
||||
@@ -126,6 +125,7 @@ class StockLocation(
|
||||
InvenTree.models.PluginValidationMixin,
|
||||
InvenTree.models.InvenTreeParameterMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeTagsMixin,
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
InvenTree.models.PathStringMixin,
|
||||
InvenTree.models.MetadataMixin,
|
||||
@@ -149,8 +149,6 @@ class StockLocation(
|
||||
verbose_name = _('Stock Location')
|
||||
verbose_name_plural = _('Stock Locations')
|
||||
|
||||
tags = TaggableManager(blank=True)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Custom model deletion routine, which updates any child locations or items.
|
||||
|
||||
@@ -426,6 +424,7 @@ class StockItem(
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
InvenTree.models.InvenTreeTagsMixin,
|
||||
StatusCodeMixin,
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
common.models.MetaMixin,
|
||||
@@ -619,8 +618,6 @@ class StockItem(
|
||||
'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"
|
||||
# See also: StockItem.in_stock() method
|
||||
IN_STOCK_FILTER = Q(
|
||||
|
||||
@@ -313,7 +313,8 @@ class StockItemSerializer(
|
||||
InvenTree.serializers.FilterableSerializerMixin,
|
||||
DataImportExportSerializerMixin,
|
||||
InvenTreeCustomStatusSerializerMixin,
|
||||
InvenTree.serializers.InvenTreeTagModelSerializer,
|
||||
InvenTree.serializers.InvenTreeTaggitSerializer,
|
||||
InvenTree.serializers.InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for a StockItem.
|
||||
|
||||
@@ -1194,7 +1195,8 @@ class LocationDeleteSerializer(serializers.Serializer):
|
||||
class LocationSerializer(
|
||||
InvenTree.serializers.FilterableSerializerMixin,
|
||||
DataImportExportSerializerMixin,
|
||||
InvenTree.serializers.InvenTreeTagModelSerializer,
|
||||
InvenTree.serializers.InvenTreeTaggitSerializer,
|
||||
InvenTree.serializers.InvenTreeModelSerializer,
|
||||
):
|
||||
"""Detailed information about a stock location."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user