mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +00:00 
			
		
		
		
	Part stocktake (#4069)
* Remove stat context variables
* Revert "Remove stat context variables"
This reverts commit 0989c308d0.
* Allow longer timeout for image download tests
* Create PartStocktake model
Model for representing stocktake entries against any given part
* Admin interface support for new model
* Adds API endpoint for listing stocktake information
* Bump API version
* Enable filtering and ordering for API endpoint
* Add model to permission group
* Add UI hooks for displaying stocktake data for a particular part
* Fix encoded type for 'quantity' field
* Load stocktake table for part
* Add "stocktake" button
* Add "note" field for stocktake
* Add user information when performing stocktake
* First pass at UI elements for performing stocktake
* Add user information to stocktake table
* Auto-calculate quantity based on available stock items
* add stocktake data as tabular inline (admin)
* js linting
* Add indication that a stock item has not been updated recently
* Display last stocktake information on part page
* js style fix
* Test fix for ongoing CI issues
* Add configurable option for controlling default "delete_on_deplete" behaviour
* Add id values to cells
* Hide action buttons (at least for now)
* Adds refresh button to table
* Add API endpoint to delete or edit stocktake entries
* Adds unit test for API list endpoint
* javascript linting
* More unit testing
* Add Part API filter for stocktake
* Add 'last_stocktake' field to Part model
- Gets filled out automatically when a new PartStocktake instance is created
* Update part table to include last_stocktake date
* Add simple unit test
* Fix test
			
			
This commit is contained in:
		@@ -104,6 +104,11 @@ class PartResource(InvenTreeResource):
 | 
			
		||||
        models.Part.objects.rebuild()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class StocktakeInline(admin.TabularInline):
 | 
			
		||||
    """Inline for part stocktake data"""
 | 
			
		||||
    model = models.PartStocktake
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartAdmin(ImportExportModelAdmin):
 | 
			
		||||
    """Admin class for the Part model"""
 | 
			
		||||
 | 
			
		||||
@@ -122,6 +127,10 @@ class PartAdmin(ImportExportModelAdmin):
 | 
			
		||||
        'default_supplier',
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    inlines = [
 | 
			
		||||
        StocktakeInline,
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartPricingAdmin(admin.ModelAdmin):
 | 
			
		||||
    """Admin class for PartPricing model"""
 | 
			
		||||
@@ -133,6 +142,12 @@ class PartPricingAdmin(admin.ModelAdmin):
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartStocktakeAdmin(admin.ModelAdmin):
 | 
			
		||||
    """Admin class for PartStocktake model"""
 | 
			
		||||
 | 
			
		||||
    list_display = ['part', 'date', 'quantity', 'user']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartCategoryResource(InvenTreeResource):
 | 
			
		||||
    """Class for managing PartCategory data import/export."""
 | 
			
		||||
 | 
			
		||||
@@ -400,3 +415,4 @@ admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin)
 | 
			
		||||
admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
 | 
			
		||||
admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)
 | 
			
		||||
admin.site.register(models.PartPricing, PartPricingAdmin)
 | 
			
		||||
admin.site.register(models.PartStocktake, PartStocktakeAdmin)
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ from django_filters import rest_framework as rest_filters
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from rest_framework import filters, serializers, status
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.permissions import IsAdminUser
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
 | 
			
		||||
import order.models
 | 
			
		||||
@@ -27,6 +28,7 @@ from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
 | 
			
		||||
                              ListAPI, ListCreateAPI, RetrieveAPI,
 | 
			
		||||
                              RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
 | 
			
		||||
                              UpdateAPI)
 | 
			
		||||
from InvenTree.permissions import RolePermission
 | 
			
		||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
 | 
			
		||||
                                    SalesOrderStatus)
 | 
			
		||||
from part.admin import PartCategoryResource, PartResource
 | 
			
		||||
@@ -38,7 +40,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
 | 
			
		||||
                     PartCategory, PartCategoryParameterTemplate,
 | 
			
		||||
                     PartInternalPriceBreak, PartParameter,
 | 
			
		||||
                     PartParameterTemplate, PartRelated, PartSellPriceBreak,
 | 
			
		||||
                     PartTestTemplate)
 | 
			
		||||
                     PartStocktake, PartTestTemplate)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CategoryList(APIDownloadMixin, ListCreateAPI):
 | 
			
		||||
@@ -1061,6 +1063,20 @@ class PartFilter(rest_filters.FilterSet):
 | 
			
		||||
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
    stocktake = rest_filters.BooleanFilter(label="Has stocktake", method='filter_has_stocktake')
 | 
			
		||||
 | 
			
		||||
    def filter_has_stocktake(self, queryset, name, value):
 | 
			
		||||
        """Filter the queryset based on whether stocktake data is available"""
 | 
			
		||||
 | 
			
		||||
        value = str2bool(value)
 | 
			
		||||
 | 
			
		||||
        if (value):
 | 
			
		||||
            queryset = queryset.exclude(last_stocktake=None)
 | 
			
		||||
        else:
 | 
			
		||||
            queryset = queryset.filter(last_stocktake=None)
 | 
			
		||||
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
    is_template = rest_filters.BooleanFilter()
 | 
			
		||||
 | 
			
		||||
    assembly = rest_filters.BooleanFilter()
 | 
			
		||||
@@ -1537,6 +1553,7 @@ class PartList(APIDownloadMixin, ListCreateAPI):
 | 
			
		||||
        'in_stock',
 | 
			
		||||
        'unallocated_stock',
 | 
			
		||||
        'category',
 | 
			
		||||
        'last_stocktake',
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    # Default ordering
 | 
			
		||||
@@ -1696,6 +1713,63 @@ class PartParameterDetail(RetrieveUpdateDestroyAPI):
 | 
			
		||||
    serializer_class = part_serializers.PartParameterSerializer
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartStocktakeFilter(rest_filters.FilterSet):
 | 
			
		||||
    """Custom fitler for the PartStocktakeList endpoint"""
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        """Metaclass options"""
 | 
			
		||||
 | 
			
		||||
        model = PartStocktake
 | 
			
		||||
        fields = [
 | 
			
		||||
            'part',
 | 
			
		||||
            'user',
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartStocktakeList(ListCreateAPI):
 | 
			
		||||
    """API endpoint for listing part stocktake information"""
 | 
			
		||||
 | 
			
		||||
    queryset = PartStocktake.objects.all()
 | 
			
		||||
    serializer_class = part_serializers.PartStocktakeSerializer
 | 
			
		||||
    filterset_class = PartStocktakeFilter
 | 
			
		||||
 | 
			
		||||
    def get_serializer_context(self):
 | 
			
		||||
        """Extend serializer context data"""
 | 
			
		||||
        context = super().get_serializer_context()
 | 
			
		||||
        context['request'] = self.request
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
    filter_backends = [
 | 
			
		||||
        DjangoFilterBackend,
 | 
			
		||||
        filters.OrderingFilter,
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    ordering_fields = [
 | 
			
		||||
        'part',
 | 
			
		||||
        'quantity',
 | 
			
		||||
        'date',
 | 
			
		||||
        'user',
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    # Reverse date ordering by default
 | 
			
		||||
    ordering = '-pk'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartStocktakeDetail(RetrieveUpdateDestroyAPI):
 | 
			
		||||
    """Detail API endpoint for a single PartStocktake instance.
 | 
			
		||||
 | 
			
		||||
    Note: Only staff (admin) users can access this endpoint.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    queryset = PartStocktake.objects.all()
 | 
			
		||||
    serializer_class = part_serializers.PartStocktakeSerializer
 | 
			
		||||
    permission_classes = [
 | 
			
		||||
        IsAdminUser,
 | 
			
		||||
        RolePermission,
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BomFilter(rest_filters.FilterSet):
 | 
			
		||||
    """Custom filters for the BOM list."""
 | 
			
		||||
 | 
			
		||||
@@ -2111,6 +2185,12 @@ part_api_urls = [
 | 
			
		||||
        re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'),
 | 
			
		||||
    ])),
 | 
			
		||||
 | 
			
		||||
    # Part stocktake data
 | 
			
		||||
    re_path(r'^stocktake/', include([
 | 
			
		||||
        re_path(r'^(?P<pk>\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'),
 | 
			
		||||
        re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'),
 | 
			
		||||
    ])),
 | 
			
		||||
 | 
			
		||||
    re_path(r'^thumbs/', include([
 | 
			
		||||
        path('', PartThumbs.as_view(), name='api-part-thumbs'),
 | 
			
		||||
        re_path(r'^(?P<pk>\d+)/?', PartThumbsUpdate.as_view(), name='api-part-thumbs-update'),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										28
									
								
								InvenTree/part/migrations/0091_partstocktake.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								InvenTree/part/migrations/0091_partstocktake.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
# Generated by Django 3.2.16 on 2022-12-21 11:26
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
import django.core.validators
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
        ('part', '0090_auto_20221115_0816'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='PartStocktake',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('quantity', models.DecimalField(decimal_places=5, help_text='Total available stock at time of stocktake', max_digits=19, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')),
 | 
			
		||||
                ('date', models.DateField(auto_now_add=True, help_text='Date stocktake was performed', verbose_name='Date')),
 | 
			
		||||
                ('note', models.CharField(blank=True, help_text='Additional notes', max_length=250, verbose_name='Notes')),
 | 
			
		||||
                ('part', models.ForeignKey(help_text='Part for stocktake', on_delete=django.db.models.deletion.CASCADE, related_name='stocktakes', to='part.part', verbose_name='Part')),
 | 
			
		||||
                ('user', models.ForeignKey(blank=True, help_text='User who performed this stocktake', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='part_stocktakes', to=settings.AUTH_USER_MODEL, verbose_name='User')),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								InvenTree/part/migrations/0092_part_last_stocktake.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								InvenTree/part/migrations/0092_part_last_stocktake.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 3.2.16 on 2022-12-31 09:51
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('part', '0091_partstocktake'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='part',
 | 
			
		||||
            name='last_stocktake',
 | 
			
		||||
            field=models.DateField(blank=True, null=True, verbose_name='Last Stocktake'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -372,6 +372,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
 | 
			
		||||
        creation_date: Date that this part was added to the database
 | 
			
		||||
        creation_user: User who added this part to the database
 | 
			
		||||
        responsible: User who is responsible for this part (optional)
 | 
			
		||||
        last_stocktake: Date at which last stocktake was performed for this Part
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    objects = PartManager()
 | 
			
		||||
@@ -1004,6 +1005,11 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
 | 
			
		||||
 | 
			
		||||
    responsible = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), related_name='parts_responible')
 | 
			
		||||
 | 
			
		||||
    last_stocktake = models.DateField(
 | 
			
		||||
        blank=True, null=True,
 | 
			
		||||
        verbose_name=_('Last Stocktake'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def category_path(self):
 | 
			
		||||
        """Return the category path of this Part instance"""
 | 
			
		||||
@@ -2161,6 +2167,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
 | 
			
		||||
 | 
			
		||||
        return params
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def latest_stocktake(self):
 | 
			
		||||
        """Return the latest PartStocktake object associated with this part (if one exists)"""
 | 
			
		||||
 | 
			
		||||
        return self.stocktakes.order_by('-pk').first()
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def has_variants(self):
 | 
			
		||||
        """Check if this Part object has variants underneath it."""
 | 
			
		||||
@@ -2878,6 +2890,66 @@ class PartPricing(models.Model):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartStocktake(models.Model):
 | 
			
		||||
    """Model representing a 'stocktake' entry for a particular Part.
 | 
			
		||||
 | 
			
		||||
    A 'stocktake' is a representative count of available stock:
 | 
			
		||||
    - Performed on a given date
 | 
			
		||||
    - Records quantity of part in stock (across multiple stock items)
 | 
			
		||||
    - Records user information
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    part = models.ForeignKey(
 | 
			
		||||
        Part,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        related_name='stocktakes',
 | 
			
		||||
        verbose_name=_('Part'),
 | 
			
		||||
        help_text=_('Part for stocktake'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    quantity = models.DecimalField(
 | 
			
		||||
        max_digits=19, decimal_places=5,
 | 
			
		||||
        validators=[MinValueValidator(0)],
 | 
			
		||||
        verbose_name=_('Quantity'),
 | 
			
		||||
        help_text=_('Total available stock at time of stocktake'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    date = models.DateField(
 | 
			
		||||
        verbose_name=_('Date'),
 | 
			
		||||
        help_text=_('Date stocktake was performed'),
 | 
			
		||||
        auto_now_add=True
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    note = models.CharField(
 | 
			
		||||
        max_length=250,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        verbose_name=_('Notes'),
 | 
			
		||||
        help_text=_('Additional notes'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    user = models.ForeignKey(
 | 
			
		||||
        User, blank=True, null=True,
 | 
			
		||||
        on_delete=models.SET_NULL,
 | 
			
		||||
        related_name='part_stocktakes',
 | 
			
		||||
        verbose_name=_('User'),
 | 
			
		||||
        help_text=_('User who performed this stocktake'),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(post_save, sender=PartStocktake, dispatch_uid='post_save_stocktake')
 | 
			
		||||
def update_last_stocktake(sender, instance, created, **kwargs):
 | 
			
		||||
    """Callback function when a PartStocktake instance is created / edited"""
 | 
			
		||||
 | 
			
		||||
    # When a new PartStocktake instance is create, update the last_stocktake date for the Part
 | 
			
		||||
    if created:
 | 
			
		||||
        try:
 | 
			
		||||
            part = instance.part
 | 
			
		||||
            part.last_stocktake = instance.date
 | 
			
		||||
            part.save()
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartAttachment(InvenTreeAttachment):
 | 
			
		||||
    """Model for storing file attachments against a Part object."""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -24,14 +24,16 @@ from InvenTree.serializers import (DataFileExtractSerializer,
 | 
			
		||||
                                   InvenTreeDecimalField,
 | 
			
		||||
                                   InvenTreeImageSerializerField,
 | 
			
		||||
                                   InvenTreeModelSerializer,
 | 
			
		||||
                                   InvenTreeMoneySerializer, RemoteImageMixin)
 | 
			
		||||
                                   InvenTreeMoneySerializer, RemoteImageMixin,
 | 
			
		||||
                                   UserSerializer)
 | 
			
		||||
from InvenTree.status_codes import BuildStatus
 | 
			
		||||
 | 
			
		||||
from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
 | 
			
		||||
                     PartCategory, PartCategoryParameterTemplate,
 | 
			
		||||
                     PartInternalPriceBreak, PartParameter,
 | 
			
		||||
                     PartParameterTemplate, PartPricing, PartRelated,
 | 
			
		||||
                     PartSellPriceBreak, PartStar, PartTestTemplate)
 | 
			
		||||
                     PartSellPriceBreak, PartStar, PartStocktake,
 | 
			
		||||
                     PartTestTemplate)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CategorySerializer(InvenTreeModelSerializer):
 | 
			
		||||
@@ -451,6 +453,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
 | 
			
		||||
            'IPN',
 | 
			
		||||
            'is_template',
 | 
			
		||||
            'keywords',
 | 
			
		||||
            'last_stocktake',
 | 
			
		||||
            'link',
 | 
			
		||||
            'minimum_stock',
 | 
			
		||||
            'name',
 | 
			
		||||
@@ -504,6 +507,44 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
 | 
			
		||||
        return self.instance
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartStocktakeSerializer(InvenTreeModelSerializer):
 | 
			
		||||
    """Serializer for the PartStocktake model"""
 | 
			
		||||
 | 
			
		||||
    quantity = serializers.FloatField()
 | 
			
		||||
 | 
			
		||||
    user_detail = UserSerializer(source='user', read_only=True, many=False)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        """Metaclass options"""
 | 
			
		||||
 | 
			
		||||
        model = PartStocktake
 | 
			
		||||
        fields = [
 | 
			
		||||
            'pk',
 | 
			
		||||
            'date',
 | 
			
		||||
            'part',
 | 
			
		||||
            'quantity',
 | 
			
		||||
            'note',
 | 
			
		||||
            'user',
 | 
			
		||||
            'user_detail',
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        read_only_fields = [
 | 
			
		||||
            'date',
 | 
			
		||||
            'user',
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def save(self):
 | 
			
		||||
        """Called when this serializer is saved"""
 | 
			
		||||
 | 
			
		||||
        data = self.validated_data
 | 
			
		||||
 | 
			
		||||
        # Add in user information automatically
 | 
			
		||||
        request = self.context['request']
 | 
			
		||||
        data['user'] = request.user
 | 
			
		||||
 | 
			
		||||
        super().save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartPricingSerializer(InvenTreeModelSerializer):
 | 
			
		||||
    """Serializer for Part pricing information"""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -53,6 +53,29 @@
 | 
			
		||||
</div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %}
 | 
			
		||||
{% if show_stocktake %}
 | 
			
		||||
<div class='panel panel-hidden' id='panel-stocktake'>
 | 
			
		||||
    <div class='panel-heading'>
 | 
			
		||||
        <div class='d-flex flex-wrap'>
 | 
			
		||||
            <h4>{% trans "Part Stocktake" %}</h4>
 | 
			
		||||
            {% include "spacer.html" %}
 | 
			
		||||
            <div class='btn-group' role='group'>
 | 
			
		||||
                {% if roles.part.add %}
 | 
			
		||||
                <button class='btn btn-success' type='button' id='btn-stocktake' title='{% trans "Add stocktake information" %}'>
 | 
			
		||||
                    <span class='fas fa-clipboard-check'></span> {% trans "Stocktake" %}
 | 
			
		||||
                </button>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                <!-- TODO: Buttons -->
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class='panel-content'>
 | 
			
		||||
        {% include "part/part_stocktake.html" %}
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
<div class='panel panel-hidden' id='panel-test-templates'>
 | 
			
		||||
    <div class='panel-heading'>
 | 
			
		||||
        <div class='d-flex flex-wrap'>
 | 
			
		||||
@@ -423,7 +446,7 @@
 | 
			
		||||
            'part-notes',
 | 
			
		||||
            '{% url "api-part-detail" part.pk %}',
 | 
			
		||||
            {
 | 
			
		||||
                editable: {% if roles.part.change %}true{% else %}false{% endif %},
 | 
			
		||||
                editable: {% js_bool roles.part.change %},
 | 
			
		||||
            }
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
@@ -442,6 +465,23 @@
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Load the "stocktake" tab
 | 
			
		||||
    onPanelLoad('stocktake', function() {
 | 
			
		||||
        loadPartStocktakeTable({{ part.pk }}, {
 | 
			
		||||
            admin: {% js_bool user.is_staff %},
 | 
			
		||||
            allow_edit: {% js_bool roles.part.change %},
 | 
			
		||||
            allow_delete: {% js_bool roles.part.delete %},
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        $('#btn-stocktake').click(function() {
 | 
			
		||||
            performStocktake({{ part.pk }}, {
 | 
			
		||||
                onSuccess: function() {
 | 
			
		||||
                    $('#part-stocktake-table').bootstrapTable('refresh');
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Load the "suppliers" tab
 | 
			
		||||
    onPanelLoad('suppliers', function() {
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -338,6 +338,20 @@
 | 
			
		||||
                </tr>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% endwith %}
 | 
			
		||||
                {% with part.latest_stocktake as stocktake %}
 | 
			
		||||
                {% if stocktake %}
 | 
			
		||||
                <tr>
 | 
			
		||||
                    <td><span class='fas fa-clipboard-check'></span></td>
 | 
			
		||||
                    <td>{% trans "Last Stocktake" %}</td>
 | 
			
		||||
                    <td>
 | 
			
		||||
                        {% decimal stocktake.quantity %} <span class='fas fa-calendar-alt' title='{% render_date stocktake.date %}'></span>
 | 
			
		||||
                        <span class='badge bg-dark rounded-pill float-right'>
 | 
			
		||||
                            {{ stocktake.user.username }}
 | 
			
		||||
                        </span>
 | 
			
		||||
                    </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% endwith %}
 | 
			
		||||
                {% with part.get_latest_serial_number as sn %}
 | 
			
		||||
                {% if part.trackable and sn %}
 | 
			
		||||
                <tr>
 | 
			
		||||
 
 | 
			
		||||
@@ -44,6 +44,11 @@
 | 
			
		||||
{% trans "Scheduling" as text %}
 | 
			
		||||
{% include "sidebar_item.html" with label="scheduling" text=text icon="fa-calendar-alt" %}
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %}
 | 
			
		||||
{% if show_stocktake %}
 | 
			
		||||
{% trans "Stocktake" as text %}
 | 
			
		||||
{% include "sidebar_item.html" with label="stocktake" text=text icon="fa-clipboard-check" %}
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% if part.trackable %}
 | 
			
		||||
{% trans "Test Templates" as text %}
 | 
			
		||||
{% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								InvenTree/part/templates/part/part_stocktake.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								InvenTree/part/templates/part/part_stocktake.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
{% load inventree_extras %}
 | 
			
		||||
 | 
			
		||||
<div id='part-stocktake-toolbar'>
 | 
			
		||||
    <div class='btn-group' role='group'>
 | 
			
		||||
        {% include "filter_list.html" with id="partstocktake" %}
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
<table class='table table-condensed table-striped' id='part-stocktake-table' data-toolbar='#part-stocktake-toolbar'>
 | 
			
		||||
</table>
 | 
			
		||||
@@ -484,6 +484,16 @@ def primitive_to_javascript(primitive):
 | 
			
		||||
        return format_html("'{}'", primitive)  # noqa: P103
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register.simple_tag()
 | 
			
		||||
def js_bool(val):
 | 
			
		||||
    """Return a javascript boolean value (true or false)"""
 | 
			
		||||
 | 
			
		||||
    if val:
 | 
			
		||||
        return 'true'
 | 
			
		||||
    else:
 | 
			
		||||
        return 'false'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register.filter
 | 
			
		||||
def keyvalue(dict, key):
 | 
			
		||||
    """Access to key of supplied dict.
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
 | 
			
		||||
                                    StockStatus)
 | 
			
		||||
from part.models import (BomItem, BomItemSubstitute, Part, PartCategory,
 | 
			
		||||
                         PartCategoryParameterTemplate, PartParameterTemplate,
 | 
			
		||||
                         PartRelated)
 | 
			
		||||
                         PartRelated, PartStocktake)
 | 
			
		||||
from stock.models import StockItem, StockLocation
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -2779,3 +2779,141 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase):
 | 
			
		||||
 | 
			
		||||
        with self.assertRaises(Part.DoesNotExist):
 | 
			
		||||
            p.refresh_from_db()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartStocktakeTest(InvenTreeAPITestCase):
 | 
			
		||||
    """Unit tests for the part stocktake functionality"""
 | 
			
		||||
 | 
			
		||||
    superuser = False
 | 
			
		||||
    is_staff = False
 | 
			
		||||
 | 
			
		||||
    fixtures = [
 | 
			
		||||
        'category',
 | 
			
		||||
        'part',
 | 
			
		||||
        'location',
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    def test_list_endpoint(self):
 | 
			
		||||
        """Test the list endpoint for the stocktake data"""
 | 
			
		||||
 | 
			
		||||
        url = reverse('api-part-stocktake-list')
 | 
			
		||||
 | 
			
		||||
        self.assignRole('part.view')
 | 
			
		||||
 | 
			
		||||
        # Initially, no stocktake entries
 | 
			
		||||
        response = self.get(url, expected_code=200)
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(len(response.data), 0)
 | 
			
		||||
 | 
			
		||||
        total = 0
 | 
			
		||||
 | 
			
		||||
        # Create some entries
 | 
			
		||||
        for p in Part.objects.all():
 | 
			
		||||
 | 
			
		||||
            for n in range(p.pk):
 | 
			
		||||
                PartStocktake.objects.create(
 | 
			
		||||
                    part=p,
 | 
			
		||||
                    quantity=(n + 1) * 100,
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
            total += p.pk
 | 
			
		||||
 | 
			
		||||
            response = self.get(
 | 
			
		||||
                url,
 | 
			
		||||
                {
 | 
			
		||||
                    'part': p.pk,
 | 
			
		||||
                },
 | 
			
		||||
                expected_code=200,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            # List by part ID
 | 
			
		||||
            self.assertEqual(len(response.data), p.pk)
 | 
			
		||||
 | 
			
		||||
        # List all entries
 | 
			
		||||
        response = self.get(url, {}, expected_code=200)
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(len(response.data), total)
 | 
			
		||||
 | 
			
		||||
    def test_create_stocktake(self):
 | 
			
		||||
        """Test that stocktake entries can be created via the API"""
 | 
			
		||||
 | 
			
		||||
        url = reverse('api-part-stocktake-list')
 | 
			
		||||
 | 
			
		||||
        self.assignRole('part.add')
 | 
			
		||||
        self.assignRole('part.view')
 | 
			
		||||
 | 
			
		||||
        for p in Part.objects.all():
 | 
			
		||||
 | 
			
		||||
            # Initially no stocktake information available
 | 
			
		||||
            self.assertIsNone(p.latest_stocktake)
 | 
			
		||||
 | 
			
		||||
            note = f"Note {p.pk}"
 | 
			
		||||
            quantity = p.pk + 5
 | 
			
		||||
 | 
			
		||||
            self.post(
 | 
			
		||||
                url,
 | 
			
		||||
                {
 | 
			
		||||
                    'part': p.pk,
 | 
			
		||||
                    'quantity': quantity,
 | 
			
		||||
                    'note': note,
 | 
			
		||||
                },
 | 
			
		||||
                expected_code=201,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            p.refresh_from_db()
 | 
			
		||||
            stocktake = p.latest_stocktake
 | 
			
		||||
 | 
			
		||||
            self.assertIsNotNone(stocktake)
 | 
			
		||||
            self.assertEqual(stocktake.quantity, quantity)
 | 
			
		||||
            self.assertEqual(stocktake.part, p)
 | 
			
		||||
            self.assertEqual(stocktake.note, note)
 | 
			
		||||
 | 
			
		||||
    def test_edit_stocktake(self):
 | 
			
		||||
        """Test that a Stoctake instance can be edited and deleted via the API.
 | 
			
		||||
 | 
			
		||||
        Note that only 'staff' users can perform these actions.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        p = Part.objects.all().first()
 | 
			
		||||
 | 
			
		||||
        st = PartStocktake.objects.create(part=p, quantity=10)
 | 
			
		||||
 | 
			
		||||
        url = reverse('api-part-stocktake-detail', kwargs={'pk': st.pk})
 | 
			
		||||
        self.assignRole('part.view')
 | 
			
		||||
 | 
			
		||||
        # Test we can retrieve via API
 | 
			
		||||
        self.get(url, expected_code=403)
 | 
			
		||||
 | 
			
		||||
        # Assign staff permission
 | 
			
		||||
        self.user.is_staff = True
 | 
			
		||||
        self.user.save()
 | 
			
		||||
 | 
			
		||||
        self.get(url, expected_code=200)
 | 
			
		||||
 | 
			
		||||
        # Try to edit data
 | 
			
		||||
        self.patch(
 | 
			
		||||
            url,
 | 
			
		||||
            {
 | 
			
		||||
                'note': 'Another edit',
 | 
			
		||||
            },
 | 
			
		||||
            expected_code=403
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Assign 'edit' role permission
 | 
			
		||||
        self.assignRole('part.change')
 | 
			
		||||
 | 
			
		||||
        # Try again
 | 
			
		||||
        self.patch(
 | 
			
		||||
            url,
 | 
			
		||||
            {
 | 
			
		||||
                'note': 'Editing note field again',
 | 
			
		||||
            },
 | 
			
		||||
            expected_code=200,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Try to delete
 | 
			
		||||
        self.delete(url, expected_code=403)
 | 
			
		||||
 | 
			
		||||
        self.assignRole('part.delete')
 | 
			
		||||
 | 
			
		||||
        self.delete(url, expected_code=204)
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,8 @@ from InvenTree import version
 | 
			
		||||
from InvenTree.helpers import InvenTreeTestCase
 | 
			
		||||
 | 
			
		||||
from .models import (Part, PartCategory, PartCategoryStar, PartRelated,
 | 
			
		||||
                     PartStar, PartTestTemplate, rename_part_image)
 | 
			
		||||
                     PartStar, PartStocktake, PartTestTemplate,
 | 
			
		||||
                     rename_part_image)
 | 
			
		||||
from .templatetags import inventree_extras
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -338,6 +339,19 @@ class PartTest(TestCase):
 | 
			
		||||
        self.r2.delete()
 | 
			
		||||
        self.assertEqual(PartRelated.objects.count(), 0)
 | 
			
		||||
 | 
			
		||||
    def test_stocktake(self):
 | 
			
		||||
        """Test for adding stocktake data"""
 | 
			
		||||
 | 
			
		||||
        # Grab a part
 | 
			
		||||
        p = Part.objects.all().first()
 | 
			
		||||
 | 
			
		||||
        self.assertIsNone(p.last_stocktake)
 | 
			
		||||
 | 
			
		||||
        ps = PartStocktake.objects.create(part=p, quantity=100)
 | 
			
		||||
 | 
			
		||||
        self.assertIsNotNone(p.last_stocktake)
 | 
			
		||||
        self.assertEqual(p.last_stocktake, ps.date)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestTemplateTest(TestCase):
 | 
			
		||||
    """Unit test for the TestTemplate class"""
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user