diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index 1669b90ea5..e76610292d 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -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: diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 1862b082fd..0fee6711b9 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 3aed39682c..a9a9be4be0 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -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', [ diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 4343b021ae..ed22fb2754 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -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('/', 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/(?Ppurchase-order|sales-order)/calendar.ics', OrderCalendarExport(), name='api-po-so-calendar'), ] diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 14f3f82df3..4aa3f14b98 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -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.""" diff --git a/requirements.in b/requirements.in index cdae788e6f..531610ad56 100644 --- a/requirements.in +++ b/requirements.in @@ -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 diff --git a/requirements.txt b/requirements.txt index bda8ab60b2..01e0ff0f4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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