mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 15:15:42 +00:00 
			
		
		
		
	[Feature] Add RMA support (#4488)
* Adds ReturnOrder and ReturnOrderAttachment models
* Adds new 'role' specific for return orders
* Refactor total_price into a mixin
- Required for PurchaseOrder and SalesOrder
- May not be required for ReturnOrder (remains to be seen)
* Adds API endpoints for ReturnOrder
- Add list endpoint
- Add detail endpoint
- Adds required serializer models
* Adds basic "index" page for Return Order model
* Update API version
* Update navbar text
* Add db migration for new "role"
* Add ContactList and ContactDetail API endpoints
* Adds template and JS code for manipulation of contacts
- Display a table
- Create / edit / delete
* Splits order.js into multiple files
- Javascript files was becoming extremely large
- Hard to debug and find code
- Split into purchase_order / return_order / sales_order
* Fix role name (change 'returns' to 'return_order')
- Similar to existing roles for purchase_order and sales_order
* Adds detail page for ReturnOrder
* URL cleanup
- Use <int:pk> instead of complex regex
* More URL cleanup
* Add "return orders" list to company detail page
* Break JS status codes into new javascript file
- Always difficult to track down where these are rendered
- Enough to warrant their own file now
* Add ability to edit return order from detail page
* Database migrations
- Add new ReturnOrder modeles
- Add new 'contact' field to external orders
* Adds "contact" to ReturnOrder
- Implement check to ensure that the selected "contact" matches the selected "company"
* Adjust filters to limit contact options
* Fix typo
* Expose 'contact' field for PurchaseOrder model
* Render contact information
* Add "contact" for SalesOrder
* Adds setting to enable / disable return order functionality
- Simply hides the navigation elements
- API is not disabled
* Support filtering ReturnOrder by 'status'
- Refactors existing filter into the OrderFilter class
* js linting
* More JS linting
* Adds ReturnOrderReport model
* Add serializer for the ReturnOrderReport model
- A little bit of refactoring along the way
* Admin integration for new report model
* Refactoring for report.api
- Adds generic mixins for filtering queryset (based on updates to label.api)
- Reduces repeated code a *lot*
* Exposes API endpoints for ReturnOrderReport
* Adds default example report file for ReturnOrder
- Requires some more work :)
* Refactor report printing javascript code
- Replace all existing functions with 'printReports'
* Improvements for default StockItem test report template
- Fix bug in template
- Handle potential errors in template tags
- Add more helpers to report tags
- Improve test result rendering
* Reduce logging verbosity from weasyprint
* Refactor javascript for label printing
- Consolidate into a single function
- Similar to refactor of report functions
* Add report print button to return order page
* Record user reference when creating via API
* Refactor order serializers
- Move common code into AbstractOrderSerializer class
* Adds extra line item model for the return order
- Adds serializer and API endpoints as appropriate
* Render extra line table for return order
- Refactor existing functions into a single generic function
- Reduces repeated JS code a lot
* Add ability to create a new extra line item
* Adds button for creating a new lien item
* JS linting
* Update test
* Typo fix
(cherry picked from commit 28ac2be35b)
* Enable search for return order
* Don't do pricing (yet) for returnorder extra line table
- Fixes an uncaught error
* Error catching for api.js
* Updates for order models:
- Add 'target_date' field to abstract Order model
- Add IN_PROGRESS status code for return order
- Refactor 'overdue' and 'outstanding' API queries
- Refactor OVERDUE_FILTER on order models
- Refactor is_overdue on order models
- More table filters for return order model
* JS cleanup
* Create ReturnOrderLineItem model
- New type of status label
- Add TotalPriceMixin to ReturnOrder model
* Adds an API serializer for the ReturnOrderLineItem model
* Add API endpoints for ReturnOrderLineItem model
- Including some refactoring along the way
* javascript: refactor loadTableFilters function
- Pass enforced query through to the filters
- Call Object.assign() to construct a superset query
- Removes a lot of code duplication
* Refactor hard-coded URLS to use {% url %} lookup
- Forces error if the URL is wrong
- If we ever change the URL, will still work
* Implement creation of new return order line items
* Adds 'part_detail' annotation to ReturnOrderLineItem serializer
- Required for rendering part information
* javascript: refactor method for creating a group of buttons in a table
* javascript: refactor common buttons with helper functions
* Allow edit and delete of return order line items
* Add form option to automatically reload a table on success
- Pass table name to options.refreshTable
* JS linting
* Add common function for createExtraLineItem
* Refactor loading of attachment tables
- Setup drag-and-drop as part of core function
* CI fixes
* Refactoring out some more common API endpoint code
* Update migrations
* Fix permission typo
* Refactor for unit testing code
* Add unit tests for Contact model
* Tests for returnorder list API
* Annotate 'line_items' to ReturnOrder serializer
* Driving the refactor tractor
* More unit tests for the ReturnOrder API endpoints
* Refactor "print orders" button for various order tables
- Move into "setupFilterList" code (generic)
* add generic 'label printing' button to table actions buttons
* Refactor build output table
* Refactoring icon generation for js
* Refactoring for Part API
* Fix database model type for 'received_date'
* Add API endpoint to "issue" a ReturnOrder
* Improvements for stock tracking table
- Add new status codes
- Add rendering for SalesOrder
- Add rendering for ReturnOrder
- Fix status badges
* Adds functionality to receive line items against a return order
* Add endpoints for completing and cancelling orders
* Add option to allow / prevent editing of ReturnOrder after completed
* js linting
* Wrap "add extra line" button in setting check
* Updates to order/admin.py
* Remove inline admin for returnorderline model
* Updates to pass CI
* Serializer fix
* order template fixes
* Unit test fix
* Fixes for ReturnOrder.receive_line_item
* Unit testing for receiving line items against an RMA
* Improve example report for return order
* Extend unit tests for reporting
* Cleanup here and there
* Unit testing for order views
* Clear "sales_order" field when returning against ReturnOrder
* Add 'location' to deltas when returning from customer
* Bug fix for unit test
			
			
This commit is contained in:
		@@ -39,34 +39,20 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
 | 
			
		||||
                     PartStocktake, PartStocktakeReport, PartTestTemplate)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CategoryList(APIDownloadMixin, ListCreateAPI):
 | 
			
		||||
    """API endpoint for accessing a list of PartCategory objects.
 | 
			
		||||
 | 
			
		||||
    - GET: Return a list of PartCategory objects
 | 
			
		||||
    - POST: Create a new PartCategory object
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    queryset = PartCategory.objects.all()
 | 
			
		||||
class CategoryMixin:
 | 
			
		||||
    """Mixin class for PartCategory endpoints"""
 | 
			
		||||
    serializer_class = part_serializers.CategorySerializer
 | 
			
		||||
 | 
			
		||||
    def download_queryset(self, queryset, export_format):
 | 
			
		||||
        """Download the filtered queryset as a data file"""
 | 
			
		||||
 | 
			
		||||
        dataset = PartCategoryResource().export(queryset=queryset)
 | 
			
		||||
        filedata = dataset.export(export_format)
 | 
			
		||||
        filename = f"InvenTree_Categories.{export_format}"
 | 
			
		||||
 | 
			
		||||
        return DownloadFile(filedata, filename)
 | 
			
		||||
    queryset = PartCategory.objects.all()
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self, *args, **kwargs):
 | 
			
		||||
        """Return an annotated queryset for the CategoryList endpoint"""
 | 
			
		||||
        """Return an annotated queryset for the CategoryDetail endpoint"""
 | 
			
		||||
 | 
			
		||||
        queryset = super().get_queryset(*args, **kwargs)
 | 
			
		||||
        queryset = part_serializers.CategorySerializer.annotate_queryset(queryset)
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
    def get_serializer_context(self):
 | 
			
		||||
        """Add extra context data to the serializer for the PartCategoryList endpoint"""
 | 
			
		||||
        """Add extra context to the serializer for the CategoryDetail endpoint"""
 | 
			
		||||
        ctx = super().get_serializer_context()
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
@@ -77,6 +63,23 @@ class CategoryList(APIDownloadMixin, ListCreateAPI):
 | 
			
		||||
 | 
			
		||||
        return ctx
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CategoryList(CategoryMixin, APIDownloadMixin, ListCreateAPI):
 | 
			
		||||
    """API endpoint for accessing a list of PartCategory objects.
 | 
			
		||||
 | 
			
		||||
    - GET: Return a list of PartCategory objects
 | 
			
		||||
    - POST: Create a new PartCategory object
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def download_queryset(self, queryset, export_format):
 | 
			
		||||
        """Download the filtered queryset as a data file"""
 | 
			
		||||
 | 
			
		||||
        dataset = PartCategoryResource().export(queryset=queryset)
 | 
			
		||||
        filedata = dataset.export(export_format)
 | 
			
		||||
        filename = f"InvenTree_Categories.{export_format}"
 | 
			
		||||
 | 
			
		||||
        return DownloadFile(filedata, filename)
 | 
			
		||||
 | 
			
		||||
    def filter_queryset(self, queryset):
 | 
			
		||||
        """Custom filtering:
 | 
			
		||||
 | 
			
		||||
@@ -184,31 +187,9 @@ class CategoryList(APIDownloadMixin, ListCreateAPI):
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CategoryDetail(CustomRetrieveUpdateDestroyAPI):
 | 
			
		||||
class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
 | 
			
		||||
    """API endpoint for detail view of a single PartCategory object."""
 | 
			
		||||
 | 
			
		||||
    serializer_class = part_serializers.CategorySerializer
 | 
			
		||||
    queryset = PartCategory.objects.all()
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self, *args, **kwargs):
 | 
			
		||||
        """Return an annotated queryset for the CategoryDetail endpoint"""
 | 
			
		||||
 | 
			
		||||
        queryset = super().get_queryset(*args, **kwargs)
 | 
			
		||||
        queryset = part_serializers.CategorySerializer.annotate_queryset(queryset)
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
    def get_serializer_context(self):
 | 
			
		||||
        """Add extra context to the serializer for the CategoryDetail endpoint"""
 | 
			
		||||
        ctx = super().get_serializer_context()
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
 | 
			
		||||
        except AttributeError:
 | 
			
		||||
            # Error is thrown if the view does not have an associated request
 | 
			
		||||
            ctx['starred_categories'] = []
 | 
			
		||||
 | 
			
		||||
        return ctx
 | 
			
		||||
 | 
			
		||||
    def update(self, request, *args, **kwargs):
 | 
			
		||||
        """Perform 'update' function and mark this part as 'starred' (or not)"""
 | 
			
		||||
        # Clean up input data
 | 
			
		||||
@@ -234,6 +215,21 @@ class CategoryDetail(CustomRetrieveUpdateDestroyAPI):
 | 
			
		||||
                                      delete_child_categories=delete_child_categories))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CategoryTree(ListAPI):
 | 
			
		||||
    """API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
 | 
			
		||||
 | 
			
		||||
    queryset = PartCategory.objects.all()
 | 
			
		||||
    serializer_class = part_serializers.CategoryTree
 | 
			
		||||
 | 
			
		||||
    filter_backends = [
 | 
			
		||||
        DjangoFilterBackend,
 | 
			
		||||
        filters.OrderingFilter,
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    # Order by tree level (top levels first) and then name
 | 
			
		||||
    ordering = ['level', 'name']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CategoryMetadata(RetrieveUpdateAPI):
 | 
			
		||||
    """API endpoint for viewing / updating PartCategory metadata."""
 | 
			
		||||
 | 
			
		||||
@@ -292,21 +288,6 @@ class CategoryParameterDetail(RetrieveUpdateDestroyAPI):
 | 
			
		||||
    serializer_class = part_serializers.CategoryParameterTemplateSerializer
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CategoryTree(ListAPI):
 | 
			
		||||
    """API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
 | 
			
		||||
 | 
			
		||||
    queryset = PartCategory.objects.all()
 | 
			
		||||
    serializer_class = part_serializers.CategoryTree
 | 
			
		||||
 | 
			
		||||
    filter_backends = [
 | 
			
		||||
        DjangoFilterBackend,
 | 
			
		||||
        filters.OrderingFilter,
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    # Order by tree level (top levels first) and then name
 | 
			
		||||
    ordering = ['level', 'name']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartSalePriceDetail(RetrieveUpdateDestroyAPI):
 | 
			
		||||
    """Detail endpoint for PartSellPriceBreak model."""
 | 
			
		||||
 | 
			
		||||
@@ -845,76 +826,6 @@ class PartValidateBOM(RetrieveUpdateAPI):
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartDetail(RetrieveUpdateDestroyAPI):
 | 
			
		||||
    """API endpoint for detail view of a single Part object."""
 | 
			
		||||
 | 
			
		||||
    queryset = Part.objects.all()
 | 
			
		||||
    serializer_class = part_serializers.PartSerializer
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
        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))
 | 
			
		||||
        except AttributeError:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        # Ensure the request context is passed through
 | 
			
		||||
        kwargs['context'] = self.get_serializer_context()
 | 
			
		||||
 | 
			
		||||
        # Pass a list of "starred" parts of the current user to the serializer
 | 
			
		||||
        # We do this to reduce the number of database queries required!
 | 
			
		||||
        if self.starred_parts is None and self.request is not None:
 | 
			
		||||
            self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
 | 
			
		||||
 | 
			
		||||
        kwargs['starred_parts'] = self.starred_parts
 | 
			
		||||
 | 
			
		||||
        return self.serializer_class(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def destroy(self, request, *args, **kwargs):
 | 
			
		||||
        """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:
 | 
			
		||||
            # Delete
 | 
			
		||||
            return super(PartDetail, self).destroy(request, *args, **kwargs)
 | 
			
		||||
        else:
 | 
			
		||||
            # Return 405 error
 | 
			
		||||
            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.
 | 
			
		||||
 | 
			
		||||
        - If the 'starred' field is provided, update the 'starred' status against current user
 | 
			
		||||
        """
 | 
			
		||||
        # Clean input data
 | 
			
		||||
        data = self.clean_data(request.data)
 | 
			
		||||
 | 
			
		||||
        if 'starred' in data:
 | 
			
		||||
            starred = str2bool(data.get('starred', False))
 | 
			
		||||
 | 
			
		||||
            self.get_object().set_starred(request.user, starred)
 | 
			
		||||
 | 
			
		||||
        response = super().update(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartFilter(rest_filters.FilterSet):
 | 
			
		||||
    """Custom filters for the PartList endpoint.
 | 
			
		||||
 | 
			
		||||
@@ -1090,22 +1001,30 @@ class PartFilter(rest_filters.FilterSet):
 | 
			
		||||
    virtual = rest_filters.BooleanFilter()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartList(APIDownloadMixin, ListCreateAPI):
 | 
			
		||||
    """API endpoint for accessing a list of Part objects, or creating a new Part instance"""
 | 
			
		||||
 | 
			
		||||
class PartMixin:
 | 
			
		||||
    """Mixin class for Part API endpoints"""
 | 
			
		||||
    serializer_class = part_serializers.PartSerializer
 | 
			
		||||
    queryset = Part.objects.all()
 | 
			
		||||
    filterset_class = PartFilter
 | 
			
		||||
 | 
			
		||||
    starred_parts = None
 | 
			
		||||
 | 
			
		||||
    is_create = False
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
    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()
 | 
			
		||||
 | 
			
		||||
        # Indicate that we can create a new Part via this endpoint
 | 
			
		||||
        kwargs['create'] = True
 | 
			
		||||
        kwargs['create'] = self.is_create
 | 
			
		||||
 | 
			
		||||
        # Pass a list of "starred" parts to the current user to the serializer
 | 
			
		||||
        # We do this to reduce the number of database queries required!
 | 
			
		||||
@@ -1132,6 +1051,13 @@ class PartList(APIDownloadMixin, ListCreateAPI):
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
 | 
			
		||||
    """API endpoint for accessing a list of Part objects, or creating a new Part instance"""
 | 
			
		||||
 | 
			
		||||
    filterset_class = PartFilter
 | 
			
		||||
    is_create = True
 | 
			
		||||
 | 
			
		||||
    def download_queryset(self, queryset, export_format):
 | 
			
		||||
        """Download the filtered queryset as a data file"""
 | 
			
		||||
        dataset = PartResource().export(queryset=queryset)
 | 
			
		||||
@@ -1169,13 +1095,6 @@ class PartList(APIDownloadMixin, ListCreateAPI):
 | 
			
		||||
        else:
 | 
			
		||||
            return Response(data)
 | 
			
		||||
 | 
			
		||||
    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"""
 | 
			
		||||
        params = self.request.query_params
 | 
			
		||||
@@ -1358,6 +1277,43 @@ class PartList(APIDownloadMixin, ListCreateAPI):
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartDetail(PartMixin, RetrieveUpdateDestroyAPI):
 | 
			
		||||
    """API endpoint for detail view of a single Part object."""
 | 
			
		||||
 | 
			
		||||
    def destroy(self, request, *args, **kwargs):
 | 
			
		||||
        """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:
 | 
			
		||||
            # Delete
 | 
			
		||||
            return super(PartDetail, self).destroy(request, *args, **kwargs)
 | 
			
		||||
        else:
 | 
			
		||||
            # Return 405 error
 | 
			
		||||
            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.
 | 
			
		||||
 | 
			
		||||
        - If the 'starred' field is provided, update the 'starred' status against current user
 | 
			
		||||
        """
 | 
			
		||||
        # Clean input data
 | 
			
		||||
        data = self.clean_data(request.data)
 | 
			
		||||
 | 
			
		||||
        if 'starred' in data:
 | 
			
		||||
            starred = str2bool(data.get('starred', False))
 | 
			
		||||
 | 
			
		||||
            self.get_object().set_starred(request.user, starred)
 | 
			
		||||
 | 
			
		||||
        response = super().update(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PartRelatedList(ListCreateAPI):
 | 
			
		||||
    """API endpoint for accessing a list of PartRelated objects."""
 | 
			
		||||
 | 
			
		||||
@@ -1674,42 +1630,11 @@ class BomFilter(rest_filters.FilterSet):
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BomList(ListCreateDestroyAPIView):
 | 
			
		||||
    """API endpoint for accessing a list of BomItem objects.
 | 
			
		||||
 | 
			
		||||
    - GET: Return list of BomItem objects
 | 
			
		||||
    - POST: Create a new BomItem object
 | 
			
		||||
    """
 | 
			
		||||
class BomMixin:
 | 
			
		||||
    """Mixin class for BomItem API endpoints"""
 | 
			
		||||
 | 
			
		||||
    serializer_class = part_serializers.BomItemSerializer
 | 
			
		||||
    queryset = BomItem.objects.all()
 | 
			
		||||
    filterset_class = BomFilter
 | 
			
		||||
 | 
			
		||||
    def list(self, request, *args, **kwargs):
 | 
			
		||||
        """Return serialized list response for this endpoint"""
 | 
			
		||||
 | 
			
		||||
        queryset = self.filter_queryset(self.get_queryset())
 | 
			
		||||
 | 
			
		||||
        page = self.paginate_queryset(queryset)
 | 
			
		||||
 | 
			
		||||
        if page is not None:
 | 
			
		||||
            serializer = self.get_serializer(page, many=True)
 | 
			
		||||
        else:
 | 
			
		||||
            serializer = self.get_serializer(queryset, many=True)
 | 
			
		||||
 | 
			
		||||
        data = serializer.data
 | 
			
		||||
 | 
			
		||||
        """
 | 
			
		||||
        Determine the response type based on the request.
 | 
			
		||||
        a) For HTTP requests (e.g. via the browseable API) return a DRF response
 | 
			
		||||
        b) For AJAX requests, simply return a JSON rendered response.
 | 
			
		||||
        """
 | 
			
		||||
        if page is not None:
 | 
			
		||||
            return self.get_paginated_response(data)
 | 
			
		||||
        elif request.is_ajax():
 | 
			
		||||
            return JsonResponse(data, safe=False)
 | 
			
		||||
        else:
 | 
			
		||||
            return Response(data)
 | 
			
		||||
 | 
			
		||||
    def get_serializer(self, *args, **kwargs):
 | 
			
		||||
        """Return the serializer instance for this API endpoint
 | 
			
		||||
@@ -1744,6 +1669,42 @@ class BomList(ListCreateDestroyAPIView):
 | 
			
		||||
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BomList(BomMixin, ListCreateDestroyAPIView):
 | 
			
		||||
    """API endpoint for accessing a list of BomItem objects.
 | 
			
		||||
 | 
			
		||||
    - GET: Return list of BomItem objects
 | 
			
		||||
    - POST: Create a new BomItem object
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    filterset_class = BomFilter
 | 
			
		||||
 | 
			
		||||
    def list(self, request, *args, **kwargs):
 | 
			
		||||
        """Return serialized list response for this endpoint"""
 | 
			
		||||
 | 
			
		||||
        queryset = self.filter_queryset(self.get_queryset())
 | 
			
		||||
 | 
			
		||||
        page = self.paginate_queryset(queryset)
 | 
			
		||||
 | 
			
		||||
        if page is not None:
 | 
			
		||||
            serializer = self.get_serializer(page, many=True)
 | 
			
		||||
        else:
 | 
			
		||||
            serializer = self.get_serializer(queryset, many=True)
 | 
			
		||||
 | 
			
		||||
        data = serializer.data
 | 
			
		||||
 | 
			
		||||
        """
 | 
			
		||||
        Determine the response type based on the request.
 | 
			
		||||
        a) For HTTP requests (e.g. via the browseable API) return a DRF response
 | 
			
		||||
        b) For AJAX requests, simply return a JSON rendered response.
 | 
			
		||||
        """
 | 
			
		||||
        if page is not None:
 | 
			
		||||
            return self.get_paginated_response(data)
 | 
			
		||||
        elif request.is_ajax():
 | 
			
		||||
            return JsonResponse(data, safe=False)
 | 
			
		||||
        else:
 | 
			
		||||
            return Response(data)
 | 
			
		||||
 | 
			
		||||
    def filter_queryset(self, queryset):
 | 
			
		||||
        """Custom query filtering for the BomItem list API"""
 | 
			
		||||
        queryset = super().filter_queryset(queryset)
 | 
			
		||||
@@ -1828,6 +1789,11 @@ class BomList(ListCreateDestroyAPIView):
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI):
 | 
			
		||||
    """API endpoint for detail view of a single BomItem object."""
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BomImportUpload(CreateAPI):
 | 
			
		||||
    """API endpoint for uploading a complete Bill of Materials.
 | 
			
		||||
 | 
			
		||||
@@ -1866,22 +1832,6 @@ class BomImportSubmit(CreateAPI):
 | 
			
		||||
    serializer_class = part_serializers.BomImportSubmitSerializer
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BomDetail(RetrieveUpdateDestroyAPI):
 | 
			
		||||
    """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)
 | 
			
		||||
        queryset = self.get_serializer_class().annotate_queryset(queryset)
 | 
			
		||||
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BomItemValidate(UpdateAPI):
 | 
			
		||||
    """API endpoint for validating a BomItem."""
 | 
			
		||||
 | 
			
		||||
@@ -1958,7 +1908,7 @@ part_api_urls = [
 | 
			
		||||
        ])),
 | 
			
		||||
 | 
			
		||||
        # Category detail endpoints
 | 
			
		||||
        re_path(r'^(?P<pk>\d+)/', include([
 | 
			
		||||
        path(r'<int:pk>/', include([
 | 
			
		||||
 | 
			
		||||
            re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'),
 | 
			
		||||
 | 
			
		||||
@@ -1971,31 +1921,31 @@ part_api_urls = [
 | 
			
		||||
 | 
			
		||||
    # Base URL for PartTestTemplate API endpoints
 | 
			
		||||
    re_path(r'^test-template/', include([
 | 
			
		||||
        re_path(r'^(?P<pk>\d+)/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'),
 | 
			
		||||
        path(r'<int:pk>/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'),
 | 
			
		||||
        path('', PartTestTemplateList.as_view(), name='api-part-test-template-list'),
 | 
			
		||||
    ])),
 | 
			
		||||
 | 
			
		||||
    # Base URL for PartAttachment API endpoints
 | 
			
		||||
    re_path(r'^attachment/', include([
 | 
			
		||||
        re_path(r'^(?P<pk>\d+)/', PartAttachmentDetail.as_view(), name='api-part-attachment-detail'),
 | 
			
		||||
        path(r'<int:pk>/', PartAttachmentDetail.as_view(), name='api-part-attachment-detail'),
 | 
			
		||||
        path('', PartAttachmentList.as_view(), name='api-part-attachment-list'),
 | 
			
		||||
    ])),
 | 
			
		||||
 | 
			
		||||
    # Base URL for part sale pricing
 | 
			
		||||
    re_path(r'^sale-price/', include([
 | 
			
		||||
        re_path(r'^(?P<pk>\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
 | 
			
		||||
        path(r'<int:pk>/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
 | 
			
		||||
        re_path(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
 | 
			
		||||
    ])),
 | 
			
		||||
 | 
			
		||||
    # Base URL for part internal pricing
 | 
			
		||||
    re_path(r'^internal-price/', include([
 | 
			
		||||
        re_path(r'^(?P<pk>\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
 | 
			
		||||
        path(r'<int:pk>/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
 | 
			
		||||
        re_path(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
 | 
			
		||||
    ])),
 | 
			
		||||
 | 
			
		||||
    # Base URL for PartRelated API endpoints
 | 
			
		||||
    re_path(r'^related/', include([
 | 
			
		||||
        re_path(r'^(?P<pk>\d+)/', PartRelatedDetail.as_view(), name='api-part-related-detail'),
 | 
			
		||||
        path(r'<int:pk>/', PartRelatedDetail.as_view(), name='api-part-related-detail'),
 | 
			
		||||
        re_path(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'),
 | 
			
		||||
    ])),
 | 
			
		||||
 | 
			
		||||
@@ -2009,7 +1959,7 @@ part_api_urls = [
 | 
			
		||||
            re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
 | 
			
		||||
        ])),
 | 
			
		||||
 | 
			
		||||
        re_path(r'^(?P<pk>\d+)/', PartParameterDetail.as_view(), name='api-part-parameter-detail'),
 | 
			
		||||
        path(r'<int:pk>/', PartParameterDetail.as_view(), name='api-part-parameter-detail'),
 | 
			
		||||
        re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'),
 | 
			
		||||
    ])),
 | 
			
		||||
 | 
			
		||||
@@ -2021,7 +1971,7 @@ part_api_urls = [
 | 
			
		||||
            re_path(r'^.*$', PartStocktakeReportList.as_view(), name='api-part-stocktake-report-list'),
 | 
			
		||||
        ])),
 | 
			
		||||
 | 
			
		||||
        re_path(r'^(?P<pk>\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'),
 | 
			
		||||
        path(r'<int:pk>/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'),
 | 
			
		||||
        re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'),
 | 
			
		||||
    ])),
 | 
			
		||||
 | 
			
		||||
@@ -2033,7 +1983,7 @@ part_api_urls = [
 | 
			
		||||
    # BOM template
 | 
			
		||||
    re_path(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='api-bom-upload-template'),
 | 
			
		||||
 | 
			
		||||
    re_path(r'^(?P<pk>\d+)/', include([
 | 
			
		||||
    path(r'<int:pk>/', include([
 | 
			
		||||
 | 
			
		||||
        # Endpoint for extra serial number information
 | 
			
		||||
        re_path(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'),
 | 
			
		||||
@@ -2073,14 +2023,14 @@ bom_api_urls = [
 | 
			
		||||
    re_path(r'^substitute/', include([
 | 
			
		||||
 | 
			
		||||
        # Detail view
 | 
			
		||||
        re_path(r'^(?P<pk>\d+)/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'),
 | 
			
		||||
        path(r'<int:pk>/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'),
 | 
			
		||||
 | 
			
		||||
        # Catch all
 | 
			
		||||
        re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),
 | 
			
		||||
    ])),
 | 
			
		||||
 | 
			
		||||
    # BOM Item Detail
 | 
			
		||||
    re_path(r'^(?P<pk>\d+)/', include([
 | 
			
		||||
    path(r'<int:pk>/', include([
 | 
			
		||||
        re_path(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),
 | 
			
		||||
        re_path(r'^metadata/?', BomItemMetadata.as_view(), name='api-bom-item-metadata'),
 | 
			
		||||
        re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
 | 
			
		||||
 
 | 
			
		||||
@@ -37,7 +37,6 @@ import common.settings
 | 
			
		||||
import InvenTree.fields
 | 
			
		||||
import InvenTree.ready
 | 
			
		||||
import InvenTree.tasks
 | 
			
		||||
import part.filters as part_filters
 | 
			
		||||
import part.settings as part_settings
 | 
			
		||||
from build import models as BuildModels
 | 
			
		||||
from common.models import InvenTreeSetting
 | 
			
		||||
@@ -1223,6 +1222,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
 | 
			
		||||
    @property
 | 
			
		||||
    def can_build(self):
 | 
			
		||||
        """Return the number of units that can be build with available stock."""
 | 
			
		||||
 | 
			
		||||
        import part.filters
 | 
			
		||||
 | 
			
		||||
        # If this part does NOT have a BOM, result is simply the currently available stock
 | 
			
		||||
        if not self.has_bom:
 | 
			
		||||
            return 0
 | 
			
		||||
@@ -1246,9 +1248,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
 | 
			
		||||
        # Annotate the 'available stock' for each part in the BOM
 | 
			
		||||
        ref = 'sub_part__'
 | 
			
		||||
        queryset = queryset.alias(
 | 
			
		||||
            total_stock=part_filters.annotate_total_stock(reference=ref),
 | 
			
		||||
            so_allocations=part_filters.annotate_sales_order_allocations(reference=ref),
 | 
			
		||||
            bo_allocations=part_filters.annotate_build_order_allocations(reference=ref),
 | 
			
		||||
            total_stock=part.filters.annotate_total_stock(reference=ref),
 | 
			
		||||
            so_allocations=part.filters.annotate_sales_order_allocations(reference=ref),
 | 
			
		||||
            bo_allocations=part.filters.annotate_build_order_allocations(reference=ref),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Calculate the 'available stock' based on previous annotations
 | 
			
		||||
@@ -1262,9 +1264,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
 | 
			
		||||
        # Extract similar information for any 'substitute' parts
 | 
			
		||||
        ref = 'substitutes__part__'
 | 
			
		||||
        queryset = queryset.alias(
 | 
			
		||||
            sub_total_stock=part_filters.annotate_total_stock(reference=ref),
 | 
			
		||||
            sub_so_allocations=part_filters.annotate_sales_order_allocations(reference=ref),
 | 
			
		||||
            sub_bo_allocations=part_filters.annotate_build_order_allocations(reference=ref),
 | 
			
		||||
            sub_total_stock=part.filters.annotate_total_stock(reference=ref),
 | 
			
		||||
            sub_so_allocations=part.filters.annotate_sales_order_allocations(reference=ref),
 | 
			
		||||
            sub_bo_allocations=part.filters.annotate_build_order_allocations(reference=ref),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        queryset = queryset.annotate(
 | 
			
		||||
@@ -1275,12 +1277,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Extract similar information for any 'variant' parts
 | 
			
		||||
        variant_stock_query = part_filters.variant_stock_query(reference='sub_part__')
 | 
			
		||||
        variant_stock_query = part.filters.variant_stock_query(reference='sub_part__')
 | 
			
		||||
 | 
			
		||||
        queryset = queryset.alias(
 | 
			
		||||
            var_total_stock=part_filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),
 | 
			
		||||
            var_bo_allocations=part_filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'),
 | 
			
		||||
            var_so_allocations=part_filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'),
 | 
			
		||||
            var_total_stock=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),
 | 
			
		||||
            var_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'),
 | 
			
		||||
            var_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        queryset = queryset.annotate(
 | 
			
		||||
@@ -2083,6 +2085,16 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
 | 
			
		||||
 | 
			
		||||
        return tests
 | 
			
		||||
 | 
			
		||||
    def getTestTemplateMap(self, **kwargs):
 | 
			
		||||
        """Return a map of all test templates associated with this Part"""
 | 
			
		||||
 | 
			
		||||
        templates = {}
 | 
			
		||||
 | 
			
		||||
        for template in self.getTestTemplates(**kwargs):
 | 
			
		||||
            templates[template.key] = template
 | 
			
		||||
 | 
			
		||||
        return templates
 | 
			
		||||
 | 
			
		||||
    def getRequiredTests(self):
 | 
			
		||||
        """Return the tests which are required by this part"""
 | 
			
		||||
        return self.getTestTemplates(required=True)
 | 
			
		||||
 
 | 
			
		||||
@@ -183,11 +183,6 @@
 | 
			
		||||
                    <li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'>
 | 
			
		||||
                        <span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
 | 
			
		||||
                    </a></li>
 | 
			
		||||
                    {% if report_enabled %}
 | 
			
		||||
                    <li><a class='dropdown-item' href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>
 | 
			
		||||
                        <span class='fas fa-tag'></span> {% trans "Print Labels" %}
 | 
			
		||||
                    </a></li>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                </ul>
 | 
			
		||||
            </div>
 | 
			
		||||
            {% include "filter_list.html" with id="parts" %}
 | 
			
		||||
 
 | 
			
		||||
@@ -548,7 +548,7 @@
 | 
			
		||||
 | 
			
		||||
            deleteManufacturerParts(selections, {
 | 
			
		||||
                success: function() {
 | 
			
		||||
                    $("#manufacturer-part-table").bootstrapTable("refresh");
 | 
			
		||||
                    $("#manufacturer-part-table").bootstrapTable('refresh');
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
@@ -558,7 +558,7 @@
 | 
			
		||||
            createManufacturerPart({
 | 
			
		||||
                part: {{ part.pk }},
 | 
			
		||||
                onSuccess: function() {
 | 
			
		||||
                    $("#manufacturer-part-table").bootstrapTable("refresh");
 | 
			
		||||
                    $("#manufacturer-part-table").bootstrapTable('refresh');
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
@@ -677,7 +677,11 @@
 | 
			
		||||
 | 
			
		||||
        {% if report_enabled %}
 | 
			
		||||
        $("#print-bom-report").click(function() {
 | 
			
		||||
            printBomReports([{{ part.pk }}]);
 | 
			
		||||
            printReports({
 | 
			
		||||
                items: [{{ part.pk }}],
 | 
			
		||||
                key: 'part',
 | 
			
		||||
                url: '{% url "api-bom-report-list" %}'
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
        {% endif %}
 | 
			
		||||
    });
 | 
			
		||||
@@ -709,9 +713,7 @@
 | 
			
		||||
                },
 | 
			
		||||
                focus: 'part_2',
 | 
			
		||||
                title: '{% trans "Add Related Part" %}',
 | 
			
		||||
                onSuccess: function() {
 | 
			
		||||
                    $('#related-parts-table').bootstrapTable('refresh');
 | 
			
		||||
                }
 | 
			
		||||
                refreshTable: '#related-parts-table',
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
@@ -797,9 +799,7 @@
 | 
			
		||||
                    part: {{ part.pk }}
 | 
			
		||||
                }),
 | 
			
		||||
                title: '{% trans "Add Test Result Template" %}',
 | 
			
		||||
                onSuccess: function() {
 | 
			
		||||
                    $("#test-template-table").bootstrapTable("refresh");
 | 
			
		||||
                }
 | 
			
		||||
                refreshTable: '#test-template-table',
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
@@ -870,9 +870,7 @@
 | 
			
		||||
                    data: {},
 | 
			
		||||
                },
 | 
			
		||||
                title: '{% trans "Add Parameter" %}',
 | 
			
		||||
                onSuccess: function() {
 | 
			
		||||
                    $('#parameter-table').bootstrapTable('refresh');
 | 
			
		||||
                }
 | 
			
		||||
                refreshTable: '#parameter-table',
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
        {% endif %}
 | 
			
		||||
@@ -906,20 +904,6 @@
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        enableDragAndDrop(
 | 
			
		||||
            '#attachment-dropzone',
 | 
			
		||||
            '{% url "api-part-attachment-list" %}',
 | 
			
		||||
            {
 | 
			
		||||
                data: {
 | 
			
		||||
                    part: {{ part.id }},
 | 
			
		||||
                },
 | 
			
		||||
                label: 'attachment',
 | 
			
		||||
                success: function(data, status, xhr) {
 | 
			
		||||
                    reloadAttachmentTable();
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    onPanelLoad('pricing', function() {
 | 
			
		||||
 
 | 
			
		||||
@@ -475,7 +475,11 @@
 | 
			
		||||
 | 
			
		||||
    {% if labels_enabled %}
 | 
			
		||||
    $('#print-label').click(function() {
 | 
			
		||||
        printPartLabels([{{ part.pk }}]);
 | 
			
		||||
        printLabels({
 | 
			
		||||
            items: [{{ part.pk }}],
 | 
			
		||||
            key: 'part',
 | 
			
		||||
            url: '{% url "api-part-label-list" %}',
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,8 @@ from django import template
 | 
			
		||||
from django.utils.safestring import mark_safe
 | 
			
		||||
 | 
			
		||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
 | 
			
		||||
                                    SalesOrderStatus, StockStatus)
 | 
			
		||||
                                    ReturnOrderStatus, SalesOrderStatus,
 | 
			
		||||
                                    StockStatus)
 | 
			
		||||
 | 
			
		||||
register = template.Library()
 | 
			
		||||
 | 
			
		||||
@@ -21,6 +22,12 @@ def sales_order_status_label(key, *args, **kwargs):
 | 
			
		||||
    return mark_safe(SalesOrderStatus.render(key, large=kwargs.get('large', False)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register.simple_tag
 | 
			
		||||
def return_order_status_label(key, *args, **kwargs):
 | 
			
		||||
    """Render a ReturnOrder status label"""
 | 
			
		||||
    return mark_safe(ReturnOrderStatus.render(key, large=kwargs.get('large', False)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register.simple_tag
 | 
			
		||||
def stock_status_label(key, *args, **kwargs):
 | 
			
		||||
    """Render a StockItem status label."""
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@
 | 
			
		||||
- Display / Create / Edit / Delete SupplierPart
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
from django.urls import include, re_path
 | 
			
		||||
from django.urls import include, path, re_path
 | 
			
		||||
 | 
			
		||||
from . import views
 | 
			
		||||
 | 
			
		||||
@@ -35,7 +35,7 @@ part_urls = [
 | 
			
		||||
    re_path(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
 | 
			
		||||
 | 
			
		||||
    # Individual part using pk
 | 
			
		||||
    re_path(r'^(?P<pk>\d+)/', include(part_detail_urls)),
 | 
			
		||||
    path(r'<int:pk>/', include(part_detail_urls)),
 | 
			
		||||
 | 
			
		||||
    # Part category
 | 
			
		||||
    re_path(r'^category/', include(category_urls)),
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user