From af4d888b1bd835b9f748978895f5819678ef6467 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 8 Feb 2024 16:19:57 +1100 Subject: [PATCH] Generate API docs (#6319) * Add basic task for generating apidocs * Fix SPECTACTULAR_SETTINGS - Some provided options were not correct * Update .gitignore * Fix for duplicated API path - `/api/plugins/activate` routed to PluginActivate view - Must be associated with a specific plugin ID * By default, fail if warnings are raised * Use GenericAPIView for GetAuthToken * Use GenericAPIView for RolesDetail endpoint * Refactor more endpoints to use GenericApiView * More API cleanup * Add extra type hints for exposed methods * Update RoleDetails endpoint - Specify serializer - Use RetrieveAPI class type * More type hints * Export API docs as part of CI * add more api views docs * even more docs * extend tests to api-version * simplify serializer * and more docs * fix serializer * added more API docs * clean diff * Added APISearch base * do not assume you know the user he might be anonymously creating the schema ;-) * set empty serializer where no input is needed * Use dummy model for schema generation * fix OpenAPI docs section * Update .github/workflows/qc_checks.yaml Co-authored-by: Matthias Mair * REmove duplicate commands * Ignore warnings in CI --------- Co-authored-by: Matthias Mair --- .github/workflows/qc_checks.yaml | 27 +++++++++++++ .gitignore | 5 +++ InvenTree/InvenTree/api.py | 52 ++++++++++++++++++++++++- InvenTree/InvenTree/magic_login.py | 4 +- InvenTree/InvenTree/serializers.py | 4 ++ InvenTree/InvenTree/settings.py | 13 ++++--- InvenTree/InvenTree/social_auth_urls.py | 28 ++++++++++++- InvenTree/InvenTree/test_api_version.py | 5 +++ InvenTree/common/api.py | 12 ++++++ InvenTree/common/models.py | 4 +- InvenTree/common/serializers.py | 6 ++- InvenTree/generic/states/api.py | 27 ++++++++++--- InvenTree/part/api.py | 10 ++++- InvenTree/part/models.py | 4 +- InvenTree/part/serializers.py | 4 +- InvenTree/plugin/base/action/api.py | 14 +++++-- InvenTree/plugin/base/locate/api.py | 23 +++++++++-- InvenTree/plugin/serializers.py | 1 + InvenTree/stock/models.py | 2 +- InvenTree/users/api.py | 12 +++++- InvenTree/users/serializers.py | 40 ++++++++++++++++++- tasks.py | 10 ++++- 22 files changed, 271 insertions(+), 36 deletions(-) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 70592c6817..fdeefc31f6 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -112,6 +112,33 @@ jobs: pip install linkcheckmd requests python -m linkcheckmd docs --recurse + schema: + name: Tests - API Schema Documentation + runs-on: ubuntu-20.04 + needs: paths-filter + if: needs.paths-filter.outputs.server == 'true' + env: + INVENTREE_DB_ENGINE: django.db.backends.sqlite3 + INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3 + INVENTREE_ADMIN_USER: testuser + INVENTREE_ADMIN_PASSWORD: testpassword + INVENTREE_ADMIN_EMAIL: test@test.com + INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345 + INVENTREE_PYTHON_TEST_USERNAME: testuser + INVENTREE_PYTHON_TEST_PASSWORD: testpassword + + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1 + - name: Environment Setup + uses: ./.github/actions/setup + with: + apt-dependency: gettext poppler-utils + dev-install: true + update: true + - name: Export API Documentation + run: | + invoke schema --ignore-warnings + python: name: Tests - inventree-python runs-on: ubuntu-20.04 diff --git a/.gitignore b/.gitignore index a590b45fc7..6296696ebe 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ label.pdf label.png InvenTree/my_special* _tests*.txt +schema.yml # Local static and media file storage (only when running in development mode) inventree_media @@ -70,6 +71,7 @@ secret_key.txt .idea/ *.code-workspace .bash_history +.DS_Store # https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore .vscode/* @@ -107,5 +109,8 @@ InvenTree/plugins/ *.mo messages.ts +# Generated API schema file +api.yaml + # web frontend (static files) InvenTree/web/static diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index ad8d29666e..41f292b7b2 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -1,5 +1,7 @@ """Main JSON interface views.""" +import sys + from django.conf import settings from django.db import transaction from django.http import JsonResponse @@ -8,6 +10,7 @@ from django.utils.translation import gettext_lazy as _ from django_q.models import OrmQ from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework import permissions, serializers +from rest_framework.generics import GenericAPIView from rest_framework.response import Response from rest_framework.serializers import ValidationError from rest_framework.views import APIView @@ -18,6 +21,7 @@ from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.mixins import ListCreateAPI from InvenTree.permissions import RolePermission from InvenTree.templatetags.inventree_extras import plugins_info +from part.models import Part from plugin.serializers import MetadataSerializer from users.models import ApiToken @@ -28,11 +32,41 @@ from .version import inventreeApiText from .views import AjaxView +class VersionViewSerializer(serializers.Serializer): + """Serializer for a single version.""" + + class VersionSerializer(serializers.Serializer): + """Serializer for server version.""" + + server = serializers.CharField() + api = serializers.IntegerField() + commit_hash = serializers.CharField() + commit_date = serializers.CharField() + commit_branch = serializers.CharField() + python = serializers.CharField() + django = serializers.CharField() + + class LinkSerializer(serializers.Serializer): + """Serializer for all possible links.""" + + doc = serializers.URLField() + code = serializers.URLField() + credit = serializers.URLField() + app = serializers.URLField() + bug = serializers.URLField() + + dev = serializers.BooleanField() + up_to_date = serializers.BooleanField() + version = VersionSerializer() + links = LinkSerializer() + + class VersionView(APIView): """Simple JSON endpoint for InvenTree version information.""" permission_classes = [permissions.IsAdminUser] + @extend_schema(responses={200: OpenApiResponse(response=VersionViewSerializer)}) def get(self, request, *args, **kwargs): """Return information about the InvenTree server.""" return JsonResponse({ @@ -81,6 +115,8 @@ class VersionApiSerializer(serializers.Serializer): class VersionTextView(ListAPI): """Simple JSON endpoint for InvenTree version text.""" + serializer_class = VersionSerializer + permission_classes = [permissions.IsAdminUser] @extend_schema(responses={200: OpenApiResponse(response=VersionApiSerializer)}) @@ -324,7 +360,17 @@ class AttachmentMixin: attachment.save() -class APISearchView(APIView): +class APISearchViewSerializer(serializers.Serializer): + """Serializer for the APISearchView.""" + + search = serializers.CharField() + search_regex = serializers.BooleanField(default=False, required=False) + search_whole = serializers.BooleanField(default=False, required=False) + limit = serializers.IntegerField(default=1, required=False) + offset = serializers.IntegerField(default=0, required=False) + + +class APISearchView(GenericAPIView): """A general-purpose 'search' API endpoint. Returns hits against a number of different models simultaneously, @@ -334,6 +380,7 @@ class APISearchView(APIView): """ permission_classes = [permissions.IsAuthenticated] + serializer_class = APISearchViewSerializer def get_result_types(self): """Construct a list of search types we can return.""" @@ -446,4 +493,7 @@ class MetadataView(RetrieveUpdateAPI): def get_serializer(self, *args, **kwargs): """Return MetadataSerializer instance.""" + # Detect if we are currently generating the OpenAPI schema + if 'spectacular' in sys.argv: + return MetadataSerializer(Part, *args, **kwargs) return MetadataSerializer(self.get_model_type(), *args, **kwargs) diff --git a/InvenTree/InvenTree/magic_login.py b/InvenTree/InvenTree/magic_login.py index 8d8df798ae..996e0b35d1 100644 --- a/InvenTree/InvenTree/magic_login.py +++ b/InvenTree/InvenTree/magic_login.py @@ -9,8 +9,8 @@ from django.utils.translation import gettext_lazy as _ import sesame.utils from rest_framework import serializers +from rest_framework.generics import GenericAPIView from rest_framework.response import Response -from rest_framework.views import APIView import InvenTree.version @@ -38,7 +38,7 @@ class GetSimpleLoginSerializer(serializers.Serializer): email = serializers.CharField(label=_('Email')) -class GetSimpleLoginView(APIView): +class GetSimpleLoginView(GenericAPIView): """View to send a simple login link.""" permission_classes = () diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index b4522bf01f..08df401085 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -28,6 +28,10 @@ from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField from InvenTree.helpers_model import download_image_from_url, get_base_url +class EmptySerializer(serializers.Serializer): + """Empty serializer for use in testing.""" + + class InvenTreeMoneySerializer(MoneyField): """Custom serializer for 'MoneyField', which ensures that passed values are numerically valid. diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 7fe6084bf3..c8c7f040c4 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -517,12 +517,15 @@ if USE_JWT: SPECTACULAR_SETTINGS = { 'TITLE': 'InvenTree API', 'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system', - 'LICENSE': {'MIT': 'https://github.com/inventree/InvenTree/blob/master/LICENSE'}, - 'EXTERNAL_DOCS': { - 'docs': 'https://docs.inventree.org', - 'web': 'https://inventree.org', + 'LICENSE': { + 'name': 'MIT', + 'url': 'https://github.com/inventree/InvenTree/blob/master/LICENSE', }, - 'VERSION': inventreeApiVersion(), + 'EXTERNAL_DOCS': { + 'description': 'More information about InvenTree in the official docs', + 'url': 'https://docs.inventree.org', + }, + 'VERSION': str(inventreeApiVersion()), 'SERVE_INCLUDE_SCHEMA': False, } diff --git a/InvenTree/InvenTree/social_auth_urls.py b/InvenTree/InvenTree/social_auth_urls.py index ecfef99b96..c9a77eb5df 100644 --- a/InvenTree/InvenTree/social_auth_urls.py +++ b/InvenTree/InvenTree/social_auth_urls.py @@ -9,6 +9,7 @@ from allauth.account.models import EmailAddress from allauth.socialaccount import providers from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import serializers from rest_framework.exceptions import NotFound from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response @@ -16,7 +17,7 @@ from rest_framework.response import Response import InvenTree.sso from common.models import InvenTreeSetting from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI -from InvenTree.serializers import InvenTreeModelSerializer +from InvenTree.serializers import EmptySerializer, InvenTreeModelSerializer logger = logging.getLogger('inventree') @@ -112,11 +113,36 @@ for name, provider in providers.registry.provider_map.items(): social_auth_urlpatterns += provider_urlpatterns +class SocialProviderListResponseSerializer(serializers.Serializer): + """Serializer for the SocialProviderListView.""" + + class SocialProvider(serializers.Serializer): + """Serializer for the SocialProviderListResponseSerializer.""" + + id = serializers.CharField() + name = serializers.CharField() + configured = serializers.BooleanField() + login = serializers.URLField() + connect = serializers.URLField() + display_name = serializers.CharField() + + sso_enabled = serializers.BooleanField() + sso_registration = serializers.BooleanField() + mfa_required = serializers.BooleanField() + providers = SocialProvider(many=True) + registration_enabled = serializers.BooleanField() + password_forgotten_enabled = serializers.BooleanField() + + class SocialProviderListView(ListAPI): """List of available social providers.""" permission_classes = (AllowAny,) + serializer_class = EmptySerializer + @extend_schema( + responses={200: OpenApiResponse(response=SocialProviderListResponseSerializer)} + ) def get(self, request, *args, **kwargs): """Get the list of providers.""" provider_list = [] diff --git a/InvenTree/InvenTree/test_api_version.py b/InvenTree/InvenTree/test_api_version.py index 4b2a3b49b5..8bddb0c561 100644 --- a/InvenTree/InvenTree/test_api_version.py +++ b/InvenTree/InvenTree/test_api_version.py @@ -18,6 +18,11 @@ class ApiVersionTests(InvenTreeAPITestCase): self.assertEqual(len(data), 10) + response = self.client.get(reverse('api-version'), format='json').json() + self.assertIn('version', response) + self.assertIn('dev', response) + self.assertIn('up_to_date', response) + def test_inventree_api_text(self): """Test that the inventreeApiText function works expected.""" # Normal run diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 7eec94aff2..13a8bc19f8 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -11,6 +11,7 @@ from django.views.decorators.csrf import csrf_exempt import django_q.models from django_q.tasks import async_task from djmoney.contrib.exchange.models import ExchangeBackend, Rate +from drf_spectacular.utils import OpenApiResponse, extend_schema from error_report.models import Error from rest_framework import permissions, serializers from rest_framework.exceptions import NotAcceptable, NotFound @@ -53,7 +54,15 @@ class WebhookView(CsrfExemptMixin, APIView): permission_classes = [] model_class = common.models.WebhookEndpoint run_async = False + serializer_class = None + @extend_schema( + responses={ + 200: OpenApiResponse( + description='Any data can be posted to the endpoint - everything will be passed to the WebhookEndpoint model.' + ) + } + ) def post(self, request, endpoint, *args, **kwargs): """Process incoming webhook.""" # get webhook definition @@ -115,6 +124,7 @@ class CurrencyExchangeView(APIView): """API endpoint for displaying currency information.""" permission_classes = [permissions.IsAuthenticated] + serializer_class = None def get(self, request, format=None): """Return information on available currency conversions.""" @@ -157,6 +167,7 @@ class CurrencyRefreshView(APIView): """ permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] + serializer_class = None def post(self, request, *args, **kwargs): """Performing a POST request will update currency exchange rates.""" @@ -516,6 +527,7 @@ class BackgroundTaskOverview(APIView): """Provides an overview of the background task queue status.""" permission_classes = [permissions.IsAuthenticated, IsAdminUser] + serializer_class = None def get(self, request, format=None): """Return information about the current status of the background task queue.""" diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index bbe4b8af21..284aef788c 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -2868,7 +2868,7 @@ class NotificationMessage(models.Model): """Return API endpoint.""" return reverse('api-notifications-list') - def age(self): + def age(self) -> int: """Age of the message in seconds.""" # Add timezone information if TZ is enabled (in production mode mostly) delta = now() - ( @@ -2878,7 +2878,7 @@ class NotificationMessage(models.Model): ) return delta.seconds - def age_human(self): + def age_human(self) -> str: """Humanized age.""" return naturaltime(self.creation) diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 8b6dcb70c2..3a6cc70b84 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -59,6 +59,8 @@ class SettingsSerializer(InvenTreeModelSerializer): units = serializers.CharField(read_only=True) + typ = serializers.CharField(read_only=True) + def get_choices(self, obj): """Returns the choices available for a given item.""" results = [] @@ -195,7 +197,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer): user = serializers.PrimaryKeyRelatedField(read_only=True) read = serializers.BooleanField() - def get_target(self, obj): + def get_target(self, obj) -> dict: """Function to resolve generic object reference to target.""" target = get_objectreference(obj, 'target_content_type', 'target_object_id') @@ -217,7 +219,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer): return target - def get_source(self, obj): + def get_source(self, obj) -> dict: """Function to resolve generic object reference to source.""" return get_objectreference(obj, 'source_content_type', 'source_object_id') diff --git a/InvenTree/generic/states/api.py b/InvenTree/generic/states/api.py index 77cd5d531d..8827d1121f 100644 --- a/InvenTree/generic/states/api.py +++ b/InvenTree/generic/states/api.py @@ -2,15 +2,24 @@ import inspect -from rest_framework import permissions +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import permissions, serializers +from rest_framework.generics import GenericAPIView from rest_framework.response import Response -from rest_framework.serializers import ValidationError -from rest_framework.views import APIView + +from InvenTree.serializers import EmptySerializer from .states import StatusCode -class StatusView(APIView): +class StatusViewSerializer(serializers.Serializer): + """Serializer for the StatusView responses.""" + + class_name = serializers.CharField() + values = serializers.DictField() + + +class StatusView(GenericAPIView): """Generic API endpoint for discovering information on 'status codes' for a particular model. This class should be implemented as a subclass for each type of status. @@ -28,12 +37,19 @@ class StatusView(APIView): status_model = self.kwargs.get(self.MODEL_REF, None) if status_model is None: - raise ValidationError( + raise serializers.ValidationError( f"StatusView view called without '{self.MODEL_REF}' parameter" ) return status_model + @extend_schema( + description='Retrieve information about a specific status code', + responses={ + 200: OpenApiResponse(description='Status code information'), + 400: OpenApiResponse(description='Invalid request'), + }, + ) def get(self, request, *args, **kwargs): """Perform a GET request to learn information about status codes.""" status_class = self.get_status_model() @@ -53,6 +69,7 @@ class AllStatusViews(StatusView): """Endpoint for listing all defined status models.""" permission_classes = [permissions.IsAuthenticated] + serializer_class = EmptySerializer def get(self, request, *args, **kwargs): """Perform a GET request to learn information about status codes.""" diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 4770d50627..b2b57fbaa8 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -49,6 +49,7 @@ from InvenTree.mixins import ( UpdateAPI, ) from InvenTree.permissions import RolePermission +from InvenTree.serializers import EmptySerializer from InvenTree.status_codes import ( BuildStatusGroups, PurchaseOrderStatusGroups, @@ -487,6 +488,7 @@ class PartScheduling(RetrieveAPI): """ queryset = Part.objects.all() + serializer_class = EmptySerializer def retrieve(self, request, *args, **kwargs): """Return scheduling information for the referenced Part instance.""" @@ -687,6 +689,7 @@ class PartRequirements(RetrieveAPI): """ queryset = Part.objects.all() + serializer_class = EmptySerializer def retrieve(self, request, *args, **kwargs): """Construct a response detailing Part requirements.""" @@ -738,6 +741,7 @@ class PartSerialNumberDetail(RetrieveAPI): """API endpoint for returning extra serial number information about a particular part.""" queryset = Part.objects.all() + serializer_class = EmptySerializer def retrieve(self, request, *args, **kwargs): """Return serial number information for the referenced Part instance.""" @@ -1068,7 +1072,11 @@ class PartMixin: # Pass a list of "starred" parts to the current user to the serializer # We do this to reduce the number of database queries required! - if self.starred_parts is None and self.request is not None: + if ( + self.starred_parts is None + and self.request is not None + and hasattr(self.request.user, 'starred_parts') + ): self.starred_parts = [ star.part for star in self.request.user.starred_parts.all() ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index c1a4b41ef9..a36f17fd55 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -748,7 +748,7 @@ class Part( return stock[0].serial @property - def full_name(self): + def full_name(self) -> str: """Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in InvenTree settings.""" return part_helpers.render_part_full_name(self) @@ -762,7 +762,7 @@ class Part( return helpers.getMediaUrl(self.image.url) return helpers.getBlankImage() - def get_thumbnail_url(self): + def get_thumbnail_url(self) -> str: """Return the URL of the image thumbnail for this part.""" if self.image: return helpers.getMediaUrl(self.image.thumbnail.url) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index d1d8051063..3c9eb554c0 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -91,7 +91,7 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer): if not path_detail: self.fields.pop('path') - def get_starred(self, category): + def get_starred(self, category) -> bool: """Return True if the category is directly "starred" by the current user.""" return category in self.context.get('starred_categories', []) @@ -723,7 +723,7 @@ class PartSerializer( return queryset - def get_starred(self, part): + def get_starred(self, part) -> bool: """Return "true" if the part is starred by the current user.""" return part in self.starred_parts diff --git a/InvenTree/plugin/base/action/api.py b/InvenTree/plugin/base/action/api.py index 37dabd9eda..7e3df03352 100644 --- a/InvenTree/plugin/base/action/api.py +++ b/InvenTree/plugin/base/action/api.py @@ -2,17 +2,25 @@ from django.utils.translation import gettext_lazy as _ -from rest_framework import permissions +from rest_framework import permissions, serializers +from rest_framework.generics import GenericAPIView from rest_framework.response import Response -from rest_framework.views import APIView from plugin import registry -class ActionPluginView(APIView): +class ActionPluginSerializer(serializers.Serializer): + """Serializer for the ActionPluginView responses.""" + + action = serializers.CharField() + data = serializers.DictField() + + +class ActionPluginView(GenericAPIView): """Endpoint for running custom action plugins.""" permission_classes = [permissions.IsAuthenticated] + serializer_class = ActionPluginSerializer def post(self, request, *args, **kwargs): """This function checks if all required info was submitted and then performs a plugin_action or returns an error.""" diff --git a/InvenTree/plugin/base/locate/api.py b/InvenTree/plugin/base/locate/api.py index c3f921d010..8d37505b3a 100644 --- a/InvenTree/plugin/base/locate/api.py +++ b/InvenTree/plugin/base/locate/api.py @@ -1,22 +1,37 @@ """API for location plugins.""" -from rest_framework import permissions +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import permissions, serializers from rest_framework.exceptions import NotFound, ParseError +from rest_framework.generics import GenericAPIView from rest_framework.response import Response -from rest_framework.views import APIView from InvenTree.tasks import offload_task from plugin.registry import registry from stock.models import StockItem, StockLocation -class LocatePluginView(APIView): +class LocatePluginSerializer(serializers.Serializer): + """Serializer for the LocatePluginView API endpoint.""" + + plugin = serializers.CharField( + help_text='Plugin to use for location identification' + ) + item = serializers.IntegerField(required=False, help_text='StockItem to identify') + location = serializers.IntegerField( + required=False, help_text='StockLocation to identify' + ) + + +class LocatePluginView(GenericAPIView): """Endpoint for using a custom plugin to identify or 'locate' a stock item or location.""" permission_classes = [permissions.IsAuthenticated] + serializer_class = LocatePluginSerializer def post(self, request, *args, **kwargs): - """Check inputs and offload the task to the plugin.""" + """Identify or 'locate' a stock item or location with a plugin.""" + # Check inputs and offload the task to the plugin # Which plugin to we wish to use? plugin = request.data.get('plugin', None) diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index 19ae5b3a6e..507ba20799 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -247,6 +247,7 @@ class NotificationUserSettingSerializer(GenericReferencedSettingSerializer): EXTRA_FIELDS = ['method'] method = serializers.CharField(read_only=True) + typ = serializers.CharField(read_only=True) class PluginRegistryErrorSerializer(serializers.Serializer): diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 05f6482bea..f4d729eee7 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -184,7 +184,7 @@ class StockLocation( ) @property - def icon(self): + def icon(self) -> str: """Get the current icon used for this location. The icon field on this model takes precedences over the possibly assigned stock location type diff --git a/InvenTree/users/api.py b/InvenTree/users/api.py index 6d97ee3ee8..17def22835 100644 --- a/InvenTree/users/api.py +++ b/InvenTree/users/api.py @@ -9,6 +9,7 @@ from django.urls import include, path, re_path from django.views.generic.base import RedirectView from dj_rest_auth.views import LogoutView +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from rest_framework import exceptions, permissions from rest_framework.response import Response from rest_framework.views import APIView @@ -110,6 +111,7 @@ class RoleDetails(APIView): """ permission_classes = [permissions.IsAuthenticated] + serializer_class = None def get(self, request, *args, **kwargs): """Return the list of roles / permissions available to the current user.""" @@ -203,10 +205,17 @@ class GroupList(ListCreateAPI): ordering_fields = ['name'] +@extend_schema_view( + post=extend_schema( + responses={200: OpenApiResponse(description='User successfully logged out')} + ) +) class Logout(LogoutView): """API view for logging out via API.""" - def logout(self, request): + serializer_class = None + + def post(self, request): """Logout the current user. Deletes user token associated with request. @@ -230,6 +239,7 @@ class GetAuthToken(APIView): """Return authentication token for an authenticated user.""" permission_classes = [permissions.IsAuthenticated] + serializer_class = None def get(self, request, *args, **kwargs): """Return an API token if the user is authenticated. diff --git a/InvenTree/users/serializers.py b/InvenTree/users/serializers.py index 435c0ea27a..b4e3dbb7da 100644 --- a/InvenTree/users/serializers.py +++ b/InvenTree/users/serializers.py @@ -1,12 +1,12 @@ """DRF API serializers for the 'users' app.""" -from django.contrib.auth.models import Group +from django.contrib.auth.models import Group, User from rest_framework import serializers from InvenTree.serializers import InvenTreeModelSerializer -from .models import Owner +from .models import Owner, RuleSet, check_user_role class OwnerSerializer(InvenTreeModelSerializer): @@ -31,3 +31,39 @@ class GroupSerializer(InvenTreeModelSerializer): model = Group fields = ['pk', 'name'] + + +class RoleSerializer(InvenTreeModelSerializer): + """Serializer for a roles associated with a given user.""" + + class Meta: + """Metaclass options.""" + + model = User + fields = ['user', 'username', 'is_staff', 'is_superuser', 'roles'] + + user = serializers.IntegerField(source='pk') + username = serializers.CharField() + is_staff = serializers.BooleanField() + is_superuser = serializers.BooleanField() + roles = serializers.SerializerMethodField() + + def get_roles(self, user: User) -> dict: + """Return roles associated with the specified User.""" + roles = {} + + for ruleset in RuleSet.RULESET_CHOICES: + role, _text = ruleset + + permissions = [] + + for permission in RuleSet.RULESET_PERMISSIONS: + if check_user_role(user, role, permission): + permissions.append(permission) + + if len(permissions) > 0: + roles[role] = permissions + else: + roles[role] = None # pragma: no cover + + return roles diff --git a/tasks.py b/tasks.py index 0bd1254937..1aece92963 100644 --- a/tasks.py +++ b/tasks.py @@ -894,10 +894,16 @@ def setup_test(c, ignore_update=False, dev=False, path='inventree-demo-dataset') 'overwrite': 'Overwrite existing files without asking first (default = off/False)', } ) -def schema(c, filename='schema.yml', overwrite=False): +def schema(c, filename='schema.yml', overwrite=False, ignore_warnings=False): """Export current API schema.""" check_file_existance(filename, overwrite) - manage(c, f'spectacular --file {filename}') + + cmd = f'spectacular --file {filename} --validate --color' + + if not ignore_warnings: + cmd += ' --fail-on-warn' + + manage(c, cmd, pty=True) @task(default=True)