diff --git a/AUTHORS.rst b/AUTHORS.rst index 0ece330..24988a6 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -10,6 +10,7 @@ Saritasa, LLC Contributors ------------ +* TheSuperiorStanislav (Stanislav Khlud) * yalef (Romaschenko Vladislav) * NikAzanov (Nikita Azanov) * ron8mcr (Roman Gorbil) diff --git a/README.rst b/README.rst index 87a28d6..6ce3f25 100644 --- a/README.rst +++ b/README.rst @@ -3,25 +3,29 @@ django-import-export-extensions =============================== .. image:: https://github.com/saritasa-nest/django-import-export-extensions/actions/workflows/checks.yml/badge.svg - :target: https://github.com/saritasa-nest/django-import-export-extensions/actions/workflows/checks.yml - :alt: Build status on Github + :target: https://github.com/saritasa-nest/django-import-export-extensions/actions/workflows/checks.yml + :alt: Build status on Github .. image:: https://coveralls.io/repos/github/saritasa-nest/django-import-export-extensions/badge.svg?branch=main - :target: https://coveralls.io/github/saritasa-nest/django-import-export-extensions?branch=main - :alt: Test coverage + :target: https://coveralls.io/github/saritasa-nest/django-import-export-extensions?branch=main + :alt: Test coverage .. image:: https://img.shields.io/badge/python%20versions-3.9%20%7C%203.10%20%7C%203.11-blue - :target: https://pypi.org/project/django-import-export-extensions/ - :alt: Supported python versions + :target: https://pypi.org/project/django-import-export-extensions/ + :alt: Supported python versions .. image:: https://img.shields.io/badge/django--versions-3.2%20%7C%204.0%20%7C%204.1%20%7C%204.2-blue - :target: https://pypi.org/project/django-import-export-extensions/ - :alt: Supported django versions + :target: https://pypi.org/project/django-import-export-extensions/ + :alt: Supported django versions .. image:: https://readthedocs.org/projects/django-import-export-extensions/badge/?version=latest :target: https://django-import-export-extensions.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status +.. image:: https://static.pepy.tech/personalized-badge/django-import-export-extensions?period=month&units=international_system&left_color=black&right_color=blue&left_text=Downloads/month + :target: https://pepy.tech/project/django-import-export-extensions + :alt: Downloading statistic + Description ----------- `django-import-export-extensions` extends the functionality of @@ -128,5 +132,5 @@ Links: * PyPI: https://pypi.org/project/django-import-export-extensions/ License: -________ +-------- * Free software: MIT license diff --git a/docs/_static/images/bands-openapi.png b/docs/_static/images/bands-openapi.png new file mode 100644 index 0000000..9de09c0 Binary files /dev/null and b/docs/_static/images/bands-openapi.png differ diff --git a/docs/_static/images/export-status.png b/docs/_static/images/export-status.png new file mode 100644 index 0000000..04bab65 Binary files /dev/null and b/docs/_static/images/export-status.png differ diff --git a/docs/_static/images/filters-openapi.png b/docs/_static/images/filters-openapi.png new file mode 100644 index 0000000..44830a6 Binary files /dev/null and b/docs/_static/images/filters-openapi.png differ diff --git a/docs/_static/images/import-job-admin.png b/docs/_static/images/import-job-admin.png new file mode 100644 index 0000000..c2ec4d0 Binary files /dev/null and b/docs/_static/images/import-job-admin.png differ diff --git a/docs/api_drf.rst b/docs/api_drf.rst index dbd594c..3763d29 100644 --- a/docs/api_drf.rst +++ b/docs/api_drf.rst @@ -2,6 +2,12 @@ API (Rest Framework) ==================== +.. autoclass:: import_export_extensions.api.views.ImportJobViewSet + :members: + +.. autoclass:: import_export_extensions.api.views.ExportJobViewSet + :members: + .. autoclass:: import_export_extensions.api.CreateExportJob :members: create, validate diff --git a/docs/extensions.rst b/docs/extensions.rst new file mode 100644 index 0000000..5362f8c --- /dev/null +++ b/docs/extensions.rst @@ -0,0 +1,224 @@ +========== +Extensions +========== + +This package is simply an extension of the original ``django-import-export``, so if you want +to know more about the import/export process and advanced resource utilization, you should refer to +`the official django-import-export documentation `_. + +This section describes the features that are added by this package. + +-------------------------- +ImportJob/ExportJob models +-------------------------- + +The ``django-import-export-extensions`` provides ``ImportJob`` and ``ExportJob`` models to store all the information +about the import/export process. In addition, these models participate in the background import/export process. + +Job models are already registered in Django Admin and have custom forms that allow to show +all current job information including import/export status. + +.. figure:: _static/images/import-job-admin.png + + Example of custom form for ImportJob details in Django Admin + +``ImportJob``/``ExportJob`` models contain useful properties and methods for managing +the import/export process. To learn more, see the :ref:`Models API documentation`. + +------------------- +Celery admin mixins +------------------- + +The mixins for admin models have been rewritten completely, although they inherit from the base mixins +``mixins.BaseImportMixin``, ``mixins.BaseExportMixin`` and ``admin.ImportExportMixinBase``. +The new celery admin mixins add new pages to display import/export status, and use custom +templates for the status and results pages. + +Now after starting the import/export, you will first be redirected to the status page and then, +after the import/export is complete, to the results page. + +.. figure:: _static/images/export-status.png + + A screenshot of Djagno Admin export status page + +-------- +ViewSets +-------- + +There are ``ImportJobViewSet`` and ``ExportJobViewSet`` view sets that make it easy +to implement import/export via API. Just create custom class with ``resource_class`` attribute: + +**api/views.py** + +.. code-block:: python + + from import_export_extensions.api import views as import_export_views + from . import resources + + + class BandImportViewSet(import_export_views.ImportJobViewSet): + resource_class = resources.BandResource + + + class BandExportViewSet(import_export_views.ExportJobViewSet): + resource_class = resources.BandResource + + +**urls.py** + +.. code-block:: python + + from rest_framework.routers import DefaultRouter + from .api import views + + band_import_export_router = DefaultRouter() + band_import_export_router.register( + "import-band", + views.BandImportViewSet, + basename="import-band", + ) + band_import_export_router.register( + "export-band", + views.BandExportViewSet, + basename="export-band", + ) + + urlpatterns = band_import_export_router.urls + + +These view sets provide all methods required for entire import/export workflow: start, details, +confirm, cancel and list actions. There is also `drf-spectacular `_ +integrations, you can see generated openapi UI for these view sets +(``drf-spectacular`` must be installed in your project): + +.. figure:: _static/images/bands-openapi.png + +------- +Filters +------- + +``CeleryResource`` and ``CeleryModelResource`` also support `django-filter `_ +to filter queryset for export. Set ``filterset_class`` attribute to your resource class and pass +filter parameters as ``filter_kwargs`` argument to resource: + + +**filters.py** + +.. code-block:: python + + from django_filters import rest_framework as filters + + from . import models + + + class BandFilterSet(filters.FilterSet): + + class Meta: + model = models.Band + fields = [ + "id", + "title", + ] + + +**resources.py** + +.. code-block:: python + + from import_export_extensions import resources + from . import filters + from . import models + + + class BandResource(resources.CeleryModelResource): + + filterset_class = filters.BandFilterSet + + class Meta: + model = models.Band + fields = ["id", "title"] + +If ``filterset_class`` is set for your resource, you can pass ``filter_kwargs`` to filter export +queryset: + +.. code-block:: python + :linenos: + + >>> from .resources import BandResource + >>> from .models import Band + >>> Band.objects.bulk_create([Band(title=title) for title in "ABC"]) + >>> BandResource().get_queryset().count() + 3 + >>> filter_kwargs = {"title": "A"} + >>> band_resource_with_filters = BandResource(filter_kwargs=filter_kwargs) + >>> band_resource_with_filters.get_queryset().count() + 1 + +Pass ``filter_kwargs`` in ``resource_kwargs`` argument to create ``ExportJob`` with filtered queryset: + +.. code-block:: python + :linenos: + + >>> export_job = ExportJob.objects.create( + resource_path=BandResource.class_path, + file_format_path=file_format_path, + resource_kwargs={"filter_kwargs": filter_kwargs}, + ) + >>> export_job.refresh_from_db() + >>> len(export_job.result) + 1 + +Since we are using the rest framework filter set, ``ExportJobViewSet`` also supports it. It takes +the filter set from ``resource_class``. You can see that ``start`` action expects query parameters +for filtering: + +.. figure:: _static/images/filters-openapi.png + + +------- +Widgets +------- + +This package also provides additional widgets for some types of data. + +FileWidget +__________ + +Working with file fields is a common issue. ``FileWidget`` allows to import/export files +including links to external resources that store files and save them in ``DEFAULT_FILE_STORAGE``. + +This widget loads a file from link to media dir. And it correctly render the link for export. It +also supports ``AWS_STORAGE_BUCKET_NAME`` setting. + + +IntermediateManyToManyWidget +____________________________ + +``IntermediateManyToManyWidget`` allows to import/export objects with related items. +Default M2M widget store just IDs of related objects. With intermediate widget +additional data may be stored. Should be used with ``IntermediateManyToManyField``. + +------ +Fields +------ + +M2MField +________ + +This is resource field for M2M fields. Provides faster import of related fields. + + This implementation deletes intermediate models, which were excluded + and creates intermediate models only for newly added models. + +IntermediateManyToManyField +___________________________ + +This is resource field for M2M with custom ``through`` model. + + By default, ``django-import-export`` set up object attributes using + ``setattr(obj, attribute_name, value)``, where ``value`` is ``QuerySet`` + of related model objects. But django forbid this when ``ManyToManyField`` + used with custom ``through`` model. + + This field expects be used with custom ``IntermediateManyToManyWidget`` widget + that return not simple value, but dict with intermediate model attributes. diff --git a/docs/getting_started.rst b/docs/getting_started.rst index d47abd2..af1dd82 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -2,118 +2,168 @@ Getting started =============== +``django-import-export-extensions`` is based on ``django-import-export`` package so it have similar +workflow and interfaces. If you already worked with original one, +you can check :ref:`Migrate from original django-import-export package` +section to start using background import/export. + +You can also read the `django-import-export documentation `_ +to learn how to work with import and export. + +There are simple examples to quickly get import/export functionality. + +Test app model +-------------- + +Here is simple django model from test app that we gonna use in the examples above. + +.. code-block:: python + + from django.db import models + + + class Band(models.Model): + + title = models.CharField( + max_length=100, + ) + + class Meta: + verbose_name = _("Band") + verbose_name_plural = _("Bands") + + def __str__(self) -> str: + return self.title + Resources --------- -You can create a resource for import/export with celery just like in -the original package. Just use ``CeleryResource`` or ``CeleryModelResource``:: + +The resource class is a core of import/export. This is similar to serializers in DRF, but +provides methods for converting data from a file to objects and vice versa. + +``django-import-export-extensions`` provides ``CeleryResource`` and ``CeleryModelResource`` classes. Here +is an example of simple model resource + +.. code-block:: python from import_export_extensions.resources import CeleryModelResource class BandResource(CeleryModelResource): - """Resource for model `Band`.""" + """Resource for `Band` model.""" class Meta: model = Band fields = [ "id", - "name", + "title", ] -You can also explicitly set fields and widgets to configure resource as -you need:: +This resource already allows you to import/export bands the same as original package. But to start +importing/exporting in background it's necessary to create ``ImportJob``/``ExportJob`` objects. - from import_export_extensions.fields import IntermediateManyToManyField - from import_export_extensions.resources import CeleryModelResource - from import_export_extensions.widgets import IntermediateManyToManyWidget +Resource classes just modified to interact with celery, but workflow is the same. So if you want to +know more, read `Resources `_ and +`Import data workflow `_ +sections of base package documentation. +Job models +---------- - class BandResource(CeleryModelResource): - """Resource for model `Band`.""" - - users = IntermediateManyToManyField( - attribute="users", - column_name="Related users", - widget=IntermediateManyToManyWidget( - rem_model=User, - rem_field="name", - instance_separator=";", - ), - ) +Package provides ``ImportJob``/``ExportJob`` that are core of background import/export. This models +stores parameters and result of import/export. Once you create an instance of the class, +the celery task will be started and the import/export process will begin. - class Meta: - model = Band - fields = [ - "id", - "name", - "users", - ] +Example of creation: + +.. code-block:: python + + from import_export_extensions import models + from . import resources + file_format_path = "import_export.formats.base_formats.CSV" + import_file = "files/import_file.csv" + + # Start import job + import_job = models.ImportJob.objects.create( + resource_path=resources.BandResource.class_path, + data_file=import_file, + resource_kwargs={}, + ) + + # Start export job + export_job = models.ExportJob.objects.create( + resource_path=resources.BandResource.class_path, + file_format_path=file_format_path, + resource_kwargs={} + ) + + print(import_job.import_status, export_job.export_status) # CREATED, CREATED + +These models are also registered in Django Admin, so you can see all information about created +jobs there. Admin models ------------ + To import/export using celery via Django Admin, use ``CeleryImportExportMixin`` -for your admin model and set ``resource_class`` class attribute:: +for your admin model and set ``resource_class`` class attribute + +.. code-block:: python from import_export_extensions.admin import CeleryImportExportMixin + from . import resources + from . import models - @admin.register(Band) + @admin.register(models.Band) class BandAdmin(CeleryImportExportMixin, admin.ModelAdmin): """Admin for `Band` model with import export functionality.""" list_display = ( - "name", - "users", + "title", ) - resource_class = BandResource + resource_classes = [resources.BandResource] + +There are also ``CeleryImportAdminMixin`` and ``CeleryExportAdminMixin`` available if you need +only one operation in admin. All of these mixins add ``status`` page to check the progress of +import/export: + +.. figure:: _static/images/export-status.png + A screenshot of Djagno Admin export status page Import/Export API ----------------- -Import ``api.views.ExportJobViewSet`` and ``api.views.ImportJobViewSet`` -to create appropriate viewsets for the resource:: +``api.views.ExportJobViewSet`` and ``api.views.ImportJobViewSet`` are provided to create appropriate +viewsets for the resource + +.. code-block:: python from import_export_extensions.api import views + from . import resources class BandExportViewSet(views.ExportJobViewSet): """Simple ViewSet for exporting `Band` model.""" - resource_class = BandResource + resource_class = resources.BandResource class BandImportViewSet(views.ImportJobViewSet): """Simple ViewSet for importing `Band` model.""" - resource_class = BandResource - -These viewsets provide endpoints to manage ImportJob/ExportJob objects. -You can create import/export job via ``start`` action, then check progress via -``details``. Set ``filterset_class`` to resource to filter queryset and export -required objects. - -If you have configured ``drf_spectacular``, you'll see that autogenerated -schemes provided correctly. - - -Fields ------- - -If you need to import/export objects that contain ``ManyToManyField`` with -an intermediate model (with ``through``), use ``IntermediateManyToManyField``:: - - class ArtistResourceWithM2M(CeleryModelResource): - """Artist resource with Many2Many field.""" - bands = IntermediateManyToManyField( - attribute="bands", - column_name="Bands he played in", - widget=IntermediateManyToManyWidget( - rem_model=Band, - rem_field="title", - extra_fields=["date_joined"], - instance_separator=";", - ), - ) + resource_class = resources.BandResource - class Meta: - model = Artist - fields = ["id", "name", "bands", "instrument"] +These viewsets provide the following actions to manage ``ImportJob``/``ExportJob`` objects: + +* ``list`` - returns list of jobs for `resource_class` set in ViewSet +* ``retrieve`` - returns details of job for passed ID +* ``start`` - creates job object and starts import/export +* ``cancel`` - stops import/export and set ``CANCELLED`` status for job +* ``confirm`` - confirms importing after parse stage. Only ``ImportJobViewSet`` has this action. + +There is also ``drf_spectacular`` integration so if you have this package configured the openapi +spec will be available. + +.. figure:: _static/images/bands-openapi.png + + A screenshot of a generated openapi spec diff --git a/docs/index.rst b/docs/index.rst index 9cdeadc..62d65d0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,8 +18,9 @@ django-import-export-extensions is a Django application and library based on :caption: User Guide installation - migrate_from_original_import_export getting_started + extensions + migrate_from_original_import_export authors history diff --git a/docs/installation.rst b/docs/installation.rst index 49f775c..4cb0794 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -8,13 +8,13 @@ Installation and configuration Stable release -------------- -To install django-import-export-extensions, run this command in your terminal: +To install ``django-import-export-extensions``, run this command in your terminal: .. code-block:: console $ pip install django-import-export-extensions -This is the preferred method to install django-import-export-extensions, as it will always install the most recent stable release. +This is the preferred method to install ``django-import-export-extensions``, as it will always install the most recent stable release. If you don't have `pip`_ installed, this `Python installation guide`_ can guide you through the process. @@ -22,7 +22,7 @@ you through the process. .. _pip: https://pip.pypa.io .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ -Then just add `import_export` and `import_export_extensions` to INSTALLED_APPS +Then just add ``import_export`` and ``import_export_extensions`` to INSTALLED_APPS .. code-block:: python diff --git a/docs/migrate_from_original_import_export.rst b/docs/migrate_from_original_import_export.rst index 1c0e099..84cbfb5 100644 --- a/docs/migrate_from_original_import_export.rst +++ b/docs/migrate_from_original_import_export.rst @@ -2,10 +2,106 @@ Migrate from original `django-import-export` package ==================================================== -If you want to make import/export via `Celery` and you already used -`django-import-export` package, you can just change base classes: +If you are already using ``django-import-export`` and want to use ``django-import-export-extensions``, +you can easily switch to it and get the benefits of background import/exporting. +First of all, install the package according to :ref:`the instruction`. +And now, all you need is to change base classes of resources and admin models. -Resources ---------- +Migrate resources +----------------- -**TODO** +To use resources that provides import/export via Celery just change base resource classes from +original package to ``CeleryResource`` / ``CeleryModelResource`` from ``django-import-export-extensions``: + +.. code-block:: diff + :emphasize-lines: 2,6,13 + + - from import_export import resources + + from import_export_extensions import resources + + class SimpleResource( + - resources.Resource, + + resources.CeleryResource, + ): + """Simple resource.""" + + + class BookResource( + - resources.ModelResource, + + resources.CeleryModelResource, + ): + """Resource class for `Book` model.""" + + class Meta: + model = Book + +Migrate admin models +-------------------- + +Then you also need to change admin mixins to use celery import/export via Django Admin: + +.. code-block:: diff + :emphasize-lines: 4,10,11 + + from django.contrib import admin + + - from import_export.admin import ImportExportModelAdmin + + from import_export_extensions.admin import CeleryImportExportMixin + + from . import resources + + class BookAdmin( + - ImportExportModelAdmin, + + CeleryImportExportMixin, + + admin.ModelAdmin, + ): + """Resource class for `Book` model.""" + + resource_classes = ( + resources.BookResource, + ) + + +If you only need import (or export) functionality, you can use ``CeleryImportAdminMixin`` +(``CeleryExportAdminMixin``) instead of ``CeleryImportExportMixin``. + +Migrate custom import/export +---------------------------- + +Background import/export is implemented based on ``ImportJob``/``ExportJob`` models. So simple +``resource.export()`` won't trigger a celery task, it works exactly the same as the original +``Resource.export()`` method. To start background import/export, you need to create objects of +import/export job: + +.. code-block:: python + :linenos: + + >>> from .resources import BandResource + >>> from import_export.formats import base_formats + >>> from import_export_extensions.models import ExportJob + >>> file_format = base_formats.CSV + >>> file_format_path = f"{file_format.__module__}.{file_format.__name__}" + >>> export_job = ExportJob.objects.create( + resource_path=BandResource.class_path, + file_format_path=file_format_path + ) + >>> export_job.export_status + 'CREATED' + +Using the ``export_status`` (``import_status``) property of the model, you can check the current status of the job. +There is also a ``progress`` property that returns information about the total number and number of completed rows. + +.. code-block:: python + :linenos: + :emphasize-lines: 2,4 + + >>> export_job.refresh_from_db() + >>> export_job.export_status + 'EXPORTING' + >>> export_job.progress + {'state': 'EXPORTING', 'info': {'current': 53, 'total': 100}} + >>> export_job.refresh_from_db() + >>> export_job.export_status + 'EXPORTED' + >>> export_job.data_file.path + '../media/import_export_extensions/export/3dfb7510-5593-4dc6-9d7d-bbd907cd3eb6/Artists-2020-02-22.csv' diff --git a/import_export_extensions/admin/mixins/export_mixin.py b/import_export_extensions/admin/mixins/export_mixin.py index 356c416..0e42c19 100644 --- a/import_export_extensions/admin/mixins/export_mixin.py +++ b/import_export_extensions/admin/mixins/export_mixin.py @@ -52,6 +52,8 @@ class CeleryExportAdminMixin( import_export_change_list_template = "admin/import_export/change_list_export.html" + import_export_change_list_template = "admin/import_export/change_list_export.html" + # Statuses that should be displayed on 'results' page export_results_statuses = models.ExportJob.export_finished_statuses diff --git a/import_export_extensions/admin/mixins/import_mixin.py b/import_export_extensions/admin/mixins/import_mixin.py index 6cee1f1..835c8f3 100644 --- a/import_export_extensions/admin/mixins/import_mixin.py +++ b/import_export_extensions/admin/mixins/import_mixin.py @@ -72,6 +72,14 @@ class CeleryImportAdminMixin( get_skip_admin_log = base_admin.ImportMixin.get_skip_admin_log has_import_permission = base_admin.ImportMixin.has_import_permission + import_export_change_list_template = "admin/import_export/change_list_import.html" + + skip_admin_log = None + # Copy methods of mixin from original package to reuse it here + generate_log_entries = base_admin.ImportMixin.generate_log_entries + get_skip_admin_log = base_admin.ImportMixin.get_skip_admin_log + has_import_permission = base_admin.ImportMixin.has_import_permission + @property def model_info(self) -> types.ModelInfo: """Get info of imported model.""" diff --git a/import_export_extensions/models/export_job.py b/import_export_extensions/models/export_job.py index 011df37..51b6264 100644 --- a/import_export_extensions/models/export_job.py +++ b/import_export_extensions/models/export_job.py @@ -1,3 +1,4 @@ +import pathlib import traceback import typing import uuid @@ -161,10 +162,10 @@ class Meta: def __str__(self) -> str: """Return string representation.""" - return ( - f"Export job({self.export_status}) <{self.pk}> " - f"using {self.resource_path}(Format {self.file_format_path})" - ) + resource_name = pathlib.Path(self.resource_path).suffix.lstrip(".") + file_format = pathlib.Path(self.file_format_path).suffix.lstrip(".") + + return f"ExportJob(resource={resource_name}, file_format={file_format})" def save( self, diff --git a/import_export_extensions/models/import_job.py b/import_export_extensions/models/import_job.py index 9c56039..7bd5cc0 100644 --- a/import_export_extensions/models/import_job.py +++ b/import_export_extensions/models/import_job.py @@ -1,4 +1,5 @@ import os +import pathlib import traceback import uuid from typing import Optional, Sequence, Type @@ -228,10 +229,9 @@ class Meta: def __str__(self) -> str: """Return string representation.""" - return ( - f"Import job({self.import_status}) " - f"<{self.pk}> using {self.resource_path}" - ) + resource_name = pathlib.Path(self.resource_path).suffix.lstrip(".") + + return f"ImportJob(resource={resource_name})" def save( self, diff --git a/import_export_extensions/resources.py b/import_export_extensions/resources.py index b23c018..cd396bd 100644 --- a/import_export_extensions/resources.py +++ b/import_export_extensions/resources.py @@ -58,7 +58,7 @@ def __init__( super().__init__() def get_queryset(self): - """Filter export queryset via django-import export.""" + """Filter export queryset via filterset class.""" queryset = super().get_queryset() if not self._filter_kwargs: return queryset diff --git a/import_export_extensions/widgets.py b/import_export_extensions/widgets.py index f7c996d..97851ee 100644 --- a/import_export_extensions/widgets.py +++ b/import_export_extensions/widgets.py @@ -13,12 +13,7 @@ from import_export.exceptions import ImportExportError from import_export.widgets import CharWidget, ManyToManyWidget -from .utils import ( - clean_sequence_of_string_values, - download_file, - get_clear_q_filter, - url_to_internal_value, -) +from . import utils DEFAULT_SYSTEM_STORAGE = "django.core.files.storage.FileSystemStorage" @@ -183,7 +178,7 @@ def clean( if self.instance_separator == "\n": value = value.replace("\r", "") - raw_instances = clean_sequence_of_string_values( + raw_instances = utils.clean_sequence_of_string_values( value.split(self.instance_separator), ) @@ -246,7 +241,7 @@ def clean_instance(self, raw_instance: str) -> list[dict[str, typing.Any]]: # i.e. PK of Band # props contain other saved properties of intermediate model # i.e. `date_joined` - rem_field_value, *props = clean_sequence_of_string_values( + rem_field_value, *props = utils.clean_sequence_of_string_values( raw_instance.split(self.prop_separator), ignore_empty=False, ) @@ -271,7 +266,7 @@ def filter_instances(self, rem_field_value: str) -> QuerySet: """Shortcut to filter corresponding instances.""" if self.rem_field_lookup: if self.rem_field_lookup == "regex": - instance_filter = get_clear_q_filter( + instance_filter = utils.get_clear_q_filter( rem_field_value, self.rem_field, ) else: @@ -304,7 +299,7 @@ def clean(self, value: str, *args, **kwargs) -> typing.Optional[str]: if not value: return None - internal_url = url_to_internal_value(urlparse(value).path) + internal_url = utils.url_to_internal_value(urlparse(value).path) if not internal_url: raise ValidationError("Invalid image path") @@ -319,7 +314,7 @@ def clean(self, value: str, *args, **kwargs) -> typing.Optional[str]: def _get_file(self, url: str) -> File: """Download file from the external resource.""" - file = download_file(url) + file = utils.download_file(url) ext = mimetypes.guess_extension(file.content_type) filename = f"{self.filename}.{ext}" if ext else self.filename diff --git a/requirements/development.txt b/requirements/development.txt index 2c8929b..f6df4ab 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -57,6 +57,8 @@ factory-boy # Install drf for test app djangorestframework +# Install drf-spectacular for test app +drf-spectacular # Install for better django shell django-extensions diff --git a/tests/settings.py b/tests/settings.py index e79bf5f..8ce322c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -22,6 +22,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", + "drf_spectacular", "django_probes", "django_extensions", "import_export", @@ -106,6 +107,11 @@ "django.contrib.staticfiles.finders.AppDirectoriesFinder", ) +# Configure `drf-spectacular` to check it works for import-export API +REST_FRAMEWORK = { + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + # Don't use celery when you're local CELERY_TASK_ALWAYS_EAGER = True diff --git a/tests/urls.py b/tests/urls.py index 78468e1..91c5e7c 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -3,10 +3,12 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin -from django.urls import re_path +from django.urls import path, re_path from rest_framework.routers import DefaultRouter +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + from .fake_app.api import views ie_router = DefaultRouter() @@ -37,3 +39,15 @@ settings.STATIC_URL, document_root=settings.STATIC_ROOT, ) + urlpatterns += [ + path( + "api/schema/", + SpectacularAPIView.as_view(), + name="schema", + ), + path( + "api/schema/swagger-ui/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + ]