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:
miggland 2022-12-21 13:17:27 +01:00 committed by GitHub
parent 4034349043
commit fdc4a46b26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 348 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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