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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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',

View 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"]'
}
```

View File

@ -166,6 +166,7 @@ nav:
- Installation: extend/plugins/install.md
- Developing a Plugin: extend/how_to_plugin.md
- Model Meatadata: extend/plugins/metadata.md
- Tags: extend/plugins/tags.md
- Plugin Mixins:
- Action Mixin: extend/plugins/action.md
- API Mixin: extend/plugins/api.md

View File

@ -23,6 +23,7 @@ django-q-sentry # sentry.io integration for django-q
django-sql-utils # Advanced query annotation / aggregation
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-taggit # Tagging support
django-user-sessions # user sessions in DB
django-weasyprint # django weasyprint integration
djangorestframework # DRF framework

View File

@ -68,6 +68,7 @@ django==3.2.18
# django-sql-utils
# django-sslserver
# django-stdimage
# django-taggit
# django-user-sessions
# django-weasyprint
# django-xforwardedfor-middleware
@ -125,6 +126,8 @@ django-sslserver==0.22
# via -r requirements.in
django-stdimage==5.3.0
# via -r requirements.in
django-taggit==3.1.0
# via -r requirements.in
django-user-sessions==2.0.0
# via -r requirements.in
django-weasyprint==2.2.0