mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Update docstrings for the 'part' directory
This commit is contained in:
parent
99676eef6d
commit
61491b7fe6
@ -583,4 +583,4 @@ class DataFileExtractSerializer(serializers.Serializer):
|
||||
|
||||
def save(self):
|
||||
"""No "save" action for this serializer."""
|
||||
...
|
||||
pass
|
||||
|
@ -1,8 +1 @@
|
||||
"""The Part module is responsible for Part management.
|
||||
|
||||
It includes models for:
|
||||
|
||||
- PartCategory
|
||||
- Part
|
||||
- BomItem
|
||||
"""
|
||||
"""The Part module is responsible for Part management."""
|
||||
|
@ -1,3 +1,5 @@
|
||||
"""Admin class definitions for the 'part' app"""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
import import_export.widgets as widgets
|
||||
@ -38,6 +40,7 @@ class PartResource(ModelResource):
|
||||
building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget())
|
||||
|
||||
class Meta:
|
||||
"""Metaclass definition"""
|
||||
model = models.Part
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
@ -61,8 +64,17 @@ class PartResource(ModelResource):
|
||||
|
||||
return query
|
||||
|
||||
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
|
||||
"""Rebuild MPTT tree structure after importing Part data"""
|
||||
|
||||
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
|
||||
|
||||
# Rebuild the Part tree(s)
|
||||
models.Part.objects.rebuild()
|
||||
|
||||
|
||||
class PartAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the Part model"""
|
||||
|
||||
resource_class = PartResource
|
||||
|
||||
@ -90,6 +102,7 @@ class PartCategoryResource(ModelResource):
|
||||
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
|
||||
|
||||
class Meta:
|
||||
"""Metaclass definition"""
|
||||
model = models.PartCategory
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
@ -102,6 +115,7 @@ class PartCategoryResource(ModelResource):
|
||||
]
|
||||
|
||||
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
|
||||
"""Rebuild MPTT tree structure after importing PartCategory data"""
|
||||
|
||||
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
|
||||
|
||||
@ -110,6 +124,7 @@ class PartCategoryResource(ModelResource):
|
||||
|
||||
|
||||
class PartCategoryAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the PartCategory model"""
|
||||
|
||||
resource_class = PartCategoryResource
|
||||
|
||||
@ -127,27 +142,15 @@ class PartRelatedAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
class PartAttachmentAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the PartAttachment model"""
|
||||
|
||||
list_display = ('part', 'attachment', 'comment')
|
||||
|
||||
autocomplete_fields = ('part',)
|
||||
|
||||
|
||||
class PartStarAdmin(admin.ModelAdmin):
|
||||
|
||||
list_display = ('part', 'user')
|
||||
|
||||
autocomplete_fields = ('part',)
|
||||
|
||||
|
||||
class PartCategoryStarAdmin(admin.ModelAdmin):
|
||||
|
||||
list_display = ('category', 'user')
|
||||
|
||||
autocomplete_fields = ('category',)
|
||||
|
||||
|
||||
class PartTestTemplateAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the PartTestTemplate model"""
|
||||
|
||||
list_display = ('part', 'test_name', 'required')
|
||||
|
||||
@ -193,7 +196,7 @@ class BomItemResource(ModelResource):
|
||||
return float(item.quantity)
|
||||
|
||||
def before_export(self, queryset, *args, **kwargs):
|
||||
|
||||
"""Perform before exporting data"""
|
||||
self.is_importing = kwargs.get('importing', False)
|
||||
|
||||
def get_fields(self, **kwargs):
|
||||
@ -229,6 +232,7 @@ class BomItemResource(ModelResource):
|
||||
return fields
|
||||
|
||||
class Meta:
|
||||
"""Metaclass definition"""
|
||||
model = models.BomItem
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
@ -243,6 +247,7 @@ class BomItemResource(ModelResource):
|
||||
|
||||
|
||||
class BomItemAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the BomItem model"""
|
||||
|
||||
resource_class = BomItemResource
|
||||
|
||||
@ -254,6 +259,8 @@ class BomItemAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
class ParameterTemplateAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the PartParameterTemplate model"""
|
||||
|
||||
list_display = ('name', 'units')
|
||||
|
||||
search_fields = ('name', 'units')
|
||||
@ -271,6 +278,7 @@ class ParameterResource(ModelResource):
|
||||
template_name = Field(attribute='template__name', readonly=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass definition"""
|
||||
model = models.PartParameter
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
@ -278,6 +286,7 @@ class ParameterResource(ModelResource):
|
||||
|
||||
|
||||
class ParameterAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the PartParameter model"""
|
||||
|
||||
resource_class = ParameterResource
|
||||
|
||||
@ -287,21 +296,26 @@ class ParameterAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
class PartCategoryParameterAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the PartCategoryParameterTemplate model"""
|
||||
|
||||
autocomplete_fields = ('category', 'parameter_template',)
|
||||
|
||||
|
||||
class PartSellPriceBreakAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the PartSellPriceBreak model"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass definition"""
|
||||
model = models.PartSellPriceBreak
|
||||
|
||||
list_display = ('part', 'quantity', 'price',)
|
||||
|
||||
|
||||
class PartInternalPriceBreakAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the PartInternalPriceBreak model"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass definition"""
|
||||
model = models.PartInternalPriceBreak
|
||||
|
||||
list_display = ('part', 'quantity', 'price',)
|
||||
@ -313,8 +327,6 @@ admin.site.register(models.Part, PartAdmin)
|
||||
admin.site.register(models.PartCategory, PartCategoryAdmin)
|
||||
admin.site.register(models.PartRelated, PartRelatedAdmin)
|
||||
admin.site.register(models.PartAttachment, PartAttachmentAdmin)
|
||||
admin.site.register(models.PartStar, PartStarAdmin)
|
||||
admin.site.register(models.PartCategoryStar, PartCategoryStarAdmin)
|
||||
admin.site.register(models.BomItem, BomItemAdmin)
|
||||
admin.site.register(models.PartParameterTemplate, ParameterTemplateAdmin)
|
||||
admin.site.register(models.PartParameter, ParameterAdmin)
|
||||
|
@ -49,7 +49,7 @@ class CategoryList(generics.ListCreateAPIView):
|
||||
serializer_class = part_serializers.CategorySerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
"""Add extra context data to the serializer for the PartCategoryList endpoint"""
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
try:
|
||||
@ -161,7 +161,7 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
queryset = PartCategory.objects.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
"""Add extra context to the serializer for the CategoryDetail endpoint"""
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
try:
|
||||
@ -173,7 +173,7 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
return ctx
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
|
||||
"""Perform 'update' function and mark this part as 'starred' (or not)"""
|
||||
if 'starred' in request.data:
|
||||
starred = str2bool(request.data.get('starred', False))
|
||||
|
||||
@ -188,6 +188,7 @@ class CategoryMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for viewing / updating PartCategory metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return a MetadataSerializer pointing to the referenced PartCategory instance"""
|
||||
return MetadataSerializer(PartCategory, *args, **kwargs)
|
||||
|
||||
queryset = PartCategory.objects.all()
|
||||
@ -370,7 +371,7 @@ class PartThumbs(generics.ListAPIView):
|
||||
serializer_class = part_serializers.PartThumbSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
"""Return a queryset which exlcudes any parts without images"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Get all Parts which have an associated image
|
||||
@ -432,7 +433,7 @@ class PartScheduling(generics.RetrieveAPIView):
|
||||
queryset = Part.objects.all()
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
|
||||
"""Return scheduling information for the referenced Part instance"""
|
||||
today = datetime.datetime.now().date()
|
||||
|
||||
part = self.get_object()
|
||||
@ -555,6 +556,7 @@ class PartMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for viewing / updating Part metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Returns a MetadataSerializer instance pointing to the referenced Part"""
|
||||
return MetadataSerializer(Part, *args, **kwargs)
|
||||
|
||||
queryset = Part.objects.all()
|
||||
@ -566,7 +568,7 @@ class PartSerialNumberDetail(generics.RetrieveAPIView):
|
||||
queryset = Part.objects.all()
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
|
||||
"""Return serial number information for the referenced Part instance"""
|
||||
part = self.get_object()
|
||||
|
||||
# Calculate the "latest" serial number
|
||||
@ -592,7 +594,7 @@ class PartCopyBOM(generics.CreateAPIView):
|
||||
serializer_class = part_serializers.PartCopyBOMSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
"""Add custom information to the serializer context for this endpoint"""
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
try:
|
||||
@ -607,8 +609,10 @@ class PartValidateBOM(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for 'validating' the BOM for a given Part."""
|
||||
|
||||
class BOMValidateSerializer(serializers.ModelSerializer):
|
||||
"""Simple serializer class for validating a single BomItem instance"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines serializer fields"""
|
||||
model = Part
|
||||
fields = [
|
||||
'checksum',
|
||||
@ -628,6 +632,7 @@ class PartValidateBOM(generics.RetrieveUpdateAPIView):
|
||||
)
|
||||
|
||||
def validate_valid(self, valid):
|
||||
"""Check that the 'valid' input was flagged"""
|
||||
if not valid:
|
||||
raise ValidationError(_('This option must be selected'))
|
||||
|
||||
@ -636,7 +641,7 @@ class PartValidateBOM(generics.RetrieveUpdateAPIView):
|
||||
serializer_class = BOMValidateSerializer
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
|
||||
"""Validate the referenced BomItem instance"""
|
||||
part = self.get_object()
|
||||
|
||||
partial = kwargs.pop('partial', False)
|
||||
@ -660,6 +665,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
starred_parts = None
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return an annotated queryset object for the PartDetail endpoint"""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
||||
@ -667,7 +673,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
return queryset
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
"""Return a serializer instance for the PartDetail endpoint"""
|
||||
# By default, include 'category_detail' information in the detail view
|
||||
try:
|
||||
kwargs['category_detail'] = str2bool(self.request.query_params.get('category_detail', True))
|
||||
@ -687,7 +693,11 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
# Retrieve part
|
||||
"""Delete a Part instance via the API
|
||||
|
||||
- If the part is 'active' it cannot be deleted
|
||||
- It must first be marked as 'inactive'
|
||||
"""
|
||||
part = Part.objects.get(pk=int(kwargs['pk']))
|
||||
# Check if inactive
|
||||
if not part.active:
|
||||
@ -695,7 +705,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
return super(PartDetail, self).destroy(request, *args, **kwargs)
|
||||
else:
|
||||
# Return 405 error
|
||||
message = f'Part \'{part.name}\' (pk = {part.pk}) is active: cannot delete'
|
||||
message = 'Part is active: cannot delete'
|
||||
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
@ -723,7 +733,7 @@ class PartFilter(rest_filters.FilterSet):
|
||||
has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn')
|
||||
|
||||
def filter_has_ipn(self, queryset, name, value):
|
||||
|
||||
"""Filter by whether the Part has an IPN (internal part number) or not"""
|
||||
value = str2bool(value)
|
||||
|
||||
if value:
|
||||
@ -768,7 +778,7 @@ class PartFilter(rest_filters.FilterSet):
|
||||
has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock')
|
||||
|
||||
def filter_has_stock(self, queryset, name, value):
|
||||
|
||||
"""Filter by whether the Part has any stock"""
|
||||
value = str2bool(value)
|
||||
|
||||
if value:
|
||||
@ -782,7 +792,7 @@ class PartFilter(rest_filters.FilterSet):
|
||||
unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock')
|
||||
|
||||
def filter_unallocated_stock(self, queryset, name, value):
|
||||
|
||||
"""Filter by whether the Part has unallocated stock"""
|
||||
value = str2bool(value)
|
||||
|
||||
if value:
|
||||
@ -837,7 +847,7 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||
starred_parts = None
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
"""Return a serializer instance for this endpoint"""
|
||||
# Ensure the request context is passed through
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
@ -859,6 +869,7 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def download_queryset(self, queryset, export_format):
|
||||
"""Download the filtered queryset as a data file"""
|
||||
dataset = PartResource().export(queryset=queryset)
|
||||
|
||||
filedata = dataset.export(export_format)
|
||||
@ -1077,17 +1088,14 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
"""Return an annotated queryset object"""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Perform custom filtering of the queryset.
|
||||
|
||||
We overide the DRF filter_fields here because
|
||||
"""
|
||||
"""Perform custom filtering of the queryset"""
|
||||
params = self.request.query_params
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
@ -1345,7 +1353,7 @@ class PartRelatedList(generics.ListCreateAPIView):
|
||||
serializer_class = part_serializers.PartRelationSerializer
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
|
||||
"""Custom queryset filtering"""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
@ -1478,8 +1486,7 @@ class BomFilter(rest_filters.FilterSet):
|
||||
validated = rest_filters.BooleanFilter(label='BOM line has been validated', method='filter_validated')
|
||||
|
||||
def filter_validated(self, queryset, name, value):
|
||||
|
||||
# Work out which lines have actually been validated
|
||||
"""Filter by which lines have actually been validated"""
|
||||
pks = []
|
||||
|
||||
value = str2bool(value)
|
||||
@ -1512,6 +1519,7 @@ class BomList(generics.ListCreateAPIView):
|
||||
filterset_class = BomFilter
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Return serialized list response for this endpoint"""
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
@ -1537,6 +1545,13 @@ class BomList(generics.ListCreateAPIView):
|
||||
return Response(data)
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return the serializer instance for this API endpoint
|
||||
|
||||
If requested, extra detail fields are annotated to the queryset:
|
||||
- part_detail
|
||||
- sub_part_detail
|
||||
- include_pricing
|
||||
"""
|
||||
|
||||
# Do we wish to include extra detail?
|
||||
try:
|
||||
@ -1561,7 +1576,7 @@ class BomList(generics.ListCreateAPIView):
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
"""Return the queryset object for this endpoint"""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = self.get_serializer_class().setup_eager_loading(queryset)
|
||||
@ -1570,7 +1585,7 @@ class BomList(generics.ListCreateAPIView):
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
|
||||
"""Custom query filtering for the BomItem list API"""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
@ -1766,7 +1781,7 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = part_serializers.BomItemSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
"""Prefetch related fields for this queryset"""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = self.get_serializer_class().setup_eager_loading(queryset)
|
||||
@ -1778,9 +1793,8 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
class BomItemValidate(generics.UpdateAPIView):
|
||||
"""API endpoint for validating a BomItem."""
|
||||
|
||||
# Very simple serializers
|
||||
class BomItemValidationSerializer(serializers.Serializer):
|
||||
|
||||
"""Simple serializer for passing a single boolean field"""
|
||||
valid = serializers.BooleanField(default=False)
|
||||
|
||||
queryset = BomItem.objects.all()
|
||||
|
@ -1,3 +1,5 @@
|
||||
"""part app specification"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
@ -9,6 +11,7 @@ logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
class PartConfig(AppConfig):
|
||||
"""Config class for the 'part' app"""
|
||||
name = 'part'
|
||||
|
||||
def ready(self):
|
||||
|
@ -5,7 +5,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from mptt.fields import TreeNodeChoiceField
|
||||
|
||||
import common.models
|
||||
from common.forms import MatchItemForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.forms import HelperForm
|
||||
@ -16,20 +15,6 @@ from .models import (Part, PartCategory, PartCategoryParameterTemplate,
|
||||
PartSellPriceBreak)
|
||||
|
||||
|
||||
class PartModelChoiceField(forms.ModelChoiceField):
|
||||
"""Extending string representation of Part instance with available stock."""
|
||||
|
||||
def label_from_instance(self, part):
|
||||
|
||||
label = str(part)
|
||||
|
||||
# Optionally display available part quantity
|
||||
if common.models.InvenTreeSetting.get_setting('PART_SHOW_QUANTITY_IN_FORMS'):
|
||||
label += f" - {part.available_stock}"
|
||||
|
||||
return label
|
||||
|
||||
|
||||
class PartImageDownloadForm(HelperForm):
|
||||
"""Form for downloading an image from a URL."""
|
||||
|
||||
@ -40,6 +25,7 @@ class PartImageDownloadForm(HelperForm):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = Part
|
||||
fields = [
|
||||
'url',
|
||||
@ -78,6 +64,7 @@ class EditPartParameterTemplateForm(HelperForm):
|
||||
"""Form for editing a PartParameterTemplate object."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = PartParameterTemplate
|
||||
fields = [
|
||||
'name',
|
||||
@ -97,6 +84,7 @@ class EditCategoryParameterTemplateForm(HelperForm):
|
||||
help_text=_('Add parameter template to all categories'))
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = PartCategoryParameterTemplate
|
||||
fields = [
|
||||
'category',
|
||||
@ -118,6 +106,7 @@ class PartPriceForm(forms.Form):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = Part
|
||||
fields = [
|
||||
'quantity',
|
||||
@ -130,6 +119,7 @@ class EditPartSalePriceBreakForm(HelperForm):
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = PartSellPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
@ -144,6 +134,7 @@ class EditPartInternalPriceBreakForm(HelperForm):
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = PartInternalPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
|
@ -103,12 +103,15 @@ class PartCategory(MetadataMixin, InvenTreeTree):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API url associated with the PartCategory model"""
|
||||
return reverse('api-part-category-list')
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Return the web URL associated with the detail view for this PartCategory instance"""
|
||||
return reverse('category-detail', kwargs={'pk': self.id})
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model properties"""
|
||||
verbose_name = _("Part Category")
|
||||
verbose_name_plural = _("Part Categories")
|
||||
|
||||
@ -131,6 +134,7 @@ class PartCategory(MetadataMixin, InvenTreeTree):
|
||||
|
||||
@property
|
||||
def item_count(self):
|
||||
"""Return the number of parts contained in this PartCategory"""
|
||||
return self.partcount()
|
||||
|
||||
def partcount(self, cascade=True, active=False):
|
||||
@ -284,7 +288,7 @@ class PartManager(TreeManager):
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
"""Perform default prefetch operations when accessing Part model from the database"""
|
||||
return super().get_queryset().prefetch_related(
|
||||
'category',
|
||||
'category__parent',
|
||||
@ -333,6 +337,7 @@ class Part(MetadataMixin, MPTTModel):
|
||||
objects = PartManager()
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model properties"""
|
||||
verbose_name = _("Part")
|
||||
verbose_name_plural = _("Parts")
|
||||
ordering = ['name', ]
|
||||
@ -341,12 +346,13 @@ class Part(MetadataMixin, MPTTModel):
|
||||
]
|
||||
|
||||
class MPTTMeta:
|
||||
"""MPTT metaclass definitions"""
|
||||
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
|
||||
parent_attr = 'variant_of'
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
|
||||
"""Return the list API endpoint URL associated with the Part model"""
|
||||
return reverse('api-part-list')
|
||||
|
||||
def api_instance_filters(self):
|
||||
@ -450,6 +456,7 @@ class Part(MetadataMixin, MPTTModel):
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the Part (for use in the admin interface)"""
|
||||
return f"{self.full_name} - {self.description}"
|
||||
|
||||
def get_parts_in_bom(self, **kwargs):
|
||||
@ -665,15 +672,6 @@ class Part(MetadataMixin, MPTTModel):
|
||||
|
||||
return ' | '.join(elements)
|
||||
|
||||
def set_category(self, category):
|
||||
|
||||
# Ignore if the category is already the same
|
||||
if self.category == category:
|
||||
return
|
||||
|
||||
self.category = category
|
||||
self.save()
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Return the web URL for viewing this part."""
|
||||
return reverse('part-detail', kwargs={'pk': self.id})
|
||||
@ -956,6 +954,7 @@ class Part(MetadataMixin, MPTTModel):
|
||||
|
||||
@property
|
||||
def category_path(self):
|
||||
"""Return the category path of this Part instance"""
|
||||
if self.category:
|
||||
return self.category.pathstring
|
||||
return ''
|
||||
@ -1442,6 +1441,7 @@ class Part(MetadataMixin, MPTTModel):
|
||||
|
||||
@property
|
||||
def has_bom(self):
|
||||
"""Return True if this Part instance has any BOM items"""
|
||||
return self.get_bom_items().count() > 0
|
||||
|
||||
def get_trackable_parts(self):
|
||||
@ -1605,7 +1605,17 @@ class Part(MetadataMixin, MPTTModel):
|
||||
return "{a} - {b}".format(a=min_price, b=max_price)
|
||||
|
||||
def get_supplier_price_range(self, quantity=1):
|
||||
"""Return the supplier price range of this part:
|
||||
|
||||
- Checks if there is any supplier pricing information associated with this Part
|
||||
- Iterate through available supplier pricing and select (min, max)
|
||||
- Returns tuple of (min, max)
|
||||
|
||||
Arguments:
|
||||
quantity: Quantity at which to calculate price (default=1)
|
||||
|
||||
Returns: (min, max) tuple or (None, None) if no supplier pricing available
|
||||
"""
|
||||
min_price = None
|
||||
max_price = None
|
||||
|
||||
@ -1719,6 +1729,7 @@ class Part(MetadataMixin, MPTTModel):
|
||||
|
||||
@property
|
||||
def has_price_breaks(self):
|
||||
"""Return True if this part has sale price breaks"""
|
||||
return self.price_breaks.count() > 0
|
||||
|
||||
@property
|
||||
@ -1728,6 +1739,7 @@ class Part(MetadataMixin, MPTTModel):
|
||||
|
||||
@property
|
||||
def unit_pricing(self):
|
||||
"""Returns the price of this Part at quantity=1"""
|
||||
return self.get_price(1)
|
||||
|
||||
def add_price_break(self, quantity, price):
|
||||
@ -1748,10 +1760,12 @@ class Part(MetadataMixin, MPTTModel):
|
||||
)
|
||||
|
||||
def get_internal_price(self, quantity, moq=True, multiples=True, currency=None):
|
||||
"""Return the internal price of this Part at the specified quantity"""
|
||||
return common.models.get_price(self, quantity, moq, multiples, currency, break_name='internal_price_breaks')
|
||||
|
||||
@property
|
||||
def has_internal_price_breaks(self):
|
||||
"""Return True if this Part has internal pricing information"""
|
||||
return self.internal_price_breaks.count() > 0
|
||||
|
||||
@property
|
||||
@ -1759,11 +1773,12 @@ class Part(MetadataMixin, MPTTModel):
|
||||
"""Return the associated price breaks in the correct order."""
|
||||
return self.internalpricebreaks.order_by('quantity').all()
|
||||
|
||||
@property
|
||||
def internal_unit_pricing(self):
|
||||
return self.get_internal_price(1)
|
||||
|
||||
def get_purchase_price(self, quantity):
|
||||
"""Calculate the purchase price for this part at the specified quantity
|
||||
|
||||
- Looks at available supplier pricing data
|
||||
- Calculates the price base on the closest price point
|
||||
"""
|
||||
currency = currency_code_default()
|
||||
try:
|
||||
prices = [convert_money(item.purchase_price, currency).amount for item in self.stock_items.all() if item.purchase_price]
|
||||
@ -1843,7 +1858,7 @@ class Part(MetadataMixin, MPTTModel):
|
||||
|
||||
@transaction.atomic
|
||||
def copy_parameters_from(self, other, **kwargs):
|
||||
|
||||
"""Copy all parameter values from another Part instance"""
|
||||
clear = kwargs.get('clear', True)
|
||||
|
||||
if clear:
|
||||
@ -1920,12 +1935,9 @@ class Part(MetadataMixin, MPTTModel):
|
||||
return tests
|
||||
|
||||
def getRequiredTests(self):
|
||||
# Return the tests which are required by this part
|
||||
"""Return the tests which are required by this part"""
|
||||
return self.getTestTemplates(required=True)
|
||||
|
||||
def requiredTestCount(self):
|
||||
return self.getRequiredTests().count()
|
||||
|
||||
@property
|
||||
def attachment_count(self):
|
||||
"""Count the number of attachments for this part.
|
||||
@ -2093,6 +2105,7 @@ class Part(MetadataMixin, MPTTModel):
|
||||
|
||||
@property
|
||||
def related_count(self):
|
||||
"""Return the number of 'related parts' which point to this Part"""
|
||||
return len(self.get_related_parts())
|
||||
|
||||
def is_part_low_on_stock(self):
|
||||
@ -2117,9 +2130,11 @@ class PartAttachment(InvenTreeAttachment):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the list API endpoint URL associated with the PartAttachment model"""
|
||||
return reverse('api-part-attachment-list')
|
||||
|
||||
def getSubdir(self):
|
||||
"""Returns the media subdirectory where part attachments are stored"""
|
||||
return os.path.join("part_files", str(self.part.id))
|
||||
|
||||
part = models.ForeignKey(Part, on_delete=models.CASCADE,
|
||||
@ -2131,6 +2146,7 @@ class PartSellPriceBreak(common.models.PriceBreak):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the list API endpoint URL associated with the PartSellPriceBreak model"""
|
||||
return reverse('api-part-sale-price-list')
|
||||
|
||||
part = models.ForeignKey(
|
||||
@ -2141,6 +2157,7 @@ class PartSellPriceBreak(common.models.PriceBreak):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass providing extra model definition"""
|
||||
unique_together = ('part', 'quantity')
|
||||
|
||||
|
||||
@ -2149,6 +2166,7 @@ class PartInternalPriceBreak(common.models.PriceBreak):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the list API endpoint URL associated with the PartInternalPriceBreak model"""
|
||||
return reverse('api-part-internal-price-list')
|
||||
|
||||
part = models.ForeignKey(
|
||||
@ -2158,6 +2176,7 @@ class PartInternalPriceBreak(common.models.PriceBreak):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass providing extra model definition"""
|
||||
unique_together = ('part', 'quantity')
|
||||
|
||||
|
||||
@ -2176,6 +2195,7 @@ class PartStar(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts')
|
||||
|
||||
class Meta:
|
||||
"""Metaclass providing extra model definition"""
|
||||
unique_together = [
|
||||
'part',
|
||||
'user'
|
||||
@ -2195,6 +2215,7 @@ class PartCategoryStar(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories')
|
||||
|
||||
class Meta:
|
||||
"""Metaclass providing extra model definition"""
|
||||
unique_together = [
|
||||
'category',
|
||||
'user',
|
||||
@ -2216,16 +2237,17 @@ class PartTestTemplate(models.Model):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the list API endpoint URL associated with the PartTestTemplate model"""
|
||||
return reverse('api-part-test-template-list')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
"""Enforce 'clean' operation when saving a PartTestTemplate instance"""
|
||||
self.clean()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
|
||||
"""Clean fields for the PartTestTemplate model"""
|
||||
self.test_name = self.test_name.strip()
|
||||
|
||||
self.validate_unique()
|
||||
@ -2320,9 +2342,11 @@ class PartParameterTemplate(models.Model):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the list API endpoint URL associated with the PartParameterTemplate model"""
|
||||
return reverse('api-part-parameter-template-list')
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of a PartParameterTemplate instance"""
|
||||
s = str(self.name)
|
||||
if self.units:
|
||||
s += " ({units})".format(units=self.units)
|
||||
@ -2368,10 +2392,11 @@ class PartParameter(models.Model):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the list API endpoint URL associated with the PartParameter model"""
|
||||
return reverse('api-part-parameter-list')
|
||||
|
||||
def __str__(self):
|
||||
# String representation of a PartParameter (used in the admin interface)
|
||||
"""String representation of a PartParameter (used in the admin interface)"""
|
||||
return "{part} : {param} = {data}{units}".format(
|
||||
part=str(self.part.full_name),
|
||||
param=str(self.template.name),
|
||||
@ -2380,6 +2405,7 @@ class PartParameter(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass providing extra model definition"""
|
||||
# Prevent multiple instances of a parameter for a single part
|
||||
unique_together = ('part', 'template')
|
||||
|
||||
@ -2391,6 +2417,7 @@ class PartParameter(models.Model):
|
||||
|
||||
@classmethod
|
||||
def create(cls, part, template, data, save=False):
|
||||
"""Custom save method for the PartParameter class"""
|
||||
part_parameter = cls(part=part, template=template, data=data)
|
||||
if save:
|
||||
part_parameter.save()
|
||||
@ -2408,6 +2435,7 @@ class PartCategoryParameterTemplate(models.Model):
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass providing extra model definition"""
|
||||
constraints = [
|
||||
UniqueConstraint(fields=['category', 'parameter_template'],
|
||||
name='unique_category_parameter_template_pair')
|
||||
@ -2438,7 +2466,7 @@ class PartCategoryParameterTemplate(models.Model):
|
||||
help_text=_('Default Parameter Value'))
|
||||
|
||||
|
||||
class BomItem(models.Model, DataImportMixin):
|
||||
class BomItem(DataImportMixin, models.Model):
|
||||
"""A BomItem links a part to its component items.
|
||||
|
||||
A part can have a BOM (bill of materials) which defines
|
||||
@ -2492,6 +2520,7 @@ class BomItem(models.Model, DataImportMixin):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the list API endpoint URL associated with the BomItem model"""
|
||||
return reverse('api-bom-list')
|
||||
|
||||
def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True):
|
||||
@ -2546,7 +2575,7 @@ class BomItem(models.Model, DataImportMixin):
|
||||
return Q(part__in=[part.pk for part in self.get_valid_parts_for_allocation()])
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
"""Enforce 'clean' operation when saving a BomItem instance"""
|
||||
self.clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@ -2686,12 +2715,14 @@ class BomItem(models.Model, DataImportMixin):
|
||||
raise ValidationError({'sub_part': _('Sub part must be specified')})
|
||||
|
||||
class Meta:
|
||||
"""Metaclass providing extra model definition"""
|
||||
verbose_name = _("BOM Item")
|
||||
|
||||
# Prevent duplication of parent/child rows
|
||||
unique_together = ('part', 'sub_part')
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of this BomItem instance"""
|
||||
return "{n} x {child} to make {parent}".format(
|
||||
parent=self.part.full_name,
|
||||
child=self.sub_part.full_name,
|
||||
@ -2788,13 +2819,14 @@ class BomItemSubstitute(models.Model):
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass providing extra model definition"""
|
||||
verbose_name = _("BOM Item Substitute")
|
||||
|
||||
# Prevent duplication of substitute parts
|
||||
unique_together = ('part', 'bom_item')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
"""Enforce a full_clean when saving the BomItemSubstitute model"""
|
||||
self.full_clean()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
@ -2814,6 +2846,7 @@ class BomItemSubstitute(models.Model):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Returns the list API endpoint URL associated with this model"""
|
||||
return reverse('api-bom-substitute-list')
|
||||
|
||||
bom_item = models.ForeignKey(
|
||||
@ -2847,6 +2880,7 @@ class PartRelated(models.Model):
|
||||
verbose_name=_('Part 2'), help_text=_('Select Related Part'))
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of this Part-Part relationship"""
|
||||
return f'{self.part_1} <--> {self.part_2}'
|
||||
|
||||
def validate(self, part_1, part_2):
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""JSON serializers for Part app."""
|
||||
"""DRF data serializers for Part app."""
|
||||
|
||||
import imghdr
|
||||
from decimal import Decimal
|
||||
@ -37,10 +37,6 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
|
||||
class CategorySerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for PartCategory."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_starred(self, category):
|
||||
"""Return True if the category is directly "starred" by the current user."""
|
||||
return category in self.context.get('starred_categories', [])
|
||||
@ -54,6 +50,7 @@ class CategorySerializer(InvenTreeModelSerializer):
|
||||
starred = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartCategory
|
||||
fields = [
|
||||
'pk',
|
||||
@ -74,6 +71,7 @@ class CategoryTree(InvenTreeModelSerializer):
|
||||
"""Serializer for PartCategory tree."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartCategory
|
||||
fields = [
|
||||
'pk',
|
||||
@ -86,6 +84,7 @@ class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializer for the PartAttachment class."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartAttachment
|
||||
|
||||
fields = [
|
||||
@ -109,6 +108,7 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer):
|
||||
key = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartTestTemplate
|
||||
|
||||
fields = [
|
||||
@ -142,6 +142,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
||||
price_string = serializers.CharField(source='price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartSellPriceBreak
|
||||
fields = [
|
||||
'pk',
|
||||
@ -172,6 +173,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
||||
price_string = serializers.CharField(source='price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartInternalPriceBreak
|
||||
fields = [
|
||||
'pk',
|
||||
@ -206,6 +208,7 @@ class PartThumbSerializerUpdate(InvenTreeModelSerializer):
|
||||
image = InvenTreeAttachmentSerializerField(required=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = Part
|
||||
fields = [
|
||||
'image',
|
||||
@ -216,6 +219,7 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||
"""JSON serializer for the PartParameterTemplate model."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartParameterTemplate
|
||||
fields = [
|
||||
'pk',
|
||||
@ -230,6 +234,7 @@ class PartParameterSerializer(InvenTreeModelSerializer):
|
||||
template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartParameter
|
||||
fields = [
|
||||
'pk',
|
||||
@ -248,6 +253,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
stock = serializers.FloatField(source='total_stock')
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = Part
|
||||
fields = [
|
||||
'pk',
|
||||
@ -277,10 +283,14 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
|
||||
def get_api_url(self):
|
||||
"""Return the API url associated with this serializer"""
|
||||
return reverse_lazy('api-part-list')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Custom initialization method for PartSerializer, so that we can optionally pass extra fields based on the query."""
|
||||
"""Custom initialization method for PartSerializer:
|
||||
|
||||
- Allows us to optionally pass extra fields based on the query.
|
||||
"""
|
||||
self.starred_parts = kwargs.pop('starred_parts', [])
|
||||
|
||||
category_detail = kwargs.pop('category_detail', False)
|
||||
@ -452,6 +462,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = Part
|
||||
partial = True
|
||||
fields = [
|
||||
@ -503,6 +514,7 @@ class PartRelationSerializer(InvenTreeModelSerializer):
|
||||
part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartRelated
|
||||
fields = [
|
||||
'pk',
|
||||
@ -520,6 +532,7 @@ class PartStarSerializer(InvenTreeModelSerializer):
|
||||
username = serializers.CharField(source='user.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartStar
|
||||
fields = [
|
||||
'pk',
|
||||
@ -536,6 +549,7 @@ class BomItemSubstituteSerializer(InvenTreeModelSerializer):
|
||||
part_detail = PartBriefSerializer(source='part', read_only=True, many=False)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = BomItemSubstitute
|
||||
fields = [
|
||||
'pk',
|
||||
@ -553,6 +567,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
quantity = InvenTreeDecimalField(required=True)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Perform validation for the BomItem quantity field"""
|
||||
if quantity <= 0:
|
||||
raise serializers.ValidationError(_("Quantity must be greater than zero"))
|
||||
|
||||
@ -584,9 +599,11 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
available_variant_stock = serializers.FloatField(read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# part_detail and sub_part_detail serializers are only included if requested.
|
||||
# This saves a bunch of database requests
|
||||
"""Determine if extra detail fields are to be annotated on this serializer
|
||||
|
||||
- part_detail and sub_part_detail serializers are only included if requested.
|
||||
- This saves a bunch of database requests
|
||||
"""
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
sub_part_detail = kwargs.pop('sub_part_detail', False)
|
||||
include_pricing = kwargs.pop('include_pricing', False)
|
||||
@ -609,6 +626,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
@staticmethod
|
||||
def setup_eager_loading(queryset):
|
||||
"""Prefetch against the provided queryset to speed up database access"""
|
||||
queryset = queryset.prefetch_related('part')
|
||||
queryset = queryset.prefetch_related('part__category')
|
||||
queryset = queryset.prefetch_related('part__stock_items')
|
||||
@ -810,6 +828,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
return purchase_price_avg
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = BomItem
|
||||
fields = [
|
||||
'allow_variants',
|
||||
@ -849,6 +868,7 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||
category_detail = CategorySerializer(source='category', many=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartCategoryParameterTemplate
|
||||
fields = [
|
||||
'pk',
|
||||
@ -863,6 +883,7 @@ class PartCopyBOMSerializer(serializers.Serializer):
|
||||
"""Serializer for copying a BOM from another part."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
fields = [
|
||||
'part',
|
||||
'remove_existing',
|
||||
@ -929,6 +950,7 @@ class BomImportUploadSerializer(DataFileUploadSerializer):
|
||||
TARGET_MODEL = BomItem
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
fields = [
|
||||
'data_file',
|
||||
'part',
|
||||
@ -948,7 +970,7 @@ class BomImportUploadSerializer(DataFileUploadSerializer):
|
||||
)
|
||||
|
||||
def save(self):
|
||||
|
||||
"""The uploaded data file has been validated, accept the submitted data"""
|
||||
data = self.validated_data
|
||||
|
||||
if data.get('clear_existing_bom', False):
|
||||
@ -959,11 +981,15 @@ class BomImportUploadSerializer(DataFileUploadSerializer):
|
||||
|
||||
|
||||
class BomImportExtractSerializer(DataFileExtractSerializer):
|
||||
""""""
|
||||
"""Serializer class for exatracting BOM data from an uploaded file.
|
||||
|
||||
The parent class DataFileExtractSerializer does most of the heavy lifting here.
|
||||
"""
|
||||
|
||||
TARGET_MODEL = BomItem
|
||||
|
||||
def validate_extracted_columns(self):
|
||||
"""Validate that the extracted columns are correct"""
|
||||
super().validate_extracted_columns()
|
||||
|
||||
part_columns = ['part', 'part_name', 'part_ipn', 'part_id']
|
||||
@ -973,7 +999,7 @@ class BomImportExtractSerializer(DataFileExtractSerializer):
|
||||
raise serializers.ValidationError(_("No part column specified"))
|
||||
|
||||
def process_row(self, row):
|
||||
|
||||
"""Process a single row from the loaded BOM file"""
|
||||
# Skip any rows which are at a lower "level"
|
||||
level = row.get('level', None)
|
||||
|
||||
@ -1050,7 +1076,10 @@ class BomImportSubmitSerializer(serializers.Serializer):
|
||||
items = BomItemSerializer(many=True, required=True)
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate the submitted BomItem data:
|
||||
|
||||
- At least one line (BomItem) is required
|
||||
"""
|
||||
items = data['items']
|
||||
|
||||
if len(items) == 0:
|
||||
@ -1061,7 +1090,11 @@ class BomImportSubmitSerializer(serializers.Serializer):
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""POST: Perform final save of submitted BOM data:
|
||||
|
||||
- By this stage each line in the BOM has been validated
|
||||
- Individually 'save' (create) each BomItem line
|
||||
"""
|
||||
data = self.validated_data
|
||||
|
||||
items = data['items']
|
||||
|
@ -1,3 +1,5 @@
|
||||
"""Background task definitions for the 'part' app"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -11,6 +13,11 @@ logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
def notify_low_stock(part: part.models.Part):
|
||||
"""Notify interested users that a part is 'low stock':
|
||||
|
||||
- Triggered when the available stock for a given part falls be low the configured threhsold
|
||||
- A notification is delivered to any users who are 'subscribed' to this part
|
||||
"""
|
||||
name = _("Low stock notification")
|
||||
message = _(f'The available stock for {part.name} has fallen below the configured minimum level')
|
||||
context = {
|
||||
|
@ -0,0 +1 @@
|
||||
"""Custom InvenTree template tags for HTML template rendering"""
|
@ -106,18 +106,6 @@ def str2bool(x, *args, **kwargs):
|
||||
return InvenTree.helpers.str2bool(x)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inrange(n, *args, **kwargs):
|
||||
"""Return range(n) for iterating through a numeric quantity."""
|
||||
return range(n)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def multiply(x, y, *args, **kwargs):
|
||||
"""Multiply two numbers together."""
|
||||
return InvenTree.helpers.decimal2string(x * y)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def add(x, y, *args, **kwargs):
|
||||
"""Add two numbers together."""
|
||||
@ -211,16 +199,19 @@ def inventree_version(shortstring=False, *args, **kwargs):
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_is_development(*args, **kwargs):
|
||||
"""Returns True if this is a development version of InvenTree"""
|
||||
return version.isInvenTreeDevelopmentVersion()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_is_release(*args, **kwargs):
|
||||
"""Returns True if this is a release version of InvenTree"""
|
||||
return not version.isInvenTreeDevelopmentVersion()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_docs_version(*args, **kwargs):
|
||||
"""Returns the InvenTree documentation version"""
|
||||
return version.inventreeDocsVersion()
|
||||
|
||||
|
||||
@ -367,6 +358,7 @@ def progress_bar(val, max_val, *args, **kwargs):
|
||||
|
||||
@register.simple_tag()
|
||||
def get_color_theme_css(username):
|
||||
"""Return the cutsom theme .css file for the selected user"""
|
||||
user_theme_name = get_user_color_theme(username)
|
||||
# Build path to CSS sheet
|
||||
inventree_css_sheet = os.path.join('css', 'color-themes', user_theme_name + '.css')
|
||||
@ -496,7 +488,7 @@ class I18nStaticNode(StaticNode):
|
||||
"""
|
||||
|
||||
def render(self, context): # pragma: no cover
|
||||
|
||||
"""Render this node with the determined locale context."""
|
||||
self.original = getattr(self, 'original', None)
|
||||
|
||||
if not self.original:
|
||||
|
@ -29,6 +29,7 @@ def stock_status_label(key, *args, **kwargs):
|
||||
|
||||
@register.simple_tag
|
||||
def stock_status_text(key, *args, **kwargs):
|
||||
"""Render the text value of a StockItem status value"""
|
||||
return mark_safe(StockStatus.text(key))
|
||||
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
"""Unit tests for the various part API endpoints"""
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
import PIL
|
||||
@ -11,7 +13,8 @@ from company.models import Company
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
StockStatus)
|
||||
from part.models import BomItem, BomItemSubstitute, Part, PartCategory
|
||||
from part.models import (BomItem, BomItemSubstitute, Part, PartCategory,
|
||||
PartRelated)
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
|
||||
@ -40,8 +43,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
]
|
||||
|
||||
def test_category_list(self):
|
||||
|
||||
# List all part categories
|
||||
"""Test the PartCategoryList API endpoint"""
|
||||
url = reverse('api-part-category-list')
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
@ -103,10 +105,6 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
'part.add',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
def test_part(self):
|
||||
"""Test the Part API OPTIONS."""
|
||||
actions = self.getActions(reverse('api-part-list'))['POST']
|
||||
@ -207,21 +205,18 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
'part_category.add',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
def test_get_categories(self):
|
||||
"""Test that we can retrieve list of part categories, with various filtering options."""
|
||||
url = reverse('api-part-category-list')
|
||||
|
||||
# Request *all* part categories
|
||||
response = self.client.get(url, format='json')
|
||||
response = self.get(url, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 8)
|
||||
|
||||
# Request top-level part categories only
|
||||
response = self.client.get(
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'parent': 'null',
|
||||
@ -232,7 +227,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
# Children of PartCategory<1>, cascade
|
||||
response = self.client.get(
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'parent': 1,
|
||||
@ -244,7 +239,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# Children of PartCategory<1>, do not cascade
|
||||
response = self.client.get(
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'parent': 1,
|
||||
@ -263,7 +258,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
}
|
||||
|
||||
url = reverse('api-part-category-list')
|
||||
response = self.client.post(url, data, format='json')
|
||||
response = self.post(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
parent = response.data['pk']
|
||||
@ -275,19 +270,20 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
'description': 'A sort of animal',
|
||||
'parent': parent,
|
||||
}
|
||||
response = self.client.post(url, data, format='json')
|
||||
response = self.post(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['parent'], parent)
|
||||
self.assertEqual(response.data['name'], animal)
|
||||
self.assertEqual(response.data['pathstring'], 'Animals/' + animal)
|
||||
|
||||
# There should be now 8 categories
|
||||
response = self.client.get(url, format='json')
|
||||
response = self.get(url, format='json')
|
||||
self.assertEqual(len(response.data), 12)
|
||||
|
||||
def test_cat_detail(self):
|
||||
"""Test the PartCategoryDetail API endpoint"""
|
||||
url = reverse('api-part-category-detail', kwargs={'pk': 4})
|
||||
response = self.client.get(url, format='json')
|
||||
response = self.get(url, format='json')
|
||||
|
||||
# Test that we have retrieved the category
|
||||
self.assertEqual(response.data['description'], 'Integrated Circuits')
|
||||
@ -298,22 +294,22 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
data['name'] = 'Changing category'
|
||||
data['parent'] = None
|
||||
data['description'] = 'Changing the description'
|
||||
response = self.client.patch(url, data, format='json')
|
||||
response = self.patch(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['description'], 'Changing the description')
|
||||
self.assertIsNone(response.data['parent'])
|
||||
|
||||
def test_get_all_parts(self):
|
||||
def test_filter_parts(self):
|
||||
"""Test part filtering using the API"""
|
||||
url = reverse('api-part-list')
|
||||
data = {'cascade': True}
|
||||
response = self.client.get(url, data, format='json')
|
||||
response = self.get(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), Part.objects.count())
|
||||
|
||||
def test_get_parts_by_cat(self):
|
||||
url = reverse('api-part-list')
|
||||
# Test filtering parts by category
|
||||
data = {'category': 2}
|
||||
response = self.client.get(url, data, format='json')
|
||||
response = self.get(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
# There should only be 2 objects in category C
|
||||
@ -322,6 +318,28 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
for part in response.data:
|
||||
self.assertEqual(part['category'], 2)
|
||||
|
||||
def test_filter_by_related(self):
|
||||
"""Test that we can filter by the 'related' status"""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
# Initially there are no relations, so this should return zero results
|
||||
response = self.get(url, {'related': 1}, expected_code=200)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
# Add some relationships
|
||||
PartRelated.objects.create(
|
||||
part_1=Part.objects.get(pk=1),
|
||||
part_2=Part.objects.get(pk=2),
|
||||
)
|
||||
|
||||
PartRelated.objects.create(
|
||||
part_2=Part.objects.get(pk=1),
|
||||
part_1=Part.objects.get(pk=3)
|
||||
)
|
||||
|
||||
response = self.get(url, {'related': 1}, expected_code=200)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
def test_include_children(self):
|
||||
"""Test the special 'include_child_categories' flag.
|
||||
|
||||
@ -331,31 +349,31 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
data = {'category': 1, 'cascade': True}
|
||||
|
||||
# Now request to include child categories
|
||||
response = self.client.get(url, data, format='json')
|
||||
response = self.get(url, data, format='json')
|
||||
|
||||
# Now there should be 5 total parts
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
def test_test_templates(self):
|
||||
|
||||
"""Test the PartTestTemplate API"""
|
||||
url = reverse('api-part-test-template-list')
|
||||
|
||||
# List ALL items
|
||||
response = self.client.get(url)
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 7)
|
||||
|
||||
# Request for a particular part
|
||||
response = self.client.get(url, data={'part': 10000})
|
||||
response = self.get(url, data={'part': 10000})
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
response = self.client.get(url, data={'part': 10004})
|
||||
response = self.get(url, data={'part': 10004})
|
||||
self.assertEqual(len(response.data), 7)
|
||||
|
||||
# Try to post a new object (missing description)
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
url,
|
||||
data={
|
||||
'part': 10000,
|
||||
@ -367,7 +385,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# Try to post a new object (should succeed)
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
url,
|
||||
data={
|
||||
'part': 10000,
|
||||
@ -381,7 +399,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
# Try to post a new test with the same name (should fail)
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
url,
|
||||
data={
|
||||
'part': 10004,
|
||||
@ -394,7 +412,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Try to post a new test against a non-trackable part (should fail)
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
url,
|
||||
data={
|
||||
'part': 1,
|
||||
@ -408,7 +426,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
"""Return list of part thumbnails."""
|
||||
url = reverse('api-part-thumbs')
|
||||
|
||||
response = self.client.get(url)
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@ -432,7 +450,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
"""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
response = self.client.post(url, {
|
||||
response = self.post(url, {
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part',
|
||||
'category': 1,
|
||||
@ -454,7 +472,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.user
|
||||
)
|
||||
|
||||
response = self.client.post(url, {
|
||||
response = self.post(url, {
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part 2',
|
||||
'category': 1,
|
||||
@ -464,7 +482,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertTrue(response.data['purchaseable'])
|
||||
|
||||
# "default" values should not be used if the value is specified
|
||||
response = self.client.post(url, {
|
||||
response = self.post(url, {
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part 2',
|
||||
'category': 1,
|
||||
@ -858,14 +876,12 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
'part_category.add',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
def test_part_operations(self):
|
||||
"""Test that Part instances can be adjusted via the API"""
|
||||
n = Part.objects.count()
|
||||
|
||||
# Create a part
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
reverse('api-part-list'),
|
||||
{
|
||||
'name': 'my test api part',
|
||||
@ -890,7 +906,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
|
||||
# Let's change the name of the part
|
||||
|
||||
response = self.client.patch(url, {
|
||||
response = self.patch(url, {
|
||||
'name': 'a new better name',
|
||||
})
|
||||
|
||||
@ -908,14 +924,14 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
|
||||
# Now, try to set the name to the *same* value
|
||||
# 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things
|
||||
response = self.client.patch(url, {
|
||||
response = self.patch(url, {
|
||||
'name': 'a new better name',
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Try to remove the part
|
||||
response = self.client.delete(url)
|
||||
response = self.delete(url)
|
||||
|
||||
# As the part is 'active' we cannot delete it
|
||||
self.assertEqual(response.status_code, 405)
|
||||
@ -923,7 +939,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
# So, let's make it not active
|
||||
response = self.patch(url, {'active': False}, expected_code=200)
|
||||
|
||||
response = self.client.delete(url)
|
||||
response = self.delete(url)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
# Part count should have reduced
|
||||
@ -932,7 +948,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
def test_duplicates(self):
|
||||
"""Check that trying to create 'duplicate' parts results in errors."""
|
||||
# Create a part
|
||||
response = self.client.post(reverse('api-part-list'), {
|
||||
response = self.post(reverse('api-part-list'), {
|
||||
'name': 'part',
|
||||
'description': 'description',
|
||||
'IPN': 'IPN-123',
|
||||
@ -945,7 +961,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
n = Part.objects.count()
|
||||
|
||||
# Check that we cannot create a duplicate in a different category
|
||||
response = self.client.post(reverse('api-part-list'), {
|
||||
response = self.post(reverse('api-part-list'), {
|
||||
'name': 'part',
|
||||
'description': 'description',
|
||||
'IPN': 'IPN-123',
|
||||
@ -968,7 +984,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
self.assertEqual(Part.objects.count(), n)
|
||||
|
||||
# But a different 'revision' *can* be created
|
||||
response = self.client.post(reverse('api-part-list'), {
|
||||
response = self.post(reverse('api-part-list'), {
|
||||
'name': 'part',
|
||||
'description': 'description',
|
||||
'IPN': 'IPN-123',
|
||||
@ -985,7 +1001,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||
|
||||
# Attempt to alter the revision code
|
||||
response = self.client.patch(
|
||||
response = self.patch(
|
||||
url,
|
||||
{
|
||||
'revision': 'A',
|
||||
@ -996,7 +1012,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# But we *can* change it to a unique revision code
|
||||
response = self.client.patch(
|
||||
response = self.patch(
|
||||
url,
|
||||
{
|
||||
'revision': 'C',
|
||||
@ -1010,7 +1026,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
self.assignRole('part.add')
|
||||
|
||||
# Create a new part
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
reverse('api-part-list'),
|
||||
{
|
||||
'name': 'imagine',
|
||||
@ -1175,7 +1191,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
|
||||
"""Create test data as part of setup routine"""
|
||||
super().setUp()
|
||||
|
||||
# Ensure the part "variant" tree is correctly structured
|
||||
@ -1199,9 +1215,10 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
StockItem.objects.create(part=self.part, quantity=400, status=StockStatus.LOST)
|
||||
|
||||
def get_part_data(self):
|
||||
"""Helper function for retrieving part data"""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
response = self.client.get(url, format='json')
|
||||
response = self.get(url, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@ -1397,9 +1414,6 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
'part.delete',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
def test_bom_list(self):
|
||||
"""Tests for the BomItem list endpoint."""
|
||||
# How many BOM items currently exist in the database?
|
||||
@ -1518,7 +1532,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
# Now try to create a BomItem which references itself
|
||||
data['part'] = 100
|
||||
data['sub_part'] = 100
|
||||
self.client.post(url, data, expected_code=400)
|
||||
self.post(url, data, expected_code=400)
|
||||
|
||||
def test_variants(self):
|
||||
"""Tests for BomItem use with variants."""
|
||||
@ -1781,20 +1795,16 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
'params',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
def test_list_params(self):
|
||||
"""Test for listing part parameters."""
|
||||
url = reverse('api-part-parameter-list')
|
||||
|
||||
response = self.client.get(url, format='json')
|
||||
response = self.get(url, format='json')
|
||||
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# Filter by part
|
||||
response = self.client.get(
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'part': 3,
|
||||
@ -1805,7 +1815,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
# Filter by template
|
||||
response = self.client.get(
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'template': 1,
|
||||
@ -1819,7 +1829,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
"""Test that we can create a param via the API."""
|
||||
url = reverse('api-part-parameter-list')
|
||||
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'part': '2',
|
||||
@ -1830,7 +1840,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
response = self.client.get(url, format='json')
|
||||
response = self.get(url, format='json')
|
||||
|
||||
self.assertEqual(len(response.data), 6)
|
||||
|
||||
@ -1838,7 +1848,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
"""Tests for the PartParameter detail endpoint."""
|
||||
url = reverse('api-part-parameter-detail', kwargs={'pk': 5})
|
||||
|
||||
response = self.client.get(url)
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@ -1849,12 +1859,12 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(data['data'], '12')
|
||||
|
||||
# PATCH data back in
|
||||
response = self.client.patch(url, {'data': '15'}, format='json')
|
||||
response = self.patch(url, {'data': '15'}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check that the data changed!
|
||||
response = self.client.get(url, format='json')
|
||||
response = self.get(url, format='json')
|
||||
|
||||
data = response.data
|
||||
|
||||
|
@ -8,6 +8,7 @@ from InvenTree.helpers import InvenTreeTestCase
|
||||
|
||||
|
||||
class BomExportTest(InvenTreeTestCase):
|
||||
"""Class for performing unit testing of BOM export functionality"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -19,6 +20,7 @@ class BomExportTest(InvenTreeTestCase):
|
||||
roles = 'all'
|
||||
|
||||
def setUp(self):
|
||||
"""Perform test setup functions"""
|
||||
super().setUp()
|
||||
|
||||
self.url = reverse('bom-download', kwargs={'pk': 100})
|
||||
|
@ -18,6 +18,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
"""Create BOM data as part of setup routine"""
|
||||
super().setUp()
|
||||
|
||||
self.part = Part.objects.create(
|
||||
@ -37,7 +38,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
def post_bom(self, filename, file_data, clear_existing=None, expected_code=None, content_type='text/plain'):
|
||||
|
||||
"""Helper function for submitting a BOM file"""
|
||||
bom_file = SimpleUploadedFile(
|
||||
filename,
|
||||
file_data,
|
||||
|
@ -1,4 +1,6 @@
|
||||
|
||||
"""Unit tests for the BomItem model"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import django.core.exceptions as django_exceptions
|
||||
@ -9,6 +11,7 @@ from .models import BomItem, BomItemSubstitute, Part
|
||||
|
||||
|
||||
class BomItemTest(TestCase):
|
||||
"""Class for unit testing BomItem model"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -22,21 +25,25 @@ class BomItemTest(TestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
"""Create initial data"""
|
||||
self.bob = Part.objects.get(id=100)
|
||||
self.orphan = Part.objects.get(name='Orphan')
|
||||
self.r1 = Part.objects.get(name='R_2K2_0805')
|
||||
|
||||
def test_str(self):
|
||||
"""Test the string representation of a BOMItem"""
|
||||
b = BomItem.objects.get(id=1)
|
||||
self.assertEqual(str(b), '10 x M2x4 LPHS to make BOB | Bob | A2')
|
||||
|
||||
def test_has_bom(self):
|
||||
"""Test the has_bom attribute"""
|
||||
self.assertFalse(self.orphan.has_bom)
|
||||
self.assertTrue(self.bob.has_bom)
|
||||
|
||||
self.assertEqual(self.bob.bom_count, 4)
|
||||
|
||||
def test_in_bom(self):
|
||||
"""Test BOM aggregation"""
|
||||
parts = self.bob.getRequiredParts()
|
||||
|
||||
self.assertIn(self.orphan, parts)
|
||||
@ -44,6 +51,7 @@ class BomItemTest(TestCase):
|
||||
self.assertTrue(self.bob.check_if_part_in_bom(self.orphan))
|
||||
|
||||
def test_used_in(self):
|
||||
"""Test that the 'used_in_count' attribute is calculated correctly"""
|
||||
self.assertEqual(self.bob.used_in_count, 1)
|
||||
self.assertEqual(self.orphan.used_in_count, 1)
|
||||
|
||||
@ -116,6 +124,7 @@ class BomItemTest(TestCase):
|
||||
self.assertNotEqual(h1, h2)
|
||||
|
||||
def test_pricing(self):
|
||||
"""Test BOM pricing"""
|
||||
self.bob.get_price(1)
|
||||
self.assertEqual(
|
||||
self.bob.get_bom_price_range(1, internal=True),
|
||||
|
@ -1,3 +1,5 @@
|
||||
"""Unit tests for the PartCategory model"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
@ -18,7 +20,7 @@ class CategoryTest(TestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
# Extract some interesting categories for time-saving
|
||||
"""Extract some interesting categories for time-saving"""
|
||||
self.electronics = PartCategory.objects.get(name='Electronics')
|
||||
self.mechanical = PartCategory.objects.get(name='Mechanical')
|
||||
self.resistors = PartCategory.objects.get(name='Resistors')
|
||||
@ -111,8 +113,7 @@ class CategoryTest(TestCase):
|
||||
self.assertEqual(len(part_parameter), 1)
|
||||
|
||||
def test_invalid_name(self):
|
||||
# Test that an illegal character is prohibited in a category name
|
||||
|
||||
"""Test that an illegal character is prohibited in a category name"""
|
||||
cat = PartCategory(name='test/with/illegal/chars', description='Test category', parent=None)
|
||||
|
||||
with self.assertRaises(ValidationError) as err:
|
||||
|
@ -32,7 +32,7 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
print(p.is_template)
|
||||
|
||||
def test_models_exist(self):
|
||||
|
||||
"""Test that the Part model can still be accessed at the end of schema migration"""
|
||||
Part = self.new_state.apps.get_model('part', 'part')
|
||||
|
||||
self.assertEqual(Part.objects.count(), 5)
|
||||
@ -42,3 +42,7 @@ class TestForwardMigrations(MigratorTestCase):
|
||||
part.save()
|
||||
part.is_template = False
|
||||
part.save()
|
||||
|
||||
for name in ['A', 'C', 'E']:
|
||||
part = Part.objects.get(name=name)
|
||||
self.assertEqual(part.description, f"My part {name}")
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Tests for Part Parameters
|
||||
"""Various unit tests for Part Parameters"""
|
||||
|
||||
import django.core.exceptions as django_exceptions
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
@ -8,6 +8,7 @@ from .models import (Part, PartCategory, PartCategoryParameterTemplate,
|
||||
|
||||
|
||||
class TestParams(TestCase):
|
||||
"""Unit test class for testing the PartParameter model"""
|
||||
|
||||
fixtures = [
|
||||
'location',
|
||||
@ -17,7 +18,7 @@ class TestParams(TestCase):
|
||||
]
|
||||
|
||||
def test_str(self):
|
||||
|
||||
"""Test the str representation of the PartParameterTemplate model"""
|
||||
t1 = PartParameterTemplate.objects.get(pk=1)
|
||||
self.assertEqual(str(t1), 'Length (mm)')
|
||||
|
||||
@ -28,7 +29,7 @@ class TestParams(TestCase):
|
||||
self.assertEqual(str(c1), 'Mechanical | Length | 2.8')
|
||||
|
||||
def test_validate(self):
|
||||
|
||||
"""Test validation for part templates"""
|
||||
n = PartParameterTemplate.objects.all().count()
|
||||
|
||||
t1 = PartParameterTemplate(name='abcde', units='dd')
|
||||
@ -44,6 +45,7 @@ class TestParams(TestCase):
|
||||
|
||||
|
||||
class TestCategoryTemplates(TransactionTestCase):
|
||||
"""Test class for PartCategoryParameterTemplate model"""
|
||||
|
||||
fixtures = [
|
||||
'location',
|
||||
@ -53,7 +55,7 @@ class TestCategoryTemplates(TransactionTestCase):
|
||||
]
|
||||
|
||||
def test_validate(self):
|
||||
|
||||
"""Test that category templates are correctly applied to Part instances"""
|
||||
# Category templates
|
||||
n = PartCategoryParameterTemplate.objects.all().count()
|
||||
self.assertEqual(n, 2)
|
||||
@ -79,6 +81,7 @@ class TestCategoryTemplates(TransactionTestCase):
|
||||
'main': True,
|
||||
'parent': True,
|
||||
}
|
||||
|
||||
# Save it with category parameters
|
||||
part.save(**{'add_category_templates': add_category_templates})
|
||||
|
||||
|
@ -15,8 +15,8 @@ from common.notifications import UIMessageNotification, storage
|
||||
from InvenTree import version
|
||||
from InvenTree.helpers import InvenTreeTestCase
|
||||
|
||||
from .models import (Part, PartCategory, PartCategoryStar, PartStar,
|
||||
PartTestTemplate, rename_part_image)
|
||||
from .models import (Part, PartCategory, PartCategoryStar, PartRelated,
|
||||
PartStar, PartTestTemplate, rename_part_image)
|
||||
from .templatetags import inventree_extras
|
||||
|
||||
|
||||
@ -24,39 +24,42 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
"""Tests for the custom template tag code."""
|
||||
|
||||
def test_define(self):
|
||||
"""Test the 'define' template tag"""
|
||||
self.assertEqual(int(inventree_extras.define(3)), 3)
|
||||
|
||||
def test_str2bool(self):
|
||||
"""Various test for the str2bool template tag"""
|
||||
self.assertEqual(int(inventree_extras.str2bool('true')), True)
|
||||
self.assertEqual(int(inventree_extras.str2bool('yes')), True)
|
||||
self.assertEqual(int(inventree_extras.str2bool('none')), False)
|
||||
self.assertEqual(int(inventree_extras.str2bool('off')), False)
|
||||
|
||||
def test_inrange(self):
|
||||
self.assertEqual(inventree_extras.inrange(3), range(3))
|
||||
|
||||
def test_multiply(self):
|
||||
self.assertEqual(int(inventree_extras.multiply(3, 5)), 15)
|
||||
|
||||
def test_add(self):
|
||||
"""Test that the 'add"""
|
||||
self.assertEqual(int(inventree_extras.add(3, 5)), 8)
|
||||
|
||||
def test_plugins_enabled(self):
|
||||
"""Test the plugins_enabled tag"""
|
||||
self.assertEqual(inventree_extras.plugins_enabled(), True)
|
||||
|
||||
def test_inventree_instance_name(self):
|
||||
"""Test the 'instance name' setting"""
|
||||
self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree server')
|
||||
|
||||
def test_inventree_base_url(self):
|
||||
"""Test that the base URL tag returns correctly"""
|
||||
self.assertEqual(inventree_extras.inventree_base_url(), '')
|
||||
|
||||
def test_inventree_is_release(self):
|
||||
"""Test that the release version check functions as expected"""
|
||||
self.assertEqual(inventree_extras.inventree_is_release(), not version.isInvenTreeDevelopmentVersion())
|
||||
|
||||
def test_inventree_docs_version(self):
|
||||
"""Test that the documentation version template tag returns correctly"""
|
||||
self.assertEqual(inventree_extras.inventree_docs_version(), version.inventreeDocsVersion())
|
||||
|
||||
def test_hash(self):
|
||||
"""Test that the commit hash template tag returns correctly"""
|
||||
result_hash = inventree_extras.inventree_commit_hash()
|
||||
if settings.DOCKER: # pragma: no cover
|
||||
# Testing inside docker environment *may* return an empty git commit hash
|
||||
@ -66,6 +69,7 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
self.assertGreater(len(result_hash), 5)
|
||||
|
||||
def test_date(self):
|
||||
"""Test that the commit date template tag returns correctly"""
|
||||
d = inventree_extras.inventree_commit_date()
|
||||
if settings.DOCKER: # pragma: no cover
|
||||
# Testing inside docker environment *may* return an empty git commit hash
|
||||
@ -75,26 +79,33 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
self.assertEqual(len(d.split('-')), 3)
|
||||
|
||||
def test_github(self):
|
||||
"""Test that the github URL template tag returns correctly"""
|
||||
self.assertIn('github.com', inventree_extras.inventree_github_url())
|
||||
|
||||
def test_docs(self):
|
||||
"""Test that the documentation URL template tag returns correctly"""
|
||||
self.assertIn('inventree.readthedocs.io', inventree_extras.inventree_docs_url())
|
||||
|
||||
def test_keyvalue(self):
|
||||
"""Test keyvalue template tag"""
|
||||
self.assertEqual(inventree_extras.keyvalue({'a': 'a'}, 'a'), 'a')
|
||||
|
||||
def test_mail_configured(self):
|
||||
"""Test that mail configuration returns False"""
|
||||
self.assertEqual(inventree_extras.mail_configured(), False)
|
||||
|
||||
def test_user_settings(self):
|
||||
"""Test user settings"""
|
||||
result = inventree_extras.user_settings(self.user)
|
||||
self.assertEqual(len(result), len(InvenTreeUserSetting.SETTINGS))
|
||||
|
||||
def test_global_settings(self):
|
||||
"""Test global settings"""
|
||||
result = inventree_extras.global_settings()
|
||||
self.assertEqual(len(result), len(InvenTreeSetting.SETTINGS))
|
||||
|
||||
def test_visible_global_settings(self):
|
||||
"""Test that hidden global settings are actually hidden"""
|
||||
result = inventree_extras.visible_global_settings()
|
||||
|
||||
n = len(result)
|
||||
@ -122,6 +133,9 @@ class PartTest(TestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
"""Create some Part instances as part of init routine"""
|
||||
super().setUp()
|
||||
|
||||
self.r1 = Part.objects.get(name='R_2K2_0805')
|
||||
self.r2 = Part.objects.get(name='R_4K7_0603')
|
||||
|
||||
@ -130,7 +144,7 @@ class PartTest(TestCase):
|
||||
Part.objects.rebuild()
|
||||
|
||||
def test_tree(self):
|
||||
# Test that the part variant tree is working properly
|
||||
"""Test that the part variant tree is working properly"""
|
||||
chair = Part.objects.get(pk=10000)
|
||||
self.assertEqual(chair.get_children().count(), 3)
|
||||
self.assertEqual(chair.get_descendant_count(), 4)
|
||||
@ -142,9 +156,23 @@ class PartTest(TestCase):
|
||||
self.assertEqual(Part.objects.filter(tree_id=chair.tree_id).count(), 5)
|
||||
|
||||
def test_str(self):
|
||||
"""Test string representation of a Part"""
|
||||
p = Part.objects.get(pk=100)
|
||||
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
||||
|
||||
def test_related(self):
|
||||
"""Test the part relationship functionality"""
|
||||
self.assertEqual(self.r1.related_count, 0)
|
||||
self.assertEqual(self.r2.related_count, 0)
|
||||
|
||||
PartRelated.objects.create(part_1=self.r1, part_2=self.r2)
|
||||
|
||||
self.assertEqual(self.r1.related_count, 1)
|
||||
self.assertEqual(self.r2.related_count, 1)
|
||||
|
||||
self.assertTrue(self.r2 in self.r1.get_related_parts())
|
||||
self.assertTrue(self.r1 in self.r2.get_related_parts())
|
||||
|
||||
def test_duplicate(self):
|
||||
"""Test that we cannot create a "duplicate" Part."""
|
||||
n = Part.objects.count()
|
||||
@ -198,10 +226,12 @@ class PartTest(TestCase):
|
||||
part_2.validate_unique()
|
||||
|
||||
def test_attributes(self):
|
||||
"""Test Part attributes"""
|
||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
||||
|
||||
def test_category(self):
|
||||
"""Test PartCategory path"""
|
||||
self.assertEqual(str(self.c1.category), 'Electronics/Capacitors - Capacitors')
|
||||
|
||||
orphan = Part.objects.get(name='Orphan')
|
||||
@ -209,26 +239,29 @@ class PartTest(TestCase):
|
||||
self.assertEqual(orphan.category_path, '')
|
||||
|
||||
def test_rename_img(self):
|
||||
"""Test that an image can be renamed"""
|
||||
img = rename_part_image(self.r1, 'hello.png')
|
||||
self.assertEqual(img, os.path.join('part_images', 'hello.png'))
|
||||
|
||||
def test_stock(self):
|
||||
# No stock of any resistors
|
||||
"""Test case where there is zero stock"""
|
||||
res = Part.objects.filter(description__contains='resistor')
|
||||
for r in res:
|
||||
self.assertEqual(r.total_stock, 0)
|
||||
self.assertEqual(r.available_stock, 0)
|
||||
|
||||
def test_barcode(self):
|
||||
"""Test barcode format functionality"""
|
||||
barcode = self.r1.format_barcode(brief=False)
|
||||
self.assertIn('InvenTree', barcode)
|
||||
self.assertIn(self.r1.name, barcode)
|
||||
|
||||
def test_copy(self):
|
||||
"""Test that we can 'deep copy' a Part instance"""
|
||||
self.r2.deep_copy(self.r1, image=True, bom=True)
|
||||
|
||||
def test_sell_pricing(self):
|
||||
# check that the sell pricebreaks were loaded
|
||||
"""Check that the sell pricebreaks were loaded"""
|
||||
self.assertTrue(self.r1.has_price_breaks)
|
||||
self.assertEqual(self.r1.price_breaks.count(), 2)
|
||||
# check that the sell pricebreaks work
|
||||
@ -236,7 +269,7 @@ class PartTest(TestCase):
|
||||
self.assertEqual(float(self.r1.get_price(10)), 1.0)
|
||||
|
||||
def test_internal_pricing(self):
|
||||
# check that the sell pricebreaks were loaded
|
||||
"""Check that the sell pricebreaks were loaded"""
|
||||
self.assertTrue(self.r1.has_internal_price_breaks)
|
||||
self.assertEqual(self.r1.internal_price_breaks.count(), 2)
|
||||
# check that the sell pricebreaks work
|
||||
@ -262,6 +295,7 @@ class PartTest(TestCase):
|
||||
|
||||
|
||||
class TestTemplateTest(TestCase):
|
||||
"""Unit test for the TestTemplate class"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -271,7 +305,7 @@ class TestTemplateTest(TestCase):
|
||||
]
|
||||
|
||||
def test_template_count(self):
|
||||
|
||||
"""Tests for the test template functions"""
|
||||
chair = Part.objects.get(pk=10000)
|
||||
|
||||
# Tests for the top-level chair object (nothing above it!)
|
||||
@ -288,8 +322,7 @@ class TestTemplateTest(TestCase):
|
||||
self.assertEqual(variant.getTestTemplates(required=True).count(), 5)
|
||||
|
||||
def test_uniqueness(self):
|
||||
# Test names must be unique for this part and also parts above
|
||||
|
||||
"""Test names must be unique for this part and also parts above"""
|
||||
variant = Part.objects.get(pk=10004)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
@ -424,6 +457,7 @@ class PartSettingsTest(InvenTreeTestCase):
|
||||
|
||||
|
||||
class PartSubscriptionTests(InvenTreeTestCase):
|
||||
"""Unit tests for part 'subscription'"""
|
||||
|
||||
fixtures = [
|
||||
'location',
|
||||
@ -432,6 +466,7 @@ class PartSubscriptionTests(InvenTreeTestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
"""Create category and part data as part of setup routine"""
|
||||
super().setUp()
|
||||
|
||||
# electronics / IC / MCU
|
||||
@ -531,6 +566,7 @@ class BaseNotificationIntegrationTest(InvenTreeTestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
"""Add an email address as part of initialization"""
|
||||
super().setUp()
|
||||
# Add Mailadress
|
||||
EmailAddress.objects.create(user=self.user, email='test@testing.com')
|
||||
@ -568,6 +604,7 @@ class PartNotificationTest(BaseNotificationIntegrationTest):
|
||||
"""Integration test for part notifications."""
|
||||
|
||||
def test_notification(self):
|
||||
"""Test that a notification is generated"""
|
||||
self._notification_run(UIMessageNotification)
|
||||
|
||||
# There should be 1 notification message right now
|
||||
|
@ -8,6 +8,7 @@ from .models import Part
|
||||
|
||||
|
||||
class PartViewTestCase(InvenTreeTestCase):
|
||||
"""Base class for unit testing the various Part views"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -21,13 +22,12 @@ class PartViewTestCase(InvenTreeTestCase):
|
||||
roles = 'all'
|
||||
superuser = True
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
|
||||
class PartListTest(PartViewTestCase):
|
||||
"""Unit tests for the PartList view"""
|
||||
|
||||
def test_part_index(self):
|
||||
"""Test that the PartIndex page returns successfully"""
|
||||
response = self.client.get(reverse('part-index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@ -38,6 +38,7 @@ class PartListTest(PartViewTestCase):
|
||||
|
||||
|
||||
class PartDetailTest(PartViewTestCase):
|
||||
"""Unit tests for the PartDetail view"""
|
||||
|
||||
def test_part_detail(self):
|
||||
"""Test that we can retrieve a part detail page."""
|
||||
@ -67,6 +68,7 @@ class PartDetailTest(PartViewTestCase):
|
||||
pk = 1
|
||||
|
||||
def test_ipn_match(index_result=False, detail_result=False):
|
||||
"""Helper function for matching IPN detail view"""
|
||||
index_redirect = False
|
||||
detail_redirect = False
|
||||
|
||||
@ -117,11 +119,12 @@ class PartQRTest(PartViewTestCase):
|
||||
"""Tests for the Part QR Code AJAX view."""
|
||||
|
||||
def test_html_redirect(self):
|
||||
# A HTML request for a QR code should be redirected (use an AJAX request instead)
|
||||
"""A HTML request for a QR code should be redirected (use an AJAX request instead)"""
|
||||
response = self.client.get(reverse('part-qr', args=(1,)))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_valid_part(self):
|
||||
"""Test QR code response for a Part"""
|
||||
response = self.client.get(reverse('part-qr', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@ -131,6 +134,7 @@ class PartQRTest(PartViewTestCase):
|
||||
self.assertIn('<img src=', data)
|
||||
|
||||
def test_invalid_part(self):
|
||||
"""Test response for an invalid Part ID value"""
|
||||
response = self.client.get(reverse('part-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
@ -48,10 +48,16 @@ class PartIndex(InvenTreeRoleMixin, ListView):
|
||||
context_object_name = 'parts'
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset lookup to prefetch related fields"""
|
||||
return Part.objects.all().select_related('category')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Returns custom context data for the PartIndex view:
|
||||
|
||||
- children: Number of child categories
|
||||
- category_count: Number of child categories
|
||||
- part_count: Number of parts contained
|
||||
"""
|
||||
context = super().get_context_data(**kwargs).copy()
|
||||
|
||||
# View top-level categories
|
||||
@ -120,15 +126,13 @@ class PartSetCategory(AjaxUpdateView):
|
||||
}
|
||||
|
||||
if valid:
|
||||
self.set_category()
|
||||
with transaction.atomic():
|
||||
for part in self.parts:
|
||||
part.category = self.category
|
||||
part.save()
|
||||
|
||||
return self.renderJsonResponse(request, data=data, form=self.get_form(), context=self.get_context_data())
|
||||
|
||||
@transaction.atomic
|
||||
def set_category(self):
|
||||
for part in self.parts:
|
||||
part.set_category(self.category)
|
||||
|
||||
def get_context_data(self):
|
||||
"""Return context data for rendering in the form."""
|
||||
ctx = {}
|
||||
@ -145,6 +149,7 @@ class PartImport(FileManagementFormView):
|
||||
permission_required = 'part.add'
|
||||
|
||||
class PartFileManager(FileManager):
|
||||
"""Import field definitions"""
|
||||
REQUIRED_HEADERS = [
|
||||
'Name',
|
||||
'Description',
|
||||
@ -338,6 +343,7 @@ class PartImport(FileManagementFormView):
|
||||
|
||||
|
||||
class PartImportAjax(FileManagementAjaxView, PartImport):
|
||||
"""Multi-step form wizard for importing Part data"""
|
||||
ajax_form_steps_template = [
|
||||
'part/import_wizard/ajax_part_upload.html',
|
||||
'part/import_wizard/ajax_match_fields.html',
|
||||
@ -345,6 +351,7 @@ class PartImportAjax(FileManagementAjaxView, PartImport):
|
||||
]
|
||||
|
||||
def validate(self, obj, form, **kwargs):
|
||||
"""Validation is performed based on the current form step"""
|
||||
return PartImport.validate(self, self.steps.current, form, **kwargs)
|
||||
|
||||
|
||||
@ -385,6 +392,7 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
return Decimal(self.request.POST.get('quantity', 1))
|
||||
|
||||
def get_part(self):
|
||||
"""Return the Part instance associated with this view"""
|
||||
return self.get_object()
|
||||
|
||||
def get_pricing(self, quantity=1, currency=None):
|
||||
@ -499,6 +507,7 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
return {'quantity': self.get_quantity()}
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""POST action performs as a GET action"""
|
||||
self.object = self.get_object()
|
||||
kwargs['object'] = self.object
|
||||
ctx = self.get_context_data(**kwargs)
|
||||
@ -506,6 +515,8 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
|
||||
|
||||
class PartDetailFromIPN(PartDetail):
|
||||
"""Part detail view using the IPN (internal part number) of the Part as the lookup field"""
|
||||
|
||||
slug_field = 'IPN'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
@ -646,7 +657,7 @@ class PartImageSelect(AjaxUpdateView):
|
||||
]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
"""Perform POST action to assign selected image to the Part instance"""
|
||||
part = self.get_object()
|
||||
form = self.get_form()
|
||||
|
||||
@ -688,7 +699,7 @@ class BomUploadTemplate(AjaxView):
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
"""Perform a GET request to download the 'BOM upload' template"""
|
||||
export_format = request.GET.get('format', 'csv')
|
||||
|
||||
return MakeBomTemplate(export_format)
|
||||
@ -705,7 +716,7 @@ class BomDownload(AjaxView):
|
||||
model = Part
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
"""Perform GET request to download BOM data"""
|
||||
part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
||||
|
||||
export_format = request.GET.get('format', 'csv')
|
||||
@ -746,6 +757,7 @@ class BomDownload(AjaxView):
|
||||
)
|
||||
|
||||
def get_data(self):
|
||||
"""Return a cutsom message"""
|
||||
return {
|
||||
'info': 'Exported BOM'
|
||||
}
|
||||
@ -762,6 +774,7 @@ class PartDelete(AjaxDeleteView):
|
||||
success_url = '/part/'
|
||||
|
||||
def get_data(self):
|
||||
"""Returns custom message once the part deletion has been performed"""
|
||||
return {
|
||||
'danger': _('Part was deleted'),
|
||||
}
|
||||
@ -782,6 +795,7 @@ class PartPricing(AjaxView):
|
||||
return Decimal(self.request.POST.get('quantity', 1))
|
||||
|
||||
def get_part(self):
|
||||
"""Return the Part instance associated with this view"""
|
||||
try:
|
||||
return Part.objects.get(id=self.kwargs['pk'])
|
||||
except Part.DoesNotExist:
|
||||
@ -886,12 +900,14 @@ class PartPricing(AjaxView):
|
||||
return {'quantity': self.get_quantity()}
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Perform custom GET action for this view"""
|
||||
init = self.get_initials()
|
||||
qty = self.get_quantity()
|
||||
|
||||
return self.renderJsonResponse(request, self.form_class(initial=init), context=self.get_pricing(qty))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
"""Perform custom POST action for this view"""
|
||||
currency = None
|
||||
|
||||
quantity = self.get_quantity()
|
||||
@ -946,7 +962,12 @@ class CategoryDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
template_name = 'part/category.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Returns custom context data for the CategoryDetail view:
|
||||
|
||||
- part_count: Number of parts in this category
|
||||
- starred_directly: True if this category is starred directly by the requesting user
|
||||
- starred: True if this category is starred by the requesting user
|
||||
"""
|
||||
context = super().get_context_data(**kwargs).copy()
|
||||
|
||||
try:
|
||||
@ -960,18 +981,22 @@ class CategoryDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
if category:
|
||||
|
||||
# Insert "starred" information
|
||||
context['starred'] = category.is_starred_by(self.request.user)
|
||||
context['starred_directly'] = context['starred'] and category.is_starred_by(
|
||||
self.request.user,
|
||||
include_parents=False,
|
||||
)
|
||||
|
||||
if context['starred_directly']:
|
||||
# Save a database lookup - if 'starred_directly' is True, we know 'starred' is also
|
||||
context['starred'] = True
|
||||
else:
|
||||
context['starred'] = category.is_starred_by(self.request.user)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class CategoryDelete(AjaxDeleteView):
|
||||
"""Delete view to delete a PartCategory."""
|
||||
|
||||
model = PartCategory
|
||||
ajax_template_name = 'part/category_delete.html'
|
||||
ajax_form_title = _('Delete Part Category')
|
||||
@ -979,6 +1004,7 @@ class CategoryDelete(AjaxDeleteView):
|
||||
success_url = '/part/'
|
||||
|
||||
def get_data(self):
|
||||
"""Return custom context data when the category is deleted"""
|
||||
return {
|
||||
'danger': _('Part category was deleted'),
|
||||
}
|
||||
@ -1092,6 +1118,11 @@ class CategoryParameterTemplateEdit(AjaxUpdateView):
|
||||
ajax_form_title = _('Edit Category Parameter Template')
|
||||
|
||||
def get_object(self):
|
||||
"""Returns the PartCategoryParameterTemplate associated with this view
|
||||
|
||||
- First, attempt lookup based on supplied 'pid' kwarg
|
||||
- Else, attempt lookup based on supplied 'pk' kwarg
|
||||
"""
|
||||
try:
|
||||
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
||||
except:
|
||||
@ -1148,6 +1179,11 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
|
||||
ajax_form_title = _("Delete Category Parameter Template")
|
||||
|
||||
def get_object(self):
|
||||
"""Returns the PartCategoryParameterTemplate associated with this view
|
||||
|
||||
- First, attempt lookup based on supplied 'pid' kwarg
|
||||
- Else, attempt lookup based on supplied 'pk' kwarg
|
||||
"""
|
||||
try:
|
||||
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
||||
except:
|
||||
|
@ -15,6 +15,8 @@ ignore =
|
||||
N806,
|
||||
# - N812 - lowercase imported as non-lowercase
|
||||
N812,
|
||||
# - D202 - No blank lines allowed after function docstring
|
||||
D202,
|
||||
# - D415 - First line should end with a period, question mark, or exclamation point
|
||||
D415,
|
||||
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,InvenTree/plugins/*
|
||||
|
Loading…
Reference in New Issue
Block a user