2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-08-05 19:41:41 +00:00

Add 'Tag' management (#4367)

* 'Tag' management
Fixes #83

* Add for ManufacturerPart, SupplierPart

* Add tags for StockLocation, StockItem

* fix serializer definition

* add migrations

* update pre-commit

* bump dependencies

* revert updates

* set version for bugbear

* remove bugbear

* readd bugbear remove isort

* and remove bugbear again

* remove bugbear

* make tag fields not required

* add ruleset

* Merge migrations

* fix migrations

* add unittest for detail

* test tag add

* order api

* reduce database access

* add tag modification test

* use overriden serializer to ensuer the manager is always available

* fix typo

* fix serializer

* increae query thershold by 1

* move tag serializer

* fix migrations

* content_types are changing between tests - removing them

* remove unneeded fixture

* Add basic docs

* bump API version

* add api access to the docs

* add python code

* Add tags to search and filters for all models
This commit is contained in:
Matthias Mair
2023-05-04 01:02:48 +02:00
committed by GitHub
parent baaa147fd0
commit f5c2591fd4
22 changed files with 258 additions and 14 deletions

View File

@@ -2,11 +2,17 @@
# InvenTree API version
INVENTREE_API_VERSION = 110
INVENTREE_API_VERSION = 111
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v111 -> 2023-05-02 : https://github.com/inventree/InvenTree/pull/4367
- Adds tags to the Part serializer
- Adds tags to the SupplierPart serializer
- Adds tags to the ManufacturerPart serializer
- Adds tags to the StockItem serializer
- Adds tags to the StockLocation serializer
v110 -> 2023-04-26 : https://github.com/inventree/InvenTree/pull/4698
- Adds 'order_currency' field for PurchaseOrder / SalesOrder endpoints

View File

@@ -19,6 +19,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.fields import empty
from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer
from common.models import InvenTreeSetting
from common.settings import currency_code_default, currency_code_mappings
@@ -264,6 +265,28 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
return data
class InvenTreeTaggitSerializer(TaggitSerializer):
"""Updated from https://github.com/glemmaPaul/django-taggit-serializer."""
def update(self, instance, validated_data):
"""Overriden update method to readd the tagmanager."""
to_be_tagged, validated_data = self._pop_tags(validated_data)
tag_object = super().update(instance, validated_data)
for key in to_be_tagged.keys():
# readd the tagmanager
new_tagobject = tag_object.__class__.objects.get(id=tag_object.id)
setattr(tag_object, key, getattr(new_tagobject, key))
return self._save_tags(tag_object, to_be_tagged)
class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSerializer):
"""Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer."""
pass
class UserSerializer(InvenTreeModelSerializer):
"""Serializer for a User."""

View File

@@ -225,6 +225,7 @@ INSTALLED_APPS = [
'django_q',
'formtools', # Form wizard tools
'dbbackup', # Backups - django-dbbackup
'taggit', # Tagging
'allauth', # Base app for SSO
'allauth.account', # Extend user with accounts

View File

@@ -146,6 +146,8 @@ class ManufacturerPartFilter(rest_filters.FilterSet):
'manufacturer',
'MPN',
'part',
'tags__name',
'tags__slug',
]
# Filter by 'active' status of linked part
@@ -163,6 +165,7 @@ class ManufacturerPartList(ListCreateDestroyAPIView):
'part',
'manufacturer',
'supplier_parts',
'tags',
)
serializer_class = ManufacturerPartSerializer
@@ -193,6 +196,8 @@ class ManufacturerPartList(ListCreateDestroyAPIView):
'part__IPN',
'part__name',
'part__description',
'tags__name',
'tags__slug',
]
@@ -303,6 +308,8 @@ class SupplierPartFilter(rest_filters.FilterSet):
'part',
'manufacturer_part',
'SKU',
'tags__name',
'tags__slug',
]
# Filter by 'active' status of linked part
@@ -323,7 +330,9 @@ class SupplierPartList(ListCreateDestroyAPIView):
- POST: Create a new SupplierPart object
"""
queryset = SupplierPart.objects.all()
queryset = SupplierPart.objects.all().prefetch_related(
'tags',
)
filterset_class = SupplierPartFilter
def get_queryset(self, *args, **kwargs):
@@ -403,6 +412,8 @@ class SupplierPartList(ListCreateDestroyAPIView):
'part__name',
'part__description',
'part__keywords',
'tags__name',
'tags__slug',
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 3.2.18 on 2023-04-27 20:33
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('taggit', '0005_auto_20220424_2025'),
('company', '0056_alter_company_notes'),
]
operations = [
migrations.AddField(
model_name='manufacturerpart',
name='tags',
field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
),
migrations.AddField(
model_name='supplierpart',
name='tags',
field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
),
]

View File

@@ -15,6 +15,7 @@ from django.utils.translation import gettext_lazy as _
from moneyed import CURRENCIES
from stdimage.models import StdImageField
from taggit.managers import TaggableManager
import common.models
import common.settings
@@ -310,6 +311,8 @@ class ManufacturerPart(MetadataMixin, models.Model):
help_text=_('Manufacturer part description')
)
tags = TaggableManager()
@classmethod
def create(cls, part, manufacturer, mpn, description, link=None):
"""Check if ManufacturerPart instance does not already exist then create it."""
@@ -445,6 +448,7 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
db_table = 'part_supplierpart'
objects = SupplierPartManager()
tags = TaggableManager()
@staticmethod
def get_api_url():

View File

@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from sql_util.utils import SubqueryCount
from taggit.serializers import TagListSerializerField
import part.filters
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
@@ -14,7 +15,9 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeDecimalField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer, RemoteImageMixin)
InvenTreeMoneySerializer,
InvenTreeTagModelSerializer,
RemoteImageMixin)
from part.serializers import PartBriefSerializer
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
@@ -149,7 +152,7 @@ class ContactSerializer(InvenTreeModelSerializer):
]
class ManufacturerPartSerializer(InvenTreeModelSerializer):
class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
"""Serializer for ManufacturerPart object."""
class Meta:
@@ -166,8 +169,12 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
'description',
'MPN',
'link',
'tags',
]
tags = TagListSerializerField(required=False)
def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra detail fields as required"""
part_detail = kwargs.pop('part_detail', True)
@@ -236,7 +243,7 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
class SupplierPartSerializer(InvenTreeModelSerializer):
class SupplierPartSerializer(InvenTreeTagModelSerializer):
"""Serializer for SupplierPart object."""
class Meta:
@@ -267,6 +274,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'supplier_detail',
'url',
'updated',
'tags',
]
read_only_fields = [
@@ -274,6 +283,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'barcode_hash',
]
tags = TagListSerializerField(required=False)
def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra detail fields as required"""

View File

@@ -970,6 +970,10 @@ class PartFilter(rest_filters.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')
class PartMixin:
"""Mixin class for Part API endpoints"""
@@ -1240,6 +1244,8 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
'category__name',
'manufacturer_parts__MPN',
'supplier_parts__SKU',
'tags__name',
'tags__slug',
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.2.18 on 2023-04-27 20:33
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('taggit', '0005_auto_20220424_2025'),
('part', '0105_alter_part_notes'),
]
operations = [
migrations.AddField(
model_name='part',
name='tags',
field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
),
]

View File

@@ -31,6 +31,7 @@ from mptt.exceptions import InvalidMove
from mptt.managers import TreeManager
from mptt.models import MPTTModel, TreeForeignKey
from stdimage.models import StdImageField
from taggit.managers import TaggableManager
import common.models
import common.settings
@@ -336,6 +337,7 @@ class PartManager(TreeManager):
'category__parent',
'stock_items',
'builds',
'tags',
)
@@ -378,6 +380,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
"""
objects = PartManager()
tags = TaggableManager()
class Meta:
"""Metaclass defines extra model properties"""

View File

@@ -15,6 +15,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum
from taggit.serializers import TagListSerializerField
import common.models
import company.models
@@ -31,8 +32,9 @@ from InvenTree.serializers import (DataFileExtractSerializer,
InvenTreeDecimalField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer, RemoteImageMixin,
UserSerializer)
InvenTreeMoneySerializer,
InvenTreeTagModelSerializer,
RemoteImageMixin, UserSerializer)
from InvenTree.status_codes import BuildStatus
from InvenTree.tasks import offload_task
@@ -403,7 +405,7 @@ class InitialSupplierSerializer(serializers.Serializer):
return data
class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
"""Serializer for complete detail information of a part.
Used when displaying all details of a single component.
@@ -464,13 +466,17 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
'duplicate',
'initial_stock',
'initial_supplier',
'copy_category_parameters'
'copy_category_parameters',
'tags',
]
read_only_fields = [
'barcode_hash',
]
tags = TagListSerializerField(required=False)
def __init__(self, *args, **kwargs):
"""Custom initialization method for PartSerializer:

View File

@@ -1425,6 +1425,7 @@ class PartDetailTests(PartAPITestBase):
'name': 'my test api part',
'description': 'a part created with the API',
'category': 1,
'tags': '["tag1", "tag2"]',
}
)
@@ -1438,6 +1439,8 @@ class PartDetailTests(PartAPITestBase):
part = Part.objects.get(pk=pk)
self.assertEqual(part.name, 'my test api part')
self.assertEqual(part.tags.count(), 2)
self.assertEqual([a.name for a in part.tags.order_by('slug')], ['tag1', 'tag2'])
# Edit the part
url = reverse('api-part-detail', kwargs={'pk': pk})
@@ -1468,6 +1471,13 @@ class PartDetailTests(PartAPITestBase):
self.assertEqual(response.status_code, 200)
# Try to remove a tag
response = self.patch(url, {
'tags': ['tag1',],
})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['tags'], ['tag1'])
# Try to remove the part
response = self.delete(url)

View File

@@ -152,7 +152,7 @@ class CategoryTest(TestCase):
def test_parameters(self):
"""Test that the Category parameters are correctly fetched."""
# Check number of SQL queries to iterate other parameters
with self.assertNumQueries(8):
with self.assertNumQueries(9):
# Prefetch: 3 queries (parts, parameters and parameters_template)
fasteners = self.fasteners.prefetch_parts_parameters()
# Iterate through all parts and parameters

View File

@@ -214,7 +214,9 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI):
- POST: Create a new StockLocation
"""
queryset = StockLocation.objects.all()
queryset = StockLocation.objects.all().prefetch_related(
'tags',
)
serializer_class = StockSerializers.LocationSerializer
def download_queryset(self, queryset, export_format):
@@ -300,11 +302,15 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI):
'name',
'structural',
'external',
'tags__name',
'tags__slug',
]
search_fields = [
'name',
'description',
'tags__name',
'tags__slug',
]
ordering_fields = [
@@ -351,6 +357,8 @@ class StockFilter(rest_filters.FilterSet):
'customer',
'sales_order',
'purchase_order',
'tags__name',
'tags__slug',
]
# Relationship filters
@@ -811,7 +819,8 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
queryset = queryset.prefetch_related(
'part',
'part__category',
'location'
'location',
'tags',
)
return queryset
@@ -1035,6 +1044,8 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
'part__IPN',
'part__description',
'location__name',
'tags__name',
'tags__slug',
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 3.2.18 on 2023-04-27 20:33
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('taggit', '0005_auto_20220424_2025'),
('stock', '0097_alter_stockitem_notes'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='tags',
field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
),
migrations.AddField(
model_name='stocklocation',
name='tags',
field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
),
]

View File

@@ -21,6 +21,7 @@ from django.utils.translation import gettext_lazy as _
from jinja2 import Template
from mptt.managers import TreeManager
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
import common.models
import InvenTree.helpers
@@ -53,6 +54,8 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
verbose_name = _('Stock Location')
verbose_name_plural = _('Stock Locations')
tags = TaggableManager()
def delete_recursive(self, *args, **kwargs):
"""This function handles the recursive deletion of sub-locations depending on kwargs contents"""
delete_stock_items = kwargs.get('delete_stock_items', False)
@@ -321,6 +324,8 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
}
}
tags = TaggableManager()
# A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock"
IN_STOCK_FILTER = Q(
quantity__gt=0,

View File

@@ -12,6 +12,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount, SubquerySum
from taggit.serializers import TagListSerializerField
import common.models
import company.models
@@ -76,7 +77,7 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
return value
class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
"""Serializer for a StockItem.
- Includes serialization for the linked part
@@ -123,6 +124,8 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'updated',
'purchase_price',
'purchase_price_currency',
'tags',
]
"""
@@ -236,6 +239,8 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True)
sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True)
tags = TagListSerializerField(required=False)
def __init__(self, *args, **kwargs):
"""Add detail fields."""
part_detail = kwargs.pop('part_detail', False)
@@ -566,7 +571,7 @@ class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
]
class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
"""Detailed information about a stock location."""
class Meta:
@@ -587,6 +592,8 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'icon',
'structural',
'external',
'tags',
]
read_only_fields = [
@@ -610,6 +617,8 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
level = serializers.IntegerField(read_only=True)
tags = TagListSerializerField(required=False)
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
"""Serializer for StockItemAttachment model."""

View File

@@ -79,6 +79,8 @@ class RuleSet(models.Model):
'plugin_pluginsetting',
'plugin_notificationusersetting',
'common_newsfeedentry',
'taggit_tag',
'taggit_taggeditem',
],
'part_category': [
'part_partcategory',