mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master' into part-stocktake
# Conflicts: # InvenTree/InvenTree/api_version.py
This commit is contained in:
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -9,6 +9,8 @@ jobs:
|
|||||||
|
|
||||||
stable:
|
stable:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
|
||||||
|
@ -123,13 +123,13 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
|
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
def get(self, url, data=None, expected_code=200):
|
def get(self, url, data=None, expected_code=200, format='json'):
|
||||||
"""Issue a GET request."""
|
"""Issue a GET request."""
|
||||||
# Set default - see B006
|
# Set default - see B006
|
||||||
if data is None:
|
if data is None:
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
response = self.client.get(url, data, format='json')
|
response = self.client.get(url, data, format=format)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
|
|
||||||
|
@ -2,14 +2,20 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 84
|
INVENTREE_API_VERSION = 86
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
v84 -> 2022-12-16 : https://github.com/inventree/InvenTree/pull/4069
|
v86 -> 2022-12-22 : https://github.com/inventree/InvenTree/pull/4069
|
||||||
- Adds API endpoints for part stocktake
|
- Adds API endpoints for part stocktake
|
||||||
|
|
||||||
|
v85 -> 2022-12-21 : https://github.com/inventree/InvenTree/pull/3858
|
||||||
|
- Add endpoints serving ICS calendars for purchase and sales orders through API
|
||||||
|
|
||||||
|
v84 -> 2022-12-21: https://github.com/inventree/InvenTree/pull/4083
|
||||||
|
- Add support for listing PO, BO, SO by their reference
|
||||||
|
|
||||||
v83 -> 2022-11-19 : https://github.com/inventree/InvenTree/pull/3949
|
v83 -> 2022-11-19 : https://github.com/inventree/InvenTree/pull/3949
|
||||||
- Add support for structural Stock locations
|
- Add support for structural Stock locations
|
||||||
|
|
||||||
|
@ -204,6 +204,8 @@ INSTALLED_APPS = [
|
|||||||
'django_otp.plugins.otp_static', # Backup codes
|
'django_otp.plugins.otp_static', # Backup codes
|
||||||
|
|
||||||
'allauth_2fa', # MFA flow for allauth
|
'allauth_2fa', # MFA flow for allauth
|
||||||
|
|
||||||
|
'django_ical', # For exporting calendars
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = CONFIG.get('middleware', [
|
MIDDLEWARE = CONFIG.get('middleware', [
|
||||||
@ -789,6 +791,9 @@ MARKDOWNIFY = {
|
|||||||
'src',
|
'src',
|
||||||
'alt',
|
'alt',
|
||||||
],
|
],
|
||||||
|
'MARKDOWN_EXTENSIONS': [
|
||||||
|
'markdown.extensions.extra'
|
||||||
|
],
|
||||||
'WHITELIST_TAGS': [
|
'WHITELIST_TAGS': [
|
||||||
'a',
|
'a',
|
||||||
'abbr',
|
'abbr',
|
||||||
@ -802,7 +807,13 @@ MARKDOWNIFY = {
|
|||||||
'ol',
|
'ol',
|
||||||
'p',
|
'p',
|
||||||
'strong',
|
'strong',
|
||||||
'ul'
|
'ul',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'tbody',
|
||||||
|
'th',
|
||||||
|
'tr',
|
||||||
|
'td'
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@ -338,7 +339,16 @@ def check_for_updates():
|
|||||||
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
||||||
return
|
return
|
||||||
|
|
||||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
headers = {}
|
||||||
|
|
||||||
|
# If running within github actions, use authentication token
|
||||||
|
if settings.TESTING:
|
||||||
|
token = os.getenv('GITHUB_TOKEN', None)
|
||||||
|
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f"Bearer {token}"
|
||||||
|
|
||||||
|
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest', headers=headers)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover
|
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover
|
||||||
|
@ -65,6 +65,13 @@ class BuildFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
# Exact match for reference
|
||||||
|
reference = rest_filters.CharFilter(
|
||||||
|
label='Filter by exact reference',
|
||||||
|
field_name='reference',
|
||||||
|
lookup_expr="iexact"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BuildList(APIDownloadMixin, ListCreateAPI):
|
class BuildList(APIDownloadMixin, ListCreateAPI):
|
||||||
"""API endpoint for accessing a list of Build objects.
|
"""API endpoint for accessing a list of Build objects.
|
||||||
|
@ -832,6 +832,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
exclude_location = kwargs.get('exclude_location', None)
|
exclude_location = kwargs.get('exclude_location', None)
|
||||||
interchangeable = kwargs.get('interchangeable', False)
|
interchangeable = kwargs.get('interchangeable', False)
|
||||||
substitutes = kwargs.get('substitutes', True)
|
substitutes = kwargs.get('substitutes', True)
|
||||||
|
optional_items = kwargs.get('optional_items', False)
|
||||||
|
|
||||||
def stock_sort(item, bom_item, variant_parts):
|
def stock_sort(item, bom_item, variant_parts):
|
||||||
if item.part == bom_item.sub_part:
|
if item.part == bom_item.sub_part:
|
||||||
@ -848,6 +849,10 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
# Do not auto-allocate stock to consumable BOM items
|
# Do not auto-allocate stock to consumable BOM items
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if bom_item.optional and not optional_items:
|
||||||
|
# User has specified that optional_items are to be ignored
|
||||||
|
continue
|
||||||
|
|
||||||
variant_parts = bom_item.sub_part.get_descendants(include_self=False)
|
variant_parts = bom_item.sub_part.get_descendants(include_self=False)
|
||||||
|
|
||||||
unallocated_quantity = self.unallocated_quantity(bom_item)
|
unallocated_quantity = self.unallocated_quantity(bom_item)
|
||||||
|
@ -812,6 +812,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
|||||||
'exclude_location',
|
'exclude_location',
|
||||||
'interchangeable',
|
'interchangeable',
|
||||||
'substitutes',
|
'substitutes',
|
||||||
|
'optional_items',
|
||||||
]
|
]
|
||||||
|
|
||||||
location = serializers.PrimaryKeyRelatedField(
|
location = serializers.PrimaryKeyRelatedField(
|
||||||
@ -844,6 +845,12 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
|||||||
help_text=_('Allow allocation of substitute parts'),
|
help_text=_('Allow allocation of substitute parts'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
optional_items = serializers.BooleanField(
|
||||||
|
default=False,
|
||||||
|
label=_('Optional Items'),
|
||||||
|
help_text=_('Allocate optional BOM items to build order'),
|
||||||
|
)
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""Perform the auto-allocation step"""
|
"""Perform the auto-allocation step"""
|
||||||
data = self.validated_data
|
data = self.validated_data
|
||||||
@ -855,6 +862,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
|
|||||||
exclude_location=data.get('exclude_location', None),
|
exclude_location=data.get('exclude_location', None),
|
||||||
interchangeable=data['interchangeable'],
|
interchangeable=data['interchangeable'],
|
||||||
substitutes=data['substitutes'],
|
substitutes=data['substitutes'],
|
||||||
|
optional_items=data['optional_items'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -63,6 +63,14 @@ class TestBuildAPI(InvenTreeAPITestCase):
|
|||||||
response = self.client.get(url, {'part': 99999}, format='json')
|
response = self.client.get(url, {'part': 99999}, format='json')
|
||||||
self.assertEqual(len(response.data), 0)
|
self.assertEqual(len(response.data), 0)
|
||||||
|
|
||||||
|
# Get a certain reference
|
||||||
|
response = self.client.get(url, {'reference': 'BO-0001'}, format='json')
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
|
||||||
|
# Get a certain reference
|
||||||
|
response = self.client.get(url, {'reference': 'BO-9999XX'}, format='json')
|
||||||
|
self.assertEqual(len(response.data), 0)
|
||||||
|
|
||||||
def test_get_build_item_list(self):
|
def test_get_build_item_list(self):
|
||||||
"""Test that we can retrieve list of BuildItem objects."""
|
"""Test that we can retrieve list of BuildItem objects."""
|
||||||
url = reverse('api-build-item-list')
|
url = reverse('api-build-item-list')
|
||||||
|
@ -82,7 +82,8 @@ class BuildTestBase(TestCase):
|
|||||||
self.bom_item_2 = BomItem.objects.create(
|
self.bom_item_2 = BomItem.objects.create(
|
||||||
part=self.assembly,
|
part=self.assembly,
|
||||||
sub_part=self.sub_part_2,
|
sub_part=self.sub_part_2,
|
||||||
quantity=3
|
quantity=3,
|
||||||
|
optional=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# sub_part_3 is trackable!
|
# sub_part_3 is trackable!
|
||||||
@ -626,6 +627,7 @@ class AutoAllocationTests(BuildTestBase):
|
|||||||
self.build.auto_allocate_stock(
|
self.build.auto_allocate_stock(
|
||||||
interchangeable=True,
|
interchangeable=True,
|
||||||
substitutes=False,
|
substitutes=False,
|
||||||
|
optional_items=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertFalse(self.build.are_untracked_parts_allocated())
|
self.assertFalse(self.build.are_untracked_parts_allocated())
|
||||||
@ -646,17 +648,18 @@ class AutoAllocationTests(BuildTestBase):
|
|||||||
|
|
||||||
# self.assertEqual(self.build.allocated_stock.count(), 8)
|
# self.assertEqual(self.build.allocated_stock.count(), 8)
|
||||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5.0)
|
||||||
|
|
||||||
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
|
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
|
||||||
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_2))
|
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
|
||||||
|
|
||||||
def test_fully_auto(self):
|
def test_fully_auto(self):
|
||||||
"""We should be able to auto-allocate against a build in a single go"""
|
"""We should be able to auto-allocate against a build in a single go"""
|
||||||
|
|
||||||
self.build.auto_allocate_stock(
|
self.build.auto_allocate_stock(
|
||||||
interchangeable=True,
|
interchangeable=True,
|
||||||
substitutes=True
|
substitutes=True,
|
||||||
|
optional_items=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(self.build.are_untracked_parts_allocated())
|
self.assertTrue(self.build.are_untracked_parts_allocated())
|
||||||
|
@ -1215,6 +1215,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'SERIAL_NUMBER_AUTOFILL': {
|
||||||
|
'name': _('Autofill Serial Numbers'),
|
||||||
|
'description': _('Autofill serial numbers in forms'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
|
||||||
'STOCK_BATCH_CODE_TEMPLATE': {
|
'STOCK_BATCH_CODE_TEMPLATE': {
|
||||||
'name': _('Batch Code Template'),
|
'name': _('Batch Code Template'),
|
||||||
'description': _('Template for generating default batch codes for stock items'),
|
'description': _('Template for generating default batch codes for stock items'),
|
||||||
|
@ -1,17 +1,23 @@
|
|||||||
"""JSON API for the Order app."""
|
"""JSON API for the Order app."""
|
||||||
|
|
||||||
|
from django.contrib.auth import authenticate, login
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
|
from django.db.utils import ProgrammingError
|
||||||
|
from django.http.response import JsonResponse
|
||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
|
from django_ical.views import ICalFeed
|
||||||
from rest_framework import filters, status
|
from rest_framework import filters, status
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
import order.models as models
|
import order.models as models
|
||||||
import order.serializers as serializers
|
import order.serializers as serializers
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
from common.settings import settings
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
||||||
ListCreateDestroyAPIView)
|
ListCreateDestroyAPIView)
|
||||||
@ -78,8 +84,15 @@ class GeneralExtraLineList:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderFilter(rest_filters.FilterSet):
|
class OrderFilter(rest_filters.FilterSet):
|
||||||
"""Custom API filters for the PurchaseOrderList endpoint."""
|
"""Base class for custom API filters for the OrderList endpoint."""
|
||||||
|
|
||||||
|
# Exact match for reference
|
||||||
|
reference = rest_filters.CharFilter(
|
||||||
|
label='Filter by exact reference',
|
||||||
|
field_name='reference',
|
||||||
|
lookup_expr="iexact"
|
||||||
|
)
|
||||||
|
|
||||||
assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
|
assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
|
||||||
|
|
||||||
@ -97,6 +110,9 @@ class PurchaseOrderFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseOrderFilter(OrderFilter):
|
||||||
|
"""Custom API filters for the PurchaseOrderList endpoint."""
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass options."""
|
"""Metaclass options."""
|
||||||
|
|
||||||
@ -106,6 +122,17 @@ class PurchaseOrderFilter(rest_filters.FilterSet):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SalesOrderFilter(OrderFilter):
|
||||||
|
"""Custom API filters for the SalesOrderList endpoint."""
|
||||||
|
class Meta:
|
||||||
|
"""Metaclass options."""
|
||||||
|
|
||||||
|
model = models.SalesOrder
|
||||||
|
fields = [
|
||||||
|
'customer',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderList(APIDownloadMixin, ListCreateAPI):
|
class PurchaseOrderList(APIDownloadMixin, ListCreateAPI):
|
||||||
"""API endpoint for accessing a list of PurchaseOrder objects.
|
"""API endpoint for accessing a list of PurchaseOrder objects.
|
||||||
|
|
||||||
@ -613,6 +640,7 @@ class SalesOrderList(APIDownloadMixin, ListCreateAPI):
|
|||||||
|
|
||||||
queryset = models.SalesOrder.objects.all()
|
queryset = models.SalesOrder.objects.all()
|
||||||
serializer_class = serializers.SalesOrderSerializer
|
serializer_class = serializers.SalesOrderSerializer
|
||||||
|
filterset_class = SalesOrderFilter
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""Save user information on create."""
|
"""Save user information on create."""
|
||||||
@ -1146,6 +1174,155 @@ class PurchaseOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
|||||||
serializer_class = serializers.PurchaseOrderAttachmentSerializer
|
serializer_class = serializers.PurchaseOrderAttachmentSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class OrderCalendarExport(ICalFeed):
|
||||||
|
"""Calendar export for Purchase/Sales Orders
|
||||||
|
|
||||||
|
Optional parameters:
|
||||||
|
- include_completed: true/false
|
||||||
|
whether or not to show completed orders. Defaults to false
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
instance_url = InvenTreeSetting.get_setting('INVENTREE_BASE_URL', create=False, cache=False)
|
||||||
|
except ProgrammingError: # pragma: no cover
|
||||||
|
# database is not initialized yet
|
||||||
|
instance_url = ''
|
||||||
|
instance_url = instance_url.replace("http://", "").replace("https://", "")
|
||||||
|
timezone = settings.TIME_ZONE
|
||||||
|
file_name = "calendar.ics"
|
||||||
|
|
||||||
|
def __call__(self, request, *args, **kwargs):
|
||||||
|
"""Overload call in order to check for authentication.
|
||||||
|
|
||||||
|
This is required to force Django to look for the authentication,
|
||||||
|
otherwise login request with Basic auth via curl or similar are ignored,
|
||||||
|
and login via a calendar client will not work.
|
||||||
|
|
||||||
|
See:
|
||||||
|
https://stackoverflow.com/questions/3817694/django-rss-feed-authentication
|
||||||
|
https://stackoverflow.com/questions/152248/can-i-use-http-basic-authentication-with-django
|
||||||
|
https://www.djangosnippets.org/snippets/243/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
# Authenticated on first try - maybe normal browser call?
|
||||||
|
return super().__call__(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# No login yet - check in headers
|
||||||
|
if 'HTTP_AUTHORIZATION' in request.META:
|
||||||
|
auth = request.META['HTTP_AUTHORIZATION'].split()
|
||||||
|
if len(auth) == 2:
|
||||||
|
# NOTE: We are only support basic authentication for now.
|
||||||
|
#
|
||||||
|
if auth[0].lower() == "basic":
|
||||||
|
uname, passwd = base64.b64decode(auth[1]).decode("ascii").split(':')
|
||||||
|
user = authenticate(username=uname, password=passwd)
|
||||||
|
if user is not None:
|
||||||
|
if user.is_active:
|
||||||
|
login(request, user)
|
||||||
|
request.user = user
|
||||||
|
|
||||||
|
# Check again
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
# Authenticated after second try
|
||||||
|
return super().__call__(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# Still nothing - return Unauth. header with info on how to authenticate
|
||||||
|
# Information is needed by client, eg Thunderbird
|
||||||
|
response = JsonResponse({"detail": "Authentication credentials were not provided."})
|
||||||
|
response['WWW-Authenticate'] = 'Basic realm="api"'
|
||||||
|
response.status_code = 401
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_object(self, request, *args, **kwargs):
|
||||||
|
"""This is where settings from the URL etc will be obtained"""
|
||||||
|
# Help:
|
||||||
|
# https://django.readthedocs.io/en/stable/ref/contrib/syndication.html
|
||||||
|
|
||||||
|
obj = dict()
|
||||||
|
obj['ordertype'] = kwargs['ordertype']
|
||||||
|
obj['include_completed'] = bool(request.GET.get('include_completed', False))
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def title(self, obj):
|
||||||
|
"""Return calendar title."""
|
||||||
|
|
||||||
|
if obj["ordertype"] == 'purchase-order':
|
||||||
|
ordertype_title = _('Purchase Order')
|
||||||
|
elif obj["ordertype"] == 'sales-order':
|
||||||
|
ordertype_title = _('Sales Order')
|
||||||
|
else:
|
||||||
|
ordertype_title = _('Unknown')
|
||||||
|
|
||||||
|
return f'{InvenTreeSetting.get_setting("INVENTREE_COMPANY_NAME")} {ordertype_title}'
|
||||||
|
|
||||||
|
def product_id(self, obj):
|
||||||
|
"""Return calendar product id."""
|
||||||
|
return f'//{self.instance_url}//{self.title(obj)}//EN'
|
||||||
|
|
||||||
|
def items(self, obj):
|
||||||
|
"""Return a list of PurchaseOrders.
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
- Only return those which have a target_date set
|
||||||
|
- Only return incomplete orders, unless include_completed is set to True
|
||||||
|
"""
|
||||||
|
if obj['ordertype'] == 'purchase-order':
|
||||||
|
if obj['include_completed'] is False:
|
||||||
|
# Do not include completed orders from list in this case
|
||||||
|
# Completed status = 30
|
||||||
|
outlist = models.PurchaseOrder.objects.filter(target_date__isnull=False).filter(status__lt=PurchaseOrderStatus.COMPLETE)
|
||||||
|
else:
|
||||||
|
outlist = models.PurchaseOrder.objects.filter(target_date__isnull=False)
|
||||||
|
else:
|
||||||
|
if obj['include_completed'] is False:
|
||||||
|
# Do not include completed (=shipped) orders from list in this case
|
||||||
|
# Shipped status = 20
|
||||||
|
outlist = models.SalesOrder.objects.filter(target_date__isnull=False).filter(status__lt=SalesOrderStatus.SHIPPED)
|
||||||
|
else:
|
||||||
|
outlist = models.SalesOrder.objects.filter(target_date__isnull=False)
|
||||||
|
|
||||||
|
return outlist
|
||||||
|
|
||||||
|
def item_title(self, item):
|
||||||
|
"""Set the event title to the purchase order reference"""
|
||||||
|
return item.reference
|
||||||
|
|
||||||
|
def item_description(self, item):
|
||||||
|
"""Set the event description"""
|
||||||
|
return item.description
|
||||||
|
|
||||||
|
def item_start_datetime(self, item):
|
||||||
|
"""Set event start to target date. Goal is all-day event."""
|
||||||
|
return item.target_date
|
||||||
|
|
||||||
|
def item_end_datetime(self, item):
|
||||||
|
"""Set event end to target date. Goal is all-day event."""
|
||||||
|
return item.target_date
|
||||||
|
|
||||||
|
def item_created(self, item):
|
||||||
|
"""Use creation date of PO as creation date of event."""
|
||||||
|
return item.creation_date
|
||||||
|
|
||||||
|
def item_class(self, item):
|
||||||
|
"""Set item class to PUBLIC"""
|
||||||
|
return 'PUBLIC'
|
||||||
|
|
||||||
|
def item_guid(self, item):
|
||||||
|
"""Return globally unique UID for event"""
|
||||||
|
return f'po_{item.pk}_{item.reference.replace(" ","-")}@{self.instance_url}'
|
||||||
|
|
||||||
|
def item_link(self, item):
|
||||||
|
"""Set the item link."""
|
||||||
|
|
||||||
|
# Do not use instance_url as here, as the protocol needs to be included
|
||||||
|
site_url = InvenTreeSetting.get_setting("INVENTREE_BASE_URL")
|
||||||
|
return f'{site_url}{item.get_absolute_url()}'
|
||||||
|
|
||||||
|
|
||||||
order_api_urls = [
|
order_api_urls = [
|
||||||
|
|
||||||
# API endpoints for purchase orders
|
# API endpoints for purchase orders
|
||||||
@ -1233,4 +1410,7 @@ order_api_urls = [
|
|||||||
path('<int:pk>/', SalesOrderAllocationDetail.as_view(), name='api-so-allocation-detail'),
|
path('<int:pk>/', SalesOrderAllocationDetail.as_view(), name='api-so-allocation-detail'),
|
||||||
re_path(r'^.*$', SalesOrderAllocationList.as_view(), name='api-so-allocation-list'),
|
re_path(r'^.*$', SalesOrderAllocationList.as_view(), name='api-so-allocation-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
# API endpoint for subscribing to ICS calendar of purchase/sales orders
|
||||||
|
re_path(r'^calendar/(?P<ordertype>purchase-order|sales-order)/calendar.ics', OrderCalendarExport(), name='api-po-so-calendar'),
|
||||||
]
|
]
|
||||||
|
@ -1,2 +1,99 @@
|
|||||||
{% extends "order/order_wizard/po_upload.html" %}
|
{% extends "order/order_wizard/po_upload.html" %}
|
||||||
{% include "patterns/wizard/match_fields.html" %}
|
{% load inventree_extras %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block form_alert %}
|
||||||
|
{% if missing_columns and missing_columns|length > 0 %}
|
||||||
|
<div class='alert alert-danger alert-block' style='margin-top:12px;' role='alert'>
|
||||||
|
{% trans "Missing selections for the following required columns" %}:
|
||||||
|
<br>
|
||||||
|
<ul>
|
||||||
|
{% for col in missing_columns %}
|
||||||
|
<li>{{ col }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if duplicates and duplicates|length > 0 %}
|
||||||
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
|
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock form_alert %}
|
||||||
|
|
||||||
|
{% block form_buttons_top %}
|
||||||
|
{% if wizard.steps.prev %}
|
||||||
|
<button name='wizard_goto_step' type='submit' value='{{ wizard.steps.prev }}' class='save btn btn-outline-secondary'>{% trans "Previous Step" %}</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type='submit' class='save btn btn-outline-secondary'>{% trans "Submit Selections" %}</button>
|
||||||
|
{% endblock form_buttons_top %}
|
||||||
|
|
||||||
|
{% block form_content %}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "File Fields" %}</th>
|
||||||
|
<th></th>
|
||||||
|
{% for col in form %}
|
||||||
|
<th>
|
||||||
|
<div>
|
||||||
|
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||||
|
{{ col.name }}
|
||||||
|
<button class='btn btn-outline-secondary btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
|
||||||
|
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{% trans "Match Fields" %}</td>
|
||||||
|
<td></td>
|
||||||
|
{% for col in form %}
|
||||||
|
<td>
|
||||||
|
{{ col }}
|
||||||
|
{% for duplicate in duplicates %}
|
||||||
|
{% if duplicate == col.value %}
|
||||||
|
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||||
|
<strong>{% trans "Duplicate selection" %}</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% for row in rows %}
|
||||||
|
{% with forloop.counter as row_index %}
|
||||||
|
<tr>
|
||||||
|
<td style='width: 32px;'>
|
||||||
|
<button class='btn btn-outline-secondary btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
|
||||||
|
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style='text-align: left;'>{{ row_index }}</td>
|
||||||
|
{% for item in row.data %}
|
||||||
|
<td>
|
||||||
|
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
|
||||||
|
{{ item }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% endblock form_content %}
|
||||||
|
|
||||||
|
{% block form_buttons_bottom %}
|
||||||
|
{% endblock form_buttons_bottom %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
$('.fieldselect').select2({
|
||||||
|
width: '100%',
|
||||||
|
matcher: partialMatcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
@ -12,12 +12,53 @@
|
|||||||
{% block page_content %}
|
{% block page_content %}
|
||||||
{% trans "Upload File for Purchase Order" as header_text %}
|
{% trans "Upload File for Purchase Order" as header_text %}
|
||||||
{% trans "Order is already processed. Files cannot be uploaded." as error_text %}
|
{% trans "Order is already processed. Files cannot be uploaded." as error_text %}
|
||||||
{% with "panel-upload-file" as panel_id %}
|
{% with panel_id="panel-upload-file" %}
|
||||||
|
|
||||||
|
<div class='panel' id='{{ panel_id }}'>
|
||||||
|
<div class='panel-heading'>
|
||||||
|
<h4>
|
||||||
|
{{ header_text }}
|
||||||
|
{{ wizard.form.media }}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class='panel-content'>
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
|
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
|
||||||
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=True error_text=error_text panel_id=panel_id %}
|
|
||||||
{% else %}
|
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||||
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=False error_text=error_text panel_id=panel_id %}
|
{% if description %}- {{ description }}{% endif %}</p>
|
||||||
|
|
||||||
|
{% block form_alert %}
|
||||||
|
{% endblock form_alert %}
|
||||||
|
|
||||||
|
<form action='' method='post' class='js-modal-form' enctype='multipart/form-data'>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block form_buttons_top %}
|
||||||
|
{% endblock form_buttons_top %}
|
||||||
|
|
||||||
|
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||||
|
{{ wizard.management_form }}
|
||||||
|
{% block form_content %}
|
||||||
|
{% crispy wizard.form %}
|
||||||
|
{% endblock form_content %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% block form_buttons_bottom %}
|
||||||
|
{% if wizard.steps.prev %}
|
||||||
|
<button name='wizard_goto_step' type='submit' value='{{ wizard.steps.prev }}' class='save btn btn-outline-secondary'>{% trans "Previous Step" %}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<button type='submit' class='save btn btn-outline-secondary'>{% trans "Upload File" %}</button>
|
||||||
|
</form>
|
||||||
|
{% endblock form_buttons_bottom %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class='alert alert-danger alert-block' role='alert'>
|
||||||
|
{{ error_text }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
"""Tests for the Order API."""
|
"""Tests for the Order API."""
|
||||||
|
|
||||||
|
import base64
|
||||||
import io
|
import io
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from icalendar import Calendar
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
import order.models as models
|
import order.models as models
|
||||||
@ -56,6 +58,10 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
# List *ALL* PurchaseOrder items
|
# List *ALL* PurchaseOrder items
|
||||||
self.filter({}, 7)
|
self.filter({}, 7)
|
||||||
|
|
||||||
|
# Filter by assigned-to-me
|
||||||
|
self.filter({'assigned_to_me': 1}, 0)
|
||||||
|
self.filter({'assigned_to_me': 0}, 7)
|
||||||
|
|
||||||
# Filter by supplier
|
# Filter by supplier
|
||||||
self.filter({'supplier': 1}, 1)
|
self.filter({'supplier': 1}, 1)
|
||||||
self.filter({'supplier': 3}, 5)
|
self.filter({'supplier': 3}, 5)
|
||||||
@ -68,6 +74,23 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
self.filter({'status': 10}, 3)
|
self.filter({'status': 10}, 3)
|
||||||
self.filter({'status': 40}, 1)
|
self.filter({'status': 40}, 1)
|
||||||
|
|
||||||
|
# Filter by "reference"
|
||||||
|
self.filter({'reference': 'PO-0001'}, 1)
|
||||||
|
self.filter({'reference': 'PO-9999'}, 0)
|
||||||
|
|
||||||
|
# Filter by "assigned_to_me"
|
||||||
|
self.filter({'assigned_to_me': 1}, 0)
|
||||||
|
self.filter({'assigned_to_me': 0}, 7)
|
||||||
|
|
||||||
|
# Filter by "part"
|
||||||
|
self.filter({'part': 1}, 2)
|
||||||
|
self.filter({'part': 2}, 0) # Part not assigned to any PO
|
||||||
|
|
||||||
|
# Filter by "supplier_part"
|
||||||
|
self.filter({'supplier_part': 1}, 1)
|
||||||
|
self.filter({'supplier_part': 3}, 2)
|
||||||
|
self.filter({'supplier_part': 4}, 0)
|
||||||
|
|
||||||
def test_overdue(self):
|
def test_overdue(self):
|
||||||
"""Test "overdue" status."""
|
"""Test "overdue" status."""
|
||||||
self.filter({'overdue': True}, 0)
|
self.filter({'overdue': True}, 0)
|
||||||
@ -247,6 +270,20 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
del data['pk']
|
del data['pk']
|
||||||
del data['reference']
|
del data['reference']
|
||||||
|
|
||||||
|
# Duplicate with non-existent PK to provoke error
|
||||||
|
data['duplicate_order'] = 10000001
|
||||||
|
data['duplicate_line_items'] = True
|
||||||
|
data['duplicate_extra_lines'] = False
|
||||||
|
|
||||||
|
data['reference'] = 'PO-9999'
|
||||||
|
|
||||||
|
# Duplicate via the API
|
||||||
|
response = self.post(
|
||||||
|
reverse('api-po-list'),
|
||||||
|
data,
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
data['duplicate_order'] = 1
|
data['duplicate_order'] = 1
|
||||||
data['duplicate_line_items'] = True
|
data['duplicate_line_items'] = True
|
||||||
data['duplicate_extra_lines'] = False
|
data['duplicate_extra_lines'] = False
|
||||||
@ -374,6 +411,134 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
order = models.PurchaseOrder.objects.get(pk=1)
|
order = models.PurchaseOrder.objects.get(pk=1)
|
||||||
self.assertEqual(order.get_metadata('yam'), 'yum')
|
self.assertEqual(order.get_metadata('yam'), 'yum')
|
||||||
|
|
||||||
|
def test_po_calendar(self):
|
||||||
|
"""Test the calendar export endpoint"""
|
||||||
|
|
||||||
|
# Create required purchase orders
|
||||||
|
self.assignRole('purchase_order.add')
|
||||||
|
|
||||||
|
for i in range(1, 9):
|
||||||
|
self.post(
|
||||||
|
reverse('api-po-list'),
|
||||||
|
{
|
||||||
|
'reference': f'PO-1100000{i}',
|
||||||
|
'supplier': 1,
|
||||||
|
'description': f'Calendar PO {i}',
|
||||||
|
'target_date': f'2024-12-{i:02d}',
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get some of these orders with target date, complete or cancel them
|
||||||
|
for po in models.PurchaseOrder.objects.filter(target_date__isnull=False):
|
||||||
|
if po.reference in ['PO-11000001', 'PO-11000002', 'PO-11000003', 'PO-11000004']:
|
||||||
|
# Set issued status for these POs
|
||||||
|
self.post(
|
||||||
|
reverse('api-po-issue', kwargs={'pk': po.pk}),
|
||||||
|
{},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
if po.reference in ['PO-11000001', 'PO-11000002']:
|
||||||
|
# Set complete status for these POs
|
||||||
|
self.post(
|
||||||
|
reverse('api-po-complete', kwargs={'pk': po.pk}),
|
||||||
|
{
|
||||||
|
'accept_incomplete': True,
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
elif po.reference in ['PO-11000005', 'PO-11000006']:
|
||||||
|
# Set cancel status for these POs
|
||||||
|
self.post(
|
||||||
|
reverse('api-po-cancel', kwargs={'pk': po.pk}),
|
||||||
|
{
|
||||||
|
'accept_incomplete': True,
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'})
|
||||||
|
|
||||||
|
# Test without completed orders
|
||||||
|
response = self.get(url, expected_code=200, format=None)
|
||||||
|
|
||||||
|
number_orders = len(models.PurchaseOrder.objects.filter(target_date__isnull=False).filter(status__lt=PurchaseOrderStatus.COMPLETE))
|
||||||
|
|
||||||
|
# Transform content to a Calendar object
|
||||||
|
calendar = Calendar.from_ical(response.content)
|
||||||
|
n_events = 0
|
||||||
|
# Count number of events in calendar
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == 'VEVENT':
|
||||||
|
n_events += 1
|
||||||
|
|
||||||
|
self.assertGreaterEqual(n_events, 1)
|
||||||
|
self.assertEqual(number_orders, n_events)
|
||||||
|
|
||||||
|
# Test with completed orders
|
||||||
|
response = self.get(url, data={'include_completed': 'True'}, expected_code=200, format=None)
|
||||||
|
|
||||||
|
number_orders_incl_completed = len(models.PurchaseOrder.objects.filter(target_date__isnull=False))
|
||||||
|
|
||||||
|
self.assertGreater(number_orders_incl_completed, number_orders)
|
||||||
|
|
||||||
|
# Transform content to a Calendar object
|
||||||
|
calendar = Calendar.from_ical(response.content)
|
||||||
|
n_events = 0
|
||||||
|
# Count number of events in calendar
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == 'VEVENT':
|
||||||
|
n_events += 1
|
||||||
|
|
||||||
|
self.assertGreaterEqual(n_events, 1)
|
||||||
|
self.assertEqual(number_orders_incl_completed, n_events)
|
||||||
|
|
||||||
|
def test_po_calendar_noauth(self):
|
||||||
|
"""Test accessing calendar without authorization"""
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'}), format='json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
resp_dict = response.json()
|
||||||
|
self.assertEqual(resp_dict['detail'], "Authentication credentials were not provided.")
|
||||||
|
|
||||||
|
def test_po_calendar_auth(self):
|
||||||
|
"""Test accessing calendar with header authorization"""
|
||||||
|
self.client.logout()
|
||||||
|
base64_token = base64.b64encode(f'{self.username}:{self.password}'.encode('ascii')).decode('ascii')
|
||||||
|
response = self.client.get(
|
||||||
|
reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'}),
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'basic {base64_token}'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseOrderLineItemTest(OrderTest):
|
||||||
|
"""Unit tests for PurchaseOrderLineItems."""
|
||||||
|
|
||||||
|
LIST_URL = reverse('api-po-line-list')
|
||||||
|
|
||||||
|
def test_po_line_list(self):
|
||||||
|
"""Test the PurchaseOrderLine list API endpoint"""
|
||||||
|
# List *ALL* PurchaseOrderLine items
|
||||||
|
self.filter({}, 5)
|
||||||
|
|
||||||
|
# Filter by pending status
|
||||||
|
self.filter({'pending': 1}, 5)
|
||||||
|
self.filter({'pending': 0}, 0)
|
||||||
|
|
||||||
|
# Filter by received status
|
||||||
|
self.filter({'received': 1}, 0)
|
||||||
|
self.filter({'received': 0}, 5)
|
||||||
|
|
||||||
|
# Filter by has_pricing status
|
||||||
|
self.filter({'has_pricing': 1}, 0)
|
||||||
|
self.filter({'has_pricing': 0}, 5)
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderDownloadTest(OrderTest):
|
class PurchaseOrderDownloadTest(OrderTest):
|
||||||
"""Unit tests for downloading PurchaseOrder data via the API endpoint."""
|
"""Unit tests for downloading PurchaseOrder data via the API endpoint."""
|
||||||
@ -828,6 +993,14 @@ class SalesOrderTest(OrderTest):
|
|||||||
self.filter({'status': 20}, 1) # SHIPPED
|
self.filter({'status': 20}, 1) # SHIPPED
|
||||||
self.filter({'status': 99}, 0) # Invalid
|
self.filter({'status': 99}, 0) # Invalid
|
||||||
|
|
||||||
|
# Filter by "reference"
|
||||||
|
self.filter({'reference': 'ABC123'}, 1)
|
||||||
|
self.filter({'reference': 'XXX999'}, 0)
|
||||||
|
|
||||||
|
# Filter by "assigned_to_me"
|
||||||
|
self.filter({'assigned_to_me': 1}, 0)
|
||||||
|
self.filter({'assigned_to_me': 0}, 5)
|
||||||
|
|
||||||
def test_overdue(self):
|
def test_overdue(self):
|
||||||
"""Test "overdue" status."""
|
"""Test "overdue" status."""
|
||||||
self.filter({'overdue': True}, 0)
|
self.filter({'overdue': True}, 0)
|
||||||
@ -1011,10 +1184,73 @@ class SalesOrderTest(OrderTest):
|
|||||||
order = models.SalesOrder.objects.get(pk=1)
|
order = models.SalesOrder.objects.get(pk=1)
|
||||||
self.assertEqual(order.get_metadata('xyz'), 'abc')
|
self.assertEqual(order.get_metadata('xyz'), 'abc')
|
||||||
|
|
||||||
|
def test_so_calendar(self):
|
||||||
|
"""Test the calendar export endpoint"""
|
||||||
|
|
||||||
|
# Create required sales orders
|
||||||
|
self.assignRole('sales_order.add')
|
||||||
|
|
||||||
|
for i in range(1, 9):
|
||||||
|
self.post(
|
||||||
|
reverse('api-so-list'),
|
||||||
|
{
|
||||||
|
'reference': f'SO-1100000{i}',
|
||||||
|
'customer': 4,
|
||||||
|
'description': f'Calendar SO {i}',
|
||||||
|
'target_date': f'2024-12-{i:02d}',
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel a few orders - these will not show in incomplete view below
|
||||||
|
for so in models.SalesOrder.objects.filter(target_date__isnull=False):
|
||||||
|
if so.reference in ['SO-11000006', 'SO-11000007', 'SO-11000008', 'SO-11000009']:
|
||||||
|
self.post(
|
||||||
|
reverse('api-so-cancel', kwargs={'pk': so.pk}),
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse('api-po-so-calendar', kwargs={'ordertype': 'sales-order'})
|
||||||
|
|
||||||
|
# Test without completed orders
|
||||||
|
response = self.get(url, expected_code=200, format=None)
|
||||||
|
|
||||||
|
number_orders = len(models.SalesOrder.objects.filter(target_date__isnull=False).filter(status__lt=SalesOrderStatus.SHIPPED))
|
||||||
|
|
||||||
|
# Transform content to a Calendar object
|
||||||
|
calendar = Calendar.from_ical(response.content)
|
||||||
|
n_events = 0
|
||||||
|
# Count number of events in calendar
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == 'VEVENT':
|
||||||
|
n_events += 1
|
||||||
|
|
||||||
|
self.assertGreaterEqual(n_events, 1)
|
||||||
|
self.assertEqual(number_orders, n_events)
|
||||||
|
|
||||||
|
# Test with completed orders
|
||||||
|
response = self.get(url, data={'include_completed': 'True'}, expected_code=200, format=None)
|
||||||
|
|
||||||
|
number_orders_incl_complete = len(models.SalesOrder.objects.filter(target_date__isnull=False))
|
||||||
|
self.assertGreater(number_orders_incl_complete, number_orders)
|
||||||
|
|
||||||
|
# Transform content to a Calendar object
|
||||||
|
calendar = Calendar.from_ical(response.content)
|
||||||
|
n_events = 0
|
||||||
|
# Count number of events in calendar
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == 'VEVENT':
|
||||||
|
n_events += 1
|
||||||
|
|
||||||
|
self.assertGreaterEqual(n_events, 1)
|
||||||
|
self.assertEqual(number_orders_incl_complete, n_events)
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderLineItemTest(OrderTest):
|
class SalesOrderLineItemTest(OrderTest):
|
||||||
"""Tests for the SalesOrderLineItem API."""
|
"""Tests for the SalesOrderLineItem API."""
|
||||||
|
|
||||||
|
LIST_URL = reverse('api-so-line-list')
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Init routine for this unit test class"""
|
"""Init routine for this unit test class"""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
@ -1087,6 +1323,14 @@ class SalesOrderLineItemTest(OrderTest):
|
|||||||
|
|
||||||
self.assertEqual(response.data['count'], n_parts)
|
self.assertEqual(response.data['count'], n_parts)
|
||||||
|
|
||||||
|
# Filter by has_pricing status
|
||||||
|
self.filter({'has_pricing': 1}, 0)
|
||||||
|
self.filter({'has_pricing': 0}, n)
|
||||||
|
|
||||||
|
# Filter by has_pricing status
|
||||||
|
self.filter({'completed': 1}, 0)
|
||||||
|
self.filter({'completed': 0}, n)
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderDownloadTest(OrderTest):
|
class SalesOrderDownloadTest(OrderTest):
|
||||||
"""Unit tests for downloading SalesOrder data via the API endpoint."""
|
"""Unit tests for downloading SalesOrder data via the API endpoint."""
|
||||||
|
@ -148,7 +148,7 @@ class PartCategory(MetadataMixin, InvenTreeTree):
|
|||||||
|
|
||||||
- Ensure that the structural parameter cannot get set if products already assigned to the category
|
- Ensure that the structural parameter cannot get set if products already assigned to the category
|
||||||
"""
|
"""
|
||||||
if self.pk and self.structural and self.item_count > 0:
|
if self.pk and self.structural and self.partcount(False, False) > 0:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("You cannot make this part category structural because some parts "
|
_("You cannot make this part category structural because some parts "
|
||||||
"are already assigned to it!"))
|
"are already assigned to it!"))
|
||||||
|
@ -462,6 +462,7 @@ class PluginsRegistry:
|
|||||||
self.activate_plugin_settings(plugins)
|
self.activate_plugin_settings(plugins)
|
||||||
self.activate_plugin_schedule(plugins)
|
self.activate_plugin_schedule(plugins)
|
||||||
self.activate_plugin_app(plugins, force_reload=force_reload, full_reload=full_reload)
|
self.activate_plugin_app(plugins, force_reload=force_reload, full_reload=full_reload)
|
||||||
|
self.activate_plugin_url(plugins, force_reload=force_reload, full_reload=full_reload)
|
||||||
|
|
||||||
def _deactivate_plugins(self):
|
def _deactivate_plugins(self):
|
||||||
"""Run deactivation functions for all plugins."""
|
"""Run deactivation functions for all plugins."""
|
||||||
@ -564,7 +565,6 @@ class PluginsRegistry:
|
|||||||
settings.INSTALLED_APPS += [plugin_path]
|
settings.INSTALLED_APPS += [plugin_path]
|
||||||
self.installed_apps += [plugin_path]
|
self.installed_apps += [plugin_path]
|
||||||
apps_changed = True
|
apps_changed = True
|
||||||
|
|
||||||
# if apps were changed or force loading base apps -> reload
|
# if apps were changed or force loading base apps -> reload
|
||||||
if apps_changed or force_reload:
|
if apps_changed or force_reload:
|
||||||
# first startup or force loading of base apps -> registry is prob false
|
# first startup or force loading of base apps -> registry is prob false
|
||||||
@ -580,6 +580,27 @@ class PluginsRegistry:
|
|||||||
# update urls - must be last as models must be registered for creating admin routes
|
# update urls - must be last as models must be registered for creating admin routes
|
||||||
self._update_urls()
|
self._update_urls()
|
||||||
|
|
||||||
|
def activate_plugin_url(self, plugins, force_reload=False, full_reload: bool = False):
|
||||||
|
"""Activate UrlsMixin plugins - add custom urls .
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugins (dict): List of IntegrationPlugins that should be installed
|
||||||
|
force_reload (bool, optional): Only reload base apps. Defaults to False.
|
||||||
|
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
|
||||||
|
"""
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
|
||||||
|
logger.info('Registering UrlsMixin Plugin')
|
||||||
|
urls_changed = False
|
||||||
|
# check whether an activated plugin extends UrlsMixin
|
||||||
|
for _key, plugin in plugins:
|
||||||
|
if plugin.mixin_enabled('urls'):
|
||||||
|
urls_changed = True
|
||||||
|
# if apps were changed or force loading base apps -> reload
|
||||||
|
if urls_changed or force_reload or full_reload:
|
||||||
|
# update urls - must be last as models must be registered for creating admin routes
|
||||||
|
self._update_urls()
|
||||||
|
|
||||||
def _reregister_contrib_apps(self):
|
def _reregister_contrib_apps(self):
|
||||||
"""Fix reloading of contrib apps - models and admin.
|
"""Fix reloading of contrib apps - models and admin.
|
||||||
|
|
||||||
|
@ -152,7 +152,7 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
|
|||||||
|
|
||||||
- Ensure stock location can't be made structural if stock items already located to them
|
- Ensure stock location can't be made structural if stock items already located to them
|
||||||
"""
|
"""
|
||||||
if self.pk and self.structural and self.item_count > 0:
|
if self.pk and self.structural and self.stock_item_count(False) > 0:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("You cannot make this stock location structural because some stock items "
|
_("You cannot make this stock location structural because some stock items "
|
||||||
"are already located into it!"))
|
"are already located into it!"))
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="SERIAL_NUMBER_GLOBALLY_UNIQUE" icon="fa-hashtag" %}
|
{% include "InvenTree/settings/setting.html" with key="SERIAL_NUMBER_GLOBALLY_UNIQUE" icon="fa-hashtag" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="SERIAL_NUMBER_AUTOFILL" icon="fa-magic" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_BATCH_CODE_TEMPLATE" icon="fa-layer-group" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_BATCH_CODE_TEMPLATE" icon="fa-layer-group" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
|
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
|
||||||
|
@ -2422,6 +2422,9 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
|
|||||||
substitutes: {
|
substitutes: {
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
|
optional_items: {
|
||||||
|
value: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
constructForm(`/api/build/${build_id}/auto-allocate/`, {
|
constructForm(`/api/build/${build_id}/auto-allocate/`, {
|
||||||
|
@ -240,6 +240,12 @@ function stockItemFields(options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormInputPlaceholder('serial_numbers', placeholder, opts);
|
setFormInputPlaceholder('serial_numbers', placeholder, opts);
|
||||||
|
|
||||||
|
if (global_settings.SERIAL_NUMBER_AUTOFILL) {
|
||||||
|
if (data.next) {
|
||||||
|
updateFieldValue('serial_numbers', `${data.next}+`, {}, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -21,7 +21,16 @@ import requests
|
|||||||
def get_existing_release_tags():
|
def get_existing_release_tags():
|
||||||
"""Request information on existing releases via the GitHub API"""
|
"""Request information on existing releases via the GitHub API"""
|
||||||
|
|
||||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases')
|
# Check for github token
|
||||||
|
token = os.getenv('GITHUB_TOKEN', None)
|
||||||
|
headers = None
|
||||||
|
|
||||||
|
if token:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get('https://api.github.com/repos/inventree/inventree/releases', headers=headers)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
|
raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}')
|
||||||
|
@ -11,6 +11,7 @@ django-dbbackup # Backup / restore of database and media
|
|||||||
django-error-report # Error report viewer for the admin interface
|
django-error-report # Error report viewer for the admin interface
|
||||||
django-filter # Extended filtering options
|
django-filter # Extended filtering options
|
||||||
django-formtools # Form wizard tools
|
django-formtools # Form wizard tools
|
||||||
|
django-ical # iCal export for calendar views
|
||||||
django-import-export==2.5.0 # Data import / export for admin interface
|
django-import-export==2.5.0 # Data import / export for admin interface
|
||||||
django-maintenance-mode # Shut down application while reloading etc.
|
django-maintenance-mode # Shut down application while reloading etc.
|
||||||
django-markdownify # Markdown rendering
|
django-markdownify # Markdown rendering
|
||||||
|
@ -52,6 +52,7 @@ django==3.2.16
|
|||||||
# django-error-report
|
# django-error-report
|
||||||
# django-filter
|
# django-filter
|
||||||
# django-formtools
|
# django-formtools
|
||||||
|
# django-ical
|
||||||
# django-import-export
|
# django-import-export
|
||||||
# django-js-asset
|
# django-js-asset
|
||||||
# django-markdownify
|
# django-markdownify
|
||||||
@ -60,6 +61,7 @@ django==3.2.16
|
|||||||
# django-otp
|
# django-otp
|
||||||
# django-picklefield
|
# django-picklefield
|
||||||
# django-q
|
# django-q
|
||||||
|
# django-recurrence
|
||||||
# django-redis
|
# django-redis
|
||||||
# django-sql-utils
|
# django-sql-utils
|
||||||
# django-sslserver
|
# django-sslserver
|
||||||
@ -88,6 +90,8 @@ django-filter==22.1
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-formtools==2.4
|
django-formtools==2.4
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
django-ical==1.8.3
|
||||||
|
# via -r requirements.in
|
||||||
django-import-export==2.5.0
|
django-import-export==2.5.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-js-asset==2.0.0
|
django-js-asset==2.0.0
|
||||||
@ -106,6 +110,8 @@ django-picklefield==3.1
|
|||||||
# via django-q
|
# via django-q
|
||||||
django-q==1.3.9
|
django-q==1.3.9
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
django-recurrence==1.11.1
|
||||||
|
# via django-ical
|
||||||
django-redis==5.2.0
|
django-redis==5.2.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-sql-utils==0.6.1
|
django-sql-utils==0.6.1
|
||||||
@ -132,6 +138,8 @@ gunicorn==20.1.0
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
html5lib==1.1
|
html5lib==1.1
|
||||||
# via weasyprint
|
# via weasyprint
|
||||||
|
icalendar==5.0.3
|
||||||
|
# via django-ical
|
||||||
idna==3.4
|
idna==3.4
|
||||||
# via requests
|
# via requests
|
||||||
importlib-metadata==5.0.0
|
importlib-metadata==5.0.0
|
||||||
@ -177,7 +185,10 @@ pyphen==0.13.0
|
|||||||
python-barcode[images]==0.14.0
|
python-barcode[images]==0.14.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
# via arrow
|
# via
|
||||||
|
# arrow
|
||||||
|
# django-recurrence
|
||||||
|
# icalendar
|
||||||
python-fsutil==0.7.0
|
python-fsutil==0.7.0
|
||||||
# via django-maintenance-mode
|
# via django-maintenance-mode
|
||||||
python3-openid==3.2.0
|
python3-openid==3.2.0
|
||||||
@ -188,6 +199,7 @@ pytz==2022.4
|
|||||||
# django
|
# django
|
||||||
# django-dbbackup
|
# django-dbbackup
|
||||||
# djangorestframework
|
# djangorestframework
|
||||||
|
# icalendar
|
||||||
pyyaml==6.0
|
pyyaml==6.0
|
||||||
# via tablib
|
# via tablib
|
||||||
qrcode[pil]==7.3.1
|
qrcode[pil]==7.3.1
|
||||||
|
Reference in New Issue
Block a user