diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a28e80f62a..15ae18ed10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,8 @@ jobs: stable: runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout Code uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0 diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index 1669b90ea5..e76610292d 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -123,13 +123,13 @@ class InvenTreeAPITestCase(UserMixin, APITestCase): 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.""" # Set default - see B006 if data is None: data = {} - response = self.client.get(url, data, format='json') + response = self.client.get(url, data, format=format) if expected_code is not None: diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index adea9a2279..dcf30151d5 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,14 +2,20 @@ # 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 -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 +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 - Add support for structural Stock locations diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 72f81f9601..a9a9be4be0 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -204,6 +204,8 @@ INSTALLED_APPS = [ 'django_otp.plugins.otp_static', # Backup codes 'allauth_2fa', # MFA flow for allauth + + 'django_ical', # For exporting calendars ] MIDDLEWARE = CONFIG.get('middleware', [ @@ -789,6 +791,9 @@ MARKDOWNIFY = { 'src', 'alt', ], + 'MARKDOWN_EXTENSIONS': [ + 'markdown.extensions.extra' + ], 'WHITELIST_TAGS': [ 'a', 'abbr', @@ -802,7 +807,13 @@ MARKDOWNIFY = { 'ol', 'p', 'strong', - 'ul' + 'ul', + 'table', + 'thead', + 'tbody', + 'th', + 'tr', + 'td' ], } } diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index f3e0258053..32c88e42a4 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -2,6 +2,7 @@ import json import logging +import os import re import warnings from dataclasses import dataclass @@ -338,7 +339,16 @@ def check_for_updates(): logger.info("Could not perform 'check_for_updates' - App registry not ready") 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: raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 71b806314f..557dde30d9 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -65,6 +65,13 @@ class BuildFilter(rest_filters.FilterSet): 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): """API endpoint for accessing a list of Build objects. diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index c2bc02cf85..1845712a1b 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -832,6 +832,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): exclude_location = kwargs.get('exclude_location', None) interchangeable = kwargs.get('interchangeable', False) substitutes = kwargs.get('substitutes', True) + optional_items = kwargs.get('optional_items', False) def stock_sort(item, bom_item, variant_parts): if item.part == bom_item.sub_part: @@ -848,6 +849,10 @@ class Build(MPTTModel, ReferenceIndexingMixin): # Do not auto-allocate stock to consumable BOM items 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) unallocated_quantity = self.unallocated_quantity(bom_item) diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index ea64cb800c..48d8b92368 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -812,6 +812,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer): 'exclude_location', 'interchangeable', 'substitutes', + 'optional_items', ] location = serializers.PrimaryKeyRelatedField( @@ -844,6 +845,12 @@ class BuildAutoAllocationSerializer(serializers.Serializer): 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): """Perform the auto-allocation step""" data = self.validated_data @@ -855,6 +862,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer): exclude_location=data.get('exclude_location', None), interchangeable=data['interchangeable'], substitutes=data['substitutes'], + optional_items=data['optional_items'], ) diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index 66185b3e54..af6d7a0781 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -63,6 +63,14 @@ class TestBuildAPI(InvenTreeAPITestCase): response = self.client.get(url, {'part': 99999}, format='json') 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): """Test that we can retrieve list of BuildItem objects.""" url = reverse('api-build-item-list') diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 6561e08903..38c20c331b 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -82,7 +82,8 @@ class BuildTestBase(TestCase): self.bom_item_2 = BomItem.objects.create( part=self.assembly, sub_part=self.sub_part_2, - quantity=3 + quantity=3, + optional=True ) # sub_part_3 is trackable! @@ -626,6 +627,7 @@ class AutoAllocationTests(BuildTestBase): self.build.auto_allocate_stock( interchangeable=True, substitutes=False, + optional_items=True, ) 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.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_2)) + self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) def test_fully_auto(self): """We should be able to auto-allocate against a build in a single go""" self.build.auto_allocate_stock( interchangeable=True, - substitutes=True + substitutes=True, + optional_items=True, ) self.assertTrue(self.build.are_untracked_parts_allocated()) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index a34cae96e3..d9bf65c105 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1215,6 +1215,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, + 'SERIAL_NUMBER_AUTOFILL': { + 'name': _('Autofill Serial Numbers'), + 'description': _('Autofill serial numbers in forms'), + 'default': False, + 'validator': bool, + }, + 'STOCK_BATCH_CODE_TEMPLATE': { 'name': _('Batch Code Template'), 'description': _('Template for generating default batch codes for stock items'), diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 3bf9035f13..ed22fb2754 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -1,17 +1,23 @@ """JSON API for the Order app.""" +from django.contrib.auth import authenticate, login from django.db import transaction 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.utils.translation import gettext_lazy as _ from django_filters import rest_framework as rest_filters +from django_ical.views import ICalFeed from rest_framework import filters, status from rest_framework.exceptions import ValidationError from rest_framework.response import Response import order.models as models import order.serializers as serializers +from common.models import InvenTreeSetting +from common.settings import settings from company.models import SupplierPart from InvenTree.api import (APIDownloadMixin, AttachmentMixin, ListCreateDestroyAPIView) @@ -78,8 +84,15 @@ class GeneralExtraLineList: ] -class PurchaseOrderFilter(rest_filters.FilterSet): - """Custom API filters for the PurchaseOrderList endpoint.""" +class OrderFilter(rest_filters.FilterSet): + """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') @@ -97,6 +110,9 @@ class PurchaseOrderFilter(rest_filters.FilterSet): return queryset + +class PurchaseOrderFilter(OrderFilter): + """Custom API filters for the PurchaseOrderList endpoint.""" class Meta: """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): """API endpoint for accessing a list of PurchaseOrder objects. @@ -613,6 +640,7 @@ class SalesOrderList(APIDownloadMixin, ListCreateAPI): queryset = models.SalesOrder.objects.all() serializer_class = serializers.SalesOrderSerializer + filterset_class = SalesOrderFilter def create(self, request, *args, **kwargs): """Save user information on create.""" @@ -1146,6 +1174,155 @@ class PurchaseOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): 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 = [ # API endpoints for purchase orders @@ -1233,4 +1410,7 @@ order_api_urls = [ path('/', SalesOrderAllocationDetail.as_view(), name='api-so-allocation-detail'), 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/(?Ppurchase-order|sales-order)/calendar.ics', OrderCalendarExport(), name='api-po-so-calendar'), ] diff --git a/InvenTree/order/templates/order/order_wizard/match_fields.html b/InvenTree/order/templates/order/order_wizard/match_fields.html index 47972361b7..288ddb21eb 100644 --- a/InvenTree/order/templates/order/order_wizard/match_fields.html +++ b/InvenTree/order/templates/order/order_wizard/match_fields.html @@ -1,2 +1,99 @@ {% 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 %} + +{% endif %} +{% if duplicates and duplicates|length > 0 %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + {% trans "File Fields" %} + + {% for col in form %} + +
+ + {{ col.name }} + +
+ + {% endfor %} + + + + + {% trans "Match Fields" %} + + {% for col in form %} + + {{ col }} + {% for duplicate in duplicates %} + {% if duplicate == col.value %} + + {% endif %} + {% endfor %} + + {% endfor %} + + {% for row in rows %} + {% with forloop.counter as row_index %} + + + + + {{ row_index }} + {% for item in row.data %} + + + {{ item }} + + {% endfor %} + + {% endwith %} + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.fieldselect').select2({ + width: '100%', + matcher: partialMatcher, +}); + +{% endblock %} diff --git a/InvenTree/order/templates/order/order_wizard/po_upload.html b/InvenTree/order/templates/order/order_wizard/po_upload.html index fa7c985bb5..6683fabdf1 100644 --- a/InvenTree/order/templates/order/order_wizard/po_upload.html +++ b/InvenTree/order/templates/order/order_wizard/po_upload.html @@ -12,12 +12,53 @@ {% block page_content %} {% trans "Upload File for Purchase Order" as header_text %} {% trans "Order is already processed. Files cannot be uploaded." as error_text %} - {% with "panel-upload-file" as panel_id %} - {% 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 %} - {% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=False error_text=error_text panel_id=panel_id %} - {% endif %} + {% with panel_id="panel-upload-file" %} + +
+
+

+ {{ header_text }} + {{ wizard.form.media }} +

+
+
+ {% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %} + +

{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} + {% if description %}- {{ description }}{% endif %}

+ + {% block form_alert %} + {% endblock form_alert %} + +
+ {% csrf_token %} + {% load crispy_forms_tags %} + + {% block form_buttons_top %} + {% endblock form_buttons_top %} + + + {{ wizard.management_form }} + {% block form_content %} + {% crispy wizard.form %} + {% endblock form_content %} +
+ + {% block form_buttons_bottom %} + {% if wizard.steps.prev %} + + {% endif %} + +
+ {% endblock form_buttons_bottom %} + + {% else %} + + {% endif %} +
+
{% endwith %} {% endblock %} diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index b7d4fec091..4aa3f14b98 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -1,11 +1,13 @@ """Tests for the Order API.""" +import base64 import io from datetime import datetime, timedelta from django.core.exceptions import ValidationError from django.urls import reverse +from icalendar import Calendar from rest_framework import status import order.models as models @@ -56,6 +58,10 @@ class PurchaseOrderTest(OrderTest): # List *ALL* PurchaseOrder items 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 self.filter({'supplier': 1}, 1) self.filter({'supplier': 3}, 5) @@ -68,6 +74,23 @@ class PurchaseOrderTest(OrderTest): self.filter({'status': 10}, 3) 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): """Test "overdue" status.""" self.filter({'overdue': True}, 0) @@ -247,6 +270,20 @@ class PurchaseOrderTest(OrderTest): del data['pk'] 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_line_items'] = True data['duplicate_extra_lines'] = False @@ -374,6 +411,134 @@ class PurchaseOrderTest(OrderTest): order = models.PurchaseOrder.objects.get(pk=1) 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): """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': 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): """Test "overdue" status.""" self.filter({'overdue': True}, 0) @@ -1011,10 +1184,73 @@ class SalesOrderTest(OrderTest): order = models.SalesOrder.objects.get(pk=1) 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): """Tests for the SalesOrderLineItem API.""" + LIST_URL = reverse('api-so-line-list') + def setUp(self): """Init routine for this unit test class""" super().setUp() @@ -1087,6 +1323,14 @@ class SalesOrderLineItemTest(OrderTest): 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): """Unit tests for downloading SalesOrder data via the API endpoint.""" diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index d9275303ae..5a9c504414 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -148,7 +148,7 @@ class PartCategory(MetadataMixin, InvenTreeTree): - 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( _("You cannot make this part category structural because some parts " "are already assigned to it!")) diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index d8a96709d3..1b81c22a98 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -462,6 +462,7 @@ class PluginsRegistry: self.activate_plugin_settings(plugins) self.activate_plugin_schedule(plugins) 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): """Run deactivation functions for all plugins.""" @@ -564,7 +565,6 @@ class PluginsRegistry: settings.INSTALLED_APPS += [plugin_path] self.installed_apps += [plugin_path] apps_changed = True - # if apps were changed or force loading base apps -> reload if apps_changed or force_reload: # 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 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): """Fix reloading of contrib apps - models and admin. diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 93ee99c951..a4a9124a2a 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -152,7 +152,7 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree): - 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( _("You cannot make this stock location structural because some stock items " "are already located into it!")) diff --git a/InvenTree/templates/InvenTree/settings/stock.html b/InvenTree/templates/InvenTree/settings/stock.html index 2f6c92c7d4..f45bf16e3c 100644 --- a/InvenTree/templates/InvenTree/settings/stock.html +++ b/InvenTree/templates/InvenTree/settings/stock.html @@ -12,6 +12,7 @@ {% 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_ENABLE_EXPIRY" icon="fa-stopwatch" %} {% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %} diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 316ae03c2f..ce4d92dc29 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -2422,6 +2422,9 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) { substitutes: { value: true, }, + optional_items: { + value: false, + }, }; constructForm(`/api/build/${build_id}/auto-allocate/`, { diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 62272e9487..bf33c4ebb8 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -240,6 +240,12 @@ function stockItemFields(options={}) { } setFormInputPlaceholder('serial_numbers', placeholder, opts); + + if (global_settings.SERIAL_NUMBER_AUTOFILL) { + if (data.next) { + updateFieldValue('serial_numbers', `${data.next}+`, {}, opts); + } + } } }); diff --git a/ci/version_check.py b/ci/version_check.py index 53bec2638d..dee36eb74a 100644 --- a/ci/version_check.py +++ b/ci/version_check.py @@ -21,7 +21,16 @@ import requests def get_existing_release_tags(): """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: raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') diff --git a/requirements.in b/requirements.in index cdae788e6f..531610ad56 100644 --- a/requirements.in +++ b/requirements.in @@ -11,6 +11,7 @@ django-dbbackup # Backup / restore of database and media django-error-report # Error report viewer for the admin interface django-filter # Extended filtering options 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-maintenance-mode # Shut down application while reloading etc. django-markdownify # Markdown rendering diff --git a/requirements.txt b/requirements.txt index bda8ab60b2..01e0ff0f4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,6 +52,7 @@ django==3.2.16 # django-error-report # django-filter # django-formtools + # django-ical # django-import-export # django-js-asset # django-markdownify @@ -60,6 +61,7 @@ django==3.2.16 # django-otp # django-picklefield # django-q + # django-recurrence # django-redis # django-sql-utils # django-sslserver @@ -88,6 +90,8 @@ django-filter==22.1 # via -r requirements.in django-formtools==2.4 # via -r requirements.in +django-ical==1.8.3 + # via -r requirements.in django-import-export==2.5.0 # via -r requirements.in django-js-asset==2.0.0 @@ -106,6 +110,8 @@ django-picklefield==3.1 # via django-q django-q==1.3.9 # via -r requirements.in +django-recurrence==1.11.1 + # via django-ical django-redis==5.2.0 # via -r requirements.in django-sql-utils==0.6.1 @@ -132,6 +138,8 @@ gunicorn==20.1.0 # via -r requirements.in html5lib==1.1 # via weasyprint +icalendar==5.0.3 + # via django-ical idna==3.4 # via requests importlib-metadata==5.0.0 @@ -177,7 +185,10 @@ pyphen==0.13.0 python-barcode[images]==0.14.0 # via -r requirements.in python-dateutil==2.8.2 - # via arrow + # via + # arrow + # django-recurrence + # icalendar python-fsutil==0.7.0 # via django-maintenance-mode python3-openid==3.2.0 @@ -188,6 +199,7 @@ pytz==2022.4 # django # django-dbbackup # djangorestframework + # icalendar pyyaml==6.0 # via tablib qrcode[pil]==7.3.1