diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 70592c68177e..41cf27aa700f 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -112,6 +112,72 @@ jobs: pip install linkcheckmd requests python -m linkcheckmd docs --recurse + apidoc: + name: Tests - API 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 + - uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # pin@v2.11.1 + id: filter + with: + filters: | + api: + - 'InvenTree/InvenTree/api_version.py' + - name: Environment Setup + uses: ./.github/actions/setup + with: + apt-dependency: gettext poppler-utils + dev-install: true + update: true + - name: Export API Documentation + run: invoke apidoc --ignore-warnings + - name: Upload schema + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # pin@v3.1.3 + with: + name: api.yaml + path: api.yaml + - name: Download public schema + if: steps.filter.outputs.api == 'false' + run: | + pip install requests >/dev/null 2>&1 + version="$(python3 ci/version_check.py only_version 2>&1)" + echo "Version: $version" + url="https://raw.githubusercontent.com/inventree/schema/main/export/${version}/api.yaml" + echo "URL: $url" + curl -s -o schema.yaml $url + echo "Downloaded schema.yaml" + - name: Check for differences in schemas + if: steps.filter.outputs.api == 'false' + run: | + diff --color -u api.yaml schema.yaml + diff -u api.yaml schema.yaml && echo "no difference in API schema " || echo "differences in API schema" && exit 2 + - name: Check schema - including warnings + run: invoke apidoc + continue-on-error: true + - name: Push new schema if change & on master + if: github.ref == 'refs/heads/master' && steps.filter.outputs.api == 'true' + run: | + pip install requests >/dev/null 2>&1 + version="$(python3 ci/version_check.py only_version 2>&1)" + echo "Version: $version" + url="https://api.github.com/repos/inventree/schema/contents/export/${version}/api.yaml" + echo "URL: $url" + content="$(cat api.yaml | base64)" + curl -L -X PUT -H "Accept: application/vnd.github+json" -H "Authorization: Bearer $GITHUB_TOKEN" -H "X-GitHub-Api-Version: 2022-11-28" $url -d '{"message":"Update API schema for ${version}","content":$content}' + echo "Uploaded api.yaml" + python: name: Tests - inventree-python runs-on: ubuntu-20.04 diff --git a/.gitignore b/.gitignore index a590b45fc772..30eb230b5364 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ secret_key.txt .idea/ *.code-workspace .bash_history +.DS_Store # https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore .vscode/* @@ -107,5 +108,8 @@ InvenTree/plugins/ *.mo messages.ts +# Generated API schema file +api.yaml + # web frontend (static files) InvenTree/web/static diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78fd74a5b1bd..b079f1243707 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,6 +79,10 @@ The HEAD of the "stable" branch represents the latest stable release code. - When approved, the branch is merged back *into* stable, with an incremented PATCH number (e.g. 0.4.1 -> 0.4.2) - The bugfix *must* also be cherry picked into the *master* branch. +## API versioning + +The [API version](https://github.com/inventree/InvenTree/blob/master/InvenTree/InvenTree/api_version.py) needs to be bumped every time when the API is changed. + ## Environment ### Target version We are currently targeting: diff --git a/Dockerfile b/Dockerfile index 60a61b13f34d..ff9a3461d17d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,7 +84,7 @@ RUN if [ `apk --print-arch` = "armv7" ]; then \ COPY tasks.py docker/gunicorn.conf.py docker/init.sh ./ RUN chmod +x init.sh -ENTRYPOINT ["/bin/sh", "./init.sh"] +ENTRYPOINT ["/bin/ash", "./init.sh"] FROM inventree_base as prebuild diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index ad8d29666ef6..41f292b7b232 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_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.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 .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 @@ def perform_create(self, serializer): 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 @@ def get_queryset(self): 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/api_version.py b/InvenTree/InvenTree/api_version.py index 38d08a7bf79b..b742f293488b 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 166 +INVENTREE_API_VERSION = 167 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v167 -> 2024-02-07: https://github.com/inventree/InvenTree/pull/6440 + - Fixes for OpenAPI schema generation + v166 -> 2024-02-04 : https://github.com/inventree/InvenTree/pull/6400 - Adds package_name to plugin API - Adds mechanism for uninstalling plugins via the API diff --git a/InvenTree/InvenTree/backends.py b/InvenTree/InvenTree/backends.py new file mode 100644 index 000000000000..82c837657971 --- /dev/null +++ b/InvenTree/InvenTree/backends.py @@ -0,0 +1,67 @@ +"""Custom backend implementations.""" + +import logging +import time + +from django.db.utils import IntegrityError, OperationalError, ProgrammingError + +from maintenance_mode.backends import AbstractStateBackend + +import common.models +import InvenTree.helpers + +logger = logging.getLogger('inventree') + + +class InvenTreeMaintenanceModeBackend(AbstractStateBackend): + """Custom backend for managing state of maintenance mode. + + Stores the current state of the maintenance mode in the database, + using an InvenTreeSetting object. + """ + + SETTING_KEY = '_MAINTENANCE_MODE' + + def get_value(self) -> bool: + """Get the current state of the maintenance mode. + + Returns: + bool: True if maintenance mode is active, False otherwise. + """ + try: + setting = common.models.InvenTreeSetting.objects.get(key=self.SETTING_KEY) + value = InvenTree.helpers.str2bool(setting.value) + except common.models.InvenTreeSetting.DoesNotExist: + # Database is accessible, but setting is not available - assume False + value = False + except (IntegrityError, OperationalError, ProgrammingError): + # Database is inaccessible - assume we are not in maintenance mode + logger.warning('Failed to read maintenance mode state - assuming True') + value = True + + logger.debug('Maintenance mode state: %s', value) + + return value + + def set_value(self, value: bool, retries: int = 5): + """Set the state of the maintenance mode.""" + logger.debug('Setting maintenance mode state: %s', value) + + while retries > 0: + try: + common.models.InvenTreeSetting.set_setting(self.SETTING_KEY, value) + + # Read the value back to confirm + if self.get_value() == value: + break + except (IntegrityError, OperationalError, ProgrammingError): + # In the database is locked, then + logger.debug( + 'Failed to set maintenance mode state (%s retries left)', retries + ) + time.sleep(0.1) + + retries -= 1 + + if retries == 0: + logger.warning('Failed to set maintenance mode state') diff --git a/InvenTree/InvenTree/magic_login.py b/InvenTree/InvenTree/magic_login.py index 8d8df798ae95..996e0b35d16c 100644 --- a/InvenTree/InvenTree/magic_login.py +++ b/InvenTree/InvenTree/magic_login.py @@ -9,8 +9,8 @@ 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 b4522bf01fc5..08df401085cd 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -28,6 +28,10 @@ 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 f04edf35e760..44a0fd56a3c7 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -489,12 +489,15 @@ 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'}, + 'LICENSE': { + 'name': 'MIT', + 'url': 'https://github.com/inventree/InvenTree/blob/master/LICENSE', + }, 'EXTERNAL_DOCS': { - 'docs': 'https://docs.inventree.org', - 'web': 'https://inventree.org', + 'description': 'More information about InvenTree in the official docs', + 'url': 'https://docs.inventree.org', }, - 'VERSION': inventreeApiVersion(), + 'VERSION': str(inventreeApiVersion()), 'SERVE_INCLUDE_SCHEMA': False, } @@ -1080,8 +1083,8 @@ IGNORED_ERRORS = [Http404, django.core.exceptions.PermissionDenied] # Maintenance mode -MAINTENANCE_MODE_RETRY_AFTER = 60 -MAINTENANCE_MODE_STATE_BACKEND = 'maintenance_mode.backends.StaticStorageBackend' +MAINTENANCE_MODE_RETRY_AFTER = 10 +MAINTENANCE_MODE_STATE_BACKEND = 'InvenTree.backends.InvenTreeMaintenanceModeBackend' # Are plugins enabled? PLUGINS_ENABLED = get_boolean_setting( diff --git a/InvenTree/InvenTree/social_auth_urls.py b/InvenTree/InvenTree/social_auth_urls.py index ecfef99b96aa..c9a77eb5dfb5 100644 --- a/InvenTree/InvenTree/social_auth_urls.py +++ b/InvenTree/InvenTree/social_auth_urls.py @@ -9,6 +9,7 @@ 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 @@ 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 @@ def handle_oauth2(adapter: OAuth2Adapter): 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 4b2a3b49b5ba..8bddb0c5612b 100644 --- a/InvenTree/InvenTree/test_api_version.py +++ b/InvenTree/InvenTree/test_api_version.py @@ -18,6 +18,11 @@ def test_api(self): 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 7eec94aff2d3..13a8bc19f85f 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -11,6 +11,7 @@ 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 7bde02d2f12f..e275e507c880 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -2854,7 +2854,7 @@ def get_api_url(): """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() - ( @@ -2864,7 +2864,7 @@ def age(self): ) 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 8b6dcb70c26b..3a6cc70b848e 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 Meta: 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 @@ def get_target(self, obj): 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 77cd5d531de2..8827d1121f47 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 @@ def get_status_model(self, *args, **kwargs): 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 4770d50627ef..b2b57fbaa821 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -49,6 +49,7 @@ 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 @@ def get_serializer(self, *args, **kwargs): # 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 c1a4b41ef93f..a36f17fd55c2 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -748,7 +748,7 @@ def get_latest_serial_number(self): 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 @@ def get_image_url(self): 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 d1d8051063a3..3c9eb554c088 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -91,7 +91,7 @@ def __init__(self, *args, **kwargs): 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 @@ def annotate_queryset(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 part in self.starred_parts diff --git a/InvenTree/plugin/base/action/api.py b/InvenTree/plugin/base/action/api.py index 37dabd9eda81..7e3df033527f 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 c3f921d010fe..8d37505b3ad0 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 19ae5b3a6e40..507ba20799ab 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 099670dfb0a4..f4d729eee715 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -184,7 +184,7 @@ def get_api_url(): ) @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 @@ -324,8 +324,9 @@ def generate_batch_code(): 'year': now.year, 'month': now.month, 'day': now.day, - 'hour': now.minute, + 'hour': now.hour, 'minute': now.minute, + 'week': now.isocalendar()[1], } return Template(batch_template).render(context) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 05676c6acdaa..c5755eeb20ee 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -1193,6 +1193,14 @@ def save(self): ) +def stock_item_adjust_status_options(): + """Return a custom set of options for the StockItem status adjustment field. + + In particular, include a Null option for the status field. + """ + return [(None, _('No Change'))] + InvenTree.status_codes.StockStatus.items() + + class StockAdjustmentItemSerializer(serializers.Serializer): """Serializer for a single StockItem within a stock adjument request. @@ -1235,8 +1243,8 @@ class Meta: ) status = serializers.ChoiceField( - choices=InvenTree.status_codes.StockStatus.items(), - default=InvenTree.status_codes.StockStatus.OK.value, + choices=stock_item_adjust_status_options(), + default=None, label=_('Status'), help_text=_('Stock item status code'), required=False, @@ -1373,8 +1381,8 @@ def save(self): kwargs = {} for field_name in StockItem.optional_transfer_fields(): - if field_name in item: - kwargs[field_name] = item[field_name] + if field_value := item.get(field_name, None): + kwargs[field_name] = field_value stock_item.move( location, notes, request.user, quantity=quantity, **kwargs diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index d2794cb99042..e8cf8ac4b757 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -971,7 +971,7 @@ function loadBuildOrderAllocationTable(table, options={}) { switchable: false, title: '{% trans "Build Order" %}', formatter: function(value, row) { - let ref = `${row.build_detail.reference}`; + let ref = row.build_detail?.reference ?? row.build; let html = renderLink(ref, `/build/${row.build}/`); html += `- ${row.build_detail.title}`; diff --git a/InvenTree/users/api.py b/InvenTree/users/api.py index 6d97ee3ee8f7..17def22835e0 100644 --- a/InvenTree/users/api.py +++ b/InvenTree/users/api.py @@ -9,6 +9,7 @@ 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 435c0ea27aae..b4e3dbb7da4d 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 Meta: 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/ci/version_check.py b/ci/version_check.py index 8bad2994882c..a46236508a29 100644 --- a/ci/version_check.py +++ b/ci/version_check.py @@ -90,6 +90,13 @@ def check_version_number(version_string, allow_duplicate=False): if __name__ == '__main__': + if 'only_version' in sys.argv: + here = Path(__file__).parent.absolute() + version_file = here.joinpath('..', 'InvenTree', 'InvenTree', 'api_version.py') + text = version_file.read_text() + results = re.findall(r"""INVENTREE_API_VERSION = (.*)""", text) + print(results[0]) + exit(0) # GITHUB_REF_TYPE may be either 'branch' or 'tag' GITHUB_REF_TYPE = os.environ['GITHUB_REF_TYPE'] diff --git a/docker/init.sh b/docker/init.sh index 0837d7f9496c..132a94379660 100644 --- a/docker/init.sh +++ b/docker/init.sh @@ -1,4 +1,5 @@ -#!/bin/sh +#!/bin/ash + # exit when any command fails set -e diff --git a/docker/requirements.txt b/docker/requirements.txt index d3d0c85e28d9..f34e24b11490 100644 --- a/docker/requirements.txt +++ b/docker/requirements.txt @@ -7,7 +7,7 @@ setuptools>=69.0.0 wheel>=0.41.0 # Database links -psycopg[binary]>=3.1.18 +psycopg[binary, c, pool] mysqlclient>=2.2.0 mariadb>=1.1.8 diff --git a/docs/docs/stock/tracking.md b/docs/docs/stock/tracking.md index 4420711ad88a..55da6b2b2597 100644 --- a/docs/docs/stock/tracking.md +++ b/docs/docs/stock/tracking.md @@ -30,6 +30,19 @@ Batch codes can be generated automatically based on a provided pattern. The defa {% include 'img.html' %} {% endwith %} +#### Context Variables + +The following context variables are available by default when generating a batch code using the builtin generation functionality: + +| Variable | Description | +| --- | --- | +| year | The current year e.g. `2024` | +| month | The current month number, e.g. `5` | +| day | The current day of month, e.g. `21` | +| hour | The current hour of day, in 24-hour format, e.g. `23` | +| minute | The current minute of hour, e.g. `17` | +| week | The current week of year, e.g. `51` | + #### Plugin Support To implement custom batch code functionality, refer to the details on the [Validation Plugin Mixin](../extend/plugins/validation.md#batch-codes). diff --git a/tasks.py b/tasks.py index 39bd16a45903..ed85192b155d 100644 --- a/tasks.py +++ b/tasks.py @@ -504,20 +504,28 @@ def export_records( with open(tmpfile, 'r') as f_in: data = json.loads(f_in.read()) + data_out = [] + if include_permissions is False: for entry in data: - if 'model' in entry: - # Clear out any permissions specified for a group - if entry['model'] == 'auth.group': - entry['fields']['permissions'] = [] + model_name = entry.get('model', None) + + # Ignore any temporary settings (start with underscore) + if model_name in ['common.inventreesetting', 'common.inventreeusersetting']: + if entry['fields'].get('key', '').startswith('_'): + continue - # Clear out any permissions specified for a user - if entry['model'] == 'auth.user': - entry['fields']['user_permissions'] = [] + if model_name == 'auth.group': + entry['fields']['permissions'] = [] + + if model_name == 'auth.user': + entry['fields']['user_permissions'] = [] + + data_out.append(entry) # Write the processed data to file with open(filename, 'w') as f_out: - f_out.write(json.dumps(data, indent=2)) + f_out.write(json.dumps(data_out, indent=2)) print('Data export completed') @@ -881,18 +889,6 @@ def setup_test(c, ignore_update=False, dev=False, path='inventree-demo-dataset') setup_dev(c) -@task( - help={ - 'filename': "Output filename (default = 'schema.yml')", - 'overwrite': 'Overwrite existing files without asking first (default = off/False)', - } -) -def schema(c, filename='schema.yml', overwrite=False): - """Export current API schema.""" - check_file_existance(filename, overwrite) - manage(c, f'spectacular --file {filename}') - - @task(default=True) def version(c): """Show the current version of InvenTree.""" @@ -938,6 +934,25 @@ def version(c): ) +@task( + help={ + 'filename': "Output filename (default = 'api.yaml')", + 'ignore_warnings': 'Ignore warnings (default = False)', + } +) +def apidoc(c, filename='api.yaml', ignore_warnings=False): + """Generate API documentation.""" + if not os.path.isabs(filename): + filename = localDir().joinpath(filename).resolve() + + cmd = f'spectacular --file {filename} --validate --color' + + if not ignore_warnings: + cmd += ' --fail-on-warn' + + manage(c, cmd, pty=True) + + @task() def frontend_check(c): """Check if frontend is available."""