diff --git a/apps/account/migrations/0009_auto_20191002_0648.py b/apps/account/migrations/0009_auto_20191002_0648.py new file mode 100644 index 00000000..d9734907 --- /dev/null +++ b/apps/account/migrations/0009_auto_20191002_0648.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-02 06:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0008_auto_20190912_1325'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(default=None, max_length=254, null=True, unique=True, verbose_name='email address'), + ), + ] diff --git a/apps/account/models.py b/apps/account/models.py index 81ade4fc..4ba03521 100644 --- a/apps/account/models.py +++ b/apps/account/models.py @@ -58,7 +58,7 @@ class User(AbstractUser): blank=True, null=True, default=None) cropped_image_url = models.URLField(verbose_name=_('Cropped image URL path'), blank=True, null=True, default=None) - email = models.EmailField(_('email address'), blank=True, + email = models.EmailField(_('email address'), unique=True, null=True, default=None) email_confirmed = models.BooleanField(_('email status'), default=False) newsletter = models.NullBooleanField(default=True) diff --git a/apps/authorization/serializers/common.py b/apps/authorization/serializers/common.py index 5d8bb3a8..6ee108dd 100644 --- a/apps/authorization/serializers/common.py +++ b/apps/authorization/serializers/common.py @@ -18,12 +18,6 @@ from utils.tokens import GMRefreshToken # Serializers class SignupSerializer(serializers.ModelSerializer): """Signup serializer serializer mixin""" - # REQUEST - username = serializers.CharField(write_only=True) - password = serializers.CharField(write_only=True) - email = serializers.EmailField(write_only=True) - newsletter = serializers.BooleanField(write_only=True) - class Meta: model = account_models.User fields = ( @@ -32,6 +26,12 @@ class SignupSerializer(serializers.ModelSerializer): 'email', 'newsletter' ) + extra_kwargs = { + 'username': {'write_only': True}, + 'password': {'write_only': True}, + 'email': {'write_only': True}, + 'newsletter': {'write_only': True} + } def validate_username(self, value): """Custom username validation""" diff --git a/apps/gallery/migrations/0003_auto_20191001_0647.py b/apps/gallery/migrations/0003_auto_20191001_0647.py new file mode 100644 index 00000000..4defed2b --- /dev/null +++ b/apps/gallery/migrations/0003_auto_20191001_0647.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.4 on 2019-10-01 06:47 + +from django.db import migrations +import sorl.thumbnail.fields +import utils.methods + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0002_auto_20190930_0714'), + ] + + operations = [ + migrations.AlterField( + model_name='image', + name='image', + field=sorl.thumbnail.fields.ImageField(upload_to=utils.methods.image_path, verbose_name='image file'), + ), + ] diff --git a/apps/gallery/models.py b/apps/gallery/models.py index 7678c29a..687b42fc 100644 --- a/apps/gallery/models.py +++ b/apps/gallery/models.py @@ -1,16 +1,16 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from easy_thumbnails.fields import ThumbnailerImageField +from sorl.thumbnail.fields import ImageField as SORLImageField from utils.methods import image_path -from utils.models import ProjectBaseMixin, ImageMixin, PlatformMixin +from utils.models import ProjectBaseMixin, SORLImageMixin, PlatformMixin class ImageQuerySet(models.QuerySet): """QuerySet for model Image.""" -class Image(ProjectBaseMixin, ImageMixin, PlatformMixin): +class Image(ProjectBaseMixin, SORLImageMixin, PlatformMixin): """Image model.""" HORIZONTAL = 0 VERTICAL = 1 @@ -20,8 +20,8 @@ class Image(ProjectBaseMixin, ImageMixin, PlatformMixin): (VERTICAL, _('Vertical')), ) - image = ThumbnailerImageField(upload_to=image_path, - verbose_name=_('image file')) + image = SORLImageField(upload_to=image_path, + verbose_name=_('image file')) parent = models.ForeignKey('self', blank=True, null=True, default=None, related_name='parent_image', diff --git a/apps/gallery/serializers.py b/apps/gallery/serializers.py index c428b8f2..f575001a 100644 --- a/apps/gallery/serializers.py +++ b/apps/gallery/serializers.py @@ -8,7 +8,6 @@ class ImageSerializer(serializers.ModelSerializer): # REQUEST file = serializers.ImageField(source='image', write_only=True) - title = serializers.CharField() orientation = serializers.ChoiceField(choices=models.Image.ORIENTATIONS, write_only=True) @@ -21,7 +20,7 @@ class ImageSerializer(serializers.ModelSerializer): class Meta: """Meta class""" model = models.Image - fields = ( + fields = [ 'id', 'file', 'url', @@ -29,5 +28,4 @@ class ImageSerializer(serializers.ModelSerializer): 'orientation', 'orientation_display', 'title', - ) - + ] diff --git a/apps/news/migrations/0020_merge_20190930_1251.py b/apps/news/migrations/0020_merge_20190930_1251.py new file mode 100644 index 00000000..deb45e63 --- /dev/null +++ b/apps/news/migrations/0020_merge_20190930_1251.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-09-30 12:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0019_news_author'), + ('news', '0016_news_gallery'), + ] + + operations = [ + ] diff --git a/apps/news/serializers.py b/apps/news/serializers.py index daee42b2..fad2f355 100644 --- a/apps/news/serializers.py +++ b/apps/news/serializers.py @@ -2,6 +2,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from account.serializers.common import UserSerializer from gallery.models import Image from gallery.serializers import ImageSerializer from location import models as location_models @@ -9,7 +10,26 @@ from location.serializers import CountrySimpleSerializer from main.serializers import MetaDataContentSerializer from news import models from utils.serializers import TranslatedField -from account.serializers.common import UserSerializer + + +class NewsImageSerializer(ImageSerializer): + """News images""" + promo_web_url = serializers.SerializerMethodField() + promo_mobile_url = serializers.SerializerMethodField() + + class Meta: + model = Image + fields = ImageSerializer.Meta.fields + [ + 'promo_web_url', + 'promo_mobile_url' + ] + + def get_promo_web_url(self, obj): + return obj.get_image_url(thumbnail_key='news_promo_horizontal_web') + + def get_promo_mobile_url(self, obj): + return obj.get_image_url(thumbnail_key='news_promo_horizontal_mobile') + class NewsTypeSerializer(serializers.ModelSerializer): """News type serializer.""" @@ -31,7 +51,7 @@ class NewsBaseSerializer(serializers.ModelSerializer): # related fields news_type = NewsTypeSerializer(read_only=True) tags = MetaDataContentSerializer(read_only=True, many=True) - gallery = ImageSerializer(read_only=True, many=True) + gallery = NewsImageSerializer(read_only=True, many=True) class Meta: """Meta class.""" @@ -115,14 +135,11 @@ class NewsBackOfficeDetailSerializer(NewsBackOfficeBaseSerializer, class NewsBackOfficeGallerySerializer(serializers.ModelSerializer): """Serializer class for model NewsGallery.""" - image = ImageSerializer(read_only=True) - class Meta: """Meta class""" model = models.NewsGallery fields = [ 'id', - 'image', ] def get_request_kwargs(self): diff --git a/apps/news/views.py b/apps/news/views.py index 6231fd3b..3eb7f9ab 100644 --- a/apps/news/views.py +++ b/apps/news/views.py @@ -1,8 +1,8 @@ """News app views.""" from django.shortcuts import get_object_or_404 -from rest_framework import generics, permissions +from rest_framework import generics, permissions, status +from rest_framework.response import Response -from gallery.serializers import ImageSerializer from news import filters, models, serializers @@ -82,10 +82,15 @@ class NewsBackOfficeGalleryCreateDestroyView(NewsBackOfficeMixinView, return gallery + def create(self, request, *args, **kwargs): + """Override create method""" + super().create(request, *args, **kwargs) + return Response(status=status.HTTP_201_CREATED) + class NewsBackOfficeGalleryListView(NewsBackOfficeMixinView, generics.ListAPIView): """Resource for returning gallery for news for back-office users.""" - serializer_class = ImageSerializer + serializer_class = serializers.NewsImageSerializer def get_object(self): """Override get_object method.""" diff --git a/apps/utils/methods.py b/apps/utils/methods.py index 25027ae9..bea8fec7 100644 --- a/apps/utils/methods.py +++ b/apps/utils/methods.py @@ -1,4 +1,5 @@ """Utils app method.""" +import logging import random import re import string @@ -10,6 +11,9 @@ from django.http.request import HttpRequest from django.utils.timezone import datetime from rest_framework import status from rest_framework.request import Request +from os.path import exists + +logger = logging.getLogger(__name__) def generate_code(digits=6, string_output=True): @@ -91,12 +95,18 @@ def get_contenttype(app_label: str, model: str): def image_url_valid(url: str): - # In case if image storage is not on CDN - if url.startswith('/media/'): - url = f'{settings.SCHEMA_URI}://{settings.DOMAIN_URI}{url}' - response = requests.request('head', url) - if response.status_code == status.HTTP_200_OK: - return True + """ + Check if requested URL is valid. + :param url: string + :return: boolean + """ + try: + assert url.startswith('http') + response = requests.request('head', url) + except Exception as e: + logger.info(f'ConnectionError: {e}') + else: + return response.status_code == status.HTTP_200_OK def absolute_url_decorator(func): @@ -105,7 +115,7 @@ def absolute_url_decorator(func): url_path = func(self, obj) if url_path: if url_path.startswith('/media/'): - return f'{settings.SCHEMA_URI}://{settings.DOMAIN_URI}{url_path}/' + return f'{settings.MEDIA_URL}{url_path}/' else: return url_path return get_absolute_image_url diff --git a/apps/utils/models.py b/apps/utils/models.py index 4e6df35e..299013b7 100644 --- a/apps/utils/models.py +++ b/apps/utils/models.py @@ -1,4 +1,5 @@ """Utils app models.""" +import logging from os.path import exists from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.gis.db import models @@ -10,8 +11,12 @@ from django.utils.translation import ugettext_lazy as _, get_language from easy_thumbnails.fields import ThumbnailerImageField from utils.methods import image_path, svg_image_path from utils.validators import svg_image_validator -from django.db.models.fields import Field -from django.core import exceptions +from sorl.thumbnail.fields import ImageField as SORLImageField +from sorl.thumbnail import get_thumbnail +from django.conf import settings +from utils.methods import image_url_valid + +logger = logging.getLogger(__name__) class ProjectBaseMixin(models.Model): @@ -177,6 +182,38 @@ class ImageMixin(models.Model): image_tag.allow_tags = True +class SORLImageMixin(models.Model): + """Abstract model for SORL ImageField""" + + image = SORLImageField(upload_to=image_path, + blank=True, null=True, default=None, + verbose_name=_('Image')) + + class Meta: + """Meta class.""" + abstract = True + + def get_image(self, thumbnail_key=None): + """Get thumbnail image file.""" + if thumbnail_key in settings.SORL_THUMBNAIL_ALIASES: + return get_thumbnail(file_=self.image, + **settings.SORL_THUMBNAIL_ALIASES[thumbnail_key]) + + def get_image_url(self, thumbnail_key=None): + """Get image thumbnail url.""" + return self.get_image(thumbnail_key).url + + def image_tag(self): + """Admin preview tag.""" + if self.image: + return mark_safe('' % self.image.url) + else: + return None + + image_tag.short_description = _('Image') + image_tag.allow_tags = True + + class SVGImageMixin(models.Model): """SVG image model.""" diff --git a/project/settings/amazon_s3.py b/project/settings/amazon_s3.py new file mode 100644 index 00000000..73b15e28 --- /dev/null +++ b/project/settings/amazon_s3.py @@ -0,0 +1,19 @@ +"""Settings for Amazon S3""" +import os +from .base import MEDIA_LOCATION + +# AMAZON S3 +AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') +AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME') +AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' +AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} + +# Static settings +# PUBLIC_STATIC_LOCATION = 'static' +# STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_STATIC_LOCATION}/' +# STATICFILES_STORAGE = 'project.storage_backends.PublicStaticStorage' + +# Public media settings +MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/' +DEFAULT_FILE_STORAGE = 'project.storage_backends.PublicMediaStorage' diff --git a/project/settings/base.py b/project/settings/base.py index 0ae088ae..2916cff0 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -91,6 +91,8 @@ EXTERNAL_APPS = [ 'rest_framework_simplejwt.token_blacklist', 'solo', 'phonenumber_field', + 'storages', + 'sorl.thumbnail', ] @@ -190,19 +192,6 @@ LOCALE_PATHS = ( os.path.abspath(os.path.join(BASE_DIR, 'locale')), ) -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.2/howto/static-files/ - -STATIC_ROOT = os.path.join(PUBLIC_ROOT, 'static') -STATIC_URL = '/static/' - -MEDIA_ROOT = os.path.join(PUBLIC_ROOT, 'media') -MEDIA_URL = '/media/' - -STATICFILES_DIRS = ( - os.path.join(PROJECT_ROOT, 'static'), -) - AVAILABLE_VERSIONS = { # 'future': '1.0.1', 'current': '1.0.0', @@ -262,6 +251,7 @@ SOCIAL_AUTH_FACEBOOK_PROFILE_EXTRA_PARAMS = { 'fields': 'id, name, email', } + # SMS Settings SMS_EXPIRATION = 5 SMS_SEND_DELAY = 30 @@ -272,12 +262,14 @@ SMS_CODE_LENGTH = 6 SEND_SMS = True SMS_CODE_SHOW = False + # SMSC Settings SMS_SERVICE = 'http://smsc.ru/sys/send.php' SMS_LOGIN = 'GM2019' SMS_PASSWORD = '}#6%Qe7CYG7n' SMS_SENDER = 'GM' + # EMAIL EMAIL_USE_TLS = True EMAIL_HOST = 'smtp.gmail.com' @@ -285,6 +277,7 @@ EMAIL_HOST_USER = 'anatolyfeteleu@gmail.com' EMAIL_HOST_PASSWORD = 'nggrlnbehzksgmbt' EMAIL_PORT = 587 + # Django Rest Swagger SWAGGER_SETTINGS = { # "DEFAULT_GENERATOR_CLASS": "rest_framework.schemas.generators.BaseSchemaGenerator", @@ -307,6 +300,7 @@ REDOC_SETTINGS = { 'LAZY_RENDERING': False, } + # CELERY BROKER_URL = 'amqp://rabbitmq:5672' CELERY_RESULT_BACKEND = BROKER_URL @@ -316,6 +310,7 @@ CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' CELERY_TIMEZONE = TIME_ZONE + # Django FCM (Firebase push notifications) FCM_DJANGO_SETTINGS = { 'FCM_SERVER_KEY': ( @@ -325,6 +320,7 @@ FCM_DJANGO_SETTINGS = { ), } + # Thumbnail settings THUMBNAIL_ALIASES = { '': { @@ -345,11 +341,23 @@ THUMBNAIL_DEFAULT_OPTIONS = { 'crop': 'smart', } -# Password reset -RESETTING_TOKEN_EXPIRATION = 24 # hours +# SORL +THUMBNAIL_QUALITY = 85 +THUMBNAIL_DEBUG = False +SORL_THUMBNAIL_ALIASES = { + 'news_preview': {'geometry_string': '100x100', 'crop': 'center'}, + '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'}, + 'news_tile_horizontal_mobile': {'geometry_string': '343x180', 'crop': 'center'}, + 'news_tile_vertical_web': {'geometry_string': '300x380', 'crop': 'center'}, + 'news_highlight_vertical_web': {'geometry_string': '460x630', 'crop': 'center'}, + 'news_editor_web': {'geometry_string': '940x430', 'crop': 'center'}, + 'news_editor_mobile': {'geometry_string': '343x260', 'crop': 'center'}, # при загрузке через контент эдитор + 'avatar_comments_web': {'geometry_string': '116x116', 'crop': 'center'}, # через контент эдитор в мобильном браузерe +} -GEOIP_PATH = os.path.join(PROJECT_ROOT, 'geoip_db') # JWT SIMPLE_JWT = { @@ -409,17 +417,20 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB FILE_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB FILE_UPLOAD_PERMISSIONS = 0o644 + # SOLO SETTINGS # todo: make a separate service (redis?) and update solo_cache SOLO_CACHE = 'default' SOLO_CACHE_PREFIX = 'solo' SOLO_CACHE_TIMEOUT = 300 + # REDIRECT URL SITE_REDIRECT_URL_UNSUBSCRIBE = '/unsubscribe/' SITE_NAME = 'Gault & Millau' + # Used in annotations for establishments. DEFAULT_ESTABLISHMENT_PUBLIC_MARK = 10 # Limit output objects (see in pagination classes). @@ -427,6 +438,19 @@ QUERY_OUTPUT_OBJECTS = 12 # Need to restrict objects to sort (3 times more then expected). LIMITING_QUERY_OBJECTS = QUERY_OUTPUT_OBJECTS * 3 + # GEO # A Spatial Reference System Identifier GEO_DEFAULT_SRID = 4326 +GEOIP_PATH = os.path.join(PROJECT_ROOT, 'geoip_db') + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.2/howto/static-files/ +STATIC_ROOT = os.path.join(PUBLIC_ROOT, 'static') +STATIC_URL = '/static/' + +STATICFILES_DIRS = ( + os.path.join(PROJECT_ROOT, 'static'), +) + +MEDIA_LOCATION = 'media' diff --git a/project/settings/development.py b/project/settings/development.py index 43a60935..29564575 100644 --- a/project/settings/development.py +++ b/project/settings/development.py @@ -1,9 +1,10 @@ """Development settings.""" from .base import * +from .amazon_s3 import * import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration -ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126'] +ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126', '0.0.0.0'] SEND_SMS = False SMS_CODE_SHOW = True diff --git a/project/settings/local.py b/project/settings/local.py index 503ad191..a7cb7c0f 100644 --- a/project/settings/local.py +++ b/project/settings/local.py @@ -17,6 +17,8 @@ BROKER_URL = 'amqp://rabbitmq:5672' CELERY_RESULT_BACKEND = BROKER_URL CELERY_BROKER_URL = BROKER_URL +MEDIA_ROOT = os.path.join(PUBLIC_ROOT, MEDIA_LOCATION) +MEDIA_URL = f'{SCHEMA_URI}://{DOMAIN_URI}/{MEDIA_LOCATION}/' # LOGGING LOGGING = { @@ -62,8 +64,10 @@ ELASTICSEARCH_DSL = { } } - ELASTICSEARCH_INDEX_NAMES = { # 'search_indexes.documents.news': 'local_news', 'search_indexes.documents.establishment': 'local_establishment', -} \ No newline at end of file +} + +# SORL thumbnails +THUMBNAIL_DEBUG = True diff --git a/project/settings/production.py b/project/settings/production.py index e491a1fb..f2855592 100644 --- a/project/settings/production.py +++ b/project/settings/production.py @@ -1,2 +1,3 @@ """Production settings.""" from .base import * +from .amazon_s3 import * diff --git a/project/settings/stage.py b/project/settings/stage.py index c0d6fdb1..e1443ab1 100644 --- a/project/settings/stage.py +++ b/project/settings/stage.py @@ -1,5 +1,6 @@ """Stage settings.""" from .base import * +from .amazon_s3 import * ALLOWED_HOSTS = ['gm-stage.id-east.ru', '95.213.204.126'] diff --git a/project/storage_backends.py b/project/storage_backends.py new file mode 100644 index 00000000..633337e8 --- /dev/null +++ b/project/storage_backends.py @@ -0,0 +1,12 @@ +"""Extend storage backend for adding custom parameters""" +from storages.backends.s3boto3 import S3Boto3Storage + + +class PublicMediaStorage(S3Boto3Storage): + location = 'media' + file_overwrite = False + + +class PublicStaticStorage(S3Boto3Storage): + location = 'static' + file_overwrite = False diff --git a/project/urls/__init__.py b/project/urls/__init__.py index f1a89695..ca76ff43 100644 --- a/project/urls/__init__.py +++ b/project/urls/__init__.py @@ -64,7 +64,7 @@ urlpatterns = [ ] urlpatterns = urlpatterns + \ - static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + static(settings.MEDIA_LOCATION, document_root=settings.MEDIA_ROOT) if settings.DEBUG: urlpatterns.extend(urlpatterns_doc) diff --git a/requirements/base.txt b/requirements/base.txt index 25749c4b..3d8955cd 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -32,4 +32,10 @@ djangorestframework-simplejwt==4.3.0 django-elasticsearch-dsl>=7.0.0,<8.0.0 django-elasticsearch-dsl-drf==0.20.2 -sentry-sdk==0.11.2 \ No newline at end of file +sentry-sdk==0.11.2 + +# AMAZON S3 +boto3==1.9.238 +django-storages==1.7.2 + +sorl-thumbnail==12.5.0 \ No newline at end of file