From c153abbac423f41f819b96dc10f8986ad18e837c Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Tue, 1 Oct 2024 22:13:15 -0400 Subject: [PATCH 1/7] Fix all drf-spectacular warnings and errors --- chris_backend/config/settings/common.py | 7 ++- chris_backend/core/serializers.py | 50 ++++++++++++++++- chris_backend/core/views.py | 4 ++ chris_backend/feeds/serializers.py | 16 +++--- chris_backend/feeds/views.py | 16 ++++++ chris_backend/filebrowser/serializers.py | 53 ++++++++----------- chris_backend/filebrowser/views.py | 20 +++++++ chris_backend/pacsfiles/serializers.py | 17 +----- chris_backend/pacsfiles/views.py | 2 + chris_backend/pipelines/serializers.py | 25 +++------ chris_backend/pipelines/views.py | 2 + chris_backend/plugininstances/serializers.py | 13 +++-- .../plugininstances/spectacular_hooks.py | 3 +- chris_backend/plugininstances/views.py | 7 +++ chris_backend/plugins/admin.py | 3 +- chris_backend/plugins/enums.py | 12 +++++ chris_backend/plugins/models.py | 14 +---- chris_backend/plugins/serializers.py | 2 +- chris_backend/userfiles/serializers.py | 17 +----- chris_backend/userfiles/views.py | 7 +++ chris_backend/users/views.py | 2 + chris_backend/workflows/serializers.py | 16 +++--- chris_backend/workflows/views.py | 8 ++- docker-compose_just.yml | 2 +- justfile | 16 ++++-- 25 files changed, 211 insertions(+), 123 deletions(-) mode change 100755 => 100644 chris_backend/feeds/serializers.py mode change 100755 => 100644 chris_backend/pipelines/serializers.py mode change 100755 => 100644 chris_backend/plugininstances/serializers.py create mode 100644 chris_backend/plugins/enums.py mode change 100755 => 100644 chris_backend/plugins/serializers.py mode change 100755 => 100644 chris_backend/workflows/serializers.py diff --git a/chris_backend/config/settings/common.py b/chris_backend/config/settings/common.py index fcbdd156..6de70270 100755 --- a/chris_backend/config/settings/common.py +++ b/chris_backend/config/settings/common.py @@ -16,6 +16,7 @@ from environs import Env from __version__ import __version__ +from plugins.enums import PLUGIN_TYPE_CHOICES, TYPE_CHOICES # Environment variables-based secrets env = Env() @@ -196,6 +197,10 @@ 'email': 'dev@babymri.org' }, 'SERVE_INCLUDE_SCHEMA': True, + 'ENUM_NAME_OVERRIDES': { + 'PluginType': PLUGIN_TYPE_CHOICES, + 'PluginParameterType': TYPE_CHOICES, + }, 'COMPONENT_SPLIT_REQUEST': env.bool("SPECTACULAR_SPLIT_REQUEST", False), 'PREPROCESSING_HOOKS': [ 'drf_spectacular.hooks.preprocess_exclude_path_format', @@ -203,7 +208,7 @@ 'POSTPROCESSING_HOOKS': [ 'drf_spectacular.hooks.postprocess_schema_enums', 'collectionjson.spectacular_hooks.postprocess_remove_collectionjson', - 'plugininstances.spectacular_hooks.additionalproperties_for_plugins_instances_create' + 'plugininstances.spectacular_hooks.additionalproperties_for_plugins_instances_create', ], 'SCHEMA_PATH_PREFIX': '/api/v1/', diff --git a/chris_backend/core/serializers.py b/chris_backend/core/serializers.py index 569655c8..d771722c 100755 --- a/chris_backend/core/serializers.py +++ b/chris_backend/core/serializers.py @@ -1,11 +1,14 @@ +from typing import Optional from rest_framework import serializers +from drf_spectacular.utils import OpenApiTypes, extend_schema_field +from collectionjson.fields import ItemLinkField +from .utils import get_file_resource_link from .models import ChrisInstance, FileDownloadToken class ChrisInstanceSerializer(serializers.HyperlinkedModelSerializer): - class Meta: model = ChrisInstance fields = ('url', 'id', 'creation_date', 'name', 'uuid', 'job_id_prefix', @@ -20,3 +23,48 @@ class FileDownloadTokenSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = FileDownloadToken fields = ('url', 'id', 'creation_date', 'token', 'owner_username', 'owner') + + +def file_serializer(cls: Optional[serializers.SerializerMetaclass] = None, required: bool = True): + """ + A class decorator for adding fields to serializers of ``ChrisFile`` views. + + :param cls: the serializer to wrap + :param required: whether the ``fname`` field is required + """ + if cls is None: + return lambda cls: _wrap_file_serializer_class(cls, required) + return _wrap_file_serializer_class(cls, required) + + +def _wrap_file_serializer_class(cls: serializers.SerializerMetaclass, required: bool): + # Implementation notes: + # - Do not use ``fsize = serializers.ReadOnlyField(source="fname.size")`` + # See bug: https://github.com/tfranzel/drf-spectacular/issues/1303 + # - Mixin pattern does not work, you get " Field name `fsize` is not valid for model `ChrisFile`." + # Decorator pattern is a workaround. + assert type(cls) == serializers.SerializerMetaclass, f'{cls} is not a serializer' + + class _FileSerializer(cls): + fname = serializers.FileField(use_url=False, required=required) + fsize = serializers.SerializerMethodField() + file_resource = ItemLinkField('get_file_link') + owner_username = serializers.ReadOnlyField(source='owner.username') + parent_folder = serializers.HyperlinkedRelatedField(view_name='chrisfolder-detail', read_only=True) + owner = serializers.HyperlinkedRelatedField(view_name='user-detail', read_only=True) + + def get_fsize(self, obj) -> int: + """ + Get the size of the file in bytes. + """ + return obj.fname.size + + @extend_schema_field(OpenApiTypes.URI) + def get_file_link(self, obj): + """ + Custom method to get the hyperlink to the actual file resource. + """ + return get_file_resource_link(self, obj) + + _FileSerializer.__name__ = cls.__name__ + return _FileSerializer diff --git a/chris_backend/core/views.py b/chris_backend/core/views.py index c0541214..7b8e685d 100755 --- a/chris_backend/core/views.py +++ b/chris_backend/core/views.py @@ -76,6 +76,8 @@ def get_queryset(self): Overriden to return a custom queryset that is only comprised by the file download tokens owned by the currently authenticated user. """ + if getattr(self, "swagger_fake_view", False): + return FileDownloadToken.objects.none() user = self.request.user # if the user is chris then return all the file download tokens in the system if user.username == 'chris': @@ -98,6 +100,8 @@ def get_queryset(self): Overriden to return a custom queryset that is only comprised by the file download tokens owned by the currently authenticated user. """ + if getattr(self, "swagger_fake_view", False): + return FileDownloadToken.objects.none() user = self.request.user # if the user is chris then return all the file download tokens in the system if user.username == 'chris': diff --git a/chris_backend/feeds/serializers.py b/chris_backend/feeds/serializers.py old mode 100755 new mode 100644 index 9fcc4740..0acb148a --- a/chris_backend/feeds/serializers.py +++ b/chris_backend/feeds/serializers.py @@ -161,7 +161,7 @@ def validate_public(self, public): "superuser 'chris'."]) return public - def get_created_jobs(self, obj): + def get_created_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'created' status. """ @@ -169,7 +169,7 @@ def get_created_jobs(self, obj): raise KeyError("Undefined plugin instance execution status: 'created'.") return obj.get_plugin_instances_status_count('created') - def get_waiting_jobs(self, obj): + def get_waiting_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'waiting' status. """ @@ -178,7 +178,7 @@ def get_waiting_jobs(self, obj): raise KeyError(msg) return obj.get_plugin_instances_status_count('waiting') - def get_scheduled_jobs(self, obj): + def get_scheduled_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'scheduled' status. """ @@ -186,7 +186,7 @@ def get_scheduled_jobs(self, obj): raise KeyError("Undefined plugin instance execution status: 'scheduled'.") return obj.get_plugin_instances_status_count('scheduled') - def get_started_jobs(self, obj): + def get_started_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'started' status. """ @@ -194,7 +194,7 @@ def get_started_jobs(self, obj): raise KeyError("Undefined plugin instance execution status: 'started'.") return obj.get_plugin_instances_status_count('started') - def get_registering_jobs(self, obj): + def get_registering_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'registeringFiles' status. """ @@ -203,7 +203,7 @@ def get_registering_jobs(self, obj): raise KeyError(msg) return obj.get_plugin_instances_status_count('registeringFiles') - def get_finished_jobs(self, obj): + def get_finished_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'finishedSuccessfully' status. """ @@ -212,7 +212,7 @@ def get_finished_jobs(self, obj): "'finishedSuccessfully'.") return obj.get_plugin_instances_status_count('finishedSuccessfully') - def get_errored_jobs(self, obj): + def get_errored_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'finishedWithError' status. """ @@ -221,7 +221,7 @@ def get_errored_jobs(self, obj): "'finishedWithError'.") return obj.get_plugin_instances_status_count('finishedWithError') - def get_cancelled_jobs(self, obj): + def get_cancelled_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'cancelled' status. """ diff --git a/chris_backend/feeds/views.py b/chris_backend/feeds/views.py index 9f60feaa..1c2f20b6 100755 --- a/chris_backend/feeds/views.py +++ b/chris_backend/feeds/views.py @@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics, permissions from rest_framework.reverse import reverse +from drf_spectacular.utils import extend_schema, extend_schema_view from collectionjson import services from plugininstances.serializers import PluginInstanceSerializer @@ -39,6 +40,9 @@ def retrieve(self, request, *args, **kwargs): return services.append_collection_template(response, template_data) +@extend_schema_view( + get=extend_schema(operation_id="tags_list") +) class TagList(generics.ListCreateAPIView): """ A view for the collection of tags. @@ -98,6 +102,9 @@ def retrieve(self, request, *args, **kwargs): return services.append_collection_template(response, template_data) +@extend_schema_view( + get=extend_schema(operation_id="feed_tags_list") +) class FeedTagList(generics.ListAPIView): """ A view for a feed-specific collection of user-specific tags. @@ -487,6 +494,8 @@ def get_queryset(self): Overriden to return a custom queryset that is comprised by the feed-specific group permissions. """ + if getattr(self, "swagger_fake_view", False): + return FeedGroupPermission.objects.none() feed = get_object_or_404(Feed, pk=self.kwargs['pk']) return FeedGroupPermission.objects.filter(feed=feed) @@ -582,6 +591,8 @@ def get_queryset(self): Overriden to return a custom queryset that is comprised by the feed-specific user permissions. """ + if getattr(self, "swagger_fake_view", False): + return FeedUserPermission.objects.none() feed = get_object_or_404(Feed, pk=self.kwargs['pk']) return FeedUserPermission.objects.filter(feed=feed) @@ -678,6 +689,8 @@ def get_queryset(self): Overriden to return a custom queryset that is comprised by the feed-specific comments. """ + if getattr(self, 'swagger_fake_view', False): + return Feed.comments.field.model.objects.none() feed = get_object_or_404(Feed, pk=self.kwargs['pk']) return feed.comments.all() @@ -702,6 +715,9 @@ def retrieve(self, request, *args, **kwargs): return services.append_collection_template(response, template_data) +# @extend_schema_view( +# get=extend_schema(operation_id='feed_plugins_instances_list') +# ) class FeedPluginInstanceList(generics.ListAPIView): """ A view for the collection of feed-specific plugin instances. diff --git a/chris_backend/filebrowser/serializers.py b/chris_backend/filebrowser/serializers.py index 133181fc..d0b6fcba 100755 --- a/chris_backend/filebrowser/serializers.py +++ b/chris_backend/filebrowser/serializers.py @@ -5,12 +5,13 @@ from django.db.utils import IntegrityError from rest_framework import serializers from rest_framework.reverse import reverse +from drf_spectacular.utils import OpenApiTypes, extend_schema_field from collectionjson.fields import ItemLinkField -from core.utils import get_file_resource_link from core.models import (ChrisFolder, ChrisFile, ChrisLinkFile, FolderGroupPermission, FolderUserPermission, FileGroupPermission, FileUserPermission, LinkFileGroupPermission, LinkFileUserPermission) +from core.serializers import file_serializer class FileBrowserFolderSerializer(serializers.HyperlinkedModelSerializer): @@ -293,20 +294,14 @@ def validate(self, data): return data +@file_serializer(required=False) class FileBrowserFileSerializer(serializers.HyperlinkedModelSerializer): new_file_path = serializers.CharField(max_length=1024, write_only=True, required=False) - fname = serializers.FileField(use_url=False, required=False) - fsize = serializers.ReadOnlyField(source='fname.size') - owner_username = serializers.ReadOnlyField(source='owner.username') - file_resource = ItemLinkField('get_file_link') - parent_folder = serializers.HyperlinkedRelatedField(view_name='chrisfolder-detail', - read_only=True) group_permissions = serializers.HyperlinkedIdentityField( view_name='filegrouppermission-list') user_permissions = serializers.HyperlinkedIdentityField( view_name='fileuserpermission-list') - owner = serializers.HyperlinkedRelatedField(view_name='user-detail', read_only=True) class Meta: model = ChrisFile @@ -343,12 +338,6 @@ def update(self, instance, validated_data): instance.create_public_link() return instance - def get_file_link(self, obj): - """ - Custom method to get the hyperlink to the actual file resource. - """ - return get_file_resource_link(self, obj) - def validate_new_file_path(self, new_file_path): """ Overriden to check whether the provided path is under a home/'s subdirectory @@ -411,7 +400,7 @@ def validate(self, data): class FileBrowserFileGroupPermissionSerializer(serializers.HyperlinkedModelSerializer): grp_name = serializers.CharField(write_only=True, required=False) file_id = serializers.ReadOnlyField(source='file.id') - file_fname = serializers.ReadOnlyField(source='file.fname.name') + file_fname = serializers.SerializerMethodField() group_id = serializers.ReadOnlyField(source='group.id') group_name = serializers.ReadOnlyField(source='group.name') file = serializers.HyperlinkedRelatedField(view_name='chrisfile-detail', @@ -423,6 +412,9 @@ class Meta: fields = ('url', 'id', 'permission', 'file_id', 'file_fname', 'group_id', 'group_name', 'file', 'group', 'grp_name') + def get_file_fname(self, obj) -> str: + return obj.file.fname.name + def create(self, validated_data): """ Overriden to handle the error when trying to create a permission for a group that @@ -476,7 +468,7 @@ class FileBrowserFileUserPermissionSerializer(serializers.HyperlinkedModelSerial username = serializers.CharField(write_only=True, min_length=4, max_length=32, required=False) file_id = serializers.ReadOnlyField(source='file.id') - file_fname = serializers.ReadOnlyField(source='file.fname.name') + file_fname = serializers.SerializerMethodField() user_id = serializers.ReadOnlyField(source='user.id') user_username = serializers.ReadOnlyField(source='user.username') file = serializers.HyperlinkedRelatedField(view_name='chrisfile-detail', @@ -488,6 +480,9 @@ class Meta: fields = ('url', 'id', 'permission', 'file_id', 'file_fname', 'user_id', 'user_username', 'file', 'user', 'username') + def get_file_fname(self, obj) -> str: + return obj.file.fname.name + def create(self, validated_data): """ Overriden to handle the error when trying to create a permission for a user that @@ -537,23 +532,17 @@ def validate(self, data): return data +@file_serializer(required=False) class FileBrowserLinkFileSerializer(serializers.HyperlinkedModelSerializer): new_link_file_path = serializers.CharField(max_length=1024, write_only=True, required=False) path = serializers.CharField(max_length=1024, required=False) - fname = serializers.FileField(use_url=False, required=False) - fsize = serializers.ReadOnlyField(source='fname.size') - owner_username = serializers.ReadOnlyField(source='owner.username') - file_resource = ItemLinkField('get_file_link') linked_folder = ItemLinkField('get_linked_folder_link') linked_file = ItemLinkField('get_linked_file_link') - parent_folder = serializers.HyperlinkedRelatedField(view_name='chrisfolder-detail', - read_only=True) group_permissions = serializers.HyperlinkedIdentityField( view_name='linkfilegrouppermission-list') user_permissions = serializers.HyperlinkedIdentityField( view_name='linkfileuserpermission-list') - owner = serializers.HyperlinkedRelatedField(view_name='user-detail', read_only=True) class Meta: model = ChrisLinkFile @@ -591,12 +580,7 @@ def update(self, instance, validated_data): instance.create_public_link() return instance - def get_file_link(self, obj): - """ - Custom method to get the hyperlink to the actual file resource. - """ - return get_file_resource_link(self, obj) - + @extend_schema_field(OpenApiTypes.URI) def get_linked_folder_link(self, obj): """ Custom method to get the hyperlink to the linked folder if the ChRIS link @@ -610,6 +594,7 @@ def get_linked_folder_link(self, obj): return reverse('chrisfolder-detail', request=request, kwargs={'pk': linked_folder.pk}) + @extend_schema_field(OpenApiTypes.URI) def get_linked_file_link(self, obj): """ Custom method to get the hyperlink to the linked file if the ChRIS link @@ -713,7 +698,7 @@ def validate(self, data): class FileBrowserLinkFileGroupPermissionSerializer(serializers.HyperlinkedModelSerializer): grp_name = serializers.CharField(write_only=True, required=False) link_file_id = serializers.ReadOnlyField(source='link_file.id') - link_file_fname = serializers.ReadOnlyField(source='link_file.fname.name') + link_file_fname = serializers.SerializerMethodField() group_id = serializers.ReadOnlyField(source='group.id') group_name = serializers.ReadOnlyField(source='group.name') link_file = serializers.HyperlinkedRelatedField(view_name='chrislinkfile-detail', @@ -725,6 +710,9 @@ class Meta: fields = ('url', 'id', 'permission', 'link_file_id', 'link_file_fname', 'group_id', 'group_name', 'link_file', 'group', 'grp_name') + def get_link_file_fname(self, obj) -> str: + return obj.link_file.fname.name + def create(self, validated_data): """ Overriden to handle the error when trying to create a permission for a group that @@ -778,7 +766,7 @@ class FileBrowserLinkFileUserPermissionSerializer(serializers.HyperlinkedModelSe username = serializers.CharField(write_only=True, min_length=4, max_length=32, required=False) link_file_id = serializers.ReadOnlyField(source='link_file.id') - link_file_fname = serializers.ReadOnlyField(source='link_file.fname.name') + link_file_fname = serializers.SerializerMethodField() user_id = serializers.ReadOnlyField(source='user.id') user_username = serializers.ReadOnlyField(source='user.username') link_file = serializers.HyperlinkedRelatedField(view_name='chrislinkfile-detail', @@ -790,6 +778,9 @@ class Meta: fields = ('url', 'id', 'permission', 'link_file_id', 'link_file_fname', 'user_id', 'user_username', 'link_file', 'user', 'username') + def get_link_file_fname(self, obj) -> str: + return obj.link_file.fname.name + def create(self, validated_data): """ Overriden to handle the error when trying to create a permission for a user that diff --git a/chris_backend/filebrowser/views.py b/chris_backend/filebrowser/views.py index 6702dacc..6ab4e792 100755 --- a/chris_backend/filebrowser/views.py +++ b/chris_backend/filebrowser/views.py @@ -6,6 +6,7 @@ from rest_framework import generics, permissions, serializers from rest_framework.reverse import reverse from rest_framework.authentication import BasicAuthentication, SessionAuthentication +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiTypes from core.models import (ChrisFolder, FolderGroupPermission, FolderGroupPermissionFilter, FolderUserPermission, @@ -76,6 +77,8 @@ def get_queryset(self): Overriden to return a custom queryset that is only comprised by the root folder (empty path). """ + if getattr(self, "swagger_fake_view", False): + return ChrisFolder.objects.none() user = self.request.user pk_dict = {'path': ''} @@ -101,6 +104,9 @@ def get_queryset(self): """ Overriden to return a custom queryset of at most one element. """ + if getattr(self, "swagger_fake_view", False): + return ChrisFolder.objects.none() + user = self.request.user id = self.request.GET.get('id') pk_dict = {'id': id} @@ -244,6 +250,8 @@ def get_queryset(self): Overriden to return a custom queryset that is comprised by the folder-specific group permissions. """ + if getattr(self, "swagger_fake_view", False): + return FolderGroupPermission.objects.none() folder = get_object_or_404(ChrisFolder, pk=self.kwargs['pk']) return FolderGroupPermission.objects.filter(folder=folder) @@ -357,6 +365,8 @@ def get_queryset(self): Overriden to return a custom queryset that is comprised by the folder-specific user permissions. """ + if getattr(self, "swagger_fake_view", False): + return FolderUserPermission.objects.none() folder = get_object_or_404(ChrisFolder, pk=self.kwargs['pk']) return FolderUserPermission.objects.filter(folder=folder) @@ -476,6 +486,7 @@ class FileBrowserFileResource(generics.GenericAPIView): authentication_classes = (TokenAuthSupportQueryString, BasicAuthentication, SessionAuthentication) + @extend_schema(responses=OpenApiResponse(OpenApiTypes.BINARY)) def get(self, request, *args, **kwargs): """ Overriden to be able to make a GET request to an actual file resource. @@ -547,6 +558,8 @@ def get_queryset(self): Overriden to return a custom queryset that is comprised by the file-specific group permissions. """ + if getattr(self, "swagger_fake_view", False): + return FileGroupPermission.objects.none() f = get_object_or_404(ChrisFile, pk=self.kwargs['pk']) return FileGroupPermission.objects.filter(file=f) @@ -660,6 +673,8 @@ def get_queryset(self): Overriden to return a custom queryset that is comprised by the file-specific user permissions. """ + if getattr(self, "swagger_fake_view", False): + return FileUserPermission.objects.none() f = get_object_or_404(ChrisFile, pk=self.kwargs['pk']) return FileUserPermission.objects.filter(file=f) @@ -799,6 +814,7 @@ class FileBrowserLinkFileResource(generics.GenericAPIView): authentication_classes = (TokenAuthSupportQueryString, BasicAuthentication, SessionAuthentication) + @extend_schema(responses=OpenApiResponse(OpenApiTypes.BINARY)) def get(self, request, *args, **kwargs): """ Overriden to be able to make a GET request to an actual file resource. @@ -870,6 +886,8 @@ def get_queryset(self): Overriden to return a custom queryset that is comprised by the link file-specific group permissions. """ + if getattr(self, "swagger_fake_view", False): + return LinkFileGroupPermission.objects.none() lf = get_object_or_404(ChrisLinkFile, pk=self.kwargs['pk']) return LinkFileGroupPermission.objects.filter(link_file=lf) @@ -983,6 +1001,8 @@ def get_queryset(self): Overriden to return a custom queryset that is comprised by the link file-specific user permissions. """ + if getattr(self, "swagger_fake_view", False): + return LinkFileUserPermission.objects.none() lf = get_object_or_404(ChrisLinkFile, pk=self.kwargs['pk']) return LinkFileUserPermission.objects.filter(link_file=lf) diff --git a/chris_backend/pacsfiles/serializers.py b/chris_backend/pacsfiles/serializers.py index f03ccf55..420c9247 100755 --- a/chris_backend/pacsfiles/serializers.py +++ b/chris_backend/pacsfiles/serializers.py @@ -7,10 +7,9 @@ from django.conf import settings from rest_framework import serializers -from collectionjson.fields import ItemLinkField from core.models import ChrisFolder -from core.utils import get_file_resource_link from core.storage import connect_storage +from core.serializers import file_serializer from .models import PACS, PACSSeries, PACSFile @@ -168,22 +167,10 @@ def validate(self, data): return data +@file_serializer(required=True) class PACSFileSerializer(serializers.HyperlinkedModelSerializer): - fname = serializers.FileField(use_url=False) - fsize = serializers.ReadOnlyField(source='fname.size') - owner_username = serializers.ReadOnlyField(source='owner.username') - file_resource = ItemLinkField('get_file_link') - parent_folder = serializers.HyperlinkedRelatedField(view_name='chrisfolder-detail', - read_only=True) - owner = serializers.HyperlinkedRelatedField(view_name='user-detail', read_only=True) class Meta: model = PACSFile fields = ('url', 'id', 'creation_date', 'fname', 'fsize', 'public', 'owner_username', 'file_resource', 'parent_folder', 'owner') - - def get_file_link(self, obj): - """ - Custom method to get the hyperlink to the actual file resource. - """ - return get_file_resource_link(self, obj) diff --git a/chris_backend/pacsfiles/views.py b/chris_backend/pacsfiles/views.py index 7cb35fb7..663a54f8 100755 --- a/chris_backend/pacsfiles/views.py +++ b/chris_backend/pacsfiles/views.py @@ -3,6 +3,7 @@ from rest_framework import generics, permissions from rest_framework.reverse import reverse from rest_framework.authentication import BasicAuthentication, SessionAuthentication +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiTypes from collectionjson import services from core.renderers import BinaryFileRenderer @@ -119,6 +120,7 @@ class PACSFileResource(generics.GenericAPIView): authentication_classes = (TokenAuthSupportQueryString, BasicAuthentication, SessionAuthentication) + @extend_schema(responses=OpenApiResponse(OpenApiTypes.BINARY)) def get(self, request, *args, **kwargs): """ Overriden to be able to make a GET request to an actual file resource. diff --git a/chris_backend/pipelines/serializers.py b/chris_backend/pipelines/serializers.py old mode 100755 new mode 100644 index 0620a4bf..82b1abc8 --- a/chris_backend/pipelines/serializers.py +++ b/chris_backend/pipelines/serializers.py @@ -9,12 +9,14 @@ from django.utils import timezone from rest_framework import serializers from rest_framework.reverse import reverse +from drf_spectacular.utils import OpenApiTypes, extend_schema_field from core.graph import Graph -from core.utils import get_file_resource_link from core.models import ChrisFolder +from core.serializers import file_serializer from collectionjson.fields import ItemLinkField -from plugins.models import Plugin, TYPES +from plugins.enums import TYPES +from plugins.models import Plugin from plugins.serializers import DEFAULT_PARAMETER_SERIALIZERS from plugininstances.models import PluginInstance @@ -475,26 +477,20 @@ class Meta: fields = ('url', 'name', 'locked', 'authors', 'category', 'description', 'plugin_tree') - def get_plugin_tree(self, obj): + def get_plugin_tree(self, obj) -> str: """ Overriden to get the plugin_tree JSON string. """ return json.dumps(obj.get_plugin_tree()) +@file_serializer(required=True) class PipelineSourceFileSerializer(serializers.HyperlinkedModelSerializer): - fname = serializers.FileField(use_url=False) - fsize = serializers.ReadOnlyField(source='fname.size') ftype = serializers.ReadOnlyField(source='meta.type') type = serializers.CharField(write_only=True, required=False) uploader_username = serializers.ReadOnlyField(source='meta.uploader.username') - owner_username = serializers.ReadOnlyField(source='owner.username') pipeline_id = serializers.ReadOnlyField(source='meta.pipeline.id') pipeline_name = serializers.ReadOnlyField(source='meta.pipeline.name') - file_resource = ItemLinkField('get_file_link') - parent_folder = serializers.HyperlinkedRelatedField(view_name='chrisfolder-detail', - read_only=True) - owner = serializers.HyperlinkedRelatedField(view_name='user-detail', read_only=True) class Meta: model = PipelineSourceFile @@ -502,12 +498,6 @@ class Meta: 'ftype', 'uploader_username', 'owner_username', 'pipeline_id', 'pipeline_name', 'file_resource', 'parent_folder', 'owner') - def get_file_link(self, obj): - """ - Custom method to get the hyperlink to the actual file resource. - """ - return get_file_resource_link(self, obj) - def create(self, validated_data): """ Overriden to create the pipeline from the source file data, set the file's saving @@ -755,6 +745,7 @@ class Meta: 'previous_plugin_piping_id', 'param_name', 'param_id', 'plugin_piping', 'plugin_name', 'plugin_version', 'plugin_id', 'plugin_param') + @extend_schema_field(OpenApiTypes.URI) def _get_url(self, obj): """ Custom method to get the correct url for the serialized object regardless of @@ -766,7 +757,7 @@ def _get_url(self, obj): view_name = 'defaultpiping' + TYPES[obj.plugin_param.type] + 'parameter-detail' return reverse(view_name, request=request, kwargs={"pk": obj.id}) - def get_value(self, obj): + def get_value(self, obj) -> str | int | float | bool: """ Overriden to get the default parameter value regardless of its type. """ diff --git a/chris_backend/pipelines/views.py b/chris_backend/pipelines/views.py index 4a3cd830..e5455c4c 100755 --- a/chris_backend/pipelines/views.py +++ b/chris_backend/pipelines/views.py @@ -6,6 +6,7 @@ from rest_framework import generics, permissions from rest_framework.reverse import reverse +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiTypes from core.renderers import BinaryFileRenderer from collectionjson import services @@ -195,6 +196,7 @@ class PipelineSourceFileResource(generics.GenericAPIView): queryset = PipelineSourceFile.get_base_queryset() renderer_classes = (BinaryFileRenderer,) + @extend_schema(responses=OpenApiResponse(OpenApiTypes.BINARY)) def get(self, request, *args, **kwargs): """ Overriden to be able to make a GET request to an actual file resource. diff --git a/chris_backend/plugininstances/serializers.py b/chris_backend/plugininstances/serializers.py old mode 100755 new mode 100644 index d9a4b80f..6a2b1cef --- a/chris_backend/plugininstances/serializers.py +++ b/chris_backend/plugininstances/serializers.py @@ -4,10 +4,12 @@ from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from rest_framework.reverse import reverse +from drf_spectacular.utils import OpenApiTypes, extend_schema_field from collectionjson.fields import ItemLinkField from core.models import ChrisFolder, ChrisFile, ChrisLinkFile -from plugins.models import TYPES, Plugin +from plugins.enums import TYPES +from plugins.models import Plugin from .models import PluginInstance, PluginInstanceSplit from .models import FloatParameter, IntParameter, BoolParameter @@ -373,7 +375,8 @@ def __init__(self, *args, **kwargs): """ Overriden to get the request user as a keyword argument at object creation. """ - self.user = kwargs.pop('user') + if 'user' in kwargs: + self.user = kwargs.pop('user') super(PathParameterSerializer, self).__init__(*args, **kwargs) def validate_value(self, value): @@ -401,7 +404,8 @@ def __init__(self, *args, **kwargs): """ Overriden to get the request user as a keyword argument at object creation. """ - self.user = kwargs.pop('user') + if 'user' in kwargs: + self.user = kwargs.pop('user') super(UnextpathParameterSerializer, self).__init__(*args, **kwargs) def validate_value(self, value): @@ -427,6 +431,7 @@ class Meta: fields = ('url', 'id', 'param_name', 'value', 'type', 'plugin_inst', 'plugin_param') + @extend_schema_field(OpenApiTypes.URI) def _get_url(self, obj): """ Custom method to get the correct url for the serialized object regardless of @@ -437,7 +442,7 @@ def _get_url(self, obj): view_name = TYPES[obj.plugin_param.type] + 'parameter-detail' return reverse(view_name, request=request, kwargs={"pk": obj.id}) - def get_value(self, obj): + def get_value(self, obj) -> str | int | float | bool: """ Overriden to get the default parameter value regardless of its type. """ diff --git a/chris_backend/plugininstances/spectacular_hooks.py b/chris_backend/plugininstances/spectacular_hooks.py index 65c33f44..deb557c5 100644 --- a/chris_backend/plugininstances/spectacular_hooks.py +++ b/chris_backend/plugininstances/spectacular_hooks.py @@ -7,6 +7,8 @@ def additionalproperties_for_plugins_instances_create(result, **_kwargs): :param result: an OpenAPI specification """ + if 'PluginInstanceRequest' not in result['components']['schemas']: + return result plugin_instance_request = result['components']['schemas']['PluginInstanceRequest'] assert plugin_instance_request['type'] == 'object' plugin_instance_request['additionalProperties'] = {} @@ -17,4 +19,3 @@ def additionalproperties_for_plugins_instances_create(result, **_kwargs): 'nullable': True } return result - diff --git a/chris_backend/plugininstances/views.py b/chris_backend/plugininstances/views.py index 6578880d..8e4930a0 100755 --- a/chris_backend/plugininstances/views.py +++ b/chris_backend/plugininstances/views.py @@ -3,6 +3,7 @@ from rest_framework import permissions from rest_framework.reverse import reverse from rest_framework.serializers import ValidationError +from drf_spectacular.utils import extend_schema_view, extend_schema from collectionjson import services from plugins.models import Plugin @@ -20,6 +21,9 @@ from .utils import run_if_ready +@extend_schema_view( + get=extend_schema(operation_id='plugins_instances_list') +) class PluginInstanceList(generics.ListCreateAPIView): """ A view for the collection of plugin instances. @@ -118,6 +122,9 @@ def get_plugin_instances_queryset(self): return self.filter_queryset(plugin.instances.all()) +@extend_schema_view( + get=extend_schema(operation_id='all_plugins_instances_list') +) class AllPluginInstanceList(generics.ListAPIView): """ A view for the collection of all plugin instances. diff --git a/chris_backend/plugins/admin.py b/chris_backend/plugins/admin.py index 7744134e..928bc893 100755 --- a/chris_backend/plugins/admin.py +++ b/chris_backend/plugins/admin.py @@ -15,7 +15,8 @@ from collectionjson import services -from .models import PluginMeta, Plugin, ComputeResource, TYPES +from .enums import TYPES +from .models import PluginMeta, Plugin, ComputeResource from .fields import CPUInt, MemoryInt from .serializers import (ComputeResourceSerializer, PluginMetaSerializer, PluginSerializer, PluginParameterSerializer, diff --git a/chris_backend/plugins/enums.py b/chris_backend/plugins/enums.py new file mode 100644 index 00000000..86dde335 --- /dev/null +++ b/chris_backend/plugins/enums.py @@ -0,0 +1,12 @@ + +# front-end API types +TYPE_CHOICES = [("string", "String values"), ("float", "Float values"), + ("boolean", "Boolean values"), ("integer", "Integer values"), + ("path", "Path values"), ("unextpath", "Unextracted path values")] + +# table of equivalence between front-end API types and back-end types +TYPES = {'string': 'str', 'integer': 'int', 'float': 'float', 'boolean': 'bool', + 'path': 'path', 'unextpath': 'unextpath'} + +PLUGIN_TYPE_CHOICES = [("ds", "Data synthesis"), ("fs", "Feed synthesis"), + ("ts", "Topology synthesis")] diff --git a/chris_backend/plugins/models.py b/chris_backend/plugins/models.py index a23f93c2..24224d9e 100755 --- a/chris_backend/plugins/models.py +++ b/chris_backend/plugins/models.py @@ -6,19 +6,7 @@ from django.core.exceptions import ValidationError from .fields import CPUField, MemoryField - - -# front-end API types -TYPE_CHOICES = [("string", "String values"), ("float", "Float values"), - ("boolean", "Boolean values"), ("integer", "Integer values"), - ("path", "Path values"), ("unextpath", "Unextracted path values")] - -# table of equivalence between front-end API types and back-end types -TYPES = {'string': 'str', 'integer': 'int', 'float': 'float', 'boolean': 'bool', - 'path': 'path', 'unextpath': 'unextpath'} - -PLUGIN_TYPE_CHOICES = [("ds", "Data synthesis"), ("fs", "Feed synthesis"), - ("ts", "Topology synthesis")] +from .enums import TYPE_CHOICES, PLUGIN_TYPE_CHOICES class ComputeResource(models.Model): diff --git a/chris_backend/plugins/serializers.py b/chris_backend/plugins/serializers.py old mode 100755 new mode 100644 index 7924b1d0..33c46857 --- a/chris_backend/plugins/serializers.py +++ b/chris_backend/plugins/serializers.py @@ -221,7 +221,7 @@ def validate(self, data): raise serializers.ValidationError({'non_field_errors': [error_msg]}) return data - def get_default(self, obj): + def get_default(self, obj) -> str | int | float | bool | None: """ Overriden to get the default parameter value regardless of type. """ diff --git a/chris_backend/userfiles/serializers.py b/chris_backend/userfiles/serializers.py index 0cdaffb1..e2769a01 100755 --- a/chris_backend/userfiles/serializers.py +++ b/chris_backend/userfiles/serializers.py @@ -4,27 +4,20 @@ from django.conf import settings from rest_framework import serializers -from collectionjson.fields import ItemLinkField -from core.utils import get_file_resource_link from core.models import ChrisFolder from core.storage import connect_storage +from core.serializers import file_serializer from .models import UserFile +@file_serializer(required=False) class UserFileSerializer(serializers.HyperlinkedModelSerializer): - fname = serializers.FileField(use_url=False, required=False) - fsize = serializers.ReadOnlyField(source='fname.size') upload_path = serializers.CharField(max_length=1024, write_only=True, required=False) - owner_username = serializers.ReadOnlyField(source='owner.username') - file_resource = ItemLinkField('get_file_link') - parent_folder = serializers.HyperlinkedRelatedField(view_name='chrisfolder-detail', - read_only=True) group_permissions = serializers.HyperlinkedIdentityField( view_name='filegrouppermission-list') user_permissions = serializers.HyperlinkedIdentityField( view_name='fileuserpermission-list') - owner = serializers.HyperlinkedRelatedField(view_name='user-detail', read_only=True) class Meta: model = UserFile @@ -89,12 +82,6 @@ def update(self, instance, validated_data): instance.save() return instance - def get_file_link(self, obj): - """ - Custom method to get the hyperlink to the actual file resource. - """ - return get_file_resource_link(self, obj) - def validate_upload_path(self, upload_path): """ Overriden to check whether the provided path is under a home/'s subdirectory diff --git a/chris_backend/userfiles/views.py b/chris_backend/userfiles/views.py index c17070bf..cca4da02 100755 --- a/chris_backend/userfiles/views.py +++ b/chris_backend/userfiles/views.py @@ -4,6 +4,7 @@ from rest_framework import generics, permissions from rest_framework.reverse import reverse from rest_framework.authentication import BasicAuthentication, SessionAuthentication +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiTypes from collectionjson import services from core.renderers import BinaryFileRenderer @@ -26,6 +27,9 @@ def get_queryset(self): Overriden to return a custom queryset that is only comprised by the files owned by the currently authenticated user. """ + if getattr(self, "swagger_fake_view", False): + return UserFile.objects.none() + user = self.request.user # if the user is chris then return all the files in the user space @@ -69,6 +73,8 @@ def get_queryset(self): Overriden to return a custom queryset that is only comprised by the files owned by the currently authenticated user. """ + if getattr(self, "swagger_fake_view", False): + return UserFile.objects.none() user = self.request.user # if the user is chris then return all the files in the user space @@ -115,6 +121,7 @@ class UserFileResource(generics.GenericAPIView): authentication_classes = (TokenAuthSupportQueryString, BasicAuthentication, SessionAuthentication) + @extend_schema(responses=OpenApiResponse(OpenApiTypes.BINARY)) def get(self, request, *args, **kwargs): """ Overriden to be able to make a GET request to an actual file resource. diff --git a/chris_backend/users/views.py b/chris_backend/users/views.py index 513a46a1..51cdf29c 100755 --- a/chris_backend/users/views.py +++ b/chris_backend/users/views.py @@ -201,6 +201,8 @@ def get_queryset(self): Overriden to return a custom queryset that is comprised by the group-specific group users. """ + if getattr(self, "swagger_fake_view", False): + return User.groups.through.objects.none() group = get_object_or_404(Group, pk=self.kwargs['pk']) return User.groups.through.objects.filter(group=group) diff --git a/chris_backend/workflows/serializers.py b/chris_backend/workflows/serializers.py old mode 100755 new mode 100644 index bc9afe6b..a21a275f --- a/chris_backend/workflows/serializers.py +++ b/chris_backend/workflows/serializers.py @@ -205,7 +205,7 @@ def validate(self, data): {'previous_plugin_inst_id': ["This field is required."]}) return data - def get_created_jobs(self, obj): + def get_created_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'created' status. """ @@ -213,7 +213,7 @@ def get_created_jobs(self, obj): raise KeyError("Undefined plugin instance execution status: 'created'.") return obj.get_plugin_instances_status_count('created') - def get_waiting_jobs(self, obj): + def get_waiting_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'waiting' status. """ @@ -222,7 +222,7 @@ def get_waiting_jobs(self, obj): raise KeyError(msg) return obj.get_plugin_instances_status_count('waiting') - def get_scheduled_jobs(self, obj): + def get_scheduled_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'scheduled' status. """ @@ -230,7 +230,7 @@ def get_scheduled_jobs(self, obj): raise KeyError("Undefined plugin instance execution status: 'scheduled'.") return obj.get_plugin_instances_status_count('scheduled') - def get_started_jobs(self, obj): + def get_started_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'started' status. """ @@ -238,7 +238,7 @@ def get_started_jobs(self, obj): raise KeyError("Undefined plugin instance execution status: 'started'.") return obj.get_plugin_instances_status_count('started') - def get_registering_jobs(self, obj): + def get_registering_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'registeringFiles' status. """ @@ -247,7 +247,7 @@ def get_registering_jobs(self, obj): raise KeyError(msg) return obj.get_plugin_instances_status_count('registeringFiles') - def get_finished_jobs(self, obj): + def get_finished_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'finishedSuccessfully' status. """ @@ -256,7 +256,7 @@ def get_finished_jobs(self, obj): "'finishedSuccessfully'.") return obj.get_plugin_instances_status_count('finishedSuccessfully') - def get_errored_jobs(self, obj): + def get_errored_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'finishedWithError' status. """ @@ -265,7 +265,7 @@ def get_errored_jobs(self, obj): "'finishedWithError'.") return obj.get_plugin_instances_status_count('finishedWithError') - def get_cancelled_jobs(self, obj): + def get_cancelled_jobs(self, obj) -> int: """ Overriden to get the number of plugin instances in 'cancelled' status. """ diff --git a/chris_backend/workflows/views.py b/chris_backend/workflows/views.py index 84f44964..4138a35b 100755 --- a/chris_backend/workflows/views.py +++ b/chris_backend/workflows/views.py @@ -4,6 +4,7 @@ from rest_framework import generics, permissions from rest_framework.reverse import reverse +from drf_spectacular.utils import extend_schema, extend_schema_view from collectionjson import services from pipelines.models import Pipeline @@ -17,7 +18,9 @@ from .permissions import IsOwnerOrChrisOrReadOnly from .serializers import WorkflowSerializer - +@extend_schema_view( + get=extend_schema(operation_id="workflows_list") +) class WorkflowList(generics.ListCreateAPIView): """ A view for the collection of pipeline-specific workflows. @@ -134,6 +137,9 @@ def create_plugin_inst(self, data: WorkflowPluginInstanceTemplate, previous: return plg_inst +@extend_schema_view( + get=extend_schema(operation_id="all_workflows_list") +) class AllWorkflowList(generics.ListAPIView): """ A view for the collection of all workflows. diff --git a/docker-compose_just.yml b/docker-compose_just.yml index ea7a4059..fdfd3b65 100755 --- a/docker-compose_just.yml +++ b/docker-compose_just.yml @@ -31,7 +31,7 @@ services: environment: &CHRIS_ENV DJANGO_SETTINGS_MODULE: "config.settings.local" STORAGE_ENV: "fslink" - SPECTACULAR_SPLIT_REQUEST: "true" + SPECTACULAR_SPLIT_REQUEST: "${SPECTACULAR_SPLIT_REQUEST-false}" user: ${UID:?Please run me using just.}:${GID:?Please run me using just.} profiles: - cube diff --git a/justfile b/justfile index 34de54ea..6a85d157 100755 --- a/justfile +++ b/justfile @@ -153,8 +153,14 @@ prefer docker_or_podman: unset-preference: rm -f .preference -[group('(6) OpenAPI generator')] -openapi-generate output +options: - mkdir -vp {{output}} - @env OPENAPI_GENERATOR_OUTPUT="$(realpath {{output}})" just docker-compose run --rm openapi-generator \ - generate {{ options }} -i http://chris:8000/schema/ -o /out +# Print the OpenAPI schema via drf-spectacular. +[group('(3) development')] +openapi: + @just run python manage.py spectacular --color + +# Print the OpenAPI schema using drf-spectacular, using workarounds for more +# reliable client generation. +[group('(3) development')] +openapi-split: + env SPECTACULAR_SPLIT_REQUEST=true just openapi + From 287e14a4a13ef6ddcd3e490835cb9484da80a0cc Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Tue, 1 Oct 2024 23:27:33 -0400 Subject: [PATCH 2/7] Add GHA to draft release --- .github/workflows/ci.yml | 63 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 440f2bcd..58c98cbc 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: command: test-${{ matrix.test-kind }} build: + name: Build needs: [ test ] if: github.event_name == 'push' runs-on: ubuntu-24.04 @@ -63,7 +64,7 @@ jobs: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - + - name: Build and push uses: docker/build-push-action@v6 with: @@ -84,3 +85,63 @@ jobs: short-description: ChRIS backend readme-filepath: ./README.md repository: fnndsc/cube + + openapi: + name: Generate OpenAPI Schema + runs-on: ubuntu-24.04 + steps: + - uses: taiki-e/install-action@just + - uses: actions/checkout@v4 + - name: Generate OpenAPI schema (correct) + run: just openapi 2> spectacular.log > schema.yaml + - name: Generate OpenAPI schema (split) + run: just openapi-split 2> spectacular_split.log > schema_split.yaml + - name: Upload schema YAML files + uses: actions/upload-artifact@v4 + with: + name: openapi_schema + path: schema*.yaml + if-no-files-found: error + - name: Assert no drf-spectacular errors nor warnings + run: | + for f in spectacular*.log; do + if grep -qi '(warning|error)' $f; + cat $f + exit 1 + fi + done + + draft-release: + name: Draft GitHub Release + runs-on: ubuntu-24.04 + if: startsWith(github.ref, 'refs/tags/v') + needs: + - build + - openapi + steps: + - id: version + run: echo "version=${GITHUB_REF_NAME:1}" >> "$GITHUB_OUTPUT" + - uses: actions/download-artifact@v4 + with: + name: openapi_schema + - uses: softprops/action-gh-release@v2 + with: + draft: true + prerelease: ${{ contains( github.ref_name, '-' ) }} + files: openapi_schema/*.yaml + fail_on_unmatched_files: true + name: CUBE - version ${{ steps.version.outputs.version }} + generate_release_notes: true + body: | + ## Getting the Image + + Using [Podman](https://podman.io): + + ```shell + podman pull ghcr.io/fnndsc/cube:${{ steps.version.outputs.version }} + ``` + Using [Docker](https://docker.com): + + ```shell + docker pull ghcr.io/fnndsc/cube:${{ steps.version.outputs.version }} + ``` From 3051066b75f2a1a4bca75839c9434922075768b1 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Tue, 1 Oct 2024 23:37:14 -0400 Subject: [PATCH 3/7] Print logs if `just openapi` fails --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58c98cbc..a134efa6 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,9 +93,9 @@ jobs: - uses: taiki-e/install-action@just - uses: actions/checkout@v4 - name: Generate OpenAPI schema (correct) - run: just openapi 2> spectacular.log > schema.yaml + run: just openapi 2> spectacular.log > schema.yaml || (cat spectacular.log; exit 1) - name: Generate OpenAPI schema (split) - run: just openapi-split 2> spectacular_split.log > schema_split.yaml + run: just openapi-split 2> spectacular_split.log > schema_split.yaml || (cat spectacular_split.log; exit 1) - name: Upload schema YAML files uses: actions/upload-artifact@v4 with: @@ -110,6 +110,8 @@ jobs: exit 1 fi done + - name: Clean up + run: just nuke draft-release: name: Draft GitHub Release From 77c69e3fbbabe538c4451bce8d22ef4b92f13dbe Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Tue, 1 Oct 2024 23:39:51 -0400 Subject: [PATCH 4/7] Fix GHA openapi generation --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a134efa6..d38d699e 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,7 @@ jobs: steps: - uses: taiki-e/install-action@just - uses: actions/checkout@v4 + - run: just prefer docker - name: Generate OpenAPI schema (correct) run: just openapi 2> spectacular.log > schema.yaml || (cat spectacular.log; exit 1) - name: Generate OpenAPI schema (split) From f9e1cbf0ffdc8d0f297b0212b045c75e9fa75d96 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Tue, 1 Oct 2024 23:42:03 -0400 Subject: [PATCH 5/7] Oops, bash ifs --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d38d699e..8524bdc6 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,7 +106,7 @@ jobs: - name: Assert no drf-spectacular errors nor warnings run: | for f in spectacular*.log; do - if grep -qi '(warning|error)' $f; + if grep -qi '(warning|error)' $f; then cat $f exit 1 fi From 2815d30199aa354e07348a7e56a7156d3c3f3bab Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Tue, 1 Oct 2024 23:43:00 -0400 Subject: [PATCH 6/7] Add `just build` step to GHA openapi generation --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8524bdc6..5a84b283 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,7 @@ jobs: - uses: taiki-e/install-action@just - uses: actions/checkout@v4 - run: just prefer docker + - run: just build - name: Generate OpenAPI schema (correct) run: just openapi 2> spectacular.log > schema.yaml || (cat spectacular.log; exit 1) - name: Generate OpenAPI schema (split) From b2fc1aafad0a0d9bca43e9ef191f6a4c607e36a9 Mon Sep 17 00:00:00 2001 From: Jennings Zhang Date: Tue, 1 Oct 2024 23:45:51 -0400 Subject: [PATCH 7/7] Change `nuke` to `down` --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a84b283..4a913dde 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,8 +112,8 @@ jobs: exit 1 fi done - - name: Clean up - run: just nuke + - name: Stop services + run: just down draft-release: name: Draft GitHub Release