2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 04:25:42 +00:00

Merge remote-tracking branch 'inventree/master' into part-stocktake

# Conflicts:
#	InvenTree/InvenTree/api_version.py
This commit is contained in:
Oliver
2022-12-22 10:16:55 +11:00
24 changed files with 706 additions and 24 deletions

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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'
],
}
}

View File

@ -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

View File

@ -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.

View File

@ -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)

View File

@ -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'],
)

View File

@ -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')

View File

@ -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())

View File

@ -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'),

View File

@ -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('<int:pk>/', 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/(?P<ordertype>purchase-order|sales-order)/calendar.ics', OrderCalendarExport(), name='api-po-so-calendar'),
]

View File

@ -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 %}
<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 %}

View File

@ -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" %}
<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 %}
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
{% 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 %}
<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 %}
{% endblock %}

View File

@ -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."""

View File

@ -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!"))

View File

@ -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.

View File

@ -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!"))

View File

@ -12,6 +12,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% 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" %}

View File

@ -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/`, {

View File

@ -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);
}
}
}
});

View File

@ -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}')

View File

@ -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

View File

@ -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