mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 13:05:42 +00:00
Docstring checks in QC checks (#3089)
* Add pre-commit to the stack * exclude static * Add locales to excludes * fix style errors * rename pipeline steps * also wait on precommit * make template matching simpler * Use the same code for python setup everywhere * use step and cache for python setup * move regular settings up into general envs * just use full update * Use invoke instead of static references * make setup actions more similar * use python3 * refactor names to be similar * fix runner version * fix references * remove incidential change * use matrix for os * Github can't do this right now * ignore docstyle errors * Add seperate docstring test * update flake call * do not fail on docstring * refactor setup into workflow * update reference * switch to action * resturcture * add bash statements * remove os from cache * update input checks * make code cleaner * fix boolean * no relative paths * install wheel by python * switch to install * revert back to simple wheel * refactor import export tests * move setup keys back to not disturbe tests * remove docstyle till that is fixed * update references * continue on error * add docstring test * use relativ action references * Change step / job docstrings * update to merge * reformat comments 1 * fix docstrings 2 * fix docstrings 3 * fix docstrings 4 * fix docstrings 5 * fix docstrings 6 * fix docstrings 7 * fix docstrings 8 * fix docstirns 9 * fix docstrings 10 * docstring adjustments * update the remaining docstrings * small docstring changes * fix function name * update support files for docstrings * Add missing args to docstrings * Remove outdated function * Add docstrings for the 'build' app * Make API code cleaner * add more docstrings for plugin app * Remove dead code for plugin settings No idea what that was even intended for * ignore __init__ files for docstrings * More docstrings * Update docstrings for the 'part' directory * Fixes for related_part functionality * Fix removed stuff from merge99676ee
* make more consistent * Show statistics for docstrings * add more docstrings * move specific register statements to make them clearer to understant * More docstrings for common * and more docstrings * and more * simpler call * docstrings for notifications * docstrings for common/tests * Add docs for common/models * Revert "move specific register statements to make them clearer to understant" This reverts commitca96654622
. * use typing here * Revert "Make API code cleaner" This reverts commit24fb68bd3e
. * docstring updates for the 'users' app * Add generic Meta info to simple Meta classes * remove unneeded unique_together statements * More simple metas * Remove unnecessary format specifier * Remove extra json format specifiers * Add docstrings for the 'plugin' app * Docstrings for the 'label' app * Add missing docstrings for the 'report' app * Fix build test regression * Fix top-level files * docstrings for InvenTree/InvenTree * reduce unneeded code * add docstrings * and more docstrings * more docstrings * more docstrings for stock * more docstrings * docstrings for order/views * Docstrings for various files in the 'order' app * Docstrings for order/test_api.py * Docstrings for order/serializers.py * Docstrings for order/admin.py * More docstrings for the order app * Add docstrings for the 'company' app * Add unit tests for rebuilding the reference fields * Prune out some more dead code * remove more dead code Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
@ -1,9 +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
|
||||
@ -11,7 +13,7 @@ from stock.models import StockLocation
|
||||
|
||||
|
||||
class PartResource(ModelResource):
|
||||
""" Class for managing Part data import/export """
|
||||
"""Class for managing Part data import/export."""
|
||||
|
||||
# ForeignKey fields
|
||||
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory))
|
||||
@ -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
|
||||
@ -49,8 +52,7 @@ class PartResource(ModelResource):
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
""" Prefetch related data for quicker access """
|
||||
|
||||
"""Prefetch related data for quicker access."""
|
||||
query = super().get_queryset()
|
||||
query = query.prefetch_related(
|
||||
'category',
|
||||
@ -62,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
|
||||
|
||||
@ -82,7 +93,7 @@ class PartAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
class PartCategoryResource(ModelResource):
|
||||
""" Class for managing PartCategory data import/export """
|
||||
"""Class for managing PartCategory data import/export."""
|
||||
|
||||
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
|
||||
|
||||
@ -91,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
|
||||
@ -103,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)
|
||||
|
||||
@ -111,6 +124,7 @@ class PartCategoryResource(ModelResource):
|
||||
|
||||
|
||||
class PartCategoryAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the PartCategory model"""
|
||||
|
||||
resource_class = PartCategoryResource
|
||||
|
||||
@ -122,35 +136,21 @@ class PartCategoryAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
class PartRelatedAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Class to manage PartRelated objects
|
||||
"""
|
||||
"""Class to manage PartRelated objects."""
|
||||
|
||||
autocomplete_fields = ('part_1', 'part_2')
|
||||
|
||||
|
||||
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')
|
||||
|
||||
@ -158,7 +158,7 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
class BomItemResource(ModelResource):
|
||||
""" Class for managing BomItem data import/export """
|
||||
"""Class for managing BomItem data import/export."""
|
||||
|
||||
level = Field(attribute='level', readonly=True)
|
||||
|
||||
@ -189,25 +189,18 @@ class BomItemResource(ModelResource):
|
||||
sub_assembly = Field(attribute='sub_part__assembly', readonly=True)
|
||||
|
||||
def dehydrate_quantity(self, item):
|
||||
"""
|
||||
Special consideration for the 'quantity' field on data export.
|
||||
We do not want a spreadsheet full of "1.0000" (we'd rather "1")
|
||||
"""Special consideration for the 'quantity' field on data export. We do not want a spreadsheet full of "1.0000" (we'd rather "1")
|
||||
|
||||
Ref: https://django-import-export.readthedocs.io/en/latest/getting_started.html#advanced-data-manipulation-on-export
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
If we are exporting for the purposes of generating
|
||||
a 'bom-import' template, there are some fields which
|
||||
we are not interested in.
|
||||
"""
|
||||
|
||||
"""If we are exporting for the purposes of generating a 'bom-import' template, there are some fields which we are not interested in."""
|
||||
fields = super().get_fields(**kwargs)
|
||||
|
||||
# If we are not generating an "import" template,
|
||||
@ -239,6 +232,7 @@ class BomItemResource(ModelResource):
|
||||
return fields
|
||||
|
||||
class Meta:
|
||||
"""Metaclass definition"""
|
||||
model = models.BomItem
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
@ -253,6 +247,7 @@ class BomItemResource(ModelResource):
|
||||
|
||||
|
||||
class BomItemAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the BomItem model"""
|
||||
|
||||
resource_class = BomItemResource
|
||||
|
||||
@ -264,13 +259,15 @@ class BomItemAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
class ParameterTemplateAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the PartParameterTemplate model"""
|
||||
|
||||
list_display = ('name', 'units')
|
||||
|
||||
search_fields = ('name', 'units')
|
||||
|
||||
|
||||
class ParameterResource(ModelResource):
|
||||
""" Class for managing PartParameter data import/export """
|
||||
"""Class for managing PartParameter data import/export."""
|
||||
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
|
||||
|
||||
@ -281,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
|
||||
@ -288,6 +286,7 @@ class ParameterResource(ModelResource):
|
||||
|
||||
|
||||
class ParameterAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the PartParameter model"""
|
||||
|
||||
resource_class = ParameterResource
|
||||
|
||||
@ -297,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',)
|
||||
@ -323,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)
|
||||
|
@ -1,6 +1,4 @@
|
||||
"""
|
||||
Provides a JSON API for the Part app
|
||||
"""
|
||||
"""Provides a JSON API for the Part app."""
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
@ -41,7 +39,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
|
||||
|
||||
|
||||
class CategoryList(generics.ListCreateAPIView):
|
||||
""" API endpoint for accessing a list of PartCategory objects.
|
||||
"""API endpoint for accessing a list of PartCategory objects.
|
||||
|
||||
- GET: Return a list of PartCategory objects
|
||||
- POST: Create a new PartCategory object
|
||||
@ -51,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:
|
||||
@ -63,11 +61,10 @@ class CategoryList(generics.ListCreateAPIView):
|
||||
return ctx
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Custom filtering:
|
||||
"""Custom filtering:
|
||||
|
||||
- Allow filtering by "null" parent to retrieve top-level part categories
|
||||
"""
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
@ -158,15 +155,13 @@ class CategoryList(generics.ListCreateAPIView):
|
||||
|
||||
|
||||
class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API endpoint for detail view of a single PartCategory object
|
||||
"""
|
||||
"""API endpoint for detail view of a single PartCategory object."""
|
||||
|
||||
serializer_class = part_serializers.CategorySerializer
|
||||
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:
|
||||
@ -178,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))
|
||||
|
||||
@ -190,16 +185,17 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
|
||||
class CategoryMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for viewing / updating PartCategory metadata"""
|
||||
"""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()
|
||||
|
||||
|
||||
class CategoryParameterList(generics.ListAPIView):
|
||||
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
|
||||
"""API endpoint for accessing a list of PartCategoryParameterTemplate objects.
|
||||
|
||||
- GET: Return a list of PartCategoryParameterTemplate objects
|
||||
"""
|
||||
@ -208,13 +204,12 @@ class CategoryParameterList(generics.ListAPIView):
|
||||
serializer_class = part_serializers.CategoryParameterTemplateSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Custom filtering:
|
||||
"""Custom filtering:
|
||||
|
||||
- Allow filtering by "null" parent to retrieve all categories parameter templates
|
||||
- Allow filtering by category
|
||||
- Allow traversing all parent categories
|
||||
"""
|
||||
|
||||
queryset = super().get_queryset()
|
||||
|
||||
params = self.request.query_params
|
||||
@ -241,9 +236,7 @@ class CategoryParameterList(generics.ListAPIView):
|
||||
|
||||
|
||||
class CategoryTree(generics.ListAPIView):
|
||||
"""
|
||||
API endpoint for accessing a list of PartCategory objects ready for rendering a tree.
|
||||
"""
|
||||
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
|
||||
|
||||
queryset = PartCategory.objects.all()
|
||||
serializer_class = part_serializers.CategoryTree
|
||||
@ -258,18 +251,14 @@ class CategoryTree(generics.ListAPIView):
|
||||
|
||||
|
||||
class PartSalePriceDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
Detail endpoint for PartSellPriceBreak model
|
||||
"""
|
||||
"""Detail endpoint for PartSellPriceBreak model."""
|
||||
|
||||
queryset = PartSellPriceBreak.objects.all()
|
||||
serializer_class = part_serializers.PartSalePriceSerializer
|
||||
|
||||
|
||||
class PartSalePriceList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for list view of PartSalePriceBreak model
|
||||
"""
|
||||
"""API endpoint for list view of PartSalePriceBreak model."""
|
||||
|
||||
queryset = PartSellPriceBreak.objects.all()
|
||||
serializer_class = part_serializers.PartSalePriceSerializer
|
||||
@ -284,18 +273,14 @@ class PartSalePriceList(generics.ListCreateAPIView):
|
||||
|
||||
|
||||
class PartInternalPriceDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
Detail endpoint for PartInternalPriceBreak model
|
||||
"""
|
||||
"""Detail endpoint for PartInternalPriceBreak model."""
|
||||
|
||||
queryset = PartInternalPriceBreak.objects.all()
|
||||
serializer_class = part_serializers.PartInternalPriceSerializer
|
||||
|
||||
|
||||
class PartInternalPriceList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for list view of PartInternalPriceBreak model
|
||||
"""
|
||||
"""API endpoint for list view of PartInternalPriceBreak model."""
|
||||
|
||||
queryset = PartInternalPriceBreak.objects.all()
|
||||
serializer_class = part_serializers.PartInternalPriceSerializer
|
||||
@ -311,9 +296,7 @@ class PartInternalPriceList(generics.ListCreateAPIView):
|
||||
|
||||
|
||||
class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
"""
|
||||
API endpoint for listing (and creating) a PartAttachment (file upload).
|
||||
"""
|
||||
"""API endpoint for listing (and creating) a PartAttachment (file upload)."""
|
||||
|
||||
queryset = PartAttachment.objects.all()
|
||||
serializer_class = part_serializers.PartAttachmentSerializer
|
||||
@ -328,38 +311,30 @@ class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
|
||||
|
||||
class PartAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
||||
"""
|
||||
Detail endpoint for PartAttachment model
|
||||
"""
|
||||
"""Detail endpoint for PartAttachment model."""
|
||||
|
||||
queryset = PartAttachment.objects.all()
|
||||
serializer_class = part_serializers.PartAttachmentSerializer
|
||||
|
||||
|
||||
class PartTestTemplateDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
Detail endpoint for PartTestTemplate model
|
||||
"""
|
||||
"""Detail endpoint for PartTestTemplate model."""
|
||||
|
||||
queryset = PartTestTemplate.objects.all()
|
||||
serializer_class = part_serializers.PartTestTemplateSerializer
|
||||
|
||||
|
||||
class PartTestTemplateList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for listing (and creating) a PartTestTemplate.
|
||||
"""
|
||||
"""API endpoint for listing (and creating) a PartTestTemplate."""
|
||||
|
||||
queryset = PartTestTemplate.objects.all()
|
||||
serializer_class = part_serializers.PartTestTemplateSerializer
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Filter the test list queryset.
|
||||
"""Filter the test list queryset.
|
||||
|
||||
If filtering by 'part', we include results for any parts "above" the specified part.
|
||||
"""
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
@ -390,15 +365,13 @@ class PartTestTemplateList(generics.ListCreateAPIView):
|
||||
|
||||
|
||||
class PartThumbs(generics.ListAPIView):
|
||||
"""
|
||||
API endpoint for retrieving information on available Part thumbnails
|
||||
"""
|
||||
"""API endpoint for retrieving information on available Part thumbnails."""
|
||||
|
||||
queryset = Part.objects.all()
|
||||
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
|
||||
@ -407,11 +380,10 @@ class PartThumbs(generics.ListAPIView):
|
||||
return queryset
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Serialize the available Part images.
|
||||
"""Serialize the available Part images.
|
||||
|
||||
- Images may be used for multiple parts!
|
||||
"""
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Return the most popular parts first
|
||||
@ -436,7 +408,7 @@ class PartThumbs(generics.ListAPIView):
|
||||
|
||||
|
||||
class PartThumbsUpdate(generics.RetrieveUpdateAPIView):
|
||||
""" API endpoint for updating Part thumbnails"""
|
||||
"""API endpoint for updating Part thumbnails."""
|
||||
|
||||
queryset = Part.objects.all()
|
||||
serializer_class = part_serializers.PartThumbSerializerUpdate
|
||||
@ -447,8 +419,7 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView):
|
||||
|
||||
|
||||
class PartScheduling(generics.RetrieveAPIView):
|
||||
"""
|
||||
API endpoint for delivering "scheduling" information about a given part via the API.
|
||||
"""API endpoint for delivering "scheduling" information about a given part via the API.
|
||||
|
||||
Returns a chronologically ordered list about future "scheduled" events,
|
||||
concerning stock levels for the part:
|
||||
@ -462,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()
|
||||
@ -470,13 +441,12 @@ class PartScheduling(generics.RetrieveAPIView):
|
||||
schedule = []
|
||||
|
||||
def add_schedule_entry(date, quantity, title, label, url):
|
||||
"""
|
||||
Check if a scheduled entry should be added:
|
||||
"""Check if a scheduled entry should be added:
|
||||
|
||||
- date must be non-null
|
||||
- date cannot be in the "past"
|
||||
- quantity must not be zero
|
||||
"""
|
||||
|
||||
if date and date >= today and quantity != 0:
|
||||
schedule.append({
|
||||
'date': date,
|
||||
@ -583,25 +553,22 @@ class PartScheduling(generics.RetrieveAPIView):
|
||||
|
||||
|
||||
class PartMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""
|
||||
API endpoint for viewing / updating Part metadata
|
||||
"""
|
||||
"""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()
|
||||
|
||||
|
||||
class PartSerialNumberDetail(generics.RetrieveAPIView):
|
||||
"""
|
||||
API endpoint for returning extra serial number information about a particular part
|
||||
"""
|
||||
"""API endpoint for returning extra serial number information about a particular part."""
|
||||
|
||||
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
|
||||
@ -621,15 +588,13 @@ class PartSerialNumberDetail(generics.RetrieveAPIView):
|
||||
|
||||
|
||||
class PartCopyBOM(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for duplicating a BOM
|
||||
"""
|
||||
"""API endpoint for duplicating a BOM."""
|
||||
|
||||
queryset = Part.objects.all()
|
||||
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:
|
||||
@ -641,13 +606,13 @@ class PartCopyBOM(generics.CreateAPIView):
|
||||
|
||||
|
||||
class PartValidateBOM(generics.RetrieveUpdateAPIView):
|
||||
"""
|
||||
API endpoint for 'validating' the BOM for a given Part
|
||||
"""
|
||||
"""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',
|
||||
@ -667,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'))
|
||||
|
||||
@ -675,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)
|
||||
@ -691,7 +657,7 @@ class PartValidateBOM(generics.RetrieveUpdateAPIView):
|
||||
|
||||
|
||||
class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of a single Part object """
|
||||
"""API endpoint for detail view of a single Part object."""
|
||||
|
||||
queryset = Part.objects.all()
|
||||
serializer_class = part_serializers.PartSerializer
|
||||
@ -699,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)
|
||||
@ -706,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))
|
||||
@ -726,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:
|
||||
@ -734,16 +705,14 @@ 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):
|
||||
"""
|
||||
Custom update functionality for Part instance.
|
||||
"""Custom update functionality for Part instance.
|
||||
|
||||
- If the 'starred' field is provided, update the 'starred' status against current user
|
||||
"""
|
||||
|
||||
if 'starred' in request.data:
|
||||
starred = str2bool(request.data.get('starred', False))
|
||||
|
||||
@ -755,8 +724,8 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
|
||||
class PartFilter(rest_filters.FilterSet):
|
||||
"""
|
||||
Custom filters for the PartList endpoint.
|
||||
"""Custom filters for the PartList endpoint.
|
||||
|
||||
Uses the django_filters extension framework
|
||||
"""
|
||||
|
||||
@ -764,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:
|
||||
@ -791,10 +760,7 @@ class PartFilter(rest_filters.FilterSet):
|
||||
low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock')
|
||||
|
||||
def filter_low_stock(self, queryset, name, value):
|
||||
"""
|
||||
Filter by "low stock" status
|
||||
"""
|
||||
|
||||
"""Filter by "low stock" status."""
|
||||
value = str2bool(value)
|
||||
|
||||
if value:
|
||||
@ -812,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:
|
||||
@ -826,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:
|
||||
@ -854,8 +820,7 @@ class PartFilter(rest_filters.FilterSet):
|
||||
|
||||
|
||||
class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for accessing a list of Part objects
|
||||
"""API endpoint for accessing a list of Part objects.
|
||||
|
||||
- GET: Return list of objects
|
||||
- POST: Create a new Part object
|
||||
@ -882,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()
|
||||
|
||||
@ -904,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)
|
||||
@ -912,14 +878,10 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||
return DownloadFile(filedata, filename)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Overide the 'list' method, as the PartCategory objects are
|
||||
very expensive to serialize!
|
||||
"""Overide the 'list' method, as the PartCategory objects are very expensive to serialize!
|
||||
|
||||
So we will serialize them first, and keep them in memory,
|
||||
so that they do not have to be serialized multiple times...
|
||||
So we will serialize them first, and keep them in memory, so that they do not have to be serialized multiple times...
|
||||
"""
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
@ -980,12 +942,10 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
We wish to save the user who created this part!
|
||||
"""We wish to save the user who created this part!
|
||||
|
||||
Note: Implementation copied from DRF class CreateModelMixin
|
||||
"""
|
||||
|
||||
# TODO: Unit tests for this function!
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
@ -1128,18 +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)
|
||||
@ -1392,15 +1348,13 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||
|
||||
|
||||
class PartRelatedList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for accessing a list of PartRelated objects
|
||||
"""
|
||||
"""API endpoint for accessing a list of PartRelated objects."""
|
||||
|
||||
queryset = PartRelated.objects.all()
|
||||
serializer_class = part_serializers.PartRelationSerializer
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
|
||||
"""Custom queryset filtering"""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
@ -1421,16 +1375,14 @@ class PartRelatedList(generics.ListCreateAPIView):
|
||||
|
||||
|
||||
class PartRelatedDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API endpoint for accessing detail view of a PartRelated object
|
||||
"""
|
||||
"""API endpoint for accessing detail view of a PartRelated object."""
|
||||
|
||||
queryset = PartRelated.objects.all()
|
||||
serializer_class = part_serializers.PartRelationSerializer
|
||||
|
||||
|
||||
class PartParameterTemplateList(generics.ListCreateAPIView):
|
||||
""" API endpoint for accessing a list of PartParameterTemplate objects.
|
||||
"""API endpoint for accessing a list of PartParameterTemplate objects.
|
||||
|
||||
- GET: Return list of PartParameterTemplate objects
|
||||
- POST: Create a new PartParameterTemplate object
|
||||
@ -1454,10 +1406,7 @@ class PartParameterTemplateList(generics.ListCreateAPIView):
|
||||
]
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""
|
||||
Custom filtering for the PartParameterTemplate API
|
||||
"""
|
||||
|
||||
"""Custom filtering for the PartParameterTemplate API."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
@ -1493,7 +1442,7 @@ class PartParameterTemplateList(generics.ListCreateAPIView):
|
||||
|
||||
|
||||
class PartParameterList(generics.ListCreateAPIView):
|
||||
""" API endpoint for accessing a list of PartParameter objects
|
||||
"""API endpoint for accessing a list of PartParameter objects.
|
||||
|
||||
- GET: Return list of PartParameter objects
|
||||
- POST: Create a new PartParameter object
|
||||
@ -1513,18 +1462,14 @@ class PartParameterList(generics.ListCreateAPIView):
|
||||
|
||||
|
||||
class PartParameterDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API endpoint for detail view of a single PartParameter object
|
||||
"""
|
||||
"""API endpoint for detail view of a single PartParameter object."""
|
||||
|
||||
queryset = PartParameter.objects.all()
|
||||
serializer_class = part_serializers.PartParameterSerializer
|
||||
|
||||
|
||||
class BomFilter(rest_filters.FilterSet):
|
||||
"""
|
||||
Custom filters for the BOM list
|
||||
"""
|
||||
"""Custom filters for the BOM list."""
|
||||
|
||||
# Boolean filters for BOM item
|
||||
optional = rest_filters.BooleanFilter(label='BOM line is optional')
|
||||
@ -1542,8 +1487,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)
|
||||
@ -1565,8 +1509,7 @@ class BomFilter(rest_filters.FilterSet):
|
||||
|
||||
|
||||
class BomList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for accessing a list of BomItem objects.
|
||||
"""API endpoint for accessing a list of BomItem objects.
|
||||
|
||||
- GET: Return list of BomItem objects
|
||||
- POST: Create a new BomItem object
|
||||
@ -1577,6 +1520,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())
|
||||
|
||||
@ -1602,6 +1546,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:
|
||||
@ -1626,7 +1577,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)
|
||||
@ -1635,7 +1586,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
|
||||
@ -1716,18 +1667,13 @@ class BomList(generics.ListCreateAPIView):
|
||||
return queryset
|
||||
|
||||
def include_pricing(self):
|
||||
"""
|
||||
Determine if pricing information should be included in the response
|
||||
"""
|
||||
"""Determine if pricing information should be included in the response."""
|
||||
pricing_default = InvenTreeSetting.get_setting('PART_SHOW_PRICE_IN_BOM')
|
||||
|
||||
return str2bool(self.request.query_params.get('include_pricing', pricing_default))
|
||||
|
||||
def annotate_pricing(self, queryset):
|
||||
"""
|
||||
Add part pricing information to the queryset
|
||||
"""
|
||||
|
||||
"""Add part pricing information to the queryset."""
|
||||
# Annotate with purchase prices
|
||||
queryset = queryset.annotate(
|
||||
purchase_price_min=Min('sub_part__stock_items__purchase_price'),
|
||||
@ -1742,8 +1688,7 @@ class BomList(generics.ListCreateAPIView):
|
||||
).values('pk', 'sub_part', 'purchase_price', 'purchase_price_currency')
|
||||
|
||||
def convert_price(price, currency, decimal_places=4):
|
||||
""" Convert price field, returns Money field """
|
||||
|
||||
"""Convert price field, returns Money field."""
|
||||
price_adjusted = None
|
||||
|
||||
# Get default currency from settings
|
||||
@ -1796,8 +1741,7 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
|
||||
class BomImportUpload(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for uploading a complete Bill of Materials.
|
||||
"""API endpoint for uploading a complete Bill of Materials.
|
||||
|
||||
It is assumed that the BOM has been extracted from a file using the BomExtract endpoint.
|
||||
"""
|
||||
@ -1806,10 +1750,7 @@ class BomImportUpload(generics.CreateAPIView):
|
||||
serializer_class = part_serializers.BomImportUploadSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
Custom create function to return the extracted data
|
||||
"""
|
||||
|
||||
"""Custom create function to return the extracted data."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
@ -1821,31 +1762,27 @@ class BomImportUpload(generics.CreateAPIView):
|
||||
|
||||
|
||||
class BomImportExtract(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for extracting BOM data from a BOM file.
|
||||
"""
|
||||
"""API endpoint for extracting BOM data from a BOM file."""
|
||||
|
||||
queryset = Part.objects.none()
|
||||
serializer_class = part_serializers.BomImportExtractSerializer
|
||||
|
||||
|
||||
class BomImportSubmit(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for submitting BOM data from a BOM file
|
||||
"""
|
||||
"""API endpoint for submitting BOM data from a BOM file."""
|
||||
|
||||
queryset = BomItem.objects.none()
|
||||
serializer_class = part_serializers.BomImportSubmitSerializer
|
||||
|
||||
|
||||
class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of a single BomItem object """
|
||||
"""API endpoint for detail view of a single BomItem object."""
|
||||
|
||||
queryset = BomItem.objects.all()
|
||||
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)
|
||||
@ -1855,19 +1792,17 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
|
||||
class BomItemValidate(generics.UpdateAPIView):
|
||||
""" API endpoint for validating a BomItem """
|
||||
"""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()
|
||||
serializer_class = BomItemValidationSerializer
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
""" Perform update request """
|
||||
|
||||
"""Perform update request."""
|
||||
partial = kwargs.pop('partial', False)
|
||||
|
||||
valid = request.data.get('valid', False)
|
||||
@ -1884,9 +1819,7 @@ class BomItemValidate(generics.UpdateAPIView):
|
||||
|
||||
|
||||
class BomItemSubstituteList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for accessing a list of BomItemSubstitute objects
|
||||
"""
|
||||
"""API endpoint for accessing a list of BomItemSubstitute objects."""
|
||||
|
||||
serializer_class = part_serializers.BomItemSubstituteSerializer
|
||||
queryset = BomItemSubstitute.objects.all()
|
||||
@ -1904,9 +1837,7 @@ class BomItemSubstituteList(generics.ListCreateAPIView):
|
||||
|
||||
|
||||
class BomItemSubstituteDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API endpoint for detail view of a single BomItemSubstitute object
|
||||
"""
|
||||
"""API endpoint for detail view of a single BomItemSubstitute object."""
|
||||
|
||||
queryset = BomItemSubstitute.objects.all()
|
||||
serializer_class = part_serializers.BomItemSubstituteSerializer
|
||||
|
@ -1,3 +1,5 @@
|
||||
"""part app specification"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
@ -9,24 +11,19 @@ logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
class PartConfig(AppConfig):
|
||||
"""Config class for the 'part' app"""
|
||||
name = 'part'
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
This function is called whenever the Part app is loaded.
|
||||
"""
|
||||
|
||||
"""This function is called whenever the Part app is loaded."""
|
||||
if canAppAccessDatabase():
|
||||
self.update_trackable_status()
|
||||
|
||||
def update_trackable_status(self):
|
||||
"""
|
||||
Check for any instances where a trackable part is used in the BOM
|
||||
for a non-trackable part.
|
||||
"""Check for any instances where a trackable part is used in the BOM for a non-trackable part.
|
||||
|
||||
In such a case, force the top-level part to be trackable too.
|
||||
"""
|
||||
|
||||
from .models import BomItem
|
||||
|
||||
try:
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""
|
||||
Functionality for Bill of Material (BOM) management.
|
||||
"""Functionality for Bill of Material (BOM) management.
|
||||
|
||||
Primarily BOM upload tools.
|
||||
"""
|
||||
|
||||
@ -11,18 +11,16 @@ from company.models import ManufacturerPart, SupplierPart
|
||||
from InvenTree.helpers import DownloadFile, GetExportFormats, normalize
|
||||
|
||||
from .admin import BomItemResource
|
||||
from .models import BomItem
|
||||
from .models import BomItem, Part
|
||||
|
||||
|
||||
def IsValidBOMFormat(fmt):
|
||||
""" Test if a file format specifier is in the valid list of BOM file formats """
|
||||
|
||||
"""Test if a file format specifier is in the valid list of BOM file formats."""
|
||||
return fmt.strip().lower() in GetExportFormats()
|
||||
|
||||
|
||||
def MakeBomTemplate(fmt):
|
||||
""" Generate a Bill of Materials upload template file (for user download) """
|
||||
|
||||
"""Generate a Bill of Materials upload template file (for user download)."""
|
||||
fmt = fmt.strip().lower()
|
||||
|
||||
if not IsValidBOMFormat(fmt):
|
||||
@ -44,14 +42,22 @@ def MakeBomTemplate(fmt):
|
||||
return DownloadFile(data, filename)
|
||||
|
||||
|
||||
def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=False, stock_data=False, supplier_data=False, manufacturer_data=False):
|
||||
""" Export a BOM (Bill of Materials) for a given part.
|
||||
def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = None, parameter_data=False, stock_data=False, supplier_data=False, manufacturer_data=False):
|
||||
"""Export a BOM (Bill of Materials) for a given part.
|
||||
|
||||
Args:
|
||||
fmt: File format (default = 'csv')
|
||||
cascade: If True, multi-level BOM output is supported. Otherwise, a flat top-level-only BOM is exported.
|
||||
"""
|
||||
part (Part): Part for which the BOM should be exported
|
||||
fmt (str, optional): file format. Defaults to 'csv'.
|
||||
cascade (bool, optional): If True, multi-level BOM output is supported. Otherwise, a flat top-level-only BOM is exported.. Defaults to False.
|
||||
max_levels (int, optional): Levels of items that should be included. None for np sublevels. Defaults to None.
|
||||
parameter_data (bool, optional): Additonal data that should be added. Defaults to False.
|
||||
stock_data (bool, optional): Additonal data that should be added. Defaults to False.
|
||||
supplier_data (bool, optional): Additonal data that should be added. Defaults to False.
|
||||
manufacturer_data (bool, optional): Additonal data that should be added. Defaults to False.
|
||||
|
||||
Returns:
|
||||
StreamingHttpResponse: Response that can be passed to the endpoint
|
||||
"""
|
||||
if not IsValidBOMFormat(fmt):
|
||||
fmt = 'csv'
|
||||
|
||||
@ -92,9 +98,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
pass
|
||||
|
||||
if parameter_data:
|
||||
"""
|
||||
If requested, add extra columns for each PartParameter associated with each line item
|
||||
"""
|
||||
"""If requested, add extra columns for each PartParameter associated with each line item."""
|
||||
|
||||
parameter_cols = {}
|
||||
|
||||
@ -117,9 +121,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
add_columns_to_dataset(parameter_cols_ordered, len(bom_items))
|
||||
|
||||
if stock_data:
|
||||
"""
|
||||
If requested, add extra columns for stock data associated with each line item
|
||||
"""
|
||||
"""If requested, add extra columns for stock data associated with each line item."""
|
||||
|
||||
stock_headers = [
|
||||
_('Default Location'),
|
||||
@ -172,9 +174,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
add_columns_to_dataset(stock_cols, len(bom_items))
|
||||
|
||||
if manufacturer_data or supplier_data:
|
||||
"""
|
||||
If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item
|
||||
"""
|
||||
"""If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item."""
|
||||
|
||||
# Keep track of the supplier parts we have already exported
|
||||
supplier_parts_used = set()
|
||||
|
@ -1,13 +1,10 @@
|
||||
"""
|
||||
Django Forms for interacting with Part objects
|
||||
"""
|
||||
"""Django Forms for interacting with Part objects."""
|
||||
|
||||
from django import forms
|
||||
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
|
||||
@ -18,24 +15,8 @@ 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
|
||||
"""
|
||||
"""Form for downloading an image from a URL."""
|
||||
|
||||
url = forms.URLField(
|
||||
label=_('URL'),
|
||||
@ -44,6 +25,7 @@ class PartImageDownloadForm(HelperForm):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = Part
|
||||
fields = [
|
||||
'url',
|
||||
@ -51,11 +33,10 @@ class PartImageDownloadForm(HelperForm):
|
||||
|
||||
|
||||
class BomMatchItemForm(MatchItemForm):
|
||||
""" Override MatchItemForm fields """
|
||||
"""Override MatchItemForm fields."""
|
||||
|
||||
def get_special_field(self, col_guess, row, file_manager):
|
||||
""" Set special fields """
|
||||
|
||||
"""Set special fields."""
|
||||
# set quantity field
|
||||
if 'quantity' in col_guess.lower():
|
||||
return forms.CharField(
|
||||
@ -74,15 +55,16 @@ class BomMatchItemForm(MatchItemForm):
|
||||
|
||||
|
||||
class SetPartCategoryForm(forms.Form):
|
||||
""" Form for setting the category of multiple Part objects """
|
||||
"""Form for setting the category of multiple Part objects."""
|
||||
|
||||
part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category'))
|
||||
|
||||
|
||||
class EditPartParameterTemplateForm(HelperForm):
|
||||
""" Form for editing a PartParameterTemplate object """
|
||||
"""Form for editing a PartParameterTemplate object."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = PartParameterTemplate
|
||||
fields = [
|
||||
'name',
|
||||
@ -91,7 +73,7 @@ class EditPartParameterTemplateForm(HelperForm):
|
||||
|
||||
|
||||
class EditCategoryParameterTemplateForm(HelperForm):
|
||||
""" Form for editing a PartCategoryParameterTemplate object """
|
||||
"""Form for editing a PartCategoryParameterTemplate object."""
|
||||
|
||||
add_to_same_level_categories = forms.BooleanField(required=False,
|
||||
initial=False,
|
||||
@ -102,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',
|
||||
@ -113,7 +96,7 @@ class EditCategoryParameterTemplateForm(HelperForm):
|
||||
|
||||
|
||||
class PartPriceForm(forms.Form):
|
||||
""" Simple form for viewing part pricing information """
|
||||
"""Simple form for viewing part pricing information."""
|
||||
|
||||
quantity = forms.IntegerField(
|
||||
required=True,
|
||||
@ -123,6 +106,7 @@ class PartPriceForm(forms.Form):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = Part
|
||||
fields = [
|
||||
'quantity',
|
||||
@ -130,13 +114,12 @@ class PartPriceForm(forms.Form):
|
||||
|
||||
|
||||
class EditPartSalePriceBreakForm(HelperForm):
|
||||
"""
|
||||
Form for creating / editing a sale price for a part
|
||||
"""
|
||||
"""Form for creating / editing a sale price for a part."""
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = PartSellPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
@ -146,13 +129,12 @@ class EditPartSalePriceBreakForm(HelperForm):
|
||||
|
||||
|
||||
class EditPartInternalPriceBreakForm(HelperForm):
|
||||
"""
|
||||
Form for creating / editing a internal price for a part
|
||||
"""
|
||||
"""Form for creating / editing a internal price for a part."""
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = PartInternalPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,4 @@
|
||||
"""
|
||||
JSON serializers for Part app
|
||||
"""
|
||||
"""DRF data serializers for Part app."""
|
||||
|
||||
import imghdr
|
||||
from decimal import Decimal
|
||||
@ -37,17 +35,10 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
|
||||
|
||||
|
||||
class CategorySerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for PartCategory """
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
"""Serializer for PartCategory."""
|
||||
|
||||
def get_starred(self, category):
|
||||
"""
|
||||
Return True if the category is directly "starred" by the current user
|
||||
"""
|
||||
|
||||
"""Return True if the category is directly "starred" by the current user."""
|
||||
return category in self.context.get('starred_categories', [])
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
@ -59,6 +50,7 @@ class CategorySerializer(InvenTreeModelSerializer):
|
||||
starred = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartCategory
|
||||
fields = [
|
||||
'pk',
|
||||
@ -76,11 +68,10 @@ class CategorySerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class CategoryTree(InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for PartCategory tree
|
||||
"""
|
||||
"""Serializer for PartCategory tree."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartCategory
|
||||
fields = [
|
||||
'pk',
|
||||
@ -90,11 +81,10 @@ class CategoryTree(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""
|
||||
Serializer for the PartAttachment class
|
||||
"""
|
||||
"""Serializer for the PartAttachment class."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartAttachment
|
||||
|
||||
fields = [
|
||||
@ -113,13 +103,12 @@ class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
|
||||
|
||||
class PartTestTemplateSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for the PartTestTemplate class
|
||||
"""
|
||||
"""Serializer for the PartTestTemplate class."""
|
||||
|
||||
key = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartTestTemplate
|
||||
|
||||
fields = [
|
||||
@ -135,9 +124,7 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class PartSalePriceSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for sale prices for Part model.
|
||||
"""
|
||||
"""Serializer for sale prices for Part model."""
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
@ -155,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',
|
||||
@ -167,9 +155,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for internal prices for Part model.
|
||||
"""
|
||||
"""Serializer for internal prices for Part model."""
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
@ -187,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',
|
||||
@ -199,8 +186,8 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class PartThumbSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for the 'image' field of the Part model.
|
||||
"""Serializer for the 'image' field of the Part model.
|
||||
|
||||
Used to serve and display existing Part images.
|
||||
"""
|
||||
|
||||
@ -209,12 +196,10 @@ class PartThumbSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class PartThumbSerializerUpdate(InvenTreeModelSerializer):
|
||||
""" Serializer for updating Part thumbnail """
|
||||
"""Serializer for updating Part thumbnail."""
|
||||
|
||||
def validate_image(self, value):
|
||||
"""
|
||||
Check that file is an image.
|
||||
"""
|
||||
"""Check that file is an image."""
|
||||
validate = imghdr.what(value)
|
||||
if not validate:
|
||||
raise serializers.ValidationError("File is not an image")
|
||||
@ -223,6 +208,7 @@ class PartThumbSerializerUpdate(InvenTreeModelSerializer):
|
||||
image = InvenTreeAttachmentSerializerField(required=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = Part
|
||||
fields = [
|
||||
'image',
|
||||
@ -230,9 +216,10 @@ class PartThumbSerializerUpdate(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class PartParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||
""" JSON serializer for the PartParameterTemplate model """
|
||||
"""JSON serializer for the PartParameterTemplate model."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartParameterTemplate
|
||||
fields = [
|
||||
'pk',
|
||||
@ -242,11 +229,12 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class PartParameterSerializer(InvenTreeModelSerializer):
|
||||
""" JSON serializers for the PartParameter model """
|
||||
"""JSON serializers for the PartParameter model."""
|
||||
|
||||
template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartParameter
|
||||
fields = [
|
||||
'pk',
|
||||
@ -258,13 +246,14 @@ class PartParameterSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for Part (brief detail) """
|
||||
"""Serializer for Part (brief detail)"""
|
||||
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
|
||||
stock = serializers.FloatField(source='total_stock')
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = Part
|
||||
fields = [
|
||||
'pk',
|
||||
@ -288,19 +277,20 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class PartSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for complete detail information of a part.
|
||||
"""Serializer for complete detail information of a part.
|
||||
|
||||
Used when displaying all details of a single component.
|
||||
"""
|
||||
|
||||
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)
|
||||
@ -317,12 +307,10 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""
|
||||
Add some extra annotations to the queryset,
|
||||
performing database queries as efficiently as possible,
|
||||
to reduce database trips.
|
||||
"""
|
||||
"""Add some extra annotations to the queryset.
|
||||
|
||||
Performing database queries as efficiently as possible, to reduce database trips.
|
||||
"""
|
||||
# Annotate with the total 'in stock' quantity
|
||||
queryset = queryset.annotate(
|
||||
in_stock=Coalesce(
|
||||
@ -444,10 +432,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
return queryset
|
||||
|
||||
def get_starred(self, part):
|
||||
"""
|
||||
Return "true" if the part is starred by the current user.
|
||||
"""
|
||||
|
||||
"""Return "true" if the part is starred by the current user."""
|
||||
return part in self.starred_parts
|
||||
|
||||
# Extra detail for the category
|
||||
@ -477,6 +462,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = Part
|
||||
partial = True
|
||||
fields = [
|
||||
@ -522,14 +508,13 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class PartRelationSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for a PartRelated model
|
||||
"""
|
||||
"""Serializer for a PartRelated model."""
|
||||
|
||||
part_1_detail = PartSerializer(source='part_1', read_only=True, many=False)
|
||||
part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartRelated
|
||||
fields = [
|
||||
'pk',
|
||||
@ -541,12 +526,13 @@ class PartRelationSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class PartStarSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for a PartStar object """
|
||||
"""Serializer for a PartStar object."""
|
||||
|
||||
partname = serializers.CharField(source='part.full_name', read_only=True)
|
||||
username = serializers.CharField(source='user.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = PartStar
|
||||
fields = [
|
||||
'pk',
|
||||
@ -558,13 +544,12 @@ class PartStarSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class BomItemSubstituteSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for the BomItemSubstitute class
|
||||
"""
|
||||
"""Serializer for the BomItemSubstitute class."""
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', read_only=True, many=False)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = BomItemSubstitute
|
||||
fields = [
|
||||
'pk',
|
||||
@ -575,15 +560,14 @@ class BomItemSubstituteSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class BomItemSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Serializer for BomItem object
|
||||
"""
|
||||
"""Serializer for BomItem object."""
|
||||
|
||||
price_range = serializers.CharField(read_only=True)
|
||||
|
||||
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"))
|
||||
|
||||
@ -615,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)
|
||||
@ -640,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')
|
||||
@ -663,18 +650,15 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""
|
||||
Annotate the BomItem queryset with extra information:
|
||||
"""Annotate the BomItem queryset with extra information:
|
||||
|
||||
Annotations:
|
||||
available_stock: The amount of stock available for the sub_part Part object
|
||||
"""
|
||||
|
||||
"""
|
||||
Construct an "available stock" quantity:
|
||||
available_stock = total_stock - build_order_allocations - sales_order_allocations
|
||||
"""
|
||||
|
||||
build_order_filter = Q(build__status__in=BuildStatus.ACTIVE_CODES)
|
||||
sales_order_filter = Q(
|
||||
line__order__status__in=SalesOrderStatus.OPEN,
|
||||
@ -799,8 +783,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
return queryset
|
||||
|
||||
def get_purchase_price_range(self, obj):
|
||||
""" Return purchase price range """
|
||||
|
||||
"""Return purchase price range."""
|
||||
try:
|
||||
purchase_price_min = obj.purchase_price_min
|
||||
except AttributeError:
|
||||
@ -830,8 +813,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
return purchase_price_range
|
||||
|
||||
def get_purchase_price_avg(self, obj):
|
||||
""" Return purchase price average """
|
||||
|
||||
"""Return purchase price average."""
|
||||
try:
|
||||
purchase_price_avg = obj.purchase_price_avg
|
||||
except AttributeError:
|
||||
@ -846,6 +828,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
return purchase_price_avg
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
model = BomItem
|
||||
fields = [
|
||||
'allow_variants',
|
||||
@ -877,7 +860,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for PartCategoryParameterTemplate """
|
||||
"""Serializer for PartCategoryParameterTemplate."""
|
||||
|
||||
parameter_template = PartParameterTemplateSerializer(many=False,
|
||||
read_only=True)
|
||||
@ -885,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',
|
||||
@ -896,11 +880,10 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class PartCopyBOMSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for copying a BOM from another part
|
||||
"""
|
||||
"""Serializer for copying a BOM from another part."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
fields = [
|
||||
'part',
|
||||
'remove_existing',
|
||||
@ -919,10 +902,7 @@ class PartCopyBOMSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
def validate_part(self, part):
|
||||
"""
|
||||
Check that a 'valid' part was selected
|
||||
"""
|
||||
|
||||
"""Check that a 'valid' part was selected."""
|
||||
return part
|
||||
|
||||
remove_existing = serializers.BooleanField(
|
||||
@ -950,10 +930,7 @@ class PartCopyBOMSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Actually duplicate the BOM
|
||||
"""
|
||||
|
||||
"""Actually duplicate the BOM."""
|
||||
base_part = self.context['part']
|
||||
|
||||
data = self.validated_data
|
||||
@ -968,13 +945,12 @@ class PartCopyBOMSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class BomImportUploadSerializer(DataFileUploadSerializer):
|
||||
"""
|
||||
Serializer for uploading a file and extracting data from it.
|
||||
"""
|
||||
"""Serializer for uploading a file and extracting data from it."""
|
||||
|
||||
TARGET_MODEL = BomItem
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defining serializer fields"""
|
||||
fields = [
|
||||
'data_file',
|
||||
'part',
|
||||
@ -994,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):
|
||||
@ -1005,12 +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']
|
||||
@ -1020,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)
|
||||
|
||||
@ -1089,8 +1068,7 @@ class BomImportExtractSerializer(DataFileExtractSerializer):
|
||||
|
||||
|
||||
class BomImportSubmitSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for uploading a BOM against a specified part.
|
||||
"""Serializer for uploading a BOM against a specified part.
|
||||
|
||||
A "BOM" is a set of BomItem objects which are to be validated together as a set
|
||||
"""
|
||||
@ -1098,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:
|
||||
@ -1109,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,61 +1,38 @@
|
||||
"""
|
||||
User-configurable settings for the Part app
|
||||
"""
|
||||
"""User-configurable settings for the Part app."""
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
|
||||
def part_assembly_default():
|
||||
"""
|
||||
Returns the default value for the 'assembly' field of a Part object
|
||||
"""
|
||||
|
||||
"""Returns the default value for the 'assembly' field of a Part object."""
|
||||
return InvenTreeSetting.get_setting('PART_ASSEMBLY')
|
||||
|
||||
|
||||
def part_template_default():
|
||||
"""
|
||||
Returns the default value for the 'is_template' field of a Part object
|
||||
"""
|
||||
|
||||
"""Returns the default value for the 'is_template' field of a Part object."""
|
||||
return InvenTreeSetting.get_setting('PART_TEMPLATE')
|
||||
|
||||
|
||||
def part_virtual_default():
|
||||
"""
|
||||
Returns the default value for the 'is_virtual' field of Part object
|
||||
"""
|
||||
|
||||
"""Returns the default value for the 'is_virtual' field of Part object."""
|
||||
return InvenTreeSetting.get_setting('PART_VIRTUAL')
|
||||
|
||||
|
||||
def part_component_default():
|
||||
"""
|
||||
Returns the default value for the 'component' field of a Part object
|
||||
"""
|
||||
|
||||
"""Returns the default value for the 'component' field of a Part object."""
|
||||
return InvenTreeSetting.get_setting('PART_COMPONENT')
|
||||
|
||||
|
||||
def part_purchaseable_default():
|
||||
"""
|
||||
Returns the default value for the 'purchasable' field for a Part object
|
||||
"""
|
||||
|
||||
"""Returns the default value for the 'purchasable' field for a Part object."""
|
||||
return InvenTreeSetting.get_setting('PART_PURCHASEABLE')
|
||||
|
||||
|
||||
def part_salable_default():
|
||||
"""
|
||||
Returns the default value for the 'salable' field for a Part object
|
||||
"""
|
||||
|
||||
"""Returns the default value for the 'salable' field for a Part object."""
|
||||
return InvenTreeSetting.get_setting('PART_SALABLE')
|
||||
|
||||
|
||||
def part_trackable_default():
|
||||
"""
|
||||
Returns the default value for the 'trackable' field for a Part object
|
||||
"""
|
||||
|
||||
"""Returns the default value for the 'trackable' field for a Part object."""
|
||||
return InvenTreeSetting.get_setting('PART_TRACKABLE')
|
||||
|
@ -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 = {
|
||||
@ -33,12 +40,10 @@ def notify_low_stock(part: part.models.Part):
|
||||
|
||||
|
||||
def notify_low_stock_if_required(part: part.models.Part):
|
||||
"""
|
||||
Check if the stock quantity has fallen below the minimum threshold of part.
|
||||
"""Check if the stock quantity has fallen below the minimum threshold of part.
|
||||
|
||||
If true, notify the users who have subscribed to the part
|
||||
"""
|
||||
|
||||
# Run "up" the tree, to allow notification for "parent" parts
|
||||
parts = part.get_ancestors(include_self=True, ascending=True)
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
"""Custom InvenTree template tags for HTML template rendering"""
|
||||
|
@ -1,9 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
This module provides template tags for extra functionality,
|
||||
over and above the built-in Django tags.
|
||||
"""
|
||||
"""This module provides template tags for extra functionality, over and above the built-in Django tags."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
@ -33,26 +28,22 @@ logger = logging.getLogger('inventree')
|
||||
|
||||
@register.simple_tag()
|
||||
def define(value, *args, **kwargs):
|
||||
"""
|
||||
Shortcut function to overcome the shortcomings of the django templating language
|
||||
"""Shortcut function to overcome the shortcomings of the django templating language.
|
||||
|
||||
Use as follows: {% define "hello_world" as hello %}
|
||||
|
||||
Ref: https://stackoverflow.com/questions/1070398/how-to-set-a-value-of-a-variable-inside-a-template-code
|
||||
"""
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def render_date(context, date_object):
|
||||
"""
|
||||
Renders a date according to the preference of the provided user
|
||||
"""Renders a date according to the preference of the provided user.
|
||||
|
||||
Note that the user preference is stored using the formatting adopted by moment.js,
|
||||
which differs from the python formatting!
|
||||
"""
|
||||
|
||||
if date_object is None:
|
||||
return None
|
||||
|
||||
@ -105,59 +96,43 @@ def render_date(context, date_object):
|
||||
|
||||
@register.simple_tag()
|
||||
def decimal(x, *args, **kwargs):
|
||||
""" Simplified rendering of a decimal number """
|
||||
|
||||
"""Simplified rendering of a decimal number."""
|
||||
return InvenTree.helpers.decimal2string(x)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def str2bool(x, *args, **kwargs):
|
||||
""" Convert a string to a boolean value """
|
||||
|
||||
"""Convert a string to a boolean value."""
|
||||
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 """
|
||||
"""Add two numbers together."""
|
||||
return x + y
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def to_list(*args):
|
||||
""" Return the input arguments as list """
|
||||
"""Return the input arguments as list."""
|
||||
return args
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def part_allocation_count(build, part, *args, **kwargs):
|
||||
""" Return the total number of <part> allocated to <build> """
|
||||
|
||||
"""Return the total number of <part> allocated to <build>"""
|
||||
return InvenTree.helpers.decimal2string(build.getAllocatedQuantity(part))
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_in_debug_mode(*args, **kwargs):
|
||||
""" Return True if the server is running in DEBUG mode """
|
||||
|
||||
"""Return True if the server is running in DEBUG mode."""
|
||||
return djangosettings.DEBUG
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_show_about(user, *args, **kwargs):
|
||||
""" Return True if the about modal should be shown """
|
||||
"""Return True if the about modal should be shown."""
|
||||
if InvenTreeSetting.get_setting('INVENTREE_RESTRICT_ABOUT') and not user.is_superuser:
|
||||
return False
|
||||
return True
|
||||
@ -165,22 +140,19 @@ def inventree_show_about(user, *args, **kwargs):
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_docker_mode(*args, **kwargs):
|
||||
""" Return True if the server is running as a Docker image """
|
||||
|
||||
"""Return True if the server is running as a Docker image."""
|
||||
return djangosettings.DOCKER
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def plugins_enabled(*args, **kwargs):
|
||||
""" Return True if plugins are enabled for the server instance """
|
||||
|
||||
"""Return True if plugins are enabled for the server instance."""
|
||||
return djangosettings.PLUGINS_ENABLED
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_db_engine(*args, **kwargs):
|
||||
""" Return the InvenTree database backend e.g. 'postgresql' """
|
||||
|
||||
"""Return the InvenTree database backend e.g. 'postgresql'."""
|
||||
db = djangosettings.DATABASES['default']
|
||||
|
||||
engine = db.get('ENGINE', _('Unknown database'))
|
||||
@ -192,33 +164,31 @@ def inventree_db_engine(*args, **kwargs):
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_instance_name(*args, **kwargs):
|
||||
""" Return the InstanceName associated with the current database """
|
||||
"""Return the InstanceName associated with the current database."""
|
||||
return version.inventreeInstanceName()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_title(*args, **kwargs):
|
||||
""" Return the title for the current instance - respecting the settings """
|
||||
"""Return the title for the current instance - respecting the settings"""
|
||||
return version.inventreeInstanceTitle()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_base_url(*args, **kwargs):
|
||||
""" Return the INVENTREE_BASE_URL setting """
|
||||
"""Return the INVENTREE_BASE_URL setting."""
|
||||
return InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def python_version(*args, **kwargs):
|
||||
"""
|
||||
Return the current python version
|
||||
"""
|
||||
"""Return the current python version."""
|
||||
return sys.version.split(' ')[0]
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_version(shortstring=False, *args, **kwargs):
|
||||
""" Return InvenTree version string """
|
||||
"""Return InvenTree version string."""
|
||||
if shortstring:
|
||||
return _("{title} v{version}".format(
|
||||
title=version.inventreeInstanceTitle(),
|
||||
@ -229,53 +199,55 @@ 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()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_api_version(*args, **kwargs):
|
||||
""" Return InvenTree API version """
|
||||
"""Return InvenTree API version."""
|
||||
return version.inventreeApiVersion()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def django_version(*args, **kwargs):
|
||||
""" Return Django version string """
|
||||
"""Return Django version string."""
|
||||
return version.inventreeDjangoVersion()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_commit_hash(*args, **kwargs):
|
||||
""" Return InvenTree git commit hash string """
|
||||
"""Return InvenTree git commit hash string."""
|
||||
return version.inventreeCommitHash()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_commit_date(*args, **kwargs):
|
||||
""" Return InvenTree git commit date string """
|
||||
"""Return InvenTree git commit date string."""
|
||||
return version.inventreeCommitDate()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_github_url(*args, **kwargs):
|
||||
""" Return URL for InvenTree github site """
|
||||
"""Return URL for InvenTree github site."""
|
||||
return "https://github.com/InvenTree/InvenTree/"
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_docs_url(*args, **kwargs):
|
||||
""" Return URL for InvenTree documenation site """
|
||||
|
||||
"""Return URL for InvenTree documenation site."""
|
||||
tag = version.inventreeDocsVersion()
|
||||
|
||||
return f"https://inventree.readthedocs.io/en/{tag}"
|
||||
@ -283,24 +255,23 @@ def inventree_docs_url(*args, **kwargs):
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_credits_url(*args, **kwargs):
|
||||
""" Return URL for InvenTree credits site """
|
||||
"""Return URL for InvenTree credits site."""
|
||||
return "https://inventree.readthedocs.io/en/latest/credits/"
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def default_currency(*args, **kwargs):
|
||||
""" Returns the default currency code """
|
||||
"""Returns the default currency code."""
|
||||
return currency_code_default()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def setting_object(key, *args, **kwargs):
|
||||
"""
|
||||
Return a setting object speciifed by the given key
|
||||
"""Return a setting object speciifed by the given key.
|
||||
|
||||
(Or return None if the setting does not exist)
|
||||
if a user-setting was requested return that
|
||||
"""
|
||||
|
||||
if 'plugin' in kwargs:
|
||||
# Note, 'plugin' is an instance of an InvenTreePlugin class
|
||||
|
||||
@ -319,10 +290,7 @@ def setting_object(key, *args, **kwargs):
|
||||
|
||||
@register.simple_tag()
|
||||
def settings_value(key, *args, **kwargs):
|
||||
"""
|
||||
Return a settings value specified by the given key
|
||||
"""
|
||||
|
||||
"""Return a settings value specified by the given key."""
|
||||
if 'user' in kwargs:
|
||||
if not kwargs['user'] or (kwargs['user'] and kwargs['user'].is_authenticated is False):
|
||||
return InvenTreeUserSetting.get_setting(key)
|
||||
@ -333,37 +301,25 @@ def settings_value(key, *args, **kwargs):
|
||||
|
||||
@register.simple_tag()
|
||||
def user_settings(user, *args, **kwargs):
|
||||
"""
|
||||
Return all USER settings as a key:value dict
|
||||
"""
|
||||
|
||||
"""Return all USER settings as a key:value dict."""
|
||||
return InvenTreeUserSetting.allValues(user=user)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def global_settings(*args, **kwargs):
|
||||
"""
|
||||
Return all GLOBAL InvenTree settings as a key:value dict
|
||||
"""
|
||||
|
||||
"""Return all GLOBAL InvenTree settings as a key:value dict."""
|
||||
return InvenTreeSetting.allValues()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def visible_global_settings(*args, **kwargs):
|
||||
"""
|
||||
Return any global settings which are not marked as 'hidden'
|
||||
"""
|
||||
|
||||
"""Return any global settings which are not marked as 'hidden'."""
|
||||
return InvenTreeSetting.allValues(exclude_hidden=True)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def progress_bar(val, max_val, *args, **kwargs):
|
||||
"""
|
||||
Render a progress bar element
|
||||
"""
|
||||
|
||||
"""Render a progress bar element."""
|
||||
item_id = kwargs.get('id', 'progress-bar')
|
||||
|
||||
val = InvenTree.helpers.normalize(val)
|
||||
@ -402,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')
|
||||
@ -414,7 +371,7 @@ def get_color_theme_css(username):
|
||||
|
||||
@register.simple_tag()
|
||||
def get_user_color_theme(username):
|
||||
""" Get current user color theme """
|
||||
"""Get current user color theme."""
|
||||
try:
|
||||
user_theme = ColorTheme.objects.filter(user=username).get()
|
||||
user_theme_name = user_theme.name
|
||||
@ -428,10 +385,7 @@ def get_user_color_theme(username):
|
||||
|
||||
@register.simple_tag()
|
||||
def get_available_themes(*args, **kwargs):
|
||||
"""
|
||||
Return the available theme choices
|
||||
"""
|
||||
|
||||
"""Return the available theme choices."""
|
||||
themes = []
|
||||
|
||||
for key, name in ColorTheme.get_color_themes_choices():
|
||||
@ -445,13 +399,11 @@ def get_available_themes(*args, **kwargs):
|
||||
|
||||
@register.simple_tag()
|
||||
def primitive_to_javascript(primitive):
|
||||
"""
|
||||
Convert a python primitive to a javascript primitive.
|
||||
"""Convert a python primitive to a javascript primitive.
|
||||
|
||||
e.g. True -> true
|
||||
'hello' -> '"hello"'
|
||||
"""
|
||||
|
||||
if type(primitive) is bool:
|
||||
return str(primitive).lower()
|
||||
|
||||
@ -465,10 +417,9 @@ def primitive_to_javascript(primitive):
|
||||
|
||||
@register.filter
|
||||
def keyvalue(dict, key):
|
||||
"""
|
||||
access to key of supplied dict
|
||||
"""Access to key of supplied dict.
|
||||
|
||||
usage:
|
||||
Usage:
|
||||
{% mydict|keyvalue:mykey %}
|
||||
"""
|
||||
return dict.get(key)
|
||||
@ -476,10 +427,9 @@ def keyvalue(dict, key):
|
||||
|
||||
@register.simple_tag()
|
||||
def call_method(obj, method_name, *args):
|
||||
"""
|
||||
enables calling model methods / functions from templates with arguments
|
||||
"""Enables calling model methods / functions from templates with arguments.
|
||||
|
||||
usage:
|
||||
Usage:
|
||||
{% call_method model_object 'fnc_name' argument1 %}
|
||||
"""
|
||||
method = getattr(obj, method_name)
|
||||
@ -488,8 +438,7 @@ def call_method(obj, method_name, *args):
|
||||
|
||||
@register.simple_tag()
|
||||
def authorized_owners(group):
|
||||
""" Return authorized owners """
|
||||
|
||||
"""Return authorized owners."""
|
||||
owners = []
|
||||
|
||||
try:
|
||||
@ -507,41 +456,39 @@ def authorized_owners(group):
|
||||
|
||||
@register.simple_tag()
|
||||
def object_link(url_name, pk, ref):
|
||||
""" Return highlighted link to object """
|
||||
|
||||
"""Return highlighted link to object."""
|
||||
ref_url = reverse(url_name, kwargs={'pk': pk})
|
||||
return mark_safe('<b><a href="{}">{}</a></b>'.format(ref_url, ref))
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def mail_configured():
|
||||
""" Return if mail is configured """
|
||||
"""Return if mail is configured."""
|
||||
return bool(settings.EMAIL_HOST)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_customize(reference, *args, **kwargs):
|
||||
""" Return customization values for the user interface """
|
||||
|
||||
"""Return customization values for the user interface."""
|
||||
return djangosettings.CUSTOMIZE.get(reference, '')
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_logo(*args, **kwargs):
|
||||
""" Return the path to the logo-file """
|
||||
|
||||
"""Return the path to the logo-file."""
|
||||
if settings.CUSTOM_LOGO:
|
||||
return default_storage.url(settings.CUSTOM_LOGO)
|
||||
return static('img/inventree.png')
|
||||
|
||||
|
||||
class I18nStaticNode(StaticNode):
|
||||
"""
|
||||
custom StaticNode
|
||||
replaces a variable named *lng* in the path with the current language
|
||||
"""
|
||||
def render(self, context): # pragma: no cover
|
||||
"""Custom StaticNode.
|
||||
|
||||
Replaces a variable named *lng* in the path with the current language
|
||||
"""
|
||||
|
||||
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:
|
||||
@ -561,17 +508,16 @@ if settings.DEBUG:
|
||||
|
||||
@register.simple_tag()
|
||||
def i18n_static(url_name):
|
||||
""" simple tag to enable {% url %} functionality instead of {% static %} """
|
||||
"""Simple tag to enable {% url %} functionality instead of {% static %}"""
|
||||
return reverse(url_name)
|
||||
|
||||
else: # pragma: no cover
|
||||
|
||||
@register.tag('i18n_static')
|
||||
def do_i18n_static(parser, token):
|
||||
"""
|
||||
Overrides normal static, adds language - lookup for prerenderd files #1485
|
||||
"""Overrides normal static, adds language - lookup for prerenderd files #1485
|
||||
|
||||
usage (like static):
|
||||
Usage (like static):
|
||||
{% i18n_static path [as varname] %}
|
||||
"""
|
||||
bits = token.split_contents()
|
||||
|
@ -1,6 +1,4 @@
|
||||
"""
|
||||
Provide templates for the various model status codes.
|
||||
"""
|
||||
"""Provide templates for the various model status codes."""
|
||||
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
@ -13,28 +11,29 @@ register = template.Library()
|
||||
|
||||
@register.simple_tag
|
||||
def purchase_order_status_label(key, *args, **kwargs):
|
||||
""" Render a PurchaseOrder status label """
|
||||
"""Render a PurchaseOrder status label."""
|
||||
return mark_safe(PurchaseOrderStatus.render(key, large=kwargs.get('large', False)))
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def sales_order_status_label(key, *args, **kwargs):
|
||||
""" Render a SalesOrder status label """
|
||||
"""Render a SalesOrder status label."""
|
||||
return mark_safe(SalesOrderStatus.render(key, large=kwargs.get('large', False)))
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def stock_status_label(key, *args, **kwargs):
|
||||
""" Render a StockItem status label """
|
||||
"""Render a StockItem status label."""
|
||||
return mark_safe(StockStatus.render(key, large=kwargs.get('large', False)))
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def build_status_label(key, *args, **kwargs):
|
||||
""" Render a Build status label """
|
||||
"""Render a Build status label."""
|
||||
return mark_safe(BuildStatus.render(key, large=kwargs.get('large', False)))
|
||||
|
@ -1,3 +1,5 @@
|
||||
"""Unit tests for the various part API endpoints"""
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
import PIL
|
||||
@ -11,12 +13,13 @@ 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
|
||||
|
||||
|
||||
class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the PartCategory API"""
|
||||
"""Unit tests for the PartCategory API."""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -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)
|
||||
@ -73,8 +75,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
def test_category_metadata(self):
|
||||
"""Test metadata endpoint for the PartCategory"""
|
||||
|
||||
"""Test metadata endpoint for the PartCategory."""
|
||||
cat = PartCategory.objects.get(pk=1)
|
||||
|
||||
cat.metadata = {
|
||||
@ -95,8 +96,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
|
||||
|
||||
class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Tests for the various OPTIONS endpoints in the /part/ API
|
||||
"""Tests for the various OPTIONS endpoints in the /part/ API.
|
||||
|
||||
Ensure that the required field details are provided!
|
||||
"""
|
||||
@ -105,15 +105,8 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
'part.add',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
def test_part(self):
|
||||
"""
|
||||
Test the Part API OPTIONS
|
||||
"""
|
||||
|
||||
"""Test the Part API OPTIONS."""
|
||||
actions = self.getActions(reverse('api-part-list'))['POST']
|
||||
|
||||
# Check that a bunch o' fields are contained
|
||||
@ -147,10 +140,7 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(category['help_text'], 'Part category')
|
||||
|
||||
def test_category(self):
|
||||
"""
|
||||
Test the PartCategory API OPTIONS endpoint
|
||||
"""
|
||||
|
||||
"""Test the PartCategory API OPTIONS endpoint."""
|
||||
actions = self.getActions(reverse('api-part-category-list'))
|
||||
|
||||
# actions should *not* contain 'POST' as we do not have the correct role
|
||||
@ -169,10 +159,7 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(loc['api_url'], reverse('api-location-list'))
|
||||
|
||||
def test_bom_item(self):
|
||||
"""
|
||||
Test the BomItem API OPTIONS endpoint
|
||||
"""
|
||||
|
||||
"""Test the BomItem API OPTIONS endpoint."""
|
||||
actions = self.getActions(reverse('api-bom-list'))['POST']
|
||||
|
||||
inherited = actions['inherited']
|
||||
@ -195,8 +182,8 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
|
||||
|
||||
class PartAPITest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Series of tests for the Part DRF API
|
||||
"""Series of tests for the Part DRF API.
|
||||
|
||||
- Tests for Part API
|
||||
- Tests for PartCategory API
|
||||
"""
|
||||
@ -218,67 +205,57 @@ 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.
|
||||
"""
|
||||
|
||||
"""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)
|
||||
|
||||
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',
|
||||
},
|
||||
format='json'
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
# Children of PartCategory<1>, cascade
|
||||
response = self.client.get(
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'parent': 1,
|
||||
'cascade': 'true',
|
||||
},
|
||||
format='json',
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# Children of PartCategory<1>, do not cascade
|
||||
response = self.client.get(
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'parent': 1,
|
||||
'cascade': 'false',
|
||||
},
|
||||
format='json',
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
def test_add_categories(self):
|
||||
""" Check that we can add categories """
|
||||
"""Check that we can add categories."""
|
||||
data = {
|
||||
'name': 'Animals',
|
||||
'description': 'All animals go here'
|
||||
}
|
||||
|
||||
url = reverse('api-part-category-list')
|
||||
response = self.client.post(url, data, format='json')
|
||||
response = self.post(url, data)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
parent = response.data['pk']
|
||||
@ -290,19 +267,20 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
'description': 'A sort of animal',
|
||||
'parent': parent,
|
||||
}
|
||||
response = self.client.post(url, data, format='json')
|
||||
response = self.post(url, data)
|
||||
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)
|
||||
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)
|
||||
|
||||
# Test that we have retrieved the category
|
||||
self.assertEqual(response.data['description'], 'Integrated Circuits')
|
||||
@ -313,22 +291,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)
|
||||
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)
|
||||
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)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
# There should only be 2 objects in category C
|
||||
@ -337,39 +315,62 @@ 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
|
||||
"""Test the special 'include_child_categories' flag.
|
||||
|
||||
If provided, parts are provided for ANY child category (recursive)
|
||||
"""
|
||||
url = reverse('api-part-list')
|
||||
data = {'category': 1, 'cascade': True}
|
||||
|
||||
# Now request to include child categories
|
||||
response = self.client.get(url, data, format='json')
|
||||
response = self.get(url, data)
|
||||
|
||||
# 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,
|
||||
@ -381,34 +382,32 @@ 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,
|
||||
'test_name': 'New Test',
|
||||
'required': True,
|
||||
'description': 'a test description'
|
||||
},
|
||||
format='json',
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
'test_name': " newtest",
|
||||
'description': 'dafsdf',
|
||||
},
|
||||
format='json',
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
@ -419,21 +418,15 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_get_thumbs(self):
|
||||
"""
|
||||
Return list of part thumbnails
|
||||
"""
|
||||
|
||||
"""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)
|
||||
|
||||
def test_paginate(self):
|
||||
"""
|
||||
Test pagination of the Part list API
|
||||
"""
|
||||
|
||||
"""Test pagination of the Part list API."""
|
||||
for n in [1, 5, 10]:
|
||||
response = self.get(reverse('api-part-list'), {'limit': n})
|
||||
|
||||
@ -445,16 +438,14 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(data['results']), n)
|
||||
|
||||
def test_default_values(self):
|
||||
"""
|
||||
Tests for 'default' values:
|
||||
"""Tests for 'default' values:
|
||||
|
||||
Ensure that unspecified fields revert to "default" values
|
||||
(as specified in the model field definition)
|
||||
"""
|
||||
|
||||
url = reverse('api-part-list')
|
||||
|
||||
response = self.client.post(url, {
|
||||
response = self.post(url, {
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part',
|
||||
'category': 1,
|
||||
@ -476,7 +467,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,
|
||||
@ -486,7 +477,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,
|
||||
@ -498,10 +489,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertFalse(response.data['purchaseable'])
|
||||
|
||||
def test_initial_stock(self):
|
||||
"""
|
||||
Tests for initial stock quantity creation
|
||||
"""
|
||||
|
||||
"""Tests for initial stock quantity creation."""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
# Track how many parts exist at the start of this test
|
||||
@ -555,10 +543,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(new_part.total_stock, 12345)
|
||||
|
||||
def test_initial_supplier_data(self):
|
||||
"""
|
||||
Tests for initial creation of supplier / manufacturer data
|
||||
"""
|
||||
|
||||
"""Tests for initial creation of supplier / manufacturer data."""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
n = Part.objects.count()
|
||||
@ -620,10 +605,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(new_part.manufacturer_parts.count(), 1)
|
||||
|
||||
def test_strange_chars(self):
|
||||
"""
|
||||
Test that non-standard ASCII chars are accepted
|
||||
"""
|
||||
|
||||
"""Test that non-standard ASCII chars are accepted."""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
name = "Kaltgerätestecker"
|
||||
@ -641,15 +623,13 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.data['description'], description)
|
||||
|
||||
def test_template_filters(self):
|
||||
"""
|
||||
Unit tests for API filters related to template parts:
|
||||
"""Unit tests for API filters related to template parts:
|
||||
|
||||
- variant_of : Return children of specified part
|
||||
- ancestor : Return descendants of specified part
|
||||
|
||||
Uses the 'chair template' part (pk=10000)
|
||||
"""
|
||||
|
||||
# Rebuild the MPTT structure before running these tests
|
||||
Part.objects.rebuild()
|
||||
|
||||
@ -732,11 +712,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(response.data), 101)
|
||||
|
||||
def test_variant_stock(self):
|
||||
"""
|
||||
Unit tests for the 'variant_stock' annotation,
|
||||
which provides a stock count for *variant* parts
|
||||
"""
|
||||
|
||||
"""Unit tests for the 'variant_stock' annotation, which provides a stock count for *variant* parts."""
|
||||
# Ensure the MPTT structure is in a known state before running tests
|
||||
Part.objects.rebuild()
|
||||
|
||||
@ -820,8 +796,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.data['variant_stock'], 500)
|
||||
|
||||
def test_part_download(self):
|
||||
"""Test download of part data via the API"""
|
||||
|
||||
"""Test download of part data via the API."""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
required_cols = [
|
||||
@ -873,9 +848,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
|
||||
|
||||
class PartDetailTests(InvenTreeAPITestCase):
|
||||
"""
|
||||
Test that we can create / edit / delete Part objects via the API
|
||||
"""
|
||||
"""Test that we can create / edit / delete Part objects via the API."""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -898,14 +871,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',
|
||||
@ -930,7 +901,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',
|
||||
})
|
||||
|
||||
@ -948,14 +919,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)
|
||||
@ -963,19 +934,16 @@ 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
|
||||
self.assertEqual(Part.objects.count(), n)
|
||||
|
||||
def test_duplicates(self):
|
||||
"""
|
||||
Check that trying to create 'duplicate' parts results in errors
|
||||
"""
|
||||
|
||||
"""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',
|
||||
@ -988,7 +956,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',
|
||||
@ -1011,7 +979,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',
|
||||
@ -1028,18 +996,17 @@ 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',
|
||||
},
|
||||
format='json',
|
||||
}
|
||||
)
|
||||
|
||||
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',
|
||||
@ -1049,14 +1016,11 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_image_upload(self):
|
||||
"""
|
||||
Test that we can upload an image to the part API
|
||||
"""
|
||||
|
||||
"""Test that we can upload an image to the part API."""
|
||||
self.assignRole('part.add')
|
||||
|
||||
# Create a new part
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
reverse('api-part-list'),
|
||||
{
|
||||
'name': 'imagine',
|
||||
@ -1120,10 +1084,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
self.assertIsNotNone(p.image)
|
||||
|
||||
def test_details(self):
|
||||
"""
|
||||
Test that the required details are available
|
||||
"""
|
||||
|
||||
"""Test that the required details are available."""
|
||||
p = Part.objects.get(pk=1)
|
||||
|
||||
url = reverse('api-part-detail', kwargs={'pk': 1})
|
||||
@ -1152,10 +1113,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
self.assertEqual(data['unallocated_stock'], 9000)
|
||||
|
||||
def test_part_metadata(self):
|
||||
"""
|
||||
Tests for the part metadata endpoint
|
||||
"""
|
||||
|
||||
"""Tests for the part metadata endpoint."""
|
||||
url = reverse('api-part-metadata', kwargs={'pk': 1})
|
||||
|
||||
part = Part.objects.get(pk=1)
|
||||
@ -1206,9 +1164,7 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
|
||||
|
||||
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Tests to ensure that the various aggregation annotations are working correctly...
|
||||
"""
|
||||
"""Tests to ensure that the various aggregation annotations are working correctly..."""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -1229,7 +1185,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
|
||||
@ -1253,9 +1209,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)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@ -1267,10 +1224,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
self.assertTrue(False) # pragma: no cover
|
||||
|
||||
def test_stock_quantity(self):
|
||||
"""
|
||||
Simple test for the stock quantity
|
||||
"""
|
||||
|
||||
"""Simple test for the stock quantity."""
|
||||
data = self.get_part_data()
|
||||
|
||||
self.assertEqual(data['in_stock'], 600)
|
||||
@ -1290,11 +1244,10 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(data['stock_item_count'], 105)
|
||||
|
||||
def test_allocation_annotations(self):
|
||||
"""
|
||||
Tests for query annotations which add allocation information.
|
||||
"""Tests for query annotations which add allocation information.
|
||||
|
||||
Ref: https://github.com/inventree/InvenTree/pull/2797
|
||||
"""
|
||||
|
||||
# We are looking at Part ID 100 ("Bob")
|
||||
url = reverse('api-part-detail', kwargs={'pk': 100})
|
||||
|
||||
@ -1438,9 +1391,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
|
||||
|
||||
class BomItemTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Unit tests for the BomItem API
|
||||
"""
|
||||
"""Unit tests for the BomItem API."""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -1457,14 +1408,8 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
'part.delete',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
def test_bom_list(self):
|
||||
"""
|
||||
Tests for the BomItem list endpoint
|
||||
"""
|
||||
|
||||
"""Tests for the BomItem list endpoint."""
|
||||
# How many BOM items currently exist in the database?
|
||||
n = BomItem.objects.count()
|
||||
|
||||
@ -1529,10 +1474,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
self.assertTrue(key in el)
|
||||
|
||||
def test_get_bom_detail(self):
|
||||
"""
|
||||
Get the detail view for a single BomItem object
|
||||
"""
|
||||
|
||||
"""Get the detail view for a single BomItem object."""
|
||||
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
@ -1570,10 +1512,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.data['note'], 'Added a note')
|
||||
|
||||
def test_add_bom_item(self):
|
||||
"""
|
||||
Test that we can create a new BomItem via the API
|
||||
"""
|
||||
|
||||
"""Test that we can create a new BomItem via the API."""
|
||||
url = reverse('api-bom-list')
|
||||
|
||||
data = {
|
||||
@ -1587,13 +1526,10 @@ 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
|
||||
"""
|
||||
|
||||
"""Tests for BomItem use with variants."""
|
||||
stock_url = reverse('api-stock-list')
|
||||
|
||||
# BOM item we are interested in
|
||||
@ -1675,10 +1611,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
def test_substitutes(self):
|
||||
"""
|
||||
Tests for BomItem substitutes
|
||||
"""
|
||||
|
||||
"""Tests for BomItem substitutes."""
|
||||
url = reverse('api-bom-substitute-list')
|
||||
stock_url = reverse('api-stock-list')
|
||||
|
||||
@ -1760,10 +1693,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(data['available_stock'], 9000)
|
||||
|
||||
def test_bom_item_uses(self):
|
||||
"""
|
||||
Tests for the 'uses' field
|
||||
"""
|
||||
|
||||
"""Tests for the 'uses' field."""
|
||||
url = reverse('api-bom-list')
|
||||
|
||||
# Test that the direct 'sub_part' association works
|
||||
@ -1813,10 +1743,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(response.data), i)
|
||||
|
||||
def test_bom_variant_stock(self):
|
||||
"""
|
||||
Test for 'available_variant_stock' annotation
|
||||
"""
|
||||
|
||||
"""Test for 'available_variant_stock' annotation."""
|
||||
Part.objects.rebuild()
|
||||
|
||||
# BOM item we are interested in
|
||||
@ -1852,10 +1779,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
|
||||
|
||||
class PartParameterTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Tests for the ParParameter API
|
||||
"""
|
||||
|
||||
"""Tests for the ParParameter API."""
|
||||
superuser = True
|
||||
|
||||
fixtures = [
|
||||
@ -1865,51 +1789,39 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
'params',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
def test_list_params(self):
|
||||
"""
|
||||
Test for listing part parameters
|
||||
"""
|
||||
|
||||
"""Test for listing part parameters."""
|
||||
url = reverse('api-part-parameter-list')
|
||||
|
||||
response = self.client.get(url, format='json')
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# Filter by part
|
||||
response = self.client.get(
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'part': 3,
|
||||
},
|
||||
format='json'
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
# Filter by template
|
||||
response = self.client.get(
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'template': 1,
|
||||
},
|
||||
format='json',
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
def test_create_param(self):
|
||||
"""
|
||||
Test that we can create a param via the API
|
||||
"""
|
||||
|
||||
"""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',
|
||||
@ -1920,18 +1832,15 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
response = self.client.get(url, format='json')
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(len(response.data), 6)
|
||||
|
||||
def test_param_detail(self):
|
||||
"""
|
||||
Tests for the PartParameter detail endpoint
|
||||
"""
|
||||
|
||||
"""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)
|
||||
|
||||
@ -1942,12 +1851,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'})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check that the data changed!
|
||||
response = self.client.get(url, format='json')
|
||||
response = self.get(url)
|
||||
|
||||
data = response.data
|
||||
|
||||
|
@ -1,6 +1,4 @@
|
||||
"""
|
||||
Unit testing for BOM export functionality
|
||||
"""
|
||||
"""Unit testing for BOM export functionality."""
|
||||
|
||||
import csv
|
||||
|
||||
@ -10,6 +8,7 @@ from InvenTree.helpers import InvenTreeTestCase
|
||||
|
||||
|
||||
class BomExportTest(InvenTreeTestCase):
|
||||
"""Class for performing unit testing of BOM export functionality"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -21,15 +20,13 @@ class BomExportTest(InvenTreeTestCase):
|
||||
roles = 'all'
|
||||
|
||||
def setUp(self):
|
||||
"""Perform test setup functions"""
|
||||
super().setUp()
|
||||
|
||||
self.url = reverse('bom-download', kwargs={'pk': 100})
|
||||
|
||||
def test_bom_template(self):
|
||||
"""
|
||||
Test that the BOM template can be downloaded from the server
|
||||
"""
|
||||
|
||||
"""Test that the BOM template can be downloaded from the server."""
|
||||
url = reverse('bom-upload-template')
|
||||
|
||||
# Download an XLS template
|
||||
@ -78,10 +75,7 @@ class BomExportTest(InvenTreeTestCase):
|
||||
self.assertTrue(header in headers)
|
||||
|
||||
def test_export_csv(self):
|
||||
"""
|
||||
Test BOM download in CSV format
|
||||
"""
|
||||
|
||||
"""Test BOM download in CSV format."""
|
||||
params = {
|
||||
'format': 'csv',
|
||||
'cascade': True,
|
||||
@ -142,10 +136,7 @@ class BomExportTest(InvenTreeTestCase):
|
||||
self.assertTrue(header in expected)
|
||||
|
||||
def test_export_xls(self):
|
||||
"""
|
||||
Test BOM download in XLS format
|
||||
"""
|
||||
|
||||
"""Test BOM download in XLS format."""
|
||||
params = {
|
||||
'format': 'xls',
|
||||
'cascade': True,
|
||||
@ -163,10 +154,7 @@ class BomExportTest(InvenTreeTestCase):
|
||||
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.xls"')
|
||||
|
||||
def test_export_xlsx(self):
|
||||
"""
|
||||
Test BOM download in XLSX format
|
||||
"""
|
||||
|
||||
"""Test BOM download in XLSX format."""
|
||||
params = {
|
||||
'format': 'xlsx',
|
||||
'cascade': True,
|
||||
@ -181,10 +169,7 @@ class BomExportTest(InvenTreeTestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_export_json(self):
|
||||
"""
|
||||
Test BOM download in JSON format
|
||||
"""
|
||||
|
||||
"""Test BOM download in JSON format."""
|
||||
params = {
|
||||
'format': 'json',
|
||||
'cascade': True,
|
||||
|
@ -1,6 +1,4 @@
|
||||
"""
|
||||
Unit testing for BOM upload / import functionality
|
||||
"""
|
||||
"""Unit testing for BOM upload / import functionality."""
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
@ -12,9 +10,7 @@ from part.models import Part
|
||||
|
||||
|
||||
class BomUploadTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Test BOM file upload API endpoint
|
||||
"""
|
||||
"""Test BOM file upload API endpoint."""
|
||||
|
||||
roles = [
|
||||
'part.add',
|
||||
@ -22,6 +18,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
"""Create BOM data as part of setup routine"""
|
||||
super().setUp()
|
||||
|
||||
self.part = Part.objects.create(
|
||||
@ -41,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,
|
||||
@ -63,10 +60,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
||||
return response
|
||||
|
||||
def test_missing_file(self):
|
||||
"""
|
||||
POST without a file
|
||||
"""
|
||||
|
||||
"""POST without a file."""
|
||||
response = self.post(
|
||||
reverse('api-bom-import-upload'),
|
||||
data={},
|
||||
@ -76,10 +70,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
||||
self.assertIn('No file was submitted', str(response.data['data_file']))
|
||||
|
||||
def test_unsupported_file(self):
|
||||
"""
|
||||
POST with an unsupported file type
|
||||
"""
|
||||
|
||||
"""POST with an unsupported file type."""
|
||||
response = self.post_bom(
|
||||
'sample.txt',
|
||||
b'hello world',
|
||||
@ -89,10 +80,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
||||
self.assertIn('Unsupported file type', str(response.data['data_file']))
|
||||
|
||||
def test_broken_file(self):
|
||||
"""
|
||||
Test upload with broken (corrupted) files
|
||||
"""
|
||||
|
||||
"""Test upload with broken (corrupted) files."""
|
||||
response = self.post_bom(
|
||||
'sample.csv',
|
||||
b'',
|
||||
@ -111,10 +99,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
||||
self.assertIn('Unsupported format, or corrupt file', str(response.data['data_file']))
|
||||
|
||||
def test_missing_rows(self):
|
||||
"""
|
||||
Test upload of an invalid file (without data rows)
|
||||
"""
|
||||
|
||||
"""Test upload of an invalid file (without data rows)"""
|
||||
dataset = tablib.Dataset()
|
||||
|
||||
dataset.headers = [
|
||||
@ -142,10 +127,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
||||
self.assertIn('No data rows found in file', str(response.data))
|
||||
|
||||
def test_missing_columns(self):
|
||||
"""
|
||||
Upload extracted data, but with missing columns
|
||||
"""
|
||||
|
||||
"""Upload extracted data, but with missing columns."""
|
||||
url = reverse('api-bom-import-extract')
|
||||
|
||||
rows = [
|
||||
@ -195,10 +177,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
def test_invalid_data(self):
|
||||
"""
|
||||
Upload data which contains errors
|
||||
"""
|
||||
|
||||
"""Upload data which contains errors."""
|
||||
dataset = tablib.Dataset()
|
||||
|
||||
# Only these headers are strictly necessary
|
||||
@ -241,10 +220,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(rows[5]['data']['errors']['part'], 'Part is not designated as a component')
|
||||
|
||||
def test_part_guess(self):
|
||||
"""
|
||||
Test part 'guessing' when PK values are not supplied
|
||||
"""
|
||||
|
||||
"""Test part 'guessing' when PK values are not supplied."""
|
||||
dataset = tablib.Dataset()
|
||||
|
||||
# Should be able to 'guess' the part from the name
|
||||
@ -304,10 +280,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(rows[idx]['data']['part'], components[idx].pk)
|
||||
|
||||
def test_levels(self):
|
||||
"""
|
||||
Test that multi-level BOMs are correctly handled during upload
|
||||
"""
|
||||
|
||||
"""Test that multi-level BOMs are correctly handled during upload."""
|
||||
url = reverse('api-bom-import-extract')
|
||||
|
||||
dataset = tablib.Dataset()
|
||||
|
@ -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,22 +51,19 @@ 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)
|
||||
|
||||
def test_self_reference(self):
|
||||
""" Test that we get an appropriate error when we create a BomItem which points to itself """
|
||||
|
||||
"""Test that we get an appropriate error when we create a BomItem which points to itself."""
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
# A validation error should be raised here
|
||||
item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7)
|
||||
item.clean() # pragma: no cover
|
||||
|
||||
def test_integer_quantity(self):
|
||||
"""
|
||||
Test integer validation for BomItem
|
||||
"""
|
||||
|
||||
"""Test integer validation for BomItem."""
|
||||
p = Part.objects.create(name="test", description="d", component=True, trackable=True)
|
||||
|
||||
# Creation of a BOMItem with a non-integer quantity of a trackable Part should fail
|
||||
@ -70,8 +74,7 @@ class BomItemTest(TestCase):
|
||||
BomItem.objects.create(part=self.bob, sub_part=p, quantity=21)
|
||||
|
||||
def test_overage(self):
|
||||
""" Test that BOM line overages are calculated correctly """
|
||||
|
||||
"""Test that BOM line overages are calculated correctly."""
|
||||
item = BomItem.objects.get(part=100, sub_part=50)
|
||||
|
||||
q = 300
|
||||
@ -106,8 +109,7 @@ class BomItemTest(TestCase):
|
||||
self.assertEqual(n, 3150)
|
||||
|
||||
def test_item_hash(self):
|
||||
""" Test BOM item hash encoding """
|
||||
|
||||
"""Test BOM item hash encoding."""
|
||||
item = BomItem.objects.get(part=100, sub_part=50)
|
||||
|
||||
h1 = item.get_item_hash()
|
||||
@ -122,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),
|
||||
@ -135,10 +138,7 @@ class BomItemTest(TestCase):
|
||||
)
|
||||
|
||||
def test_substitutes(self):
|
||||
"""
|
||||
Tests for BOM item substitutes
|
||||
"""
|
||||
|
||||
"""Tests for BOM item substitutes."""
|
||||
# We will make some subtitute parts for the "orphan" part
|
||||
bom_item = BomItem.objects.get(
|
||||
part=self.bob,
|
||||
|
@ -1,3 +1,5 @@
|
||||
"""Unit tests for the PartCategory model"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
@ -5,8 +7,7 @@ from .models import Part, PartCategory, PartParameter, PartParameterTemplate
|
||||
|
||||
|
||||
class CategoryTest(TestCase):
|
||||
"""
|
||||
Tests to ensure that the relational category tree functions correctly.
|
||||
"""Tests to ensure that the relational category tree functions correctly.
|
||||
|
||||
Loads the following test fixtures:
|
||||
- category.yaml
|
||||
@ -19,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')
|
||||
@ -29,9 +30,7 @@ class CategoryTest(TestCase):
|
||||
self.transceivers = PartCategory.objects.get(name='Transceivers')
|
||||
|
||||
def test_parents(self):
|
||||
""" Test that the parent fields are properly set,
|
||||
based on the test fixtures """
|
||||
|
||||
"""Test that the parent fields are properly set, based on the test fixtures."""
|
||||
self.assertEqual(self.resistors.parent, self.electronics)
|
||||
self.assertEqual(self.capacitors.parent, self.electronics)
|
||||
self.assertEqual(self.electronics.parent, None)
|
||||
@ -39,8 +38,7 @@ class CategoryTest(TestCase):
|
||||
self.assertEqual(self.fasteners.parent, self.mechanical)
|
||||
|
||||
def test_children_count(self):
|
||||
""" Test that categories have the correct number of children """
|
||||
|
||||
"""Test that categories have the correct number of children."""
|
||||
self.assertTrue(self.electronics.has_children)
|
||||
self.assertTrue(self.mechanical.has_children)
|
||||
|
||||
@ -48,8 +46,7 @@ class CategoryTest(TestCase):
|
||||
self.assertEqual(len(self.mechanical.children.all()), 1)
|
||||
|
||||
def test_unique_childs(self):
|
||||
""" Test the 'unique_children' functionality """
|
||||
|
||||
"""Test the 'unique_children' functionality."""
|
||||
childs = [item.pk for item in self.electronics.getUniqueChildren()]
|
||||
|
||||
self.assertIn(self.transceivers.id, childs)
|
||||
@ -58,8 +55,7 @@ class CategoryTest(TestCase):
|
||||
self.assertNotIn(self.fasteners.id, childs)
|
||||
|
||||
def test_unique_parents(self):
|
||||
""" Test the 'unique_parents' functionality """
|
||||
|
||||
"""Test the 'unique_parents' functionality."""
|
||||
parents = [item.pk for item in self.transceivers.getUniqueParents()]
|
||||
|
||||
self.assertIn(self.electronics.id, parents)
|
||||
@ -67,22 +63,16 @@ class CategoryTest(TestCase):
|
||||
self.assertNotIn(self.fasteners.id, parents)
|
||||
|
||||
def test_path_string(self):
|
||||
""" Test that the category path string works correctly """
|
||||
|
||||
"""Test that the category path string works correctly."""
|
||||
self.assertEqual(str(self.resistors), 'Electronics/Resistors - Resistors')
|
||||
self.assertEqual(str(self.transceivers.pathstring), 'Electronics/IC/Transceivers')
|
||||
|
||||
def test_url(self):
|
||||
""" Test that the PartCategory URL works """
|
||||
|
||||
"""Test that the PartCategory URL works."""
|
||||
self.assertEqual(self.capacitors.get_absolute_url(), '/part/category/3/')
|
||||
|
||||
def test_part_count(self):
|
||||
""" Test that the Category part count works """
|
||||
|
||||
self.assertTrue(self.resistors.has_parts)
|
||||
self.assertTrue(self.fasteners.has_parts)
|
||||
self.assertFalse(self.transceivers.has_parts)
|
||||
"""Test that the Category part count works."""
|
||||
|
||||
self.assertEqual(self.fasteners.partcount(), 2)
|
||||
self.assertEqual(self.capacitors.partcount(), 1)
|
||||
@ -96,8 +86,7 @@ class CategoryTest(TestCase):
|
||||
self.assertEqual(self.electronics.item_count, self.electronics.partcount())
|
||||
|
||||
def test_parameters(self):
|
||||
""" Test that the Category parameters are correctly fetched """
|
||||
|
||||
"""Test that the Category parameters are correctly fetched."""
|
||||
# Check number of SQL queries to iterate other parameters
|
||||
with self.assertNumQueries(7):
|
||||
# Prefetch: 3 queries (parts, parameters and parameters_template)
|
||||
@ -121,8 +110,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:
|
||||
@ -135,8 +123,7 @@ class CategoryTest(TestCase):
|
||||
cat.save()
|
||||
|
||||
def test_delete(self):
|
||||
""" Test that category deletion moves the children properly """
|
||||
|
||||
"""Test that category deletion moves the children properly."""
|
||||
# Delete the 'IC' category and 'Transceiver' should move to be under 'Electronics'
|
||||
self.assertEqual(self.transceivers.parent, self.ic)
|
||||
self.assertEqual(self.ic.parent, self.electronics)
|
||||
@ -156,8 +143,7 @@ class CategoryTest(TestCase):
|
||||
self.assertEqual(f.category, self.mechanical)
|
||||
|
||||
def test_default_locations(self):
|
||||
""" Test traversal for default locations """
|
||||
|
||||
"""Test traversal for default locations."""
|
||||
self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer_1 - In my desk')
|
||||
|
||||
# Any part under electronics should default to 'Home'
|
||||
@ -174,12 +160,11 @@ class CategoryTest(TestCase):
|
||||
self.assertIsNone(w.get_default_location())
|
||||
|
||||
def test_category_tree(self):
|
||||
"""
|
||||
Unit tests for the part category tree structure (MPTT)
|
||||
"""Unit tests for the part category tree structure (MPTT)
|
||||
|
||||
Ensure that the MPTT structure is rebuilt correctly,
|
||||
and the correct ancestor tree is observed.
|
||||
"""
|
||||
|
||||
# Clear out any existing parts
|
||||
Part.objects.all().delete()
|
||||
|
||||
|
@ -1,6 +1,4 @@
|
||||
"""
|
||||
Unit tests for the part model database migrations
|
||||
"""
|
||||
"""Unit tests for the part model database migrations."""
|
||||
|
||||
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||
|
||||
@ -8,18 +6,13 @@ from InvenTree import helpers
|
||||
|
||||
|
||||
class TestForwardMigrations(MigratorTestCase):
|
||||
"""
|
||||
Test entire schema migration sequence for the part app
|
||||
"""
|
||||
"""Test entire schema migration sequence for the part app."""
|
||||
|
||||
migrate_from = ('part', helpers.getOldestMigrationFile('part'))
|
||||
migrate_to = ('part', helpers.getNewestMigrationFile('part'))
|
||||
|
||||
def prepare(self):
|
||||
"""
|
||||
Create initial data
|
||||
"""
|
||||
|
||||
"""Create initial data."""
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
|
||||
Part.objects.create(name='A', description='My part A')
|
||||
@ -39,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)
|
||||
@ -49,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})
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Tests for the Part model
|
||||
"""Tests for the Part model."""
|
||||
|
||||
import os
|
||||
|
||||
@ -21,42 +21,45 @@ from .templatetags import inventree_extras
|
||||
|
||||
|
||||
class TemplateTagTest(InvenTreeTestCase):
|
||||
""" Tests for the custom template tag code """
|
||||
"""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)
|
||||
@ -112,7 +123,7 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
|
||||
|
||||
class PartTest(TestCase):
|
||||
""" Tests for the Part model """
|
||||
"""Tests for the Part model."""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -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,14 +156,12 @@ 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_duplicate(self):
|
||||
"""
|
||||
Test that we cannot create a "duplicate" Part
|
||||
"""
|
||||
|
||||
"""Test that we cannot create a "duplicate" Part."""
|
||||
n = Part.objects.count()
|
||||
|
||||
cat = PartCategory.objects.get(pk=1)
|
||||
@ -201,10 +213,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')
|
||||
@ -212,26 +226,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
|
||||
@ -239,7 +256,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
|
||||
@ -247,8 +264,7 @@ class PartTest(TestCase):
|
||||
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
|
||||
|
||||
def test_metadata(self):
|
||||
"""Unit tests for the Part metadata field"""
|
||||
|
||||
"""Unit tests for the Part metadata field."""
|
||||
p = Part.objects.get(pk=1)
|
||||
self.assertIsNone(p.metadata)
|
||||
|
||||
@ -266,6 +282,7 @@ class PartTest(TestCase):
|
||||
|
||||
|
||||
class TestTemplateTest(TestCase):
|
||||
"""Unit test for the TestTemplate class"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -275,7 +292,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!)
|
||||
@ -292,8 +309,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):
|
||||
@ -324,17 +340,13 @@ class TestTemplateTest(TestCase):
|
||||
|
||||
|
||||
class PartSettingsTest(InvenTreeTestCase):
|
||||
"""
|
||||
Tests to ensure that the user-configurable default values work as expected.
|
||||
"""Tests to ensure that the user-configurable default values work as expected.
|
||||
|
||||
Some fields for the Part model can have default values specified by the user.
|
||||
"""
|
||||
|
||||
def make_part(self):
|
||||
"""
|
||||
Helper function to create a simple part
|
||||
"""
|
||||
|
||||
"""Helper function to create a simple part."""
|
||||
part = Part.objects.create(
|
||||
name='Test Part',
|
||||
description='I am but a humble test part',
|
||||
@ -344,20 +356,14 @@ class PartSettingsTest(InvenTreeTestCase):
|
||||
return part
|
||||
|
||||
def test_defaults(self):
|
||||
"""
|
||||
Test that the default values for the part settings are correct
|
||||
"""
|
||||
|
||||
"""Test that the default values for the part settings are correct."""
|
||||
self.assertTrue(part.settings.part_component_default())
|
||||
self.assertTrue(part.settings.part_purchaseable_default())
|
||||
self.assertFalse(part.settings.part_salable_default())
|
||||
self.assertFalse(part.settings.part_trackable_default())
|
||||
|
||||
def test_initial(self):
|
||||
"""
|
||||
Test the 'initial' default values (no default values have been set)
|
||||
"""
|
||||
|
||||
"""Test the 'initial' default values (no default values have been set)"""
|
||||
part = self.make_part()
|
||||
|
||||
self.assertTrue(part.component)
|
||||
@ -366,10 +372,7 @@ class PartSettingsTest(InvenTreeTestCase):
|
||||
self.assertFalse(part.trackable)
|
||||
|
||||
def test_custom(self):
|
||||
"""
|
||||
Update some of the part values and re-test
|
||||
"""
|
||||
|
||||
"""Update some of the part values and re-test."""
|
||||
for val in [True, False]:
|
||||
InvenTreeSetting.set_setting('PART_COMPONENT', val, self.user)
|
||||
InvenTreeSetting.set_setting('PART_PURCHASEABLE', val, self.user)
|
||||
@ -395,10 +398,7 @@ class PartSettingsTest(InvenTreeTestCase):
|
||||
Part.objects.filter(pk=part.pk).delete()
|
||||
|
||||
def test_duplicate_ipn(self):
|
||||
"""
|
||||
Test the setting which controls duplicate IPN values
|
||||
"""
|
||||
|
||||
"""Test the setting which controls duplicate IPN values."""
|
||||
# Create a part
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='A')
|
||||
|
||||
@ -444,6 +444,7 @@ class PartSettingsTest(InvenTreeTestCase):
|
||||
|
||||
|
||||
class PartSubscriptionTests(InvenTreeTestCase):
|
||||
"""Unit tests for part 'subscription'"""
|
||||
|
||||
fixtures = [
|
||||
'location',
|
||||
@ -452,6 +453,7 @@ class PartSubscriptionTests(InvenTreeTestCase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
"""Create category and part data as part of setup routine"""
|
||||
super().setUp()
|
||||
|
||||
# electronics / IC / MCU
|
||||
@ -465,10 +467,7 @@ class PartSubscriptionTests(InvenTreeTestCase):
|
||||
)
|
||||
|
||||
def test_part_subcription(self):
|
||||
"""
|
||||
Test basic subscription against a part
|
||||
"""
|
||||
|
||||
"""Test basic subscription against a part."""
|
||||
# First check that the user is *not* subscribed to the part
|
||||
self.assertFalse(self.part.is_starred_by(self.user))
|
||||
|
||||
@ -485,10 +484,7 @@ class PartSubscriptionTests(InvenTreeTestCase):
|
||||
self.assertFalse(self.part.is_starred_by(self.user))
|
||||
|
||||
def test_variant_subscription(self):
|
||||
"""
|
||||
Test subscription against a parent part
|
||||
"""
|
||||
|
||||
"""Test subscription against a parent part."""
|
||||
# Construct a sub-part to star against
|
||||
sub_part = Part.objects.create(
|
||||
name='sub_part',
|
||||
@ -505,10 +501,7 @@ class PartSubscriptionTests(InvenTreeTestCase):
|
||||
self.assertTrue(sub_part.is_starred_by(self.user))
|
||||
|
||||
def test_category_subscription(self):
|
||||
"""
|
||||
Test subscription against a PartCategory
|
||||
"""
|
||||
|
||||
"""Test subscription against a PartCategory."""
|
||||
self.assertEqual(PartCategoryStar.objects.count(), 0)
|
||||
|
||||
self.assertFalse(self.part.is_starred_by(self.user))
|
||||
@ -533,10 +526,7 @@ class PartSubscriptionTests(InvenTreeTestCase):
|
||||
self.assertFalse(self.part.is_starred_by(self.user))
|
||||
|
||||
def test_parent_category_subscription(self):
|
||||
"""
|
||||
Check that a parent category can be subscribed to
|
||||
"""
|
||||
|
||||
"""Check that a parent category can be subscribed to."""
|
||||
# Top-level "electronics" category
|
||||
cat = PartCategory.objects.get(pk=1)
|
||||
|
||||
@ -553,7 +543,7 @@ class PartSubscriptionTests(InvenTreeTestCase):
|
||||
|
||||
|
||||
class BaseNotificationIntegrationTest(InvenTreeTestCase):
|
||||
""" Integration test for notifications """
|
||||
"""Integration test for notifications."""
|
||||
|
||||
fixtures = [
|
||||
'location',
|
||||
@ -563,6 +553,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')
|
||||
@ -571,8 +562,8 @@ class BaseNotificationIntegrationTest(InvenTreeTestCase):
|
||||
self.part = Part.objects.get(name='R_2K2_0805')
|
||||
|
||||
def _notification_run(self, run_class=None):
|
||||
"""
|
||||
Run a notification test suit through.
|
||||
"""Run a notification test suit through.
|
||||
|
||||
If you only want to test one class pass it to run_class
|
||||
"""
|
||||
# reload notification methods
|
||||
@ -597,9 +588,10 @@ class BaseNotificationIntegrationTest(InvenTreeTestCase):
|
||||
|
||||
|
||||
class PartNotificationTest(BaseNotificationIntegrationTest):
|
||||
""" Integration test for part notifications """
|
||||
"""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
|
||||
|
@ -1,4 +1,4 @@
|
||||
""" Unit tests for Part Views (see views.py) """
|
||||
"""Unit tests for Part Views (see views.py)"""
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
@ -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,10 +38,10 @@ 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 """
|
||||
|
||||
"""Test that we can retrieve a part detail page."""
|
||||
pk = 1
|
||||
|
||||
response = self.client.get(reverse('part-detail', args=(pk,)))
|
||||
@ -58,8 +58,8 @@ class PartDetailTest(PartViewTestCase):
|
||||
self.assertEqual(response.context['category'], part.category)
|
||||
|
||||
def test_part_detail_from_ipn(self):
|
||||
"""
|
||||
Test that we can retrieve a part detail page from part IPN:
|
||||
"""Test that we can retrieve a part detail page from part IPN:
|
||||
|
||||
- if no part with matching IPN -> return part index
|
||||
- if unique IPN match -> return part detail page
|
||||
- if multiple IPN matches -> return part index
|
||||
@ -68,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
|
||||
|
||||
@ -108,22 +109,22 @@ class PartDetailTest(PartViewTestCase):
|
||||
test_ipn_match(index_result=True, detail_result=False)
|
||||
|
||||
def test_bom_download(self):
|
||||
""" Test downloading a BOM for a valid part """
|
||||
|
||||
"""Test downloading a BOM for a valid part."""
|
||||
response = self.client.get(reverse('bom-download', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('streaming_content', dir(response))
|
||||
|
||||
|
||||
class PartQRTest(PartViewTestCase):
|
||||
""" Tests for the Part QR Code AJAX view """
|
||||
"""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)
|
||||
|
||||
@ -133,17 +134,17 @@ 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)
|
||||
|
||||
|
||||
class CategoryTest(PartViewTestCase):
|
||||
""" Tests for PartCategory related views """
|
||||
"""Tests for PartCategory related views."""
|
||||
|
||||
def test_set_category(self):
|
||||
""" Test that the "SetCategory" view works """
|
||||
|
||||
"""Test that the "SetCategory" view works."""
|
||||
url = reverse('part-set-category')
|
||||
|
||||
response = self.client.get(url, {'parts[]': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
@ -1,11 +1,9 @@
|
||||
"""
|
||||
URL lookup for Part app. Provides URL endpoints for:
|
||||
"""URL lookup for Part app. Provides URL endpoints for:
|
||||
|
||||
- Display / Create / Edit / Delete PartCategory
|
||||
- Display / Create / Edit / Delete Part
|
||||
- Create / Edit / Delete PartAttachment
|
||||
- Display / Create / Edit / Delete SupplierPart
|
||||
|
||||
"""
|
||||
|
||||
from django.urls import include, re_path
|
||||
|
@ -1,6 +1,4 @@
|
||||
"""
|
||||
Django views for interacting with Part app
|
||||
"""
|
||||
"""Django views for interacting with Part app."""
|
||||
|
||||
import io
|
||||
import os
|
||||
@ -43,18 +41,23 @@ from .models import (Part, PartCategory, PartCategoryParameterTemplate,
|
||||
|
||||
|
||||
class PartIndex(InvenTreeRoleMixin, ListView):
|
||||
""" View for displaying list of Part objects
|
||||
"""
|
||||
"""View for displaying list of Part objects."""
|
||||
|
||||
model = Part
|
||||
template_name = 'part/category.html'
|
||||
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
|
||||
@ -68,7 +71,7 @@ class PartIndex(InvenTreeRoleMixin, ListView):
|
||||
|
||||
|
||||
class PartSetCategory(AjaxUpdateView):
|
||||
""" View for settings the part category for multiple parts at once """
|
||||
"""View for settings the part category for multiple parts at once."""
|
||||
|
||||
ajax_template_name = 'part/set_category.html'
|
||||
ajax_form_title = _('Set Part Category')
|
||||
@ -80,8 +83,7 @@ class PartSetCategory(AjaxUpdateView):
|
||||
parts = []
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
""" Respond to a GET request to this view """
|
||||
|
||||
"""Respond to a GET request to this view."""
|
||||
self.request = request
|
||||
|
||||
if 'parts[]' in request.GET:
|
||||
@ -92,8 +94,7 @@ class PartSetCategory(AjaxUpdateView):
|
||||
return self.renderJsonResponse(request, form=self.get_form(), context=self.get_context_data())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
""" Respond to a POST request to this view """
|
||||
|
||||
"""Respond to a POST request to this view."""
|
||||
self.parts = []
|
||||
|
||||
for item in request.POST:
|
||||
@ -125,17 +126,15 @@ 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 """
|
||||
"""Return context data for rendering in the form."""
|
||||
ctx = {}
|
||||
|
||||
ctx['parts'] = self.parts
|
||||
@ -146,10 +145,11 @@ class PartSetCategory(AjaxUpdateView):
|
||||
|
||||
|
||||
class PartImport(FileManagementFormView):
|
||||
''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
|
||||
"""Part: Upload file, match to fields and import parts(using multi-Step form)"""
|
||||
permission_required = 'part.add'
|
||||
|
||||
class PartFileManager(FileManager):
|
||||
"""Import field definitions"""
|
||||
REQUIRED_HEADERS = [
|
||||
'Name',
|
||||
'Description',
|
||||
@ -226,7 +226,7 @@ class PartImport(FileManagementFormView):
|
||||
file_manager_class = PartFileManager
|
||||
|
||||
def get_field_selection(self):
|
||||
""" Fill the form fields for step 3 """
|
||||
"""Fill the form fields for step 3."""
|
||||
# fetch available elements
|
||||
self.allowed_items = {}
|
||||
self.matches = {}
|
||||
@ -269,7 +269,7 @@ class PartImport(FileManagementFormView):
|
||||
row[idx.lower()] = data
|
||||
|
||||
def done(self, form_list, **kwargs):
|
||||
""" Create items """
|
||||
"""Create items."""
|
||||
items = self.get_clean_items()
|
||||
|
||||
import_done = 0
|
||||
@ -343,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',
|
||||
@ -350,12 +351,12 @@ 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)
|
||||
|
||||
|
||||
class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
""" Detail view for Part object
|
||||
"""
|
||||
"""Detail view for Part object."""
|
||||
|
||||
context_object_name = 'part'
|
||||
queryset = Part.objects.all().select_related('category')
|
||||
@ -364,9 +365,7 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
|
||||
# Add in some extra context information based on query params
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
Provide extra context data to template
|
||||
"""
|
||||
"""Provide extra context data to template."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
part = self.get_object()
|
||||
@ -389,14 +388,15 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
return context
|
||||
|
||||
def get_quantity(self):
|
||||
""" Return set quantity in decimal format """
|
||||
"""Return set quantity in decimal format."""
|
||||
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):
|
||||
""" returns context with pricing information """
|
||||
"""Returns context with pricing information."""
|
||||
ctx = PartPricing.get_pricing(self, quantity, currency)
|
||||
part = self.get_part()
|
||||
default_currency = inventree_settings.currency_code_default()
|
||||
@ -503,10 +503,11 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
return ctx
|
||||
|
||||
def get_initials(self):
|
||||
""" returns initials for form """
|
||||
"""Returns initials for form."""
|
||||
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)
|
||||
@ -514,11 +515,13 @@ 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'
|
||||
|
||||
def get_object(self):
|
||||
""" Return Part object which IPN field matches the slug value """
|
||||
"""Return Part object which IPN field matches the slug value."""
|
||||
queryset = self.get_queryset()
|
||||
# Get slug
|
||||
slug = self.kwargs.get(self.slug_url_kwarg)
|
||||
@ -541,7 +544,7 @@ class PartDetailFromIPN(PartDetail):
|
||||
return None
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
""" Attempt to match slug to a Part, else redirect to PartIndex view """
|
||||
"""Attempt to match slug to a Part, else redirect to PartIndex view."""
|
||||
self.object = self.get_object()
|
||||
|
||||
if not self.object:
|
||||
@ -551,15 +554,14 @@ class PartDetailFromIPN(PartDetail):
|
||||
|
||||
|
||||
class PartQRCode(QRCodeView):
|
||||
""" View for displaying a QR code for a Part object """
|
||||
"""View for displaying a QR code for a Part object."""
|
||||
|
||||
ajax_form_title = _("Part QR Code")
|
||||
|
||||
role_required = 'part.view'
|
||||
|
||||
def get_qr_data(self):
|
||||
""" Generate QR code data for the Part """
|
||||
|
||||
"""Generate QR code data for the Part."""
|
||||
try:
|
||||
part = Part.objects.get(id=self.pk)
|
||||
return part.format_barcode()
|
||||
@ -568,9 +570,7 @@ class PartQRCode(QRCodeView):
|
||||
|
||||
|
||||
class PartImageDownloadFromURL(AjaxUpdateView):
|
||||
"""
|
||||
View for downloading an image from a provided URL
|
||||
"""
|
||||
"""View for downloading an image from a provided URL."""
|
||||
|
||||
model = Part
|
||||
|
||||
@ -579,12 +579,10 @@ class PartImageDownloadFromURL(AjaxUpdateView):
|
||||
ajax_form_title = _('Download Image')
|
||||
|
||||
def validate(self, part, form):
|
||||
"""
|
||||
Validate that the image data are correct.
|
||||
"""Validate that the image data are correct.
|
||||
|
||||
- Try to download the image!
|
||||
"""
|
||||
|
||||
# First ensure that the normal validation routines pass
|
||||
if not form.is_valid():
|
||||
return
|
||||
@ -628,10 +626,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
|
||||
return
|
||||
|
||||
def save(self, part, form, **kwargs):
|
||||
"""
|
||||
Save the downloaded image to the part
|
||||
"""
|
||||
|
||||
"""Save the downloaded image to the part."""
|
||||
fmt = self.image.format
|
||||
|
||||
if not fmt:
|
||||
@ -651,7 +646,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
|
||||
|
||||
|
||||
class PartImageSelect(AjaxUpdateView):
|
||||
""" View for selecting Part image from existing images. """
|
||||
"""View for selecting Part image from existing images."""
|
||||
|
||||
model = Part
|
||||
ajax_template_name = 'part/select_image.html'
|
||||
@ -662,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()
|
||||
|
||||
@ -690,7 +685,7 @@ class PartImageSelect(AjaxUpdateView):
|
||||
|
||||
|
||||
class BomUpload(InvenTreeRoleMixin, DetailView):
|
||||
""" View for uploading a BOM file, and handling BOM data importing. """
|
||||
"""View for uploading a BOM file, and handling BOM data importing."""
|
||||
|
||||
context_object_name = 'part'
|
||||
queryset = Part.objects.all()
|
||||
@ -698,21 +693,21 @@ class BomUpload(InvenTreeRoleMixin, DetailView):
|
||||
|
||||
|
||||
class BomUploadTemplate(AjaxView):
|
||||
"""
|
||||
Provide a BOM upload template file for download.
|
||||
"""Provide a BOM upload template file for download.
|
||||
|
||||
- Generates a template file in the provided format e.g. ?format=csv
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class BomDownload(AjaxView):
|
||||
"""
|
||||
Provide raw download of a BOM file.
|
||||
"""Provide raw download of a BOM file.
|
||||
|
||||
- File format should be passed as a query param e.g. ?format=csv
|
||||
"""
|
||||
|
||||
@ -721,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')
|
||||
@ -762,13 +757,14 @@ class BomDownload(AjaxView):
|
||||
)
|
||||
|
||||
def get_data(self):
|
||||
"""Return a cutsom message"""
|
||||
return {
|
||||
'info': 'Exported BOM'
|
||||
}
|
||||
|
||||
|
||||
class PartDelete(AjaxDeleteView):
|
||||
""" View to delete a Part object """
|
||||
"""View to delete a Part object."""
|
||||
|
||||
model = Part
|
||||
ajax_template_name = 'part/partial_delete.html'
|
||||
@ -778,13 +774,14 @@ 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'),
|
||||
}
|
||||
|
||||
|
||||
class PartPricing(AjaxView):
|
||||
""" View for inspecting part pricing information """
|
||||
"""View for inspecting part pricing information."""
|
||||
|
||||
model = Part
|
||||
ajax_template_name = "part/part_pricing.html"
|
||||
@ -794,17 +791,18 @@ class PartPricing(AjaxView):
|
||||
role_required = ['sales_order.view', 'part.view']
|
||||
|
||||
def get_quantity(self):
|
||||
""" Return set quantity in decimal format """
|
||||
"""Return set quantity in decimal format."""
|
||||
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:
|
||||
return None
|
||||
|
||||
def get_pricing(self, quantity=1, currency=None):
|
||||
""" returns context with pricing information """
|
||||
"""Returns context with pricing information."""
|
||||
if quantity <= 0:
|
||||
quantity = 1
|
||||
|
||||
@ -898,16 +896,18 @@ class PartPricing(AjaxView):
|
||||
return ctx
|
||||
|
||||
def get_initials(self):
|
||||
""" returns initials for form """
|
||||
"""Returns initials for form."""
|
||||
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()
|
||||
@ -931,9 +931,7 @@ class PartPricing(AjaxView):
|
||||
|
||||
|
||||
class PartParameterTemplateCreate(AjaxCreateView):
|
||||
"""
|
||||
View for creating a new PartParameterTemplate
|
||||
"""
|
||||
"""View for creating a new PartParameterTemplate."""
|
||||
|
||||
model = PartParameterTemplate
|
||||
form_class = part_forms.EditPartParameterTemplateForm
|
||||
@ -941,9 +939,7 @@ class PartParameterTemplateCreate(AjaxCreateView):
|
||||
|
||||
|
||||
class PartParameterTemplateEdit(AjaxUpdateView):
|
||||
"""
|
||||
View for editing a PartParameterTemplate
|
||||
"""
|
||||
"""View for editing a PartParameterTemplate."""
|
||||
|
||||
model = PartParameterTemplate
|
||||
form_class = part_forms.EditPartParameterTemplateForm
|
||||
@ -951,14 +947,14 @@ class PartParameterTemplateEdit(AjaxUpdateView):
|
||||
|
||||
|
||||
class PartParameterTemplateDelete(AjaxDeleteView):
|
||||
""" View for deleting an existing PartParameterTemplate """
|
||||
"""View for deleting an existing PartParameterTemplate."""
|
||||
|
||||
model = PartParameterTemplate
|
||||
ajax_form_title = _("Delete Part Parameter Template")
|
||||
|
||||
|
||||
class CategoryDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
""" Detail view for PartCategory """
|
||||
"""Detail view for PartCategory."""
|
||||
|
||||
model = PartCategory
|
||||
context_object_name = 'category'
|
||||
@ -966,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:
|
||||
@ -980,20 +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
|
||||
"""
|
||||
|
||||
"""Delete view to delete a PartCategory."""
|
||||
model = PartCategory
|
||||
ajax_template_name = 'part/category_delete.html'
|
||||
ajax_form_title = _('Delete Part Category')
|
||||
@ -1001,20 +1004,21 @@ 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'),
|
||||
}
|
||||
|
||||
|
||||
class CategoryParameterTemplateCreate(AjaxCreateView):
|
||||
""" View for creating a new PartCategoryParameterTemplate """
|
||||
"""View for creating a new PartCategoryParameterTemplate."""
|
||||
|
||||
model = PartCategoryParameterTemplate
|
||||
form_class = part_forms.EditCategoryParameterTemplateForm
|
||||
ajax_form_title = _('Create Category Parameter Template')
|
||||
|
||||
def get_initial(self):
|
||||
""" Get initial data for Category """
|
||||
"""Get initial data for Category."""
|
||||
initials = super().get_initial()
|
||||
|
||||
category_id = self.kwargs.get('pk', None)
|
||||
@ -1028,11 +1032,11 @@ class CategoryParameterTemplateCreate(AjaxCreateView):
|
||||
return initials
|
||||
|
||||
def get_form(self):
|
||||
""" Create a form to upload a new CategoryParameterTemplate
|
||||
"""Create a form to upload a new CategoryParameterTemplate.
|
||||
|
||||
- Hide the 'category' field (parent part)
|
||||
- Display parameter templates which are not yet related
|
||||
"""
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
form.fields['category'].widget = HiddenInput()
|
||||
@ -1062,14 +1066,13 @@ class CategoryParameterTemplateCreate(AjaxCreateView):
|
||||
return form
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
""" Capture the POST request
|
||||
"""Capture the POST request.
|
||||
|
||||
- If the add_to_all_categories object is set, link parameter template to
|
||||
all categories
|
||||
- If the add_to_same_level_categories object is set, link parameter template to
|
||||
same level categories
|
||||
"""
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
valid = form.is_valid()
|
||||
@ -1108,13 +1111,18 @@ class CategoryParameterTemplateCreate(AjaxCreateView):
|
||||
|
||||
|
||||
class CategoryParameterTemplateEdit(AjaxUpdateView):
|
||||
""" View for editing a PartCategoryParameterTemplate """
|
||||
"""View for editing a PartCategoryParameterTemplate."""
|
||||
|
||||
model = PartCategoryParameterTemplate
|
||||
form_class = part_forms.EditCategoryParameterTemplateForm
|
||||
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:
|
||||
@ -1123,11 +1131,11 @@ class CategoryParameterTemplateEdit(AjaxUpdateView):
|
||||
return self.object
|
||||
|
||||
def get_form(self):
|
||||
""" Create a form to upload a new CategoryParameterTemplate
|
||||
"""Create a form to upload a new CategoryParameterTemplate.
|
||||
|
||||
- Hide the 'category' field (parent part)
|
||||
- Display parameter templates which are not yet related
|
||||
"""
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
form.fields['category'].widget = HiddenInput()
|
||||
@ -1165,12 +1173,17 @@ class CategoryParameterTemplateEdit(AjaxUpdateView):
|
||||
|
||||
|
||||
class CategoryParameterTemplateDelete(AjaxDeleteView):
|
||||
""" View for deleting an existing PartCategoryParameterTemplate """
|
||||
"""View for deleting an existing PartCategoryParameterTemplate."""
|
||||
|
||||
model = PartCategoryParameterTemplate
|
||||
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:
|
||||
|
Reference in New Issue
Block a user