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:
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -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
|
||||
|
@ -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:
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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'
|
||||
],
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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'],
|
||||
)
|
||||
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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())
|
||||
|
@ -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'),
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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!"))
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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!"))
|
||||
|
@ -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" %}
|
||||
|
@ -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/`, {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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}')
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user