diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 09d73b7..d02fdc8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: - name: Install dependencies and package run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements-test.txt pip install django~=${{ matrix.django-version }}.0 pip install djangorestframework~=${{ matrix.djangorestframework-version }}.0 diff --git a/README.md b/README.md index 5eb2e11..4082d8b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,13 @@ If used with DRF, `django-filter` is an additional requirement. 1. Install using pip: ```shell -pip install git+https://github.com/anexia/drf-attachments@main +pip install git+https://github.com/anexia/drf-attachments@main[drf] +``` + +or to install without DRF dependencies (no REST endpoints available from scratch) + +``` +pip install git+https://github.com/anexia-it/drf-attachments@main ``` 2. Integrate `drf_attachments` and `django_userforeignkey` into your `settings.py` @@ -69,6 +75,12 @@ class PhotoAlbumAdmin(admin.ModelAdmin): ] ``` +4. Optionally define a custom DIR as root for your attachments ("attachments" by default) +```python +# settings.py +ATTACHMENT_UPLOAD_ROOT_DIR = "your/custom/attachments/root/" +``` + `ReadOnlyAttachmentInlineAdmin` is useful when attachments should be provided only by REST API. You may consider extending the classes in order to handle additional permission checks. diff --git a/drf_attachments/models/models.py b/drf_attachments/models/models.py index db84cc1..d094d3f 100644 --- a/drf_attachments/models/models.py +++ b/drf_attachments/models/models.py @@ -3,6 +3,7 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db.models import ( CASCADE, CharField, @@ -14,9 +15,6 @@ UUIDField, ) from django.utils.translation import gettext_lazy as _ -from django_userforeignkey.request import get_current_request -from rest_framework.exceptions import ValidationError -from rest_framework.reverse import reverse from drf_attachments.config import config from drf_attachments.models.managers import AttachmentManager @@ -101,6 +99,16 @@ class Meta: verbose_name_plural = _("attachments") ordering = ("creation_date",) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + try: + # if DRF is installed, use their ValidationError + from rest_framework.exceptions import ValidationError as DrfValidationError + self.validation_error_class = DrfValidationError + except ImportError: + # otherwise use default django ValidationError + self.validation_error_class = ValidationError + def __str__(self): return f"{self.content_type} | {self.object_id} | {self.context_label} | {self.name}" @@ -112,18 +120,6 @@ def is_image(self): and self.meta["mime_type"].startswith("image") ) - @property - def download_url(self): - # Note: The attachment-download URL is auto-generated by the AttachmentViewSet - relative_url = reverse("attachment-download", kwargs={"pk": self.id}) - - # Make sure NOT to throw an exception in any case, otherwise the serializer will not provide the property - request = get_current_request() - if not request: - return relative_url - - return request.build_absolute_uri(relative_url) - @property def default_context(self): return config.default_context() @@ -202,12 +198,7 @@ def validate_context(self): context=self.context, valid_contexts=", ".join(self.valid_contexts), ) - raise ValidationError( - { - "context": error_msg, - }, - code="invalid", - ) + raise self.validation_error_class(error_msg, code="invalid") def validate_file(self): """ @@ -232,7 +223,7 @@ def _validate_file_mime_type(self): mime_type=self.meta["mime_type"], valid_mime_types=", ".join(self.valid_mime_types), ) - raise ValidationError( + raise self.validation_error_class( { "file": error_msg, }, @@ -254,7 +245,7 @@ def _validate_file_extension(self): extension=self.meta["extension"], valid_extensions=", ".join(self.valid_extensions), ) - raise ValidationError( + raise self.validation_error_class( { "file": error_msg, }, @@ -275,7 +266,7 @@ def _validate_file_size(self): size=self.file.size, min_size=self.min_size, ) - raise ValidationError( + raise self.validation_error_class( { "file": error_msg, }, @@ -290,7 +281,7 @@ def _validate_file_size(self): size=self.file.size, max_size=self.max_size, ) - raise ValidationError( + raise self.validation_error_class( { "file": error_msg, }, diff --git a/drf_attachments/models/querysets.py b/drf_attachments/models/querysets.py index 425148d..12f9cbc 100644 --- a/drf_attachments/models/querysets.py +++ b/drf_attachments/models/querysets.py @@ -34,6 +34,13 @@ def delete(self): return result + def get_names_list(self): + """ + Return the names of all files within the queryset as list + """ + names = list(self.values_list("name", flat=True)) + return ", ".join(names) + def __filter_by_callable(self, callable_) -> QuerySet: if callable_: return callable_(self) diff --git a/drf_attachments/rest/serializers.py b/drf_attachments/rest/serializers.py index c4f14f6..b52b99c 100644 --- a/drf_attachments/rest/serializers.py +++ b/drf_attachments/rest/serializers.py @@ -1,5 +1,7 @@ +from django_userforeignkey.request import get_current_request from rest_framework import serializers -from rest_framework.fields import ChoiceField, FileField, ReadOnlyField +from rest_framework.fields import ChoiceField, FileField, ReadOnlyField, SerializerMethodField +from rest_framework.reverse import reverse from drf_attachments.config import config from drf_attachments.models.models import Attachment @@ -16,6 +18,7 @@ class AttachmentSerializer(serializers.ModelSerializer): to their own respective serializers. """ + download_url = SerializerMethodField(read_only=True) file = FileField(write_only=True, required=True) content_object = config.get_content_object_field() context = ChoiceField(choices=config.context_choices(values_list=False)) @@ -33,12 +36,17 @@ class Meta: "file", ) + def get_download_url(self, obj): + request = get_current_request() + relative_url = reverse("attachment-download", kwargs={"pk": obj.id}) + return request.build_absolute_uri(relative_url) + class AttachmentSubSerializer(serializers.ModelSerializer): """Sub serializer for nested data inside other serializers""" # pk is read-only by default - download_url = ReadOnlyField() + download_url = SerializerMethodField(read_only=True) name = ReadOnlyField() context = ChoiceField( choices=config.context_choices(include_default=False, values_list=False), @@ -53,3 +61,8 @@ class Meta: "name", "context", ) + + def get_download_url(self, obj): + request = get_current_request() + relative_url = reverse("attachment-download", kwargs={"pk": obj.id}) + return request.build_absolute_uri(relative_url) diff --git a/drf_attachments/storage.py b/drf_attachments/storage.py index b9aa82c..9ff6783 100644 --- a/drf_attachments/storage.py +++ b/drf_attachments/storage.py @@ -4,6 +4,7 @@ from django.conf import settings from django.core.files.storage import FileSystemStorage from django.utils import timezone +from rest_framework.reverse import reverse __all__ = [ "AttachmentFileStorage", @@ -27,7 +28,14 @@ def url(self, name): if not attachment: return "" - return attachment.download_url + return reverse("attachment-download", kwargs={"pk": attachment.id}) + + +def attachment_upload_root_dir(): + """ + Extract ATTACHMENT_UPLOAD_ROOT_DIR from the settings (if defined) + """ + return getattr(settings, "ATTACHMENT_UPLOAD_ROOT_DIR", "attachments") def attachment_upload_path(attachment, filename): @@ -44,4 +52,4 @@ def attachment_upload_path(attachment, filename): """ filename, file_extension = os.path.splitext(filename) month_directory = timezone.now().strftime("%Y%m") - return f"attachments/{month_directory}/{str(uuid1())}{file_extension}" + return f"{attachment_upload_root_dir()}/{month_directory}/{str(uuid1())}{file_extension}" diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..140e8e2 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,4 @@ +# load base requirements +-r base.txt + +rest-framework-generic-relations>=2.0.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e399643..0c9beab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,9 +15,7 @@ black>=22.6.0,<22.7 # 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 diff --git a/setup.py b/setup.py index 21cc231..372e631 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,6 @@ author_email="abruckner@anexia-it.com", install_requires=[ "python-magic>=0.4.18", - "rest-framework-generic-relations>=2.0.0", "django-userforeignkey>=0.4", ], classifiers=[ @@ -39,4 +38,7 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", ], + extras_require={ + "drf": ["rest-framework-generic-relations>=2.0.0"], + }, ) diff --git a/tests/testapp/tests/test_api.py b/tests/testapp/tests/test_api.py index 162ef68..e61c25a 100644 --- a/tests/testapp/tests/test_api.py +++ b/tests/testapp/tests/test_api.py @@ -98,18 +98,19 @@ def test_download(self): # get attachment from main serializer response = self.client.get(path=f"/api/attachment/") - attachment_response = response.json()[0] - # TODO: download_url is not provided by main serializer. intentional? + main_attachment_response = response.json()[0] + main_download_url = main_attachment_response["download_url"] + self.assertIsNotNone(main_download_url) + self.assertGreater(len(main_download_url), 0) # get attachment from sub-serializer 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) - self.assertGreater(len(download_url), 0) + sub_attachment_response = response.json()["attachments"][0] + sub_download_url = sub_attachment_response["download_url"] + self.assertEqual(main_download_url, sub_download_url) # download - response = self.client.get(download_url) + response = self.client.get(sub_download_url) # check response self.assertEqual(