mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Calendar export (#3858)
* Basic implementation of iCal feed * Add calendar download to API * Improve comments, remove unused outputs * Basic implementation of iCal feed * Add calendar download to API * Improve comments, remove unused outputs * Improve comment * Implement filter include_completed * update requirements.txt with pip-compile --output-file=requirements.txt requirements.in -U * Fix less than filter * Change URL to include calendar.ics * Fix filtering of orders * Remove URL/functions for calendar in views * Lint * More lint * Even more style fixes * Updated requirements-dev.txt because of style fail * Now? * Fine, fix it manually * Gaaah * Fix with same method as in common/settings.py * Fix setting name; improve name of calendar endpoint * Adapt InvenTreeAPITester get function to match post, etc (required for calendar test) * Merge * Reduce requirements.txt * Update requirements-dev.txt * Update tests * Set expected codes in API calendar test * SO completion can not work without line items; set a target date on existing orders instead * Correct method to PATCH * Well that didn't work for some reason.. try with cancelled orders instead * Make sure there are more completed orders than other orders in test * Correct wrong variable * Lint * Use correct status code * Add test for unauthorized access to calendar * Add a working test for unauthorised access * Put the correct test in place, fix Lint * Revert changes to requirements-dev, which appear magically... * Lint * Add test for basic auth * make sample simpler * Increment API version Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
parent
4034349043
commit
fdc4a46b26
@ -123,13 +123,13 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
|
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
def get(self, url, data=None, expected_code=200):
|
def get(self, url, data=None, expected_code=200, format='json'):
|
||||||
"""Issue a GET request."""
|
"""Issue a GET request."""
|
||||||
# Set default - see B006
|
# Set default - see B006
|
||||||
if data is None:
|
if data is None:
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
response = self.client.get(url, data, format='json')
|
response = self.client.get(url, data, format=format)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
|
|
||||||
|
@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 84
|
INVENTREE_API_VERSION = 85
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
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
|
v84 -> 2022-12-21: https://github.com/inventree/InvenTree/pull/4083
|
||||||
- Add support for listing PO, BO, SO by their reference
|
- Add support for listing PO, BO, SO by their reference
|
||||||
|
|
||||||
|
@ -204,6 +204,8 @@ INSTALLED_APPS = [
|
|||||||
'django_otp.plugins.otp_static', # Backup codes
|
'django_otp.plugins.otp_static', # Backup codes
|
||||||
|
|
||||||
'allauth_2fa', # MFA flow for allauth
|
'allauth_2fa', # MFA flow for allauth
|
||||||
|
|
||||||
|
'django_ical', # For exporting calendars
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = CONFIG.get('middleware', [
|
MIDDLEWARE = CONFIG.get('middleware', [
|
||||||
|
@ -1,17 +1,23 @@
|
|||||||
"""JSON API for the Order app."""
|
"""JSON API for the Order app."""
|
||||||
|
|
||||||
|
from django.contrib.auth import authenticate, login
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
|
from django.db.utils import ProgrammingError
|
||||||
|
from django.http.response import JsonResponse
|
||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
|
from django_ical.views import ICalFeed
|
||||||
from rest_framework import filters, status
|
from rest_framework import filters, status
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
import order.models as models
|
import order.models as models
|
||||||
import order.serializers as serializers
|
import order.serializers as serializers
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
from common.settings import settings
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
|
||||||
ListCreateDestroyAPIView)
|
ListCreateDestroyAPIView)
|
||||||
@ -1168,6 +1174,155 @@ class PurchaseOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
|||||||
serializer_class = serializers.PurchaseOrderAttachmentSerializer
|
serializer_class = serializers.PurchaseOrderAttachmentSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class OrderCalendarExport(ICalFeed):
|
||||||
|
"""Calendar export for Purchase/Sales Orders
|
||||||
|
|
||||||
|
Optional parameters:
|
||||||
|
- include_completed: true/false
|
||||||
|
whether or not to show completed orders. Defaults to false
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
instance_url = InvenTreeSetting.get_setting('INVENTREE_BASE_URL', create=False, cache=False)
|
||||||
|
except ProgrammingError: # pragma: no cover
|
||||||
|
# database is not initialized yet
|
||||||
|
instance_url = ''
|
||||||
|
instance_url = instance_url.replace("http://", "").replace("https://", "")
|
||||||
|
timezone = settings.TIME_ZONE
|
||||||
|
file_name = "calendar.ics"
|
||||||
|
|
||||||
|
def __call__(self, request, *args, **kwargs):
|
||||||
|
"""Overload call in order to check for authentication.
|
||||||
|
|
||||||
|
This is required to force Django to look for the authentication,
|
||||||
|
otherwise login request with Basic auth via curl or similar are ignored,
|
||||||
|
and login via a calendar client will not work.
|
||||||
|
|
||||||
|
See:
|
||||||
|
https://stackoverflow.com/questions/3817694/django-rss-feed-authentication
|
||||||
|
https://stackoverflow.com/questions/152248/can-i-use-http-basic-authentication-with-django
|
||||||
|
https://www.djangosnippets.org/snippets/243/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
# Authenticated on first try - maybe normal browser call?
|
||||||
|
return super().__call__(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# No login yet - check in headers
|
||||||
|
if 'HTTP_AUTHORIZATION' in request.META:
|
||||||
|
auth = request.META['HTTP_AUTHORIZATION'].split()
|
||||||
|
if len(auth) == 2:
|
||||||
|
# NOTE: We are only support basic authentication for now.
|
||||||
|
#
|
||||||
|
if auth[0].lower() == "basic":
|
||||||
|
uname, passwd = base64.b64decode(auth[1]).decode("ascii").split(':')
|
||||||
|
user = authenticate(username=uname, password=passwd)
|
||||||
|
if user is not None:
|
||||||
|
if user.is_active:
|
||||||
|
login(request, user)
|
||||||
|
request.user = user
|
||||||
|
|
||||||
|
# Check again
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
# Authenticated after second try
|
||||||
|
return super().__call__(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# Still nothing - return Unauth. header with info on how to authenticate
|
||||||
|
# Information is needed by client, eg Thunderbird
|
||||||
|
response = JsonResponse({"detail": "Authentication credentials were not provided."})
|
||||||
|
response['WWW-Authenticate'] = 'Basic realm="api"'
|
||||||
|
response.status_code = 401
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_object(self, request, *args, **kwargs):
|
||||||
|
"""This is where settings from the URL etc will be obtained"""
|
||||||
|
# Help:
|
||||||
|
# https://django.readthedocs.io/en/stable/ref/contrib/syndication.html
|
||||||
|
|
||||||
|
obj = dict()
|
||||||
|
obj['ordertype'] = kwargs['ordertype']
|
||||||
|
obj['include_completed'] = bool(request.GET.get('include_completed', False))
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def title(self, obj):
|
||||||
|
"""Return calendar title."""
|
||||||
|
|
||||||
|
if obj["ordertype"] == 'purchase-order':
|
||||||
|
ordertype_title = _('Purchase Order')
|
||||||
|
elif obj["ordertype"] == 'sales-order':
|
||||||
|
ordertype_title = _('Sales Order')
|
||||||
|
else:
|
||||||
|
ordertype_title = _('Unknown')
|
||||||
|
|
||||||
|
return f'{InvenTreeSetting.get_setting("INVENTREE_COMPANY_NAME")} {ordertype_title}'
|
||||||
|
|
||||||
|
def product_id(self, obj):
|
||||||
|
"""Return calendar product id."""
|
||||||
|
return f'//{self.instance_url}//{self.title(obj)}//EN'
|
||||||
|
|
||||||
|
def items(self, obj):
|
||||||
|
"""Return a list of PurchaseOrders.
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
- Only return those which have a target_date set
|
||||||
|
- Only return incomplete orders, unless include_completed is set to True
|
||||||
|
"""
|
||||||
|
if obj['ordertype'] == 'purchase-order':
|
||||||
|
if obj['include_completed'] is False:
|
||||||
|
# Do not include completed orders from list in this case
|
||||||
|
# Completed status = 30
|
||||||
|
outlist = models.PurchaseOrder.objects.filter(target_date__isnull=False).filter(status__lt=PurchaseOrderStatus.COMPLETE)
|
||||||
|
else:
|
||||||
|
outlist = models.PurchaseOrder.objects.filter(target_date__isnull=False)
|
||||||
|
else:
|
||||||
|
if obj['include_completed'] is False:
|
||||||
|
# Do not include completed (=shipped) orders from list in this case
|
||||||
|
# Shipped status = 20
|
||||||
|
outlist = models.SalesOrder.objects.filter(target_date__isnull=False).filter(status__lt=SalesOrderStatus.SHIPPED)
|
||||||
|
else:
|
||||||
|
outlist = models.SalesOrder.objects.filter(target_date__isnull=False)
|
||||||
|
|
||||||
|
return outlist
|
||||||
|
|
||||||
|
def item_title(self, item):
|
||||||
|
"""Set the event title to the purchase order reference"""
|
||||||
|
return item.reference
|
||||||
|
|
||||||
|
def item_description(self, item):
|
||||||
|
"""Set the event description"""
|
||||||
|
return item.description
|
||||||
|
|
||||||
|
def item_start_datetime(self, item):
|
||||||
|
"""Set event start to target date. Goal is all-day event."""
|
||||||
|
return item.target_date
|
||||||
|
|
||||||
|
def item_end_datetime(self, item):
|
||||||
|
"""Set event end to target date. Goal is all-day event."""
|
||||||
|
return item.target_date
|
||||||
|
|
||||||
|
def item_created(self, item):
|
||||||
|
"""Use creation date of PO as creation date of event."""
|
||||||
|
return item.creation_date
|
||||||
|
|
||||||
|
def item_class(self, item):
|
||||||
|
"""Set item class to PUBLIC"""
|
||||||
|
return 'PUBLIC'
|
||||||
|
|
||||||
|
def item_guid(self, item):
|
||||||
|
"""Return globally unique UID for event"""
|
||||||
|
return f'po_{item.pk}_{item.reference.replace(" ","-")}@{self.instance_url}'
|
||||||
|
|
||||||
|
def item_link(self, item):
|
||||||
|
"""Set the item link."""
|
||||||
|
|
||||||
|
# Do not use instance_url as here, as the protocol needs to be included
|
||||||
|
site_url = InvenTreeSetting.get_setting("INVENTREE_BASE_URL")
|
||||||
|
return f'{site_url}{item.get_absolute_url()}'
|
||||||
|
|
||||||
|
|
||||||
order_api_urls = [
|
order_api_urls = [
|
||||||
|
|
||||||
# API endpoints for purchase orders
|
# API endpoints for purchase orders
|
||||||
@ -1255,4 +1410,7 @@ order_api_urls = [
|
|||||||
path('<int:pk>/', SalesOrderAllocationDetail.as_view(), name='api-so-allocation-detail'),
|
path('<int:pk>/', SalesOrderAllocationDetail.as_view(), name='api-so-allocation-detail'),
|
||||||
re_path(r'^.*$', SalesOrderAllocationList.as_view(), name='api-so-allocation-list'),
|
re_path(r'^.*$', SalesOrderAllocationList.as_view(), name='api-so-allocation-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
# API endpoint for subscribing to ICS calendar of purchase/sales orders
|
||||||
|
re_path(r'^calendar/(?P<ordertype>purchase-order|sales-order)/calendar.ics', OrderCalendarExport(), name='api-po-so-calendar'),
|
||||||
]
|
]
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
"""Tests for the Order API."""
|
"""Tests for the Order API."""
|
||||||
|
|
||||||
|
import base64
|
||||||
import io
|
import io
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from icalendar import Calendar
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
import order.models as models
|
import order.models as models
|
||||||
@ -409,6 +411,111 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
order = models.PurchaseOrder.objects.get(pk=1)
|
order = models.PurchaseOrder.objects.get(pk=1)
|
||||||
self.assertEqual(order.get_metadata('yam'), 'yum')
|
self.assertEqual(order.get_metadata('yam'), 'yum')
|
||||||
|
|
||||||
|
def test_po_calendar(self):
|
||||||
|
"""Test the calendar export endpoint"""
|
||||||
|
|
||||||
|
# Create required purchase orders
|
||||||
|
self.assignRole('purchase_order.add')
|
||||||
|
|
||||||
|
for i in range(1, 9):
|
||||||
|
self.post(
|
||||||
|
reverse('api-po-list'),
|
||||||
|
{
|
||||||
|
'reference': f'PO-1100000{i}',
|
||||||
|
'supplier': 1,
|
||||||
|
'description': f'Calendar PO {i}',
|
||||||
|
'target_date': f'2024-12-{i:02d}',
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get some of these orders with target date, complete or cancel them
|
||||||
|
for po in models.PurchaseOrder.objects.filter(target_date__isnull=False):
|
||||||
|
if po.reference in ['PO-11000001', 'PO-11000002', 'PO-11000003', 'PO-11000004']:
|
||||||
|
# Set issued status for these POs
|
||||||
|
self.post(
|
||||||
|
reverse('api-po-issue', kwargs={'pk': po.pk}),
|
||||||
|
{},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
if po.reference in ['PO-11000001', 'PO-11000002']:
|
||||||
|
# Set complete status for these POs
|
||||||
|
self.post(
|
||||||
|
reverse('api-po-complete', kwargs={'pk': po.pk}),
|
||||||
|
{
|
||||||
|
'accept_incomplete': True,
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
elif po.reference in ['PO-11000005', 'PO-11000006']:
|
||||||
|
# Set cancel status for these POs
|
||||||
|
self.post(
|
||||||
|
reverse('api-po-cancel', kwargs={'pk': po.pk}),
|
||||||
|
{
|
||||||
|
'accept_incomplete': True,
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'})
|
||||||
|
|
||||||
|
# Test without completed orders
|
||||||
|
response = self.get(url, expected_code=200, format=None)
|
||||||
|
|
||||||
|
number_orders = len(models.PurchaseOrder.objects.filter(target_date__isnull=False).filter(status__lt=PurchaseOrderStatus.COMPLETE))
|
||||||
|
|
||||||
|
# Transform content to a Calendar object
|
||||||
|
calendar = Calendar.from_ical(response.content)
|
||||||
|
n_events = 0
|
||||||
|
# Count number of events in calendar
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == 'VEVENT':
|
||||||
|
n_events += 1
|
||||||
|
|
||||||
|
self.assertGreaterEqual(n_events, 1)
|
||||||
|
self.assertEqual(number_orders, n_events)
|
||||||
|
|
||||||
|
# Test with completed orders
|
||||||
|
response = self.get(url, data={'include_completed': 'True'}, expected_code=200, format=None)
|
||||||
|
|
||||||
|
number_orders_incl_completed = len(models.PurchaseOrder.objects.filter(target_date__isnull=False))
|
||||||
|
|
||||||
|
self.assertGreater(number_orders_incl_completed, number_orders)
|
||||||
|
|
||||||
|
# Transform content to a Calendar object
|
||||||
|
calendar = Calendar.from_ical(response.content)
|
||||||
|
n_events = 0
|
||||||
|
# Count number of events in calendar
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == 'VEVENT':
|
||||||
|
n_events += 1
|
||||||
|
|
||||||
|
self.assertGreaterEqual(n_events, 1)
|
||||||
|
self.assertEqual(number_orders_incl_completed, n_events)
|
||||||
|
|
||||||
|
def test_po_calendar_noauth(self):
|
||||||
|
"""Test accessing calendar without authorization"""
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'}), format='json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
resp_dict = response.json()
|
||||||
|
self.assertEqual(resp_dict['detail'], "Authentication credentials were not provided.")
|
||||||
|
|
||||||
|
def test_po_calendar_auth(self):
|
||||||
|
"""Test accessing calendar with header authorization"""
|
||||||
|
self.client.logout()
|
||||||
|
base64_token = base64.b64encode(f'{self.username}:{self.password}'.encode('ascii')).decode('ascii')
|
||||||
|
response = self.client.get(
|
||||||
|
reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'}),
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'basic {base64_token}'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderLineItemTest(OrderTest):
|
class PurchaseOrderLineItemTest(OrderTest):
|
||||||
"""Unit tests for PurchaseOrderLineItems."""
|
"""Unit tests for PurchaseOrderLineItems."""
|
||||||
@ -1077,6 +1184,67 @@ class SalesOrderTest(OrderTest):
|
|||||||
order = models.SalesOrder.objects.get(pk=1)
|
order = models.SalesOrder.objects.get(pk=1)
|
||||||
self.assertEqual(order.get_metadata('xyz'), 'abc')
|
self.assertEqual(order.get_metadata('xyz'), 'abc')
|
||||||
|
|
||||||
|
def test_so_calendar(self):
|
||||||
|
"""Test the calendar export endpoint"""
|
||||||
|
|
||||||
|
# Create required sales orders
|
||||||
|
self.assignRole('sales_order.add')
|
||||||
|
|
||||||
|
for i in range(1, 9):
|
||||||
|
self.post(
|
||||||
|
reverse('api-so-list'),
|
||||||
|
{
|
||||||
|
'reference': f'SO-1100000{i}',
|
||||||
|
'customer': 4,
|
||||||
|
'description': f'Calendar SO {i}',
|
||||||
|
'target_date': f'2024-12-{i:02d}',
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel a few orders - these will not show in incomplete view below
|
||||||
|
for so in models.SalesOrder.objects.filter(target_date__isnull=False):
|
||||||
|
if so.reference in ['SO-11000006', 'SO-11000007', 'SO-11000008', 'SO-11000009']:
|
||||||
|
self.post(
|
||||||
|
reverse('api-so-cancel', kwargs={'pk': so.pk}),
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse('api-po-so-calendar', kwargs={'ordertype': 'sales-order'})
|
||||||
|
|
||||||
|
# Test without completed orders
|
||||||
|
response = self.get(url, expected_code=200, format=None)
|
||||||
|
|
||||||
|
number_orders = len(models.SalesOrder.objects.filter(target_date__isnull=False).filter(status__lt=SalesOrderStatus.SHIPPED))
|
||||||
|
|
||||||
|
# Transform content to a Calendar object
|
||||||
|
calendar = Calendar.from_ical(response.content)
|
||||||
|
n_events = 0
|
||||||
|
# Count number of events in calendar
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == 'VEVENT':
|
||||||
|
n_events += 1
|
||||||
|
|
||||||
|
self.assertGreaterEqual(n_events, 1)
|
||||||
|
self.assertEqual(number_orders, n_events)
|
||||||
|
|
||||||
|
# Test with completed orders
|
||||||
|
response = self.get(url, data={'include_completed': 'True'}, expected_code=200, format=None)
|
||||||
|
|
||||||
|
number_orders_incl_complete = len(models.SalesOrder.objects.filter(target_date__isnull=False))
|
||||||
|
self.assertGreater(number_orders_incl_complete, number_orders)
|
||||||
|
|
||||||
|
# Transform content to a Calendar object
|
||||||
|
calendar = Calendar.from_ical(response.content)
|
||||||
|
n_events = 0
|
||||||
|
# Count number of events in calendar
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == 'VEVENT':
|
||||||
|
n_events += 1
|
||||||
|
|
||||||
|
self.assertGreaterEqual(n_events, 1)
|
||||||
|
self.assertEqual(number_orders_incl_complete, n_events)
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderLineItemTest(OrderTest):
|
class SalesOrderLineItemTest(OrderTest):
|
||||||
"""Tests for the SalesOrderLineItem API."""
|
"""Tests for the SalesOrderLineItem API."""
|
||||||
|
@ -11,6 +11,7 @@ django-dbbackup # Backup / restore of database and media
|
|||||||
django-error-report # Error report viewer for the admin interface
|
django-error-report # Error report viewer for the admin interface
|
||||||
django-filter # Extended filtering options
|
django-filter # Extended filtering options
|
||||||
django-formtools # Form wizard tools
|
django-formtools # Form wizard tools
|
||||||
|
django-ical # iCal export for calendar views
|
||||||
django-import-export==2.5.0 # Data import / export for admin interface
|
django-import-export==2.5.0 # Data import / export for admin interface
|
||||||
django-maintenance-mode # Shut down application while reloading etc.
|
django-maintenance-mode # Shut down application while reloading etc.
|
||||||
django-markdownify # Markdown rendering
|
django-markdownify # Markdown rendering
|
||||||
|
@ -52,6 +52,7 @@ django==3.2.16
|
|||||||
# django-error-report
|
# django-error-report
|
||||||
# django-filter
|
# django-filter
|
||||||
# django-formtools
|
# django-formtools
|
||||||
|
# django-ical
|
||||||
# django-import-export
|
# django-import-export
|
||||||
# django-js-asset
|
# django-js-asset
|
||||||
# django-markdownify
|
# django-markdownify
|
||||||
@ -60,6 +61,7 @@ django==3.2.16
|
|||||||
# django-otp
|
# django-otp
|
||||||
# django-picklefield
|
# django-picklefield
|
||||||
# django-q
|
# django-q
|
||||||
|
# django-recurrence
|
||||||
# django-redis
|
# django-redis
|
||||||
# django-sql-utils
|
# django-sql-utils
|
||||||
# django-sslserver
|
# django-sslserver
|
||||||
@ -88,6 +90,8 @@ django-filter==22.1
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-formtools==2.4
|
django-formtools==2.4
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
django-ical==1.8.3
|
||||||
|
# via -r requirements.in
|
||||||
django-import-export==2.5.0
|
django-import-export==2.5.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-js-asset==2.0.0
|
django-js-asset==2.0.0
|
||||||
@ -106,6 +110,8 @@ django-picklefield==3.1
|
|||||||
# via django-q
|
# via django-q
|
||||||
django-q==1.3.9
|
django-q==1.3.9
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
django-recurrence==1.11.1
|
||||||
|
# via django-ical
|
||||||
django-redis==5.2.0
|
django-redis==5.2.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-sql-utils==0.6.1
|
django-sql-utils==0.6.1
|
||||||
@ -132,6 +138,8 @@ gunicorn==20.1.0
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
html5lib==1.1
|
html5lib==1.1
|
||||||
# via weasyprint
|
# via weasyprint
|
||||||
|
icalendar==5.0.3
|
||||||
|
# via django-ical
|
||||||
idna==3.4
|
idna==3.4
|
||||||
# via requests
|
# via requests
|
||||||
importlib-metadata==5.0.0
|
importlib-metadata==5.0.0
|
||||||
@ -177,7 +185,10 @@ pyphen==0.13.0
|
|||||||
python-barcode[images]==0.14.0
|
python-barcode[images]==0.14.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
# via arrow
|
# via
|
||||||
|
# arrow
|
||||||
|
# django-recurrence
|
||||||
|
# icalendar
|
||||||
python-fsutil==0.7.0
|
python-fsutil==0.7.0
|
||||||
# via django-maintenance-mode
|
# via django-maintenance-mode
|
||||||
python3-openid==3.2.0
|
python3-openid==3.2.0
|
||||||
@ -188,6 +199,7 @@ pytz==2022.4
|
|||||||
# django
|
# django
|
||||||
# django-dbbackup
|
# django-dbbackup
|
||||||
# djangorestframework
|
# djangorestframework
|
||||||
|
# icalendar
|
||||||
pyyaml==6.0
|
pyyaml==6.0
|
||||||
# via tablib
|
# via tablib
|
||||||
qrcode[pil]==7.3.1
|
qrcode[pil]==7.3.1
|
||||||
|
Loading…
Reference in New Issue
Block a user