From 93da9370bcac9c77985c950c55ad22371cd26cf8 Mon Sep 17 00:00:00 2001 From: Kuroshini Date: Thu, 6 Feb 2020 20:55:47 +0300 Subject: [PATCH 1/7] extend establishments gallery --- .../migrations/0009_auto_20200206_1749.py | 53 +++++++++++++++++++ apps/gallery/models.py | 11 ++-- 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 apps/gallery/migrations/0009_auto_20200206_1749.py diff --git a/apps/gallery/migrations/0009_auto_20200206_1749.py b/apps/gallery/migrations/0009_auto_20200206_1749.py new file mode 100644 index 00000000..bde5e9b7 --- /dev/null +++ b/apps/gallery/migrations/0009_auto_20200206_1749.py @@ -0,0 +1,53 @@ +# Generated by Django 2.2.7 on 2020-02-06 17:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import sorl.thumbnail.fields +import utils.methods + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('gallery', '0008_merge_20191212_0752'), + ] + + operations = [ + migrations.AddField( + model_name='image', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_records_created', to=settings.AUTH_USER_MODEL, verbose_name='created by'), + ), + migrations.AddField( + model_name='image', + name='is_public', + field=models.BooleanField(default=False, verbose_name='Is media source public'), + ), + migrations.AddField( + model_name='image', + name='link', + field=models.URLField(blank=True, default=None, null=True, verbose_name='mp4 or youtube video link'), + ), + migrations.AddField( + model_name='image', + name='modified_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_records_modified', to=settings.AUTH_USER_MODEL, verbose_name='modified by'), + ), + migrations.AddField( + model_name='image', + name='order', + field=models.PositiveIntegerField(default=0, verbose_name='Sorting order'), + ), + migrations.AddField( + model_name='image', + name='preview', + field=sorl.thumbnail.fields.ImageField(default=None, max_length=255, null=True, upload_to=utils.methods.image_path, verbose_name='image preview'), + ), + migrations.AlterField( + model_name='image', + name='image', + field=sorl.thumbnail.fields.ImageField(default=None, max_length=255, null=True, upload_to=utils.methods.image_path, verbose_name='image file'), + ), + ] diff --git a/apps/gallery/models.py b/apps/gallery/models.py index ebca56ce..88a50035 100644 --- a/apps/gallery/models.py +++ b/apps/gallery/models.py @@ -4,14 +4,14 @@ from sorl import thumbnail from sorl.thumbnail.fields import ImageField as SORLImageField from utils.methods import image_path -from utils.models import ProjectBaseMixin, SORLImageMixin, PlatformMixin +from utils.models import ProjectBaseMixin, SORLImageMixin, PlatformMixin, BaseAttributes class ImageQuerySet(models.QuerySet): """QuerySet for model Image.""" -class Image(ProjectBaseMixin, SORLImageMixin, PlatformMixin): +class Image(BaseAttributes, SORLImageMixin, PlatformMixin): """Image model.""" HORIZONTAL = 0 VERTICAL = 1 @@ -22,12 +22,17 @@ class Image(ProjectBaseMixin, SORLImageMixin, PlatformMixin): ) image = SORLImageField(max_length=255, upload_to=image_path, - verbose_name=_('image file')) + verbose_name=_('image file'), default=None, null=True) orientation = models.PositiveSmallIntegerField(choices=ORIENTATIONS, blank=True, null=True, default=None, verbose_name=_('image orientation')) title = models.CharField(_('title'), max_length=255, default='') + is_public = models.BooleanField(default=False, verbose_name=_('Is media source public')) + preview = SORLImageField(max_length=255, upload_to=image_path, verbose_name=_('image preview'), null=True, + default=None) + link = models.URLField(blank=True, null=True, default=None, verbose_name=_('mp4 or youtube video link')) + order = models.PositiveIntegerField(default=0, verbose_name=_('Sorting order')) objects = ImageQuerySet.as_manager() class Meta: From 2c3beb78d8eb304647d5be62171e082e27e4683f Mon Sep 17 00:00:00 2001 From: Kuroshini Date: Thu, 6 Feb 2020 21:50:27 +0300 Subject: [PATCH 2/7] remove wrong unique constraint --- apps/establishment/migrations/0062_auto_20191117_1117.py | 2 +- apps/establishment/models.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/establishment/migrations/0062_auto_20191117_1117.py b/apps/establishment/migrations/0062_auto_20191117_1117.py index 9d0fc2f9..1bef7322 100644 --- a/apps/establishment/migrations/0062_auto_20191117_1117.py +++ b/apps/establishment/migrations/0062_auto_20191117_1117.py @@ -27,7 +27,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'establishment gallery', 'verbose_name_plural': 'establishment galleries', - 'unique_together': {('establishment', 'is_main'), ('establishment', 'image')}, + 'unique_together': {('establishment', 'image')}, }, ), migrations.AddField( diff --git a/apps/establishment/models.py b/apps/establishment/models.py index f2ad7c2e..60e3e0b5 100644 --- a/apps/establishment/models.py +++ b/apps/establishment/models.py @@ -963,7 +963,7 @@ class EstablishmentGallery(IntermediateGalleryModelMixin): """Meta class.""" verbose_name = _('establishment gallery') verbose_name_plural = _('establishment galleries') - unique_together = (('establishment', 'is_main'), ('establishment', 'image')) + unique_together = ('establishment', 'image') class PositionQuerySet(models.QuerySet): From 53bfa878ba5c1841afe2a35c89a8f9f13c5a5a4e Mon Sep 17 00:00:00 2001 From: Kuroshini Date: Thu, 6 Feb 2020 22:47:57 +0300 Subject: [PATCH 3/7] establishment gallery --- apps/gallery/models.py | 23 ++++++++++++++++++- apps/gallery/serializers.py | 45 +++++++++++++++++++++++++++++++++++++ apps/gallery/urls.py | 2 ++ apps/gallery/views.py | 14 +++++++++++- 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/apps/gallery/models.py b/apps/gallery/models.py index 88a50035..dcd38faa 100644 --- a/apps/gallery/models.py +++ b/apps/gallery/models.py @@ -16,6 +16,12 @@ class Image(BaseAttributes, SORLImageMixin, PlatformMixin): HORIZONTAL = 0 VERTICAL = 1 + MEDIA_TYPES = ( + _('photo'), + _('video'), + _('youtube'), + ) + ORIENTATIONS = ( (HORIZONTAL, _('Horizontal')), (VERTICAL, _('Vertical')), @@ -27,7 +33,7 @@ class Image(BaseAttributes, SORLImageMixin, PlatformMixin): blank=True, null=True, default=None, verbose_name=_('image orientation')) title = models.CharField(_('title'), max_length=255, default='') - is_public = models.BooleanField(default=False, verbose_name=_('Is media source public')) + is_public = models.BooleanField(default=True, verbose_name=_('Is media source public')) preview = SORLImageField(max_length=255, upload_to=image_path, verbose_name=_('image preview'), null=True, default=None) @@ -45,6 +51,21 @@ class Image(BaseAttributes, SORLImageMixin, PlatformMixin): """String representation""" return f'{self.id}' + @property + def type(self) -> str: + if self.image: + return self.MEDIA_TYPES[0] + if self.link is not None and self.link.endswith('.mp4'): + return self.MEDIA_TYPES[1] + return self.MEDIA_TYPES[2] + + @property + def image_size_in_KB(self): + try: + return self.image.size / 1024 if self.image else None + except FileNotFoundError: + return None + def delete_image(self, completely: bool = True): """ Deletes an instance and crops of instance from media storage. diff --git a/apps/gallery/serializers.py b/apps/gallery/serializers.py index f7d2d2a3..5d270abf 100644 --- a/apps/gallery/serializers.py +++ b/apps/gallery/serializers.py @@ -5,6 +5,9 @@ from sorl.thumbnail import get_thumbnail from sorl.thumbnail.parsers import parse_crop, ThumbnailParseError from django.utils.translation import gettext_lazy as _ from django.conf import settings +from django.shortcuts import get_object_or_404 +from establishment.models import Establishment +from account.serializers.common import UserBaseSerializer from . import models @@ -45,6 +48,48 @@ class ImageSerializer(serializers.ModelSerializer): return attrs +class EstablishmentGallerySerializer(serializers.ModelSerializer): + """Serializer for creating and retrieving establishment media""" + type = serializers.CharField(read_only=True) + created_by = UserBaseSerializer(read_only=True, allow_null=True) + image_size_in_KB = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=20) + + class Meta: + model = models.Image + fields = ( + 'id', + 'image', + 'type', + 'link', + 'order', + 'preview', + 'is_public', + 'title', + 'created_by', + 'image_size_in_KB', + ) + extra_kwargs = { + 'created': {'read_only': True}, + } + + def validate(self, attrs): + """Overridden validate method.""" + image = attrs.get('image') + + if image and image.size >= settings.FILE_UPLOAD_MAX_MEMORY_SIZE: + raise serializers.ValidationError({'detail': _('File size too large: %s bytes') % image.size}) + + return attrs + + def create(self, validated_data): + establishment = get_object_or_404(klass=Establishment, pk=self.context['view'].kwargs['establishment_id']) + instance = super().create(validated_data) + instance.created_by = self.context['request'].user + instance.establishment_set.add(establishment) + instance.save() + return instance + + class CropImageSerializer(ImageSerializer): """Serializers for image crops.""" diff --git a/apps/gallery/urls.py b/apps/gallery/urls.py index 987685cb..834dc347 100644 --- a/apps/gallery/urls.py +++ b/apps/gallery/urls.py @@ -7,6 +7,8 @@ app_name = 'gallery' urlpatterns = [ path('', views.ImageListCreateView.as_view(), name='list-create'), + path('for_establishment//', views.MediaForEstablishmentView.as_view(), + name='establishment-media'), path('/', views.ImageRetrieveDestroyView.as_view(), name='retrieve-destroy'), path('/crop/', views.CropImageCreateView.as_view(), name='create-crop'), ] diff --git a/apps/gallery/views.py b/apps/gallery/views.py index 3b5063ba..dfb76019 100644 --- a/apps/gallery/views.py +++ b/apps/gallery/views.py @@ -4,7 +4,8 @@ from rest_framework import generics, status from rest_framework.response import Response from utils.methods import get_permission_classes -from utils.permissions import IsContentPageManager +from utils.permissions import IsContentPageManager, IsCountryAdmin, IsEstablishmentManager, \ + IsProducerFoodInspector, IsEstablishmentAdministrator from . import tasks, models, serializers @@ -22,6 +23,17 @@ class ImageListCreateView(ImageBaseView, generics.ListCreateAPIView): """List/Create Image view.""" +class MediaForEstablishmentView(ImageBaseView, generics.ListCreateAPIView): + """View for creating and retrieving certain establishment media.""" + pagination_class = None + permission_classes = (IsCountryAdmin, IsEstablishmentAdministrator, IsEstablishmentManager, IsProducerFoodInspector) + serializer_class = serializers.EstablishmentGallerySerializer + + def get_queryset(self): + return super().get_queryset().filter(establishment__pk=self.kwargs['establishment_id'])\ + .order_by('-order').prefetch_related('created_by') + + class ImageRetrieveDestroyView(ImageBaseView, generics.RetrieveDestroyAPIView): """Destroy view for model Image""" From 258e13c808885632c4f178936ff902ce9f1cd0fb Mon Sep 17 00:00:00 2001 From: Kuroshini Date: Thu, 6 Feb 2020 22:55:41 +0300 Subject: [PATCH 4/7] establishment type as enum --- apps/gallery/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/gallery/serializers.py b/apps/gallery/serializers.py index 5d270abf..33f909d9 100644 --- a/apps/gallery/serializers.py +++ b/apps/gallery/serializers.py @@ -50,7 +50,7 @@ class ImageSerializer(serializers.ModelSerializer): class EstablishmentGallerySerializer(serializers.ModelSerializer): """Serializer for creating and retrieving establishment media""" - type = serializers.CharField(read_only=True) + type = serializers.ChoiceField(read_only=True, choices=models.Image.MEDIA_TYPES) created_by = UserBaseSerializer(read_only=True, allow_null=True) image_size_in_KB = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=20) From 4388253bf9548a7c12d3a371640b4140b812d4c6 Mon Sep 17 00:00:00 2001 From: Kuroshini Date: Fri, 7 Feb 2020 00:41:07 +0300 Subject: [PATCH 5/7] media update route --- apps/gallery/models.py | 22 ++++++++++++++++++++-- apps/gallery/serializers.py | 6 ++++++ apps/gallery/urls.py | 1 + apps/gallery/views.py | 7 ++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/gallery/models.py b/apps/gallery/models.py index dcd38faa..cc51af11 100644 --- a/apps/gallery/models.py +++ b/apps/gallery/models.py @@ -1,5 +1,9 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from botocore.exceptions import ClientError +from django.conf import settings +from project.storage_backends import PublicMediaStorage +import boto3 from sorl import thumbnail from sorl.thumbnail.fields import ImageField as SORLImageField @@ -51,6 +55,20 @@ class Image(BaseAttributes, SORLImageMixin, PlatformMixin): """String representation""" return f'{self.id}' + def set_pubic(self, is_public=True): + if not settings.AWS_STORAGE_BUCKET_NAME: + """Backend doesn't use aws s3""" + return + s3 = boto3.resource('s3', region_name=settings.AWS_S3_REGION_NAME, aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY) + bucket = s3.Bucket(settings.AWS_STORAGE_BUCKET_NAME) + if self.image: + file_object = bucket.Object(f'{PublicMediaStorage.location}/{str(self.image.file)}') + if is_public: + file_object.Acl().put(ACL='public-read') + else: + file_object.Acl().put(ACL='authenticated-read') + @property def type(self) -> str: if self.image: @@ -62,8 +80,8 @@ class Image(BaseAttributes, SORLImageMixin, PlatformMixin): @property def image_size_in_KB(self): try: - return self.image.size / 1024 if self.image else None - except FileNotFoundError: + return self.image.size / 1000 if self.image else None + except (FileNotFoundError, ClientError): return None def delete_image(self, completely: bool = True): diff --git a/apps/gallery/serializers.py b/apps/gallery/serializers.py index 33f909d9..0e50b231 100644 --- a/apps/gallery/serializers.py +++ b/apps/gallery/serializers.py @@ -89,6 +89,12 @@ class EstablishmentGallerySerializer(serializers.ModelSerializer): instance.save() return instance + def update(self, instance: models.Image, validated_data): + if instance.is_public != validated_data.get('is_public'): + instance.set_pubic(validated_data.get('is_public', True)) + return super().update(instance, validated_data) + + class CropImageSerializer(ImageSerializer): """Serializers for image crops.""" diff --git a/apps/gallery/urls.py b/apps/gallery/urls.py index 834dc347..afc72f69 100644 --- a/apps/gallery/urls.py +++ b/apps/gallery/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path('', views.ImageListCreateView.as_view(), name='list-create'), path('for_establishment//', views.MediaForEstablishmentView.as_view(), name='establishment-media'), + path('media//', views.MediaUpdateView.as_view(), name='media-update'), path('/', views.ImageRetrieveDestroyView.as_view(), name='retrieve-destroy'), path('/crop/', views.CropImageCreateView.as_view(), name='create-crop'), ] diff --git a/apps/gallery/views.py b/apps/gallery/views.py index dfb76019..7dc99248 100644 --- a/apps/gallery/views.py +++ b/apps/gallery/views.py @@ -8,7 +8,6 @@ from utils.permissions import IsContentPageManager, IsCountryAdmin, IsEstablishm IsProducerFoodInspector, IsEstablishmentAdministrator from . import tasks, models, serializers - class ImageBaseView(generics.GenericAPIView): """Base Image view.""" model = models.Image @@ -34,6 +33,12 @@ class MediaForEstablishmentView(ImageBaseView, generics.ListCreateAPIView): .order_by('-order').prefetch_related('created_by') +class MediaUpdateView(ImageBaseView, generics.UpdateAPIView): + """View for updating media data""" + serializer_class = serializers.EstablishmentGallerySerializer + permission_classes = () + + class ImageRetrieveDestroyView(ImageBaseView, generics.RetrieveDestroyAPIView): """Destroy view for model Image""" From 14da53ed94f83c08a42cf026817b71d1901cb5af Mon Sep 17 00:00:00 2001 From: Anatoly Date: Fri, 7 Feb 2020 09:44:04 +0300 Subject: [PATCH 6/7] refactored permission a little --- apps/utils/methods.py | 7 ++++--- apps/utils/permissions.py | 13 +++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/utils/methods.py b/apps/utils/methods.py index a2fd4bd3..bd069ce0 100644 --- a/apps/utils/methods.py +++ b/apps/utils/methods.py @@ -7,6 +7,7 @@ import string from collections import namedtuple from functools import reduce from io import BytesIO +from operator import or_ import requests from PIL import Image @@ -242,12 +243,12 @@ def get_image_meta_by_url(url) -> (int, int, int): def get_permission_classes(*args) -> list: """Return permission_class object with admin permissions.""" from rest_framework.permissions import IsAdminUser - from utils.permissions import IsCountryAdmin + from utils.permissions import IsCountryAdmin, IsReadOnly - admin_permission_classes = [IsCountryAdmin, IsAdminUser] + admin_permission_classes = [IsCountryAdmin, IsAdminUser, IsReadOnly] permission_classes = [ reduce( - lambda a, b: a | b, admin_permission_classes + list(args) + or_, admin_permission_classes + list(args) ) ] return permission_classes diff --git a/apps/utils/permissions.py b/apps/utils/permissions.py index 7fe145f5..877d4a50 100644 --- a/apps/utils/permissions.py +++ b/apps/utils/permissions.py @@ -53,7 +53,7 @@ class IsRefreshTokenValid(permissions.BasePermission): return False -class IsGuest(permissions.IsAuthenticatedOrReadOnly): +class IsGuest(permissions.BasePermission): """ Object-level permission to only allow owners of an object to edit it. """ @@ -66,6 +66,15 @@ class IsGuest(permissions.IsAuthenticatedOrReadOnly): return all(rules) +class IsReadOnly(permissions.BasePermission): + """ + Allows getting access to resource only if request method in SAFE_METHODs. + """ + + def has_permission(self, request, view): + return request.method in SAFE_HTTP_METHODS + + class IsApprovedUser(IsAuthenticatedAndTokenIsValid): """ Object-level permission to only allow owners of an object to edit it. @@ -200,7 +209,7 @@ class IsEstablishmentAdministrator(IsApprovedUser): ).only('id') has_permission = True if user_role.exists() else has_permission rules.append(has_permission) - return all(rules) + return bool(request.method in SAFE_HTTP_METHODS or all(rules)) def has_object_permission(self, request, view, obj): rules = [ From 8483a56ea7d86267d84b2394337fbcc871c06f17 Mon Sep 17 00:00:00 2001 From: "a.gorbunov" Date: Fri, 7 Feb 2020 07:53:29 +0000 Subject: [PATCH 7/7] fix country_name for mobile --- apps/establishment/serializers/common.py | 3 ++- apps/location/serializers/common.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/establishment/serializers/common.py b/apps/establishment/serializers/common.py index bcb2d0b6..0c40efbb 100644 --- a/apps/establishment/serializers/common.py +++ b/apps/establishment/serializers/common.py @@ -13,7 +13,7 @@ from location.serializers import ( AddressBaseSerializer, AddressDetailSerializer, CityBaseSerializer, CityShortSerializer, EstablishmentWineOriginBaseSerializer, EstablishmentWineRegionBaseSerializer, -) + AddressMobileDetailSerializer) from main.serializers import AwardSerializer, CurrencySerializer from review.serializers import ReviewShortSerializer, ReviewBaseSerializer from tag.serializers import TagBaseSerializer @@ -501,6 +501,7 @@ class MobileEstablishmentDetailSerializer(EstablishmentDetailSerializer): """Serializer for Establishment model for mobiles.""" last_comment = comment_serializers.CommentBaseSerializer(allow_null=True) + address = AddressMobileDetailSerializer(read_only=True) class Meta(EstablishmentDetailSerializer.Meta): """Meta class.""" diff --git a/apps/location/serializers/common.py b/apps/location/serializers/common.py index 4293485d..b4ea9c5f 100644 --- a/apps/location/serializers/common.py +++ b/apps/location/serializers/common.py @@ -141,6 +141,19 @@ class CityBaseSerializer(serializers.ModelSerializer): } +class CityMobileSerializer(CityBaseSerializer): + name = serializers.SerializerMethodField() + + class Meta(CityBaseSerializer.Meta): + fields = CityBaseSerializer.Meta.fields + [ + 'name' + ] + + def get_name(self, obj: models.City) -> str: + if hasattr(obj, 'name_translated'): + return obj.name_translated + + class CityDetailSerializer(CityBaseSerializer): """Serializer for detail view.""" image = ImageBaseSerializer(source='crop_image', read_only=True) @@ -245,6 +258,10 @@ class AddressDetailSerializer(AddressBaseSerializer): ) +class AddressMobileDetailSerializer(AddressDetailSerializer): + city = CityMobileSerializer(read_only=True) + + class WineRegionBaseSerializer(serializers.ModelSerializer): """Wine region serializer.""" country = CountrySerializer()