Merge remote-tracking branch 'origin/feature/develop_ci' into feature/migrate-news

# Conflicts:
#	apps/gallery/models.py
#	requirements/base.txt
This commit is contained in:
alex 2019-10-24 13:11:05 +03:00
commit 0adac058c5
88 changed files with 1505 additions and 185 deletions

2
.gitignore vendored
View File

@ -22,3 +22,5 @@ logs/
# dev # dev
./docker-compose.override.yml ./docker-compose.override.yml
celerybeat-schedule

40
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,40 @@
image: docker:latest
stages:
- build
- test
- deploy
- clean
clean:
stage: clean
script:
- docker-compose -f compose-ci.yml stop
- docker-compose -f compose-ci.yml rm --force gm_app
when: always
buid:
stage: build
script:
- docker-compose -f compose-ci.yml build gm_app
when: always
test:
stage: test
script:
- docker-compose -f compose-ci.yml run gm_app python manage.py test -v 3 --noinput
when: always
deploy-develop:
stage: deploy
only:
- develop
script:
- fab --roles=develop deploy
environment:
name: Develop

View File

@ -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'),
),
]

View File

@ -0,0 +1,14 @@
# Generated by Django 2.2.4 on 2019-10-11 13:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('account', '0009_auto_20191002_0648'),
('account', '0010_user_password_confirmed'),
]
operations = [
]

View File

@ -0,0 +1,14 @@
# Generated by Django 2.2.4 on 2019-10-15 09:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('account', '0011_merge_20191014_1258'),
('account', '0011_merge_20191011_1336'),
]
operations = [
]

View File

@ -0,0 +1,14 @@
# Generated by Django 2.2.4 on 2019-10-23 09:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('account', '0009_auto_20191002_0648'),
('account', '0013_auto_20191016_0810'),
]
operations = [
]

View File

@ -0,0 +1,14 @@
# Generated by Django 2.2.4 on 2019-10-23 13:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('account', '0014_merge_20191023_0959'),
('account', '0012_merge_20191015_0912'),
]
operations = [
]

View File

@ -0,0 +1,24 @@
# Generated by Django 2.2.4 on 2019-10-24 08:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('account', '0015_merge_20191023_1317'),
]
operations = [
migrations.AlterField(
model_name='role',
name='role',
field=models.PositiveIntegerField(choices=[(1, 'Standard user'), (2, 'Comments moderator'), (3, 'Country admin'), (4, 'Content page manager'), (5, 'Establishment manager'), (6, 'Reviewer manager'), (7, 'Restaurant reviewer')], verbose_name='Role'),
),
migrations.AlterField(
model_name='userrole',
name='establishment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='establishment.Establishment', verbose_name='Establishment'),
),
]

View File

@ -89,7 +89,7 @@ class User(AbstractUser):
blank=True, null=True, default=None) blank=True, null=True, default=None)
cropped_image_url = models.URLField(verbose_name=_('Cropped image URL path'), cropped_image_url = models.URLField(verbose_name=_('Cropped image URL path'),
blank=True, null=True, default=None) blank=True, null=True, default=None)
email = models.EmailField(_('email address'), blank=True, email = models.EmailField(_('email address'), unique=True,
null=True, default=None) null=True, default=None)
unconfirmed_email = models.EmailField(_('unconfirmed email'), blank=True, null=True, default=None) unconfirmed_email = models.EmailField(_('unconfirmed email'), blank=True, null=True, default=None)
email_confirmed = models.BooleanField(_('email status'), default=False) email_confirmed = models.BooleanField(_('email status'), default=False)
@ -214,6 +214,15 @@ class User(AbstractUser):
template_name=settings.RESETTING_TOKEN_TEMPLATE, template_name=settings.RESETTING_TOKEN_TEMPLATE,
context=context) context=context)
def notify_password_changed_template(self, country_code):
"""Get notification email template"""
context = {'contry_code': country_code}
context.update(self.base_template)
return render_to_string(
template_name=settings.NOTIFICATION_PASSWORD_TEMPLATE,
context=context,
)
def confirm_email_template(self, country_code): def confirm_email_template(self, country_code):
"""Get confirm email template""" """Get confirm email template"""
context = {'token': self.confirm_email_token, context = {'token': self.confirm_email_token,

View File

@ -127,6 +127,14 @@ class ChangePasswordSerializer(serializers.ModelSerializer):
except serializers.ValidationError as e: except serializers.ValidationError as e:
raise serializers.ValidationError({'detail': e.detail}) raise serializers.ValidationError({'detail': e.detail})
else: else:
if settings.USE_CELERY:
tasks.send_password_changed_email(
user_id=self.instance.id,
country_code=self.context.get('request').country_code)
else:
tasks.send_password_changed_email(
user_id=self.instance.id,
country_code=self.context.get('request').country_code)
return attrs return attrs
def update(self, instance, validated_data): def update(self, instance, validated_data):

View File

@ -1,8 +1,9 @@
"""Serializers for account web""" """Serializers for account web"""
from django.conf import settings
from django.contrib.auth import password_validation as password_validators from django.contrib.auth import password_validation as password_validators
from rest_framework import serializers from rest_framework import serializers
from account import models from account import models, tasks
from utils import exceptions as utils_exceptions from utils import exceptions as utils_exceptions
from utils.methods import username_validator from utils.methods import username_validator
@ -68,4 +69,12 @@ class PasswordResetConfirmSerializer(serializers.ModelSerializer):
# Update user password from instance # Update user password from instance
instance.set_password(validated_data.get('password')) instance.set_password(validated_data.get('password'))
instance.save() instance.save()
if settings.USE_CELERY:
tasks.send_password_changed_email(
user_id=instance.id,
country_code=self.context.get('request').country_code)
else:
tasks.send_password_changed_email(
user_id=instance.id,
country_code=self.context.get('request').country_code)
return instance return instance

View File

@ -1,47 +1,46 @@
"""Account app celery tasks.""" """Account app celery tasks."""
import inspect
import logging import logging
from celery import shared_task from celery import shared_task
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from . import models from account.models import User
logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO) logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def send_email(user_id: int, subject: str, message_prop: str, country_code: str):
try:
user = User.objects.get(id=user_id)
user.send_email(subject=_(subject),
message=getattr(user, message_prop, lambda _: '')(country_code))
except:
cur_frame = inspect.currentframe()
cal_frame = inspect.getouterframes(cur_frame, 2)
logger.error(f'METHOD_NAME: {cal_frame[1][3]}\n'
f'DETAIL: Exception occurred for user: {user_id}')
@shared_task @shared_task
def send_reset_password_email(user_id, country_code): def send_reset_password_email(user_id, country_code):
"""Send email to user for reset password.""" """Send email to user for reset password."""
try: send_email(user_id, 'Password_resetting', 'reset_password_template', country_code)
user = models.User.objects.get(id=user_id)
user.send_email(subject=_('Password resetting'),
message=user.reset_password_template(country_code))
except:
logger.error(f'METHOD_NAME: {send_reset_password_email.__name__}\n'
f'DETAIL: Exception occurred for reset password: '
f'{user_id}')
@shared_task @shared_task
def confirm_new_email_address(user_id, country_code): def confirm_new_email_address(user_id, country_code):
"""Send email to user new email.""" """Send email to user new email."""
try: send_email(user_id, 'Confirm new email address', 'confirm_email_template', country_code)
user = models.User.objects.get(id=user_id)
user.send_email(subject=_('Validate new email address'),
message=user.confirm_email_template(country_code))
except:
logger.error(f'METHOD_NAME: {confirm_new_email_address.__name__}\n'
f'DETAIL: Exception occurred for user: {user_id}')
@shared_task @shared_task
def change_email_address(user_id, country_code): def change_email_address(user_id, country_code):
"""Send email to user new email.""" """Send email to user new email."""
try: send_email(user_id, 'Validate new email address', 'change_email_template', country_code)
user = models.User.objects.get(id=user_id)
user.send_email(subject=_('Validate new email address'),
message=user.change_email_template(country_code)) @shared_task
except: def send_password_changed_email(user_id, country_code):
logger.error(f'METHOD_NAME: {change_email_address.__name__}\n' """Send email which notifies user that his password had changed"""
f'DETAIL: Exception occurred for user: {user_id}') send_email(user_id, 'Notify password changed', 'notify_password_changed_template', country_code)

View File

@ -18,12 +18,6 @@ from utils.tokens import GMRefreshToken
# Serializers # Serializers
class SignupSerializer(serializers.ModelSerializer): class SignupSerializer(serializers.ModelSerializer):
"""Signup serializer serializer mixin""" """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: class Meta:
model = account_models.User model = account_models.User
fields = ( fields = (
@ -32,6 +26,12 @@ class SignupSerializer(serializers.ModelSerializer):
'email', 'email',
'newsletter' 'newsletter'
) )
extra_kwargs = {
'username': {'write_only': True},
'password': {'write_only': True},
'email': {'write_only': True},
'newsletter': {'write_only': True}
}
def validate_username(self, value): def validate_username(self, value):
"""Custom username validation""" """Custom username validation"""

View File

@ -0,0 +1,23 @@
from django.core.management.base import BaseCommand
from pytz import timezone as py_tz
from timezonefinder import TimezoneFinder
from establishment.models import Establishment
class Command(BaseCommand):
help = 'Attach correct timestamps according to coordinates to existing establishments'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.tf = TimezoneFinder(in_memory=True)
def handle(self, *args, **options):
for establishment in Establishment.objects.prefetch_related('address').all():
if establishment.address and establishment.address.latitude and establishment.address.longitude:
establishment.tz = py_tz(self.tf.certain_timezone_at(lng=establishment.address.longitude,
lat=establishment.address.latitude))
establishment.save()
self.stdout.write(self.style.SUCCESS(f'Attached timezone {establishment.tz} to {establishment}'))
else:
self.stdout.write(self.style.WARNING(f'Establishment {establishment} has no coordinates'
f'passing...'))

View File

@ -8,7 +8,7 @@ def fill_establishment_subtype(apps, schema_editor):
# version than this migration expects. We use the historical version. # version than this migration expects. We use the historical version.
EstablishmentSubType = apps.get_model('establishment', 'EstablishmentSubType') EstablishmentSubType = apps.get_model('establishment', 'EstablishmentSubType')
for n, et in enumerate(EstablishmentSubType.objects.all()): for n, et in enumerate(EstablishmentSubType.objects.all()):
et.index_name = f'Type {n}' et.index_name = 'Type %s' % n
et.save() et.save()

View File

@ -0,0 +1,21 @@
# Generated by Django 2.2.4 on 2019-10-21 17:33
import timezone_field.fields
from django.db import migrations
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('establishment', '0041_auto_20191023_0920'),
]
operations = [
migrations.AddField(
model_name='establishment',
name='tz',
field=timezone_field.fields.TimeZoneField(default=settings.TIME_ZONE),
),
]

View File

@ -1,6 +1,8 @@
"""Establishment models.""" """Establishment models."""
from datetime import datetime
from functools import reduce from functools import reduce
import elasticsearch_dsl
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes import fields as generic from django.contrib.contenttypes import fields as generic
from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.db.models.functions import Distance
@ -19,6 +21,7 @@ from main.models import Award
from review.models import Review from review.models import Review
from utils.models import (ProjectBaseMixin, TJSONField, URLImageMixin, from utils.models import (ProjectBaseMixin, TJSONField, URLImageMixin,
TranslatedFieldsMixin, BaseAttributes) TranslatedFieldsMixin, BaseAttributes)
from timezone_field import TimeZoneField
# todo: establishment type&subtypes check # todo: establishment type&subtypes check
@ -128,15 +131,15 @@ class EstablishmentQuerySet(models.QuerySet):
else: else:
return self.none() return self.none()
# def es_search(self, value, locale=None): def es_search(self, value, locale=None):
# """Search text via ElasticSearch.""" """Search text via ElasticSearch."""
# from search_indexes.documents import EstablishmentDocument from search_indexes.documents import EstablishmentDocument
# search = EstablishmentDocument.search().filter( search = EstablishmentDocument.search().filter(
# Elastic_Q('match', name=value) | elasticsearch_dsl.Q('match', name=value) |
# Elastic_Q('match', **{f'description.{locale}': value}) elasticsearch_dsl.Q('match', **{f'description.{locale}': value})
# ).execute() ).execute()
# ids = [result.meta.id for result in search] ids = [result.meta.id for result in search]
# return self.filter(id__in=ids) return self.filter(id__in=ids)
def by_country_code(self, code): def by_country_code(self, code):
"""Return establishments by country code""" """Return establishments by country code"""
@ -204,7 +207,7 @@ class EstablishmentQuerySet(models.QuerySet):
.filter(image_url__isnull=False, public_mark__gte=10) .filter(image_url__isnull=False, public_mark__gte=10)
.has_published_reviews() .has_published_reviews()
.annotate_distance(point=establishment.location) .annotate_distance(point=establishment.location)
.order_by('distance')[:settings.LIMITING_QUERY_NUMBER] .order_by('distance')[:settings.LIMITING_QUERY_OBJECTS]
.values('id') .values('id')
) )
return self.filter(id__in=subquery_filter_by_distance) \ return self.filter(id__in=subquery_filter_by_distance) \
@ -224,7 +227,7 @@ class EstablishmentQuerySet(models.QuerySet):
self.filter(image_url__isnull=False, public_mark__gte=10) self.filter(image_url__isnull=False, public_mark__gte=10)
.has_published_reviews() .has_published_reviews()
.annotate_distance(point=point) .annotate_distance(point=point)
.order_by('distance')[:settings.LIMITING_QUERY_NUMBER] .order_by('distance')[:settings.LIMITING_QUERY_OBJECTS]
.values('id') .values('id')
) )
return self.filter(id__in=subquery_filter_by_distance) \ return self.filter(id__in=subquery_filter_by_distance) \
@ -351,6 +354,7 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin):
blank=True, null=True, default=None) blank=True, null=True, default=None)
slug = models.SlugField(unique=True, max_length=255, null=True, slug = models.SlugField(unique=True, max_length=255, null=True,
verbose_name=_('Establishment slug')) verbose_name=_('Establishment slug'))
tz = TimeZoneField(default=settings.TIME_ZONE)
awards = generic.GenericRelation(to='main.Award', related_query_name='establishment') awards = generic.GenericRelation(to='main.Award', related_query_name='establishment')
tags = models.ManyToManyField('tag.Tag', related_name='establishments', tags = models.ManyToManyField('tag.Tag', related_name='establishments',
@ -416,6 +420,32 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin):
def best_price_carte(self): def best_price_carte(self):
return 200 return 200
@property
def works_noon(self):
""" Used for indexing working by day """
return [ret.weekday for ret in self.schedule.all() if ret.works_at_noon]
@property
def works_evening(self):
""" Used for indexing working by day """
return [ret.weekday for ret in self.schedule.all() if ret.works_at_afternoon]
@property
def works_now(self):
""" Is establishment working now """
now_at_est_tz = datetime.now(tz=self.tz)
current_week = now_at_est_tz.weekday()
schedule_for_today = self.schedule.filter(weekday=current_week).first()
if schedule_for_today is None or schedule_for_today.closed_at is None or schedule_for_today.opening_at is None:
return False
time_at_est_tz = now_at_est_tz.time()
return schedule_for_today.closed_at > time_at_est_tz > schedule_for_today.opening_at
@property
def tags_indexing(self):
return [{'id': tag.metadata.id,
'label': tag.metadata.label} for tag in self.tags.all()]
@property @property
def last_published_review(self): def last_published_review(self):
"""Return last published review""" """Return last published review"""
@ -431,8 +461,8 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin):
@property @property
def the_most_recent_award(self): def the_most_recent_award(self):
return Award.objects.filter(Q(establishment=self) | Q(employees__establishments=self)).latest( return Award.objects.filter(Q(establishment=self) | Q(employees__establishments=self)) \
field_name='vintage_year') .latest(field_name='vintage_year')
@property @property
def country_id(self): def country_id(self):

View File

@ -41,6 +41,7 @@ class EstablishmentListCreateSerializer(EstablishmentBaseSerializer):
'is_publish', 'is_publish',
'guestonline_id', 'guestonline_id',
'lastable_id', 'lastable_id',
'tz',
] ]

View File

@ -26,7 +26,7 @@ class BaseTestCase(APITestCase):
self.test_news = News.objects.create(created_by=self.user, modified_by=self.user, title={"en-GB": "Test news"}, self.test_news = News.objects.create(created_by=self.user, modified_by=self.user, title={"en-GB": "Test news"},
news_type=self.test_news_type, news_type=self.test_news_type,
description={"en-GB": "Description test news"}, description={"en-GB": "Description test news"},
playlist=1, start="2020-12-03 12:00:00", end="2020-12-13 12:00:00", start="2020-12-03 12:00:00", end="2020-12-13 12:00:00",
state=News.PUBLISHED, slug='test-news') state=News.PUBLISHED, slug='test-news')
self.test_content_type = ContentType.objects.get(app_label="news", model="news") self.test_content_type = ContentType.objects.get(app_label="news", model="news")

View File

@ -5,4 +5,9 @@ from gallery.models import Image
@admin.register(Image) @admin.register(Image)
class ImageModelAdmin(admin.ModelAdmin): class ImageModelAdmin(admin.ModelAdmin):
"""Image model admin""" """Image model admin."""
list_display = ['id', 'title', 'orientation_display', 'image_tag', ]
def orientation_display(self, obj):
"""Get image orientation name."""
return obj.get_orientation_display() if obj.orientation else None

View File

@ -0,0 +1,41 @@
# Generated by Django 2.2.4 on 2019-09-30 07:14
from django.db import migrations, models
import django.db.models.deletion
import easy_thumbnails.fields
import utils.methods
class Migration(migrations.Migration):
dependencies = [
('gallery', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='image',
name='orientation',
field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'Horizontal'), (1, 'Vertical')], default=None, null=True, verbose_name='image orientation'),
),
migrations.AddField(
model_name='image',
name='parent',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='parent_image', to='gallery.Image', verbose_name='parent image'),
),
migrations.AddField(
model_name='image',
name='source',
field=models.PositiveSmallIntegerField(choices=[(0, 'Mobile'), (1, 'Web'), (2, 'All')], default=0, verbose_name='Source'),
),
migrations.AddField(
model_name='image',
name='title',
field=models.CharField(default='', max_length=255, verbose_name='title'),
),
migrations.AlterField(
model_name='image',
name='image',
field=easy_thumbnails.fields.ThumbnailerImageField(upload_to=utils.methods.image_path, verbose_name='image file'),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 2.2.4 on 2019-10-03 12:28
from django.db import migrations
import sorl.thumbnail.fields
import utils.methods
class Migration(migrations.Migration):
dependencies = [
('gallery', '0002_auto_20190930_0714'),
]
operations = [
migrations.RemoveField(
model_name='image',
name='parent',
),
migrations.AlterField(
model_name='image',
name='image',
field=sorl.thumbnail.fields.ImageField(upload_to=utils.methods.image_path, verbose_name='image file'),
),
]

View File

@ -1,15 +1,34 @@
from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from easy_thumbnails.fields import ThumbnailerImageField from sorl.thumbnail import delete
from sorl.thumbnail.fields import ImageField as SORLImageField
from utils.methods import image_path from utils.methods import image_path
from utils.models import ProjectBaseMixin, ImageMixin from utils.models import ProjectBaseMixin, SORLImageMixin, PlatformMixin
class Image(ProjectBaseMixin, ImageMixin): class ImageQuerySet(models.QuerySet):
"""QuerySet for model Image."""
class Image(ProjectBaseMixin, SORLImageMixin, PlatformMixin):
"""Image model.""" """Image model."""
HORIZONTAL = 0
VERTICAL = 1
image = ThumbnailerImageField(upload_to=image_path, ORIENTATIONS = (
verbose_name=_('Image file'), max_length=255) (HORIZONTAL, _('Horizontal')),
(VERTICAL, _('Vertical')),
)
image = SORLImageField(upload_to=image_path,
verbose_name=_('image file'))
orientation = models.PositiveSmallIntegerField(choices=ORIENTATIONS,
blank=True, null=True, default=None,
verbose_name=_('image orientation'))
title = models.CharField(_('title'), max_length=255, default='')
objects = ImageQuerySet.as_manager()
class Meta: class Meta:
"""Meta class.""" """Meta class."""
@ -18,4 +37,22 @@ class Image(ProjectBaseMixin, ImageMixin):
def __str__(self): def __str__(self):
"""String representation""" """String representation"""
return str(self.id) return f'{self.id}'
def delete_image(self, completely: bool = True):
"""
Deletes an instance and crops of instance from media storage.
:param completely: if set to False then removed only crop neither original image.
"""
try:
# Delete from remote storage
delete(file_=self.image.file, delete_file=completely)
except FileNotFoundError:
pass
finally:
if completely:
# Delete an instance of image
super().delete()

View File

@ -12,13 +12,20 @@ class ImageSerializer(serializers.ModelSerializer):
# RESPONSE # RESPONSE
url = serializers.ImageField(source='image', url = serializers.ImageField(source='image',
read_only=True) read_only=True)
orientation_display = serializers.CharField(source='get_orientation_display',
read_only=True)
class Meta: class Meta:
"""Meta class""" """Meta class"""
model = models.Image model = models.Image
fields = ( fields = [
'id', 'id',
'file', 'file',
'url' 'url',
) 'orientation',
'orientation_display',
'title',
]
extra_kwargs = {
'orientation': {'write_only': True}
}

23
apps/gallery/tasks.py Normal file
View File

@ -0,0 +1,23 @@
"""Gallery app celery tasks."""
import logging
from celery import shared_task
from . import models
logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)
@shared_task
def delete_image(image_id: int, completely: bool = True):
"""Delete an image from remote storage."""
image_qs = models.Image.objects.filter(id=image_id)
if image_qs.exists():
try:
image = image_qs.first()
image.delete_image(completely=completely)
except:
logger.error(f'TASK_NAME: delete_image\n'
f'DETAIL: Exception occurred while deleting an image '
f'and related crops from remote storage: image_id - {image_id}')

View File

@ -6,5 +6,6 @@ from . import views
app_name = 'gallery' app_name = 'gallery'
urlpatterns = [ urlpatterns = [
path('upload/', views.ImageUploadView.as_view(), name='upload-image') path('', views.ImageListCreateView.as_view(), name='list-create-image'),
path('<int:pk>/', views.ImageRetrieveDestroyView.as_view(), name='retrieve-destroy-image'),
] ]

View File

@ -1,10 +1,30 @@
from rest_framework import generics from django.conf import settings
from django.db.transaction import on_commit
from rest_framework import generics, status
from rest_framework.response import Response
from . import models, serializers from . import tasks, models, serializers
class ImageUploadView(generics.CreateAPIView): class ImageBaseView(generics.GenericAPIView):
"""Upload image to gallery""" """Base Image view."""
model = models.Image model = models.Image
queryset = models.Image.objects.all() queryset = models.Image.objects.all()
serializer_class = serializers.ImageSerializer serializer_class = serializers.ImageSerializer
class ImageListCreateView(ImageBaseView, generics.ListCreateAPIView):
"""List/Create Image view."""
class ImageRetrieveDestroyView(ImageBaseView, generics.RetrieveDestroyAPIView):
"""Destroy view for model Image"""
def delete(self, request, *args, **kwargs):
"""Override destroy view"""
instance = self.get_object()
if settings.USE_CELERY:
on_commit(lambda: tasks.delete_image.delay(image_id=instance.id))
else:
on_commit(lambda: tasks.delete_image(image_id=instance.id))
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -5,8 +5,9 @@ from django.db.models.signals import post_save
from django.db.transaction import on_commit from django.db.transaction import on_commit
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from utils.models import ProjectBaseMixin, SVGImageMixin, TranslatedFieldsMixin, TJSONField
from translation.models import Language from translation.models import Language
from utils.models import ProjectBaseMixin, SVGImageMixin, TranslatedFieldsMixin, TJSONField
class Country(TranslatedFieldsMixin, SVGImageMixin, ProjectBaseMixin): class Country(TranslatedFieldsMixin, SVGImageMixin, ProjectBaseMixin):

View File

@ -0,0 +1,57 @@
# Generated by Django 2.2.4 on 2019-10-23 11:13
from django.db import migrations, models
import django.db.models.deletion
import utils.models
def fill_currency_name(apps, schema_editor):
# We can't import the Person model directly as it may be a newer
# version than this migration expects. We use the historical version.
Currency = apps.get_model('main', 'Currency')
for currency in Currency.objects.all():
currency.name_json = {'en-GB': currency.name}
currency.save()
class Migration(migrations.Migration):
dependencies = [
('main', '0021_auto_20191023_0924'),
]
operations = [
migrations.AddField(
model_name='currency',
name='sign',
field=models.CharField(default='?', max_length=1, verbose_name='sign'),
preserve_default=False,
),
migrations.AddField(
model_name='currency',
name='slug',
field=models.SlugField(default='?', max_length=255, unique=True),
preserve_default=False,
),
migrations.AddField(
model_name='sitesettings',
name='currency',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='main.Currency'),
),
migrations.AddField(
model_name='currency',
name='name_json',
field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='name'),
),
migrations.RunPython(fill_currency_name, migrations.RunPython.noop),
migrations.RemoveField(
model_name='currency',
name='name',
),
migrations.RenameField(
model_name='currency',
old_name='name_json',
new_name='name',
),
]

View File

@ -106,6 +106,22 @@ from utils.querysets import ContentTypeQuerySetMixin
# #
class Currency(TranslatedFieldsMixin, models.Model):
"""Currency model."""
name = TJSONField(
_('name'), null=True, blank=True,
default=None, help_text='{"en-GB":"some text"}')
sign = models.CharField(_('sign'), max_length=1)
slug = models.SlugField(max_length=255, unique=True)
class Meta:
verbose_name = _('currency')
verbose_name_plural = _('currencies')
def __str__(self):
return f'{self.name}'
class SiteSettingsQuerySet(models.QuerySet): class SiteSettingsQuerySet(models.QuerySet):
"""Extended queryset for SiteSettings model.""" """Extended queryset for SiteSettings model."""
@ -114,6 +130,7 @@ class SiteSettingsQuerySet(models.QuerySet):
class SiteSettings(ProjectBaseMixin): class SiteSettings(ProjectBaseMixin):
subdomain = models.CharField(max_length=255, db_index=True, unique=True, subdomain = models.CharField(max_length=255, db_index=True, unique=True,
verbose_name=_('Subdomain')) verbose_name=_('Subdomain'))
country = models.OneToOneField(Country, on_delete=models.PROTECT, country = models.OneToOneField(Country, on_delete=models.PROTECT,
@ -135,6 +152,7 @@ class SiteSettings(ProjectBaseMixin):
verbose_name=_('Config')) verbose_name=_('Config'))
ad_config = models.TextField(blank=True, null=True, default=None, ad_config = models.TextField(blank=True, null=True, default=None,
verbose_name=_('AD config')) verbose_name=_('AD config'))
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, null=True, default=None)
objects = SiteSettingsQuerySet.as_manager() objects = SiteSettingsQuerySet.as_manager()
@ -257,18 +275,6 @@ class AwardType(models.Model):
return self.name return self.name
class Currency(models.Model):
"""Currency model."""
name = models.CharField(_('name'), max_length=50)
class Meta:
verbose_name = _('currency')
verbose_name_plural = _('currencies')
def __str__(self):
return f'{self.name}'
class CarouselQuerySet(models.QuerySet): class CarouselQuerySet(models.QuerySet):
"""Carousel QuerySet.""" """Carousel QuerySet."""

View File

@ -4,7 +4,7 @@ from rest_framework import serializers
from advertisement.serializers.web import AdvertisementSerializer from advertisement.serializers.web import AdvertisementSerializer
from location.serializers import CountrySerializer from location.serializers import CountrySerializer
from main import models from main import models
from utils.serializers import TranslatedField from utils.serializers import ProjectModelSerializer
class FeatureSerializer(serializers.ModelSerializer): class FeatureSerializer(serializers.ModelSerializer):
@ -40,11 +40,24 @@ class SiteFeatureSerializer(serializers.ModelSerializer):
) )
class CurrencySerializer(ProjectModelSerializer):
"""Currency serializer"""
class Meta:
model = models.Currency
fields = [
'id',
'name_translated',
'sign'
]
class SiteSettingsSerializer(serializers.ModelSerializer): class SiteSettingsSerializer(serializers.ModelSerializer):
"""Site settings serializer.""" """Site settings serializer."""
published_features = SiteFeatureSerializer(source='published_sitefeatures', published_features = SiteFeatureSerializer(source='published_sitefeatures',
many=True, allow_null=True) many=True, allow_null=True)
currency = CurrencySerializer()
# todo: remove this # todo: remove this
country_code = serializers.CharField(source='subdomain', read_only=True) country_code = serializers.CharField(source='subdomain', read_only=True)
@ -63,6 +76,7 @@ class SiteSettingsSerializer(serializers.ModelSerializer):
'config', 'config',
'ad_config', 'ad_config',
'published_features', 'published_features',
'currency'
) )
@ -114,17 +128,6 @@ class AwardSerializer(AwardBaseSerializer):
fields = AwardBaseSerializer.Meta.fields + ['award_type', ] fields = AwardBaseSerializer.Meta.fields + ['award_type', ]
class CurrencySerializer(serializers.ModelSerializer):
"""Currency serializer"""
class Meta:
model = models.Currency
fields = [
'id',
'name'
]
class CarouselListSerializer(serializers.ModelSerializer): class CarouselListSerializer(serializers.ModelSerializer):
"""Serializer for retrieving list of carousel items.""" """Serializer for retrieving list of carousel items."""
model_name = serializers.CharField() model_name = serializers.CharField()

View File

@ -1,4 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.conf import settings
from news import models from news import models
from .tasks import send_email_with_news from .tasks import send_email_with_news
@ -12,9 +14,10 @@ class NewsTypeAdmin(admin.ModelAdmin):
def send_email_action(modeladmin, request, queryset): def send_email_action(modeladmin, request, queryset):
news_ids = list(queryset.values_list("id", flat=True)) news_ids = list(queryset.values_list("id", flat=True))
if settings.USE_CELERY:
send_email_with_news.delay(news_ids) send_email_with_news.delay(news_ids)
else:
send_email_with_news(news_ids)
send_email_action.short_description = "Send the selected news by email" send_email_action.short_description = "Send the selected news by email"
@ -24,3 +27,8 @@ send_email_action.short_description = "Send the selected news by email"
class NewsAdmin(admin.ModelAdmin): class NewsAdmin(admin.ModelAdmin):
"""News admin.""" """News admin."""
actions = [send_email_action] actions = [send_email_action]
@admin.register(models.NewsGallery)
class NewsGalleryAdmin(admin.ModelAdmin):
"""News gallery admin."""

View File

@ -0,0 +1,26 @@
# Generated by Django 2.2.4 on 2019-09-30 08:57
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('gallery', '0002_auto_20190930_0714'),
]
operations = [
migrations.CreateModel(
name='NewsGallery',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='news_gallery', to='gallery.Image', verbose_name='gallery')),
('news', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='news_gallery', to='news.News', verbose_name='news')),
],
options={
'verbose_name': 'news gallery',
'verbose_name_plural': 'news galleries',
},
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.4 on 2019-09-30 12:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gallery', '0002_auto_20190930_0714'),
('news', '0015_newsgallery'),
]
operations = [
migrations.AddField(
model_name='news',
name='gallery',
field=models.ManyToManyField(through='news.NewsGallery', to='gallery.Image'),
),
]

View File

@ -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 = [
]

View File

@ -0,0 +1,14 @@
# Generated by Django 2.2.4 on 2019-10-02 13:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('news', '0020_remove_news_author'),
('news', '0020_merge_20190930_1251'),
]
operations = [
]

View File

@ -0,0 +1,14 @@
# Generated by Django 2.2.4 on 2019-10-15 09:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('news', '0021_auto_20191009_1408'),
('news', '0021_merge_20191002_1300'),
]
operations = [
]

View File

@ -0,0 +1,14 @@
# Generated by Django 2.2.4 on 2019-10-23 10:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('news', '0021_merge_20191002_1300'),
('news', '0022_auto_20191021_1306'),
]
operations = [
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.4 on 2019-10-23 10:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('news', '0023_merge_20191023_1000'),
]
operations = [
migrations.AddField(
model_name='newsgallery',
name='is_main',
field=models.BooleanField(default=False, verbose_name='Is the main image'),
),
]

View File

@ -0,0 +1,15 @@
# Generated by Django 2.2.4 on 2019-10-23 13:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('news', '0024_newsgallery_is_main'),
('news', '0023_auto_20191023_0903'),
('news', '0022_merge_20191015_0912'),
]
operations = [
]

View File

@ -0,0 +1,21 @@
# Generated by Django 2.2.4 on 2019-10-24 09:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('news', '0025_merge_20191023_1317'),
]
operations = [
migrations.RemoveField(
model_name='news',
name='image_url',
),
migrations.RemoveField(
model_name='news',
name='preview_image_url',
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 2.2.4 on 2019-10-24 09:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('news', '0026_auto_20191024_0913'),
]
operations = [
migrations.RemoveField(
model_name='news',
name='playlist',
),
]

View File

@ -141,15 +141,10 @@ class News(BaseAttributes, TranslatedFieldsMixin):
verbose_name=_('End')) verbose_name=_('End'))
slug = models.SlugField(unique=True, max_length=255, slug = models.SlugField(unique=True, max_length=255,
verbose_name=_('News slug')) verbose_name=_('News slug'))
playlist = models.IntegerField(_('playlist'))
state = models.PositiveSmallIntegerField(default=WAITING, choices=STATE_CHOICES, state = models.PositiveSmallIntegerField(default=WAITING, choices=STATE_CHOICES,
verbose_name=_('State')) verbose_name=_('State'))
is_highlighted = models.BooleanField(default=False, is_highlighted = models.BooleanField(default=False,
verbose_name=_('Is highlighted')) verbose_name=_('Is highlighted'))
image_url = models.URLField(blank=True, null=True, default=None,
verbose_name=_('Image URL path'))
preview_image_url = models.URLField(blank=True, null=True, default=None,
verbose_name=_('Preview image URL path'))
template = models.PositiveIntegerField(choices=TEMPLATE_CHOICES, default=NEWSPAPER) template = models.PositiveIntegerField(choices=TEMPLATE_CHOICES, default=NEWSPAPER)
address = models.ForeignKey('location.Address', blank=True, null=True, address = models.ForeignKey('location.Address', blank=True, null=True,
default=None, verbose_name=_('address'), default=None, verbose_name=_('address'),
@ -159,6 +154,7 @@ class News(BaseAttributes, TranslatedFieldsMixin):
verbose_name=_('country')) verbose_name=_('country'))
tags = models.ManyToManyField('tag.Tag', related_name='news', tags = models.ManyToManyField('tag.Tag', related_name='news',
verbose_name=_('Tags')) verbose_name=_('Tags'))
gallery = models.ManyToManyField('gallery.Image', through='news.NewsGallery')
ratings = generic.GenericRelation(Rating) ratings = generic.GenericRelation(Rating)
agenda = models.ForeignKey('news.Agenda', blank=True, null=True, agenda = models.ForeignKey('news.Agenda', blank=True, null=True,
@ -195,3 +191,36 @@ class News(BaseAttributes, TranslatedFieldsMixin):
@property @property
def same_theme(self): def same_theme(self):
return self.__class__.objects.same_theme(self)[:3] return self.__class__.objects.same_theme(self)[:3]
@property
def main_image(self):
return self.news_gallery.main_images().first().image
class NewsGalleryQuerySet(models.QuerySet):
"""QuerySet for model News"""
def main_images(self):
"""Return objects with flag is_main is True"""
return self.filter(is_main=True)
class NewsGallery(models.Model):
news = models.ForeignKey(News, null=True,
related_name='news_gallery',
on_delete=models.SET_NULL,
verbose_name=_('news'))
image = models.ForeignKey('gallery.Image', null=True,
related_name='news_gallery',
on_delete=models.SET_NULL,
verbose_name=_('gallery'))
is_main = models.BooleanField(default=False,
verbose_name=_('Is the main image'))
objects = NewsGalleryQuerySet.as_manager()
class Meta:
"""NewsGallery meta class."""
verbose_name = _('news gallery')
verbose_name_plural = _('news galleries')

View File

@ -1,6 +1,9 @@
"""News app common serializers.""" """News app common serializers."""
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from account.serializers.common import UserBaseSerializer from account.serializers.common import UserBaseSerializer
from gallery.models import Image
from location import models as location_models from location import models as location_models
from location.serializers import CountrySimpleSerializer, AddressBaseSerializer from location.serializers import CountrySimpleSerializer, AddressBaseSerializer
from news import models from news import models
@ -42,6 +45,77 @@ class NewsBannerSerializer(ProjectModelSerializer):
) )
class CropImageSerializer(serializers.Serializer):
"""Serializer for crop images for News object."""
preview_url = serializers.SerializerMethodField()
promo_horizontal_web_url = serializers.SerializerMethodField()
promo_horizontal_mobile_url = serializers.SerializerMethodField()
tile_horizontal_web_url = serializers.SerializerMethodField()
tile_horizontal_mobile_url = serializers.SerializerMethodField()
tile_vertical_web_url = serializers.SerializerMethodField()
highlight_vertical_web_url = serializers.SerializerMethodField()
editor_web_url = serializers.SerializerMethodField()
editor_mobile_url = serializers.SerializerMethodField()
def get_preview_url(self, obj):
"""Get crop preview."""
return obj.instance.get_image_url('news_preview')
def get_promo_horizontal_web_url(self, obj):
"""Get crop promo_horizontal_web."""
return obj.instance.get_image_url('news_promo_horizontal_web')
def get_promo_horizontal_mobile_url(self, obj):
"""Get crop promo_horizontal_mobile."""
return obj.instance.get_image_url('news_promo_horizontal_mobile')
def get_tile_horizontal_web_url(self, obj):
"""Get crop tile_horizontal_web."""
return obj.instance.get_image_url('news_tile_horizontal_web')
def get_tile_horizontal_mobile_url(self, obj):
"""Get crop tile_horizontal_mobile."""
return obj.instance.get_image_url('news_tile_horizontal_mobile')
def get_tile_vertical_web_url(self, obj):
"""Get crop tile_vertical_web."""
return obj.instance.get_image_url('news_tile_vertical_web')
def get_highlight_vertical_web_url(self, obj):
"""Get crop highlight_vertical_web."""
return obj.instance.get_image_url('news_highlight_vertical_web')
def get_editor_web_url(self, obj):
"""Get crop editor_web."""
return obj.instance.get_image_url('news_editor_web')
def get_editor_mobile_url(self, obj):
"""Get crop editor_mobile."""
return obj.instance.get_image_url('news_editor_mobile')
class NewsImageSerializer(serializers.ModelSerializer):
"""Serializer for returning crop images of news image."""
orientation_display = serializers.CharField(source='get_orientation_display',
read_only=True)
original_url = serializers.URLField(source='image.url')
auto_crop_images = CropImageSerializer(source='image', allow_null=True)
class Meta:
model = Image
fields = [
'id',
'title',
'orientation_display',
'original_url',
'auto_crop_images',
]
extra_kwargs = {
'orientation': {'write_only': True}
}
class NewsTypeSerializer(serializers.ModelSerializer): class NewsTypeSerializer(serializers.ModelSerializer):
"""News type serializer.""" """News type serializer."""
@ -55,11 +129,8 @@ class NewsTypeSerializer(serializers.ModelSerializer):
class NewsBaseSerializer(ProjectModelSerializer): class NewsBaseSerializer(ProjectModelSerializer):
"""Base serializer for News model.""" """Base serializer for News model."""
# read only fields title_translated = TranslatedField()
title_translated = TranslatedField(source='title')
subtitle_translated = TranslatedField() subtitle_translated = TranslatedField()
# related fields
news_type = NewsTypeSerializer(read_only=True) news_type = NewsTypeSerializer(read_only=True)
tags = TagBaseSerializer(read_only=True, many=True) tags = TagBaseSerializer(read_only=True, many=True)
@ -72,14 +143,25 @@ class NewsBaseSerializer(ProjectModelSerializer):
'title_translated', 'title_translated',
'subtitle_translated', 'subtitle_translated',
'is_highlighted', 'is_highlighted',
'image_url',
'preview_image_url',
'news_type', 'news_type',
'tags', 'tags',
'slug', 'slug',
) )
class NewsListSerializer(NewsBaseSerializer):
"""List serializer for News model."""
image = NewsImageSerializer(source='main_image', allow_null=True)
class Meta(NewsBaseSerializer.Meta):
"""Meta class."""
fields = NewsBaseSerializer.Meta.fields + (
'image',
)
class NewsDetailSerializer(NewsBaseSerializer): class NewsDetailSerializer(NewsBaseSerializer):
"""News detail serializer.""" """News detail serializer."""
@ -88,6 +170,7 @@ class NewsDetailSerializer(NewsBaseSerializer):
author = UserBaseSerializer(source='created_by', read_only=True) author = UserBaseSerializer(source='created_by', read_only=True)
state_display = serializers.CharField(source='get_state_display', state_display = serializers.CharField(source='get_state_display',
read_only=True) read_only=True)
gallery = NewsImageSerializer(read_only=True, many=True)
class Meta(NewsBaseSerializer.Meta): class Meta(NewsBaseSerializer.Meta):
"""Meta class.""" """Meta class."""
@ -96,12 +179,12 @@ class NewsDetailSerializer(NewsBaseSerializer):
'description_translated', 'description_translated',
'start', 'start',
'end', 'end',
'playlist',
'is_publish', 'is_publish',
'state', 'state',
'state_display', 'state_display',
'author', 'author',
'country', 'country',
'gallery',
) )
@ -160,3 +243,48 @@ class NewsBackOfficeDetailSerializer(NewsBackOfficeBaseSerializer,
'template', 'template',
'template_display', 'template_display',
) )
class NewsBackOfficeGallerySerializer(serializers.ModelSerializer):
"""Serializer class for model NewsGallery."""
class Meta:
"""Meta class"""
model = models.NewsGallery
fields = [
'id',
'is_main',
]
def get_request_kwargs(self):
"""Get url kwargs from request."""
return self.context.get('request').parser_context.get('kwargs')
def validate(self, attrs):
"""Override validate method."""
news_pk = self.get_request_kwargs().get('pk')
image_id = self.get_request_kwargs().get('image_id')
is_main = attrs.get('is_main')
news_qs = models.News.objects.filter(pk=news_pk)
image_qs = Image.objects.filter(id=image_id)
if not news_qs.exists():
raise serializers.ValidationError({'detail': _('News not found')})
if not image_qs.exists():
raise serializers.ValidationError({'detail': _('Image not found')})
news = news_qs.first()
image = image_qs.first()
if news.news_gallery.filter(image=image).exists():
raise serializers.ValidationError({'detail': _('Image is already added')})
if is_main and news.news_gallery.main_images().exists():
raise serializers.ValidationError({'detail': _('Main image is already added')})
attrs['news'] = news
attrs['image'] = image
return attrs

View File

@ -50,7 +50,7 @@ class BaseTestCase(APITestCase):
title={"en-GB": "Test news"}, title={"en-GB": "Test news"},
news_type=self.test_news_type, news_type=self.test_news_type,
description={"en-GB": "Description test news"}, description={"en-GB": "Description test news"},
playlist=1, start=datetime.now() + timedelta(hours=-2), start=datetime.now() + timedelta(hours=-2),
end=datetime.now() + timedelta(hours=2), end=datetime.now() + timedelta(hours=2),
state=News.PUBLISHED, slug='test-news-slug', state=News.PUBLISHED, slug='test-news-slug',
country=self.country_ru) country=self.country_ru)
@ -85,7 +85,6 @@ class NewsTestCase(BaseTestCase):
'description': {"en-GB": "Description test news!"}, 'description': {"en-GB": "Description test news!"},
'slug': self.test_news.slug, 'slug': self.test_news.slug,
'start': self.test_news.start, 'start': self.test_news.start,
'playlist': self.test_news.playlist,
'news_type_id':self.test_news.news_type_id, 'news_type_id':self.test_news.news_type_id,
'country_id': self.country_ru.id 'country_id': self.country_ru.id
} }

View File

@ -1,5 +1,6 @@
"""News app urlpatterns for backoffice""" """News app urlpatterns for backoffice"""
from django.urls import path from django.urls import path
from news import views from news import views
app_name = 'news' app_name = 'news'
@ -7,5 +8,9 @@ app_name = 'news'
urlpatterns = [ urlpatterns = [
path('', views.NewsBackOfficeLCView.as_view(), name='list-create'), path('', views.NewsBackOfficeLCView.as_view(), name='list-create'),
path('<int:pk>/', views.NewsBackOfficeRUDView.as_view(), path('<int:pk>/', views.NewsBackOfficeRUDView.as_view(),
name='retrieve-update-destroy'), name='gallery-retrieve-update-destroy'),
path('<int:pk>/gallery/', views.NewsBackOfficeGalleryListView.as_view(),
name='gallery-list'),
path('<int:pk>/gallery/<int:image_id>/', views.NewsBackOfficeGalleryCreateDestroyView.as_view(),
name='gallery-create-destroy'),
] ]

View File

@ -1,6 +1,11 @@
"""News app views.""" """News app views."""
from django.conf import settings
from django.db.transaction import on_commit
from django.shortcuts import get_object_or_404 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.tasks import delete_image
from news import filters, models, serializers from news import filters, models, serializers
from rating.tasks import add_rating from rating.tasks import add_rating
from utils.permissions import IsCountryAdmin, IsContentPageManager from utils.permissions import IsCountryAdmin, IsContentPageManager
@ -10,7 +15,6 @@ class NewsMixinView:
"""News mixin.""" """News mixin."""
permission_classes = (permissions.AllowAny, ) permission_classes = (permissions.AllowAny, )
serializer_class = serializers.NewsBaseSerializer
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
"""Override get_queryset method.""" """Override get_queryset method."""
@ -24,6 +28,7 @@ class NewsMixinView:
class NewsListView(NewsMixinView, generics.ListAPIView): class NewsListView(NewsMixinView, generics.ListAPIView):
"""News list view.""" """News list view."""
serializer_class = serializers.NewsListSerializer
filter_class = filters.NewsListFilterSet filter_class = filters.NewsListFilterSet
@ -74,6 +79,64 @@ class NewsBackOfficeLCView(NewsBackOfficeMixinView,
return super().get_queryset().with_extended_related() return super().get_queryset().with_extended_related()
class NewsBackOfficeGalleryCreateDestroyView(NewsBackOfficeMixinView,
generics.CreateAPIView,
generics.DestroyAPIView):
"""Resource for a create gallery for news for back-office users."""
serializer_class = serializers.NewsBackOfficeGallerySerializer
def get_object(self):
"""
Returns the object the view is displaying.
"""
news_qs = self.filter_queryset(self.get_queryset())
news = get_object_or_404(news_qs, pk=self.kwargs['pk'])
gallery = get_object_or_404(news.news_gallery, image_id=self.kwargs['image_id'])
# May raise a permission denied
self.check_object_permissions(self.request, gallery)
return gallery
def create(self, request, *args, **kwargs):
"""Override create method"""
super().create(request, *args, **kwargs)
return Response(status=status.HTTP_201_CREATED)
def destroy(self, request, *args, **kwargs):
"""Override destroy method."""
gallery_obj = self.get_object()
if settings.USE_CELERY:
on_commit(lambda: delete_image.delay(image_id=gallery_obj.image.id,
completely=False))
else:
on_commit(lambda: delete_image(image_id=gallery_obj.image.id,
completely=False))
# Delete an instances of NewsGallery model
gallery_obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class NewsBackOfficeGalleryListView(NewsBackOfficeMixinView, generics.ListAPIView):
"""Resource for returning gallery for news for back-office users."""
serializer_class = serializers.NewsImageSerializer
def get_object(self):
"""Override get_object method."""
qs = super(NewsBackOfficeGalleryListView, self).get_queryset()
news = get_object_or_404(qs, pk=self.kwargs['pk'])
# May raise a permission denied
self.check_object_permissions(self.request, news)
return news
def get_queryset(self):
"""Override get_queryset method."""
return self.get_object().gallery.all()
class NewsBackOfficeRUDView(NewsBackOfficeMixinView, class NewsBackOfficeRUDView(NewsBackOfficeMixinView,
generics.RetrieveUpdateDestroyAPIView): generics.RetrieveUpdateDestroyAPIView):
"""Resource for detailed information about news for back-office users.""" """Resource for detailed information about news for back-office users."""

View File

@ -32,6 +32,13 @@ class EstablishmentDocument(Document):
}), }),
}, },
multi=True) multi=True)
works_evening = fields.ListField(fields.IntegerField(
attr='works_evening'
))
works_noon = fields.ListField(fields.IntegerField(
attr='works_noon'
))
works_now = fields.BooleanField(attr='works_now')
tags = fields.ObjectField( tags = fields.ObjectField(
properties={ properties={
'id': fields.IntegerField(attr='id'), 'id': fields.IntegerField(attr='id'),
@ -39,6 +46,14 @@ class EstablishmentDocument(Document):
properties=OBJECT_FIELD_PROPERTIES), properties=OBJECT_FIELD_PROPERTIES),
}, },
multi=True) multi=True)
schedule = fields.ListField(fields.ObjectField(
properties={
'id': fields.IntegerField(attr='id'),
'weekday': fields.IntegerField(attr='weekday'),
'weekday_display': fields.KeywordField(attr='get_weekday_display'),
'closed_at': fields.KeywordField(attr='closed_at_str'),
}
))
address = fields.ObjectField( address = fields.ObjectField(
properties={ properties={
'id': fields.IntegerField(), 'id': fields.IntegerField(),

View File

@ -37,14 +37,11 @@ class NewsDocument(Document):
model = models.News model = models.News
fields = ( fields = (
'id', 'id',
'playlist',
'start', 'start',
'end', 'end',
'slug', 'slug',
'state', 'state',
'is_highlighted', 'is_highlighted',
'image_url',
'preview_image_url',
'template', 'template',
) )
related_models = [models.NewsType] related_models = [models.NewsType]

View File

@ -30,6 +30,15 @@ class AddressDocumentSerializer(serializers.Serializer):
geo_lat = serializers.FloatField(allow_null=True, source='coordinates.lat') geo_lat = serializers.FloatField(allow_null=True, source='coordinates.lat')
class ScheduleDocumentSerializer(serializers.Serializer):
"""Schedule serializer for ES Document"""
id = serializers.IntegerField()
weekday = serializers.IntegerField()
weekday_display = serializers.CharField()
closed_at = serializers.CharField()
class NewsDocumentSerializer(DocumentSerializer): class NewsDocumentSerializer(DocumentSerializer):
"""News document serializer.""" """News document serializer."""
@ -68,6 +77,7 @@ class EstablishmentDocumentSerializer(DocumentSerializer):
address = AddressDocumentSerializer() address = AddressDocumentSerializer()
tags = TagsDocumentSerializer(many=True) tags = TagsDocumentSerializer(many=True)
schedule = ScheduleDocumentSerializer(many=True, allow_null=True)
class Meta: class Meta:
"""Meta class.""" """Meta class."""
@ -84,6 +94,10 @@ class EstablishmentDocumentSerializer(DocumentSerializer):
'preview_image', 'preview_image',
'address', 'address',
'tags', 'tags',
'schedule',
'works_noon',
'works_evening',
'works_now',
# 'collections', # 'collections',
# 'establishment_type', # 'establishment_type',
# 'establishment_subtypes', # 'establishment_subtypes',

View File

@ -38,7 +38,7 @@ def update_document(sender, **kwargs):
for establishment in establishments: for establishment in establishments:
registry.update(establishment) registry.update(establishment)
if model_name == 'establishmentsubtype': if model_name == 'establishmentsubtype':
if instance(instance, establishment_models.EstablishmentSubType): if isinstance(instance, establishment_models.EstablishmentSubType):
establishments = Establishment.objects.filter( establishments = Establishment.objects.filter(
establishment_subtypes=instance) establishment_subtypes=instance)
for establishment in establishments: for establishment in establishments:

View File

@ -111,6 +111,9 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet):
}, },
'tags_category_id': { 'tags_category_id': {
'field': 'tags.category.id', 'field': 'tags.category.id',
'lookups': [
constants.LOOKUP_QUERY_IN,
],
}, },
'collection_type': { 'collection_type': {
'field': 'collections.collection_type' 'field': 'collections.collection_type'
@ -121,6 +124,24 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet):
'establishment_subtypes': { 'establishment_subtypes': {
'field': 'establishment_subtypes.id' 'field': 'establishment_subtypes.id'
}, },
'works_noon': {
'field': 'works_noon',
'lookups': [
constants.LOOKUP_QUERY_IN,
],
},
'works_evening': {
'field': 'works_evening',
'lookups': [
constants.LOOKUP_QUERY_IN,
],
},
'works_now': {
'field': 'works_now',
'lookups': [
constants.LOOKUP_FILTER_TERM,
]
},
} }
geo_spatial_filter_fields = { geo_spatial_filter_fields = {

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.4 on 2019-10-22 15:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tag', '0004_tag_priority'),
]
operations = [
migrations.AddField(
model_name='tagcategory',
name='index_name',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='indexing name', unique=True),
),
]

View File

@ -5,6 +5,14 @@ from configuration.models import TranslationSettings
from utils.models import TJSONField, TranslatedFieldsMixin from utils.models import TJSONField, TranslatedFieldsMixin
class TagQuerySet(models.QuerySet):
def filter_chosen(self):
return self.exclude(priority__isnull=True)
def order_by_priority(self):
return self.order_by('priority')
class Tag(TranslatedFieldsMixin, models.Model): class Tag(TranslatedFieldsMixin, models.Model):
"""Tag model.""" """Tag model."""
@ -16,6 +24,8 @@ class Tag(TranslatedFieldsMixin, models.Model):
verbose_name=_('Category')) verbose_name=_('Category'))
priority = models.IntegerField(unique=True, null=True, default=None) priority = models.IntegerField(unique=True, null=True, default=None)
objects = TagQuerySet.as_manager()
class Meta: class Meta:
"""Meta class.""" """Meta class."""
@ -56,7 +66,7 @@ class TagCategoryQuerySet(models.QuerySet):
def with_tags(self, switcher=True): def with_tags(self, switcher=True):
"""Filter by existing tags.""" """Filter by existing tags."""
return self.filter(tags__isnull=not switcher) return self.exclude(tags__isnull=switcher)
class TagCategory(TranslatedFieldsMixin, models.Model): class TagCategory(TranslatedFieldsMixin, models.Model):
@ -69,6 +79,8 @@ class TagCategory(TranslatedFieldsMixin, models.Model):
on_delete=models.SET_NULL, null=True, on_delete=models.SET_NULL, null=True,
default=None) default=None)
public = models.BooleanField(default=False) public = models.BooleanField(default=False)
index_name = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('indexing name'), unique=True)
objects = TagCategoryQuerySet.as_manager() objects = TagCategoryQuerySet.as_manager()

View File

@ -49,7 +49,8 @@ class TagCategoryBaseSerializer(serializers.ModelSerializer):
fields = ( fields = (
'id', 'id',
'label_translated', 'label_translated',
'tags' 'index_name',
'tags',
) )

7
apps/tag/urls/mobile.py Normal file
View File

@ -0,0 +1,7 @@
from tag.urls.web import urlpatterns as common_urlpatterns
urlpatterns = [
]
urlpatterns.extend(common_urlpatterns)

View File

@ -7,6 +7,7 @@ app_name = 'tag'
router = SimpleRouter() router = SimpleRouter()
router.register(r'categories', views.TagCategoryViewSet) router.register(r'categories', views.TagCategoryViewSet)
router.register(r'chosen_tags', views.ChosenTagsView, basename='Tag')
urlpatterns = [ urlpatterns = [

View File

@ -1,11 +1,22 @@
"""Tag views.""" """Tag views."""
from rest_framework import viewsets, mixins, status from rest_framework import viewsets, mixins, status, generics
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from tag import filters, models, serializers from tag import filters, models, serializers
from rest_framework import permissions from rest_framework import permissions
class ChosenTagsView(generics.ListAPIView, viewsets.GenericViewSet):
pagination_class = None
permission_classes = (permissions.AllowAny,)
serializer_class = serializers.TagBaseSerializer
def get_queryset(self):
return models.Tag.objects\
.filter_chosen() \
.order_by_priority()
# User`s views & viewsets # User`s views & viewsets
class TagCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): class TagCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
"""ViewSet for TagCategory model.""" """ViewSet for TagCategory model."""

View File

@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from datetime import time
from utils.models import ProjectBaseMixin from utils.models import ProjectBaseMixin
@ -14,6 +15,8 @@ class Timetable(ProjectBaseMixin):
SATURDAY = 5 SATURDAY = 5
SUNDAY = 6 SUNDAY = 6
NOON = time(17, 0)
WEEKDAYS_CHOICES = ( WEEKDAYS_CHOICES = (
(MONDAY, _('Monday')), (MONDAY, _('Monday')),
(TUESDAY, _('Tuesday')), (TUESDAY, _('Tuesday')),
@ -32,6 +35,18 @@ class Timetable(ProjectBaseMixin):
opening_at = models.TimeField(verbose_name=_('Opening time'), null=True) opening_at = models.TimeField(verbose_name=_('Opening time'), null=True)
closed_at = models.TimeField(verbose_name=_('Closed time'), null=True) closed_at = models.TimeField(verbose_name=_('Closed time'), null=True)
@property
def closed_at_str(self):
return str(self.closed_at) if self.closed_at else None
@property
def works_at_noon(self):
return bool(self.closed_at and self.closed_at <= self.NOON)
@property
def works_at_afternoon(self):
return bool(self.closed_at and self.closed_at > self.NOON)
class Meta: class Meta:
"""Meta class.""" """Meta class."""
verbose_name = _('Timetable') verbose_name = _('Timetable')

View File

@ -77,3 +77,17 @@ class ScheduleCreateSerializer(ScheduleRUDSerializer):
schedule_qs.delete() schedule_qs.delete()
establishment.schedule.add(instance) establishment.schedule.add(instance)
return instance return instance
class TimetableSerializer(serializers.ModelSerializer):
"""Serailzier for Timetable model."""
weekday_display = serializers.CharField(source='get_weekday_display',
read_only=True)
class Meta:
model = Timetable
fields = (
'id',
'weekday_display',
'works_at_noon',
)

View File

View File

@ -6,5 +6,5 @@ from timetable import views
app_name = 'timetable' app_name = 'timetable'
urlpatterns = [ urlpatterns = [
path('', views.TimetableListView.as_view(), name='list') # path('', views.TimetableListView.as_view(), name='list')
] ]

View File

@ -0,0 +1,6 @@
from timetable.urls.common import urlpatterns as common_urlpatterns
urlpatterns = []
urlpatterns.extend(common_urlpatterns)

View File

@ -0,0 +1,6 @@
from timetable.urls.common import urlpatterns as common_urlpatterns
urlpatterns = []
urlpatterns.extend(common_urlpatterns)

View File

@ -1,4 +1,4 @@
from rest_framework import generics from rest_framework import generics, permissions
from timetable import serialziers, models from timetable import serialziers, models
@ -6,3 +6,5 @@ class TimetableListView(generics.ListAPIView):
"""Method to get timetables""" """Method to get timetables"""
serializer_class = serialziers.TimetableSerializer serializer_class = serialziers.TimetableSerializer
queryset = models.Timetable.objects.all() queryset = models.Timetable.objects.all()
pagination_class = None
permission_classes = (permissions.AllowAny, )

View File

@ -1,13 +1,19 @@
"""Utils app method.""" """Utils app method."""
import logging
import random import random
import re import re
import string import string
import requests
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http.request import HttpRequest from django.http.request import HttpRequest
from django.utils.timezone import datetime from django.utils.timezone import datetime
from rest_framework import status
from rest_framework.request import Request from rest_framework.request import Request
from os.path import exists
logger = logging.getLogger(__name__)
def generate_code(digits=6, string_output=True): def generate_code(digits=6, string_output=True):
@ -86,3 +92,30 @@ def get_contenttype(app_label: str, model: str):
qs = ContentType.objects.filter(app_label=app_label, model=model) qs = ContentType.objects.filter(app_label=app_label, model=model)
if qs.exists(): if qs.exists():
return qs.first() return qs.first()
def image_url_valid(url: str):
"""
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):
def get_absolute_image_url(self, obj):
"""Get absolute image url"""
url_path = func(self, obj)
if url_path:
if url_path.startswith('/media/'):
return f'{settings.MEDIA_URL}{url_path}/'
else:
return url_path
return get_absolute_image_url

View File

@ -1,5 +1,8 @@
"""Utils app models.""" """Utils app models."""
import logging
from os.path import exists from os.path import exists
from django.conf import settings
from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.contrib.gis.db import models from django.contrib.gis.db import models
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
@ -8,10 +11,13 @@ from django.utils import timezone
from django.utils.html import mark_safe from django.utils.html import mark_safe
from django.utils.translation import ugettext_lazy as _, get_language from django.utils.translation import ugettext_lazy as _, get_language
from easy_thumbnails.fields import ThumbnailerImageField from easy_thumbnails.fields import ThumbnailerImageField
from sorl.thumbnail import get_thumbnail
from sorl.thumbnail.fields import ImageField as SORLImageField
from utils.methods import image_path, svg_image_path from utils.methods import image_path, svg_image_path
from utils.validators import svg_image_validator from utils.validators import svg_image_validator
from django.db.models.fields import Field
from django.core import exceptions logger = logging.getLogger(__name__)
class ProjectBaseMixin(models.Model): class ProjectBaseMixin(models.Model):
@ -118,7 +124,7 @@ class OAuthProjectMixin:
def get_source(self): def get_source(self):
"""Method to get of platform""" """Method to get of platform"""
return NotImplemented return NotImplementedError
class BaseAttributes(ProjectBaseMixin): class BaseAttributes(ProjectBaseMixin):
@ -177,6 +183,41 @@ class ImageMixin(models.Model):
image_tag.allow_tags = True 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: str):
"""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: str):
"""Get image thumbnail url."""
crop_image = self.get_image(thumbnail_key)
if hasattr(crop_image, 'url'):
return self.get_image(thumbnail_key).url
def image_tag(self):
"""Admin preview tag."""
if self.image:
return mark_safe(f'<img src="{self.image.url}" style="max-height: 25%; max-width: 25%" />')
else:
return None
image_tag.short_description = _('Image')
image_tag.allow_tags = True
class SVGImageMixin(models.Model): class SVGImageMixin(models.Model):
"""SVG image model.""" """SVG image model."""

View File

@ -52,4 +52,4 @@ class EstablishmentPortionPagination(ProjectMobilePagination):
""" """
Pagination for app establishments with limit page size equal to 12 Pagination for app establishments with limit page size equal to 12
""" """
page_size = settings.LIMITING_OUTPUT_OBJECTS page_size = settings.QUERY_OUTPUT_OBJECTS

View File

@ -1,8 +1,10 @@
"""Utils QuerySet Mixins""" """Utils QuerySet Mixins"""
from django.db import models
from django.db.models import Q, Sum, F
from functools import reduce from functools import reduce
from operator import add from operator import add
from django.db import models
from django.db.models import Q, F
from utils.methods import get_contenttype from utils.methods import get_contenttype
@ -50,7 +52,7 @@ class RelatedObjectsCountMixin(models.QuerySet):
def filter_all_related_gt(self, count): def filter_all_related_gt(self, count):
"""Queryset filter by all related objects count""" """Queryset filter by all related objects count"""
exp =reduce(add, [F(f"{related_object}_count") for related_object in self._get_related_objects_names()]) exp = reduce(add, [F(f"{related_object}_count") for related_object in self._get_related_objects_names()])
return self._annotate_related_objects_count()\ return self._annotate_related_objects_count()\
.annotate(all_related_count=exp)\ .annotate(all_related_count=exp)\
.filter(all_related_count__gt=count) .filter(all_related_count__gt=count)

View File

@ -47,7 +47,6 @@ class TranslateFieldTests(BaseTestCase):
"ru-RU": "Тестовая новость" "ru-RU": "Тестовая новость"
}, },
description={"en-GB": "Test description"}, description={"en-GB": "Test description"},
playlist=1,
start=datetime.now(pytz.utc) + timedelta(hours=-13), start=datetime.now(pytz.utc) + timedelta(hours=-13),
end=datetime.now(pytz.utc) + timedelta(hours=13), end=datetime.now(pytz.utc) + timedelta(hours=13),
news_type=self.news_type, news_type=self.news_type,

Binary file not shown.

79
compose-ci.yml Normal file
View File

@ -0,0 +1,79 @@
version: '2'
services:
db:
build:
context: ./_dockerfiles/db
dockerfile: Dockerfile
hostname: db
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=postgres
ports:
- "5436:5432"
elasticsearch:
image: elasticsearch:7.3.1
hostname: elasticsearch
ports:
- 9200:9200
- 9300:9300
environment:
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- discovery.type=single-node
- xpack.security.enabled=false
# Redis
redis:
image: redis:2.8.23
ports:
- "6379:6379"
# Celery
worker:
build: .
command: ./run_celery.sh
environment:
- SETTINGS_CONFIGURATION=local
- DB_NAME=postgres
- DB_USERNAME=postgres
- DB_HOSTNAME=db
- DB_PORT=5432
- DB_PASSWORD=postgres
links:
- db
- redis
worker_beat:
build: .
command: ./run_celery_beat.sh
environment:
- SETTINGS_CONFIGURATION=local
- DB_NAME=postgres
- DB_USERNAME=postgres
- DB_HOSTNAME=db
- DB_PORT=5432
- DB_PASSWORD=postgres
links:
- db
- redis
# App: G&M
gm_app:
build: .
command: python manage.py runserver 0.0.0.0:8000
environment:
- SETTINGS_CONFIGURATION=local
- DB_HOSTNAME=db
- DB_PORT=5432
- DB_NAME=postgres
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
depends_on:
- db
- redis
- worker
- worker_beat
- elasticsearch
ports:
- "8000:8000"

69
fabfile.py vendored Normal file
View File

@ -0,0 +1,69 @@
import os # NOQA
from fabric.api import * # NOQA
user = 'gm'
env.roledefs = {
'develop': {
'branch': 'develop',
'hosts': ['%s@95.213.204.126' % user, ]
}
}
env.root = '~/'
env.src = '~/project'
env.default_branch = 'develop'
env.tmpdir = '~/tmp'
def fetch(branch=None):
with cd(env.src):
role = env.roles[0]
run('git pull origin {}'.format(env.roledefs[role]['branch']))
def migrate():
with cd(env.src):
run('./manage.py migrate')
def install_requirements():
with cd(env.src):
run('pip install -r requirements/base.txt')
def touch():
with cd(env.src):
run('touch ~/%s.touch' % user)
def kill_celery():
"""Kill celery workers for $user."""
with cd(env.src):
run('ps -u %s -o pid,fname | grep celery | (while read a b; do kill -9 $a; done;)' % user)
def collectstatic():
with cd(env.src):
run('./manage.py collectstatic --noinput')
def deploy(branch=None):
fetch()
install_requirements()
migrate()
collectstatic()
touch()
kill_celery()
def rev():
"""Show head commit."""
with hide('running', 'stdout'):
with cd(env.src):
commit = run('git rev-parse HEAD')
return local('git show -q %s' % commit)

View File

@ -0,0 +1,22 @@
"""Settings for Amazon S3"""
import os
from .base import MEDIA_LOCATION
# AMAZON S3
AWS_S3_REGION_NAME = 'eu-central-1'
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's3.{AWS_S3_REGION_NAME}.amazonaws.com/{AWS_STORAGE_BUCKET_NAME}'
AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'}
AWS_S3_ADDRESSING_STYLE = 'path'
# 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'

View File

@ -97,6 +97,10 @@ EXTERNAL_APPS = [
'rest_framework_simplejwt.token_blacklist', 'rest_framework_simplejwt.token_blacklist',
'solo', 'solo',
'phonenumber_field', 'phonenumber_field',
'timezone_field',
'storages',
'sorl.thumbnail',
'timezonefinder'
] ]
@ -205,19 +209,6 @@ LOCALE_PATHS = (
os.path.abspath(os.path.join(BASE_DIR, 'locale')), 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 = { AVAILABLE_VERSIONS = {
# 'future': '1.0.1', # 'future': '1.0.1',
'current': '1.0.0', 'current': '1.0.0',
@ -294,12 +285,14 @@ SMS_CODE_LENGTH = 6
SEND_SMS = True SEND_SMS = True
SMS_CODE_SHOW = False SMS_CODE_SHOW = False
# SMSC Settings # SMSC Settings
SMS_SERVICE = 'http://smsc.ru/sys/send.php' SMS_SERVICE = 'http://smsc.ru/sys/send.php'
SMS_LOGIN = os.environ.get('SMS_LOGIN') SMS_LOGIN = os.environ.get('SMS_LOGIN')
SMS_PASSWORD = os.environ.get('SMS_PASSWORD') SMS_PASSWORD = os.environ.get('SMS_PASSWORD')
SMS_SENDER = 'GM' SMS_SENDER = 'GM'
# EMAIL # EMAIL
EMAIL_USE_TLS = True EMAIL_USE_TLS = True
EMAIL_HOST = 'smtp.mandrillapp.com' EMAIL_HOST = 'smtp.mandrillapp.com'
@ -307,6 +300,7 @@ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD')
EMAIL_PORT = 587 EMAIL_PORT = 587
# Django Rest Swagger # Django Rest Swagger
SWAGGER_SETTINGS = { SWAGGER_SETTINGS = {
# "DEFAULT_GENERATOR_CLASS": "rest_framework.schemas.generators.BaseSchemaGenerator", # "DEFAULT_GENERATOR_CLASS": "rest_framework.schemas.generators.BaseSchemaGenerator",
@ -329,6 +323,7 @@ REDOC_SETTINGS = {
'LAZY_RENDERING': False, 'LAZY_RENDERING': False,
} }
# CELERY # CELERY
# RabbitMQ # RabbitMQ
# BROKER_URL = 'amqp://rabbitmq:5672' # BROKER_URL = 'amqp://rabbitmq:5672'
@ -341,7 +336,7 @@ CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = TIME_ZONE CELERY_TIMEZONE = TIME_ZONE
# Django FCM (Firebase push notificatoins) # Django FCM (Firebase push notifications)
FCM_DJANGO_SETTINGS = { FCM_DJANGO_SETTINGS = {
'FCM_SERVER_KEY': ( 'FCM_SERVER_KEY': (
"AAAAJcC4Vbc:APA91bGovq7233-RHu2MbZTsuMU4jNf3obOue8s" "AAAAJcC4Vbc:APA91bGovq7233-RHu2MbZTsuMU4jNf3obOue8s"
@ -350,39 +345,44 @@ FCM_DJANGO_SETTINGS = {
), ),
} }
# Thumbnail settings # Thumbnail settings
THUMBNAIL_ALIASES = { THUMBNAIL_ALIASES = {
'news_preview': { '': {
'web': {'size': (300, 260), } 'news_preview': {'size': (300, 260), },
}, 'news_promo_horizontal_web': {'size': (1900, 600), },
'news_promo_horizontal': { 'news_promo_horizontal_mobile': {'size': (375, 260), },
'web': {'size': (1900, 600), }, 'news_tile_horizontal_web': {'size': (300, 275), },
'mobile': {'size': (375, 260), }, 'news_tile_horizontal_mobile': {'size': (343, 180), },
}, 'news_tile_vertical_web': {'size': (300, 380), },
'news_tile_horizontal': { 'news_highlight_vertical_web': {'size': (460, 630), },
'web': {'size': (300, 275), }, 'news_editor_web': {'size': (940, 430), }, # при загрузке через контент эдитор
'mobile': {'size': (343, 180), }, 'news_editor_mobile': {'size': (343, 260), }, # через контент эдитор в мобильном браузерe
}, 'avatar_comments_web': {'size': (116, 116), },
'news_tile_vertical': { }
'web': {'size': (300, 380), },
},
'news_highlight_vertical': {
'web': {'size': (460, 630), },
},
'news_editor': {
'web': {'size': (940, 430), }, # при загрузке через контент эдитор
'mobile': {'size': (343, 260), }, # через контент эдитор в мобильном браузерe
},
'avatar_comments': {
'web': {'size': (116, 116), },
},
} }
# Password reset THUMBNAIL_DEFAULT_OPTIONS = {
RESETTING_TOKEN_EXPIRATION = 24 # hours 'crop': 'smart',
}
GEOIP_PATH = os.path.join(PROJECT_ROOT, 'geoip_db') # 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
}
# JWT # JWT
SIMPLE_JWT = { SIMPLE_JWT = {
@ -422,7 +422,8 @@ PASSWORD_RESET_TIMEOUT_DAYS = 1
RESETTING_TOKEN_TEMPLATE = 'account/password_reset_email.html' RESETTING_TOKEN_TEMPLATE = 'account/password_reset_email.html'
CHANGE_EMAIL_TEMPLATE = 'account/change_email.html' CHANGE_EMAIL_TEMPLATE = 'account/change_email.html'
CONFIRM_EMAIL_TEMPLATE = 'authorization/confirm_email.html' CONFIRM_EMAIL_TEMPLATE = 'authorization/confirm_email.html'
NEWS_EMAIL_TEMPLATE = "news/news_email.html" NEWS_EMAIL_TEMPLATE = 'news/news_email.html'
NOTIFICATION_PASSWORD_TEMPLATE = 'account/password_change_email.html'
# COOKIES # COOKIES
@ -443,24 +444,42 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB
FILE_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB FILE_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB
FILE_UPLOAD_PERMISSIONS = 0o644 FILE_UPLOAD_PERMISSIONS = 0o644
# SOLO SETTINGS # SOLO SETTINGS
# todo: make a separate service (redis?) and update solo_cache # todo: make a separate service (redis?) and update solo_cache
SOLO_CACHE = 'default' SOLO_CACHE = 'default'
SOLO_CACHE_PREFIX = 'solo' SOLO_CACHE_PREFIX = 'solo'
SOLO_CACHE_TIMEOUT = 300 SOLO_CACHE_TIMEOUT = 300
# REDIRECT URL # REDIRECT URL
SITE_REDIRECT_URL_UNSUBSCRIBE = '/unsubscribe/' SITE_REDIRECT_URL_UNSUBSCRIBE = '/unsubscribe/'
SITE_NAME = 'Gault & Millau' SITE_NAME = 'Gault & Millau'
# Used in annotations for establishments. # Used in annotations for establishments.
DEFAULT_ESTABLISHMENT_PUBLIC_MARK = 10 DEFAULT_ESTABLISHMENT_PUBLIC_MARK = 10
# Limit output objects (see in pagination classes). # Limit output objects (see in pagination classes).
LIMITING_OUTPUT_OBJECTS = 12 QUERY_OUTPUT_OBJECTS = 12
# Need to restrict objects to sort (3 times more then expected). # Need to restrict objects to sort (3 times more then expected).
LIMITING_QUERY_NUMBER = LIMITING_OUTPUT_OBJECTS * 3 LIMITING_QUERY_OBJECTS = QUERY_OUTPUT_OBJECTS * 3
# GEO # GEO
# A Spatial Reference System Identifier # A Spatial Reference System Identifier
GEO_DEFAULT_SRID = 4326 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
MEDIA_LOCATION = 'media'

View File

@ -1,13 +1,14 @@
"""Development settings.""" """Development settings."""
from .base import * from .base import *
from .amazon_s3 import *
import sentry_sdk import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration 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 SEND_SMS = False
SMS_CODE_SHOW = True SMS_CODE_SHOW = True
USE_CELERY = False USE_CELERY = True
SCHEMA_URI = 'http' SCHEMA_URI = 'http'
DEFAULT_SUBDOMAIN = 'www' DEFAULT_SUBDOMAIN = 'www'

View File

@ -1,18 +1,22 @@
"""Local settings.""" """Local settings."""
from .base import * from .base import *
import sys import sys
# from .amazon_s3 import *
ALLOWED_HOSTS = ['*', ] ALLOWED_HOSTS = ['*', ]
SEND_SMS = False SEND_SMS = False
SMS_CODE_SHOW = True SMS_CODE_SHOW = True
USE_CELERY = True USE_CELERY = True
SCHEMA_URI = 'http' SCHEMA_URI = 'http'
DEFAULT_SUBDOMAIN = 'www' DEFAULT_SUBDOMAIN = 'www'
SITE_DOMAIN_URI = 'testserver.com:8000' SITE_DOMAIN_URI = 'testserver.com:8000'
DOMAIN_URI = '0.0.0.0:8000' DOMAIN_URI = '0.0.0.0:8000'
# CELERY # CELERY
# RabbitMQ # RabbitMQ
# BROKER_URL = 'amqp://rabbitmq:5672' # BROKER_URL = 'amqp://rabbitmq:5672'
@ -22,6 +26,15 @@ CELERY_RESULT_BACKEND = BROKER_URL
CELERY_BROKER_URL = BROKER_URL CELERY_BROKER_URL = BROKER_URL
# MEDIA
MEDIA_URL = f'{SCHEMA_URI}://{DOMAIN_URI}/{MEDIA_LOCATION}/'
MEDIA_ROOT = os.path.join(PUBLIC_ROOT, MEDIA_LOCATION)
# SORL thumbnails
THUMBNAIL_DEBUG = True
# LOGGING # LOGGING
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
@ -59,6 +72,7 @@ LOGGING = {
} }
} }
# ELASTICSEARCH SETTINGS # ELASTICSEARCH SETTINGS
ELASTICSEARCH_DSL = { ELASTICSEARCH_DSL = {
'default': { 'default': {
@ -66,7 +80,6 @@ ELASTICSEARCH_DSL = {
} }
} }
ELASTICSEARCH_INDEX_NAMES = { ELASTICSEARCH_INDEX_NAMES = {
# 'search_indexes.documents.news': 'local_news', # 'search_indexes.documents.news': 'local_news',
'search_indexes.documents.establishment': 'local_establishment', 'search_indexes.documents.establishment': 'local_establishment',

View File

@ -1,5 +1,6 @@
"""Production settings.""" """Production settings."""
from .base import * from .base import *
from .amazon_s3 import *
# Booking API configuration # Booking API configuration
GUESTONLINE_SERVICE = 'https://api.guestonline.fr/' GUESTONLINE_SERVICE = 'https://api.guestonline.fr/'

View File

@ -1,11 +1,12 @@
"""Stage settings.""" """Stage settings."""
from .base import * from .base import *
from .amazon_s3 import *
ALLOWED_HOSTS = ['gm-stage.id-east.ru', '95.213.204.126'] ALLOWED_HOSTS = ['gm-stage.id-east.ru', '95.213.204.126']
SEND_SMS = False SEND_SMS = False
SMS_CODE_SHOW = True SMS_CODE_SHOW = True
USE_CELERY = False USE_CELERY = True
SCHEMA_URI = 'https' SCHEMA_URI = 'https'
DEFAULT_SUBDOMAIN = 'www' DEFAULT_SUBDOMAIN = 'www'

View File

@ -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

View File

@ -0,0 +1,7 @@
{% load i18n %}{% autoescape off %}
{% blocktrans %}You're receiving this email because your account's password address at {{ site_name }}.{% endblocktrans %}
{% trans "Thanks for using our site!" %}
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
{% endautoescape %}

File diff suppressed because one or more lines are too long

View File

@ -64,7 +64,7 @@ urlpatterns = [
] ]
urlpatterns = urlpatterns + \ urlpatterns = urlpatterns + \
static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) static(settings.MEDIA_LOCATION, document_root=settings.MEDIA_ROOT)
if settings.DEBUG: if settings.DEBUG:
urlpatterns.extend(urlpatterns_doc) urlpatterns.extend(urlpatterns_doc)

View File

@ -4,8 +4,10 @@ app_name = 'mobile'
urlpatterns = [ urlpatterns = [
path('establishments/', include('establishment.urls.mobile')), path('establishments/', include('establishment.urls.mobile')),
path('location/', include('location.urls.mobile')),
path('main/', include('main.urls.mobile')), path('main/', include('main.urls.mobile')),
path('location/', include('location.urls.mobile')) path('tags/', include('tag.urls.mobile')),
path('timetables/', include('timetable.urls.mobile')),
# path('account/', include('account.urls.web')), # path('account/', include('account.urls.web')),
# path('advertisement/', include('advertisement.urls.web')), # path('advertisement/', include('advertisement.urls.web')),
# path('collection/', include('collection.urls.web')), # path('collection/', include('collection.urls.web')),

View File

@ -34,4 +34,5 @@ urlpatterns = [
path('translation/', include('translation.urls')), path('translation/', include('translation.urls')),
path('comments/', include('comment.urls.web')), path('comments/', include('comment.urls.web')),
path('favorites/', include('favorites.urls')), path('favorites/', include('favorites.urls')),
path('timetables/', include('timetable.urls.web')),
] ]

View File

@ -9,6 +9,7 @@ fcm-django
django-easy-select2 django-easy-select2
bootstrap-admin bootstrap-admin
drf-yasg==1.16.0 drf-yasg==1.16.0
timezonefinder
PySocks!=1.5.7,>=1.5.6; PySocks!=1.5.7,>=1.5.6;
djangorestframework==3.9.4 djangorestframework==3.9.4
@ -17,6 +18,7 @@ django-filter==2.1.0
djangorestframework-xml djangorestframework-xml
geoip2==2.9.0 geoip2==2.9.0
django-phonenumber-field[phonenumbers]==2.1.0 django-phonenumber-field[phonenumbers]==2.1.0
django-timezone-field==3.1
# auth socials # auth socials
django-rest-framework-social-oauth2==1.1.0 django-rest-framework-social-oauth2==1.1.0
@ -33,6 +35,12 @@ django-elasticsearch-dsl>=7.0.0,<8.0.0
django-elasticsearch-dsl-drf==0.20.2 django-elasticsearch-dsl-drf==0.20.2
sentry-sdk==0.11.2 sentry-sdk==0.11.2
# AMAZON S3
boto3==1.9.238
django-storages==1.7.2
sorl-thumbnail==12.5.0
mysqlclient==1.4.4 mysqlclient==1.4.4