From 7905d2e53be1c3f3c67c0bdb9bb8352dd6952065 Mon Sep 17 00:00:00 2001 From: Alexandra Bruckner Date: Fri, 21 Oct 2022 12:16:21 +0200 Subject: [PATCH 1/3] move to 'anexia' organization --- CHANGELOG.md | 4 ++-- README.md | 22 +++++++++++++++++++++- requirements.txt | 4 +++- setup.py | 2 +- tests/core/settings.py | 2 +- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f672aac..21dcecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,5 +6,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -[Unreleased]: https://github.com/anexia-it/drf-attachments/compare/1.0.0...HEAD -[1.0.0]: https://github.com/anexia-it/drf-attachments/releases/tag/1.0.0 +[Unreleased]: https://github.com/anexia/drf-attachments/compare/1.0.0...HEAD +[1.0.0]: https://github.com/anexia/drf-attachments/releases/tag/1.0.0 diff --git a/README.md b/README.md index 43e07de..deca5c2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ If used with DRF, `django-filter` is an additional requirement. 1. Install using pip: ```shell -pip install git+https://github.com/anexia-it/drf-attachments@main +pip install git+https://github.com/anexia/drf-attachments@main ``` 2. Integrate `drf_attachments` and `django_userforeignkey` into your `settings.py` @@ -284,6 +284,26 @@ python manage.py runserver # Admin Panel: http://localhost:8000/admin ``` +## Unit Tests + +See folder [tests/](tests/). Basically, all endpoints are covered with multiple +unit tests. + +Follow below instructions to run the tests. +You may exchange the installed Django and DRF versions according to your requirements. +:warning: Depending on your local environment settings you might need to explicitly call `python3` instead of `python`. +```bash +# install dependencies +python -m pip install --upgrade pip +pip install -r requirements.txt + +# setup environment +pip install -e . + +# run tests +cd tests && python manage.py test +``` + ## ToDos * Simplify configuration (maybe a default configuration class that can be subclasses for customizations?) diff --git a/requirements.txt b/requirements.txt index 7da2d00..d51091d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,10 +4,12 @@ # Development dependencies # TestApp dependencies - django>=3.2,<4 djangorestframework>=3.13,<4 python-magic>=0.4.18 rest-framework-generic-relations>=2.0.0 django-userforeignkey>=0.4 django-filter>=21.1,<22 + +# fix importlib version to avoid "AttributeError: 'EntryPoints' object has no attribute 'get'" with flake8 +importlib-metadata<5.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 21d3185..cc5c45a 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ description='A django module to manage any model\'s file up-/downloads by relating an Attachment model to it.', long_description=README, long_description_content_type='text/markdown', - url='https://github.com/anexia-it/drf-attachments', + url='https://github.com/anexia/drf-attachments', author='Alexandra Bruckner', author_email='abruckner@anexia-it.com', install_requires=[ diff --git a/tests/core/settings.py b/tests/core/settings.py index 84fd25d..d25f1be 100644 --- a/tests/core/settings.py +++ b/tests/core/settings.py @@ -1,6 +1,6 @@ """ Django settings for "tests" project which assures the functionality of the "drf-attachments" package -(https://github.com/anexia-it/drf-attachments). +(https://github.com/anexia/drf-attachments). """ import os From 5cad68758a0132bab0b27c3aeba7c62786b2a3e4 Mon Sep 17 00:00:00 2001 From: Alexandra Bruckner Date: Fri, 21 Oct 2022 12:18:30 +0200 Subject: [PATCH 2/3] add isort/black linting --- .pre-commit-config.yaml | 12 +++ README.md | 11 +++ drf_attachments/admin.py | 4 +- drf_attachments/config.py | 22 ++--- drf_attachments/migrations/0001_initial.py | 87 ++++++++++++----- .../0002_attachment_object_id_types.py | 6 +- drf_attachments/models/models.py | 66 +++++++++---- drf_attachments/models/querysets.py | 3 +- drf_attachments/rest/renderers.py | 2 +- drf_attachments/rest/serializers.py | 4 +- drf_attachments/rest/views.py | 12 ++- drf_attachments/utils.py | 3 +- requirements.txt | 5 + setup.py | 48 +++++----- tests/core/settings.py | 95 ++++++++++--------- tests/core/urls.py | 20 ++-- tests/core/wsgi.py | 2 +- tests/testapp/admin.py | 2 +- tests/testapp/apps.py | 2 +- tests/testapp/attachments.py | 7 +- tests/testapp/migrations/0001_initial.py | 31 ++++-- tests/testapp/models.py | 4 + tests/testapp/serializers.py | 22 ++++- tests/testapp/tests/demo_files.py | 2 +- tests/testapp/tests/test_api.py | 52 ++++++---- tests/testapp/tests/test_setup.py | 6 +- tests/testapp/views.py | 7 +- 27 files changed, 347 insertions(+), 190 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a884998 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +repos: + - repo: https://github.com/pycqa/isort + rev: 5.10.1 + hooks: + - id: isort + args: [ "--profile", "black", "--filter-files" ] + + - repo: https://github.com/psf/black + rev: 22.6.0 # Replace by any tag/version: https://github.com/psf/black/tags + hooks: + - id: black + language_version: python3 # Should be a command that runs python3.6.2+ diff --git a/README.md b/README.md index deca5c2..5eb2e11 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,17 @@ def attachment_context_translations(): } ``` +### Auto-formatter setup +We use isort (https://github.com/pycqa/isort) and black (https://github.com/psf/black) for local auto-formatting and for linting in the CI pipeline. +The pre-commit framework (https://pre-commit.com) provides GIT hooks for these tools, so they are automatically applied before every commit. + +Steps to activate: +* Install the pre-commit framework: `pip install pre-commit` (for alternative installation options see https://pre-commit.com/#install) +* Activate the framework (from the root directory of the repository): `pre-commit install` + +Hint: You can also run the formatters manually at any time with the following command: `pre-commit run --all-files` + + ## Usage Attachments accept any other Model as content_object and store the uploaded files in their respective directories diff --git a/drf_attachments/admin.py b/drf_attachments/admin.py index e1be1e0..8d1ef64 100644 --- a/drf_attachments/admin.py +++ b/drf_attachments/admin.py @@ -64,7 +64,9 @@ def content_object(obj): app_label = entity._meta.app_label model_name = entity._meta.model_name try: - admin_url = reverse(f"admin:{app_label}_{model_name}_change", args=(entity.pk,)) + admin_url = reverse( + f"admin:{app_label}_{model_name}_change", args=(entity.pk,) + ) return mark_safe(f'{entity}') except NoReverseMatch: return entity diff --git a/drf_attachments/config.py b/drf_attachments/config.py index 3f82840..7958c58 100644 --- a/drf_attachments/config.py +++ b/drf_attachments/config.py @@ -32,7 +32,7 @@ def get_callable(cls, setting_key) -> Optional[Callable]: if not setting: return None - module_name, callable_name = setting.rsplit('.', maxsplit=1) + module_name, callable_name = setting.rsplit(".", maxsplit=1) module = importlib.import_module(module_name) return getattr(module, callable_name) @@ -51,7 +51,10 @@ def get_content_object_field(cls) -> GenericRelatedField: @classmethod def context_choices( - cls, include_default=True, values_list=True, translated=True, + cls, + include_default=True, + values_list=True, + translated=True, ) -> Union[List[str], Tuple[Tuple[Any, Any]]]: """ Extract all unique context definitions from settings "ATTACHMENT_CONTEXT_*" + "ATTACHMENT_DEFAULT_CONTEXT" @@ -73,21 +76,16 @@ def context_choices( def get_contexts(cls, include_default) -> Set[str]: settings_keys = dir(settings) return { - getattr(settings, key) for key in settings_keys if cls.__is_context_setting(key, include_default) + getattr(settings, key) + for key in settings_keys + if cls.__is_context_setting(key, include_default) } @staticmethod def __is_context_setting(key, include_default) -> bool: return ( - ( - key.startswith("ATTACHMENT_CONTEXT_") - and not key.endswith("_CALLABLE") - ) - or ( - include_default - and key == DEFAULT_CONTEXT_SETTING - ) - ) + key.startswith("ATTACHMENT_CONTEXT_") and not key.endswith("_CALLABLE") + ) or (include_default and key == DEFAULT_CONTEXT_SETTING) @classmethod def translate_context(cls, context): diff --git a/drf_attachments/migrations/0001_initial.py b/drf_attachments/migrations/0001_initial.py index a8c008d..4f66a91 100644 --- a/drf_attachments/migrations/0001_initial.py +++ b/drf_attachments/migrations/0001_initial.py @@ -12,36 +12,77 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), + ("contenttypes", "0002_remove_content_type_name"), ] operations = [ migrations.CreateModel( - name='Attachment', + name="Attachment", fields=[ - ('id', - models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, - verbose_name='Attachment ID')), - ('name', models.CharField(blank=True, max_length=255, verbose_name='name')), - ('context', - models.CharField(blank=True, help_text="Additional info about the attachment's context/meaning.", - max_length=255, verbose_name='context')), - ('meta', django.db.models.JSONField( - help_text='Additional info about the attachment (e.g. file meta data: mime_type, extension, size).', - verbose_name='meta')), - ('file', - models.FileField(storage=storage.AttachmentFileStorage(), upload_to=storage.attachment_upload_path, - verbose_name='file')), - ('object_id', models.UUIDField()), - ('creation_date', models.DateTimeField(auto_now_add=True, verbose_name='Creation date')), - ('last_modification_date', models.DateTimeField(auto_now=True, verbose_name='Last modification date')), - ('content_type', - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + verbose_name="Attachment ID", + ), + ), + ( + "name", + models.CharField(blank=True, max_length=255, verbose_name="name"), + ), + ( + "context", + models.CharField( + blank=True, + help_text="Additional info about the attachment's context/meaning.", + max_length=255, + verbose_name="context", + ), + ), + ( + "meta", + django.db.models.JSONField( + help_text="Additional info about the attachment (e.g. file meta data: mime_type, extension, size).", + verbose_name="meta", + ), + ), + ( + "file", + models.FileField( + storage=storage.AttachmentFileStorage(), + upload_to=storage.attachment_upload_path, + verbose_name="file", + ), + ), + ("object_id", models.UUIDField()), + ( + "creation_date", + models.DateTimeField( + auto_now_add=True, verbose_name="Creation date" + ), + ), + ( + "last_modification_date", + models.DateTimeField( + auto_now=True, verbose_name="Last modification date" + ), + ), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), ], options={ - 'verbose_name': 'attachment', - 'verbose_name_plural': 'attachments', - 'ordering': ('creation_date',), + "verbose_name": "attachment", + "verbose_name_plural": "attachments", + "ordering": ("creation_date",), }, ), ] diff --git a/drf_attachments/migrations/0002_attachment_object_id_types.py b/drf_attachments/migrations/0002_attachment_object_id_types.py index 55a67ba..e22885c 100644 --- a/drf_attachments/migrations/0002_attachment_object_id_types.py +++ b/drf_attachments/migrations/0002_attachment_object_id_types.py @@ -6,13 +6,13 @@ class Migration(migrations.Migration): dependencies = [ - ('drf_attachments', '0001_initial'), + ("drf_attachments", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='attachment', - name='object_id', + model_name="attachment", + name="object_id", field=models.CharField(db_index=True, max_length=64), ), ] diff --git a/drf_attachments/models/models.py b/drf_attachments/models/models.py index beee6bf..db84cc1 100644 --- a/drf_attachments/models/models.py +++ b/drf_attachments/models/models.py @@ -3,8 +3,16 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.db.models import CASCADE, CharField, DateTimeField, FileField, ForeignKey, Model, UUIDField -from django.db.models import JSONField +from django.db.models import ( + CASCADE, + CharField, + DateTimeField, + FileField, + ForeignKey, + JSONField, + Model, + UUIDField, +) from django.utils.translation import gettext_lazy as _ from django_userforeignkey.request import get_current_request from rest_framework.exceptions import ValidationError @@ -28,7 +36,7 @@ class Attachment(Model): objects = AttachmentManager() id = UUIDField( - _('Attachment ID'), + _("Attachment ID"), default=uuid.uuid4, editable=False, unique=True, @@ -50,7 +58,9 @@ class Attachment(Model): meta = JSONField( _("meta"), - help_text=_("Additional info about the attachment (e.g. file meta data: mime_type, extension, size)."), + help_text=_( + "Additional info about the attachment (e.g. file meta data: mime_type, extension, size)." + ), blank=False, null=False, ) @@ -96,7 +106,11 @@ def __str__(self): @property def is_image(self): - return 'mime_type' in self.meta and self.meta['mime_type'] and self.meta['mime_type'].startswith("image") + return ( + "mime_type" in self.meta + and self.meta["mime_type"] + and self.meta["mime_type"].startswith("image") + ) @property def download_url(self): @@ -126,13 +140,13 @@ def is_modified(self): return self.creation_date != self.last_modification_date def get_extension(self): - return self.meta.get('extension') + return self.meta.get("extension") def get_size(self): - return self.meta.get('size') + return self.meta.get("size") def get_mime_type(self): - return self.meta.get('mime_type') + return self.meta.get("mime_type") def save(self, *args, **kwargs): # set computed values for direct and API access @@ -152,8 +166,8 @@ def set_and_validate(self): self.cleanup_file() # remove the old file of a changed Attachment def set_default_context(self): - """ Set context to settings.ATTACHMENT_DEFAULT_CONTEXT (if defined) if it's still empty """ - if not self.context and hasattr(settings, 'ATTACHMENT_DEFAULT_CONTEXT'): + """Set context to settings.ATTACHMENT_DEFAULT_CONTEXT (if defined) if it's still empty""" + if not self.context and hasattr(settings, "ATTACHMENT_DEFAULT_CONTEXT"): self.context = self.default_context def set_attachment_meta(self): @@ -166,14 +180,16 @@ def set_attachment_meta(self): int(settings.ATTACHMENT_MAX_UPLOAD_SIZE), ) self.unique_upload = getattr(meta, "unique_upload", False) - self.unique_upload_per_context = getattr(meta, "unique_upload_per_context", False) + self.unique_upload_per_context = getattr( + meta, "unique_upload_per_context", False + ) def set_file_meta(self): if self.meta is None: self.meta = {} - self.meta['mime_type'] = get_mime_type(self.file) - self.meta['extension'] = get_extension(self.file) - self.meta['size'] = self.file.size + self.meta["mime_type"] = get_mime_type(self.file) + self.meta["extension"] = get_extension(self.file) + self.meta["size"] = self.file.size def validate_context(self): """ @@ -206,11 +222,14 @@ def _validate_file_mime_type(self): Validate the mime_type against the AttachmentMeta.valid_mime_types defined in the content_object's model class. Raise a ValidationError on failure. """ - if self.valid_mime_types and self.meta['mime_type'] not in self.valid_mime_types: + if ( + self.valid_mime_types + and self.meta["mime_type"] not in self.valid_mime_types + ): error_msg = _( "Invalid mime type {mime_type} detected! It must be one of the following: {valid_mime_types}" ).format( - mime_type=self.meta['mime_type'], + mime_type=self.meta["mime_type"], valid_mime_types=", ".join(self.valid_mime_types), ) raise ValidationError( @@ -225,11 +244,14 @@ def _validate_file_extension(self): Validate the extension against the AttachmentMeta.valid_extensions defined in the content_object's model class. Raise a ValidationError on failure. """ - if self.valid_extensions and self.meta['extension'] not in self.valid_extensions: + if ( + self.valid_extensions + and self.meta["extension"] not in self.valid_extensions + ): error_msg = _( "Invalid extension {extension} detected! It must be one of the following: {valid_extensions}" ).format( - extension=self.meta['extension'], + extension=self.meta["extension"], valid_extensions=", ".join(self.valid_extensions), ) raise ValidationError( @@ -247,7 +269,9 @@ def _validate_file_size(self): Validate the extension and raise a ValidationError on failure. """ if self.min_size and self.file.size < self.min_size: - error_msg = _("File size {size} too small! It must be at least {min_size}").format( + error_msg = _( + "File size {size} too small! It must be at least {min_size}" + ).format( size=self.file.size, min_size=self.min_size, ) @@ -260,7 +284,9 @@ def _validate_file_size(self): # self.max_size is always given (settings.ATTACHMENT_MAX_UPLOAD_SIZE by default and as maximum) if self.file.size > self.max_size: - error_msg = _("File size {size} too large! It can only be {max_size}").format( + error_msg = _( + "File size {size} too large! It can only be {max_size}" + ).format( size=self.file.size, max_size=self.max_size, ) diff --git a/drf_attachments/models/querysets.py b/drf_attachments/models/querysets.py index 24ed8d2..425148d 100644 --- a/drf_attachments/models/querysets.py +++ b/drf_attachments/models/querysets.py @@ -10,7 +10,6 @@ class AttachmentQuerySet(QuerySet): - def viewable(self, *args, **kwargs): callable_ = config.get_filter_callable_for_viewable_content_objects() return self.__filter_by_callable(callable_) @@ -25,7 +24,7 @@ def deletable(self, *args, **kwargs): def delete(self): """Bulk remove files after related Attachments were deleted""" - files = list(self.values_list('file', flat=True)) + files = list(self.values_list("file", flat=True)) result = super().delete() # remove all files that belonged to the deleted attachments diff --git a/drf_attachments/rest/renderers.py b/drf_attachments/rest/renderers.py index 12e34ac..32f5a12 100644 --- a/drf_attachments/rest/renderers.py +++ b/drf_attachments/rest/renderers.py @@ -6,7 +6,7 @@ class FileDownloadRenderer(BaseRenderer): - """ Return data as download/attachment. """ + """Return data as download/attachment.""" media_type = "application/octet-stream" format = "binary" diff --git a/drf_attachments/rest/serializers.py b/drf_attachments/rest/serializers.py index e851eef..c4f14f6 100644 --- a/drf_attachments/rest/serializers.py +++ b/drf_attachments/rest/serializers.py @@ -35,14 +35,14 @@ class Meta: class AttachmentSubSerializer(serializers.ModelSerializer): - """ Sub serializer for nested data inside other serializers """ + """Sub serializer for nested data inside other serializers""" # pk is read-only by default download_url = ReadOnlyField() name = ReadOnlyField() context = ChoiceField( choices=config.context_choices(include_default=False, values_list=False), - read_only=True + read_only=True, ) class Meta: diff --git a/drf_attachments/rest/views.py b/drf_attachments/rest/views.py index 15e981a..d9933f6 100644 --- a/drf_attachments/rest/views.py +++ b/drf_attachments/rest/views.py @@ -19,7 +19,7 @@ @parser_classes([MultiPartParser]) class AttachmentViewSet(viewsets.ModelViewSet): - """ Manages any attachments according to their respective content_object. """ + """Manages any attachments according to their respective content_object.""" queryset = Attachment.objects.none() filter_backends = ( @@ -31,15 +31,19 @@ class AttachmentViewSet(viewsets.ModelViewSet): permission_classes = (IsAuthenticated,) def get_serializer(self, *args, **kwargs): - many = kwargs.pop('many', isinstance(kwargs.get('data'), (list, tuple))) + many = kwargs.pop("many", isinstance(kwargs.get("data"), (list, tuple))) return super().get_serializer(*args, many=many, **kwargs) def get_queryset(self): return Attachment.objects.viewable() - @action(detail=True, methods=["GET"], renderer_classes=[JSONRenderer, FileDownloadRenderer]) + @action( + detail=True, + methods=["GET"], + renderer_classes=[JSONRenderer, FileDownloadRenderer], + ) def download(self, request, format=None, *args, **kwargs): - """ Downloads the uploaded attachment file. """ + """Downloads the uploaded attachment file.""" attachment = self.get_object() extension = attachment.get_extension() diff --git a/drf_attachments/utils.py b/drf_attachments/utils.py index 247ea51..bc54ece 100644 --- a/drf_attachments/utils.py +++ b/drf_attachments/utils.py @@ -1,6 +1,7 @@ -import magic import os +import magic + def get_mime_type(file): """ diff --git a/requirements.txt b/requirements.txt index d51091d..08181b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,11 @@ # Development dependencies +# Linters and formatters +pre-commit>=2.20,<2.21 +isort>=5.10,<5.11 +black>=22.6.0,<22.7 + # TestApp dependencies django>=3.2,<4 djangorestframework>=3.13,<4 diff --git a/setup.py b/setup.py index cc5c45a..21cc231 100644 --- a/setup.py +++ b/setup.py @@ -2,41 +2,41 @@ from setuptools import find_packages, setup -with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme: +with open(os.path.join(os.path.dirname(__file__), "README.md")) as readme: README = readme.read() # allow setup.py to be run from any path os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( - name='drf-attachments', - version=os.getenv('PACKAGE_VERSION', '0.2.0').replace('refs/tags/', ''), + name="drf-attachments", + version=os.getenv("PACKAGE_VERSION", "0.2.0").replace("refs/tags/", ""), packages=find_packages(), include_package_data=True, - license='MIT License', - description='A django module to manage any model\'s file up-/downloads by relating an Attachment model to it.', + license="MIT License", + description="A django module to manage any model's file up-/downloads by relating an Attachment model to it.", long_description=README, - long_description_content_type='text/markdown', - url='https://github.com/anexia/drf-attachments', - author='Alexandra Bruckner', - author_email='abruckner@anexia-it.com', + long_description_content_type="text/markdown", + url="https://github.com/anexia/drf-attachments", + author="Alexandra Bruckner", + author_email="abruckner@anexia-it.com", install_requires=[ - 'python-magic>=0.4.18', - 'rest-framework-generic-relations>=2.0.0', - 'django-userforeignkey>=0.4', + "python-magic>=0.4.18", + "rest-framework-generic-relations>=2.0.0", + "django-userforeignkey>=0.4", ], classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Framework :: Django', - 'Framework :: Django :: 3.2', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', + "Development Status :: 5 - Production/Stable", + "Framework :: Django", + "Framework :: Django :: 3.2", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", ], ) diff --git a/tests/core/settings.py b/tests/core/settings.py index d25f1be..bca64f5 100644 --- a/tests/core/settings.py +++ b/tests/core/settings.py @@ -9,7 +9,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = '-ca82+nb!ay6-gq=6t_cps(*9tlp+t#sd%trg3avne!8v4x9#b' +SECRET_KEY = "-ca82+nb!ay6-gq=6t_cps(*9tlp+t#sd%trg3avne!8v4x9#b" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -19,57 +19,56 @@ # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - 'rest_framework', - 'django_userforeignkey', - 'drf_attachments', - 'testapp', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "django_userforeignkey", + "drf_attachments", + "testapp", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'django_userforeignkey.middleware.UserForeignKeyMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django_userforeignkey.middleware.UserForeignKeyMiddleware", ] -ROOT_URLCONF = 'core.urls' +ROOT_URLCONF = "core.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'core.wsgi.application' +WSGI_APPLICATION = "core.wsgi.application" # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), } } @@ -78,25 +77,25 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -107,16 +106,20 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" # Attachments Configuration -ATTACHMENT_DEFAULT_CONTEXT = 'ATTACHMENT' -ATTACHMENT_CONTEXT_VACATION_PHOTO = 'VACATION_PHOTO' -ATTACHMENT_CONTEXT_WORK_PHOTO = 'WORK_PHOTO' +ATTACHMENT_DEFAULT_CONTEXT = "ATTACHMENT" +ATTACHMENT_CONTEXT_VACATION_PHOTO = "VACATION_PHOTO" +ATTACHMENT_CONTEXT_WORK_PHOTO = "WORK_PHOTO" -ATTACHMENT_CONTENT_OBJECT_FIELD_CALLABLE = "testapp.attachments.attachment_content_object_field" -ATTACHMENT_CONTEXT_TRANSLATIONS_CALLABLE = "testapp.attachments.attachment_context_translations" +ATTACHMENT_CONTENT_OBJECT_FIELD_CALLABLE = ( + "testapp.attachments.attachment_content_object_field" +) +ATTACHMENT_CONTEXT_TRANSLATIONS_CALLABLE = ( + "testapp.attachments.attachment_context_translations" +) ATTACHMENT_MAX_UPLOAD_SIZE = 1024 * 1024 * 25 diff --git a/tests/core/urls.py b/tests/core/urls.py index 3cc319d..b56fb23 100644 --- a/tests/core/urls.py +++ b/tests/core/urls.py @@ -1,18 +1,20 @@ from django.contrib import admin from django.urls import include, path from rest_framework import routers +from testapp.views import ( + DiagramViewSet, + FileViewSet, + PhotoAlbumViewSet, + ThumbnailViewSet, +) from drf_attachments.rest.views import AttachmentViewSet -from testapp.views import DiagramViewSet, FileViewSet, PhotoAlbumViewSet, ThumbnailViewSet router = routers.DefaultRouter() -router.register(r'photo_album', PhotoAlbumViewSet) -router.register(r'thumbnail', ThumbnailViewSet) -router.register(r'diagram', DiagramViewSet) -router.register(r'file', FileViewSet) +router.register(r"photo_album", PhotoAlbumViewSet) +router.register(r"thumbnail", ThumbnailViewSet) +router.register(r"diagram", DiagramViewSet) +router.register(r"file", FileViewSet) router.register(r"attachment", AttachmentViewSet) -urlpatterns = [ - path('admin/', admin.site.urls), - path('api/', include(router.urls)) -] +urlpatterns = [path("admin/", admin.site.urls), path("api/", include(router.urls))] diff --git a/tests/core/wsgi.py b/tests/core/wsgi.py index caf2b11..6529b36 100644 --- a/tests/core/wsgi.py +++ b/tests/core/wsgi.py @@ -9,6 +9,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") application = get_wsgi_application() diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py index 7eedc8e..47b6dab 100644 --- a/tests/testapp/admin.py +++ b/tests/testapp/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin +from testapp.models import Diagram, File, PhotoAlbum, Thumbnail from drf_attachments.admin import AttachmentInlineAdmin -from testapp.models import Diagram, File, PhotoAlbum, Thumbnail @admin.register(PhotoAlbum) diff --git a/tests/testapp/apps.py b/tests/testapp/apps.py index 9806af7..4c407e7 100644 --- a/tests/testapp/apps.py +++ b/tests/testapp/apps.py @@ -2,4 +2,4 @@ class TestappConfig(AppConfig): - name = 'testapp' + name = "testapp" diff --git a/tests/testapp/attachments.py b/tests/testapp/attachments.py index e7c868c..b2802a7 100644 --- a/tests/testapp/attachments.py +++ b/tests/testapp/attachments.py @@ -2,7 +2,6 @@ from django.utils.translation import gettext_lazy as _ from generic_relations.relations import GenericRelatedField from rest_framework import serializers - from testapp.models import Diagram, File, PhotoAlbum, Thumbnail @@ -24,9 +23,11 @@ def attachment_content_object_field(): File: serializers.HyperlinkedRelatedField( queryset=File.objects.all(), view_name="file-detail", - ) + ), }, - help_text=_("Unambiguous URL to a single resource (e.g. /api/v1//1/)."), + help_text=_( + "Unambiguous URL to a single resource (e.g. /api/v1//1/)." + ), ) diff --git a/tests/testapp/migrations/0001_initial.py b/tests/testapp/migrations/0001_initial.py index e4bee05..8b1d244 100644 --- a/tests/testapp/migrations/0001_initial.py +++ b/tests/testapp/migrations/0001_initial.py @@ -7,32 +7,43 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Diagram', + name="Diagram", fields=[ - ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), + ( + "name", + models.CharField(max_length=50, primary_key=True, serialize=False), + ), ], ), migrations.CreateModel( - name='File', + name="File", fields=[ - ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), + ( + "name", + models.CharField(max_length=50, primary_key=True, serialize=False), + ), ], ), migrations.CreateModel( - name='PhotoAlbum', + name="PhotoAlbum", fields=[ - ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), + ( + "name", + models.CharField(max_length=50, primary_key=True, serialize=False), + ), ], ), migrations.CreateModel( - name='Thumbnail', + name="Thumbnail", fields=[ - ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), + ( + "name", + models.CharField(max_length=50, primary_key=True, serialize=False), + ), ], ), ] diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 3b283ca..f00f8c4 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -7,6 +7,7 @@ class PhotoAlbum(models.Model): """ Photo album with any number of JPEG photos and PDF scans as attachments. """ + name = models.CharField(max_length=50, primary_key=True) attachments = AttachmentRelation() @@ -19,6 +20,7 @@ class Thumbnail(models.Model): """ Thumbnail collection with one JPEG image per context as attachments. """ + name = models.CharField(max_length=50, primary_key=True) attachments = AttachmentRelation() @@ -32,6 +34,7 @@ class Diagram(models.Model): """ Single diagram with an SVG file as attachment. """ + name = models.CharField(max_length=50, primary_key=True) attachments = AttachmentRelation() @@ -45,6 +48,7 @@ class File(models.Model): """ Single file in arbitrary format with constrained file size. """ + name = models.CharField(max_length=50, primary_key=True) attachments = AttachmentRelation() diff --git a/tests/testapp/serializers.py b/tests/testapp/serializers.py index 92a3997..e53006b 100644 --- a/tests/testapp/serializers.py +++ b/tests/testapp/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers +from testapp.models import Diagram, File, PhotoAlbum, Thumbnail from drf_attachments.rest.serializers import AttachmentSubSerializer -from testapp.models import Diagram, File, PhotoAlbum, Thumbnail class PhotoAlbumSerializer(serializers.ModelSerializer): @@ -9,7 +9,10 @@ class PhotoAlbumSerializer(serializers.ModelSerializer): class Meta: model = PhotoAlbum - fields = ['name', 'attachments', ] + fields = [ + "name", + "attachments", + ] class ThumbnailSerializer(serializers.ModelSerializer): @@ -17,7 +20,10 @@ class ThumbnailSerializer(serializers.ModelSerializer): class Meta: model = Thumbnail - fields = ['name', 'attachments', ] + fields = [ + "name", + "attachments", + ] class DiagramSerializer(serializers.ModelSerializer): @@ -25,7 +31,10 @@ class DiagramSerializer(serializers.ModelSerializer): class Meta: model = Diagram - fields = ['name', 'attachments', ] + fields = [ + "name", + "attachments", + ] class FileSerializer(serializers.ModelSerializer): @@ -33,4 +42,7 @@ class FileSerializer(serializers.ModelSerializer): class Meta: model = File - fields = ['name', 'attachments', ] + fields = [ + "name", + "attachments", + ] diff --git a/tests/testapp/tests/demo_files.py b/tests/testapp/tests/demo_files.py index 021c232..52b2f43 100644 --- a/tests/testapp/tests/demo_files.py +++ b/tests/testapp/tests/demo_files.py @@ -18,7 +18,7 @@ def __init__(self, file_name, as_django_file=False): def __enter__(self): file_path = os.path.join(self.DIRECTORY, self.file_name) - self.file = open(file_path, 'rb') + self.file = open(file_path, "rb") if self.as_django_file: self.file = File(self.file) return self.file diff --git a/tests/testapp/tests/test_api.py b/tests/testapp/tests/test_api.py index 7dcc4de..162ef68 100644 --- a/tests/testapp/tests/test_api.py +++ b/tests/testapp/tests/test_api.py @@ -3,16 +3,21 @@ from django.conf import settings from django.contrib.auth.models import User from django.test import TestCase, override_settings -from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST - -from drf_attachments.models import Attachment +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, +) from testapp.models import Diagram, File, PhotoAlbum, Thumbnail from testapp.tests.demo_files import DemoFile +from drf_attachments.models import Attachment # TODO: Test viewable/editable/deletable configuration # TODO: Test context translations + class TestApi(TestCase): def setUp(self): super().setUp() @@ -40,16 +45,20 @@ def test_get_all_attachments(self): ) # GET all attachments - response = self.client.get(path=f'/api/attachment/') + response = self.client.get(path=f"/api/attachment/") self.assertEqual(HTTP_200_OK, response.status_code, response.content) # check response response_data = response.json() self.assertEqual(2, len(response_data)) self.assertEqual("attach1", response_data[0]["name"]) - self.assertEqual(settings.ATTACHMENT_CONTEXT_WORK_PHOTO, response_data[0]["context"]) + self.assertEqual( + settings.ATTACHMENT_CONTEXT_WORK_PHOTO, response_data[0]["context"] + ) self.assertEqual("attach2", response_data[1]["name"]) - self.assertEqual(settings.ATTACHMENT_DEFAULT_CONTEXT, response_data[1]["context"]) + self.assertEqual( + settings.ATTACHMENT_DEFAULT_CONTEXT, response_data[1]["context"] + ) def test_get_attachments_of_entity(self): # prepare data @@ -67,7 +76,7 @@ def test_get_attachments_of_entity(self): ) # GET all attachments of PhotoAlbum1 - response = self.client.get(path=f'/api/photo_album/{self.photo_album.pk}/') + response = self.client.get(path=f"/api/photo_album/{self.photo_album.pk}/") self.assertEqual(HTTP_200_OK, response.status_code, response.content) # check response @@ -88,12 +97,12 @@ def test_download(self): ) # get attachment from main serializer - response = self.client.get(path=f'/api/attachment/') + response = self.client.get(path=f"/api/attachment/") attachment_response = response.json()[0] # TODO: download_url is not provided by main serializer. intentional? # get attachment from sub-serializer - response = self.client.get(path=f'/api/photo_album/{self.photo_album.pk}/') + response = self.client.get(path=f"/api/photo_album/{self.photo_album.pk}/") attachment_response = response.json()["attachments"][0] download_url = attachment_response["download_url"] self.assertIsNotNone(download_url) @@ -105,7 +114,7 @@ def test_download(self): # check response self.assertEqual( f'attachment; filename="{attachment.name}{attachment.get_extension()}"', - response.get("Content-Disposition") + response.get("Content-Disposition"), ) # check content @@ -194,8 +203,13 @@ def test_multi_attachment_upload(self): # check attachment model attachments = Attachment.objects.all() self.assertEqual(2, attachments.count()) - self.assertSetEqual({"My First Attachment", "My Second Attachment"}, {att.name for att in attachments}) - self.assertSetEqual({self.photo_album.pk}, {att.object_id for att in attachments}) + self.assertSetEqual( + {"My First Attachment", "My Second Attachment"}, + {att.name for att in attachments}, + ) + self.assertSetEqual( + {self.photo_album.pk}, {att.object_id for att in attachments} + ) # check photo album model photo_album_attachments = self.photo_album.attachments.all() @@ -296,7 +310,9 @@ def test_unique_per_context_upload(self): # check attachment model attachments = Attachment.objects.all() self.assertEqual(2, attachments.count()) - self.assertSetEqual({"Second Work Photo", "Vacation Photo"}, {att.name for att in attachments}) + self.assertSetEqual( + {"Second Work Photo", "Vacation Photo"}, {att.name for att in attachments} + ) self.assertSetEqual({self.thumbnail.pk}, {att.object_id for att in attachments}) # check diagram model @@ -380,10 +396,12 @@ def test_max_upload_size_from_settings_is_enforced(self): # check that the attachment was not created self.assertEqual(0, len(Attachment.objects.all())) - def upload_attachment(self, name: str, content_object_path: str, file_name: str, context: str): + def upload_attachment( + self, name: str, content_object_path: str, file_name: str, context: str + ): with DemoFile(file_name) as file: return self.client.post( - path=f'/api/attachment/', + path=f"/api/attachment/", data={ "name": name, "context": context, @@ -393,7 +411,9 @@ def upload_attachment(self, name: str, content_object_path: str, file_name: str, ) @staticmethod - def create_attachment(name: str, context: str, content_object: object, file_name: str) -> Attachment: + def create_attachment( + name: str, context: str, content_object: object, file_name: str + ) -> Attachment: with DemoFile(file_name, as_django_file=True) as file: return Attachment.objects.create( name=name, diff --git a/tests/testapp/tests/test_setup.py b/tests/testapp/tests/test_setup.py index 068448f..ffc97e5 100644 --- a/tests/testapp/tests/test_setup.py +++ b/tests/testapp/tests/test_setup.py @@ -1,9 +1,9 @@ from django.apps import apps from django.conf import settings from django.test import SimpleTestCase +from testapp.models import PhotoAlbum from drf_attachments.models import Attachment -from testapp.models import PhotoAlbum class TestSetup(SimpleTestCase): @@ -11,5 +11,5 @@ def test_installed_apps(self): self.assertIn("drf_attachments", settings.INSTALLED_APPS) def test_models(self): - self.assertIs(apps.get_model('drf_attachments', 'Attachment'), Attachment) - self.assertIs(apps.get_model('testapp', 'PhotoAlbum'), PhotoAlbum) + self.assertIs(apps.get_model("drf_attachments", "Attachment"), Attachment) + self.assertIs(apps.get_model("testapp", "PhotoAlbum"), PhotoAlbum) diff --git a/tests/testapp/views.py b/tests/testapp/views.py index 059db28..d1124b0 100644 --- a/tests/testapp/views.py +++ b/tests/testapp/views.py @@ -1,7 +1,12 @@ from rest_framework import viewsets from .models import Diagram, File, PhotoAlbum, Thumbnail -from .serializers import DiagramSerializer, FileSerializer, PhotoAlbumSerializer, ThumbnailSerializer +from .serializers import ( + DiagramSerializer, + FileSerializer, + PhotoAlbumSerializer, + ThumbnailSerializer, +) class PhotoAlbumViewSet(viewsets.ModelViewSet): From 321ccadacbe9393be37b740cf15025b1900a241e Mon Sep 17 00:00:00 2001 From: Alexandra Bruckner Date: Fri, 21 Oct 2022 12:19:58 +0200 Subject: [PATCH 3/3] prepare github actions for tests, coverage report, linting, publish --- .github/workflows/publish.yml | 41 ++++++++++++++++++++++ .github/workflows/test.yml | 64 +++++++++++++++++++++++++++++++++++ requirements.txt | 7 +++- 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..97892da --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,41 @@ +name: Publish package +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.10' + architecture: 'x64' + + - name: Install dependencies and package + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Build source and binary distribution package + run: | + python setup.py sdist bdist_wheel + env: + PACKAGE_VERSION: ${{ github.ref }} + + - name: Check distribution package + run: | + twine check dist/* + + - name: Publish distribution package + run: | + twine upload dist/* + env: + TWINE_REPOSITORY: ${{ secrets.PYPI_REPOSITORY }} + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + TWINE_NON_INTERACTIVE: yes diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..09d73b7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,64 @@ +name: Run linter and tests +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - '3.7' + - '3.8' + - '3.9' + - '3.10' + django-version: + - '3.2' + djangorestframework-version: + - '3.10' + - '3.11' + - '3.12' + exclude: + - django-version: '3.1' + djangorestframework-version: '3.10' + - django-version: '3.2' + djangorestframework-version: '3.10' + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies and package + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install django~=${{ matrix.django-version }}.0 + pip install djangorestframework~=${{ matrix.djangorestframework-version }}.0 + + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 './drf_attachments' --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 './drf_attachments' --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Run tests with coverage + run: | + # prepare Django project: link all necessary data from the test project into the root directory + # Hint: Simply changing the directory does not work (leads to missing files in coverage report) + ln -s ./tests/core core + ln -s ./tests/testapp testapp + ln -s ./tests/manage.py manage.py + + # run tests with coverage + coverage run \ + --source='./drf_attachments' \ + manage.py test + coverage xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 diff --git a/requirements.txt b/requirements.txt index 08181b4..e399643 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,9 +2,14 @@ -e . # Development dependencies +flake8>=3.9,<3.10 +codecov>=2.1,<2.2 +setuptools>=42 +wheel>=0.37 +twine>=3.4 # Linters and formatters -pre-commit>=2.20,<2.21 +pre-commit>=2.17.0,<2.18 isort>=5.10,<5.11 black>=22.6.0,<22.7