diff --git a/configs/nginx/nginx.conf b/configs/nginx/nginx.conf index 036c44ad0..b89442a0e 100644 --- a/configs/nginx/nginx.conf +++ b/configs/nginx/nginx.conf @@ -9,6 +9,13 @@ server { proxy_redirect off; } + location /media/ { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_pass http://api-dev:8000/media/; + proxy_redirect off; + } + # Django API location /api/ { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/forum/settings.py b/forum/settings.py index 95255762e..720184bed 100644 --- a/forum/settings.py +++ b/forum/settings.py @@ -55,6 +55,7 @@ "administration", "search", "drf_spectacular", + "images", ] MIDDLEWARE = [ diff --git a/forum/urls.py b/forum/urls.py index 9d38e36d6..04642ff9c 100644 --- a/forum/urls.py +++ b/forum/urls.py @@ -16,6 +16,8 @@ from django.contrib import admin from django.urls import include, path from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +from django.conf import settings +from django.conf.urls.static import static urlpatterns = [ path("admin/", admin.site.urls), @@ -26,10 +28,11 @@ include("administration.urls", namespace="administration"), ), path("api/", include("search.urls", namespace="search")), + path("api/", include("images.urls", namespace="images")), path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path( "api/schema/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="schema_docs", ), -] +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/images/__init__.py b/images/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/images/serializers.py b/images/serializers.py new file mode 100644 index 000000000..5510390ef --- /dev/null +++ b/images/serializers.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from profiles.models import Profile + + +class BannerSerializer(serializers.ModelSerializer): + class Meta: + model = Profile + fields = ("banner_image",) diff --git a/images/tests/__init__.py b/images/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/images/tests/test_banner.py b/images/tests/test_banner.py new file mode 100644 index 000000000..bd44dad7e --- /dev/null +++ b/images/tests/test_banner.py @@ -0,0 +1,129 @@ +from rest_framework import status +from rest_framework.test import APITestCase + +import os +from io import BytesIO +from PIL import Image + +from authentication.factories import UserFactory +from profiles.factories import ( + ProfileStartupFactory, + ProfileCompanyFactory, +) + +from utils.dump_response import dump # noqa + + +class TestBannerChange(APITestCase): + @staticmethod + def _generate_image(ext, size=(100, 100)): + """for mocking png and jpeg files""" + file = BytesIO() + image = Image.new("RGB", size=size) + formatext = ext.upper() + image.save(file, formatext) + file.name = f"test.{formatext}" + file.seek(0) + return file + + def setUp(self) -> None: + self.right_image = self._generate_image("jpeg", (100, 100)) + self.wrong_image = self._generate_image("png", (7000, 7000)) + + self.user = UserFactory(email="test1@test.com") + self.company_dnipro = ProfileStartupFactory.create( + person=self.user, + name="Dnipro", + banner_image=f"banners/{self.right_image.name}", + ) + + self.company_kyiv = ProfileCompanyFactory(name="Kyivbud") + + def tearDown(self) -> None: + if os.path.exists(self.right_image.name): + os.remove(self.right_image.name) + if os.path.exists(self.wrong_image.name): + os.remove(self.wrong_image.name) + + def test_get_empty_banner_unauthorized(self): + response = self.client.get(path=f"/api/banner/{self.company_kyiv.id}/") + self.assertEqual(200, response.status_code) + self.assertEqual({"banner_image": None}, response.json()) + + def test_get_banner_unauthorized(self): + response = self.client.get( + path=f"/api/banner/{self.company_dnipro.id}/" + ) + self.assertEqual(200, response.status_code) + self.assertEqual( + { + "banner_image": f"http://testserver/media/banners/{self.right_image.name}" + }, + response.json(), + ) + + def test_get_banner_authorized(self): + self.client.force_authenticate(self.user) + response = self.client.get( + path=f"/api/banner/{self.company_dnipro.id}/" + ) + self.assertEqual(200, response.status_code) + self.assertEqual( + { + "banner_image": f"http://testserver/media/banners/{self.right_image.name}" + }, + response.json(), + ) + + def test_get_empty_banner_authorized(self): + self.client.force_authenticate(self.user) + response = self.client.get(path=f"/api/banner/{self.company_kyiv.id}/") + self.assertEqual(200, response.status_code) + self.assertEqual({"banner_image": None}, response.json()) + + def test_put_banner_unauthorized(self): + response = self.client.put( + path=f"/api/banner/{self.company_dnipro.id}/", + data={"banner_image": self.right_image}, + ) + self.assertEqual(401, response.status_code) + self.assertEqual( + {"detail": "Authentication credentials were not provided."}, + response.json(), + ) + + def test_put_banner_authorized_not_owner(self): + self.client.force_authenticate(self.user) + response = self.client.put( + path=f"/api/banner/{self.company_kyiv.id}/", + data={"banner_image": self.right_image}, + ) + self.assertEqual(403, response.status_code) + self.assertEqual( + {"detail": "You do not have permission to perform this action."}, + response.json(), + ) + + def test_put_banner_authorized_owner_right_image(self): + self.client.force_authenticate(self.user) + response = self.client.put( + path=f"/api/banner/{self.company_dnipro.id}/", + data={"banner_image": self.right_image}, + ) + self.assertEqual(200, response.status_code) + + def test_put_banner_authorized_owner_wrong_image(self): + self.client.force_authenticate(self.user) + response = self.client.put( + path=f"/api/banner/{self.company_dnipro.id}/", + data={"banner_image": self.wrong_image}, + ) + self.assertEqual(400, response.status_code) + self.assertEqual( + { + "banner_image": [ + "Image size exceeds the maximum allowed (50MB)." + ] + }, + response.json(), + ) diff --git a/images/urls.py b/images/urls.py new file mode 100644 index 000000000..4f6277366 --- /dev/null +++ b/images/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import BannerRetrieveUpdate + +app_name = "images" + +urlpatterns = [ + path("banner//", BannerRetrieveUpdate.as_view(), name="banner_change"), +] diff --git a/images/views.py b/images/views.py new file mode 100644 index 000000000..5df554cb5 --- /dev/null +++ b/images/views.py @@ -0,0 +1,15 @@ +from rest_framework.generics import ( + RetrieveUpdateAPIView, +) +from rest_framework.parsers import MultiPartParser, FormParser + +from profiles.permissions import UserIsProfileOwnerOrReadOnly +from profiles.models import Profile +from .serializers import BannerSerializer + + +class BannerRetrieveUpdate(RetrieveUpdateAPIView): + permission_classes = (UserIsProfileOwnerOrReadOnly,) + serializer_class = BannerSerializer + parser_classes = (MultiPartParser, FormParser) + queryset = Profile.objects.all() diff --git a/profiles/models.py b/profiles/models.py index f9f5e02e2..784ad2657 100644 --- a/profiles/models.py +++ b/profiles/models.py @@ -87,7 +87,9 @@ class Profile(models.Model): startup_idea = models.TextField(default=None, null=True) banner_image = models.ImageField( - validators=[validate_image_format, validate_image_size], null=True + upload_to="banners", + validators=[validate_image_format, validate_image_size], + null=True, ) is_deleted = models.BooleanField(default=False) diff --git a/profiles/serializers.py b/profiles/serializers.py index 35a4f4da0..666c4ef1c 100644 --- a/profiles/serializers.py +++ b/profiles/serializers.py @@ -61,6 +61,7 @@ class ProfileDetailSerializer(serializers.ModelSerializer): categories = CategorySerializer(many=True, read_only=True) is_saved = serializers.SerializerMethodField() region_display = serializers.SerializerMethodField() + banner_image = serializers.ImageField(required=False) class Meta: model = Profile