From 14d53b45e3cca951e060d8ae5fb0271ca84e08fc Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Mon, 12 May 2025 18:33:19 +0200 Subject: [PATCH 1/2] Update packages to Django 4.x versions, rewrite API to use ModernRPC because django-json-rpc is broken, fix other small bugs --- alexia/api/decorators.py | 17 +++++- alexia/api/exceptions.py | 32 ++++++---- alexia/api/handlers.py | 53 ++++++++++++++++ alexia/api/urls.py | 13 ++-- alexia/api/v1/__init__.py | 2 - alexia/api/v1/config.py | 4 -- alexia/api/v1/methods/authorization.py | 30 ++++----- alexia/api/v1/methods/billing.py | 31 ++++++---- alexia/api/v1/methods/event.py | 12 ++-- alexia/api/v1/methods/generic.py | 36 +++++------ alexia/api/v1/methods/juliana.py | 41 ++++++++----- alexia/api/v1/methods/organization.py | 26 +++++--- alexia/api/v1/methods/rfid.py | 28 +++++---- alexia/api/v1/methods/scheduling.py | 16 ++--- alexia/api/v1/methods/user.py | 50 +++++++-------- alexia/api/v1/test/test_authorization.py | 5 +- alexia/api/v1/test/test_billing.py | 5 +- alexia/api/v1/test/test_juliana.py | 30 ++++----- alexia/api/v1/views.py | 18 ------ alexia/api/views.py | 72 ---------------------- alexia/apps/billing/forms.py | 2 +- alexia/apps/billing/models.py | 9 ++- alexia/apps/billing/urls.py | 78 ++++++++++++------------ alexia/apps/billing/views.py | 10 +-- alexia/apps/config.py | 2 +- alexia/apps/consumption/forms.py | 2 +- alexia/apps/consumption/models.py | 2 +- alexia/apps/consumption/urls.py | 20 +++--- alexia/apps/consumption/views.py | 2 +- alexia/apps/general/views.py | 6 +- alexia/apps/organization/admin.py | 4 +- alexia/apps/organization/forms.py | 2 +- alexia/apps/organization/models.py | 2 +- alexia/apps/organization/urls.py | 24 ++++---- alexia/apps/profile/urls.py | 16 ++--- alexia/apps/scheduling/admin.py | 2 +- alexia/apps/scheduling/forms.py | 6 +- alexia/apps/scheduling/models.py | 2 +- alexia/apps/scheduling/urls.py | 41 ++++++------- alexia/apps/scheduling/views.py | 13 ++-- alexia/auth/mixins.py | 10 +-- alexia/conf/settings/base.py | 12 +++- alexia/conf/settings/environ.py | 2 + alexia/conf/urls.py | 58 +++++++++--------- alexia/core/validators.py | 2 +- alexia/forms/mixins.py | 2 +- alexia/test/testcases.py | 13 ++-- alexia/utils/request.py | 7 +++ requirements.txt | 19 +++--- 49 files changed, 448 insertions(+), 443 deletions(-) create mode 100644 alexia/api/handlers.py delete mode 100644 alexia/api/v1/config.py delete mode 100644 alexia/api/v1/views.py create mode 100644 alexia/utils/request.py diff --git a/alexia/api/decorators.py b/alexia/api/decorators.py index 399b811..9a24fd4 100644 --- a/alexia/api/decorators.py +++ b/alexia/api/decorators.py @@ -1,14 +1,25 @@ from functools import wraps from django.core.exceptions import PermissionDenied +from modernrpc.core import REQUEST_KEY def manager_required(f): @wraps(f) - def wrap(request, *args, **kwargs): + def wrap(*args, **kwargs): + request = kwargs.get(REQUEST_KEY) if not request.user.is_authenticated or not request.user.is_superuser and ( - not request.organization or not request.user.profile.is_manager(request.organization)): + not request.organization or not request.user.profile.is_manager(request.organization) + ): raise PermissionDenied - return f(request, *args, **kwargs) + return f(*args, **kwargs) + return wrap +def login_required(f): + @wraps(f) + def wrap(*args, **kwargs): + request = kwargs.get(REQUEST_KEY) + if not request.user.is_authenticated: + raise PermissionDenied + return f(*args, **kwargs) return wrap diff --git a/alexia/api/exceptions.py b/alexia/api/exceptions.py index dfb632f..b20fff0 100644 --- a/alexia/api/exceptions.py +++ b/alexia/api/exceptions.py @@ -1,22 +1,28 @@ -from jsonrpc.exceptions import Error +from modernrpc.exceptions import RPCException, RPC_INVALID_PARAMS, RPC_CUSTOM_ERROR_BASE -class ForbiddenError(Error): +class ForbiddenError(RPCException): """ The token was not recognized. """ - code = 403 - status = 403 - message = 'Forbidden.' + def __init__(self, message=None): + super(ForbiddenError, self).__init__( + code=(RPC_CUSTOM_ERROR_BASE + 3), + message='Forbidden.' if message is None else message + ) -class ObjectNotFoundError(Error): +class ObjectNotFoundError(RPCException): """ The requested object does not exist. """ - code = 404 - message = 'Object not found.' - status = 404 + def __init__(self, message=None): + super(ObjectNotFoundError, self).__init__( + code=404, + message='Object not found.' if message is None else message + ) -class InvalidParamsError(Error): +class InvalidParamsError(RPCException): """ Invalid method parameters. """ - code = -32602 - message = 'Invalid params.' - status = 422 + def __init__(self, message=None): + super(InvalidParamsError, self).__init__( + code=RPC_INVALID_PARAMS, + message='Invalid params.' if message is None else message + ) diff --git a/alexia/api/handlers.py b/alexia/api/handlers.py new file mode 100644 index 0000000..3094d62 --- /dev/null +++ b/alexia/api/handlers.py @@ -0,0 +1,53 @@ +import json + +from modernrpc.handlers import JSONRPCHandler +from modernrpc.handlers.jsonhandler import JsonResult, JsonSuccessResult, JsonErrorResult + +from alexia.api.v1 import version + + +class AlexiaJSONRPCHandler(JSONRPCHandler): + + # Override content types because older apps send an initialization request with the content type + # text/plain, so we need to add that to the allowed content types for JSON-RPC. -- albertskja 2025-05-12 + @staticmethod + def valid_content_types(): + return [ + "text/plain", + "application/json", + "application/json-rpc", + "application/jsonrequest", + ] + + def process_single_request(self, request_data, context): + # Older apps send the jsonrpc version as a float, but the library expects it as a string. + # So we need to overwrite that attribute to the correct type if it is a float. -- albertskja 2025-05-12 + if request_data and isinstance(request_data, dict) and "jsonrpc" in request_data and isinstance(request_data['jsonrpc'], float): + request_data['jsonrpc'] = str(request_data['jsonrpc']) + + # Older apps may set the jsonrpc attribute to "1.0", but the library expects "2.0". + # Because there is no functional difference otherwise, and to maintain backwards compatibility + # we can just override it to "2.0" in those cases. -- albertskja 2025-05-12 + version_overridden = False + if request_data and isinstance(request_data, dict) and "jsonrpc" in request_data and request_data['jsonrpc'] == "1.0": + request_data['jsonrpc'] = "2.0" + version_overridden = True + + result_data = super().process_single_request(request_data=request_data, context=context) + + # Put back the version "1.0" if it was previously overridden -- albertskja 2025-05-12 + if version_overridden: + result_data.version = "1.0" + + return result_data + + def dumps_result(self, result: JsonResult) -> str: + # The old API backend included the "error" attribute set to None, even if the request was successful. + # Also, it included the "result" attribute set to None, even if the request failed. + # ModernRPC does not, so we need to add those back to maintain backwards compatibility -- albertskja 2025-05-12 + result_json = json.loads(super().dumps_result(result)) + if isinstance(result, JsonSuccessResult) and "error" not in result_json.keys(): + result_json["error"] = None + if isinstance(result, JsonErrorResult) and "result" not in result_json.keys(): + result_json["result"] = None + return json.dumps(result_json) diff --git a/alexia/api/urls.py b/alexia/api/urls.py index a2814b1..3994c32 100644 --- a/alexia/api/urls.py +++ b/alexia/api/urls.py @@ -1,12 +1,13 @@ -from django.conf.urls import url +from django.urls import path -from .v1 import APIv1BrowserView, APIv1DocumentationView, api_v1_site from .views import APIInfoView +from modernrpc.core import Protocol +from modernrpc.views import RPCEntryPoint + urlpatterns = [ - url(r'^$', APIInfoView.as_view(), name='api'), + path('', APIInfoView.as_view(), name='api'), - url(r'^1/$', api_v1_site.dispatch, name='api_v1_mountpoint'), - url(r'^1/browse/$', APIv1BrowserView.as_view(), name='api_v1_browse'), - url(r'^1/doc/$', APIv1DocumentationView.as_view(), name='api_v1_doc'), + path('1/', RPCEntryPoint.as_view(protocol=Protocol.JSON_RPC, entry_point="v1"), name="jsonrpc_mountpoint"), + path('1/doc/', RPCEntryPoint.as_view(enable_doc=True, enable_rpc=False, template_name="api/v1/doc.html", entry_point="v1")), ] diff --git a/alexia/api/v1/__init__.py b/alexia/api/v1/__init__.py index d92502c..0761daa 100644 --- a/alexia/api/v1/__init__.py +++ b/alexia/api/v1/__init__.py @@ -1,3 +1 @@ -from .config import * # NOQA from .methods import * # NOQA -from .views import * # NOQA diff --git a/alexia/api/v1/config.py b/alexia/api/v1/config.py deleted file mode 100644 index 66faa79..0000000 --- a/alexia/api/v1/config.py +++ /dev/null @@ -1,4 +0,0 @@ -from jsonrpc.site import JSONRPCSite - -api_v1_site = JSONRPCSite() -api_v1_site.name = 'Alexia API v1' diff --git a/alexia/api/v1/methods/authorization.py b/alexia/api/v1/methods/authorization.py index c0baa05..28b6027 100644 --- a/alexia/api/v1/methods/authorization.py +++ b/alexia/api/v1/methods/authorization.py @@ -1,7 +1,9 @@ +from typing import List, Dict, Optional + from django.contrib.auth.models import User from django.db import transaction from django.utils import timezone -from jsonrpc import jsonrpc_method +from modernrpc.core import rpc_method, REQUEST_KEY from alexia.api.decorators import manager_required from alexia.api.exceptions import InvalidParamsError @@ -9,12 +11,11 @@ from alexia.auth.backends import OIDC_BACKEND_NAME from ..common import format_authorization -from ..config import api_v1_site -@jsonrpc_method('authorization.list(radius_username=String) -> Array', site=api_v1_site, authenticated=True, safe=True) +@rpc_method(name='authorization.list', entry_point='v1') @manager_required -def authorization_list(request, radius_username=None): +def authorization_list(radius_username: Optional[str] = None, **kwargs) -> List[Dict]: """ Retrieve registered authorizations for the current selected organization. @@ -38,6 +39,7 @@ def authorization_list(request, radius_username=None): Raises error -32602 (Invalid params) if the username does not exist. """ + request = kwargs.get(REQUEST_KEY) result = [] authorizations = Authorization.objects.filter(organization=request.organization) @@ -58,9 +60,9 @@ def authorization_list(request, radius_username=None): return result -@jsonrpc_method('authorization.get(radius_username=String) -> Array', site=api_v1_site, authenticated=True, safe=True) +@rpc_method(name='authorization.get', entry_point='v1') @manager_required -def authorization_get(request, radius_username): +def authorization_get(radius_username: str, **kwargs) -> List[Dict]: """ Retrieve registered authorizations for a specified user and current selected organization. @@ -82,6 +84,7 @@ def authorization_get(request, radius_username): Raises error -32602 (Invalid params) if the username does not exist. """ + request = kwargs.get(REQUEST_KEY) result = [] try: @@ -102,14 +105,10 @@ def authorization_get(request, radius_username): return result -@jsonrpc_method( - 'authorization.add(radius_username=String, account=String) -> Object', - site=api_v1_site, - authenticated=True -) +@rpc_method(name='authorization.add', entry_point='v1') @manager_required @transaction.atomic -def authorization_add(request, radius_username, account): +def authorization_add(radius_username: str, account: str, **kwargs) -> Dict: """ Add a new authorization to the specified user. @@ -129,6 +128,7 @@ def authorization_add(request, radius_username, account): Raises error -32602 (Invalid params) if the username does not exist. """ + request = kwargs.get(REQUEST_KEY) try: user = User.objects.get(authenticationdata__backend=OIDC_BACKEND_NAME, authenticationdata__username=radius_username) @@ -141,11 +141,10 @@ def authorization_add(request, radius_username, account): return format_authorization(authorization) -@jsonrpc_method('authorization.end(radius_username=String, authorization_id=Number) -> Boolean', site=api_v1_site, - authenticated=True) +@rpc_method(name='authorization.end', entry_point='v1') @manager_required @transaction.atomic -def authorization_end(request, radius_username, authorization_id): +def authorization_end(radius_username: str, authorization_id: int, **kwargs) -> bool: """ End an authorization from the specified user. @@ -159,6 +158,7 @@ def authorization_end(request, radius_username, authorization_id): Raises error -32602 (Invalid params) if the username does not exist. Raises error -32602 (Invalid params) if provided authorization cannot be found. """ + request = kwargs.get(REQUEST_KEY) try: user = User.objects.get(authenticationdata__backend=OIDC_BACKEND_NAME, authenticationdata__username=radius_username) diff --git a/alexia/api/v1/methods/billing.py b/alexia/api/v1/methods/billing.py index 0527c70..63db470 100644 --- a/alexia/api/v1/methods/billing.py +++ b/alexia/api/v1/methods/billing.py @@ -1,6 +1,8 @@ +from typing import List, Dict, Optional + from django.contrib.auth.models import User from django.db import transaction -from jsonrpc import jsonrpc_method +from modernrpc.core import rpc_method, REQUEST_KEY from alexia.api.decorators import manager_required from alexia.api.exceptions import InvalidParamsError, ObjectNotFoundError @@ -8,12 +10,11 @@ from alexia.auth.backends import OIDC_BACKEND_NAME from ..common import format_order -from ..config import api_v1_site -@jsonrpc_method('order.unsynchronized(unused=Number) -> Array', site=api_v1_site, safe=True, authenticated=True) +@rpc_method(name='order.unsynchronized', entry_point='v1') @manager_required -def order_unsynchronized(request, unused=0): +def order_unsynchronized(unused: int = 0, **kwargs) -> List[Dict]: """ Return a list of unsynchronized orders. @@ -35,7 +36,7 @@ def order_unsynchronized(request, unused=0): }, { "price": "1.00", - "product": {"name": "Coca Cola"}, + "product": {"name": "Coca-Cola"}, "amount": 2 } ], @@ -58,7 +59,7 @@ def order_unsynchronized(request, unused=0): "purchases": [ { "price": "1.00", - "product": {"name": "Coca Cola"}, + "product": {"name": "Coca-Cola"}, "amount": 2 }, { "price": "0.50", @@ -83,6 +84,7 @@ def order_unsynchronized(request, unused=0): } ] """ + request = kwargs.get(REQUEST_KEY) result = [] orders = Order.objects.filter(authorization__organization=request.organization, synchronized=False) @@ -94,9 +96,9 @@ def order_unsynchronized(request, unused=0): return result -@jsonrpc_method('order.get(order_id=Number) -> Object', site=api_v1_site, safe=True, authenticated=True) +@rpc_method(name='order.get', entry_point='v1') @manager_required -def order_get(request, order_id): +def order_get(order_id: int, **kwargs) -> Dict: """ Return a specific order. @@ -137,6 +139,7 @@ def order_get(request, order_id): } } """ + request = kwargs.get(REQUEST_KEY) try: order = Order.objects.get(authorization__organization=request.organization, pk=order_id) except Order.DoesNotExist: @@ -145,9 +148,9 @@ def order_get(request, order_id): return format_order(order) -@jsonrpc_method('order.list(radius_username=String) -> Array', site=api_v1_site, safe=True, authenticated=True) +@rpc_method(name='order.list', entry_point='v1') @manager_required -def order_list(request, radius_username=None): +def order_list(radius_username: Optional[str] = None, **kwargs) -> List[Dict]: """ Retrieve a list of orders for the currently selected organization. @@ -165,7 +168,7 @@ def order_list(request, radius_username=None): "purchases": [ { "price": "1.00", - "product": {"name": "Coca Cola"}, + "product": {"name": "Coca-Cola"}, "amount": 2 }, { "price": "0.50", @@ -213,6 +216,7 @@ def order_list(request, radius_username=None): } ] """ + request = kwargs.get(REQUEST_KEY) result = [] orders = Order.objects.filter(event__organizer=request.organization) @@ -232,10 +236,10 @@ def order_list(request, radius_username=None): return result -@jsonrpc_method('order.marksynchronized(order_id=Number) -> Boolean', site=api_v1_site, authenticated=True) +@rpc_method(name='order.marksynchronized', entry_point='v1') @manager_required @transaction.atomic -def order_marksynchronized(request, order_id): +def order_marksynchronized(order_id: int, **kwargs) -> bool: """ Mark an order as synchronized. @@ -247,6 +251,7 @@ def order_marksynchronized(request, order_id): Raises error 422 if provided order id cannot be found. """ + request = kwargs.get(REQUEST_KEY) try: order = Order.objects.select_for_update().get(authorization__organization=request.organization, pk=order_id) except Order.DoesNotExist: diff --git a/alexia/api/v1/methods/event.py b/alexia/api/v1/methods/event.py index a9783f1..9a2a230 100644 --- a/alexia/api/v1/methods/event.py +++ b/alexia/api/v1/methods/event.py @@ -1,13 +1,13 @@ +from typing import List, Dict + from django.utils import timezone -from jsonrpc import jsonrpc_method +from modernrpc.core import rpc_method from alexia.apps.scheduling.models import Event -from ..config import api_v1_site - -@jsonrpc_method('event.upcoming_list(include_ongoing=Boolean) -> Array', site=api_v1_site, safe=True) -def upcoming_events_list(request, include_ongoing=False): +@rpc_method(name='event.upcoming_list', entry_point='v1') +def upcoming_events_list(include_ongoing: bool = False, **kwargs) -> List[Dict]: """ List all current and upcoming events. @@ -39,4 +39,4 @@ def upcoming_events_list(request, include_ongoing=False): 'ends_at': e.ends_at, 'kegs': e.kegs, 'is_risky': e.is_risky, - } for e in Event.objects.filter(**{filter_key: timezone.now()})] \ No newline at end of file + } for e in Event.objects.filter(**{filter_key: timezone.now()})] diff --git a/alexia/api/v1/methods/generic.py b/alexia/api/v1/methods/generic.py index eb8b56e..bb1118c 100644 --- a/alexia/api/v1/methods/generic.py +++ b/alexia/api/v1/methods/generic.py @@ -1,11 +1,11 @@ -from django.db import transaction -from jsonrpc import jsonrpc_method +from typing import List -from ..config import api_v1_site +from django.db import transaction +from modernrpc.core import rpc_method, registry, REQUEST_KEY, ENTRY_POINT_KEY, PROTOCOL_KEY -@jsonrpc_method('version() -> Number', site=api_v1_site, safe=True) -def version(request): +@rpc_method(name='version', entry_point='v1') +def version(**kwargs) -> int: """ Returns the current API version. @@ -20,8 +20,8 @@ def version(request): return 1 -@jsonrpc_method('methods() -> Array', site=api_v1_site, safe=True) -def methods(request): +@rpc_method(name='methods', entry_point='v1') +def methods(**kwargs) -> List[str]: """ Introspect the API and return all callable methods. @@ -29,29 +29,28 @@ def methods(request): Returns an array with the methods. """ - result = [] - - for proc in api_v1_site.describe(request)['procs']: - result.append(proc['name']) + entry_point = kwargs.get(ENTRY_POINT_KEY) + protocol = kwargs.get(PROTOCOL_KEY) - return result + return registry.get_all_method_names(entry_point, protocol, sort_methods=True) -@jsonrpc_method('login(username=String, password=String) -> Boolean', site=api_v1_site) +@rpc_method(name='login', entry_point='v1') @transaction.atomic -def login(request, username, password): +def login(username: str, password: str, **kwargs) -> bool: """ - Authenticate an user to use the API. + Authenticate a user to use the API. Required user level: None - Returns true when an user has successful signed in. A session will be + Returns true when a user has successfully signed in. A session will be started and stored. Cookies must be supported by the client. username -- Username of user password -- Password of the user """ from django.contrib.auth import authenticate, login + request = kwargs.get(REQUEST_KEY) # TODO: Authenticating for the API will be hard once RADIUS shuts down. As a stopgap, we can give each association # a local Alexia account to use for the API, but in due time we will probably want to move to something @@ -65,9 +64,9 @@ def login(request, username, password): return True -@jsonrpc_method('logout() -> Nil', site=api_v1_site) +@rpc_method(name='logout', entry_point='v1') @transaction.atomic -def logout(request): +def logout(**kwargs) -> None: """ Sign out the current user, even if no one was signed in. @@ -76,5 +75,6 @@ def logout(request): Destroys the current session. """ from django.contrib.auth import logout + request = kwargs.get(REQUEST_KEY) logout(request) diff --git a/alexia/api/v1/methods/juliana.py b/alexia/api/v1/methods/juliana.py index c8ed11c..ab6f8e5 100644 --- a/alexia/api/v1/methods/juliana.py +++ b/alexia/api/v1/methods/juliana.py @@ -1,10 +1,11 @@ from decimal import Decimal +from typing import Dict, List from django.contrib.auth.models import User from django.db import transaction from django.db.models import Sum -from jsonrpc import jsonrpc_method -from jsonrpc.exceptions import OtherError +from modernrpc.core import rpc_method, REQUEST_KEY +from modernrpc.exceptions import RPCInternalError from alexia.api.exceptions import ( ForbiddenError, InvalidParamsError, ObjectNotFoundError, @@ -15,7 +16,7 @@ from alexia.apps.scheduling.models import Event from ..common import format_authorization -from ..config import api_v1_site +from ...decorators import login_required def rfid_to_identifier(rfid): @@ -78,8 +79,10 @@ def _get_validate_event(request, event_id, safe=False): return event -@jsonrpc_method('juliana.rfid.get(Number,Object) -> Object', site=api_v1_site, safe=True, authenticated=True) -def juliana_rfid_get(request, event_id, rfid): +@rpc_method(name='juliana.rfid.get', entry_point='v1') +@login_required +def juliana_rfid_get(event_id: int, rfid: Dict, **kwargs) -> Dict: + request = kwargs.get(REQUEST_KEY) event = _get_validate_event(request, event_id, True) identifier = rfid_to_identifier(rfid=rfid) @@ -108,10 +111,12 @@ def juliana_rfid_get(request, event_id, rfid): return res -@jsonrpc_method('juliana.order.save(Number,Number,Array,Object) -> Nil', site=api_v1_site, authenticated=True) +@rpc_method(name='juliana.order.save', entry_point='v1') +@login_required @transaction.atomic -def juliana_order_save(request, event_id, user_id, purchases, rfid_data): +def juliana_order_save(event_id: int, user_id: int, purchases: List[Dict], rfid_data: Dict, **kwargs) -> None: """Saves a new order in the database""" + request = kwargs.get(REQUEST_KEY) event = _get_validate_event(request, event_id) rfid_identifier = rfid_to_identifier(rfid=rfid_data) @@ -150,7 +155,7 @@ def juliana_order_save(request, event_id, user_id, purchases, rfid_data): if event != product.event: raise InvalidParamsError('Product %s is not available for this event' % p['product']) else: - raise OtherError('Product %s is broken' % p['product']) + raise RPCInternalError('Product %s is broken' % p['product']) amount = p['amount'] @@ -169,8 +174,10 @@ def juliana_order_save(request, event_id, user_id, purchases, rfid_data): return True -@jsonrpc_method('juliana.user.check(Number, Number) -> Number', site=api_v1_site, safe=True, authenticated=True) -def juliana_user_check(request, event_id, user_id): +@rpc_method(name='juliana.user.check', entry_point='v1') +@login_required +def juliana_user_check(event_id: int, user_id: int, **kwargs) -> int: + request = kwargs.get(REQUEST_KEY) event = _get_validate_event(request, event_id, True) try: @@ -187,12 +194,14 @@ def juliana_user_check(request, event_id, user_id): else: return 0 -@jsonrpc_method('juliana.writeoff.save(Number,Number,Array) -> Nil', site=api_v1_site, authenticated=True) +@rpc_method(name='juliana.writeoff.save', entry_point='v1') +@login_required @transaction.atomic -def juliana_writeoff_save(request, event_id, writeoff_id, purchases): +def juliana_writeoff_save(event_id: int, writeoff_id: int, purchases: List[Dict], **kwargs) -> bool: """Saves a writeoff order in the Database""" + request = kwargs.get(REQUEST_KEY) event = _get_validate_event(request, event_id) - + try: writeoff_cat = WriteoffCategory.objects.get(id=writeoff_id) except WriteoffCategory.DoesNotExist: @@ -217,7 +226,7 @@ def juliana_writeoff_save(request, event_id, writeoff_id, purchases): if event != product.event: raise InvalidParamsError('Product %s is not available for this event' % p['product']) else: - raise OtherError('Product %s is broken' % p['product']) + raise RPCInternalError('Product %s is broken' % p['product']) amount = p['amount'] @@ -228,9 +237,9 @@ def juliana_writeoff_save(request, event_id, writeoff_id, purchases): if price != p['price'] / Decimal(100): raise InvalidParamsError('Price for product %s is incorrect' % p['product']) - + purchase = WriteOffPurchase(order=order, product=product.name, amount=amount, price=price) purchase.save() order.save(force_update=True) # ensure order.amount is correct - return True \ No newline at end of file + return True diff --git a/alexia/api/v1/methods/organization.py b/alexia/api/v1/methods/organization.py index 8676d55..eee6901 100644 --- a/alexia/api/v1/methods/organization.py +++ b/alexia/api/v1/methods/organization.py @@ -1,13 +1,15 @@ -from jsonrpc import jsonrpc_method +from typing import Optional, List +from modernrpc.core import rpc_method, REQUEST_KEY + +from alexia.api.decorators import login_required from alexia.api.exceptions import ObjectNotFoundError from alexia.apps.organization.models import Organization -from ..config import api_v1_site - -@jsonrpc_method('organization.current.get() -> String', site=api_v1_site, safe=True, authenticated=True) -def organization_current_get(request): +@rpc_method(name='organization.current.get', entry_point='v1') +@login_required +def organization_current_get(**kwargs) -> Optional[str]: """ Return the current organization slug. @@ -20,14 +22,16 @@ def organization_current_get(request): Example return value: "inter-actief" """ + request = kwargs.get(REQUEST_KEY) if request.organization: return request.organization.slug else: return None -@jsonrpc_method('organization.current.set(organization=String) -> Boolean', site=api_v1_site, authenticated=True) -def organization_current_set(request, organization): +@rpc_method(name='organization.current.set', entry_point='v1') +@login_required +def organization_current_set(organization: str, **kwargs) -> bool: """ Set the current organization. @@ -42,6 +46,7 @@ def organization_current_set(request, organization): Raises error 404 if provided organization cannot be found. """ + request = kwargs.get(REQUEST_KEY) if not organization: if 'organization_pk' in request.session: del request.session['organization_pk'] @@ -61,14 +66,15 @@ def organization_current_set(request, organization): return False -@jsonrpc_method('organization.list() -> Array', site=api_v1_site, safe=True) -def organization_list(request): +@rpc_method(name='organization.list', entry_point='v1') +@login_required +def organization_list(**kwargs) -> List[str]: """ List all public organizations. Required user level: None - Returns a array with zero or more organizations. + Returns an array with zero or more organizations. Example return value: [ diff --git a/alexia/api/v1/methods/rfid.py b/alexia/api/v1/methods/rfid.py index d47316e..8cb13ee 100644 --- a/alexia/api/v1/methods/rfid.py +++ b/alexia/api/v1/methods/rfid.py @@ -1,6 +1,8 @@ +from typing import List, Dict + from django.contrib.auth.models import User from django.db import transaction -from jsonrpc import jsonrpc_method +from modernrpc.core import rpc_method, REQUEST_KEY from alexia.api.decorators import manager_required from alexia.api.exceptions import InvalidParamsError @@ -8,12 +10,11 @@ from alexia.auth.backends import OIDC_BACKEND_NAME from ..common import format_rfidcard -from ..config import api_v1_site -@jsonrpc_method('rfid.list(radius_username=String) -> Array', site=api_v1_site, safe=True, authenticated=True) +@rpc_method(name='rfid.list', entry_point='v1') @manager_required -def rfid_list(request, radius_username=None): +def rfid_list(radius_username: str = None, **kwargs) -> List[Dict]: """ Retrieve registered RFID cards for the current selected organization. @@ -49,6 +50,7 @@ def rfid_list(request, radius_username=None): } ] """ + request = kwargs.get(REQUEST_KEY) result = [] rfidcards = RfidCard.objects.filter(managed_by=request.organization) @@ -68,9 +70,9 @@ def rfid_list(request, radius_username=None): return result -@jsonrpc_method('rfid.get(radius_username=String) -> Array', site=api_v1_site, safe=True, authenticated=True) +@rpc_method(name='rfid.get', entry_point='v1') @manager_required -def rfid_get(request, radius_username): +def rfid_get(radius_username: str, **kwargs) -> List[str]: """ Retrieve registered RFID cards for a specified user and current selected organization. @@ -91,6 +93,7 @@ def rfid_get(request, radius_username): Raises error -32602 (Invalid params) if the username does not exist. """ + request = kwargs.get(REQUEST_KEY) result = [] try: user = User.objects.get(authenticationdata__backend=OIDC_BACKEND_NAME, @@ -106,10 +109,10 @@ def rfid_get(request, radius_username): return result -@jsonrpc_method('rfid.add(radius_username=String, identifier=String) -> Object', site=api_v1_site, authenticated=True) +@rpc_method(name='rfid.add', entry_point='v1') @manager_required @transaction.atomic -def rfid_add(request, radius_username, identifier): +def rfid_add(radius_username: str, identifier: str, **kwargs) -> Dict: """ Add a new RFID card to the specified user. @@ -131,7 +134,7 @@ def rfid_add(request, radius_username, identifier): Raises error -32602 (Invalid params) if the RFID card already exists for this person. Raises error -32602 (Invalid params) if the RFID card is already registered by someone else. """ - + request = kwargs.get(REQUEST_KEY) try: user = User.objects.get(authenticationdata__backend=OIDC_BACKEND_NAME, authenticationdata__username=radius_username) @@ -155,12 +158,12 @@ def rfid_add(request, radius_username, identifier): raise InvalidParamsError('RFID card with provided identifier already exists for this person') -@jsonrpc_method('rfid.remove(radius_username=String, identifier=String) -> Nil', site=api_v1_site, authenticated=True) +@rpc_method(name='rfid.remove', entry_point='v1') @manager_required @transaction.atomic -def rfid_remove(request, radius_username, identifier): +def rfid_remove(radius_username: str, identifier: str, **kwargs) -> None: """ - Remove a RFID card from the specified user. + Remove an RFID card from the specified user. Required user level: Manager @@ -170,6 +173,7 @@ def rfid_remove(request, radius_username, identifier): Raises error -32602 (Invalid params) if the username does not exist. Raises error -32602 (Invalid params) if the RFID card does not exist for this person/organization. """ + request = kwargs.get(REQUEST_KEY) try: user = User.objects.get(authenticationdata__backend=OIDC_BACKEND_NAME, authenticationdata__username=radius_username) diff --git a/alexia/api/v1/methods/scheduling.py b/alexia/api/v1/methods/scheduling.py index 435876e..01b1644 100644 --- a/alexia/api/v1/methods/scheduling.py +++ b/alexia/api/v1/methods/scheduling.py @@ -1,21 +1,16 @@ -from jsonrpc import jsonrpc_method +from typing import List, Dict + +from modernrpc.core import rpc_method, REQUEST_KEY from alexia.api.decorators import manager_required from alexia.api.exceptions import InvalidParamsError from alexia.apps.scheduling.models import BartenderAvailability from alexia.auth.backends import OIDC_BACKEND_NAME, User -from ..config import api_v1_site - -@jsonrpc_method( - 'user.get_availabilities(radius_username=String) -> Array', - site=api_v1_site, - safe=True, - authenticated=True -) +@rpc_method(name='user.get_availabilities', entry_point='v1') @manager_required -def user_get_availabilities(request, radius_username): +def user_get_availabilities(radius_username: str, **kwargs) -> List[Dict]: """ Retrieve the availabilities entered by a specific user for the current organization. @@ -45,6 +40,7 @@ def user_get_availabilities(request, radius_username): } ] """ + request = kwargs.get(REQUEST_KEY) try: user = User.objects.get(authenticationdata__backend=OIDC_BACKEND_NAME, authenticationdata__username=radius_username) diff --git a/alexia/api/v1/methods/user.py b/alexia/api/v1/methods/user.py index 9c751f5..0a20194 100644 --- a/alexia/api/v1/methods/user.py +++ b/alexia/api/v1/methods/user.py @@ -1,8 +1,10 @@ +from typing import Dict + from django.contrib.auth.models import User from django.db import transaction -from jsonrpc import jsonrpc_method +from modernrpc.core import rpc_method, REQUEST_KEY -from alexia.api.decorators import manager_required +from alexia.api.decorators import manager_required, login_required from alexia.api.exceptions import InvalidParamsError, ObjectNotFoundError from alexia.apps.organization.models import ( AuthenticationData, Certificate, Membership, Profile, @@ -10,18 +12,16 @@ from alexia.auth.backends import OIDC_BACKEND_NAME from ..common import format_certificate, format_user -from ..config import api_v1_site -@jsonrpc_method('user.add(radius_username=String, first_name=String, last_name=String, email=String) -> Object', - site=api_v1_site, authenticated=True) +@rpc_method(name='user.add', entry_point='v1') @manager_required @transaction.atomic -def user_add(request, radius_username, first_name, last_name, email): +def user_add(radius_username: str, first_name: str, last_name: str, email: str, **kwargs) -> Dict: """ Add a new user to Alexia. - An user must have an unique username and a valid email address. + A user must have a unique username and a valid email address. Returns the user information on success. @@ -55,8 +55,9 @@ def user_add(request, radius_username, first_name, last_name, email): return format_user(user) -@jsonrpc_method('user.exists(radius_username=String) -> Boolean', site=api_v1_site, authenticated=True, safe=True) -def user_exists(request, radius_username): +@rpc_method(name='user.exists', entry_point='v1') +@login_required +def user_exists(radius_username: str, **kwargs) -> bool: """ Check if a user exists by his or her username. @@ -68,12 +69,13 @@ def user_exists(request, radius_username): authenticationdata__username=radius_username).exists() -@jsonrpc_method('user.get(radius_username=String) -> Object', site=api_v1_site, authenticated=True, safe=True) -def user_get(request, radius_username): +@rpc_method(name='user.get', entry_point='v1') +@login_required +def user_get(radius_username: str, **kwargs) -> Dict: """ Retrieve information about a specific user. - Returns a object representing the user. Result contains first_name, + Returns an object representing the user. Result contains first_name, last_name and radius_username. radius_username -- Username to search for. @@ -96,8 +98,9 @@ def user_get(request, radius_username): return format_user(user) -@jsonrpc_method('user.get_by_id(user_id=Number) -> Object', site=api_v1_site, authenticated=True, safe=True) -def user_get_by_id(request, user_id): +@rpc_method(name='user.get_by_id', entry_point='v1') +@login_required +def user_get_by_id(user_id: int, **kwargs) -> Dict: """ Retrieve information about a specific user. @@ -120,14 +123,9 @@ def user_get_by_id(request, user_id): return format_user(user) -@jsonrpc_method( - 'user.get_membership(radius_username=String) -> Object', - site=api_v1_site, - safe=True, - authenticated=True -) +@rpc_method(name='user.get_membership', entry_point='v1') @manager_required -def user_get_membership(request, radius_username): +def user_get_membership(radius_username: str, **kwargs) -> Dict: """ Retrieve the membership details for a specific user for the current organization. @@ -149,6 +147,7 @@ def user_get_membership(request, radius_username): "is_active": True } """ + request = kwargs.get(REQUEST_KEY) try: user = User.objects.get(authenticationdata__backend=OIDC_BACKEND_NAME, authenticationdata__username=radius_username) @@ -174,14 +173,9 @@ def user_get_membership(request, radius_username): } -@jsonrpc_method( - 'user.get_iva_certificate(radius_username=String) -> Object', - site=api_v1_site, - safe=True, - authenticated=True -) +@rpc_method(name='user.get_iva_certificate', entry_point='v1') @manager_required -def user_get_iva_certificate(request, radius_username): +def user_get_iva_certificate(radius_username: str, **kwargs) -> Dict: """ Retrieve the IVA certificate file for a specific user. diff --git a/alexia/api/v1/test/test_authorization.py b/alexia/api/v1/test/test_authorization.py index 4229a04..a8edd92 100644 --- a/alexia/api/v1/test/test_authorization.py +++ b/alexia/api/v1/test/test_authorization.py @@ -31,9 +31,8 @@ def test_authorization_list_invalid_user(self): """ # Invalid user self.send_and_compare_request_error('authorization.list', ['invalidusername'], - status_code=422, + status_code=200, error_code=-32602, - error_name='InvalidParamsError', - error_message='InvalidParamsError: User with provided ' + + error_message='User with provided ' + 'username does not exist', ) diff --git a/alexia/api/v1/test/test_billing.py b/alexia/api/v1/test/test_billing.py index 9c91ba8..6e7e1b8 100644 --- a/alexia/api/v1/test/test_billing.py +++ b/alexia/api/v1/test/test_billing.py @@ -80,8 +80,7 @@ def test_order_marksynchronized_invalid_order(self): self.load_billing_order_data() self.send_and_compare_request_error('order.marksynchronized', [self.data['order1'].id*10], - status_code=422, + status_code=200, error_code=-32602, - error_name='InvalidParamsError', - error_message='InvalidParamsError: Order with id not found', + error_message='Order with id not found', ) diff --git a/alexia/api/v1/test/test_juliana.py b/alexia/api/v1/test/test_juliana.py index af16d43..1358735 100644 --- a/alexia/api/v1/test/test_juliana.py +++ b/alexia/api/v1/test/test_juliana.py @@ -244,10 +244,9 @@ def test_rfid_get_no_rfid(self): self.send_and_compare_request_error( 'juliana.rfid.get', [event_id, rfid_data], - status_code=404, + status_code=200, error_code=404, - error_name='ObjectNotFoundError', - error_message='ObjectNotFoundError: RFID card not found', + error_message='RFID card not found', ) def test_rfid_get_no_authorization(self): @@ -264,10 +263,9 @@ def test_rfid_get_no_authorization(self): self.send_and_compare_request_error( 'juliana.rfid.get', [event_id, rfid_data], - status_code=404, + status_code=200, error_code=404, - error_name='ObjectNotFoundError', - error_message='ObjectNotFoundError: No authorization found for user', + error_message='No authorization found for user', ) def test_rfid_get_other_authorization(self): @@ -284,10 +282,9 @@ def test_rfid_get_other_authorization(self): self.send_and_compare_request_error( 'juliana.rfid.get', [event_id, rfid_data], - status_code=404, + status_code=200, error_code=404, - error_name='ObjectNotFoundError', - error_message='ObjectNotFoundError: No authorization found for user', + error_message='No authorization found for user', ) def test_rfid_get_invalid_event(self): @@ -301,10 +298,9 @@ def test_rfid_get_invalid_event(self): self.send_and_compare_request_error( 'juliana.rfid.get', [event_id, rfid_data], - status_code=404, + status_code=200, error_code=404, - error_name='ObjectNotFoundError', - error_message='ObjectNotFoundError: Event does not exist', + error_message='Event does not exist', ) def test_user_check_no_orders(self): @@ -330,10 +326,9 @@ def test_user_check_invalid_event(self): self.send_and_compare_request_error( 'juliana.user.check', [event_id, user_id], - status_code=404, + status_code=200, error_code=404, - error_name='ObjectNotFoundError', - error_message='ObjectNotFoundError: Event does not exist', + error_message='Event does not exist', ) def test_user_check_invalid_user(self): @@ -342,8 +337,7 @@ def test_user_check_invalid_user(self): self.send_and_compare_request_error( 'juliana.user.check', [event_id, user_id], - status_code=404, + status_code=200, error_code=404, - error_name='ObjectNotFoundError', - error_message='ObjectNotFoundError: User does not exist', + error_message='User does not exist', ) diff --git a/alexia/api/v1/views.py b/alexia/api/v1/views.py deleted file mode 100644 index 0f728d3..0000000 --- a/alexia/api/v1/views.py +++ /dev/null @@ -1,18 +0,0 @@ -from ..views import APIBrowserView, APIDocumentationView -from .config import api_v1_site - - -class APIv1BrowserView(APIBrowserView): - site = api_v1_site - mountpoint = 'api_v1_mountpoint' - template_name = 'api/v1/browse.html' - - -class APIv1DocumentationView(APIDocumentationView): - site = api_v1_site - mountpoint = 'api_v1_mountpoint' - template_name = 'api/v1/doc.html' - methods = ['authorization.add', 'authorization.end', 'authorization.list', 'event.upcoming_list', 'login', - 'logout', 'order.get', 'order.marksynchronized', 'order.unsynchronized', 'organization.current.get', - 'organization.current.set', 'rfid.add', 'rfid.list', 'rfid.remove', 'user.add', - 'user.exists', 'user.get'] diff --git a/alexia/api/views.py b/alexia/api/views.py index 5a0ce8c..389cd2d 100644 --- a/alexia/api/views.py +++ b/alexia/api/views.py @@ -1,76 +1,4 @@ -from json import dumps - -from django.http import HttpResponse -from django.urls import reverse from django.views.generic.base import TemplateView -from jsonrpc import mochikit -from jsonrpc.site import jsonrpc_site - - -class APIBrowserView(TemplateView): - template_name = 'api/browse.html' - site = jsonrpc_site - mountpoint = 'jsonrpc_mountpoint' - - def get(self, request, *args, **kwargs): - if request.GET.get('f', None) == 'mochikit.js': - return HttpResponse(mochikit.mochikit, content_type='application/javascript') - if request.GET.get('f', None) == 'interpreter.js': - return HttpResponse(mochikit.interpreter, content_type='application/javascript') - return super(APIBrowserView, self).get(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super(APIBrowserView, self).get_context_data(**kwargs) - - desc = self.site.service_desc() - context['methods'] = sorted(desc['procs'], key=lambda x: x['name']) - context['method_names_str'] = dumps([m['name'] for m in desc['procs']]) - context['mountpoint'] = reverse(self.mountpoint) - - return context - - -class APIDocumentationView(TemplateView): - template_name = 'api/documentation.html' - site = jsonrpc_site - mountpoint = 'jsonrpc_mountpoint' - methods = None - - def get_context_data(self, **kwargs): - context = super(APIDocumentationView, self).get_context_data(**kwargs) - - desc = self.site.service_desc() - methods = sorted(desc['procs'], key=lambda x: x['name']) - - # Filter methods if filter is provided - if self.methods is not None: - methods = [method for method in methods if method['name'] in self.methods] - - # Strip leading spaces if first line starts with spaces - for method in methods: - summarylines = method['summary'].splitlines() - - # Strip first line if emtpy - if not summarylines[0].strip(): - del summarylines[0] - - # Strip last line if emtpy - if not summarylines[-1].strip(): - del summarylines[-1] - - if summarylines[0].startswith(' '): - result = [] - for line in summarylines: - if line.startswith(' '): - result.append(line[4:]) - else: - result.append(line) - method['summary'] = '\n'.join(result) - - context['methods'] = methods - context['mountpoint'] = reverse(self.mountpoint) - - return context class APIInfoView(TemplateView): diff --git a/alexia/apps/billing/forms.py b/alexia/apps/billing/forms.py index a6428ee..715df54 100644 --- a/alexia/apps/billing/forms.py +++ b/alexia/apps/billing/forms.py @@ -4,7 +4,7 @@ from crispy_forms.helper import FormHelper from django import forms from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from alexia.apps.billing.models import ( PermanentProduct, PriceGroup, ProductGroup, SellingPrice, diff --git a/alexia/apps/billing/models.py b/alexia/apps/billing/models.py index 76e2223..6b992dc 100644 --- a/alexia/apps/billing/models.py +++ b/alexia/apps/billing/models.py @@ -8,7 +8,7 @@ from django.db.models import Q from django.urls import reverse from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from alexia.apps.organization.models import Organization from alexia.apps.scheduling.models import Event @@ -297,6 +297,11 @@ def __str__(self): ) def save(self, *args, **kwargs): + # Set amount to 0, save the order, and then update the amount. + # This needs to be done in 2 steps because the get_price() method uses a + # relationship that can only be used after the model has been saved + self.amount = Decimal('0.0') + super(Order, self).save(*args, **kwargs) self.amount = self.get_price() super(Order, self).save(*args, **kwargs) @@ -357,7 +362,7 @@ class WriteOffOrder(models.Model): on_delete=models.PROTECT, # We cannot delete purchases verbose_name=_('writeoff category') ) - + class Meta: ordering = ['-placed_at'] verbose_name = _('writeoff order') diff --git a/alexia/apps/billing/urls.py b/alexia/apps/billing/urls.py index 7f2937b..db88e09 100644 --- a/alexia/apps/billing/urls.py +++ b/alexia/apps/billing/urls.py @@ -1,53 +1,53 @@ -from django.conf.urls import url +from django.urls import re_path, path from . import views urlpatterns = [ - url(r'^order/$', views.OrderListView.as_view(), name='orders'), - url(r'^order/(?P[0-9]+)/$', views.OrderDetailView.as_view(), name='event-orders'), - url(r'^order/writeoff/(?P[0-9]+)/$', views.WriteOffExportView.as_view(), name='writeoff_export'), - url(r'^writeoff/(?P[0-9]+)/$', views.WriteOffDetailView.as_view(), name='writeoff-order'), - url(r'^order/export/$', views.OrderExportView.as_view(), name='export-orders'), - url(r'^stats/(?P[0-9]{4})/$', views.OrderYearView.as_view(), name='year-orders'), - url(r'^stats/(?P[0-9]{4})/(?P[0-9]{1,2})/$', views.OrderMonthView.as_view(), name='month-orders'), - url(r'^payment/(?P[0-9]+)/$', views.PaymentDetailView.as_view(), name='order'), - - url(r'^pricegroup/$', views.PriceGroupListView.as_view(), name='pricegroup_list'), - url(r'^pricegroup/create/$', views.PriceGroupCreateView.as_view(), name='pricegroup_create'), - url(r'^pricegroup/(?P[0-9]+)/$', views.PriceGroupDetailView.as_view(), name='pricegroup_detail'), - url(r'^pricegroup/(?P[0-9]+)/update/$', views.PriceGroupUpdateView.as_view(), name='pricegroup_update'), - url(r'^pricegroup/(?P[0-9]+)/delete/$', views.PriceGroupDeleteView.as_view(), name='pricegroup_delete'), - - url(r'^productgroup/$', views.ProductGroupListView.as_view(), name='productgroup_list'), - url(r'^productgroup/create/$', views.ProductGroupCreateView.as_view(), name='productgroup_create'), - url(r'^productgroup/(?P[0-9]+)/$', views.ProductGroupDetailView.as_view(), name='productgroup_detail'), - url(r'^productgroup/(?P[0-9]+)/update/$', views.ProductGroupUpdateView.as_view(), name='productgroup_update'), - url(r'^productgroup/(?P[0-9]+)/delete/$', views.ProductGroupDeleteView.as_view(), name='productgroup_delete'), - - url(r'^product/(?P[0-9]+)/$', views.ProductRedirectView.as_view(), name='product_detail'), - - url(r'^product/permanent/$', views.ProductListView.as_view(), name='product_list'), - url(r'^product/permanent/create/$', views.ProductCreateView.as_view(), name='product_create'), - url(r'^product/permanent/create/productgroup/(?P[0-9]+)/$', + path('order/', views.OrderListView.as_view(), name='orders'), + path('order//', views.OrderDetailView.as_view(), name='event-orders'), + path('order/writeoff//', views.WriteOffExportView.as_view(), name='writeoff_export'), + path('writeoff//', views.WriteOffDetailView.as_view(), name='writeoff-order'), + path('order/export/', views.OrderExportView.as_view(), name='export-orders'), + re_path(r'^stats/(?P[0-9]{4})/$', views.OrderYearView.as_view(), name='year-orders'), + re_path(r'^stats/(?P[0-9]{4})/(?P[0-9]{1,2})/$', views.OrderMonthView.as_view(), name='month-orders'), + path('payment//', views.PaymentDetailView.as_view(), name='order'), + + path('pricegroup/', views.PriceGroupListView.as_view(), name='pricegroup_list'), + path('pricegroup/create/', views.PriceGroupCreateView.as_view(), name='pricegroup_create'), + path('pricegroup//', views.PriceGroupDetailView.as_view(), name='pricegroup_detail'), + path('pricegroup//update/', views.PriceGroupUpdateView.as_view(), name='pricegroup_update'), + path('pricegroup//delete/', views.PriceGroupDeleteView.as_view(), name='pricegroup_delete'), + + path('productgroup/', views.ProductGroupListView.as_view(), name='productgroup_list'), + path('productgroup/create/', views.ProductGroupCreateView.as_view(), name='productgroup_create'), + path('productgroup//', views.ProductGroupDetailView.as_view(), name='productgroup_detail'), + path('productgroup//update/', views.ProductGroupUpdateView.as_view(), name='productgroup_update'), + path('productgroup//delete/', views.ProductGroupDeleteView.as_view(), name='productgroup_delete'), + + path('product//', views.ProductRedirectView.as_view(), name='product_detail'), + + path('product/permanent/', views.ProductListView.as_view(), name='product_list'), + path('product/permanent/create/', views.ProductCreateView.as_view(), name='product_create'), + path('product/permanent/create/productgroup//', views.ProductCreateView.as_view(), name='product_create'), - url(r'^product/permanent/(?P[0-9]+)/$', views.ProductDetailView.as_view(), name='product_detail'), - url(r'^product/permanent/(?P[0-9]+)/update/$', views.ProductUpdateView.as_view(), name='product_update'), - url(r'^product/permanent/(?P[0-9]+)/delete/$', views.ProductDeleteView.as_view(), name='product_delete'), + path('product/permanent//', views.ProductDetailView.as_view(), name='product_detail'), + path('product/permanent//update/', views.ProductUpdateView.as_view(), name='product_update'), + path('product/permanent//delete/', views.ProductDeleteView.as_view(), name='product_delete'), - url(r'^product/temporary/create/event/(?P[0-9]+)/$', views.TemporaryProductCreateView.as_view(), + path('product/temporary/create/event//', views.TemporaryProductCreateView.as_view(), name='temporaryproduct_create'), - url(r'^product/temporary/(?P[0-9]+)/update/$', views.TemporaryProductUpdateView.as_view(), + path('product/temporary//update/', views.TemporaryProductUpdateView.as_view(), name='temporaryproduct_update'), - url(r'^product/temporary/(?P[0-9]+)/delete/$', views.TemporaryProductDeleteView.as_view(), + path('product/temporary//delete/', views.TemporaryProductDeleteView.as_view(), name='temporaryproduct_delete'), - url(r'^sellingprice/$', views.SellingPriceListView.as_view(), name='sellingprice_list'), - url(r'^sellingprice/create/pricegroup/(?P[0-9]+)/$', views.SellingPriceCreateView.as_view(), + path('sellingprice/', views.SellingPriceListView.as_view(), name='sellingprice_list'), + path('sellingprice/create/pricegroup//', views.SellingPriceCreateView.as_view(), name='sellingprice_create'), - url(r'^sellingprice/create/productgroup/(?P[0-9]+)/$', views.SellingPriceCreateView.as_view(), + path('sellingprice/create/productgroup//', views.SellingPriceCreateView.as_view(), name='sellingprice_create'), - url(r'^sellingprice/create/pricegroup/(?P[0-9]+)/productgroup/(?P[0-9]+)/$', + path('sellingprice/create/pricegroup//productgroup//', views.SellingPriceCreateView.as_view(), name='sellingprice_create'), - url(r'^sellingprice/(?P[0-9]+)/update/$', views.SellingPriceUpdateView.as_view(), name='sellingprice_update'), - url(r'^sellingprice/(?P[0-9]+)/delete/$', views.SellingPriceDeleteView.as_view(), name='sellingprice_delete'), + path('sellingprice//update/', views.SellingPriceUpdateView.as_view(), name='sellingprice_update'), + path('sellingprice//delete/', views.SellingPriceDeleteView.as_view(), name='sellingprice_delete'), ] diff --git a/alexia/apps/billing/views.py b/alexia/apps/billing/views.py index 7053bef..a747b15 100644 --- a/alexia/apps/billing/views.py +++ b/alexia/apps/billing/views.py @@ -8,7 +8,7 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse, reverse_lazy from django.utils.dates import MONTHS -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from django.views import View from django.views.generic.base import RedirectView, TemplateView from django.views.generic.detail import DetailView, SingleObjectMixin @@ -132,7 +132,7 @@ def get_context_data(self, **kwargs): .annotate(amount=Sum('amount'), price=Sum('price')) writeoff_exists = self.object.writeoff_orders.exists - + grouped_writeoff_products = None writeoff_orders = None if writeoff_exists: @@ -179,9 +179,9 @@ def get(self, request, *args, **kwargs): if not writeoff_exists: raise Http404 - + grouped_writeoff_products = WriteOffPurchase.get_writeoff_products(event=event) - + return JsonResponse(grouped_writeoff_products) @@ -273,7 +273,7 @@ class PriceGroupUpdateView(ManagerRequiredMixin, OrganizationFilterMixin, Crispy fields = ['name'] -class PriceGroupDeleteView(ManagerRequiredMixin, OrganizationFilterMixin, FormMixin, DeleteView): +class PriceGroupDeleteView(ManagerRequiredMixin, OrganizationFilterMixin, DeleteView): model = PriceGroup success_url = reverse_lazy('pricegroup_list') form_class = DeletePriceGroupForm diff --git a/alexia/apps/config.py b/alexia/apps/config.py index 176cba4..2b483b8 100644 --- a/alexia/apps/config.py +++ b/alexia/apps/config.py @@ -1,5 +1,5 @@ from django.apps import AppConfig -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ class BillingConfig(AppConfig): diff --git a/alexia/apps/consumption/forms.py b/alexia/apps/consumption/forms.py index 2d953c4..e55d983 100644 --- a/alexia/apps/consumption/forms.py +++ b/alexia/apps/consumption/forms.py @@ -4,7 +4,7 @@ from django.forms import inlineformset_factory from django.utils import timezone from django.utils.dates import MONTHS -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from alexia.forms import AlexiaForm, EmptyInlineFormSet diff --git a/alexia/apps/consumption/models.py b/alexia/apps/consumption/models.py index 6d82ad3..bbf5935 100644 --- a/alexia/apps/consumption/models.py +++ b/alexia/apps/consumption/models.py @@ -1,7 +1,7 @@ from django.conf import settings from django.core.validators import MinValueValidator from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from alexia.apps.scheduling.models import Event diff --git a/alexia/apps/consumption/urls.py b/alexia/apps/consumption/urls.py index ad70d41..6cee533 100644 --- a/alexia/apps/consumption/urls.py +++ b/alexia/apps/consumption/urls.py @@ -1,19 +1,19 @@ -from django.conf.urls import url +from django.urls import path from . import views urlpatterns = [ - url(r'^products/$', views.ConsumptionProductListView.as_view(), name='consumptionproduct_list'), - url(r'^products/new/$', views.ConsumptionProductCreateView.as_view(), name='consumptionproduct_create'), - url(r'^products/new/weight/$', views.WeightConsumptionProductCreateView.as_view(), + path('products/', views.ConsumptionProductListView.as_view(), name='consumptionproduct_list'), + path('products/new/', views.ConsumptionProductCreateView.as_view(), name='consumptionproduct_create'), + path('products/new/weight/', views.WeightConsumptionProductCreateView.as_view(), name='weightconsumptionproduct_create'), - url(r'^products/(?P\d+)/edit/$', views.ConsumptionProductUpdateView.as_view(), + path('products//edit/', views.ConsumptionProductUpdateView.as_view(), name='consumptionproduct_update'), - url(r'^products/(?P\d+)/edit/weight/$', views.WeightConsumptionProductUpdateView.as_view(), + path('products//edit/weight/', views.WeightConsumptionProductUpdateView.as_view(), name='weightconsumptionproduct_update'), - url(r'^forms/$', views.ConsumptionFormListView.as_view(), name='consumptionform_list'), - url(r'^forms/export/$', views.ConsumptionFormExportView.as_view(), name='consumptionform_export'), - url(r'^forms/(?P\d+)/$', views.ConsumptionFormDetailView.as_view(), name='consumptionform_detail'), - url(r'^forms/(?P\d+)/pdf/$', views.ConsumptionFormPDFView.as_view(), name='consumptionform_pdf'), + path('forms/', views.ConsumptionFormListView.as_view(), name='consumptionform_list'), + path('forms/export/', views.ConsumptionFormExportView.as_view(), name='consumptionform_export'), + path('forms//', views.ConsumptionFormDetailView.as_view(), name='consumptionform_detail'), + path('forms//pdf/', views.ConsumptionFormPDFView.as_view(), name='consumptionform_pdf'), ] diff --git a/alexia/apps/consumption/views.py b/alexia/apps/consumption/views.py index 7db243c..f943a28 100644 --- a/alexia/apps/consumption/views.py +++ b/alexia/apps/consumption/views.py @@ -8,7 +8,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse_lazy from django.utils import timezone -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from django.views.generic.detail import DetailView from django.views.generic.edit import CreateView, FormView, UpdateView from django.views.generic.list import ListView diff --git a/alexia/apps/general/views.py b/alexia/apps/general/views.py index 578a0e3..3822775 100644 --- a/alexia/apps/general/views.py +++ b/alexia/apps/general/views.py @@ -11,13 +11,13 @@ from django.shortcuts import get_object_or_404, resolve_url from django.template.response import TemplateResponse from django.urls import reverse_lazy -from django.utils.http import is_safe_url +from django.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect from django.views.decorators.debug import sensitive_post_parameters from django.views.generic.base import RedirectView, TemplateView from django.views.generic.edit import UpdateView -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from alexia.apps.organization.models import Location, Membership, Organization from alexia.apps.scheduling.models import ( @@ -42,7 +42,7 @@ def _get_login_redirect_url(request, redirect_to): # Ensure the user-originating redirection URL is safe. - if not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): + if not url_has_allowed_host_and_scheme(url=redirect_to, allowed_hosts=request.get_host()): return resolve_url(settings.LOGIN_REDIRECT_URL) return redirect_to diff --git a/alexia/apps/organization/admin.py b/alexia/apps/organization/admin.py index 62cb6ad..17c7938 100644 --- a/alexia/apps/organization/admin.py +++ b/alexia/apps/organization/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.models import Group, User -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from alexia.apps.billing.models import Authorization, RfidCard, WriteoffCategory from alexia.apps.scheduling.models import Availability @@ -74,7 +74,7 @@ class LocationAdmin(admin.ModelAdmin): class AvailabilityInline(admin.TabularInline): model = Availability extra = 0 - + class WriteoffCategoryInline(admin.TabularInline): model = WriteoffCategory extra = 0 diff --git a/alexia/apps/organization/forms.py b/alexia/apps/organization/forms.py index abbb4d7..1effadb 100644 --- a/alexia/apps/organization/forms.py +++ b/alexia/apps/organization/forms.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from alexia.apps.organization.models import Certificate from alexia.apps.scheduling.models import Availability, BartenderAvailability diff --git a/alexia/apps/organization/models.py b/alexia/apps/organization/models.py index 5970f24..a96f613 100644 --- a/alexia/apps/organization/models.py +++ b/alexia/apps/organization/models.py @@ -6,7 +6,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.text import slugify -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from alexia.apps.organization import managers from alexia.apps.scheduling.models import Availability, BartenderAvailability diff --git a/alexia/apps/organization/urls.py b/alexia/apps/organization/urls.py index a906236..1fa8d72 100644 --- a/alexia/apps/organization/urls.py +++ b/alexia/apps/organization/urls.py @@ -1,19 +1,19 @@ -from django.conf.urls import url +from django.urls import re_path, path from . import views urlpatterns = [ - url(r'^membership/$', views.MembershipListView.as_view(), name='memberships'), - url(r'^membership/iva/$', views.IvaListView.as_view(), name='iva-memberships'), - url(r'^membership/create/$', views.MembershipCreateView.as_view(), name='new-membership'), - url(r'^membership/create/(?P[ms][0-9]{7})/$', views.UserCreateView.as_view(), name='add-membership'), - url(r'^membership/(?P[0-9]+)/$', views.MembershipDetailView.as_view(), name='membership'), - url(r'^membership/(?P[0-9]+)/update/$', views.MembershipUpdate.as_view(), name='edit-membership'), - url(r'^membership/(?P[0-9]+)/delete/$', views.MembershipDelete.as_view(), name='delete-membership'), - url(r'^membership/(?P[0-9]+)/iva/$', views.MembershipIvaView.as_view(), name='iva-membership'), - url(r'^membership/(?P[0-9]+)/iva/upload/$', views.MembershipIvaUpdate.as_view(), name='upload-iva-membership'), - url(r'^membership/(?P[0-9]+)/iva/approve/$', views.MembershipIvaApprove.as_view(), + path('membership/', views.MembershipListView.as_view(), name='memberships'), + path('membership/iva/', views.IvaListView.as_view(), name='iva-memberships'), + path('membership/create/', views.MembershipCreateView.as_view(), name='new-membership'), + re_path(r'^membership/create/(?P[ms][0-9]{7})/$', views.UserCreateView.as_view(), name='add-membership'), + path('membership//', views.MembershipDetailView.as_view(), name='membership'), + path('membership//update/', views.MembershipUpdate.as_view(), name='edit-membership'), + path('membership//delete/', views.MembershipDelete.as_view(), name='delete-membership'), + path('membership//iva/', views.MembershipIvaView.as_view(), name='iva-membership'), + path('membership//iva/upload/', views.MembershipIvaUpdate.as_view(), name='upload-iva-membership'), + path('membership//iva/approve/', views.MembershipIvaApprove.as_view(), name='approve-iva-membership'), - url(r'^membership/(?P[0-9]+)/iva/decline/$', views.MembershipIvaDecline.as_view(), + path('membership//iva/decline/', views.MembershipIvaDecline.as_view(), name='decline-iva-membership'), ] diff --git a/alexia/apps/profile/urls.py b/alexia/apps/profile/urls.py index 9c73615..116804a 100644 --- a/alexia/apps/profile/urls.py +++ b/alexia/apps/profile/urls.py @@ -1,13 +1,13 @@ -from django.conf.urls import url +from django.urls import path from . import views urlpatterns = [ - url(r'^$', views.ProfileView.as_view(), name='profile'), - url(r'^update/$', views.ProfileUpdate.as_view(), name='edit-profile'), - url(r'^ical/$', views.GenerateIcalView.as_view(), name='ical-gen-profile'), - url(r'^iva/$', views.IvaView.as_view(), name='view-iva-profile'), - url(r'^iva/update/$', views.IvaUpdate.as_view(), name='iva-profile'), - url(r'^expenditures/$', views.ExpenditureListView.as_view(), name='expenditures-profile'), - url(r'^expenditures/(?P\d+)/$', views.ExpenditureDetailView.as_view(), name='event-expenditures-profile') + path('', views.ProfileView.as_view(), name='profile'), + path('update/', views.ProfileUpdate.as_view(), name='edit-profile'), + path('ical/', views.GenerateIcalView.as_view(), name='ical-gen-profile'), + path('iva/', views.IvaView.as_view(), name='view-iva-profile'), + path('iva/update/', views.IvaUpdate.as_view(), name='iva-profile'), + path('expenditures/', views.ExpenditureListView.as_view(), name='expenditures-profile'), + path('expenditures//', views.ExpenditureDetailView.as_view(), name='event-expenditures-profile') ] diff --git a/alexia/apps/scheduling/admin.py b/alexia/apps/scheduling/admin.py index a5b7e25..6b818aa 100644 --- a/alexia/apps/scheduling/admin.py +++ b/alexia/apps/scheduling/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from alexia.apps.billing.models import TemporaryProduct diff --git a/alexia/apps/scheduling/forms.py b/alexia/apps/scheduling/forms.py index 5bb042d..205f8a7 100644 --- a/alexia/apps/scheduling/forms.py +++ b/alexia/apps/scheduling/forms.py @@ -1,6 +1,6 @@ from django import forms from django.utils import timezone -from django.utils.translation import ugettext, ugettext_lazy as _ +from django.utils.translation import gettext, gettext_lazy as _ from alexia.apps.organization.models import Location, Organization from alexia.forms import AlexiaForm, AlexiaModelForm @@ -32,7 +32,7 @@ def clean_ends_at(self): ends_at = self.cleaned_data.get('ends_at') if starts_at and ends_at and starts_at > ends_at: raise forms.ValidationError( - ugettext('The end time is earlier than the start time.'), + gettext('The end time is earlier than the start time.'), code='invalid', ) return ends_at @@ -49,7 +49,7 @@ def clean_location(self): conflicting_events = conflicting_events.exclude(pk=self.instance.pk) if conflicting_events.exists(): raise forms.ValidationError( - ugettext('There is already an event in %(location)s.'), + gettext('There is already an event in %(location)s.'), code='conflicting_event', params={'location': location}, ) diff --git a/alexia/apps/scheduling/models.py b/alexia/apps/scheduling/models.py index c6fa855..6151dea 100644 --- a/alexia/apps/scheduling/models.py +++ b/alexia/apps/scheduling/models.py @@ -6,7 +6,7 @@ from django.db.models.signals import pre_save from django.urls import reverse from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from .tools import notify_tenders diff --git a/alexia/apps/scheduling/urls.py b/alexia/apps/scheduling/urls.py index a686ab5..0536496 100644 --- a/alexia/apps/scheduling/urls.py +++ b/alexia/apps/scheduling/urls.py @@ -1,32 +1,31 @@ -from django.conf.urls import url +from django.urls import re_path, path from . import views urlpatterns = [ - url(r'^$', views.event_list_view, name='event-list'), - url(r'^bartender/$', views.EventBartenderView.as_view(), name='bartender-schedule'), - url(r'^calendar/$', views.EventCalendarView.as_view(), name='calendar-schedule'), - url(r'^calendar/fetch/$', views.EventCalendarFetch.as_view(), name='fetch-calendar-schedule'), - url(r'^matrix/$', views.EventMatrixView.as_view(), name='event_matrix'), + path('', views.event_list_view, name='event-list'), + path('bartender/', views.EventBartenderView.as_view(), name='bartender-schedule'), + path('calendar/', views.EventCalendarView.as_view(), name='calendar-schedule'), + path('calendar/fetch/', views.EventCalendarFetch.as_view(), name='fetch-calendar-schedule'), + path('matrix/', views.EventMatrixView.as_view(), name='event_matrix'), - url(r'^event/create/$', views.EventCreateView.as_view(), name='new-event'), - url(r'^event/(?P\d+)/$', views.EventDetailView.as_view(), name='event'), - url(r'^event/(?P\d+)/update/$', views.EventUpdateView.as_view(), name='edit-event'), - url(r'^event/(?P\d+)/update/bartender_availability/(?P\d+)/$', + path('event/create/', views.EventCreateView.as_view(), name='new-event'), + path('event//', views.EventDetailView.as_view(), name='event'), + path('event//update/', views.EventUpdateView.as_view(), name='edit-event'), + path('event//update/bartender_availability//', views.event_edit_bartender_availability, name='edit-event-bartender-availability'), - url(r'^event/(?P\d+)/delete/$', views.EventDelete.as_view(), name='delete-event'), + path('event//delete/', views.EventDelete.as_view(), name='delete-event'), - url(r'^mailtemplate/$', views.MailTemplateListView.as_view(), name='mailtemplate_list'), - url(r'^mailtemplate/(?P[a-z]+)/$', views.MailTemplateDetailView.as_view(), name='mailtemplate_detail'), - url(r'^mailtemplate/(?P[a-z]+)/update/$', views.MailTemplateUpdateView.as_view(), + path('mailtemplate/', views.MailTemplateListView.as_view(), name='mailtemplate_list'), + re_path(r'^mailtemplate/(?P[a-z]+)/$', views.MailTemplateDetailView.as_view(), name='mailtemplate_detail'), + re_path(r'^mailtemplate/(?P[a-z]+)/update/$', views.MailTemplateUpdateView.as_view(), name='mailtemplate_update'), - url(r'^availability/$', views.AvailabilityListView.as_view(), name='availability_list'), - url(r'^availability/create/$', views.AvailabilityCreateView.as_view(), name='availability_create'), - url(r'^availability/(?P\d+)/update/$', views.AvailabilityUpdateView.as_view(), name='availability_update'), + path('availability/', views.AvailabilityListView.as_view(), name='availability_list'), + path('availability/create/', views.AvailabilityCreateView.as_view(), name='availability_create'), + path('availability//update/', views.AvailabilityUpdateView.as_view(), name='availability_update'), - # AJAT (Asynchroon Javascript en Tekst... wie gebruikt er nog in - # hemelsnaam XML!?) - url(r'^ajax/bartender_availability/$', views.set_bartender_availability), - url(r'^ajax/bartender_availability/comment/$', views.set_bartender_availability_comment), + # AJAT (Asynchroon Javascript en Tekst... wie gebruikt er nog in hemelsnaam XML!?) + path('ajax/bartender_availability/', views.set_bartender_availability), + path('ajax/bartender_availability/comment/', views.set_bartender_availability_comment), ] diff --git a/alexia/apps/scheduling/views.py b/alexia/apps/scheduling/views.py index ffcace2..c9df8db 100644 --- a/alexia/apps/scheduling/views.py +++ b/alexia/apps/scheduling/views.py @@ -31,6 +31,7 @@ from .forms import EventForm, FilterEventForm from .models import Availability, BartenderAvailability, Event, MailTemplate +from ...utils.request import is_ajax def event_list_view(request): @@ -109,7 +110,7 @@ def event_list_view(request): # Net als onze BartenderAvailabilities bartender_availabilities = BartenderAvailability.objects.filter( user_id=request.user.pk).values('event_id', 'availability_id', 'comment') - + bartender_availabilities = {ba['event_id']: ba for ba in bartender_availabilities} return render(request, 'scheduling/event_list.html', locals()) @@ -135,7 +136,7 @@ def get(self, request, *args, **kwargs): start = request.GET.get('start', None) end = request.GET.get('end', None) - if not (start and end) or not request.is_ajax(): + if not (start and end) or not is_ajax(request): raise SuspiciousOperation('Bad calendar fetch request') from_time = datetime.fromtimestamp(float(start), tz=timezone.utc) @@ -337,10 +338,10 @@ def set_bartender_availability_comment(request): if (request.organization not in event.participants.all()) or \ not request.user.profile.is_tender(request.organization): raise PermissionDenied - - if not (request.method == 'POST' and request.is_ajax()): + + if not (request.method == 'POST' and is_ajax(request)): return HttpResponseBadRequest("NOTOK") - + comment = request.POST.get('comment') if len(comment) > 100: return HttpResponseBadRequest("TOOLONG") @@ -378,7 +379,7 @@ def set_bartender_availability(request): request.user in event.get_assigned_bartenders(): raise PermissionDenied - if request.method == 'POST' and request.is_ajax(): + if request.method == 'POST' and is_ajax(request): bartender_availability, is_new_record = \ BartenderAvailability.objects.get_or_create( user=request.user, event=event, diff --git a/alexia/auth/mixins.py b/alexia/auth/mixins.py index b58df9a..72492f7 100644 --- a/alexia/auth/mixins.py +++ b/alexia/auth/mixins.py @@ -9,8 +9,10 @@ from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.http.response import HttpResponseRedirect from django.shortcuts import render -from django.utils.http import urlquote -from django.utils.translation import ugettext_lazy as _ +from urllib.parse import quote +from django.utils.translation import gettext_lazy as _ + +from alexia.utils.request import is_ajax class PassesTestMixin(object): @@ -31,7 +33,7 @@ def test_requirement(self, request): return True def dispatch(self, request, *args, **kwargs): - url = u'%s?%s=%s' % (settings.LOGIN_URL, REDIRECT_FIELD_NAME, urlquote(request.get_full_path())) + url = u'%s?%s=%s' % (settings.LOGIN_URL, REDIRECT_FIELD_NAME, quote(request.get_full_path())) if self.needs_login and not request.user.is_authenticated: return HttpResponseRedirect(url) else: @@ -45,7 +47,7 @@ class RequireAjaxMixin(PassesTestMixin): reason = _('AJAX-request required') def test_requirement(self, request): - return request.is_ajax() + return is_ajax(request) class TenderRequiredMixin(PassesTestMixin): diff --git a/alexia/conf/settings/base.py b/alexia/conf/settings/base.py index a8a4e21..352cb81 100644 --- a/alexia/conf/settings/base.py +++ b/alexia/conf/settings/base.py @@ -1,6 +1,6 @@ import os -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ BASE_DIR = os.path.normpath(os.path.join(os.path.abspath(__file__), '..', '..', '..', '..')) @@ -74,7 +74,7 @@ 'crispy_forms', 'crispy_bootstrap3', 'debug_toolbar', - 'jsonrpc', + 'modernrpc', 'wkhtmltopdf', # OIDC Client (authentication via auth.ia) @@ -150,6 +150,14 @@ # See https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +# ModernRPC API +MODERNRPC_HANDLERS = [ + "alexia.api.handlers.AlexiaJSONRPCHandler" +] +MODERNRPC_METHODS_MODULES = [ + 'alexia.api.v1.methods' +] + # Single Sign On via https://auth.ia.utwente.nl/ OIDC_OP_AUTHORIZATION_ENDPOINT = "https://auth.ia.utwente.nl/realms/inter-actief/protocol/openid-connect/auth" OIDC_OP_TOKEN_ENDPOINT = "https://auth.ia.utwente.nl/realms/inter-actief/protocol/openid-connect/token" diff --git a/alexia/conf/settings/environ.py b/alexia/conf/settings/environ.py index a82e19b..30de237 100644 --- a/alexia/conf/settings/environ.py +++ b/alexia/conf/settings/environ.py @@ -155,6 +155,8 @@ def get_random_secret_key_no_dollar(): }, # Set OIDC logging to at least info due to process_request log flooding 'mozilla_django_oidc.middleware': {'level': 'INFO'}, + # Set ModernRPC logging to at least info due to "register_method" log flooding for API + 'modernrpc.core': {'level': 'INFO'}, }, } diff --git a/alexia/conf/urls.py b/alexia/conf/urls.py index cd23f5e..71707c7 100644 --- a/alexia/conf/urls.py +++ b/alexia/conf/urls.py @@ -1,8 +1,8 @@ from django.conf import settings -from django.conf.urls import include, url +from django.conf.urls import include from django.contrib import admin from django.contrib.auth import views as auth_views -from django.urls import re_path +from django.urls import re_path, path from django.views.generic import RedirectView, TemplateView from django.views.static import serve @@ -13,56 +13,56 @@ urlpatterns = [ # Root - url(r'^$', RedirectView.as_view(pattern_name='event-list', permanent=True)), + path(r'', RedirectView.as_view(pattern_name='event-list', permanent=True)), # Short urls to 'subsystems' - url(r'^dcf/(?P\d+)/$', dcf, name='dcf'), - url(r'^dcf/(?P\d+)/check/$', complete_dcf, name='dcf-complete'), - url(r'^juliana/(?P\d+)/$', JulianaView.as_view(), name='juliana'), + path('dcf//', dcf, name='dcf'), + path('dcf//check/', complete_dcf, name='dcf-complete'), + path('juliana//', JulianaView.as_view(), name='juliana'), # Apps - url(r'^billing/', include('alexia.apps.billing.urls')), - url(r'^consumption/', include('alexia.apps.consumption.urls')), - url(r'^organization/', include('alexia.apps.organization.urls')), - url(r'^profile/', include('alexia.apps.profile.urls')), - url(r'^scheduling/', include('alexia.apps.scheduling.urls')), - url(r'^ical$', scheduling_views.ical), - url(r'^ical/(?P[^/]+)$', scheduling_views.personal_ical, name='ical'), + path('billing/', include('alexia.apps.billing.urls')), + path('consumption/', include('alexia.apps.consumption.urls')), + path('organization/', include('alexia.apps.organization.urls')), + path('profile/', include('alexia.apps.profile.urls')), + path('scheduling/', include('alexia.apps.scheduling.urls')), + path('ical', scheduling_views.ical), + path('ical/', scheduling_views.personal_ical, name='ical'), - url(r'^api/', include('alexia.api.urls')), + path('api/', include('alexia.api.urls')), # "Static" general_views - url(r'^healthz/$', general_views.healthz_view, name='healthz_simple'), - url(r'^about/$', general_views.AboutView.as_view(), name='about'), - url(r'^help/$', general_views.HelpView.as_view(), name='help'), - url(r'^login_complete/$', general_views.login_complete, name='login_complete'), - url(r'^legacy_login/$', general_views.login, name='login'), - url(r'^legacy_logout/$', auth_views.LogoutView.as_view(), name='logout'), - url(r'^register/$', general_views.RegisterView.as_view(), name='register'), - url(r'^change_current_organization/(?P[-\w]+)/$', + path('healthz/', general_views.healthz_view, name='healthz_simple'), + path('about/', general_views.AboutView.as_view(), name='about'), + path('help/', general_views.HelpView.as_view(), name='help'), + path('login_complete/', general_views.login_complete, name='login_complete'), + path('legacy_login/', general_views.login, name='login'), + path('legacy_logout/', auth_views.LogoutView.as_view(), name='logout'), + path('register/', general_views.RegisterView.as_view(), name='register'), + path('change_current_organization//', general_views.ChangeCurrentOrganizationView.as_view(), name='change-current-organization'), - url(r'^oidc/', include('mozilla_django_oidc.urls')), + path('oidc/', include('mozilla_django_oidc.urls')), # Django Admin - url(r'^admin/doc/', include('django.contrib.admindocs.urls')), - url(r'^admin/', admin.site.urls), + path('admin/doc/', include('django.contrib.admindocs.urls')), + path('admin/', admin.site.urls), # Internationalization - url(r'^i18n/', include('django.conf.urls.i18n')), + path('i18n/', include('django.conf.urls.i18n')), # Robots - url(r'^robots\.txt$', TemplateView.as_view(template_name='robots.txt', content_type='text/plain')), + path('robots.txt', TemplateView.as_view(template_name='robots.txt', content_type='text/plain')), ] # Debug toolbar if settings.DEBUG: import debug_toolbar urlpatterns += [ - url(r'^__debug__/', include(debug_toolbar.urls)), + path('__debug__/', include(debug_toolbar.urls)), ] # Translation application for development urlpatterns += [ - url(r'^translations/', include('rosetta.urls'), name='translations') + path('translations/', include('rosetta.urls'), name='translations') ] # Static and media files in development mode urlpatterns += [ diff --git a/alexia/core/validators.py b/alexia/core/validators.py index f04ff70..c270577 100644 --- a/alexia/core/validators.py +++ b/alexia/core/validators.py @@ -1,5 +1,5 @@ from django.core.validators import RegexValidator -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ color_validator = RegexValidator( r'^[0-9a-zA-Z]{6}$', diff --git a/alexia/forms/mixins.py b/alexia/forms/mixins.py index 5f9c333..2fe5afb 100644 --- a/alexia/forms/mixins.py +++ b/alexia/forms/mixins.py @@ -1,7 +1,7 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Submit from django.forms import Form, ModelForm -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ class BaseCrispyFormMixin(object): diff --git a/alexia/test/testcases.py b/alexia/test/testcases.py index 0f118e2..807e331 100644 --- a/alexia/test/testcases.py +++ b/alexia/test/testcases.py @@ -203,7 +203,7 @@ def send_request(self, method, params): :param params: Parameters for JSON RPC call. :rtype : django.http.response.HttpResponse """ - path = reverse('api_v1_mountpoint') + path = reverse('jsonrpc_mountpoint') req = { 'jsonrpc': '1.0', @@ -225,7 +225,7 @@ def send_and_compare_request(self, method, params, expected_result): response = self.send_request(method, params) - self.assertEqual(response['Content-Type'], 'application/json-rpc') + self.assertEqual(response['Content-Type'], 'application/json') content = response.content.decode('utf-8') @@ -238,14 +238,13 @@ def send_and_compare_request(self, method, params, expected_result): self.assertJSONEqual(content, expected_data) - def send_and_compare_request_error(self, method, params, error_code, error_name, error_message, error_data=None, + def send_and_compare_request_error(self, method, params, error_code, error_message, error_data=None, status_code=200): """ Send JSON RPC method call and compare actual error result with expected error result. :param method: Name of method to call. :param params: Parameters for JSON RPC call. :param error_code: Expected error code. - :param error_name: Expected error name. :param error_message: Expected error message. :param error_data: Expected error data. :param status_code: Expected HTTP status code. @@ -257,7 +256,7 @@ def send_and_compare_request_error(self, method, params, error_code, error_name, self.assertEqual(response.status_code, status_code, 'HTTP status code') - self.assertEqual(response['Content-Type'], 'application/json-rpc') + self.assertEqual(response['Content-Type'], 'application/json') content = response.content.decode('utf-8') @@ -266,11 +265,11 @@ def send_and_compare_request_error(self, method, params, error_code, error_name, 'id': 'jsonrpc', 'error': { 'code': error_code, - 'name': error_name, 'message': error_message, - 'data': error_data, }, 'result': None, } + if error_data is not None: + expected_data['error']['data'] = error_data self.assertJSONEqual(content, expected_data, 'JSON RPC result') diff --git a/alexia/utils/request.py b/alexia/utils/request.py new file mode 100644 index 0000000..ec620b5 --- /dev/null +++ b/alexia/utils/request.py @@ -0,0 +1,7 @@ +def is_ajax(request): + """ + Copied from Django because HttpRequest.is_ajax() was deprecated in 3.1 and removed in 4.x + See: https://docs.djangoproject.com/en/3.2/ref/request-response/#django.http.HttpRequest.is_ajax + and: https://docs.djangoproject.com/en/3.2/_modules/django/http/request/#HttpRequest.is_ajax + """ + return request.headers.get('X-Requested-With') == 'XMLHttpRequest' diff --git a/requirements.txt b/requirements.txt index 9ee7df9..3a7365a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,21 @@ -Django>=3.2.25,<3.3 -django-compressor>=4.4,<4.5 -django-crispy-forms>=2.0,<2.1 +Django>=4.2.20,<4.3 +django-compressor>=4.5.1,<4.6 +django-crispy-forms>=2.4,<2.5 crispy-bootstrap3>=2024.1,<2024.2 -django-debug-toolbar>=4.3.0,<4.4 +django-debug-toolbar>=5.1.0,<5.2 django-wkhtmltopdf>=3.4.0,<3.5 -mysqlclient>=2.2.7,<2.3 +mysqlclient>=2.1.1 icalendar==4.1.0 jsonfield>=3.1.0,<3.2 -git+https://github.com/samuraisam/django-json-rpc@a88d744d960e828f3eb21265da0f10a694b8ebcf +# Django Modern RPC -- For our JSONRPC API +django-modern-rpc>=1.0.1,<1.1 raven==6.10.0 # Translations -- development -django-rosetta>=0.9.8,<0.10.0 +django-rosetta>=0.10.2,<0.11.0 # Django-extensions (runserver_plus command) -- development -django-extensions>=3.1.5,<4.0 +django-extensions>=4.1,<4.2 # Werkzeug needed for runserver_plus command) -- development -Werkzeug>=3.0,<3.1 +Werkzeug>=3.1,<3.2 # Single Sign On - OIDC Client mozilla-django-oidc>=4.0.1,<4.1 # Sentry error logging From 2f39c56ad0469eac922a1076a426e27fea909f4c Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Mon, 12 May 2025 20:05:41 +0200 Subject: [PATCH 2/2] Update API docs for new generated docs page of ModernRPC --- alexia/api/urls.py | 2 +- alexia/api/v1/methods/authorization.py | 122 ++++++++++----- alexia/api/v1/methods/billing.py | 197 +++++++++++++++---------- alexia/api/v1/methods/event.py | 41 +++-- alexia/api/v1/methods/generic.py | 70 +++++++-- alexia/api/v1/methods/juliana.py | 26 +++- alexia/api/v1/methods/organization.py | 80 +++++++--- alexia/api/v1/methods/rfid.py | 135 +++++++++++------ alexia/api/v1/methods/scheduling.py | 45 +++--- alexia/api/v1/methods/user.py | 190 +++++++++++++++++------- alexia/conf/settings/base.py | 8 +- requirements.txt | 2 + templates/api/doc.html | 39 +---- templates/api/info.html | 3 +- templates/api/v1/doc.html | 2 +- 15 files changed, 651 insertions(+), 311 deletions(-) diff --git a/alexia/api/urls.py b/alexia/api/urls.py index 3994c32..cb99510 100644 --- a/alexia/api/urls.py +++ b/alexia/api/urls.py @@ -9,5 +9,5 @@ path('', APIInfoView.as_view(), name='api'), path('1/', RPCEntryPoint.as_view(protocol=Protocol.JSON_RPC, entry_point="v1"), name="jsonrpc_mountpoint"), - path('1/doc/', RPCEntryPoint.as_view(enable_doc=True, enable_rpc=False, template_name="api/v1/doc.html", entry_point="v1")), + path('1/doc/', RPCEntryPoint.as_view(enable_doc=True, enable_rpc=False, template_name="api/v1/doc.html", entry_point="v1"), name="jsonrpc_docs"), ] diff --git a/alexia/api/v1/methods/authorization.py b/alexia/api/v1/methods/authorization.py index 28b6027..a8d11c0 100644 --- a/alexia/api/v1/methods/authorization.py +++ b/alexia/api/v1/methods/authorization.py @@ -17,27 +17,40 @@ @manager_required def authorization_list(radius_username: Optional[str] = None, **kwargs) -> List[Dict]: """ - Retrieve registered authorizations for the current selected organization. + **Signature**: `authorization.list(radius_username)` + + **Arguments**: + + - `radius_username` : `str` -- *(optional)* Username to search for. + + **Return type**: List of `dict` + + **Idempotent**: yes + + **Required user level**: Manager - Required user level: Manager + **Documentation**: + + Retrieve registered authorizations for the current selected organization. Provide radius_username to select only authorizations of the provided user. Returns an array of accounts of registered authorizations. - radius_username -- (optional) Username to search for. + **Example return value**: - Example return value: - [ - { + [ + { "id": 1, "end_date": null, "start_date": "2014-09-21T14:16:06+00:00", "user": "s0000000" - } - ] + } + ] + + **Raises errors**: - Raises error -32602 (Invalid params) if the username does not exist. + - `-32602` (Invalid params) if the username does not exist. """ request = kwargs.get(REQUEST_KEY) result = [] @@ -64,25 +77,38 @@ def authorization_list(radius_username: Optional[str] = None, **kwargs) -> List[ @manager_required def authorization_get(radius_username: str, **kwargs) -> List[Dict]: """ + **Signature**: `authorization.get(radius_username)` + + **Arguments**: + + - `radius_username` : `str` -- Username to search for. + + **Return type**: `dict` + + **Idempotent**: yes + + **Required user level**: Manager + + **Documentation**: + Retrieve registered authorizations for a specified user and current selected organization. - Required user level: Manager - Returns an array of accounts of registered authorizations. - radius_username -- Username to search for. + **Example return value**: - Example return value: - [ - { + [ + { "id": 1, "end_date": null, "start_date": "2014-09-21T14:16:06+00:00" - } - ] + } + ] - Raises error -32602 (Invalid params) if the username does not exist. + **Raises errors**: + + - `-32602` (Invalid params) if the username does not exist. """ request = kwargs.get(REQUEST_KEY) result = [] @@ -110,23 +136,37 @@ def authorization_get(radius_username: str, **kwargs) -> List[Dict]: @transaction.atomic def authorization_add(radius_username: str, account: str, **kwargs) -> Dict: """ - Add a new authorization to the specified user. + **Signature**: `authorization.add(radius_username, account)` + + **Arguments**: + + - `radius_username` : `str` -- Username to search for. + - `account` : `str` -- Unused + + **Return type**: `dict` - Required user level: Manager + **Idempotent**: no + + **Required user level**: Manager + + **Documentation**: + + Add a new authorization to the specified user. Returns the authorization on success. - radius_username -- Username to search for. + **Example return value**: + + { + "id": 1, + "end_date": null, + "start_date": "2014-09-21T14:16:06+00:00", + "user": "s0000000" + } - Example return value: - { - "id": 1, - "end_date": null, - "start_date": "2014-09-21T14:16:06+00:00", - "user": "s0000000" - } + **Raises errors**: - Raises error -32602 (Invalid params) if the username does not exist. + - `-32602` (Invalid params) if the username does not exist. """ request = kwargs.get(REQUEST_KEY) try: @@ -146,17 +186,29 @@ def authorization_add(radius_username: str, account: str, **kwargs) -> Dict: @transaction.atomic def authorization_end(radius_username: str, authorization_id: int, **kwargs) -> bool: """ - End an authorization from the specified user. + **Signature**: `authorization.end(radius_username, authorization_id)` + + **Arguments**: + + - `radius_username` : `str` -- Username to search for. + - `authorization_id` : `int` -- ID of the authorization to end - Required user level: Manager + **Return type**: `bool` + + **Idempotent**: no + + **Required user level**: Manager + + **Documentation**: + + End an authorization from the specified user. Returns true when successful. Returns false when the authorization was already ended. - radius_username -- Username to search for. - identifier -- RFID card hardware identifier (max. 16 chars) + **Raises errors**: - Raises error -32602 (Invalid params) if the username does not exist. - Raises error -32602 (Invalid params) if provided authorization cannot be found. + - `-32602` (Invalid params) if the username does not exist. + - `-32602` (Invalid params) if provided authorization cannot be found. """ request = kwargs.get(REQUEST_KEY) try: diff --git a/alexia/api/v1/methods/billing.py b/alexia/api/v1/methods/billing.py index 63db470..4c9186c 100644 --- a/alexia/api/v1/methods/billing.py +++ b/alexia/api/v1/methods/billing.py @@ -16,18 +16,31 @@ @manager_required def order_unsynchronized(unused: int = 0, **kwargs) -> List[Dict]: """ - Return a list of unsynchronized orders. + **Signature**: `order.unsynchronized(unused)` + + **Arguments**: + + - `unused` : `int` -- Unused parameter + + **Return type**: List of `dict` + + **Idempotent**: yes - Required user level: Manager + **Required user level**: Manager + + **Documentation**: + + Return a list of unsynchronized orders. Optionally gets an unused parameter because of a former compatibility issue between the jsonrpc server en jsonrpclib client. Returns a list of Order objects. - Example return value: - [ - { + **Example return value**: + + [ + { "purchases": [ { "price": "5.00", @@ -54,8 +67,8 @@ def order_unsynchronized(unused: int = 0, **kwargs) -> List[Dict]: "start_date": "2014-09-21T14:16:06+00:00", "user": "s0000000" } - }, - { + }, + { "purchases": [ { "price": "1.00", @@ -81,8 +94,8 @@ def order_unsynchronized(unused: int = 0, **kwargs) -> List[Dict]: "start_date": "2014-09-21T14:16:06+00:00", "user": "s0000000" } - } - ] + } + ] """ request = kwargs.get(REQUEST_KEY) result = [] @@ -100,44 +113,57 @@ def order_unsynchronized(unused: int = 0, **kwargs) -> List[Dict]: @manager_required def order_get(order_id: int, **kwargs) -> Dict: """ - Return a specific order. + **Signature**: `order.get(order_id)` - Required user level: Manager + **Arguments**: - Returns an order object. + - `order_id` : `int` -- ID of the Order object. + + **Return type**: `dict` - order_id -- ID of the Order object. + **Idempotent**: yes - Raises error 404 if provided order id cannot be found. + **Required user level**: Manager + + **Documentation**: + + Return a specific order. + + Returns an order object. - Example return value: - { - "purchases": [ + **Example return value**: + + { + "purchases": [ { - "price": "1.00", - "product": {"name": "Coca Cola"}, - "amount": 2 + "price": "1.00", + "product": {"name": "Coca-Cola"}, + "amount": 2 }, { - "price": "0.50", - "product": {"name": "Grolsch"}, - "amount": 1 + "price": "0.50", + "product": {"name": "Grolsch"}, + "amount": 1 } - ], - "synchronized": false, - "event": { + ], + "synchronized": false, + "event": { "id": 4210, "name": "Testborrel" - }, - "placed_at": "2015-03-11T15:24:06+00:00", - "id": 1255, - "rfid": "02,06:65:74:49", - "authorization": { + }, + "placed_at": "2015-03-11T15:24:06+00:00", + "id": 1255, + "rfid": "02,06:65:74:49", + "authorization": { "id": 1, "end_date": null, "start_date": "2014-09-21T14:16:06+00:00", "user": "s0000000" + } } - } + + **Raises errors**: + + - `404` (Object not found) if provided order id cannot be found. """ request = kwargs.get(REQUEST_KEY) try: @@ -152,69 +178,80 @@ def order_get(order_id: int, **kwargs) -> Dict: @manager_required def order_list(radius_username: Optional[str] = None, **kwargs) -> List[Dict]: """ - Retrieve a list of orders for the currently selected organization. + **Signature**: `order.list(radius_username)` - Required user level: Manager + **Arguments**: - Povide a username to select only orders made by the provided user. + - `radius_username` : `str` -- *(optional)* Username to search for. + + **Return type**: List of `dict` + + **Idempotent**: yes + + **Required user level**: Manager + + **Documentation**: + + Retrieve a list of orders for the currently selected organization. + + Provide a username to select only orders made by the provided user. Returns an array of orders. - radius_username -- (optional) Username to search for. + **Example return value**: - Example return value: - [ - { + [ + { "purchases": [ - { - "price": "1.00", - "product": {"name": "Coca-Cola"}, - "amount": 2 - }, { - "price": "0.50", - "product": {"name": "Grolsch"}, - "amount": 1 - } + { + "price": "1.00", + "product": {"name": "Coca-Cola"}, + "amount": 2 + }, { + "price": "0.50", + "product": {"name": "Grolsch"}, + "amount": 1 + } ], "synchronized": false, "event": { - "id": 4210, - "name": "Testborrel" + "id": 4210, + "name": "Testborrel" }, "placed_at": "2015-03-11T15:24:06+00:00", "id": 1255, "rfid": "02,06:65:74:49", "authorization": { - "id": 1, - "end_date": null, - "start_date": "2014-09-21T14:16:06+00:00", - "user": "s0000000" + "id": 1, + "end_date": null, + "start_date": "2014-09-21T14:16:06+00:00", + "user": "s0000000" } - }, - { + }, + { "purchases": [ - { - "price": "1.50", - "product": {"name": "Grolsch"}, - "amount": 3 - } + { + "price": "1.50", + "product": {"name": "Grolsch"}, + "amount": 3 + } ], "synchronized": true, "event": { - "id": 4210, - "name": "Testborrel" + "id": 4210, + "name": "Testborrel" }, "placed_at": "2015-03-11T16:47:21+00:00", "id": 1271, "rfid": "02,06:65:74:49", "authorization": { - "id": 1, - "end_date": null, - "start_date": "2014-09-21T14:16:06+00:00", - "user": "s0000000" + "id": 1, + "end_date": null, + "start_date": "2014-09-21T14:16:06+00:00", + "user": "s0000000" } - } - ] + } + ] """ request = kwargs.get(REQUEST_KEY) result = [] @@ -241,15 +278,27 @@ def order_list(radius_username: Optional[str] = None, **kwargs) -> List[Dict]: @transaction.atomic def order_marksynchronized(order_id: int, **kwargs) -> bool: """ - Mark an order as synchronized. + **Signature**: `order.marksynchronized(order_id)` + + **Arguments**: + + - `order_id` : `int` -- ID of the Order object. + + **Return type**: `bool` - Required user level: Manager + **Idempotent**: no + + **Required user level**: Manager + + **Documentation**: + + Mark an order as synchronized. - Returns True if the operation succeeded. Returns False if the order is already marked as synchronized. + Returns `True` if the operation succeeded. Returns `False` if the order is already marked as synchronized. - order_id -- ID of the Order object. + **Raises errors**: - Raises error 422 if provided order id cannot be found. + - `-32602` (Invalid params) if provided order id cannot be found. """ request = kwargs.get(REQUEST_KEY) try: diff --git a/alexia/api/v1/methods/event.py b/alexia/api/v1/methods/event.py index 9a2a230..32b976d 100644 --- a/alexia/api/v1/methods/event.py +++ b/alexia/api/v1/methods/event.py @@ -9,25 +9,38 @@ @rpc_method(name='event.upcoming_list', entry_point='v1') def upcoming_events_list(include_ongoing: bool = False, **kwargs) -> List[Dict]: """ - List all current and upcoming events. + **Signature**: `event.upcoming_list(include_current)` + + **Arguments**: + + - `include_current` : `bool` -- Whether to include ongoing events, defaults to false + + **Return type**: List of `dict` - Required user level: None + **Idempotent**: yes - include_current -- Whether to include ongoing events, defaults to false + **Required user level**: *None* + + **Documentation**: + + List all current and upcoming events. Returns an array with zero or more events. - Example output: - [{ - 'name': 'Test Drink', - 'locations': 'Abscint', - 'organizer': 'Inter-Actief', - 'participants': ['Inter-Actief'], - 'starts_at': 2017-09-12T14:00:00Z, - 'ends_at': 2017-09-12T16:00:00Z, - 'kegs': 2, - 'is_risky': false, - }] + **Example return value**: + + [ + { + 'name': 'Test Drink', + 'locations': 'Abscint', + 'organizer': 'Inter-Actief', + 'participants': ['Inter-Actief'], + 'starts_at': 2017-09-12T14:00:00Z, + 'ends_at': 2017-09-12T16:00:00Z, + 'kegs': 2, + 'is_risky': false, + } + ] """ filter_key = 'ends_at__gte' if include_ongoing else 'starts_at__gte' return [{ diff --git a/alexia/api/v1/methods/generic.py b/alexia/api/v1/methods/generic.py index bb1118c..33226fc 100644 --- a/alexia/api/v1/methods/generic.py +++ b/alexia/api/v1/methods/generic.py @@ -7,9 +7,21 @@ @rpc_method(name='version', entry_point='v1') def version(**kwargs) -> int: """ - Returns the current API version. + **Signature**: `version()` + + **Arguments**: + + - *None* + + **Return type**: `int` + + **Idempotent**: yes - Required user level: None + **Required user level**: *None* + + **Documentation**: + + Returns the current API version. The client will then be able to determine which methods it can use and which methods it cannot use. @@ -23,9 +35,21 @@ def version(**kwargs) -> int: @rpc_method(name='methods', entry_point='v1') def methods(**kwargs) -> List[str]: """ - Introspect the API and return all callable methods. + **Signature**: `methods()` - Required user level: None + **Arguments**: + + - *None* + + **Return type**: List of `str` + + **Idempotent**: yes + + **Required user level**: Manager + + **Documentation**: + + Introspect the API and return all callable methods. Returns an array with the methods. """ @@ -39,15 +63,25 @@ def methods(**kwargs) -> List[str]: @transaction.atomic def login(username: str, password: str, **kwargs) -> bool: """ - Authenticate a user to use the API. + **Signature**: `login()` - Required user level: None + **Arguments**: - Returns true when a user has successfully signed in. A session will be - started and stored. Cookies must be supported by the client. + - `username` : `str` -- Username of user. + - `password` : `str` -- Password of the user. + + **Return type**: `bool` + + **Idempotent**: no + + **Required user level**: *None* - username -- Username of user - password -- Password of the user + **Documentation**: + + Authenticate a user to use the API. + + Returns `True` when a user has successfully signed in. A session will be + started and stored. Cookies must be supported by the client. """ from django.contrib.auth import authenticate, login request = kwargs.get(REQUEST_KEY) @@ -68,9 +102,21 @@ def login(username: str, password: str, **kwargs) -> bool: @transaction.atomic def logout(**kwargs) -> None: """ - Sign out the current user, even if no one was signed in. + **Signature**: `logout()` + + **Arguments**: + + - *None* + + **Return type**: `None` - Required user level: None + **Idempotent**: no + + **Required user level**: *None* + + **Documentation**: + + Sign out the current user, even if no one was signed in. Destroys the current session. """ diff --git a/alexia/api/v1/methods/juliana.py b/alexia/api/v1/methods/juliana.py index ab6f8e5..1400df5 100644 --- a/alexia/api/v1/methods/juliana.py +++ b/alexia/api/v1/methods/juliana.py @@ -82,6 +82,11 @@ def _get_validate_event(request, event_id, safe=False): @rpc_method(name='juliana.rfid.get', entry_point='v1') @login_required def juliana_rfid_get(event_id: int, rfid: Dict, **kwargs) -> Dict: + """ + Internal API method for the Point of Sale module. + + *No documentation available yet.* + """ request = kwargs.get(REQUEST_KEY) event = _get_validate_event(request, event_id, True) @@ -115,7 +120,13 @@ def juliana_rfid_get(event_id: int, rfid: Dict, **kwargs) -> Dict: @login_required @transaction.atomic def juliana_order_save(event_id: int, user_id: int, purchases: List[Dict], rfid_data: Dict, **kwargs) -> None: - """Saves a new order in the database""" + """ + Internal API method for the Point of Sale module. + + Saves a new order in the database + + *No documentation available yet.* + """ request = kwargs.get(REQUEST_KEY) event = _get_validate_event(request, event_id) @@ -177,6 +188,11 @@ def juliana_order_save(event_id: int, user_id: int, purchases: List[Dict], rfid_ @rpc_method(name='juliana.user.check', entry_point='v1') @login_required def juliana_user_check(event_id: int, user_id: int, **kwargs) -> int: + """ + Internal API method for the Point of Sale module. + + *No documentation available yet.* + """ request = kwargs.get(REQUEST_KEY) event = _get_validate_event(request, event_id, True) @@ -198,7 +214,13 @@ def juliana_user_check(event_id: int, user_id: int, **kwargs) -> int: @login_required @transaction.atomic def juliana_writeoff_save(event_id: int, writeoff_id: int, purchases: List[Dict], **kwargs) -> bool: - """Saves a writeoff order in the Database""" + """ + Internal API method for the Point of Sale module. + + Saves a writeoff order in the Database + + *No documentation available yet.* + """ request = kwargs.get(REQUEST_KEY) event = _get_validate_event(request, event_id) diff --git a/alexia/api/v1/methods/organization.py b/alexia/api/v1/methods/organization.py index eee6901..0c7393a 100644 --- a/alexia/api/v1/methods/organization.py +++ b/alexia/api/v1/methods/organization.py @@ -11,16 +11,29 @@ @login_required def organization_current_get(**kwargs) -> Optional[str]: """ - Return the current organization slug. + **Signature**: `organization.current.get()` + + **Arguments**: + + - *None* + + **Return type**: *(optional)* `str` - Required user level: None + **Idempotent**: yes + + **Required user level**: *None* + + **Documentation**: + + Return the current organization slug. All operations performed will be performed by this organization. - If no organization has been chosen, it will return None. + If no organization has been chosen, it will return `None`. + + **Example return value**: - Example return value: - "inter-actief" + "inter-actief" """ request = kwargs.get(REQUEST_KEY) if request.organization: @@ -33,18 +46,30 @@ def organization_current_get(**kwargs) -> Optional[str]: @login_required def organization_current_set(organization: str, **kwargs) -> bool: """ - Set the current organization. + **Signature**: `organization.current.set(organization)` + + **Arguments**: + + - `organization` : `str` -- slug of the organization or empty string to deselect organization. + + **Return type**: `bool` - Required user level: None + **Idempotent**: no + + **Required user level**: *None* + + **Documentation**: + + Set the current organization. All further operations performed will be performed by this organization. - Return true if the organization is switched. Returns false if the current + Return `True` if the organization is switched. Returns `False` if the current organization equals the provided organization. - organization -- slug of the organization or empty string to deselect organization. + **Raises errors**: - Raises error 404 if provided organization cannot be found. + - `404` (Object not found) if provided organization cannot be found. """ request = kwargs.get(REQUEST_KEY) if not organization: @@ -70,20 +95,33 @@ def organization_current_set(organization: str, **kwargs) -> bool: @login_required def organization_list(**kwargs) -> List[str]: """ - List all public organizations. + **Signature**: `organization.list()` + + **Arguments**: + + - *None* - Required user level: None + **Return type**: List of `str` + + **Idempotent**: no + + **Required user level**: *None* + + **Documentation**: + + List all public organizations. Returns an array with zero or more organizations. - Example return value: - [ - "abacus", - "inter-actief", - "proto", - "scintilla", - "sirius", - "stress" - ] + **Example return value**: + + [ + "abacus", + "inter-actief", + "proto", + "scintilla", + "sirius", + "stress" + ] """ return [o.slug for o in Organization.objects.all()] diff --git a/alexia/api/v1/methods/rfid.py b/alexia/api/v1/methods/rfid.py index 8cb13ee..f5d95db 100644 --- a/alexia/api/v1/methods/rfid.py +++ b/alexia/api/v1/methods/rfid.py @@ -16,39 +16,50 @@ @manager_required def rfid_list(radius_username: str = None, **kwargs) -> List[Dict]: """ - Retrieve registered RFID cards for the current selected organization. + **Signature**: `rfid.list(radius_username)` + + **Arguments**: + + - `radius_username` : `str` -- *(optional)* Username to search for. - Required user level: Manager + **Return type**: List of `dict` - Provide radius_username to select only RFID cards registered by the provided user. + **Idempotent**: yes + + **Required user level**: Manager + + **Documentation**: + + Retrieve registered RFID cards for the current selected organization. + + Provide `radius_username` to select only RFID cards registered by the provided user. Returns an array of registered RFID cards. - radius_username -- (optional) Username to search for. + **Example return value**: - Example return value: - [ - { + [ + { "identifier": "02,98:76:54:32", "registered_at": "2014-09-21T14:16:06+00:00" "user": "s0000000" - }, - { + }, + { "identifier": "02,dd:ee:ff:00", "registered_at": "2014-09-21T14:16:06+00:00" "user": "s0000000" - }, - { + }, + { "identifier": "03,fe:dc:ba:98", "registered_at": "2014-09-21T14:16:06+00:00" "user": "s0000000" - }, - { + }, + { "identifier": "05,01:23:45:67:89:ab:cd", "registered_at": "2014-09-21T14:16:06+00:00" "user": "s0000019" - } - ] + } + ] """ request = kwargs.get(REQUEST_KEY) result = [] @@ -74,22 +85,33 @@ def rfid_list(radius_username: str = None, **kwargs) -> List[Dict]: @manager_required def rfid_get(radius_username: str, **kwargs) -> List[str]: """ + **Signature**: `rfid.get(radius_username)` + + **Arguments**: + + - `radius_username` : `str` -- Username to search for. + + **Return type**: List of `str` + + **Idempotent**: yes + + **Required user level**: Manager + + **Documentation**: + Retrieve registered RFID cards for a specified user and current selected organization. - Required user level: Manager - Returns an array of registered RFID cards. - radius_username -- Username to search for. + **Example return value**: - Example return value: - [ - "02,98:76:54:32", - "02,dd:ee:ff:00", - "03,fe:dc:ba:98", - "05,01:23:45:67:89:ab:cd" - ] + [ + "02,98:76:54:32", + "02,dd:ee:ff:00", + "03,fe:dc:ba:98", + "05,01:23:45:67:89:ab:cd" + ] Raises error -32602 (Invalid params) if the username does not exist. """ @@ -114,25 +136,38 @@ def rfid_get(radius_username: str, **kwargs) -> List[str]: @transaction.atomic def rfid_add(radius_username: str, identifier: str, **kwargs) -> Dict: """ - Add a new RFID card to the specified user. + **Signature**: `rfid.add(radius_username, identifier)` + + **Arguments**: + + - `radius_username` : `str` -- Username to search for. + - `identifier` : `str` -- RFID card hardware identifier. + + **Return type**: `dict` + + **Idempotent**: no + + **Required user level**: Manager - Required user level: Manager + **Documentation**: + + Add a new RFID card to the specified user. Returns the RFID card on success. - radius_username -- Username to search for. - identifier -- RFID card hardware identiefier. + **Example return value**: - Example return value: - { - "identifier": "02,98:76:54:32", - "registered_at": "2014-09-21T14:16:06+00:00" - "user": "s0000000" - } + { + "identifier": "02,98:76:54:32", + "registered_at": "2014-09-21T14:16:06+00:00" + "user": "s0000000" + } - Raises error -32602 (Invalid params) if the username does not exist. - Raises error -32602 (Invalid params) if the RFID card already exists for this person. - Raises error -32602 (Invalid params) if the RFID card is already registered by someone else. + **Raises errors**: + + - `-32602` (Invalid params) if the username does not exist. + - `-32602` (Invalid params) if the RFID card already exists for this person. + - `-32602` (Invalid params) if the RFID card is already registered by someone else. """ request = kwargs.get(REQUEST_KEY) try: @@ -163,15 +198,27 @@ def rfid_add(radius_username: str, identifier: str, **kwargs) -> Dict: @transaction.atomic def rfid_remove(radius_username: str, identifier: str, **kwargs) -> None: """ - Remove an RFID card from the specified user. + **Signature**: `rfid.remove(radius_username, identifier)` - Required user level: Manager + **Arguments**: - radius_username -- Username to search for. - identifier -- RFID card hardware identiefier. + - `radius_username` : `str` -- Username to search for. + - `identifier` : `str` -- RFID card hardware identifier. - Raises error -32602 (Invalid params) if the username does not exist. - Raises error -32602 (Invalid params) if the RFID card does not exist for this person/organization. + **Return type**: `dict` + + **Idempotent**: no + + **Required user level**: Manager + + **Documentation**: + + Remove an RFID card from the specified user. + + **Raises errors**: + + - `-32602` (Invalid params) if the username does not exist. + - `-32602` (Invalid params) if the RFID card does not exist for this person/organization. """ request = kwargs.get(REQUEST_KEY) try: diff --git a/alexia/api/v1/methods/scheduling.py b/alexia/api/v1/methods/scheduling.py index 01b1644..b4c1992 100644 --- a/alexia/api/v1/methods/scheduling.py +++ b/alexia/api/v1/methods/scheduling.py @@ -12,33 +12,44 @@ @manager_required def user_get_availabilities(radius_username: str, **kwargs) -> List[Dict]: """ - Retrieve the availabilities entered by a specific user for the current organization. + **Signature**: `user.get_availabilities(radius_username)` - Required user level: Manager + **Arguments**: - radius_username -- Username to search for. + - `radius_username` : `str` -- Username to search for. - Raises error -32602 (Invalid params) if the username does not exist. + **Return type**: `dict` - Example result value: - [ - { - "event": { - "name": "Test Drink", - "date": "2017-09-12T14:00:00Z", + **Idempotent**: yes + + **Required user level**: Manager + + **Documentation**: + + Retrieve the availabilities entered by a specific user for the current organization. + **Example return value**: + + [ + { + "event": { + "name": "Test Drink", + "date": "2017-09-12T14:00:00Z", }, "availability": "Yes" - }, - { + }, + { "event": { - "name": "Test Drink 2", - "date": "2017-09-18T16:00:00Z", - + "name": "Test Drink 2", + "date": "2017-09-18T16:00:00Z", }, "availability": "Maybe" - } - ] + } + ] + + **Raises errors**: + + - `-32602` (Invalid params) if the username does not exist. """ request = kwargs.get(REQUEST_KEY) try: diff --git a/alexia/api/v1/methods/user.py b/alexia/api/v1/methods/user.py index 0a20194..0972d9d 100644 --- a/alexia/api/v1/methods/user.py +++ b/alexia/api/v1/methods/user.py @@ -19,25 +19,40 @@ @transaction.atomic def user_add(radius_username: str, first_name: str, last_name: str, email: str, **kwargs) -> Dict: """ + **Signature**: `user.add(radius_username, first_name, last_name, email)` + + **Arguments**: + + - `radius_username` : `str` -- Unique username + - `first_name` : `str` -- First name + - `last_name` : `str` -- Last name + - `email` : `str` -- Valid email address + + **Return type**: `dict` + + **Idempotent**: no + + **Required user level**: Manager + + **Documentation**: + Add a new user to Alexia. A user must have a unique username and a valid email address. Returns the user information on success. - radius_username -- Unique username - first_name -- First name - last_name -- Last name - email -- Valid email address + **Example return value**: - Example result value: - { - "first_name": "John", - "last_name": "Doe", - "radius_username": "s0000000" - } + { + "first_name": "John", + "last_name": "Doe", + "radius_username": "s0000000" + } + + **Raises errors**: - Raises error -32602 (Invalid params) if the username already exists. + - `-32602` (Invalid params) if the username already exists. """ if User.objects.filter(username=radius_username).exists() or \ AuthenticationData.objects.filter(backend=OIDC_BACKEND_NAME, username__iexact=radius_username).exists(): @@ -59,11 +74,23 @@ def user_add(radius_username: str, first_name: str, last_name: str, email: str, @login_required def user_exists(radius_username: str, **kwargs) -> bool: """ - Check if a user exists by his or her username. + **Signature**: `user.exists(radius_username)` + + **Arguments**: + + - `radius_username` : `str` -- Username to search for + + **Return type**: `bool` - Returns true when the username exists, false otherwise. + **Idempotent**: yes - radius_username -- Username to search for. + **Required user level**: User + + **Documentation**: + + Check if a user exists by his or her username. + + Returns `True` when the username exists, `False` otherwise. """ return User.objects.filter(authenticationdata__backend=OIDC_BACKEND_NAME, authenticationdata__username=radius_username).exists() @@ -73,21 +100,35 @@ def user_exists(radius_username: str, **kwargs) -> bool: @login_required def user_get(radius_username: str, **kwargs) -> Dict: """ + **Signature**: `user.get(radius_username)` + + **Arguments**: + + - `radius_username` : `str` -- Username to search for. + + **Return type**: `dict` + + **Idempotent**: yes + + **Required user level**: User + + **Documentation**: + Retrieve information about a specific user. - Returns an object representing the user. Result contains first_name, - last_name and radius_username. + Returns an object representing the user. Result contains `first_name`, `last_name` and `radius_username`. - radius_username -- Username to search for. + **Example return value**: - Raises error 404 if provided username cannot be found. + { + "first_name": "John", + "last_name": "Doe", + "radius_username": "s0000000" + } - Example result value: - { - "first_name": "John", - "last_name": "Doe", - "radius_username": "s0000000" - } + **Raises errors**: + + - `404` (Object not found) if the provided username cannot be found. """ try: user = User.objects.get(authenticationdata__backend=OIDC_BACKEND_NAME, @@ -102,18 +143,33 @@ def user_get(radius_username: str, **kwargs) -> Dict: @login_required def user_get_by_id(user_id: int, **kwargs) -> Dict: """ + **Signature**: `user.get_by_id(radius_username)` + + **Arguments**: + + - `user_id` : `int` -- User id to search for. + + **Return type**: `dict` + + **Idempotent**: yes + + **Required user level**: User + + **Documentation**: + Retrieve information about a specific user. - user_id -- User id to search for. + **Example return value**: - Raises error 404 if provided username cannot be found. + { + "first_name": "John", + "last_name": "Doe", + "radius_username": "s0000000" + } - Example result value: - { - "first_name": "John", - "last_name": "Doe", - "radius_username": "s0000000" - } + **Raises errors**: + + - `404` (Object not found) if the provided username cannot be found. """ try: user = User.objects.get(id=user_id) @@ -127,25 +183,37 @@ def user_get_by_id(user_id: int, **kwargs) -> Dict: @manager_required def user_get_membership(radius_username: str, **kwargs) -> Dict: """ + **Signature**: `user.get_membership(radius_username)` + + **Arguments**: + + - `radius_username` : `str` -- Username to search for. + + **Return type**: `dict` + + **Idempotent**: yes + + **Required user level**: Manager + + **Documentation**: + Retrieve the membership details for a specific user for the current organization. - Required user level: Manager + **Example return value**: - radius_username -- Username to search for. + { + "user": "s0000000", + "organization": "Inter-Actief", + "comments": "", + "is_tender": True, + "is_planner": False, + "is_manager": False, + "is_active": True + } - Raises error 404 if the provided username cannot be found or the user has no membership with the current - organization. + **Raises errors**: - Example result value: - { - "user": "s0000000", - "organization": "Inter-Actief", - "comments": "", - "is_tender": True, - "is_planner": False, - "is_manager": False, - "is_active": True - } + - `404` (Object not found) if the provided username cannot be found or the user has no membership with the current organization. """ request = kwargs.get(REQUEST_KEY) try: @@ -177,19 +245,33 @@ def user_get_membership(radius_username: str, **kwargs) -> Dict: @manager_required def user_get_iva_certificate(radius_username: str, **kwargs) -> Dict: """ + **Signature**: `user.get_iva_certificate(radius_username)` + + **Arguments**: + + - `radius_username` : `str` -- Username to search for. + + **Return type**: `dict` + + **Idempotent**: yes + + **Required user level**: Manager + + **Documentation**: + Retrieve the IVA certificate file for a specific user. - Required user level: Manager + **Example return value**: - radius_username -- Username to search for. + { + "user": "s0000000", + "certificate_data": "U29tZSBiYXNlIDY0IHRleHQgdGhhdCBtaWdodCBiZ.........BhIGxvdCBsb25nZXIgdGhhbiB0aGlzIGlzLi4u" + } - Raises error 404 if provided username cannot be found or the user has no IVA certificate. + **Raises errors**: + + - `404` (Object not found) if provided username cannot be found or the user has no IVA certificate. - Example result value: - { - "user": "s0000000", - "certificate_data": "U29tZSBiYXNlIDY0IHRleHQgdGhhdCBtaWdodCBiZ.........BhIGxvdCBsb25nZXIgdGhhbiB0aGlzIGlzLi4u" - } """ try: user = User.objects.get(authenticationdata__backend=OIDC_BACKEND_NAME, diff --git a/alexia/conf/settings/base.py b/alexia/conf/settings/base.py index 352cb81..4143169 100644 --- a/alexia/conf/settings/base.py +++ b/alexia/conf/settings/base.py @@ -151,12 +151,14 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' # ModernRPC API -MODERNRPC_HANDLERS = [ - "alexia.api.handlers.AlexiaJSONRPCHandler" -] MODERNRPC_METHODS_MODULES = [ 'alexia.api.v1.methods' ] +MODERNRPC_HANDLERS = [ + "alexia.api.handlers.AlexiaJSONRPCHandler" +] +# API documentation strings are formatted with markdown +MODERNRPC_DOC_FORMAT = 'markdown' # Single Sign On via https://auth.ia.utwente.nl/ OIDC_OP_AUTHORIZATION_ENDPOINT = "https://auth.ia.utwente.nl/realms/inter-actief/protocol/openid-connect/auth" diff --git a/requirements.txt b/requirements.txt index 3a7365a..c05c07c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,8 @@ icalendar==4.1.0 jsonfield>=3.1.0,<3.2 # Django Modern RPC -- For our JSONRPC API django-modern-rpc>=1.0.1,<1.1 +# Markdown formatting is used in the API docstrings +Markdown>=3.8,<4 raven==6.10.0 # Translations -- development django-rosetta>=0.10.2,<0.11.0 diff --git a/templates/api/doc.html b/templates/api/doc.html index 87a5ee7..fe1d7dd 100644 --- a/templates/api/doc.html +++ b/templates/api/doc.html @@ -20,38 +20,15 @@

{% block headertitle %}API documentation{% endblock %}

Methods {% for method in methods %}

{{ method.name }}

+ - - - - - - - - - - - - - - - - - - - - - - +
Signature - {{ method.name }}({% for param in method.params %}{{ param.name }}{% if not forloop.last %}, {% endif %}{% endfor %}) -
Arguments - {% for param in method.params %} - {{ param.name }} : {{ param.type }} - {% if not forloop.last %}
{% endif %} - {% empty %} - None specified - {% endfor %} -
Return type{{ method.return.type }}
Idempotent{{ method.idempotent|yesno:'yes,no' }}
Summary
{{ method.summary }}
+ {% if method.html_doc %} + {{ method.html_doc | safe | urlize }} + {% else %} + No documentation available yet. + {% endif %} +
{% endfor %} diff --git a/templates/api/info.html b/templates/api/info.html index 2052acb..341e915 100644 --- a/templates/api/info.html +++ b/templates/api/info.html @@ -16,7 +16,6 @@

fullest, a manager-authorization on at least one organization is recommended.

{% endblock content %} diff --git a/templates/api/v1/doc.html b/templates/api/v1/doc.html index d28d921..5670c79 100644 --- a/templates/api/v1/doc.html +++ b/templates/api/v1/doc.html @@ -19,7 +19,7 @@

Basics

of positional parameters is highly encouraged.

- URL: https://alex.ia.utwente.nl{{ mountpoint }} + URL: https://alex.ia.utwente.nl/api/1/

Authentication