2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 03:26:45 +00:00

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 <code@mjmair.com>

* REmove duplicate commands

* Ignore warnings in CI

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver 2024-02-08 16:19:57 +11:00 committed by GitHub
parent a99ba75fed
commit af4d888b1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 271 additions and 36 deletions

View File

@ -112,6 +112,33 @@ jobs:
pip install linkcheckmd requests pip install linkcheckmd requests
python -m linkcheckmd docs --recurse 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: python:
name: Tests - inventree-python name: Tests - inventree-python
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04

5
.gitignore vendored
View File

@ -48,6 +48,7 @@ label.pdf
label.png label.png
InvenTree/my_special* InvenTree/my_special*
_tests*.txt _tests*.txt
schema.yml
# Local static and media file storage (only when running in development mode) # Local static and media file storage (only when running in development mode)
inventree_media inventree_media
@ -70,6 +71,7 @@ secret_key.txt
.idea/ .idea/
*.code-workspace *.code-workspace
.bash_history .bash_history
.DS_Store
# https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore # https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
.vscode/* .vscode/*
@ -107,5 +109,8 @@ InvenTree/plugins/
*.mo *.mo
messages.ts messages.ts
# Generated API schema file
api.yaml
# web frontend (static files) # web frontend (static files)
InvenTree/web/static InvenTree/web/static

View File

@ -1,5 +1,7 @@
"""Main JSON interface views.""" """Main JSON interface views."""
import sys
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.http import JsonResponse from django.http import JsonResponse
@ -8,6 +10,7 @@ from django.utils.translation import gettext_lazy as _
from django_q.models import OrmQ from django_q.models import OrmQ
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from rest_framework.views import APIView from rest_framework.views import APIView
@ -18,6 +21,7 @@ from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI from InvenTree.mixins import ListCreateAPI
from InvenTree.permissions import RolePermission from InvenTree.permissions import RolePermission
from InvenTree.templatetags.inventree_extras import plugins_info from InvenTree.templatetags.inventree_extras import plugins_info
from part.models import Part
from plugin.serializers import MetadataSerializer from plugin.serializers import MetadataSerializer
from users.models import ApiToken from users.models import ApiToken
@ -28,11 +32,41 @@ from .version import inventreeApiText
from .views import AjaxView 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): class VersionView(APIView):
"""Simple JSON endpoint for InvenTree version information.""" """Simple JSON endpoint for InvenTree version information."""
permission_classes = [permissions.IsAdminUser] permission_classes = [permissions.IsAdminUser]
@extend_schema(responses={200: OpenApiResponse(response=VersionViewSerializer)})
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Return information about the InvenTree server.""" """Return information about the InvenTree server."""
return JsonResponse({ return JsonResponse({
@ -81,6 +115,8 @@ class VersionApiSerializer(serializers.Serializer):
class VersionTextView(ListAPI): class VersionTextView(ListAPI):
"""Simple JSON endpoint for InvenTree version text.""" """Simple JSON endpoint for InvenTree version text."""
serializer_class = VersionSerializer
permission_classes = [permissions.IsAdminUser] permission_classes = [permissions.IsAdminUser]
@extend_schema(responses={200: OpenApiResponse(response=VersionApiSerializer)}) @extend_schema(responses={200: OpenApiResponse(response=VersionApiSerializer)})
@ -324,7 +360,17 @@ class AttachmentMixin:
attachment.save() 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. """A general-purpose 'search' API endpoint.
Returns hits against a number of different models simultaneously, Returns hits against a number of different models simultaneously,
@ -334,6 +380,7 @@ class APISearchView(APIView):
""" """
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
serializer_class = APISearchViewSerializer
def get_result_types(self): def get_result_types(self):
"""Construct a list of search types we can return.""" """Construct a list of search types we can return."""
@ -446,4 +493,7 @@ class MetadataView(RetrieveUpdateAPI):
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance.""" """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) return MetadataSerializer(self.get_model_type(), *args, **kwargs)

View File

@ -9,8 +9,8 @@ from django.utils.translation import gettext_lazy as _
import sesame.utils import sesame.utils
from rest_framework import serializers from rest_framework import serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView
import InvenTree.version import InvenTree.version
@ -38,7 +38,7 @@ class GetSimpleLoginSerializer(serializers.Serializer):
email = serializers.CharField(label=_('Email')) email = serializers.CharField(label=_('Email'))
class GetSimpleLoginView(APIView): class GetSimpleLoginView(GenericAPIView):
"""View to send a simple login link.""" """View to send a simple login link."""
permission_classes = () permission_classes = ()

View File

@ -28,6 +28,10 @@ from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers_model import download_image_from_url, get_base_url 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): class InvenTreeMoneySerializer(MoneyField):
"""Custom serializer for 'MoneyField', which ensures that passed values are numerically valid. """Custom serializer for 'MoneyField', which ensures that passed values are numerically valid.

View File

@ -517,12 +517,15 @@ if USE_JWT:
SPECTACULAR_SETTINGS = { SPECTACULAR_SETTINGS = {
'TITLE': 'InvenTree API', 'TITLE': 'InvenTree API',
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system', 'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
'LICENSE': {'MIT': 'https://github.com/inventree/InvenTree/blob/master/LICENSE'}, 'LICENSE': {
'EXTERNAL_DOCS': { 'name': 'MIT',
'docs': 'https://docs.inventree.org', 'url': 'https://github.com/inventree/InvenTree/blob/master/LICENSE',
'web': 'https://inventree.org',
}, },
'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, 'SERVE_INCLUDE_SCHEMA': False,
} }

View File

@ -9,6 +9,7 @@ from allauth.account.models import EmailAddress
from allauth.socialaccount import providers from allauth.socialaccount import providers
from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import serializers
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
@ -16,7 +17,7 @@ from rest_framework.response import Response
import InvenTree.sso import InvenTree.sso
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import EmptySerializer, InvenTreeModelSerializer
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -112,11 +113,36 @@ for name, provider in providers.registry.provider_map.items():
social_auth_urlpatterns += provider_urlpatterns 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): class SocialProviderListView(ListAPI):
"""List of available social providers.""" """List of available social providers."""
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
serializer_class = EmptySerializer
@extend_schema(
responses={200: OpenApiResponse(response=SocialProviderListResponseSerializer)}
)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Get the list of providers.""" """Get the list of providers."""
provider_list = [] provider_list = []

View File

@ -18,6 +18,11 @@ class ApiVersionTests(InvenTreeAPITestCase):
self.assertEqual(len(data), 10) 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): def test_inventree_api_text(self):
"""Test that the inventreeApiText function works expected.""" """Test that the inventreeApiText function works expected."""
# Normal run # Normal run

View File

@ -11,6 +11,7 @@ from django.views.decorators.csrf import csrf_exempt
import django_q.models import django_q.models
from django_q.tasks import async_task from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error from error_report.models import Error
from rest_framework import permissions, serializers from rest_framework import permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound from rest_framework.exceptions import NotAcceptable, NotFound
@ -53,7 +54,15 @@ class WebhookView(CsrfExemptMixin, APIView):
permission_classes = [] permission_classes = []
model_class = common.models.WebhookEndpoint model_class = common.models.WebhookEndpoint
run_async = False 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): def post(self, request, endpoint, *args, **kwargs):
"""Process incoming webhook.""" """Process incoming webhook."""
# get webhook definition # get webhook definition
@ -115,6 +124,7 @@ class CurrencyExchangeView(APIView):
"""API endpoint for displaying currency information.""" """API endpoint for displaying currency information."""
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
serializer_class = None
def get(self, request, format=None): def get(self, request, format=None):
"""Return information on available currency conversions.""" """Return information on available currency conversions."""
@ -157,6 +167,7 @@ class CurrencyRefreshView(APIView):
""" """
permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser]
serializer_class = None
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Performing a POST request will update currency exchange rates.""" """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.""" """Provides an overview of the background task queue status."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser] permission_classes = [permissions.IsAuthenticated, IsAdminUser]
serializer_class = None
def get(self, request, format=None): def get(self, request, format=None):
"""Return information about the current status of the background task queue.""" """Return information about the current status of the background task queue."""

View File

@ -2868,7 +2868,7 @@ class NotificationMessage(models.Model):
"""Return API endpoint.""" """Return API endpoint."""
return reverse('api-notifications-list') return reverse('api-notifications-list')
def age(self): def age(self) -> int:
"""Age of the message in seconds.""" """Age of the message in seconds."""
# Add timezone information if TZ is enabled (in production mode mostly) # Add timezone information if TZ is enabled (in production mode mostly)
delta = now() - ( delta = now() - (
@ -2878,7 +2878,7 @@ class NotificationMessage(models.Model):
) )
return delta.seconds return delta.seconds
def age_human(self): def age_human(self) -> str:
"""Humanized age.""" """Humanized age."""
return naturaltime(self.creation) return naturaltime(self.creation)

View File

@ -59,6 +59,8 @@ class SettingsSerializer(InvenTreeModelSerializer):
units = serializers.CharField(read_only=True) units = serializers.CharField(read_only=True)
typ = serializers.CharField(read_only=True)
def get_choices(self, obj): def get_choices(self, obj):
"""Returns the choices available for a given item.""" """Returns the choices available for a given item."""
results = [] results = []
@ -195,7 +197,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
user = serializers.PrimaryKeyRelatedField(read_only=True) user = serializers.PrimaryKeyRelatedField(read_only=True)
read = serializers.BooleanField() read = serializers.BooleanField()
def get_target(self, obj): def get_target(self, obj) -> dict:
"""Function to resolve generic object reference to target.""" """Function to resolve generic object reference to target."""
target = get_objectreference(obj, 'target_content_type', 'target_object_id') target = get_objectreference(obj, 'target_content_type', 'target_object_id')
@ -217,7 +219,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
return target return target
def get_source(self, obj): def get_source(self, obj) -> dict:
"""Function to resolve generic object reference to source.""" """Function to resolve generic object reference to source."""
return get_objectreference(obj, 'source_content_type', 'source_object_id') return get_objectreference(obj, 'source_content_type', 'source_object_id')

View File

@ -2,15 +2,24 @@
import inspect 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.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView from InvenTree.serializers import EmptySerializer
from .states import StatusCode 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. """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. 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) status_model = self.kwargs.get(self.MODEL_REF, None)
if status_model is None: if status_model is None:
raise ValidationError( raise serializers.ValidationError(
f"StatusView view called without '{self.MODEL_REF}' parameter" f"StatusView view called without '{self.MODEL_REF}' parameter"
) )
return status_model 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): def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes.""" """Perform a GET request to learn information about status codes."""
status_class = self.get_status_model() status_class = self.get_status_model()
@ -53,6 +69,7 @@ class AllStatusViews(StatusView):
"""Endpoint for listing all defined status models.""" """Endpoint for listing all defined status models."""
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
serializer_class = EmptySerializer
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes.""" """Perform a GET request to learn information about status codes."""

View File

@ -49,6 +49,7 @@ from InvenTree.mixins import (
UpdateAPI, UpdateAPI,
) )
from InvenTree.permissions import RolePermission from InvenTree.permissions import RolePermission
from InvenTree.serializers import EmptySerializer
from InvenTree.status_codes import ( from InvenTree.status_codes import (
BuildStatusGroups, BuildStatusGroups,
PurchaseOrderStatusGroups, PurchaseOrderStatusGroups,
@ -487,6 +488,7 @@ class PartScheduling(RetrieveAPI):
""" """
queryset = Part.objects.all() queryset = Part.objects.all()
serializer_class = EmptySerializer
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
"""Return scheduling information for the referenced Part instance.""" """Return scheduling information for the referenced Part instance."""
@ -687,6 +689,7 @@ class PartRequirements(RetrieveAPI):
""" """
queryset = Part.objects.all() queryset = Part.objects.all()
serializer_class = EmptySerializer
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
"""Construct a response detailing Part requirements.""" """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.""" """API endpoint for returning extra serial number information about a particular part."""
queryset = Part.objects.all() queryset = Part.objects.all()
serializer_class = EmptySerializer
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
"""Return serial number information for the referenced Part instance.""" """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 # Pass a list of "starred" parts to the current user to the serializer
# We do this to reduce the number of database queries required! # 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 = [ self.starred_parts = [
star.part for star in self.request.user.starred_parts.all() star.part for star in self.request.user.starred_parts.all()
] ]

View File

@ -748,7 +748,7 @@ class Part(
return stock[0].serial return stock[0].serial
@property @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.""" """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) return part_helpers.render_part_full_name(self)
@ -762,7 +762,7 @@ class Part(
return helpers.getMediaUrl(self.image.url) return helpers.getMediaUrl(self.image.url)
return helpers.getBlankImage() return helpers.getBlankImage()
def get_thumbnail_url(self): def get_thumbnail_url(self) -> str:
"""Return the URL of the image thumbnail for this part.""" """Return the URL of the image thumbnail for this part."""
if self.image: if self.image:
return helpers.getMediaUrl(self.image.thumbnail.url) return helpers.getMediaUrl(self.image.thumbnail.url)

View File

@ -91,7 +91,7 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
if not path_detail: if not path_detail:
self.fields.pop('path') 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 True if the category is directly "starred" by the current user."""
return category in self.context.get('starred_categories', []) return category in self.context.get('starred_categories', [])
@ -723,7 +723,7 @@ class PartSerializer(
return queryset 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 "true" if the part is starred by the current user."""
return part in self.starred_parts return part in self.starred_parts

View File

@ -2,17 +2,25 @@
from django.utils.translation import gettext_lazy as _ 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.response import Response
from rest_framework.views import APIView
from plugin import registry 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.""" """Endpoint for running custom action plugins."""
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
serializer_class = ActionPluginSerializer
def post(self, request, *args, **kwargs): 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.""" """This function checks if all required info was submitted and then performs a plugin_action or returns an error."""

View File

@ -1,22 +1,37 @@
"""API for location plugins.""" """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.exceptions import NotFound, ParseError
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView
from InvenTree.tasks import offload_task from InvenTree.tasks import offload_task
from plugin.registry import registry from plugin.registry import registry
from stock.models import StockItem, StockLocation 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.""" """Endpoint for using a custom plugin to identify or 'locate' a stock item or location."""
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
serializer_class = LocatePluginSerializer
def post(self, request, *args, **kwargs): 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? # Which plugin to we wish to use?
plugin = request.data.get('plugin', None) plugin = request.data.get('plugin', None)

View File

@ -247,6 +247,7 @@ class NotificationUserSettingSerializer(GenericReferencedSettingSerializer):
EXTRA_FIELDS = ['method'] EXTRA_FIELDS = ['method']
method = serializers.CharField(read_only=True) method = serializers.CharField(read_only=True)
typ = serializers.CharField(read_only=True)
class PluginRegistryErrorSerializer(serializers.Serializer): class PluginRegistryErrorSerializer(serializers.Serializer):

View File

@ -184,7 +184,7 @@ class StockLocation(
) )
@property @property
def icon(self): def icon(self) -> str:
"""Get the current icon used for this location. """Get the current icon used for this location.
The icon field on this model takes precedences over the possibly assigned stock location type The icon field on this model takes precedences over the possibly assigned stock location type

View File

@ -9,6 +9,7 @@ from django.urls import include, path, re_path
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from dj_rest_auth.views import LogoutView 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 import exceptions, permissions
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@ -110,6 +111,7 @@ class RoleDetails(APIView):
""" """
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
serializer_class = None
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Return the list of roles / permissions available to the current user.""" """Return the list of roles / permissions available to the current user."""
@ -203,10 +205,17 @@ class GroupList(ListCreateAPI):
ordering_fields = ['name'] ordering_fields = ['name']
@extend_schema_view(
post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged out')}
)
)
class Logout(LogoutView): class Logout(LogoutView):
"""API view for logging out via API.""" """API view for logging out via API."""
def logout(self, request): serializer_class = None
def post(self, request):
"""Logout the current user. """Logout the current user.
Deletes user token associated with request. Deletes user token associated with request.
@ -230,6 +239,7 @@ class GetAuthToken(APIView):
"""Return authentication token for an authenticated user.""" """Return authentication token for an authenticated user."""
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
serializer_class = None
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Return an API token if the user is authenticated. """Return an API token if the user is authenticated.

View File

@ -1,12 +1,12 @@
"""DRF API serializers for the 'users' app.""" """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 rest_framework import serializers
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
from .models import Owner from .models import Owner, RuleSet, check_user_role
class OwnerSerializer(InvenTreeModelSerializer): class OwnerSerializer(InvenTreeModelSerializer):
@ -31,3 +31,39 @@ class GroupSerializer(InvenTreeModelSerializer):
model = Group model = Group
fields = ['pk', 'name'] 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

View File

@ -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)', '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.""" """Export current API schema."""
check_file_existance(filename, overwrite) 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) @task(default=True)