From 037dba33f32931e885da82207d58acdb384e6a87 Mon Sep 17 00:00:00 2001 From: Stanislav Khlud Date: Tue, 14 Jan 2025 12:40:37 +0700 Subject: [PATCH] Set up export/import action mixin --- .pre-commit-config.yaml | 6 + HISTORY.rst | 11 +- docs/_static/images/action-bands-openapi.png | Bin 0 -> 23157 bytes docs/api_drf.rst | 18 ++ docs/getting_started.rst | 20 +- docs/installation.rst | 14 +- docs/migrate_from_original_import_export.rst | 2 +- import_export_extensions/api/__init__.py | 10 +- .../api/mixins/__init__.py | 3 + .../api/{mixins.py => mixins/common.py} | 5 +- .../api/mixins/export_mixins.py | 141 +++++++++++++ .../api/mixins/import_mixins.py | 115 ++++++++++ .../api/serializers/export_job.py | 12 +- .../api/serializers/import_job.py | 12 +- .../api/views/__init__.py | 4 + .../api/views/export_job.py | 198 +++++------------- .../api/views/import_job.py | 196 +++++++---------- import_export_extensions/resources.py | 26 +-- invocations/docs.py | 4 +- test_project/fake_app/api/views.py | 46 +++- test_project/fake_app/resources.py | 20 +- .../integration_tests/test_api/test_export.py | 146 ++++++++++++- .../integration_tests/test_api/test_import.py | 165 +++++++++++++-- test_project/urls.py | 17 ++ 24 files changed, 868 insertions(+), 323 deletions(-) create mode 100644 docs/_static/images/action-bands-openapi.png create mode 100644 import_export_extensions/api/mixins/__init__.py rename import_export_extensions/api/{mixins.py => mixins/common.py} (76%) create mode 100644 import_export_extensions/api/mixins/export_mixins.py create mode 100644 import_export_extensions/api/mixins/import_mixins.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f339d80..51e86fd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -64,3 +64,9 @@ repos: pass_filenames: false types: [python] stages: [pre-push] + - id: doc_build_verify + name: verify that docs could be build + entry: inv docs.build + language: system + pass_filenames: false + stages: [pre-push] diff --git a/HISTORY.rst b/HISTORY.rst index d0496a5..8723cce 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,7 +6,12 @@ UNRELEASED ------------------ * Add explicit `created_by` argument to `CeleryResourceMixin` and pass it in -`ExportJobSerializer` validation + `ExportJobSerializer` validation +* Add export/import action mixins `api.mixins.ExportStartActionMixin` + and `api.mixins.ImportStartActionMixin` +* Add `api.views.BaseExportJobViewSet`, `BaseExportJobForUsersViewSet`, + `api.views.BaseImportJobViewSet` and `BaseImportJobForUsersViewSet` for + job management 1.3.1 (2025-01-13) ------------------ @@ -21,8 +26,8 @@ UNRELEASED * Small actions definition refactor in `ExportJobViewSet/ExportJobViewSet` to allow easier overriding. * Add support for ordering in `export` * Add settings for DjangoFilterBackend and OrderingFilter in export api. -`DRF_EXPORT_DJANGO_FILTERS_BACKEND` with default `django_filters.rest_framework.DjangoFilterBackend` and -`DRF_EXPORT_ORDERING_BACKEND` with default `rest_framework.filters.OrderingFilter`. + `DRF_EXPORT_DJANGO_FILTERS_BACKEND` with default `django_filters.rest_framework.DjangoFilterBackend` and + `DRF_EXPORT_ORDERING_BACKEND` with default `rest_framework.filters.OrderingFilter`. 1.2.0 (2024-12-26) ------------------ diff --git a/docs/_static/images/action-bands-openapi.png b/docs/_static/images/action-bands-openapi.png new file mode 100644 index 0000000000000000000000000000000000000000..7edf6ac80915a762d8dbeacb4ea156c8654b75a3 GIT binary patch literal 23157 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 PpwHY@*HbG~u?hJXQQ;C6 literal 0 HcmV?d00001 diff --git a/docs/api_drf.rst b/docs/api_drf.rst index 00b8bdb..be65d48 100644 --- a/docs/api_drf.rst +++ b/docs/api_drf.rst @@ -14,9 +14,27 @@ API (Rest Framework) .. autoclass:: import_export_extensions.api.ExportJobForUserViewSet :members: +.. autoclass:: import_export_extensions.api.BaseImportJobViewSet + :members: + +.. autoclass:: import_export_extensions.api.BaseExportJobViewSet + :members: + +.. autoclass:: import_export_extensions.api.BaseImportJobForUserViewSet + :members: + +.. autoclass:: import_export_extensions.api.BaseExportJobForUserViewSet + :members: + .. autoclass:: import_export_extensions.api.LimitQuerySetToCurrentUserMixin :members: +.. autoclass:: import_export_extensions.api.ImportStartActionMixin + :members: + +.. autoclass:: import_export_extensions.api.ExportStartActionMixin + :members: + .. autoclass:: import_export_extensions.api.CreateExportJob :members: create, validate diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 38a1931..a612516 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -135,7 +135,7 @@ a ``status page``, where you can monitor the progress of the import/export proce A screenshot of Django Admin export status page Import/Export API ------------------ +---------------------------------------------------------------- The ``api.views.ExportJobViewSet`` and ``api.views.ImportJobViewSet`` are provided to create the corresponding viewsets for the resource. @@ -169,3 +169,21 @@ the OpenAPI specification will be available. .. figure:: _static/images/bands-openapi.png A screenshot of the generated OpenAPI specification + + +Import/Export API actions mixins +---------------------------------------------------------------- + +Alternatively you can use ``api.mixins.ExportStartActionMixin`` and ``api.mixins.ImportStartActionMixin`` +to add to your current viewsets ability to create import/export jobs. +You would also need to use ``api.views.BaseExportJobViewSet`` or ``BaseExportJobForUsersViewSet`` +and ``api.views.BaseImportJobViewSet`` or ``BaseImportJobForUsersViewSet`` to setup endpoints to be able to: + +* ``list`` - Returns a list of jobs for the ``resource_class`` set in ViewSet. +* ``retrieve`` - Returns details of a job based on the provided ID. +* ``cancel`` - Stops the import/export process and sets the job's status to ``CANCELLED``. +* ``confirm`` - Confirms the import after the parse stage. This action is available only in import jobs. + +.. figure:: _static/images/action-bands-openapi.png + + A screenshot of the generated OpenAPI specification diff --git a/docs/installation.rst b/docs/installation.rst index a816655..fa5057e 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -81,13 +81,25 @@ By default, it uses the `mimetypes.types_map None: + super().__init_subclass__() + # Skip if it is has no resource_class specified + if not hasattr(cls, "resource_class"): + return + filter_backends = [ + module_loading.import_string( + settings.DRF_EXPORT_DJANGO_FILTERS_BACKEND, + ), + ] + if cls.export_ordering_fields: + filter_backends.append( + module_loading.import_string( + settings.DRF_EXPORT_ORDERING_BACKEND, + ), + ) + + def start_export_action( + self: "ExportStartActionMixin", + request: request.Request, + ) -> response.Response: + return self.start_export(request) + + setattr(cls, cls.export_action, start_export_action) + decorators.action( + methods=["POST"], + url_name=cls.export_action_name, + url_path=cls.export_action_url, + detail=False, + queryset=cls.resource_class.get_model_queryset(), + serializer_class=cls().get_export_create_serializer_class(), + filterset_class=getattr( + cls.resource_class, + "filterset_class", + None, + ), + filter_backends=filter_backends, + ordering=cls.export_ordering, + ordering_fields=cls.export_ordering_fields, + )(getattr(cls, cls.export_action)) + # Correct specs of drf-spectacular if it is installed + with contextlib.suppress(ImportError): + from drf_spectacular import utils + + utils.extend_schema_view( + **{ + cls.export_action: utils.extend_schema( + description=cls.export_open_api_description, + filters=True, + responses={ + status.HTTP_201_CREATED: cls().get_export_detail_serializer_class(), # noqa: E501 + }, + ), + }, + )(cls) + + def get_queryset(self): + """Return export model queryset on export action. + + For better openapi support and consistency. + + """ + if self.action == self.export_action: + return self.resource_class.get_model_queryset() # pragma: no cover + return super().get_queryset() + + def get_export_detail_serializer_class(self): + """Get serializer which will be used show details of export job.""" + return self.export_detail_serializer_class + + def get_export_create_serializer_class(self): + """Get serializer which will be used to start export job.""" + return serializers.get_create_export_job_serializer( + self.resource_class, + ) + + def get_export_resource_kwargs(self) -> dict[str, typing.Any]: + """Provide extra arguments to resource class.""" + return {} + + def get_serializer(self, *args, **kwargs): + """Provide resource kwargs to serializer class.""" + if self.action == self.export_action: + kwargs.setdefault( + "resource_kwargs", + self.get_export_resource_kwargs(), + ) + return super().get_serializer(*args, **kwargs) + + def start_export(self, request: request.Request) -> response.Response: + """Validate request data and start ExportJob.""" + ordering = request.query_params.get("ordering", "") + if ordering: + ordering = ordering.split(",") + serializer = self.get_serializer( + data=request.data, + ordering=ordering, + filter_kwargs=request.query_params, + ) + serializer.is_valid(raise_exception=True) + export_job = serializer.save() + return response.Response( + data=self.get_export_detail_serializer_class()( + instance=export_job, + ).data, + status=status.HTTP_201_CREATED, + ) diff --git a/import_export_extensions/api/mixins/import_mixins.py b/import_export_extensions/api/mixins/import_mixins.py new file mode 100644 index 0000000..3bce3f4 --- /dev/null +++ b/import_export_extensions/api/mixins/import_mixins.py @@ -0,0 +1,115 @@ +import contextlib +import typing + +from rest_framework import ( + decorators, + request, + response, + status, +) + +from ... import resources +from .. import serializers + + +class ImportStartActionMixin: + """Mixin which adds start import action.""" + + resource_class: type[resources.CeleryModelResource] + import_action = "start_import_action" + import_action_name = "import" + import_action_url = "import" + import_detail_serializer_class = serializers.ImportJobSerializer + import_open_api_description = ( + "This endpoint creates import job and starts it. " + "To monitor progress use detail endpoint for jobs to fetch state of " + "job. Once it's status is `PARSED`, you can confirm import and data " + "should start importing. When status `INPUT_ERROR` or `PARSE_ERROR` " + "it means data failed validations and can't be imported. " + "When status is `IMPORTED`, it means data is in system and " + "job is completed." + ) + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + # Skip if it is has no resource_class specified + if not hasattr(cls, "resource_class"): + return + + def start_import_action( + self: "ImportStartActionMixin", + request: request.Request, + ) -> response.Response: + return self.start_import(request) + + setattr(cls, cls.import_action, start_import_action) + decorators.action( + methods=["POST"], + url_name=cls.import_action_name, + url_path=cls.import_action_url, + detail=False, + queryset=cls.resource_class.get_model_queryset(), + serializer_class=cls().get_import_create_serializer_class(), + )(getattr(cls, cls.import_action)) + # Correct specs of drf-spectacular if it is installed + with contextlib.suppress(ImportError): + from drf_spectacular import utils + + utils.extend_schema_view( + **{ + cls.import_action: utils.extend_schema( + description=cls.import_open_api_description, + filters=True, + responses={ + status.HTTP_201_CREATED: cls().get_import_detail_serializer_class(), # noqa: E501 + }, + ), + }, + )(cls) + + def get_queryset(self): + """Return import model queryset on import action. + + For better openapi support and consistency. + + """ + if self.action == self.import_action: + return self.resource_class.get_model_queryset() # pragma: no cover + return super().get_queryset() + + def get_import_detail_serializer_class(self): + """Get serializer which will be used show details of import job.""" + return self.import_detail_serializer_class + + def get_import_create_serializer_class(self): + """Get serializer which will be used to start import job.""" + return serializers.get_create_import_job_serializer( + self.resource_class, + ) + + def get_import_resource_kwargs(self) -> dict[str, typing.Any]: + """Provide extra arguments to resource class.""" + return {} + + def get_serializer(self, *args, **kwargs): + """Provide resource kwargs to serializer class.""" + if self.action == self.import_action: + kwargs.setdefault( + "resource_kwargs", + self.get_import_resource_kwargs(), + ) + return super().get_serializer(*args, **kwargs) + + def start_import(self, request: request.Request) -> response.Response: + """Validate request data and start ImportJob.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + import_job = serializer.save() + + return response.Response( + data=self.get_import_detail_serializer_class()( + instance=import_job, + ).data, + status=status.HTTP_201_CREATED, + ) diff --git a/import_export_extensions/api/serializers/export_job.py b/import_export_extensions/api/serializers/export_job.py index 6a8cc6c..e2b38b9 100644 --- a/import_export_extensions/api/serializers/export_job.py +++ b/import_export_extensions/api/serializers/export_job.py @@ -99,10 +99,19 @@ def update(self, instance, validated_data): """Empty method to pass linters checks.""" +# Use it to cache already generated serializers to avoid duplication +_GENERATED_EXPORT_JOB_SERIALIZERS: dict[ + type[resources.CeleryModelResource], + type, +] = {} + + def get_create_export_job_serializer( resource: type[resources.CeleryModelResource], ) -> type: """Create serializer for ExportJobs creation.""" + if resource in _GENERATED_EXPORT_JOB_SERIALIZERS: + return _GENERATED_EXPORT_JOB_SERIALIZERS[resource] class _CreateExportJob(CreateExportJob): """Serializer to start export job.""" @@ -116,8 +125,9 @@ class _CreateExportJob(CreateExportJob): ], ) - return type( + _GENERATED_EXPORT_JOB_SERIALIZERS[resource] = type( f"{resource.__name__}CreateExportJob", (_CreateExportJob,), {}, ) + return _GENERATED_EXPORT_JOB_SERIALIZERS[resource] diff --git a/import_export_extensions/api/serializers/import_job.py b/import_export_extensions/api/serializers/import_job.py index 5bb02ad..c52b3d8 100644 --- a/import_export_extensions/api/serializers/import_job.py +++ b/import_export_extensions/api/serializers/import_job.py @@ -126,18 +126,28 @@ def update(self, instance, validated_data): """Empty method to pass linters checks.""" +# Use it to cache already generated serializers to avoid duplication +_GENERATED_IMPORT_JOB_SERIALIZERS: dict[ + type[resources.CeleryModelResource], + type, +] = {} + + def get_create_import_job_serializer( resource: type[resources.CeleryModelResource], ) -> type: """Create serializer for ImportJobs creation.""" + if resource in _GENERATED_IMPORT_JOB_SERIALIZERS: + return _GENERATED_IMPORT_JOB_SERIALIZERS[resource] class _CreateImportJob(CreateImportJob): """Serializer to start import job.""" resource_class: type[resources.CeleryModelResource] = resource - return type( + _GENERATED_IMPORT_JOB_SERIALIZERS[resource] = type( f"{resource.__name__}CreateImportJob", (_CreateImportJob,), {}, ) + return _GENERATED_IMPORT_JOB_SERIALIZERS[resource] diff --git a/import_export_extensions/api/views/__init__.py b/import_export_extensions/api/views/__init__.py index 2409e38..2ddfa4e 100644 --- a/import_export_extensions/api/views/__init__.py +++ b/import_export_extensions/api/views/__init__.py @@ -1,8 +1,12 @@ from .export_job import ( + BaseExportJobForUserViewSet, + BaseExportJobViewSet, ExportJobForUserViewSet, ExportJobViewSet, ) from .import_job import ( + BaseImportJobForUserViewSet, + BaseImportJobViewSet, ImportJobForUserViewSet, ImportJobViewSet, ) diff --git a/import_export_extensions/api/views/export_job.py b/import_export_extensions/api/views/export_job.py index 16b3061..9e1aafa 100644 --- a/import_export_extensions/api/views/export_job.py +++ b/import_export_extensions/api/views/export_job.py @@ -1,9 +1,5 @@ import collections.abc import contextlib -import typing - -from django.conf import settings -from django.utils import module_loading from rest_framework import ( decorators, @@ -14,87 +10,80 @@ status, viewsets, ) -from rest_framework.request import Request import django_filters -from ... import models, resources +from ... import models from .. import mixins as core_mixins -from .. import serializers -class ExportBase(type): - """Add custom create action for each ExportJobViewSet.""" +class BaseExportJobViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + """Base viewset for managing export jobs.""" - def __new__(cls, name, bases, attrs, **kwargs): - """Dynamically create an export start api endpoint. + permission_classes = (permissions.IsAuthenticated,) + serializer_class = core_mixins.ExportStartActionMixin.export_detail_serializer_class # noqa: E501 + queryset = models.ExportJob.objects.all() + filterset_class: django_filters.rest_framework.FilterSet | None = None + search_fields: collections.abc.Sequence[str] = ("id",) + ordering: collections.abc.Sequence[str] = ( + "id", + ) + ordering_fields: collections.abc.Sequence[str] = ( + "id", + "created", + "modified", + ) - We need this to specify on fly action's filterset_class and queryset - (django-filters requires view's queryset and filterset_class's - queryset model to match). Also, if drf-spectacular is installed - specify request and response, and enable filters. + def __init_subclass__(cls) -> None: + """Dynamically create an cancel api endpoints. + + Need to do this to enable action and correct open-api spec generated by + drf_spectacular. """ - viewset: type[ExportJobViewSet] = super().__new__( - cls, - name, - bases, - attrs, - **kwargs, - ) - # Skip if it is has no resource_class specified - if not hasattr(viewset, "resource_class"): - return viewset - filter_backends = [ - module_loading.import_string(settings.DRF_EXPORT_DJANGO_FILTERS_BACKEND), - ] - if viewset.export_ordering_fields: - filter_backends.append( - module_loading.import_string(settings.DRF_EXPORT_ORDERING_BACKEND), - ) - decorators.action( - methods=["POST"], - detail=False, - queryset=viewset.resource_class.get_model_queryset(), - filterset_class=getattr( - viewset.resource_class, "filterset_class", None, - ), - filter_backends=filter_backends, - ordering=viewset.export_ordering, - ordering_fields=viewset.export_ordering_fields, - )(viewset.start) + super().__init_subclass__() decorators.action( methods=["POST"], detail=True, - )(viewset.cancel) + )(cls.cancel) # Correct specs of drf-spectacular if it is installed with contextlib.suppress(ImportError): from drf_spectacular.utils import extend_schema, extend_schema_view - - detail_serializer_class = viewset().get_detail_serializer_class() - return extend_schema_view( - start=extend_schema( - filters=True, - request=viewset().get_export_create_serializer_class(), - responses={ - status.HTTP_201_CREATED: detail_serializer_class, - }, - ), + if hasattr(cls, "get_export_detail_serializer_class"): + response_serializer = cls().get_export_detail_serializer_class() # noqa: E501 + else: + response_serializer = cls().get_serializer_class() + extend_schema_view( cancel=extend_schema( request=None, responses={ - status.HTTP_200_OK: detail_serializer_class, + status.HTTP_200_OK: response_serializer, }, ), - )(viewset) - return viewset + )(cls) + def cancel(self, *args, **kwargs) -> response.Response: + """Cancel export job that is in progress.""" + job: models.ExportJob = self.get_object() + + try: + job.cancel_export() + except ValueError as error: + raise exceptions.ValidationError(error.args[0]) from error + + serializer = self.get_serializer(instance=job) + return response.Response( + status=status.HTTP_200_OK, + data=serializer.data, + ) class ExportJobViewSet( - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - viewsets.GenericViewSet, - metaclass=ExportBase, + core_mixins.ExportStartActionMixin, + BaseExportJobViewSet, ): """Base API viewset for ExportJob model. @@ -106,95 +95,24 @@ class ExportJobViewSet( """ - permission_classes = (permissions.IsAuthenticated,) - queryset = models.ExportJob.objects.all() - serializer_class = serializers.ExportJobSerializer - resource_class: type[resources.CeleryModelResource] - filterset_class: django_filters.rest_framework.FilterSet | None = None - search_fields: collections.abc.Sequence[str] = ("id",) - ordering: collections.abc.Sequence[str] = ( - "id", - ) - ordering_fields: collections.abc.Sequence[str] = ( - "id", - "created", - "modified", - ) - export_ordering: collections.abc.Sequence[str] = () - export_ordering_fields: collections.abc.Sequence[str] = () + export_action_name = "start" + export_action_url = "start" def get_queryset(self): """Filter export jobs by resource used in viewset.""" - if self.action == "start": - # To make it consistent and for better support of drf-spectacular - return super().get_queryset() # pragma: no cover return super().get_queryset().filter( resource_path=self.resource_class.class_path, ) - def get_resource_kwargs(self) -> dict[str, typing.Any]: - """Provide extra arguments to resource class.""" - return {} - - def get_serializer(self, *args, **kwargs): - """Provide resource kwargs to serializer class.""" - if self.action == "start": - kwargs.setdefault("resource_kwargs", self.get_resource_kwargs()) - return super().get_serializer(*args, **kwargs) - - def get_serializer_class(self): - """Return special serializer on creation.""" - if self.action == "start": - return self.get_export_create_serializer_class() - return self.get_detail_serializer_class() - - def get_detail_serializer_class(self): - """Get serializer which will be used show details of export job.""" - return self.serializer_class - - def get_export_create_serializer_class(self): - """Get serializer which will be used to start export job.""" - return serializers.get_create_export_job_serializer( - self.resource_class, - ) - - def start(self, request: Request): - """Validate request data and start ExportJob.""" - ordering = request.query_params.get("ordering", "") - if ordering: - ordering = ordering.split(",") - serializer = self.get_serializer( - data=request.data, - ordering=ordering, - filter_kwargs=request.query_params, - ) - serializer.is_valid(raise_exception=True) - export_job = serializer.save() - return response.Response( - data=self.get_detail_serializer_class()( - instance=export_job, - ).data, - status=status.HTTP_201_CREATED, - ) - - def cancel(self, *args, **kwargs): - """Cancel export job that is in progress.""" - job: models.ExportJob = self.get_object() - - try: - job.cancel_export() - except ValueError as error: - raise exceptions.ValidationError(error.args[0]) from error - - serializer = self.get_serializer(instance=job) - return response.Response( - status=status.HTTP_200_OK, - data=serializer.data, - ) - class ExportJobForUserViewSet( core_mixins.LimitQuerySetToCurrentUserMixin, ExportJobViewSet, ): """Viewset for providing export feature to users.""" + +class BaseExportJobForUserViewSet( + core_mixins.LimitQuerySetToCurrentUserMixin, + BaseExportJobViewSet, +): + """Viewset for providing export job management to users.""" diff --git a/import_export_extensions/api/views/import_job.py b/import_export_extensions/api/views/import_job.py index be7bcfb..d10b0d3 100644 --- a/import_export_extensions/api/views/import_job.py +++ b/import_export_extensions/api/views/import_job.py @@ -1,5 +1,5 @@ +import collections import contextlib -import typing from rest_framework import ( decorators, @@ -11,153 +11,67 @@ viewsets, ) -from ... import models, resources +from ... import models from .. import mixins as core_mixins -from .. import serializers -class ImportBase(type): - """Add custom create action for each ImportJobViewSet.""" +class BaseImportJobViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + """Base viewset for managing import jobs.""" + + permission_classes = (permissions.IsAuthenticated,) + serializer_class = core_mixins.ImportStartActionMixin.import_detail_serializer_class # noqa: E501 + queryset = models.ImportJob.objects.all() + search_fields: collections.abc.Sequence[str] = ("id",) + ordering: collections.abc.Sequence[str] = ( + "id", + ) + ordering_fields: collections.abc.Sequence[str] = ( + "id", + "created", + "modified", + ) - def __new__(cls, name, bases, attrs, **kwargs): - """Dynamically create an import start api endpoint. + def __init_subclass__(cls) -> None: + """Dynamically create an cancel api endpoints. - If drf-spectacular is installed - specify request and response, and enable filters. + Need to do this to enable action and correct open-api spec generated by + drf_spectacular. """ - viewset: type[ImportJobViewSet] = super().__new__( - cls, - name, - bases, - attrs, - **kwargs, - ) - # Skip if it is has no resource_class specified - if not hasattr(viewset, "resource_class"): - return viewset - - decorators.action( - methods=["POST"], - detail=False, - )(viewset.start) + super().__init_subclass__() decorators.action( methods=["POST"], detail=True, - )(viewset.confirm) + )(cls.cancel) decorators.action( methods=["POST"], detail=True, - )(viewset.cancel) - + )(cls.confirm) # Correct specs of drf-spectacular if it is installed with contextlib.suppress(ImportError): from drf_spectacular.utils import extend_schema, extend_schema_view - - detail_serializer_class = viewset().get_detail_serializer_class() - return extend_schema_view( - start=extend_schema( - request=viewset().get_import_create_serializer_class(), - responses={ - status.HTTP_201_CREATED: detail_serializer_class, - }, - ), - confirm=extend_schema( + if hasattr(cls, "get_import_detail_serializer_class"): + response_serializer = cls().get_import_detail_serializer_class() # noqa: E501 + else: + response_serializer = cls().get_serializer_class() + extend_schema_view( + cancel=extend_schema( request=None, responses={ - status.HTTP_200_OK: detail_serializer_class, + status.HTTP_200_OK: response_serializer, }, ), - cancel=extend_schema( + confirm=extend_schema( request=None, responses={ - status.HTTP_200_OK: detail_serializer_class, + status.HTTP_200_OK: response_serializer, }, ), - )(viewset) - return viewset - - -class ImportJobViewSet( - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - viewsets.GenericViewSet, - metaclass=ImportBase, -): - """Base API viewset for ImportJob model. - - Based on resource_class it will generate an endpoint which will allow to - start an import to model which was specified in resource_class. On success - this endpoint we return an instance of import. - - Endpoints: - list - to get list of all import jobs - details(retrieve) - to get status of import job - start - create import job and start parsing data from attached file - confirm - confirm import after parsing process is finished - cancel - stop importing/parsing process and cancel this import job - - """ - - permission_classes = (permissions.IsAuthenticated,) - queryset = models.ImportJob.objects.all() - serializer_class = serializers.ImportJobSerializer - resource_class: type[resources.CeleryModelResource] - search_fields = ("id",) - ordering = ( - "id", - ) - ordering_fields = ( - "id", - "created", - "modified", - ) - - def get_queryset(self): - """Filter import jobs by resource used in viewset.""" - return super().get_queryset().filter( - resource_path=self.resource_class.class_path, - ) - - def get_resource_kwargs(self) -> dict[str, typing.Any]: - """Provide extra arguments to resource class.""" - return {} - - def get_serializer(self, *args, **kwargs): - """Provide resource kwargs to serializer class.""" - if self.action == "start": - kwargs.setdefault("resource_kwargs", self.get_resource_kwargs()) - return super().get_serializer(*args, **kwargs) - - def get_serializer_class(self): - """Return special serializer on creation.""" - if self.action == "start": - return self.get_import_create_serializer_class() - return self.get_detail_serializer_class() - - def get_detail_serializer_class(self): - """Get serializer which will be used show details of import job.""" - return self.serializer_class - - def get_import_create_serializer_class(self): - """Get serializer which will be used to start import job.""" - return serializers.get_create_import_job_serializer( - self.resource_class, - ) - - def start(self, request, *args, **kwargs): - """Validate request data and start ImportJob.""" - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - import_job = serializer.save() - - return response.Response( - data=self.get_detail_serializer_class()( - instance=import_job, - ).data, - status=status.HTTP_201_CREATED, - ) + )(cls) def confirm(self, *args, **kwargs): """Confirm import job that has `parsed` status.""" @@ -189,8 +103,42 @@ def cancel(self, *args, **kwargs): data=serializer.data, ) +class ImportJobViewSet( + core_mixins.ImportStartActionMixin, + BaseImportJobViewSet, +): + """Base API viewset for ImportJob model. + + Based on resource_class it will generate an endpoint which will allow to + start an import to model which was specified in resource_class. On success + this endpoint we return an instance of import. + + Endpoints: + list - to get list of all import jobs + details(retrieve) - to get status of import job + start - create import job and start parsing data from attached file + confirm - confirm import after parsing process is finished + cancel - stop importing/parsing process and cancel this import job + + """ + + import_action_name = "start" + import_action_url = "start" + + def get_queryset(self): + """Filter import jobs by resource used in viewset.""" + return super().get_queryset().filter( + resource_path=self.resource_class.class_path, + ) + class ImportJobForUserViewSet( core_mixins.LimitQuerySetToCurrentUserMixin, ImportJobViewSet, ): """Viewset for providing import feature to users.""" + +class BaseImportJobForUserViewSet( + core_mixins.LimitQuerySetToCurrentUserMixin, + BaseImportJobViewSet, +): + """Viewset for providing export job management to users.""" diff --git a/import_export_extensions/resources.py b/import_export_extensions/resources.py index c6e5d9d..0e38990 100644 --- a/import_export_extensions/resources.py +++ b/import_export_extensions/resources.py @@ -61,9 +61,21 @@ def status_update_row_count(self): settings.STATUS_UPDATE_ROW_COUNT, ) + @classmethod + def get_model_queryset(cls) -> QuerySet: + """Return a queryset of all objects for this model. + + Override this if you + want to limit the returned queryset. + + Same as resources.ModelResource get_queryset. + + """ + return cls._meta.model.objects.all() + def get_queryset(self): """Filter export queryset via filterset class.""" - queryset = super().get_queryset() + queryset = self.get_model_queryset() try: queryset = queryset.order_by(*(self._ordering or ())) except FieldError as error: @@ -345,17 +357,5 @@ class CeleryResource(CeleryResourceMixin, resources.Resource): class CeleryModelResource(CeleryResourceMixin, resources.ModelResource): """ModelResource which supports importing via celery.""" - @classmethod - def get_model_queryset(cls) -> QuerySet: - """Return a queryset of all objects for this model. - - Override this if you - want to limit the returned queryset. - - Same as resources.ModelResource get_queryset. - - """ - return cls._meta.model.objects.all() - class Meta: store_instance = True diff --git a/invocations/docs.py b/invocations/docs.py index 99f9edc..e0bb0ff 100644 --- a/invocations/docs.py +++ b/invocations/docs.py @@ -10,5 +10,7 @@ def build(context: invoke.Context): """Build documentation.""" saritasa_invocations.print_success("Start building of local documentation") - context.run(f"sphinx-build -E -a docs {LOCAL_DOCS_DIR}") + context.run( + f"sphinx-build -E -a docs {LOCAL_DOCS_DIR} --exception-on-warning", + ) saritasa_invocations.print_success("Building completed") diff --git a/test_project/fake_app/api/views.py b/test_project/fake_app/api/views.py index a5e860e..b88cc98 100644 --- a/test_project/fake_app/api/views.py +++ b/test_project/fake_app/api/views.py @@ -1,18 +1,52 @@ -from import_export_extensions.api import views +from rest_framework import mixins, serializers, viewsets -from ..resources import SimpleArtistResource +from import_export_extensions import api +from .. import models, resources -class ArtistExportViewSet(views.ExportJobForUserViewSet): + +class ArtistExportViewSet(api.ExportJobForUserViewSet): """Simple ViewSet for exporting Artist model.""" - resource_class = SimpleArtistResource + resource_class = resources.SimpleArtistResource export_ordering_fields = ( "id", "name", ) -class ArtistImportViewSet(views.ImportJobForUserViewSet): +class ArtistImportViewSet(api.ImportJobForUserViewSet): """Simple ViewSet for importing Artist model.""" - resource_class = SimpleArtistResource + resource_class = resources.SimpleArtistResource + +class ArtistSerializer(serializers.ModelSerializer): + """Serializer for Artist model.""" + + class Meta: + model = models.Artist + fields = ( + "id", + "name", + "instrument", + ) + +class ArtistViewSet( + api.ExportStartActionMixin, + api.ImportStartActionMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + """Simple viewset for Artist model.""" + + resource_class = resources.SimpleArtistResource + queryset = models.Artist.objects.all() + serializer_class = ArtistSerializer + filterset_class = resources.SimpleArtistResource.filterset_class + ordering = ( + "id", + ) + ordering_fields = ( + "id", + "name", + ) diff --git a/test_project/fake_app/resources.py b/test_project/fake_app/resources.py index bbf9e7d..384d94d 100644 --- a/test_project/fake_app/resources.py +++ b/test_project/fake_app/resources.py @@ -44,9 +44,13 @@ class Meta: def get_queryset(self): """Return a queryset.""" - return Artist.objects.all().prefetch_related( - "membership_set__band", - "bands", + return ( + super() + .get_queryset() + .prefetch_related( + "membership_set__band", + "bands", + ) ) @@ -71,7 +75,11 @@ class Meta: def get_queryset(self): """Return a queryset.""" - return Band.objects.all().prefetch_related( - "membership_set__artist", - "artists", + return ( + super() + .get_queryset() + .prefetch_related( + "membership_set__artist", + "artists", + ) ) diff --git a/test_project/tests/integration_tests/test_api/test_export.py b/test_project/tests/integration_tests/test_api/test_export.py index 1cd12be..d3ad63a 100644 --- a/test_project/tests/integration_tests/test_api/test_export.py +++ b/test_project/tests/integration_tests/test_api/test_export.py @@ -11,12 +11,26 @@ @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="export_url", + argvalues=[ + pytest.param( + reverse("export-artist-start"), + id="Export url", + ), + pytest.param( + f"{reverse('artists-export')}", + id="Action url", + ), + ], +) def test_export_api_creates_export_job( admin_api_client: test.APIClient, + export_url: str, ): """Ensure export start API creates new export job.""" response = admin_api_client.post( - path=reverse("export-artist-start"), + path=export_url, data={ "file_format": "csv", }, @@ -53,15 +67,29 @@ def test_export_api_creates_export_job( ), ], ) +@pytest.mark.parametrize( + argnames="export_url", + argvalues=[ + pytest.param( + reverse("export-artist-start"), + id="Export url", + ), + pytest.param( + f"{reverse('artists-export')}", + id="Action url", + ), + ], +) def test_export_api_filtering( admin_api_client: test.APIClient, filter_query: str, filter_name: str, filter_value: str, + export_url: str, ): """Ensure export start API passes filter kwargs correctly.""" response = admin_api_client.post( - path=f"{reverse('export-artist-start')}?{filter_query}", + path=f"{export_url}?{filter_query}", data={ "file_format": "csv", }, @@ -100,14 +128,28 @@ def test_export_api_filtering( ), ], ) +@pytest.mark.parametrize( + argnames="export_url", + argvalues=[ + pytest.param( + reverse("export-artist-start"), + id="Export url", + ), + pytest.param( + f"{reverse('artists-export')}", + id="Action url", + ), + ], +) def test_export_api_ordering( admin_api_client: test.APIClient, ordering_query: str, ordering_value: collections.abc.Sequence[str], + export_url: str, ): """Ensure export start API passes ordering correctly.""" response = admin_api_client.post( - path=f"{reverse('export-artist-start')}?{ordering_query}", + path=f"{export_url}?{ordering_query}", data={ "file_format": "csv", }, @@ -123,12 +165,26 @@ def test_export_api_ordering( @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="export_url", + argvalues=[ + pytest.param( + f"{reverse('export-artist-start')}?id=invalid_id", + id="Export url with invalid filter_kwargs", + ), + pytest.param( + f"{reverse('artists-export')}?id=invalid_id", + id="Action url with invalid filter_kwargs", + ), + ], +) def test_export_api_create_export_job_with_invalid_filter_kwargs( admin_api_client: test.APIClient, + export_url: str, ): """Ensure export start API with invalid kwargs return an error.""" response = admin_api_client.post( - path=f"{reverse('export-artist-start')}?id=invalid_id", + path=export_url, data={ "file_format": "csv", }, @@ -138,12 +194,26 @@ def test_export_api_create_export_job_with_invalid_filter_kwargs( @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="export_url", + argvalues=[ + pytest.param( + f"{reverse('export-artist-start')}?ordering=invalid_id", + id="Export url with invalid ordering", + ), + pytest.param( + f"{reverse('artists-export')}?ordering=invalid_id", + id="Action url with invalid ordering", + ), + ], +) def test_export_api_create_export_job_with_invalid_ordering( admin_api_client: test.APIClient, + export_url: str, ): """Ensure export start API with invalid ordering return an error.""" response = admin_api_client.post( - path=f"{reverse('export-artist-start')}?ordering=invalid_id", + path=export_url, data={ "file_format": "csv", }, @@ -156,14 +226,28 @@ def test_export_api_create_export_job_with_invalid_ordering( @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="export_url", + argvalues=[ + pytest.param( + "export-artist-detail", + id="Model url", + ), + pytest.param( + "export-jobs-detail", + id="General url", + ), + ], +) def test_export_api_detail( admin_api_client: test.APIClient, artist_export_job: ExportJob, + export_url: str, ): """Ensure export detail API shows current export job status.""" response = admin_api_client.get( path=reverse( - "export-artist-detail", + export_url, kwargs={"pk": artist_export_job.id}, ), ) @@ -172,15 +256,29 @@ def test_export_api_detail( @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="export_url", + argvalues=[ + pytest.param( + "export-artist-detail", + id="Model url", + ), + pytest.param( + "export-jobs-detail", + id="General url", + ), + ], +) def test_import_user_api_get_detail( user: User, admin_api_client: test.APIClient, artist_export_job: ExportJob, + export_url: str, ): """Ensure import detail api for user returns only users jobs.""" response = admin_api_client.get( path=reverse( - "export-artist-detail", + export_url, kwargs={"pk": artist_export_job.id}, ), ) @@ -190,7 +288,7 @@ def test_import_user_api_get_detail( artist_export_job.save() response = admin_api_client.get( path=reverse( - "export-artist-detail", + export_url, kwargs={"pk": artist_export_job.id}, ), ) @@ -205,17 +303,31 @@ def test_import_user_api_get_detail( ExportJob.ExportStatus.EXPORTING, ], ) +@pytest.mark.parametrize( + argnames="export_url", + argvalues=[ + pytest.param( + "export-artist-cancel", + id="Model url", + ), + pytest.param( + "export-jobs-cancel", + id="General url", + ), + ], +) def test_export_api_cancel( admin_api_client: test.APIClient, artist_export_job: ExportJob, allowed_cancel_status: ExportJob.ExportStatus, + export_url: str, ): """Ensure that export canceled with allowed statuses.""" artist_export_job.export_status = allowed_cancel_status artist_export_job.save() response = admin_api_client.post( path=reverse( - "export-artist-cancel", + export_url, kwargs={"pk": artist_export_job.pk}, ), ) @@ -233,17 +345,31 @@ def test_export_api_cancel( ExportJob.ExportStatus.CANCELLED, ], ) +@pytest.mark.parametrize( + argnames="export_url", + argvalues=[ + pytest.param( + "export-artist-cancel", + id="Model url", + ), + pytest.param( + "export-jobs-cancel", + id="General url", + ), + ], +) def test_export_api_cancel_with_errors( admin_api_client: test.APIClient, artist_export_job: ExportJob, incorrect_job_status: ExportJob.ExportStatus, + export_url: str, ): """Ensure that export job with incorrect statuses cannot be canceled.""" artist_export_job.export_status = incorrect_job_status artist_export_job.save() response = admin_api_client.post( path=reverse( - "export-artist-cancel", + export_url, kwargs={"pk": artist_export_job.pk}, ), ) diff --git a/test_project/tests/integration_tests/test_api/test_import.py b/test_project/tests/integration_tests/test_api/test_import.py index 2bd8bc3..c4b3011 100644 --- a/test_project/tests/integration_tests/test_api/test_import.py +++ b/test_project/tests/integration_tests/test_api/test_import.py @@ -14,14 +14,28 @@ @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="import_url", + argvalues=[ + pytest.param( + reverse("import-artist-start"), + id="Model url", + ), + pytest.param( + f"{reverse('artists-import')}", + id="Action url", + ), + ], +) def test_import_api_creates_import_job( admin_api_client: APIClient, uploaded_file: SimpleUploadedFile, + import_url: str, ): """Ensure import start api creates new import job.""" import_job_count = ImportJob.objects.count() response = admin_api_client.post( - path=reverse("import-artist-start"), + path=import_url, data={ "file": uploaded_file, }, @@ -33,14 +47,28 @@ def test_import_api_creates_import_job( @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="import_url", + argvalues=[ + pytest.param( + "import-artist-detail", + id="Model url", + ), + pytest.param( + "import-jobs-detail", + id="General url", + ), + ], +) def test_import_api_detail( admin_api_client: APIClient, artist_import_job: ImportJob, + import_url: str, ): """Ensure import detail api shows current import job status.""" response = admin_api_client.get( path=reverse( - "import-artist-detail", + import_url, kwargs={"pk": artist_import_job.id}, ), ) @@ -52,7 +80,7 @@ def test_import_api_detail( response = admin_api_client.get( path=reverse( - "import-artist-detail", + import_url, kwargs={"pk": artist_import_job.id}, ), ) @@ -61,15 +89,29 @@ def test_import_api_detail( @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="import_url", + argvalues=[ + pytest.param( + "import-artist-detail", + id="Model url", + ), + pytest.param( + "import-jobs-detail", + id="General url", + ), + ], +) def test_import_user_api_get_detail( user: User, admin_api_client: APIClient, artist_import_job: ImportJob, + import_url: str, ): """Ensure import detail api for user returns only users jobs.""" response = admin_api_client.get( path=reverse( - "import-artist-detail", + import_url, kwargs={"pk": artist_import_job.id}, ), ) @@ -87,10 +129,23 @@ def test_import_user_api_get_detail( @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="import_url", + argvalues=[ + pytest.param( + "import-artist-detail", + id="Model url", + ), + pytest.param( + "import-jobs-detail", + id="General url", + ), + ], +) def test_force_import_api_detail( admin_api_client: APIClient, - superuser: User, force_import_artist_job: ImportJob, + import_url: str, ): """Test detail api for force import job. @@ -101,7 +156,7 @@ def test_force_import_api_detail( """ response = admin_api_client.get( path=reverse( - "import-artist-detail", + import_url, kwargs={"pk": force_import_artist_job.id}, ), ) @@ -113,7 +168,7 @@ def test_force_import_api_detail( response = admin_api_client.get( path=reverse( - "import-artist-detail", + import_url, kwargs={"pk": force_import_artist_job.id}, ), ) @@ -126,10 +181,24 @@ def test_force_import_api_detail( @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="import_url", + argvalues=[ + pytest.param( + "import-artist-detail", + id="Model url", + ), + pytest.param( + "import-jobs-detail", + id="General url", + ), + ], +) def test_import_api_detail_with_row_errors( admin_api_client: APIClient, existing_artist: Artist, superuser: User, + import_url: str, ): """Ensure import detail api shows row errors.""" expected_error_message = "Instrument matching query does not exist." @@ -150,7 +219,7 @@ def test_import_api_detail_with_row_errors( response = admin_api_client.get( path=reverse( - "import-artist-detail", + import_url, kwargs={"pk": import_artist_job.pk}, ), ) @@ -164,10 +233,24 @@ def test_import_api_detail_with_row_errors( @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="import_url", + argvalues=[ + pytest.param( + "import-artist-detail", + id="Model url", + ), + pytest.param( + "import-jobs-detail", + id="General url", + ), + ], +) def test_import_api_detail_with_base_errors( superuser: User, admin_api_client: APIClient, existing_artist: Artist, + import_url: str, ): """Ensure import detail api shows base errors.""" expected_error_message = ( @@ -193,7 +276,7 @@ def test_import_api_detail_with_base_errors( response = admin_api_client.get( path=reverse( - "import-artist-detail", + import_url, kwargs={"pk": import_artist_job.pk}, ), ) @@ -207,16 +290,30 @@ def test_import_api_detail_with_base_errors( @pytest.mark.django_db(transaction=True) +@pytest.mark.parametrize( + argnames="import_url", + argvalues=[ + pytest.param( + "import-artist-confirm", + id="Model url", + ), + pytest.param( + "import-jobs-confirm", + id="General url", + ), + ], +) def test_import_api_confirm_parsed_job( admin_api_client: APIClient, artist_import_job: ImportJob, + import_url: str, ): """Check that parsed import job can be confirmed.""" artist_import_job.parse_data() artist_import_job.refresh_from_db() response = admin_api_client.post( path=reverse( - "import-artist-confirm", + import_url, kwargs={"pk": artist_import_job.pk}, ), ) @@ -240,10 +337,24 @@ def test_import_api_confirm_parsed_job( ImportJob.ImportStatus.CANCELLED, ], ) +@pytest.mark.parametrize( + argnames="import_url", + argvalues=[ + pytest.param( + "import-artist-confirm", + id="Model url", + ), + pytest.param( + "import-jobs-confirm", + id="General url", + ), + ], +) def test_import_api_confirm_incorrect_job_status( admin_api_client: APIClient, artist_import_job: ImportJob, incorrect_job_status: ImportJob.ImportStatus, + import_url: str, ): """Ensure that not parsed job can't be confirmed.""" artist_import_job.import_status = incorrect_job_status @@ -251,7 +362,7 @@ def test_import_api_confirm_incorrect_job_status( response = admin_api_client.post( path=reverse( - "import-artist-confirm", + import_url, kwargs={"pk": artist_import_job.pk}, ), ) @@ -273,17 +384,31 @@ def test_import_api_confirm_incorrect_job_status( ImportJob.ImportStatus.CONFIRMED, ], ) +@pytest.mark.parametrize( + argnames="import_url", + argvalues=[ + pytest.param( + "import-artist-cancel", + id="Model url", + ), + pytest.param( + "import-jobs-cancel", + id="General url", + ), + ], +) def test_import_api_cancel_job( admin_api_client: APIClient, artist_import_job: ImportJob, allowed_cancel_status: ImportJob.ImportStatus, + import_url: str, ): """Check that import job with allowed statuses can be cancelled.""" artist_import_job.import_status = allowed_cancel_status artist_import_job.save() response = admin_api_client.post( path=reverse( - "import-artist-cancel", + import_url, kwargs={"pk": artist_import_job.pk}, ), ) @@ -303,10 +428,24 @@ def test_import_api_cancel_job( ImportJob.ImportStatus.CANCELLED, ], ) +@pytest.mark.parametrize( + argnames="import_url", + argvalues=[ + pytest.param( + "import-artist-cancel", + id="Model url", + ), + pytest.param( + "import-jobs-cancel", + id="General url", + ), + ], +) def test_import_api_cancel_incorrect_job_status( admin_api_client: APIClient, artist_import_job: ImportJob, incorrect_job_status: ImportJob.ImportStatus, + import_url: str, ): """Ensure that import job with incorrect statuses cannot be canceled.""" artist_import_job.import_status = incorrect_job_status @@ -314,7 +453,7 @@ def test_import_api_cancel_incorrect_job_status( response = admin_api_client.post( path=reverse( - "import-artist-cancel", + import_url, kwargs={"pk": artist_import_job.pk}, ), ) diff --git a/test_project/urls.py b/test_project/urls.py index 413e23c..5818293 100644 --- a/test_project/urls.py +++ b/test_project/urls.py @@ -7,6 +7,8 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +from import_export_extensions import api + from .fake_app.api import views ie_router = DefaultRouter() @@ -15,11 +17,26 @@ views.ArtistExportViewSet, basename="export-artist", ) +ie_router.register( + "export-jobs", + api.BaseExportJobForUserViewSet, + basename="export-jobs", +) +ie_router.register( + "import-jobs", + api.BaseImportJobForUserViewSet, + basename="import-jobs", +) ie_router.register( "import-artist", views.ArtistImportViewSet, basename="import-artist", ) +ie_router.register( + "artists", + views.ArtistViewSet, + basename="artists", +) urlpatterns = [re_path("^admin/", admin.site.urls), *ie_router.urls]