diff --git a/apps/collection/management/commands/collection_optimize_images.py b/apps/collection/management/commands/collection_optimize_images.py new file mode 100644 index 00000000..2b6c9e3d --- /dev/null +++ b/apps/collection/management/commands/collection_optimize_images.py @@ -0,0 +1,29 @@ +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db import transaction +from sorl.thumbnail import get_thumbnail + +from collection.models import Collection +from utils.methods import image_url_valid, get_image_meta_by_url + + +class Command(BaseCommand): + SORL_THUMBNAIL_ALIAS = 'collection_image' + + def handle(self, *args, **options): + with transaction.atomic(): + for collection in Collection.objects.all(): + if not image_url_valid(collection.image_url): + continue + + _, width, height = get_image_meta_by_url(collection.image_url) + sorl_settings = settings.SORL_THUMBNAIL_ALIASES[self.SORL_THUMBNAIL_ALIAS] + sorl_width_height = sorl_settings['geometry_string'].split('x') + + if int(sorl_width_height[0]) > width or int(sorl_width_height[1]) > height: + collection.image_url = get_thumbnail( + file_=collection.image_url, + **settings.SORL_THUMBNAIL_ALIASES[self.SORL_THUMBNAIL_ALIAS] + ).url + + collection.save() diff --git a/apps/establishment/management/commands/establishment_optimize_preview_image.py b/apps/establishment/management/commands/establishment_optimize_preview_image.py new file mode 100644 index 00000000..50a73a26 --- /dev/null +++ b/apps/establishment/management/commands/establishment_optimize_preview_image.py @@ -0,0 +1,28 @@ +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db import transaction +from sorl.thumbnail import get_thumbnail + +from establishment.models import Establishment +from utils.methods import image_url_valid, get_image_meta_by_url + + +class Command(BaseCommand): + SORL_THUMBNAIL_ALIAS = 'establishment_collection_image' + + def handle(self, *args, **options): + with transaction.atomic(): + for establishment in Establishment.objects.all(): + if not image_url_valid(establishment.preview_image_url): + continue + + _, width, height = get_image_meta_by_url(establishment.preview_image_url) + sorl_settings = settings.SORL_THUMBNAIL_ALIASES[self.SORL_THUMBNAIL_ALIAS] + sorl_width_height = sorl_settings['geometry_string'].split('x') + + if int(sorl_width_height[0]) > width or int(sorl_width_height[1]) > height: + establishment.preview_image_url = get_thumbnail( + file_=establishment.preview_image_url, + **sorl_settings + ) + establishment.save() diff --git a/apps/establishment/models.py b/apps/establishment/models.py index f5c2d616..c10be29d 100644 --- a/apps/establishment/models.py +++ b/apps/establishment/models.py @@ -58,8 +58,6 @@ class EstablishmentType(TypeDefaultImageMixin, TranslatedFieldsMixin, ProjectBas blank=True, null=True, default=None, verbose_name='default image') - chosen_tags = generic.GenericRelation(to='tag.ChosenTag') - class Meta: """Meta class.""" diff --git a/apps/establishment/serializers/back.py b/apps/establishment/serializers/back.py index 23283981..54f46780 100644 --- a/apps/establishment/serializers/back.py +++ b/apps/establishment/serializers/back.py @@ -1,16 +1,27 @@ +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from account.serializers.common import UserShortSerializer from establishment import models from establishment import serializers as model_serializers +from establishment.models import ContactPhone +from gallery.models import Image +from location.models import Address from location.serializers import AddressDetailSerializer, TranslatedField from main.models import Currency -from location.models import Address from main.serializers import AwardSerializer from utils.decorators import with_base_attributes from utils.serializers import TimeZoneChoiceField -from gallery.models import Image -from django.utils.translation import gettext_lazy as _ -from account.serializers.common import UserShortSerializer + + +def phones_handler(phones_list, establishment): + """ + create or update phones for establishment 35016 string + """ + ContactPhone.objects.filter(establishment=establishment).delete() + + for new_phone in phones_list: + ContactPhone.objects.create(establishment=establishment, phone=new_phone) class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSerializer): @@ -37,6 +48,11 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria address_id = serializers.PrimaryKeyRelatedField(write_only=True, source='address', queryset=Address.objects.all()) tz = TimeZoneChoiceField() + phones_list = serializers.ListField( + child=serializers.CharField(max_length=20), + allow_empty=True, + write_only=True, + ) class Meta: model = models.Establishment @@ -62,8 +78,15 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria 'tags', 'tz', 'address_id', + 'phones_list', ] + def create(self, validated_data): + phones_list = validated_data.pop('phones_list') + instance = super().create(validated_data) + phones_handler(phones_list, instance) + return instance + class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer): """Establishment create serializer""" @@ -80,6 +103,11 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer): socials = model_serializers.SocialNetworkRelatedSerializers(read_only=False, many=True, ) type = model_serializers.EstablishmentTypeBaseSerializer(source='establishment_type') + phones_list = serializers.ListField( + child=serializers.CharField(max_length=20), + allow_empty=True, + write_only=True, + ) class Meta: model = models.Establishment @@ -99,8 +127,15 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer): 'is_publish', 'address', 'tags', + 'phones_list', ] + def update(self, instance, validated_data): + phones_list = validated_data.pop('phones_list') + instance = super().update(instance, validated_data) + phones_handler(phones_list, instance) + return instance + class SocialChoiceSerializers(serializers.ModelSerializer): """SocialChoice serializers.""" @@ -166,7 +201,6 @@ class ContactEmailBackSerializers(model_serializers.PlateSerializer): ] - class PositionBackSerializer(serializers.ModelSerializer): """Position Back serializer.""" @@ -181,6 +215,7 @@ class PositionBackSerializer(serializers.ModelSerializer): 'index_name', ] + # TODO: test decorator @with_base_attributes class EmployeeBackSerializers(serializers.ModelSerializer): @@ -190,24 +225,22 @@ class EmployeeBackSerializers(serializers.ModelSerializer): establishment = serializers.SerializerMethodField() awards = AwardSerializer(many=True, read_only=True) - def get_public_mark(self, obj): """Get last list actual public_mark""" - qs = obj.establishmentemployee_set.actual().order_by('-from_date')\ + qs = obj.establishmentemployee_set.actual().order_by('-from_date') \ .values('establishment__public_mark').first() return qs['establishment__public_mark'] if qs else None - def get_positions(self, obj): """Get last list actual positions""" - est_id = obj.establishmentemployee_set.actual().\ + est_id = obj.establishmentemployee_set.actual(). \ order_by('-from_date').first() if not est_id: return None - qs = obj.establishmentemployee_set.actual()\ - .filter(establishment_id=est_id.establishment_id)\ + qs = obj.establishmentemployee_set.actual() \ + .filter(establishment_id=est_id.establishment_id) \ .prefetch_related('position').values('position') positions = models.Position.objects.filter(id__in=[q['position'] for q in qs]) @@ -216,7 +249,7 @@ class EmployeeBackSerializers(serializers.ModelSerializer): def get_establishment(self, obj): """Get last actual establishment""" - est = obj.establishmentemployee_set.actual().order_by('-from_date')\ + est = obj.establishmentemployee_set.actual().order_by('-from_date') \ .first() if not est: @@ -380,6 +413,7 @@ class EstablishmentNoteListCreateSerializer(EstablishmentNoteBaseSerializer): class EstablishmentAdminListSerializer(UserShortSerializer): """Establishment admin serializer.""" + class Meta: model = UserShortSerializer.Meta.model fields = [ diff --git a/apps/establishment/serializers/common.py b/apps/establishment/serializers/common.py index ada87016..d53fca1e 100644 --- a/apps/establishment/serializers/common.py +++ b/apps/establishment/serializers/common.py @@ -8,6 +8,8 @@ from comment.serializers import common as comment_serializers from establishment import models from location.serializers import AddressBaseSerializer, CitySerializer, AddressDetailSerializer, \ CityShortSerializer +from location.serializers import EstablishmentWineRegionBaseSerializer, \ + EstablishmentWineOriginBaseSerializer from main.serializers import AwardSerializer, CurrencySerializer from review.serializers import ReviewShortSerializer from tag.serializers import TagBaseSerializer @@ -16,8 +18,6 @@ from utils import exceptions as utils_exceptions from utils.serializers import ImageBaseSerializer, CarouselCreateSerializer from utils.serializers import (ProjectModelSerializer, TranslatedField, FavoritesCreateSerializer) -from location.serializers import EstablishmentWineRegionBaseSerializer, \ - EstablishmentWineOriginBaseSerializer class ContactPhonesSerializer(serializers.ModelSerializer): diff --git a/apps/main/models.py b/apps/main/models.py index b9433b7d..6882568f 100644 --- a/apps/main/models.py +++ b/apps/main/models.py @@ -17,6 +17,7 @@ from configuration.models import TranslationSettings from location.models import Country from main import methods from review.models import Review +from tag.models import Tag from utils.exceptions import UnprocessableEntityError from utils.methods import dictfetchall from utils.models import (ProjectBaseMixin, TJSONField, URLImageMixin, @@ -118,6 +119,8 @@ class Feature(ProjectBaseMixin, PlatformMixin): site_settings = models.ManyToManyField(SiteSettings, through='SiteFeature') old_id = models.IntegerField(null=True, blank=True) + chosen_tags = generic.GenericRelation(to='tag.ChosenTag') + class Meta: """Meta class.""" verbose_name = _('Feature') @@ -126,6 +129,10 @@ class Feature(ProjectBaseMixin, PlatformMixin): def __str__(self): return f'{self.slug}' + @property + def get_chosen_tags(self): + return Tag.objects.filter(chosen_tags__in=self.chosen_tags.all()).distinct() + class SiteFeatureQuerySet(models.QuerySet): """Extended queryset for SiteFeature model.""" diff --git a/apps/main/serializers.py b/apps/main/serializers.py index 4f097273..afb77aa8 100644 --- a/apps/main/serializers.py +++ b/apps/main/serializers.py @@ -6,6 +6,7 @@ from account.models import User from account.serializers.back import BackUserSerializer from location.serializers import CountrySerializer from main import models +from tag.serializers import TagBackOfficeSerializer from utils.serializers import ProjectModelSerializer, TranslatedField, RecursiveFieldSerializer @@ -90,6 +91,8 @@ class SiteFeatureSerializer(serializers.ModelSerializer): route = serializers.CharField(source='feature.route.name', allow_null=True) source = serializers.IntegerField(source='feature.source', allow_null=True) nested = RecursiveFieldSerializer(many=True, read_only=True, allow_null=True) + chosen_tags = TagBackOfficeSerializer( + source='feature.get_chosen_tags', many=True, read_only=True) class Meta: """Meta class.""" @@ -102,6 +105,7 @@ class SiteFeatureSerializer(serializers.ModelSerializer): 'route', 'source', 'nested', + 'chosen_tags', ) diff --git a/apps/news/management/commands/news_optimize_images.py b/apps/news/management/commands/news_optimize_images.py new file mode 100644 index 00000000..440afbc2 --- /dev/null +++ b/apps/news/management/commands/news_optimize_images.py @@ -0,0 +1,68 @@ +# coding=utf-8 +from django.core.management.base import BaseCommand + +from utils.methods import get_url_images_in_text, get_image_meta_by_url +from news.models import News +from sorl.thumbnail import get_thumbnail + + +class Command(BaseCommand): + IMAGE_MAX_SIZE_IN_BYTES = 1048576 # ~ 1mb + IMAGE_QUALITY_PERCENTS = 50 + + def add_arguments(self, parser): + parser.add_argument( + '-s', + '--size', + default=self.IMAGE_MAX_SIZE_IN_BYTES, + help='Максимальный размер файла в байтах', + type=int + ) + parser.add_argument( + '-q', + '--quality', + default=self.IMAGE_QUALITY_PERCENTS, + help='Качество изображения', + type=int + ) + + def optimize(self, text, max_size, max_quality): + """optimize news images""" + for image in get_url_images_in_text(text): + try: + size, width, height = get_image_meta_by_url(image) + except IOError as ie: + self.stdout.write(self.style.NOTICE(f'{ie}\n')) + continue + + if size < max_size: + self.stdout.write(self.style.SUCCESS(f'No need to compress images size is {size / (2**20)}Mb\n')) + continue + + percents = round(max_size / (size * 0.01)) + width = round(width * percents / 100) + height = round(height * percents / 100) + optimized_image = get_thumbnail( + file_=image, + geometry_string=f'{width}x{height}', + upscale=False, + quality=max_quality + ).url + text = text.replace(image, optimized_image) + self.stdout.write(self.style.SUCCESS(f'Optimized {image} -> {optimized_image}\n' + f'Quality [{percents}%]\n')) + + return text + + def handle(self, *args, **options): + size = options['size'] + quality = options['quality'] + + for news in News.objects.all(): + if not isinstance(news.description, dict): + continue + news.description = { + locale: self.optimize(text, size, quality) + for locale, text in news.description.items() + } + news.save() diff --git a/apps/news/models.py b/apps/news/models.py index 3beb0d80..79e2bbd0 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -54,7 +54,6 @@ class NewsType(models.Model): name = models.CharField(_('name'), max_length=250) tag_categories = models.ManyToManyField('tag.TagCategory', related_name='news_types') - chosen_tags = generic.GenericRelation(to='tag.ChosenTag') class Meta: """Meta class.""" diff --git a/apps/product/models.py b/apps/product/models.py index b8195fa8..d31e2477 100644 --- a/apps/product/models.py +++ b/apps/product/models.py @@ -38,6 +38,13 @@ class ProductType(TypeDefaultImageMixin, TranslatedFieldsMixin, ProjectBaseMixin (SOUVENIR, 'souvenir'), (BOOK, 'book') ) + + INDEX_PLURAL_ONE = { + 'food': 'food', + 'wines': 'wine', + 'liquors': 'liquor', + } + name = TJSONField(blank=True, null=True, default=None, verbose_name=_('Name'), help_text='{"en-GB":"some text"}') index_name = models.CharField(max_length=50, unique=True, db_index=True, diff --git a/apps/product/urls/common.py b/apps/product/urls/common.py index 4d64b93e..fe0fcd66 100644 --- a/apps/product/urls/common.py +++ b/apps/product/urls/common.py @@ -19,10 +19,13 @@ urlpatterns = [ # similar products by type/subtype # temporary uses single mechanism, bec. description in process - path('slug//similar/wines/', views.SimilarListView.as_view(), - name='similar-wine'), - path('slug//similar/liquors/', views.SimilarListView.as_view(), - name='similar-liquor'), - path('slug//similar/food/', views.SimilarListView.as_view(), - name='similar-food'), + # path('slug//similar/wines/', views.SimilarListView.as_view(), + # name='similar-wine'), + # path('slug//similar/liquors/', views.SimilarListView.as_view(), + # name='similar-liquor'), + # path('slug//similar/food/', views.SimilarListView.as_view(), + # name='similar-food'), + + path('slug//similar//', views.SimilarListView.as_view(), + name='similar-products') ] diff --git a/apps/product/views/common.py b/apps/product/views/common.py index 34990cc3..8b0567b2 100644 --- a/apps/product/views/common.py +++ b/apps/product/views/common.py @@ -6,7 +6,7 @@ from rest_framework import generics, permissions from comment.models import Comment from comment.serializers import CommentRUDSerializer from product import filters, serializers -from product.models import Product +from product.models import Product, ProductType from utils.views import FavoritesCreateDestroyMixinView from utils.pagination import PortionPagination from django.conf import settings @@ -44,8 +44,16 @@ class ProductSimilarView(ProductListView): """ Return base product instance for a getting list of similar products. """ - product = get_object_or_404(Product.objects.all(), - slug=self.kwargs.get('slug')) + find_by = { + 'slug': self.kwargs.get('slug'), + } + + if isinstance(self.kwargs.get('type'), str): + if not self.kwargs.get('type') in ProductType.INDEX_PLURAL_ONE: + return None + find_by['product_type'] = get_object_or_404(ProductType.objects.all(), index_name=ProductType.INDEX_PLURAL_ONE[self.kwargs.get('type')]) + + product = get_object_or_404(Product.objects.all(), **find_by) return product diff --git a/apps/tag/serializers.py b/apps/tag/serializers.py index 1450c7dc..9722e87a 100644 --- a/apps/tag/serializers.py +++ b/apps/tag/serializers.py @@ -9,6 +9,7 @@ from tag import models from utils.exceptions import BindingObjectNotFound, ObjectAlreadyAdded, RemovedBindingObjectNotFound from utils.serializers import TranslatedField from utils.models import get_default_locale, get_language, to_locale +from main.models import Feature def translate_obj(obj): @@ -309,48 +310,25 @@ class ChosenTagSerializer(serializers.ModelSerializer): class ChosenTagBindObjectSerializer(serializers.Serializer): """Serializer for binding chosen tag and objects""" - ESTABLISHMENT_TYPE = 'establishment_type' - NEWS_TYPE = 'news_type' - - TYPE_CHOICES = ( - (ESTABLISHMENT_TYPE, 'Establishment type'), - (NEWS_TYPE, 'News type'), - ) - - type = serializers.ChoiceField(TYPE_CHOICES) - object_id = serializers.IntegerField() + feature_id = serializers.IntegerField() def validate(self, attrs): view = self.context.get('view') request = self.context.get('request') - obj_type = attrs.get('type') - obj_id = attrs.get('object_id') + obj_id = attrs.get('feature_id') tag = view.get_object() attrs['tag'] = tag - if obj_type == self.ESTABLISHMENT_TYPE: - establishment_type = EstablishmentType.objects.filter(pk=obj_id). \ - first() - if not establishment_type: - raise BindingObjectNotFound() - if request.method == 'DELETE' and not establishment_type. \ - chosen_tags.filter(tag=tag). \ - exists(): - raise RemovedBindingObjectNotFound() - attrs['related_object'] = establishment_type - - elif obj_type == self.NEWS_TYPE: - news_type = NewsType.objects.filter(pk=obj_id).first() - if not news_type: - raise BindingObjectNotFound() - if request.method == 'POST' and news_type.chosen_tags. \ - filter(tag=tag).exists(): - raise ObjectAlreadyAdded() - if request.method == 'DELETE' and not news_type.chosen_tags. \ - filter(tag=tag).exists(): - raise RemovedBindingObjectNotFound() - attrs['related_object'] = news_type + feature = Feature.objects.filter(pk=obj_id). \ + first() + if not feature: + raise BindingObjectNotFound() + if request.method == 'DELETE' and not feature. \ + chosen_tags.filter(tag=tag). \ + exists(): + raise RemovedBindingObjectNotFound() + attrs['related_object'] = feature return attrs diff --git a/apps/utils/methods.py b/apps/utils/methods.py index e06881e5..9fd26dca 100644 --- a/apps/utils/methods.py +++ b/apps/utils/methods.py @@ -4,6 +4,9 @@ import random import re import string from collections import namedtuple +from io import BytesIO +from PIL import Image + import requests from django.conf import settings @@ -119,6 +122,7 @@ def absolute_url_decorator(func): return f'{settings.MEDIA_URL}{url_path}/' else: return url_path + return get_absolute_image_url @@ -169,3 +173,16 @@ def section_name_into_index_name(section_name: str): result = re.findall(re_exp, section_name) if result: return f"{' '.join([word.capitalize() if i == 0 else word for i, word in enumerate(result[:-2])])}" + + +def get_url_images_in_text(text): + """Find images urls in text""" + return re.findall(r'(?:http:|https:)?//.*\.(?:png|jpg|svg)', text) + + +def get_image_meta_by_url(url) -> (int, int, int): + """Returns image size (bytes, width, height)""" + image_raw = requests.get(url) + image = Image.open(BytesIO(image_raw.content)) + width, height = image.size + return int(image_raw.headers.get('content-length')), width, height \ No newline at end of file diff --git a/apps/utils/thumbnail_engine.py b/apps/utils/thumbnail_engine.py index f55d58f8..8e3b50ba 100644 --- a/apps/utils/thumbnail_engine.py +++ b/apps/utils/thumbnail_engine.py @@ -5,14 +5,13 @@ from sorl.thumbnail.engines.pil_engine import Engine as PILEngine class GMEngine(PILEngine): def create(self, image, geometry, options): - """ - Processing conductor, returns the thumbnail as an image engine instance - """ image = self.cropbox(image, geometry, options) image = self.orientation(image, geometry, options) image = self.colorspace(image, geometry, options) image = self.remove_border(image, options) + image = self.scale(image, geometry, options) image = self.crop(image, geometry, options) + image = self.scale(image, geometry, options) image = self.rounded(image, geometry, options) image = self.blur(image, geometry, options) image = self.padding(image, geometry, options) diff --git a/make_data_migration.sh b/make_data_migration.sh index 933639fa..edc50180 100755 --- a/make_data_migration.sh +++ b/make_data_migration.sh @@ -27,4 +27,7 @@ ./manage.py transfer --overlook ./manage.py transfer --inquiries ./manage.py transfer --product_review -./manage.py transfer --transfer_text_review \ No newline at end of file +./manage.py transfer --transfer_text_review + +# оптимизация изображений +/manage.py news_optimize_images # сжимает картинки в описаниях новостей \ No newline at end of file diff --git a/project/settings/base.py b/project/settings/base.py index 08a36609..0b5b9c20 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -385,6 +385,7 @@ THUMBNAIL_QUALITY = 85 THUMBNAIL_DEBUG = False SORL_THUMBNAIL_ALIASES = { 'news_preview': {'geometry_string': '300x260', 'crop': 'center'}, + 'news_description': {'geometry_string': '100x100'}, 'news_promo_horizontal_web': {'geometry_string': '1900x600', 'crop': 'center'}, 'news_promo_horizontal_mobile': {'geometry_string': '375x260', 'crop': 'center'}, 'news_tile_horizontal_web': {'geometry_string': '300x275', 'crop': 'center'}, @@ -411,6 +412,8 @@ SORL_THUMBNAIL_ALIASES = { 'city_detail': {'geometry_string': '1120x1120', 'crop': 'center'}, 'city_original': {'geometry_string': '2048x1536', 'crop': 'center'}, 'type_preview': {'geometry_string': '300x260', 'crop': 'center'}, + 'collection_image': {'geometry_string': '940x620', 'upscale': False, 'quality': 100}, + 'establishment_collection_image': {'geometry_string': '940x620', 'upscale': False, 'quality': 100} }