mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
baaa147fd0
commit
f5c2591fd4
@ -2,11 +2,17 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# 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
|
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
|
v110 -> 2023-04-26 : https://github.com/inventree/InvenTree/pull/4698
|
||||||
- Adds 'order_currency' field for PurchaseOrder / SalesOrder endpoints
|
- Adds 'order_currency' field for PurchaseOrder / SalesOrder endpoints
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ from rest_framework.exceptions import ValidationError
|
|||||||
from rest_framework.fields import empty
|
from rest_framework.fields import empty
|
||||||
from rest_framework.serializers import DecimalField
|
from rest_framework.serializers import DecimalField
|
||||||
from rest_framework.utils import model_meta
|
from rest_framework.utils import model_meta
|
||||||
|
from taggit.serializers import TaggitSerializer
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from common.settings import currency_code_default, currency_code_mappings
|
from common.settings import currency_code_default, currency_code_mappings
|
||||||
@ -264,6 +265,28 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
return data
|
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):
|
class UserSerializer(InvenTreeModelSerializer):
|
||||||
"""Serializer for a User."""
|
"""Serializer for a User."""
|
||||||
|
|
||||||
|
@ -225,6 +225,7 @@ INSTALLED_APPS = [
|
|||||||
'django_q',
|
'django_q',
|
||||||
'formtools', # Form wizard tools
|
'formtools', # Form wizard tools
|
||||||
'dbbackup', # Backups - django-dbbackup
|
'dbbackup', # Backups - django-dbbackup
|
||||||
|
'taggit', # Tagging
|
||||||
|
|
||||||
'allauth', # Base app for SSO
|
'allauth', # Base app for SSO
|
||||||
'allauth.account', # Extend user with accounts
|
'allauth.account', # Extend user with accounts
|
||||||
|
@ -146,6 +146,8 @@ class ManufacturerPartFilter(rest_filters.FilterSet):
|
|||||||
'manufacturer',
|
'manufacturer',
|
||||||
'MPN',
|
'MPN',
|
||||||
'part',
|
'part',
|
||||||
|
'tags__name',
|
||||||
|
'tags__slug',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Filter by 'active' status of linked part
|
# Filter by 'active' status of linked part
|
||||||
@ -163,6 +165,7 @@ class ManufacturerPartList(ListCreateDestroyAPIView):
|
|||||||
'part',
|
'part',
|
||||||
'manufacturer',
|
'manufacturer',
|
||||||
'supplier_parts',
|
'supplier_parts',
|
||||||
|
'tags',
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer_class = ManufacturerPartSerializer
|
serializer_class = ManufacturerPartSerializer
|
||||||
@ -193,6 +196,8 @@ class ManufacturerPartList(ListCreateDestroyAPIView):
|
|||||||
'part__IPN',
|
'part__IPN',
|
||||||
'part__name',
|
'part__name',
|
||||||
'part__description',
|
'part__description',
|
||||||
|
'tags__name',
|
||||||
|
'tags__slug',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -303,6 +308,8 @@ class SupplierPartFilter(rest_filters.FilterSet):
|
|||||||
'part',
|
'part',
|
||||||
'manufacturer_part',
|
'manufacturer_part',
|
||||||
'SKU',
|
'SKU',
|
||||||
|
'tags__name',
|
||||||
|
'tags__slug',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Filter by 'active' status of linked part
|
# Filter by 'active' status of linked part
|
||||||
@ -323,7 +330,9 @@ class SupplierPartList(ListCreateDestroyAPIView):
|
|||||||
- POST: Create a new SupplierPart object
|
- POST: Create a new SupplierPart object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = SupplierPart.objects.all()
|
queryset = SupplierPart.objects.all().prefetch_related(
|
||||||
|
'tags',
|
||||||
|
)
|
||||||
filterset_class = SupplierPartFilter
|
filterset_class = SupplierPartFilter
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
@ -403,6 +412,8 @@ class SupplierPartList(ListCreateDestroyAPIView):
|
|||||||
'part__name',
|
'part__name',
|
||||||
'part__description',
|
'part__description',
|
||||||
'part__keywords',
|
'part__keywords',
|
||||||
|
'tags__name',
|
||||||
|
'tags__slug',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
25
InvenTree/company/migrations/0057_auto_20230427_2033.py
Normal file
25
InvenTree/company/migrations/0057_auto_20230427_2033.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -15,6 +15,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from moneyed import CURRENCIES
|
from moneyed import CURRENCIES
|
||||||
from stdimage.models import StdImageField
|
from stdimage.models import StdImageField
|
||||||
|
from taggit.managers import TaggableManager
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import common.settings
|
import common.settings
|
||||||
@ -310,6 +311,8 @@ class ManufacturerPart(MetadataMixin, models.Model):
|
|||||||
help_text=_('Manufacturer part description')
|
help_text=_('Manufacturer part description')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tags = TaggableManager()
|
||||||
|
|
||||||
@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."""
|
||||||
@ -445,6 +448,7 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
|
|||||||
db_table = 'part_supplierpart'
|
db_table = 'part_supplierpart'
|
||||||
|
|
||||||
objects = SupplierPartManager()
|
objects = SupplierPartManager()
|
||||||
|
tags = TaggableManager()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
|
@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from sql_util.utils import SubqueryCount
|
from sql_util.utils import SubqueryCount
|
||||||
|
from taggit.serializers import TagListSerializerField
|
||||||
|
|
||||||
import part.filters
|
import part.filters
|
||||||
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
||||||
@ -14,7 +15,9 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
|||||||
InvenTreeDecimalField,
|
InvenTreeDecimalField,
|
||||||
InvenTreeImageSerializerField,
|
InvenTreeImageSerializerField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
InvenTreeMoneySerializer, RemoteImageMixin)
|
InvenTreeMoneySerializer,
|
||||||
|
InvenTreeTagModelSerializer,
|
||||||
|
RemoteImageMixin)
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
|
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
|
||||||
@ -149,7 +152,7 @@ class ContactSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
|
||||||
"""Serializer for ManufacturerPart object."""
|
"""Serializer for ManufacturerPart object."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -166,8 +169,12 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
|||||||
'description',
|
'description',
|
||||||
'MPN',
|
'MPN',
|
||||||
'link',
|
'link',
|
||||||
|
|
||||||
|
'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
tags = TagListSerializerField(required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Initialize this serializer with extra detail fields as required"""
|
"""Initialize this serializer with extra detail fields as required"""
|
||||||
part_detail = kwargs.pop('part_detail', True)
|
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)
|
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class SupplierPartSerializer(InvenTreeModelSerializer):
|
class SupplierPartSerializer(InvenTreeTagModelSerializer):
|
||||||
"""Serializer for SupplierPart object."""
|
"""Serializer for SupplierPart object."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -267,6 +274,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
'supplier_detail',
|
'supplier_detail',
|
||||||
'url',
|
'url',
|
||||||
'updated',
|
'updated',
|
||||||
|
|
||||||
|
'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@ -274,6 +283,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
'barcode_hash',
|
'barcode_hash',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
tags = TagListSerializerField(required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Initialize this serializer with extra detail fields as required"""
|
"""Initialize this serializer with extra detail fields as required"""
|
||||||
|
|
||||||
|
@ -970,6 +970,10 @@ class PartFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
virtual = rest_filters.BooleanFilter()
|
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:
|
class PartMixin:
|
||||||
"""Mixin class for Part API endpoints"""
|
"""Mixin class for Part API endpoints"""
|
||||||
@ -1240,6 +1244,8 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
|
|||||||
'category__name',
|
'category__name',
|
||||||
'manufacturer_parts__MPN',
|
'manufacturer_parts__MPN',
|
||||||
'supplier_parts__SKU',
|
'supplier_parts__SKU',
|
||||||
|
'tags__name',
|
||||||
|
'tags__slug',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
20
InvenTree/part/migrations/0106_part_tags.py
Normal file
20
InvenTree/part/migrations/0106_part_tags.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -31,6 +31,7 @@ from mptt.exceptions import InvalidMove
|
|||||||
from mptt.managers import TreeManager
|
from mptt.managers import TreeManager
|
||||||
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.models
|
import common.models
|
||||||
import common.settings
|
import common.settings
|
||||||
@ -336,6 +337,7 @@ class PartManager(TreeManager):
|
|||||||
'category__parent',
|
'category__parent',
|
||||||
'stock_items',
|
'stock_items',
|
||||||
'builds',
|
'builds',
|
||||||
|
'tags',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -378,6 +380,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
objects = PartManager()
|
objects = PartManager()
|
||||||
|
tags = TaggableManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass defines extra model properties"""
|
"""Metaclass defines extra model properties"""
|
||||||
|
@ -15,6 +15,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from sql_util.utils import SubqueryCount, SubquerySum
|
from sql_util.utils import SubqueryCount, SubquerySum
|
||||||
|
from taggit.serializers import TagListSerializerField
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import company.models
|
import company.models
|
||||||
@ -31,8 +32,9 @@ from InvenTree.serializers import (DataFileExtractSerializer,
|
|||||||
InvenTreeDecimalField,
|
InvenTreeDecimalField,
|
||||||
InvenTreeImageSerializerField,
|
InvenTreeImageSerializerField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
InvenTreeMoneySerializer, RemoteImageMixin,
|
InvenTreeMoneySerializer,
|
||||||
UserSerializer)
|
InvenTreeTagModelSerializer,
|
||||||
|
RemoteImageMixin, UserSerializer)
|
||||||
from InvenTree.status_codes import BuildStatus
|
from InvenTree.status_codes import BuildStatus
|
||||||
from InvenTree.tasks import offload_task
|
from InvenTree.tasks import offload_task
|
||||||
|
|
||||||
@ -403,7 +405,7 @@ class InitialSupplierSerializer(serializers.Serializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
|
||||||
"""Serializer for complete detail information of a part.
|
"""Serializer for complete detail information of a part.
|
||||||
|
|
||||||
Used when displaying all details of a single component.
|
Used when displaying all details of a single component.
|
||||||
@ -464,13 +466,17 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
|||||||
'duplicate',
|
'duplicate',
|
||||||
'initial_stock',
|
'initial_stock',
|
||||||
'initial_supplier',
|
'initial_supplier',
|
||||||
'copy_category_parameters'
|
'copy_category_parameters',
|
||||||
|
|
||||||
|
'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'barcode_hash',
|
'barcode_hash',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
tags = TagListSerializerField(required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Custom initialization method for PartSerializer:
|
"""Custom initialization method for PartSerializer:
|
||||||
|
|
||||||
|
@ -1425,6 +1425,7 @@ class PartDetailTests(PartAPITestBase):
|
|||||||
'name': 'my test api part',
|
'name': 'my test api part',
|
||||||
'description': 'a part created with the API',
|
'description': 'a part created with the API',
|
||||||
'category': 1,
|
'category': 1,
|
||||||
|
'tags': '["tag1", "tag2"]',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1438,6 +1439,8 @@ class PartDetailTests(PartAPITestBase):
|
|||||||
part = Part.objects.get(pk=pk)
|
part = Part.objects.get(pk=pk)
|
||||||
|
|
||||||
self.assertEqual(part.name, 'my test api part')
|
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
|
# Edit the part
|
||||||
url = reverse('api-part-detail', kwargs={'pk': pk})
|
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||||
@ -1468,6 +1471,13 @@ class PartDetailTests(PartAPITestBase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
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
|
# Try to remove the part
|
||||||
response = self.delete(url)
|
response = self.delete(url)
|
||||||
|
|
||||||
|
@ -152,7 +152,7 @@ class CategoryTest(TestCase):
|
|||||||
def test_parameters(self):
|
def test_parameters(self):
|
||||||
"""Test that the Category parameters are correctly fetched."""
|
"""Test that the Category parameters are correctly fetched."""
|
||||||
# Check number of SQL queries to iterate other parameters
|
# 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)
|
# Prefetch: 3 queries (parts, parameters and parameters_template)
|
||||||
fasteners = self.fasteners.prefetch_parts_parameters()
|
fasteners = self.fasteners.prefetch_parts_parameters()
|
||||||
# Iterate through all parts and parameters
|
# Iterate through all parts and parameters
|
||||||
|
@ -214,7 +214,9 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI):
|
|||||||
- POST: Create a new StockLocation
|
- POST: Create a new StockLocation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = StockLocation.objects.all()
|
queryset = StockLocation.objects.all().prefetch_related(
|
||||||
|
'tags',
|
||||||
|
)
|
||||||
serializer_class = StockSerializers.LocationSerializer
|
serializer_class = StockSerializers.LocationSerializer
|
||||||
|
|
||||||
def download_queryset(self, queryset, export_format):
|
def download_queryset(self, queryset, export_format):
|
||||||
@ -300,11 +302,15 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI):
|
|||||||
'name',
|
'name',
|
||||||
'structural',
|
'structural',
|
||||||
'external',
|
'external',
|
||||||
|
'tags__name',
|
||||||
|
'tags__slug',
|
||||||
]
|
]
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
|
'tags__name',
|
||||||
|
'tags__slug',
|
||||||
]
|
]
|
||||||
|
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
@ -351,6 +357,8 @@ class StockFilter(rest_filters.FilterSet):
|
|||||||
'customer',
|
'customer',
|
||||||
'sales_order',
|
'sales_order',
|
||||||
'purchase_order',
|
'purchase_order',
|
||||||
|
'tags__name',
|
||||||
|
'tags__slug',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Relationship filters
|
# Relationship filters
|
||||||
@ -811,7 +819,8 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
|
|||||||
queryset = queryset.prefetch_related(
|
queryset = queryset.prefetch_related(
|
||||||
'part',
|
'part',
|
||||||
'part__category',
|
'part__category',
|
||||||
'location'
|
'location',
|
||||||
|
'tags',
|
||||||
)
|
)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
@ -1035,6 +1044,8 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
|
|||||||
'part__IPN',
|
'part__IPN',
|
||||||
'part__description',
|
'part__description',
|
||||||
'location__name',
|
'location__name',
|
||||||
|
'tags__name',
|
||||||
|
'tags__slug',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
25
InvenTree/stock/migrations/0098_auto_20230427_2033.py
Normal file
25
InvenTree/stock/migrations/0098_auto_20230427_2033.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -21,6 +21,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from mptt.managers import TreeManager
|
from mptt.managers import TreeManager
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
|
from taggit.managers import TaggableManager
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
@ -53,6 +54,8 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
|
|||||||
verbose_name = _('Stock Location')
|
verbose_name = _('Stock Location')
|
||||||
verbose_name_plural = _('Stock Locations')
|
verbose_name_plural = _('Stock Locations')
|
||||||
|
|
||||||
|
tags = TaggableManager()
|
||||||
|
|
||||||
def delete_recursive(self, *args, **kwargs):
|
def delete_recursive(self, *args, **kwargs):
|
||||||
"""This function handles the recursive deletion of sub-locations depending on kwargs contents"""
|
"""This function handles the recursive deletion of sub-locations depending on kwargs contents"""
|
||||||
delete_stock_items = kwargs.get('delete_stock_items', False)
|
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"
|
# A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock"
|
||||||
IN_STOCK_FILTER = Q(
|
IN_STOCK_FILTER = Q(
|
||||||
quantity__gt=0,
|
quantity__gt=0,
|
||||||
|
@ -12,6 +12,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
from sql_util.utils import SubqueryCount, SubquerySum
|
from sql_util.utils import SubqueryCount, SubquerySum
|
||||||
|
from taggit.serializers import TagListSerializerField
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import company.models
|
import company.models
|
||||||
@ -76,7 +77,7 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
|
||||||
"""Serializer for a StockItem.
|
"""Serializer for a StockItem.
|
||||||
|
|
||||||
- Includes serialization for the linked part
|
- Includes serialization for the linked part
|
||||||
@ -123,6 +124,8 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
'updated',
|
'updated',
|
||||||
'purchase_price',
|
'purchase_price',
|
||||||
'purchase_price_currency',
|
'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)
|
purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True)
|
||||||
sales_order_reference = serializers.CharField(source='sales_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):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Add detail fields."""
|
"""Add detail fields."""
|
||||||
part_detail = kwargs.pop('part_detail', False)
|
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."""
|
"""Detailed information about a stock location."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -587,6 +592,8 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
'icon',
|
'icon',
|
||||||
'structural',
|
'structural',
|
||||||
'external',
|
'external',
|
||||||
|
|
||||||
|
'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@ -610,6 +617,8 @@ class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
|
|
||||||
level = serializers.IntegerField(read_only=True)
|
level = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
tags = TagListSerializerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
|
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
|
||||||
"""Serializer for StockItemAttachment model."""
|
"""Serializer for StockItemAttachment model."""
|
||||||
|
@ -79,6 +79,8 @@ class RuleSet(models.Model):
|
|||||||
'plugin_pluginsetting',
|
'plugin_pluginsetting',
|
||||||
'plugin_notificationusersetting',
|
'plugin_notificationusersetting',
|
||||||
'common_newsfeedentry',
|
'common_newsfeedentry',
|
||||||
|
'taggit_tag',
|
||||||
|
'taggit_taggeditem',
|
||||||
],
|
],
|
||||||
'part_category': [
|
'part_category': [
|
||||||
'part_partcategory',
|
'part_partcategory',
|
||||||
|
61
docs/docs/extend/plugins/tags.md
Normal file
61
docs/docs/extend/plugins/tags.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
title: Item Tags
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
Several models in InvenTree can be tagged with arbitrary tags. Tags are useful for grouping items together. This can be used to mark items with a plugin or to group items together for a particular theme. Tags are meant to be used by programms and are not visible to the end user.
|
||||||
|
Tags are shared between all models that can be tagged.
|
||||||
|
|
||||||
|
The following models can be tagged:
|
||||||
|
- [Parts](../../part/part.md) and [Supplier Parts](../../order/company#supplier-parts)/[Manufacturer Part](../../order/company#manufacturer-parts)
|
||||||
|
- [Stock Items](../../stock/stock.md#stock-item) / [Stock Location](../../stock/stock.md#stock-location)
|
||||||
|
|
||||||
|
|
||||||
|
## Accessing Tags
|
||||||
|
|
||||||
|
### Plugin Access
|
||||||
|
|
||||||
|
The `tags` field can be accessed and updated directly from custom plugin code, as follows:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from part.models import Part
|
||||||
|
|
||||||
|
# Show tags for a particular Part instance
|
||||||
|
part = Part.objects.get(pk=100)
|
||||||
|
print(part.tags)
|
||||||
|
|
||||||
|
> {['Tag1', 'Another Tag']}
|
||||||
|
|
||||||
|
# Tags can also be accessed via tags.all()
|
||||||
|
print(part.tags.all())
|
||||||
|
|
||||||
|
> {['Tag1', 'Another Tag']}
|
||||||
|
|
||||||
|
# Add tag
|
||||||
|
part.tags.add('Tag 2')
|
||||||
|
print(part.tags)
|
||||||
|
|
||||||
|
> {['Tag1', 'Tag 2', 'Another Tag']}
|
||||||
|
|
||||||
|
# Remove tag
|
||||||
|
part.tags.remove('Tag1')
|
||||||
|
print(part.tags)
|
||||||
|
|
||||||
|
> {['Tag 2', 'Another Tag']}
|
||||||
|
|
||||||
|
# Filter by tags
|
||||||
|
Part.objects.filter(tags__name__in=["Tag1", "Tag 2"]).distinct()
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Access
|
||||||
|
|
||||||
|
For models which provide tags, access is also provided via the API. The tags are exposed via the detail endpoint for the models starting from version 111.
|
||||||
|
|
||||||
|
Tags can be cached via PATCH or POST requests. The tags are provided as a json formatted list of strings. The tags are note case sensitive and must be unique across the instance - else the exsisting tag gets assigned. The tags are not sorted and the order is not guaranteed.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tags": '["foo", "bar"]'
|
||||||
|
}
|
||||||
|
```
|
@ -166,6 +166,7 @@ nav:
|
|||||||
- Installation: extend/plugins/install.md
|
- Installation: extend/plugins/install.md
|
||||||
- Developing a Plugin: extend/how_to_plugin.md
|
- Developing a Plugin: extend/how_to_plugin.md
|
||||||
- Model Meatadata: extend/plugins/metadata.md
|
- Model Meatadata: extend/plugins/metadata.md
|
||||||
|
- Tags: extend/plugins/tags.md
|
||||||
- Plugin Mixins:
|
- Plugin Mixins:
|
||||||
- Action Mixin: extend/plugins/action.md
|
- Action Mixin: extend/plugins/action.md
|
||||||
- API Mixin: extend/plugins/api.md
|
- API Mixin: extend/plugins/api.md
|
||||||
|
@ -23,6 +23,7 @@ django-q-sentry # sentry.io integration for django-q
|
|||||||
django-sql-utils # Advanced query annotation / aggregation
|
django-sql-utils # Advanced query annotation / aggregation
|
||||||
django-sslserver # Secure HTTP development server
|
django-sslserver # Secure HTTP development server
|
||||||
django-stdimage<6.0.0 # Advanced ImageField management # FIXED 2022-06-29 6.0.0 breaks serialization for django-q
|
django-stdimage<6.0.0 # Advanced ImageField management # FIXED 2022-06-29 6.0.0 breaks serialization for django-q
|
||||||
|
django-taggit # Tagging support
|
||||||
django-user-sessions # user sessions in DB
|
django-user-sessions # user sessions in DB
|
||||||
django-weasyprint # django weasyprint integration
|
django-weasyprint # django weasyprint integration
|
||||||
djangorestframework # DRF framework
|
djangorestframework # DRF framework
|
||||||
|
@ -68,6 +68,7 @@ django==3.2.18
|
|||||||
# django-sql-utils
|
# django-sql-utils
|
||||||
# django-sslserver
|
# django-sslserver
|
||||||
# django-stdimage
|
# django-stdimage
|
||||||
|
# django-taggit
|
||||||
# django-user-sessions
|
# django-user-sessions
|
||||||
# django-weasyprint
|
# django-weasyprint
|
||||||
# django-xforwardedfor-middleware
|
# django-xforwardedfor-middleware
|
||||||
@ -125,6 +126,8 @@ django-sslserver==0.22
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-stdimage==5.3.0
|
django-stdimage==5.3.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
django-taggit==3.1.0
|
||||||
|
# via -r requirements.in
|
||||||
django-user-sessions==2.0.0
|
django-user-sessions==2.0.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-weasyprint==2.2.0
|
django-weasyprint==2.2.0
|
||||||
|
Loading…
Reference in New Issue
Block a user