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
|
||||
|
||||
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,11 +2,14 @@
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
|
@ -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', [
|
||||
|
@ -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)
|
||||
@ -1168,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
|
||||
@ -1255,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,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
|
||||
@ -409,6 +411,111 @@ 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."""
|
||||
@ -1077,6 +1184,67 @@ 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."""
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user