Merge branch 'develop' into 'feature/agenda-back-office'

# Conflicts:
#   apps/news/serializers.py
This commit is contained in:
Ruslan Stepanov 2020-01-17 11:12:30 +00:00
commit 903bdbb39d
96 changed files with 1955 additions and 556 deletions

View File

@ -7,12 +7,14 @@ from account import models
@admin.register(models.Role)
class RoleAdmin(admin.ModelAdmin):
list_display = ['role', 'country']
list_display = ['id', 'role', 'country']
raw_id_fields = ['country', ]
@admin.register(models.UserRole)
class UserRoleAdmin(admin.ModelAdmin):
list_display = ['user', 'role', 'establishment']
list_display = ['user', 'role', 'establishment', ]
raw_id_fields = ['user', 'role', 'establishment', 'requester', ]
@admin.register(models.User)

View File

@ -0,0 +1,26 @@
# Generated by Django 2.2.7 on 2020-01-14 13:11
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('main', '0046_auto_20200114_1218'),
('account', '0031_user_last_country'),
]
operations = [
migrations.AddField(
model_name='role',
name='navigation_bar_permission',
field=models.ForeignKey(blank=True, help_text='navigation bar item permission', null=True, on_delete=django.db.models.deletion.SET_NULL, to='main.NavigationBarPermission', verbose_name='navigation bar permission'),
),
migrations.AlterField(
model_name='userrole',
name='requester',
field=models.ForeignKey(blank=True, default=None, help_text='A user (REQUESTER) who requests a role change for a USER', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='roles_requested', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,10 +1,8 @@
"""Account models"""
from datetime import datetime
from tabnanny import verbose
from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager
from django.contrib.auth.tokens import default_token_generator as password_token_generator
from django.core.mail import send_mail
from django.db import models
from django.template.loader import render_to_string, get_template
@ -53,6 +51,7 @@ class Role(ProjectBaseMixin):
(LIQUOR_REVIEWER, 'Liquor reviewer'),
(PRODUCT_REVIEWER, 'Product reviewer'),
)
role = models.PositiveIntegerField(verbose_name=_('Role'), choices=ROLE_CHOICES,
null=False, blank=False)
country = models.ForeignKey(Country, verbose_name=_('Country'),
@ -62,6 +61,11 @@ class Role(ProjectBaseMixin):
establishment_subtype = models.ForeignKey(EstablishmentSubType,
verbose_name=_('Establishment subtype'),
null=True, blank=True, on_delete=models.SET_NULL)
navigation_bar_permission = models.ForeignKey('main.NavigationBarPermission',
blank=True, null=True,
on_delete=models.SET_NULL,
help_text='navigation bar item permission',
verbose_name=_('navigation bar permission'))
class UserManager(BaseUserManager):
@ -147,8 +151,8 @@ class User(AbstractUser):
)
EMAIL_FIELD = 'email'
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['email']
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username']
roles = models.ManyToManyField(
Role, verbose_name=_('Roles'), symmetrical=False,
@ -238,7 +242,7 @@ class User(AbstractUser):
@property
def reset_password_token(self):
"""Make a token for finish signup."""
return password_token_generator.make_token(self)
return GMTokenGenerator(purpose=GMTokenGenerator.RESET_PASSWORD).make_token(self)
@property
def get_user_uidb64(self):
@ -334,6 +338,14 @@ class User(AbstractUser):
model='product',
).values_list('object_id', flat=True)
@property
def subscription_types(self):
result = []
for subscription in self.subscriber.all():
for item in subscription.active_subscriptions:
result.append(item.id)
return set(result)
class UserRole(ProjectBaseMixin):
"""UserRole model."""
@ -348,6 +360,7 @@ class UserRole(ProjectBaseMixin):
(CANCELLED, _('cancelled')),
(REJECTED, _('rejected'))
)
user = models.ForeignKey(
'account.User', verbose_name=_('User'), on_delete=models.CASCADE)
role = models.ForeignKey(
@ -358,9 +371,11 @@ class UserRole(ProjectBaseMixin):
state = models.CharField(
_('state'), choices=STATE_CHOICES, max_length=10, default=PENDING)
requester = models.ForeignKey(
'account.User', blank=True, null=True, default=None, related_name='roles_requested',
on_delete=models.SET_NULL)
requester = models.ForeignKey('account.User', on_delete=models.SET_NULL,
blank=True, null=True, default=None,
related_name='roles_requested',
help_text='A user (REQUESTER) who requests a '
'role change for a USER')
class Meta:
unique_together = ['user', 'role', 'establishment', 'state']
@ -371,4 +386,4 @@ class OldRole(models.Model):
old_role = models.CharField(verbose_name=_('Old role'), max_length=512, null=True)
class Meta:
unique_together = ('new_role', 'old_role')
unique_together = ('new_role', 'old_role')

View File

@ -0,0 +1,3 @@
from account.serializers.common import *
from account.serializers.web import *
from account.serializers.back import *

View File

@ -1,19 +1,12 @@
"""Back account serializers"""
from rest_framework import serializers
from account import models
from account.models import User
from account.serializers import RoleBaseSerializer, subscriptions_handler
from main.models import SiteSettings
class RoleSerializer(serializers.ModelSerializer):
class Meta:
model = models.Role
fields = [
'role',
'country'
]
class _SiteSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = SiteSettings
@ -26,6 +19,15 @@ class _SiteSettingsSerializer(serializers.ModelSerializer):
class BackUserSerializer(serializers.ModelSerializer):
last_country = _SiteSettingsSerializer(read_only=True)
roles = RoleBaseSerializer(many=True, read_only=True)
subscriptions = serializers.ListField(
source='subscription_types',
allow_null=True,
allow_empty=True,
child=serializers.IntegerField(min_value=1),
required=False,
help_text='list of subscription_types id',
)
class Meta:
model = User
@ -45,37 +47,98 @@ class BackUserSerializer(serializers.ModelSerializer):
'unconfirmed_email',
'email_confirmed',
'newsletter',
'roles',
'password',
'city',
'locale',
'last_ip',
'last_country',
'roles',
'subscriptions',
)
extra_kwargs = {
'password': {'write_only': True},
}
read_only_fields = ('old_password', 'last_login', 'date_joined', 'city', 'locale', 'last_ip', 'last_country')
read_only_fields = (
'old_password',
'last_login',
'date_joined',
'city',
'locale',
'last_ip',
'last_country',
)
def create(self, validated_data):
subscriptions_list = []
if 'subscription_types' in validated_data:
subscriptions_list = validated_data.pop('subscription_types')
user = super().create(validated_data)
user.set_password(validated_data['password'])
user.save()
subscriptions_handler(subscriptions_list, user)
return user
class BackDetailUserSerializer(BackUserSerializer):
class Meta:
model = User
exclude = ('password',)
read_only_fields = ('old_password', 'last_login', 'date_joined')
fields = (
'id',
'last_country',
'roles',
'last_login',
'is_superuser',
'first_name',
'last_name',
'is_staff',
'is_active',
'date_joined',
'username',
'image_url',
'cropped_image_url',
'email',
'unconfirmed_email',
'email_confirmed',
'newsletter',
'old_id',
'locale',
'city',
'last_ip',
'groups',
'user_permissions',
'subscriptions',
)
read_only_fields = (
'old_password',
'last_login',
'date_joined',
'last_ip',
'last_country',
)
def create(self, validated_data):
subscriptions_list = []
if 'subscription_types' in validated_data:
subscriptions_list = validated_data.pop('subscription_types')
user = super().create(validated_data)
user.set_password(validated_data['password'])
user.save()
subscriptions_handler(subscriptions_list, user)
return user
def update(self, instance, validated_data):
subscriptions_list = []
if 'subscription_types' in validated_data:
subscriptions_list = validated_data.pop('subscription_types')
instance = super().update(instance, validated_data)
subscriptions_handler(subscriptions_list, instance)
return instance
class UserRoleSerializer(serializers.ModelSerializer):
class Meta:

View File

@ -1,19 +1,59 @@
"""Common account serializers"""
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from django.contrib.auth import password_validation as password_validators
from django.utils.translation import gettext_lazy as _
from fcm_django.models import FCMDevice
from rest_framework import exceptions
from rest_framework import serializers
from rest_framework import validators as rest_validators
from account import models, tasks
from notification.models import Subscriber
from main.serializers.common import NavigationBarPermissionBaseSerializer
from notification.models import Subscribe, Subscriber
from utils import exceptions as utils_exceptions
from utils import methods as utils_methods
from utils.methods import generate_string_code
def subscriptions_handler(subscriptions_list, user):
"""
create or update subscriptions for user
"""
Subscribe.objects.filter(subscriber__user=user).delete()
subscriber, _ = Subscriber.objects.get_or_create(
email=user.email,
defaults={
'user': user,
'email': user.email,
'ip_address': user.last_ip,
'country_code': user.last_country.country.code if user.last_country else None,
'locale': user.locale,
'update_code': generate_string_code(),
}
)
for subscription in subscriptions_list:
Subscribe.objects.create(
subscriber=subscriber,
subscription_type_id=subscription,
)
class RoleBaseSerializer(serializers.ModelSerializer):
"""Serializer for model Role."""
role_display = serializers.CharField(source='get_role_display', read_only=True)
navigation_bar_permission = NavigationBarPermissionBaseSerializer(read_only=True)
class Meta:
"""Meta class."""
model = models.Role
fields = [
'id',
'role_display',
'navigation_bar_permission',
]
# User serializers
class UserSerializer(serializers.ModelSerializer):
"""User serializer."""
# RESPONSE
@ -26,6 +66,15 @@ class UserSerializer(serializers.ModelSerializer):
email = serializers.EmailField(
validators=(rest_validators.UniqueValidator(queryset=models.User.objects.all()),),
required=False)
roles = RoleBaseSerializer(many=True, read_only=True)
subscriptions = serializers.ListField(
source='subscription_types',
allow_null=True,
allow_empty=True,
child=serializers.IntegerField(min_value=1),
required=False,
help_text='list of subscription_types id',
)
class Meta:
model = models.User
@ -39,6 +88,8 @@ class UserSerializer(serializers.ModelSerializer):
'email',
'email_confirmed',
'newsletter',
'roles',
'subscriptions',
]
extra_kwargs = {
'first_name': {'required': False, 'write_only': True, },
@ -50,9 +101,14 @@ class UserSerializer(serializers.ModelSerializer):
}
def create(self, validated_data):
subscriptions_list = []
if 'subscription_types' in validated_data:
subscriptions_list = validated_data.pop('subscription_types')
user = super(UserSerializer, self).create(validated_data)
validated_data['user'] = user
Subscriber.objects.make_subscriber(**validated_data)
subscriptions_handler(subscriptions_list, user)
return user
def validate_email(self, value):
@ -70,6 +126,10 @@ class UserSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
"""Override update method"""
subscriptions_list = []
if 'subscription_types' in validated_data:
subscriptions_list = validated_data.pop('subscription_types')
old_email = instance.email
instance = super().update(instance, validated_data)
if 'email' in validated_data:
@ -82,12 +142,14 @@ class UserSerializer(serializers.ModelSerializer):
tasks.change_email_address.delay(
user_id=instance.id,
country_code=self.context.get('request').country_code,
emails=[validated_data['email'],])
emails=[validated_data['email'], ])
else:
tasks.change_email_address(
user_id=instance.id,
country_code=self.context.get('request').country_code,
emails=[validated_data['email'],])
emails=[validated_data['email'], ])
subscriptions_handler(subscriptions_list, instance)
return instance
@ -214,6 +276,7 @@ class ChangeEmailSerializer(serializers.ModelSerializer):
# Firebase Cloud Messaging serializers
class FCMDeviceSerializer(serializers.ModelSerializer):
"""FCM Device model serializer"""
class Meta:
model = FCMDevice
fields = ('id', 'name', 'registration_id', 'device_id',
@ -233,8 +296,8 @@ class FCMDeviceSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
super(FCMDeviceSerializer, self).__init__(*args, **kwargs)
self.fields['type'].help_text = (
'Should be one of ["%s"]' %
'", "'.join([i for i in self.fields['type'].choices]))
'Should be one of ["%s"]' %
'", "'.join([i for i in self.fields['type'].choices]))
def create(self, validated_data):
user = self.context['request'].user

View File

@ -6,9 +6,9 @@ from account.views import back as views
app_name = 'account'
urlpatterns = [
path('role/', views.RoleLstView.as_view(), name='role-list-create'),
path('user-role/', views.UserRoleLstView.as_view(), name='user-role-list-create'),
path('user/', views.UserLstView.as_view(), name='user-create-list'),
path('role/', views.RoleListView.as_view(), name='role-list-create'),
path('user-role/', views.UserRoleListView.as_view(), name='user-role-list-create'),
path('user/', views.UserListView.as_view(), name='user-create-list'),
path('user/<int:id>/', views.UserRUDView.as_view(), name='user-rud'),
path('user/<int:id>/csv/', views.get_user_csv, name='user-csv'),
]

View File

@ -8,24 +8,26 @@ from rest_framework.authtoken.models import Token
from account import models
from account.models import User
from account.serializers import back as serializers
from account.serializers.common import RoleBaseSerializer
class RoleLstView(generics.ListCreateAPIView):
serializer_class = serializers.RoleSerializer
class RoleListView(generics.ListCreateAPIView):
serializer_class = RoleBaseSerializer
queryset = models.Role.objects.all()
class UserRoleLstView(generics.ListCreateAPIView):
class UserRoleListView(generics.ListCreateAPIView):
serializer_class = serializers.UserRoleSerializer
queryset = models.UserRole.objects.all()
class UserLstView(generics.ListCreateAPIView):
class UserListView(generics.ListCreateAPIView):
"""User list create view."""
queryset = User.objects.prefetch_related('roles')
serializer_class = serializers.BackUserSerializer
permission_classes = (permissions.IsAdminUser,)
filter_backends = (DjangoFilterBackend, OrderingFilter)
filterset_fields = (
'email_confirmed',
'is_staff',

View File

@ -1,6 +1,5 @@
"""Web account views"""
from django.conf import settings
from django.contrib.auth.tokens import default_token_generator as password_token_generator
from django.shortcuts import get_object_or_404
from django.utils.encoding import force_text
from django.utils.http import urlsafe_base64_decode
@ -10,6 +9,7 @@ from rest_framework.response import Response
from account import tasks, models
from account.serializers import web as serializers
from utils import exceptions as utils_exceptions
from utils.models import GMTokenGenerator
from utils.views import JWTGenericViewMixin
@ -40,22 +40,23 @@ class PasswordResetConfirmView(JWTGenericViewMixin, generics.GenericAPIView):
queryset = models.User.objects.active()
def get_object(self):
"""Override get_object method"""
"""Overridden get_object method"""
queryset = self.filter_queryset(self.get_queryset())
uidb64 = self.kwargs.get('uidb64')
user_id = force_text(urlsafe_base64_decode(uidb64))
token = self.kwargs.get('token')
obj = get_object_or_404(queryset, id=user_id)
user = get_object_or_404(queryset, id=user_id)
if not password_token_generator.check_token(user=obj, token=token):
if not GMTokenGenerator(GMTokenGenerator.RESET_PASSWORD).check_token(
user, token):
raise utils_exceptions.NotValidTokenError()
# May raise a permission denied
self.check_object_permissions(self.request, obj)
self.check_object_permissions(self.request, user)
return obj
return user
def patch(self, request, *args, **kwargs):
"""Implement PATCH method"""

View File

@ -18,6 +18,7 @@ from utils.tokens import GMRefreshToken
# Serializers
class SignupSerializer(serializers.ModelSerializer):
"""Signup serializer serializer mixin"""
class Meta:
model = account_models.User
fields = (
@ -35,11 +36,13 @@ class SignupSerializer(serializers.ModelSerializer):
def validate_username(self, value):
"""Custom username validation"""
valid = utils_methods.username_validator(username=value)
if not valid:
raise utils_exceptions.NotValidUsernameError()
if account_models.User.objects.filter(username__iexact=value).exists():
raise serializers.ValidationError()
if value:
valid = utils_methods.username_validator(username=value)
if not valid:
raise utils_exceptions.NotValidUsernameError()
if account_models.User.objects.filter(username__iexact=value).exists():
raise serializers.ValidationError()
return value
def validate_email(self, value):
@ -59,6 +62,13 @@ class SignupSerializer(serializers.ModelSerializer):
def create(self, validated_data):
"""Overridden create method"""
username = validated_data.get('username')
if not username:
username = utils_methods.username_random()
while account_models.User.objects.filter(username__iexact=username).exists():
username = utils_methods.username_random()
obj = account_models.User.objects.make(
username=validated_data.get('username'),
password=validated_data.get('password'),

View File

@ -0,0 +1,29 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from sorl.thumbnail import get_thumbnail
from collection.models import Collection
from utils.methods import image_url_valid, get_image_meta_by_url
class Command(BaseCommand):
SORL_THUMBNAIL_ALIAS = 'collection_image'
def handle(self, *args, **options):
with transaction.atomic():
for collection in Collection.objects.all():
if not image_url_valid(collection.image_url):
continue
_, width, height = get_image_meta_by_url(collection.image_url)
sorl_settings = settings.SORL_THUMBNAIL_ALIASES[self.SORL_THUMBNAIL_ALIAS]
sorl_width_height = sorl_settings['geometry_string'].split('x')
if int(sorl_width_height[0]) > width or int(sorl_width_height[1]) > height:
collection.image_url = get_thumbnail(
file_=collection.image_url,
**settings.SORL_THUMBNAIL_ALIASES[self.SORL_THUMBNAIL_ALIAS]
).url
collection.save()

View File

@ -165,7 +165,7 @@ class GuideQuerySet(models.QuerySet):
def with_base_related(self):
"""Return QuerySet with related."""
return self.select_related('guide_type', 'site')
return self.select_related('site', )
def by_country_id(self, country_id):
"""Return QuerySet filtered by country code."""

View File

@ -65,7 +65,8 @@ class CollectionEstablishmentListView(CollectionListView):
# May raise a permission denied
self.check_object_permissions(self.request, collection)
return collection.establishments.published().annotate_in_favorites(self.request.user)
return collection.establishments.published().annotate_in_favorites(self.request.user) \
.with_base_related().with_extended_related()
# Guide

View File

@ -0,0 +1,18 @@
from django.core.management.base import BaseCommand
from comment.models import Comment
from tqdm import tqdm
class Command(BaseCommand):
help = 'Add status to comments from is_publish_ flag.'
def handle(self, *args, **kwargs):
to_update = []
for comment in tqdm(Comment.objects.all()):
if hasattr(comment, 'is_publish') and hasattr(comment, 'status'):
comment.status = Comment.PUBLISHED if comment.is_publish else Comment.WAITING
to_update.append(comment)
Comment.objects.bulk_update(to_update, ('status', ))
self.stdout.write(self.style.WARNING(f'Updated {len(to_update)} objects.'))

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.7 on 2020-01-15 13:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comment', '0007_comment_site'),
]
operations = [
migrations.AddField(
model_name='comment',
name='status',
field=models.PositiveSmallIntegerField(choices=[(0, 'Waiting'), (1, 'Published'), (2, 'Rejected'), (3, 'Deleted')], default=0, verbose_name='status'),
),
]

View File

@ -16,6 +16,10 @@ class CommentQuerySet(ContentTypeQuerySetMixin):
"""Return comments by author"""
return self.filter(user=user)
def published(self):
"""Return only published comments."""
return self.filter(status=self.model.PUBLISHED)
def annotate_is_mine_status(self, user):
"""Annotate belonging status"""
return self.annotate(is_mine=models.Case(
@ -27,16 +31,48 @@ class CommentQuerySet(ContentTypeQuerySetMixin):
output_field=models.BooleanField()
))
def public(self, user):
"""
Return QuerySet by rules:
1 With status PUBLISHED
2 With status WAITING if comment author is <user>
"""
qs = self.published()
if isinstance(user, User):
waiting_ids = list(
self.filter(status=self.model.WAITING, user=user)
.values_list('id', flat=True))
published_ids = list(qs.values_list('id', flat=True))
waiting_ids.extend(published_ids)
qs = self.filter(id__in=tuple(waiting_ids))
return qs
class Comment(ProjectBaseMixin):
"""Comment model."""
WAITING = 0
PUBLISHED = 1
REJECTED = 2
DELETED = 3
STATUSES = (
(WAITING, _('Waiting')),
(PUBLISHED, _('Published')),
(REJECTED, _('Rejected')),
(DELETED, _('Deleted')),
)
text = models.TextField(verbose_name=_('Comment text'))
mark = models.PositiveIntegerField(blank=True, null=True, default=None, verbose_name=_('Mark'))
user = models.ForeignKey('account.User', related_name='comments', on_delete=models.CASCADE, verbose_name=_('User'))
old_id = models.IntegerField(null=True, blank=True, default=None)
is_publish = models.BooleanField(default=False, verbose_name=_('Publish status'))
status = models.PositiveSmallIntegerField(choices=STATUSES,
default=WAITING,
verbose_name=_('status'))
site = models.ForeignKey('main.SiteSettings', blank=True, null=True,
on_delete=models.SET_NULL, verbose_name=_('site'))
on_delete=models.SET_NULL, verbose_name=_('site'))
content_type = models.ForeignKey(generic.ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = generic.GenericForeignKey('content_type', 'object_id')

View File

@ -1,4 +1,4 @@
from .common import *
from .mobile import *
from .back import *
from .web import *
from .common import *

View File

@ -1,9 +1 @@
"""Comment app common serializers."""
from comment import models
from rest_framework import serializers
class CommentBaseSerializer(serializers.ModelSerializer):
class Meta:
model = models.Comment
fields = ('id', 'text', 'mark', 'user', 'object_id', 'content_type')

View File

@ -4,13 +4,16 @@ from rest_framework import serializers
from comment.models import Comment
class CommentSerializer(serializers.ModelSerializer):
class CommentBaseSerializer(serializers.ModelSerializer):
"""Comment serializer"""
nickname = serializers.CharField(read_only=True,
source='user.username')
is_mine = serializers.BooleanField(read_only=True)
profile_pic = serializers.URLField(read_only=True,
source='user.cropped_image_url')
status_display = serializers.CharField(read_only=True,
source='get_status_display')
last_ip = serializers.IPAddressField(read_only=True, source='user.last_ip')
class Meta:
"""Serializer for model Comment"""
@ -23,19 +26,11 @@ class CommentSerializer(serializers.ModelSerializer):
'text',
'mark',
'nickname',
'profile_pic'
]
class CommentRUDSerializer(CommentSerializer):
"""Retrieve/Update/Destroy comment serializer."""
class Meta(CommentSerializer.Meta):
"""Meta class."""
fields = [
'id',
'created',
'text',
'mark',
'nickname',
'profile_pic',
'status',
'status_display',
'last_ip',
]
extra_kwargs = {
'status': {'read_only': True},
}

View File

@ -20,6 +20,7 @@ def transfer_comments():
'mark',
'establishment_id',
'account_id',
'state',
)
serialized_data = CommentSerializer(data=list(queryset.values()), many=True)

View File

@ -1,19 +1,19 @@
from rest_framework import generics, permissions
from comment.serializers import back as serializers
from comment.serializers import CommentBaseSerializer
from comment import models
from utils.permissions import IsCommentModerator, IsCountryAdmin
class CommentLstView(generics.ListCreateAPIView):
"""Comment list create view."""
serializer_class = serializers.CommentBaseSerializer
serializer_class = CommentBaseSerializer
queryset = models.Comment.objects.all()
# permission_classes = [permissions.IsAuthenticatedOrReadOnly| IsCommentModerator|IsCountryAdmin]
class CommentRUDView(generics.RetrieveUpdateDestroyAPIView):
"""Comment RUD view."""
serializer_class = serializers.CommentBaseSerializer
serializer_class = CommentBaseSerializer
queryset = models.Comment.objects.all()
permission_classes = [IsCommentModerator]
# permission_classes = [IsCountryAdmin | IsCommentModerator]

View File

@ -1,6 +1,8 @@
"""Establishment app filters."""
from django.core.validators import EMPTY_VALUES
from django.utils.translation import ugettext_lazy as _
from django_filters import rest_framework as filters
from rest_framework.serializers import ValidationError
from establishment import models
@ -8,8 +10,8 @@ from establishment import models
class EstablishmentFilter(filters.FilterSet):
"""Establishment filter set."""
tag_id = filters.NumberFilter(field_name='tags__metadata__id',)
award_id = filters.NumberFilter(field_name='awards__id',)
tag_id = filters.NumberFilter(field_name='tags__metadata__id', )
award_id = filters.NumberFilter(field_name='awards__id', )
search = filters.CharFilter(method='search_text')
type = filters.CharFilter(method='by_type')
subtype = filters.CharFilter(method='by_subtype')
@ -65,6 +67,10 @@ class EmployeeBackFilter(filters.FilterSet):
"""Employee filter set."""
search = filters.CharFilter(method='search_by_name_or_last_name')
position_id = filters.NumberFilter(method='search_by_actual_position_id')
public_mark = filters.NumberFilter(method='search_by_public_mark')
toque_number = filters.NumberFilter(field_name='toque_number')
username = filters.CharFilter(method='search_by_username_or_name')
class Meta:
"""Meta class."""
@ -72,6 +78,10 @@ class EmployeeBackFilter(filters.FilterSet):
model = models.Employee
fields = (
'search',
'position_id',
'public_mark',
'toque_number',
'username',
)
def search_by_name_or_last_name(self, queryset, name, value):
@ -79,3 +89,30 @@ class EmployeeBackFilter(filters.FilterSet):
if value not in EMPTY_VALUES:
return queryset.search_by_name_or_last_name(value)
return queryset
def search_by_actual_position_id(self, queryset, name, value):
"""Search by actual position_id."""
if value not in EMPTY_VALUES:
return queryset.search_by_position_id(value)
return queryset
def search_by_public_mark(self, queryset, name, value):
"""Search by establishment public_mark."""
if value not in EMPTY_VALUES:
return queryset.search_by_public_mark(value)
return queryset
def search_by_username_or_name(self, queryset, name, value):
"""Search by username or name."""
if value not in EMPTY_VALUES:
return queryset.search_by_username_or_name(value)
return queryset
class EmployeeBackSearchFilter(EmployeeBackFilter):
def search_by_name_or_last_name(self, queryset, name, value):
if value not in EMPTY_VALUES:
if len(value) < 3:
raise ValidationError({'detail': _('Type at least 3 characters to search please.')})
return queryset.trigram_search(value)
return queryset

View File

@ -0,0 +1,35 @@
import os
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from sorl.thumbnail import get_thumbnail
from establishment.models import Establishment
from utils.methods import image_url_valid, get_image_meta_by_url
class Command(BaseCommand):
SORL_THUMBNAIL_ALIAS = 'establishment_collection_image'
def handle(self, *args, **options):
with transaction.atomic():
for establishment in Establishment.objects.all():
if establishment.preview_image_url is None \
or not image_url_valid(establishment.preview_image_url):
continue
_, width, height = get_image_meta_by_url(establishment.preview_image_url)
sorl_settings = settings.SORL_THUMBNAIL_ALIASES[self.SORL_THUMBNAIL_ALIAS]
sorl_width_height = sorl_settings['geometry_string'].split('x')
if int(sorl_width_height[0]) > width or int(sorl_width_height[1]) > height:
_, file_ext = os.path.splitext(establishment.preview_image_url)
self.stdout.write(self.style.SUCCESS(f'Optimize: {establishment.preview_image_url}, extension: {file_ext}'))
establishment.preview_image_url = get_thumbnail(
file_=establishment.preview_image_url,
**sorl_settings,
format='JPEG' if file_ext[1:] == 'jpg' else 'PNG'
).url
establishment.save()

View File

@ -0,0 +1,16 @@
# Generated by Django 2.2.7 on 2020-01-15 17:02
from django.db import migrations
from django.contrib.postgres.operations import TrigramExtension, BtreeGinExtension
class Migration(migrations.Migration):
dependencies = [
('establishment', '0071_auto_20200110_1055'),
]
operations = [
TrigramExtension(),
BtreeGinExtension(),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 2.2.7 on 2020-01-15 17:10
import django.contrib.postgres.indexes
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('establishment', '0072_auto_20200115_1702'),
]
operations = [
migrations.AddIndex(
model_name='employee',
index=django.contrib.postgres.indexes.GinIndex(fields=['name'], name='establishme_name_39fda6_gin'),
),
migrations.AddIndex(
model_name='employee',
index=django.contrib.postgres.indexes.GinIndex(fields=['last_name'], name='establishme_last_na_3c53de_gin'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.7 on 2020-01-17 08:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('establishment', '0073_auto_20200115_1710'),
]
operations = [
migrations.AddField(
model_name='employee',
name='available_for_events',
field=models.BooleanField(default=False, verbose_name='Available for events'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 2.2.7 on 2020-01-17 10:50
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('gallery', '0008_merge_20191212_0752'),
('establishment', '0074_employee_available_for_events'),
]
operations = [
migrations.AddField(
model_name='employee',
name='photo',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employee_photo', to='gallery.Image', verbose_name='image instance of model Image'),
),
]

View File

@ -11,6 +11,8 @@ from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance as DistanceMeasure
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import TrigramDistance
from django.contrib.postgres.indexes import GinIndex
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
@ -120,7 +122,7 @@ class EstablishmentQuerySet(models.QuerySet):
def with_base_related(self):
"""Return qs with related objects."""
return self.select_related('address', 'establishment_type'). \
prefetch_related('tags', 'tags__translation')
prefetch_related('tags', 'tags__translation').with_main_image()
def with_schedule(self):
"""Return qs with related schedule."""
@ -264,16 +266,16 @@ class EstablishmentQuerySet(models.QuerySet):
2 With ordering by distance.
"""
qs = self.similar_base(establishment) \
.filter(**filters)
.filter(**filters)
if establishment.address and establishment.address.coordinates:
return Subquery(
qs.annotate_distance(point=establishment.location)
.order_by('distance')
.distinct()
.values_list('id', flat=True)[:settings.LIMITING_QUERY_OBJECTS]
.order_by('distance')
.distinct()
.values_list('id', flat=True)[:settings.LIMITING_QUERY_OBJECTS]
)
return Subquery(
qs.values_list('id', flat=True)[:settings.LIMITING_QUERY_OBJECTS]
qs.values_list('id', flat=True)[:settings.LIMITING_QUERY_OBJECTS]
)
def similar_restaurants(self, restaurant):
@ -291,12 +293,12 @@ class EstablishmentQuerySet(models.QuerySet):
}
)
return self.filter(id__in=ids_by_subquery.queryset) \
.annotate_intermediate_public_mark() \
.annotate_mark_similarity(mark=restaurant.public_mark) \
.order_by('mark_similarity') \
.distinct('mark_similarity', 'id')
.annotate_intermediate_public_mark() \
.annotate_mark_similarity(mark=restaurant.public_mark) \
.order_by('mark_similarity') \
.distinct('mark_similarity', 'id')
def same_subtype(self, establishment):
def annotate_same_subtype(self, establishment):
"""Annotate flag same subtype."""
return self.annotate(same_subtype=Case(
models.When(
@ -312,7 +314,7 @@ class EstablishmentQuerySet(models.QuerySet):
Return QuerySet with objects that similar to Artisan/Producer(s).
:param establishment: Establishment instance
"""
base_qs = self.similar_base(establishment).same_subtype(establishment)
base_qs = self.similar_base(establishment).annotate_same_subtype(establishment)
similarity_rules = {
'ordering': [F('same_subtype').desc(), ],
'distinctions': ['same_subtype', ]
@ -323,8 +325,8 @@ class EstablishmentQuerySet(models.QuerySet):
similarity_rules['distinctions'].append('distance')
return base_qs.has_published_reviews() \
.order_by(*similarity_rules['ordering']) \
.distinct(*similarity_rules['distinctions'], 'id')
.order_by(*similarity_rules['ordering']) \
.distinct(*similarity_rules['distinctions'], 'id')
def by_wine_region(self, wine_region):
"""
@ -358,17 +360,19 @@ class EstablishmentQuerySet(models.QuerySet):
similarity_rules['distinctions'].append('distance')
return base_qs.order_by(*similarity_rules['ordering']) \
.distinct(*similarity_rules['distinctions'], 'id')
.distinct(*similarity_rules['distinctions'], 'id')
def similar_distilleries(self, distillery):
"""
Return QuerySet with objects that similar to Distillery.
:param distillery: Establishment instance
"""
base_qs = self.similar_base(distillery).same_subtype(distillery)
base_qs = self.similar_base(distillery).annotate_same_subtype(distillery).filter(
same_subtype=True
)
similarity_rules = {
'ordering': [F('same_subtype').desc(), ],
'distinctions': ['same_subtype', ]
'ordering': [],
'distinctions': []
}
if distillery.address and distillery.address.coordinates:
base_qs = base_qs.annotate_distance(point=distillery.location)
@ -376,27 +380,29 @@ class EstablishmentQuerySet(models.QuerySet):
similarity_rules['distinctions'].append('distance')
return base_qs.published() \
.has_published_reviews() \
.order_by(*similarity_rules['ordering']) \
.distinct(*similarity_rules['distinctions'], 'id')
.has_published_reviews() \
.order_by(*similarity_rules['ordering']) \
.distinct(*similarity_rules['distinctions'], 'id')
def similar_food_producers(self, distillery):
def similar_food_producers(self, food_producer):
"""
Return QuerySet with objects that similar to Food Producer.
:param distillery: Establishment instance
:param food_producer: Establishment instance
"""
base_qs = self.similar_base(distillery).same_subtype(distillery)
base_qs = self.similar_base(food_producer).annotate_same_subtype(food_producer).filter(
same_subtype=True
)
similarity_rules = {
'ordering': [F('same_subtype').desc(), ],
'distinctions': ['same_subtype', ]
'ordering': [],
'distinctions': []
}
if distillery.address and distillery.address.coordinates:
base_qs = base_qs.annotate_distance(point=distillery.location)
if food_producer.address and food_producer.address.coordinates:
base_qs = base_qs.annotate_distance(point=food_producer.location)
similarity_rules['ordering'].append(F('distance').asc())
similarity_rules['distinctions'].append('distance')
return base_qs.order_by(*similarity_rules['ordering']) \
.distinct(*similarity_rules['distinctions'], 'id')
.distinct(*similarity_rules['distinctions'], 'id')
def last_reviewed(self, point: Point):
"""
@ -500,6 +506,13 @@ class EstablishmentQuerySet(models.QuerySet):
to_attr=attr_name)
)
def with_main_image(self):
return self.prefetch_related(
models.Prefetch('establishment_gallery',
queryset=EstablishmentGallery.objects.main_image(),
to_attr='main_image')
)
class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin,
TranslatedFieldsMixin, HasTagsMixin, FavoritesMixin):
@ -647,7 +660,6 @@ class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin,
country = self.address.city.country
return country.low_price, country.high_price
def set_establishment_type(self, establishment_type):
self.establishment_type = establishment_type
self.establishment_subtypes.exclude(
@ -767,10 +779,12 @@ class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin,
return self.products.wines()
@property
def main_image(self):
def _main_image(self):
"""Please consider using prefetched query_set instead due to API performance issues"""
qs = self.establishment_gallery.main_image()
if qs.exists():
return qs.first().image
image_model = qs.first()
if image_model is not None:
return image_model.image
@property
def restaurant_category_indexing(self):
@ -784,6 +798,10 @@ class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin,
def artisan_category_indexing(self):
return self.tags.filter(category__index_name='shop_category')
@property
def distillery_type_indexing(self):
return self.tags.filter(category__index_name='distillery_type')
@property
def last_comment(self):
if hasattr(self, 'comments_prefetched') and len(self.comments_prefetched):
@ -969,16 +987,54 @@ class EmployeeQuerySet(models.QuerySet):
]
return self.filter(reduce(lambda x, y: x | y, [models.Q(**i) for i in filters]))
def trigram_search(self, search_value: str):
"""Search with mistakes by name or last name."""
return self.annotate(
name_distance=TrigramDistance('name', search_value.lower()),
last_name_distance=TrigramDistance('last_name', search_value.lower()),
).filter(Q(name_distance__lte=0.7) | Q(last_name_distance__lte=0.7)).order_by('name_distance')
def search_by_name_or_last_name(self, value):
"""Search by name or last_name."""
return self._generic_search(value, ['name', 'last_name'])
def search_by_actual_employee(self):
"""Search by actual employee."""
return self.filter(
Q(establishmentemployee__from_date__lte=datetime.now()),
Q(establishmentemployee__to_date__gte=datetime.now()) |
Q(establishmentemployee__to_date__isnull=True)
)
def search_by_position_id(self, value):
"""Search by position_id."""
return self.search_by_actual_employee().filter(
Q(establishmentemployee__position_id=value),
)
def search_by_public_mark(self, value):
"""Search by establishment public_mark."""
return self.search_by_actual_employee().filter(
Q(establishmentemployee__establishment__public_mark=value),
)
def search_by_username_or_name(self, value):
"""Search by username or name."""
return self.search_by_actual_employee().filter(
Q(user__username__icontains=value) |
Q(user__first_name__icontains=value) |
Q(user__last_name__icontains=value)
)
def actual_establishment(self):
e = EstablishmentEmployee.objects.actual().filter(employee=self)
return self.prefetch_related(models.Prefetch('establishmentemployee_set',
queryset=EstablishmentEmployee.objects.actual()
)).all().distinct()
def with_extended_related(self):
return self.prefetch_related('establishments')
class Employee(BaseAttributes):
"""Employee model."""
@ -1014,6 +1070,11 @@ class Employee(BaseAttributes):
verbose_name=_('Tags'))
# old_id = profile_id
old_id = models.IntegerField(verbose_name=_('Old id'), null=True, blank=True)
available_for_events = models.BooleanField(_('Available for events'), default=False)
photo = models.ForeignKey('gallery.Image', on_delete=models.SET_NULL,
blank=True, null=True, default=None,
related_name='employee_photo',
verbose_name=_('image instance of model Image'))
objects = EmployeeQuerySet.as_manager()
@ -1022,6 +1083,34 @@ class Employee(BaseAttributes):
verbose_name = _('Employee')
verbose_name_plural = _('Employees')
indexes = [
GinIndex(fields=('name',)),
GinIndex(fields=('last_name',))
]
@property
def image_object(self):
"""Return image object."""
return self.photo.image if self.photo else None
@property
def crop_image(self):
if hasattr(self, 'photo') and hasattr(self, '_meta'):
if self.photo:
image_property = {
'id': self.photo.id,
'title': self.photo.title,
'original_url': self.photo.image.url,
'orientation_display': self.photo.get_orientation_display(),
'auto_crop_images': {},
}
crop_parameters = [p for p in settings.SORL_THUMBNAIL_ALIASES
if p.startswith(self._meta.model_name.lower())]
for crop in crop_parameters:
image_property['auto_crop_images'].update(
{crop: self.photo.get_image_url(crop)}
)
return image_property
class EstablishmentScheduleQuerySet(models.QuerySet):

View File

@ -1,16 +1,27 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from account.serializers.common import UserShortSerializer
from establishment import models
from establishment import serializers as model_serializers
from establishment.models import ContactPhone
from gallery.models import Image
from location.models import Address
from location.serializers import AddressDetailSerializer, TranslatedField
from main.models import Currency
from location.models import Address
from main.serializers import AwardSerializer
from utils.decorators import with_base_attributes
from utils.serializers import TimeZoneChoiceField
from gallery.models import Image
from django.utils.translation import gettext_lazy as _
from account.serializers.common import UserShortSerializer
from utils.serializers import TimeZoneChoiceField, ImageBaseSerializer
def phones_handler(phones_list, establishment):
"""
create or update phones for establishment
"""
ContactPhone.objects.filter(establishment=establishment).delete()
for new_phone in phones_list:
ContactPhone.objects.create(establishment=establishment, phone=new_phone)
class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSerializer):
@ -26,8 +37,6 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria
queryset=models.Address.objects.all(),
write_only=True
)
phones = model_serializers.ContactPhonesSerializer(read_only=True,
many=True, )
emails = model_serializers.ContactEmailsSerializer(read_only=True,
many=True, )
socials = model_serializers.SocialNetworkRelatedSerializers(read_only=True,
@ -37,6 +46,13 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria
address_id = serializers.PrimaryKeyRelatedField(write_only=True, source='address',
queryset=Address.objects.all())
tz = TimeZoneChoiceField()
phones = serializers.ListField(
source='contact_phones',
allow_null=True,
allow_empty=True,
child=serializers.CharField(max_length=128),
required=False,
)
class Meta:
model = models.Establishment
@ -64,6 +80,15 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria
'address_id',
]
def create(self, validated_data):
phones_list = []
if 'contact_phones' in validated_data:
phones_list = validated_data.pop('contact_phones')
instance = super().create(validated_data)
phones_handler(phones_list, instance)
return instance
class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer):
"""Establishment create serializer"""
@ -73,18 +98,24 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer):
queryset=models.EstablishmentType.objects.all(), write_only=True
)
address = AddressDetailSerializer()
phones = model_serializers.ContactPhonesSerializer(read_only=False,
many=True, )
emails = model_serializers.ContactEmailsSerializer(read_only=False,
many=True, )
socials = model_serializers.SocialNetworkRelatedSerializers(read_only=False,
many=True, )
type = model_serializers.EstablishmentTypeBaseSerializer(source='establishment_type')
phones = serializers.ListField(
source='contact_phones',
allow_null=True,
allow_empty=True,
child=serializers.CharField(max_length=128),
required=False,
)
class Meta:
model = models.Establishment
fields = [
'id',
'slug',
'name',
'website',
'phones',
@ -101,6 +132,15 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer):
'tags',
]
def update(self, instance, validated_data):
phones_list = []
if 'contact_phones' in validated_data:
phones_list = validated_data.pop('contact_phones')
instance = super().update(instance, validated_data)
phones_handler(phones_list, instance)
return instance
class SocialChoiceSerializers(serializers.ModelSerializer):
"""SocialChoice serializers."""
@ -166,7 +206,6 @@ class ContactEmailBackSerializers(model_serializers.PlateSerializer):
]
class PositionBackSerializer(serializers.ModelSerializer):
"""Position Back serializer."""
@ -181,6 +220,7 @@ class PositionBackSerializer(serializers.ModelSerializer):
'index_name',
]
# TODO: test decorator
@with_base_attributes
class EmployeeBackSerializers(serializers.ModelSerializer):
@ -189,25 +229,30 @@ class EmployeeBackSerializers(serializers.ModelSerializer):
positions = serializers.SerializerMethodField()
establishment = serializers.SerializerMethodField()
awards = AwardSerializer(many=True, read_only=True)
toque_number = serializers.SerializerMethodField()
photo = ImageBaseSerializer(source='crop_image', read_only=True)
def get_public_mark(self, obj):
"""Get last list actual public_mark"""
qs = obj.establishmentemployee_set.actual().order_by('-from_date')\
qs = obj.establishmentemployee_set.actual().order_by('-from_date') \
.values('establishment__public_mark').first()
return qs['establishment__public_mark'] if qs else None
def get_toque_number(self, obj):
qs = obj.establishmentemployee_set.actual().order_by('-from_date') \
.values('establishment__toque_number').first()
return qs['establishment__toque_number'] if qs else None
def get_positions(self, obj):
"""Get last list actual positions"""
est_id = obj.establishmentemployee_set.actual().\
est_id = obj.establishmentemployee_set.actual(). \
order_by('-from_date').first()
if not est_id:
return None
qs = obj.establishmentemployee_set.actual()\
.filter(establishment_id=est_id.establishment_id)\
qs = obj.establishmentemployee_set.actual() \
.filter(establishment_id=est_id.establishment_id) \
.prefetch_related('position').values('position')
positions = models.Position.objects.filter(id__in=[q['position'] for q in qs])
@ -216,7 +261,7 @@ class EmployeeBackSerializers(serializers.ModelSerializer):
def get_establishment(self, obj):
"""Get last actual establishment"""
est = obj.establishmentemployee_set.actual().order_by('-from_date')\
est = obj.establishmentemployee_set.actual().order_by('-from_date') \
.first()
if not est:
@ -242,7 +287,9 @@ class EmployeeBackSerializers(serializers.ModelSerializer):
'birth_date',
'email',
'phone',
'toque_number'
'toque_number',
'available_for_events',
'photo',
]
@ -380,6 +427,7 @@ class EstablishmentNoteListCreateSerializer(EstablishmentNoteBaseSerializer):
class EstablishmentAdminListSerializer(UserShortSerializer):
"""Establishment admin serializer."""
class Meta:
model = UserShortSerializer.Meta.model
fields = [

View File

@ -1,4 +1,7 @@
"""Establishment serializers."""
import logging
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from phonenumber_field.phonenumber import to_python as str_to_phonenumber
from rest_framework import serializers
@ -6,8 +9,10 @@ from rest_framework import serializers
from comment import models as comment_models
from comment.serializers import common as comment_serializers
from establishment import models
from location.serializers import AddressBaseSerializer, CitySerializer, AddressDetailSerializer, \
from location.serializers import AddressBaseSerializer, CityBaseSerializer, AddressDetailSerializer, \
CityShortSerializer
from location.serializers import EstablishmentWineRegionBaseSerializer, \
EstablishmentWineOriginBaseSerializer
from main.serializers import AwardSerializer, CurrencySerializer
from review.serializers import ReviewShortSerializer
from tag.serializers import TagBaseSerializer
@ -16,9 +21,8 @@ from utils import exceptions as utils_exceptions
from utils.serializers import ImageBaseSerializer, CarouselCreateSerializer
from utils.serializers import (ProjectModelSerializer, TranslatedField,
FavoritesCreateSerializer)
from location.serializers import EstablishmentWineRegionBaseSerializer, \
EstablishmentWineOriginBaseSerializer
logger = logging.getLogger(__name__)
class ContactPhonesSerializer(serializers.ModelSerializer):
"""Contact phone serializer"""
@ -54,7 +58,6 @@ class SocialNetworkRelatedSerializers(serializers.ModelSerializer):
class PlateSerializer(ProjectModelSerializer):
name_translated = TranslatedField()
currency = CurrencySerializer(read_only=True)
class Meta:
model = models.Plate
@ -232,7 +235,7 @@ class EstablishmentEmployeeCreateSerializer(serializers.ModelSerializer):
class EstablishmentShortSerializer(serializers.ModelSerializer):
"""Short serializer for establishment."""
city = CitySerializer(source='address.city', allow_null=True)
city = CityBaseSerializer(source='address.city', allow_null=True)
establishment_type = EstablishmentTypeGeoSerializer()
establishment_subtypes = EstablishmentSubTypeBaseSerializer(many=True)
currency = CurrencySerializer(read_only=True)
@ -254,10 +257,9 @@ class EstablishmentShortSerializer(serializers.ModelSerializer):
class _EstablishmentAddressShortSerializer(serializers.ModelSerializer):
"""Short serializer for establishment."""
city = CitySerializer(source='address.city', allow_null=True)
city = CityBaseSerializer(source='address.city', allow_null=True)
establishment_type = EstablishmentTypeGeoSerializer()
establishment_subtypes = EstablishmentSubTypeBaseSerializer(many=True)
currency = CurrencySerializer(read_only=True)
address = AddressBaseSerializer(read_only=True)
class Meta:
@ -321,14 +323,45 @@ class EstablishmentBaseSerializer(ProjectModelSerializer):
currency = CurrencySerializer()
type = EstablishmentTypeBaseSerializer(source='establishment_type', read_only=True)
subtypes = EstablishmentSubTypeBaseSerializer(many=True, source='establishment_subtypes')
image = serializers.URLField(source='image_url', read_only=True)
image = serializers.SerializerMethodField(read_only=True)
wine_regions = EstablishmentWineRegionBaseSerializer(many=True, source='wine_origins_unique',
read_only=True, allow_null=True)
preview_image = serializers.URLField(source='preview_image_url',
allow_null=True,
read_only=True)
tz = serializers.CharField(read_only=True, source='timezone_as_str')
new_image = ImageBaseSerializer(source='crop_main_image', allow_null=True, read_only=True)
new_image = serializers.SerializerMethodField(allow_null=True, read_only=True)
distillery_type = TagBaseSerializer(read_only=True, many=True, allow_null=True)
def get_image(self, obj):
if obj.main_image:
return obj.main_image[0].image.image.url if len(obj.main_image) else None
logging.info('Possibly not optimal image reading')
return obj._main_image # backwards compatibility
def get_new_image(self, obj):
if hasattr(self, 'main_image') and hasattr(self, '_meta'):
if obj.main_image and len(obj.main_image):
main_image = obj.main_image[0].image
else:
logging.info('Possibly not optimal image reading')
main_image = obj._main_image
if main_image:
image = main_image
image_property = {
'id': image.id,
'title': image.title,
'original_url': image.image.url,
'orientation_display': image.get_orientation_display(),
'auto_crop_images': {},
}
crop_parameters = [p for p in settings.SORL_THUMBNAIL_ALIASES
if p.startswith(self._meta.model_name.lower())]
for crop in crop_parameters:
image_property['auto_crop_images'].update(
{crop: image.get_image_url(crop)}
)
return image_property
class Meta:
"""Meta class."""
@ -354,6 +387,7 @@ class EstablishmentBaseSerializer(ProjectModelSerializer):
'new_image',
'tz',
'wine_regions',
'distillery_type',
]
@ -365,6 +399,7 @@ class EstablishmentListRetrieveSerializer(EstablishmentBaseSerializer):
restaurant_category = TagBaseSerializer(read_only=True, many=True, allow_null=True)
restaurant_cuisine = TagBaseSerializer(read_only=True, many=True, allow_null=True)
artisan_category = TagBaseSerializer(read_only=True, many=True, allow_null=True)
distillery_type = TagBaseSerializer(read_only=True, many=True, allow_null=True)
class Meta(EstablishmentBaseSerializer.Meta):
"""Meta class."""
@ -374,6 +409,7 @@ class EstablishmentListRetrieveSerializer(EstablishmentBaseSerializer):
'restaurant_category',
'restaurant_cuisine',
'artisan_category',
'distillery_type',
]
@ -448,7 +484,7 @@ class EstablishmentDetailSerializer(EstablishmentBaseSerializer):
class MobileEstablishmentDetailSerializer(EstablishmentDetailSerializer):
"""Serializer for Establishment model for mobiles."""
last_comment = comment_serializers.CommentRUDSerializer(allow_null=True)
last_comment = comment_serializers.CommentBaseSerializer(allow_null=True)
class Meta(EstablishmentDetailSerializer.Meta):
"""Meta class."""
@ -467,6 +503,7 @@ class EstablishmentSimilarSerializer(EstablishmentBaseSerializer):
artisan_category = TagBaseSerializer(many=True, allow_null=True, read_only=True)
restaurant_category = TagBaseSerializer(many=True, allow_null=True, read_only=True)
restaurant_cuisine = TagBaseSerializer(many=True, allow_null=True, read_only=True)
distillery_type = TagBaseSerializer(many=True, allow_null=True, read_only=True)
class Meta(EstablishmentBaseSerializer.Meta):
fields = EstablishmentBaseSerializer.Meta.fields + [
@ -475,16 +512,15 @@ class EstablishmentSimilarSerializer(EstablishmentBaseSerializer):
'artisan_category',
'restaurant_category',
'restaurant_cuisine',
'distillery_type',
]
class EstablishmentCommentCreateSerializer(comment_serializers.CommentSerializer):
class EstablishmentCommentBaseSerializer(comment_serializers.CommentBaseSerializer):
"""Create comment serializer"""
mark = serializers.IntegerField()
class Meta:
class Meta(comment_serializers.CommentBaseSerializer.Meta):
"""Serializer for model Comment"""
model = comment_models.Comment
fields = [
'id',
'created',
@ -492,8 +528,14 @@ class EstablishmentCommentCreateSerializer(comment_serializers.CommentSerializer
'mark',
'nickname',
'profile_pic',
'status',
'status_display',
]
class EstablishmentCommentCreateSerializer(EstablishmentCommentBaseSerializer):
"""Extended EstablishmentCommentBaseSerializer."""
def validate(self, attrs):
"""Override validate method"""
# Check establishment object
@ -513,7 +555,7 @@ class EstablishmentCommentCreateSerializer(comment_serializers.CommentSerializer
return super().create(validated_data)
class EstablishmentCommentRUDSerializer(comment_serializers.CommentSerializer):
class EstablishmentCommentRUDSerializer(comment_serializers.CommentBaseSerializer):
"""Retrieve/Update/Destroy comment serializer."""
class Meta:

View File

@ -45,6 +45,7 @@ urlpatterns = [
path('<int:establishment_id>/employees/', views.EstablishmentEmployeeListView.as_view(),
name='establishment-employees'),
path('employees/', views.EmployeeListCreateView.as_view(), name='employees'),
path('employees/search/', views.EmployeesListSearchViews.as_view(), name='employees-search'),
path('employees/<int:pk>/', views.EmployeeRUDView.as_view(), name='employees-rud'),
path('<int:establishment_id>/employee/<int:employee_id>/position/<int:position_id>',
views.EstablishmentEmployeeCreateView.as_view(),

View File

@ -2,7 +2,7 @@
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import generics, permissions, status
from rest_framework import generics, permissions, status, filters as rest_filters
from account.models import User
from establishment import filters, models, serializers
@ -19,7 +19,7 @@ class EstablishmentMixinViews:
def get_queryset(self):
"""Overrided method 'get_queryset'."""
return models.Establishment.objects.published().with_base_related()
return models.Establishment.objects.with_base_related()
class EstablishmentListCreateView(EstablishmentMixinViews, generics.ListCreateAPIView):
@ -34,7 +34,10 @@ class EstablishmentListCreateView(EstablishmentMixinViews, generics.ListCreateAP
class EstablishmentRUDView(generics.RetrieveUpdateDestroyAPIView):
lookup_field = 'slug'
queryset = models.Establishment.objects.all()
queryset = models.Establishment.objects.all().prefetch_related(
'establishmentemployee_set',
'establishmentemployee_set__establishment',
)
serializer_class = serializers.EstablishmentRUDSerializer
permission_classes = [IsWineryReviewer | IsCountryAdmin | IsEstablishmentManager]
@ -171,17 +174,33 @@ class EmployeeListCreateView(generics.ListCreateAPIView):
permission_classes = (permissions.AllowAny,)
filter_class = filters.EmployeeBackFilter
serializer_class = serializers.EmployeeBackSerializers
queryset = models.Employee.objects.all()
queryset = models.Employee.objects.all().prefetch_related(
'establishmentemployee_set',
'establishmentemployee_set__establishment',
)
class EmployeesListSearchViews(generics.ListAPIView):
"""Employee search view"""
pagination_class = None
permission_classes = (permissions.AllowAny,)
queryset = models.Employee.objects.all().prefetch_related(
'establishmentemployee_set',
'establishmentemployee_set__establishment',
).select_related('photo')
filter_class = filters.EmployeeBackSearchFilter
serializer_class = serializers.EmployeeBackSerializers
class EstablishmentEmployeeListView(generics.ListCreateAPIView):
"""Establishment emplyoees list view."""
permission_classes = (permissions.AllowAny,)
serializer_class = serializers.EstablishmentEmployeeBackSerializer
pagination_class = None
def get_queryset(self):
establishment_id = self.kwargs['establishment_id']
return models.EstablishmentEmployee.objects.filter(establishment__id=establishment_id)
return models.EstablishmentEmployee.objects.filter(establishment__id=establishment_id).order_by('position')
class EmployeeRUDView(generics.RetrieveUpdateDestroyAPIView):
@ -193,27 +212,27 @@ class EmployeeRUDView(generics.RetrieveUpdateDestroyAPIView):
class EstablishmentTypeListCreateView(generics.ListCreateAPIView):
"""Establishment type list/create view."""
serializer_class = serializers.EstablishmentTypeBaseSerializer
queryset = models.EstablishmentType.objects.all()
queryset = models.EstablishmentType.objects.all().select_related('default_image')
pagination_class = None
class EstablishmentTypeRUDView(generics.RetrieveUpdateDestroyAPIView):
"""Establishment type retrieve/update/destroy view."""
serializer_class = serializers.EstablishmentTypeBaseSerializer
queryset = models.EstablishmentType.objects.all()
queryset = models.EstablishmentType.objects.all().select_related('default_image')
class EstablishmentSubtypeListCreateView(generics.ListCreateAPIView):
"""Establishment subtype list/create view."""
serializer_class = serializers.EstablishmentSubTypeBaseSerializer
queryset = models.EstablishmentSubType.objects.all()
queryset = models.EstablishmentSubType.objects.all().select_related('default_image')
pagination_class = None
class EstablishmentSubtypeRUDView(generics.RetrieveUpdateDestroyAPIView):
"""Establishment subtype retrieve/update/destroy view."""
serializer_class = serializers.EstablishmentSubTypeBaseSerializer
queryset = models.EstablishmentSubType.objects.all()
queryset = models.EstablishmentSubType.objects.all().select_related('default_image')
class EstablishmentGalleryCreateDestroyView(EstablishmentMixinViews,
@ -385,7 +404,7 @@ class EstablishmentPositionListView(generics.ListAPIView):
class EstablishmentAdminView(generics.ListAPIView):
"""Establishment admin list view."""
serializer_class = serializers.EstablishmentAdminListSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly, )
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def get_queryset(self):
establishment = get_object_or_404(

View File

@ -5,7 +5,6 @@ from django.shortcuts import get_object_or_404
from rest_framework import generics, permissions
from comment import models as comment_models
from comment.serializers import CommentRUDSerializer
from establishment import filters, models, serializers
from main import methods
from utils.pagination import PortionPagination
@ -38,7 +37,8 @@ class EstablishmentListView(EstablishmentMixinView, generics.ListAPIView):
.with_extended_address_related().with_currency_related() \
.with_certain_tag_category_related('category', 'restaurant_category') \
.with_certain_tag_category_related('cuisine', 'restaurant_cuisine') \
.with_certain_tag_category_related('shop_category', 'artisan_category')
.with_certain_tag_category_related('shop_category', 'artisan_category') \
.with_certain_tag_category_related('distillery_type', 'distillery_type')
class EstablishmentSimilarView(EstablishmentListView):
@ -67,7 +67,8 @@ class EstablishmentRetrieveView(EstablishmentMixinView, generics.RetrieveAPIView
serializer_class = serializers.EstablishmentDetailSerializer
def get_queryset(self):
return super().get_queryset().with_extended_related()
return super().get_queryset().with_extended_related() \
.with_certain_tag_category_related('distillery_type', 'distillery_type')
class EstablishmentMobileRetrieveView(EstablishmentRetrieveView):
@ -186,18 +187,17 @@ class EstablishmentCommentListView(generics.ListAPIView):
"""View for return list of establishment comments."""
permission_classes = (permissions.AllowAny,)
serializer_class = serializers.EstablishmentCommentCreateSerializer
serializer_class = serializers.EstablishmentCommentBaseSerializer
def get_queryset(self):
"""Override get_queryset method"""
establishment = get_object_or_404(models.Establishment, slug=self.kwargs['slug'])
return establishment.comments.order_by('-created')
return establishment.comments.public(self.request.user).order_by('-created')
class EstablishmentCommentRUDView(generics.RetrieveUpdateDestroyAPIView):
"""View for retrieve/update/destroy establishment comment."""
serializer_class = CommentRUDSerializer
serializer_class = serializers.EstablishmentCommentBaseSerializer
queryset = models.Establishment.objects.all()
def get_object(self):

View File

@ -32,7 +32,8 @@ class FavoritesEstablishmentListView(generics.ListAPIView):
.order_by('-favorites').with_base_related() \
.with_certain_tag_category_related('category', 'restaurant_category') \
.with_certain_tag_category_related('cuisine', 'restaurant_cuisine') \
.with_certain_tag_category_related('shop_category', 'artisan_category')
.with_certain_tag_category_related('shop_category', 'artisan_category') \
.with_certain_tag_category_related('distillery_type', 'distillery_type')
class FavoritesProductListView(generics.ListAPIView):

View File

@ -0,0 +1,20 @@
# Generated by Django 2.2.7 on 2020-01-15 09:45
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('gallery', '0008_merge_20191212_0752'),
('location', '0033_merge_20191224_0920'),
]
operations = [
migrations.AddField(
model_name='city',
name='image',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='city_image', to='gallery.Image', verbose_name='image instance of model Image'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 2.2.7 on 2020-01-15 11:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('location', '0034_city_image'),
]
operations = [
migrations.RemoveField(
model_name='city',
name='gallery',
),
migrations.DeleteModel(
name='CityGallery',
),
]

View File

@ -75,7 +75,6 @@ class Country(TranslatedFieldsMixin,
return self.id
class RegionQuerySet(models.QuerySet):
"""QuerySet for model Region."""
@ -144,7 +143,7 @@ class CityQuerySet(models.QuerySet):
return self.filter(country__code=code)
class City(GalleryMixin, models.Model):
class City(models.Model):
"""Region model."""
name = models.CharField(_('name'), max_length=250)
name_translated = TJSONField(blank=True, null=True, default=None,
@ -167,8 +166,10 @@ class City(GalleryMixin, models.Model):
map2 = models.CharField(max_length=255, blank=True, null=True)
map_ref = models.CharField(max_length=255, blank=True, null=True)
situation = models.CharField(max_length=255, blank=True, null=True)
gallery = models.ManyToManyField('gallery.Image', through='location.CityGallery', blank=True)
image = models.ForeignKey('gallery.Image', on_delete=models.SET_NULL,
blank=True, null=True, default=None,
related_name='city_image',
verbose_name=_('image instance of model Image'))
mysql_id = models.IntegerField(blank=True, null=True, default=None)
@ -181,23 +182,29 @@ class City(GalleryMixin, models.Model):
def __str__(self):
return self.name
@property
def image_object(self):
"""Return image object."""
return self.image.image if self.image else None
class CityGallery(IntermediateGalleryModelMixin):
"""Gallery for model City."""
city = models.ForeignKey(City, null=True,
related_name='city_gallery',
on_delete=models.CASCADE,
verbose_name=_('city'))
image = models.ForeignKey('gallery.Image', null=True,
related_name='city_gallery',
on_delete=models.CASCADE,
verbose_name=_('image'))
class Meta:
"""CityGallery meta class."""
verbose_name = _('city gallery')
verbose_name_plural = _('city galleries')
unique_together = (('city', 'is_main'), ('city', 'image'))
@property
def crop_image(self):
if hasattr(self, 'image') and hasattr(self, '_meta'):
if self.image:
image_property = {
'id': self.image.id,
'title': self.image.title,
'original_url': self.image.image.url,
'orientation_display': self.image.get_orientation_display(),
'auto_crop_images': {},
}
crop_parameters = [p for p in settings.SORL_THUMBNAIL_ALIASES
if p.startswith(self._meta.model_name.lower())]
for crop in crop_parameters:
image_property['auto_crop_images'].update(
{crop: self.image.get_image_url(crop)}
)
return image_property
class Address(models.Model):

View File

@ -1,8 +1,5 @@
from location import models
from location.serializers import common
from rest_framework import serializers
from gallery.models import Image
from django.utils.translation import gettext_lazy as _
class AddressCreateSerializer(common.AddressDetailSerializer):
@ -21,46 +18,3 @@ class CountryBackSerializer(common.CountrySerializer):
'name',
'country_id'
]
class CityGallerySerializer(serializers.ModelSerializer):
"""Serializer class for model CityGallery."""
class Meta:
"""Meta class"""
model = models.CityGallery
fields = [
'id',
'is_main',
]
@property
def request_kwargs(self):
"""Get url kwargs from request."""
return self.context.get('request').parser_context.get('kwargs')
def validate(self, attrs):
"""Override validate method."""
city_pk = self.request_kwargs.get('pk')
image_id = self.request_kwargs.get('image_id')
city_qs = models.City.objects.filter(pk=city_pk)
image_qs = Image.objects.filter(id=image_id)
if not city_qs.exists():
raise serializers.ValidationError({'detail': _('City not found')})
if not image_qs.exists():
raise serializers.ValidationError({'detail': _('Image not found')})
city = city_qs.first()
image = image_qs.first()
if image in city.gallery.all():
raise serializers.ValidationError({'detail': _('Image is already added.')})
attrs['city'] = city
attrs['image'] = image
return attrs

View File

@ -3,7 +3,7 @@ from django.contrib.gis.geos import Point
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from location import models
from utils.serializers import TranslatedField
from utils.serializers import TranslatedField, ImageBaseSerializer
class CountrySerializer(serializers.ModelSerializer):
@ -70,7 +70,7 @@ class CityShortSerializer(serializers.ModelSerializer):
)
class CitySerializer(serializers.ModelSerializer):
class CityBaseSerializer(serializers.ModelSerializer):
"""City serializer."""
region = RegionSerializer(read_only=True)
region_id = serializers.PrimaryKeyRelatedField(
@ -96,6 +96,21 @@ class CitySerializer(serializers.ModelSerializer):
'country',
'postal_code',
'is_island',
'image',
]
extra_fields = {
'image': {'write_only': True}
}
class CityDetailSerializer(CityBaseSerializer):
"""Serializer for detail view."""
image = ImageBaseSerializer(source='crop_image', read_only=True)
class Meta(CityBaseSerializer.Meta):
"""Meta class."""
fields = CityBaseSerializer.Meta.fields + [
'image',
]
@ -154,7 +169,7 @@ class AddressDetailSerializer(AddressBaseSerializer):
city_id = serializers.PrimaryKeyRelatedField(
source='city', write_only=True,
queryset=models.City.objects.all())
city = CitySerializer(read_only=True)
city = CityBaseSerializer(read_only=True)
class Meta(AddressBaseSerializer.Meta):
"""Meta class."""

View File

@ -10,7 +10,7 @@ from tqdm import tqdm
from account.models import Role
from collection.models import Collection
from gallery.models import Image
from location.models import Country, Region, City, Address, CityGallery
from location.models import Country, Region, City, Address
from main.models import AwardType
from news.models import News
from review.models import Review
@ -218,29 +218,6 @@ def migrate_city_map_situation(get_exists_cities=False):
pprint(f"City info serializer errors: {serialized_data.errors}")
def migrate_city_photos():
queryset = transfer_models.CityPhotos.objects.raw("""SELECT city_photos.id, city_photos.city_id, city_photos.attachment_file_name
FROM city_photos WHERE
city_photos.attachment_file_name IS NOT NULL AND
city_id IN(
SELECT cities.id
FROM cities WHERE
region_code IS NOT NULL AND
region_code != "" AND
country_code_2 IS NOT NULL AND
country_code_2 != ""
)
""")
queryset = [vars(query) for query in queryset]
serialized_data = location_serializers.CityGallerySerializer(data=queryset, many=True)
if serialized_data.is_valid():
serialized_data.save()
else:
pprint(f"Address serializer errors: {serialized_data.errors}")
# Update location models with ruby library
# Utils functions defined before transfer functions
def get_ruby_socket(params):
@ -554,10 +531,10 @@ def remove_old_records():
clean_old_region_records(Region, {"mysql_ids__isnull": True})
def transfer_city_gallery():
def transfer_city_photos():
created_counter = 0
cities_not_exists = {}
gallery_obj_exists_counter = 0
cities_has_same_image = 0
city_gallery = transfer_models.CityPhotos.objects.exclude(city__isnull=True) \
.exclude(city__country_code_2__isnull=True) \
@ -565,7 +542,7 @@ def transfer_city_gallery():
.exclude(city__region_code__isnull=True) \
.exclude(city__region_code__iexact='') \
.values_list('city_id', 'attachment_suffix_url')
for old_city_id, image_suffix_url in city_gallery:
for old_city_id, image_suffix_url in tqdm(city_gallery):
city = City.objects.filter(old_id=old_city_id)
if city.exists():
city = city.first()
@ -575,19 +552,18 @@ def transfer_city_gallery():
'orientation': Image.HORIZONTAL,
'title': f'{city.name} - {image_suffix_url}',
})
city_gallery, created = CityGallery.objects.get_or_create(image=image,
city=city,
is_main=True)
if created:
if city.image != image:
city.image = image
city.save()
created_counter += 1
else:
gallery_obj_exists_counter += 1
cities_has_same_image += 1
else:
cities_not_exists.update({'city_old_id': old_city_id})
print(f'Created: {created_counter}\n'
f'City not exists: {cities_not_exists}\n'
f'Already added: {gallery_obj_exists_counter}')
f'City has same image: {cities_has_same_image}')
@atomic
@ -758,8 +734,8 @@ def setup_clean_db():
print('update_flags')
update_flags()
print('transfer_city_gallery')
transfer_city_gallery()
print('transfer_city_photos')
transfer_city_photos()
def set_unused_regions():
@ -796,7 +772,6 @@ def set_unused_regions():
)
data_types = {
"dictionaries": [
# transfer_countries,
@ -813,9 +788,6 @@ data_types = {
"update_city_info": [
migrate_city_map_situation
],
"migrate_city_gallery": [
migrate_city_photos
],
"fix_location": [
add_fake_country,
fix_location_models,
@ -823,13 +795,12 @@ data_types = {
"remove_old_locations": [
remove_old_records
],
"fill_city_gallery": [
transfer_city_gallery
"migrate_city_photos": [
transfer_city_photos,
],
"add_fake_country": [
add_fake_country,
],
"setup_clean_db": [setup_clean_db],
"set_unused_regions": [set_unused_regions],
"update_fake_country_flag": [update_fake_country_flag]

View File

@ -12,11 +12,6 @@ urlpatterns = [
path('cities/', views.CityListCreateView.as_view(), name='city-list-create'),
path('cities/all/', views.CityListSearchView.as_view(), name='city-list-create'),
path('cities/<int:pk>/', views.CityRUDView.as_view(), name='city-retrieve'),
path('cities/<int:pk>/gallery/', views.CityGalleryListView.as_view(),
name='gallery-list'),
path('cities/<int:pk>/gallery/<int:image_id>/',
views.CityGalleryCreateDestroyView.as_view(),
name='gallery-create-destroy'),
path('countries/', views.CountryListCreateView.as_view(), name='country-list-create'),
path('countries/<int:pk>/', views.CountryRUDView.as_view(), name='country-retrieve'),

View File

@ -36,7 +36,7 @@ class AddressRUDView(common.AddressViewMixin, generics.RetrieveUpdateDestroyAPIV
# City
class CityListCreateView(common.CityViewMixin, generics.ListCreateAPIView):
"""Create view for model City."""
serializer_class = serializers.CitySerializer
serializer_class = serializers.CityBaseSerializer
permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin]
queryset = models.City.objects.all()
filter_class = filters.CityBackFilter
@ -52,7 +52,7 @@ class CityListCreateView(common.CityViewMixin, generics.ListCreateAPIView):
class CityListSearchView(common.CityViewMixin, generics.ListCreateAPIView):
"""Create view for model City."""
serializer_class = serializers.CitySerializer
serializer_class = serializers.CityBaseSerializer
permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin]
queryset = models.City.objects.all()\
.annotate(locale_name=KeyTextTransform(get_current_locale(), 'name_translated'))\
@ -63,61 +63,10 @@ class CityListSearchView(common.CityViewMixin, generics.ListCreateAPIView):
class CityRUDView(common.CityViewMixin, generics.RetrieveUpdateDestroyAPIView):
"""RUD view for model City."""
serializer_class = serializers.CitySerializer
serializer_class = serializers.CityDetailSerializer
permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin]
class CityGalleryCreateDestroyView(common.CityViewMixin,
CreateDestroyGalleryViewMixin):
"""Resource for a create gallery for product for back-office users."""
serializer_class = serializers.CityGallerySerializer
permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin]
def get_object(self):
"""
Returns the object the view is displaying.
"""
city_qs = self.filter_queryset(self.get_queryset())
city = get_object_or_404(city_qs, pk=self.kwargs.get('pk'))
gallery = get_object_or_404(city.city_gallery, image_id=self.kwargs.get('image_id'))
# May raise a permission denied
self.check_object_permissions(self.request, gallery)
return gallery
def create(self, request, *args, **kwargs):
try:
return super(CityGalleryCreateDestroyView, self).create(request, *args, **kwargs)
except IntegrityError as e:
if not 'unique constraint' in e.args[0]:
raise e
models.CityGallery.objects.filter(city=kwargs['pk'], is_main=request.data['is_main']).delete()
return super(CityGalleryCreateDestroyView, self).create(request, *args, **kwargs)
class CityGalleryListView(common.CityViewMixin,
generics.ListAPIView):
"""Resource for returning gallery for product for back-office users."""
serializer_class = ImageBaseSerializer
permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin]
def get_object(self):
"""Override get_object method."""
qs = super(CityGalleryListView, self).get_queryset()
city = get_object_or_404(qs, pk=self.kwargs['pk'])
# May raise a permission denied
self.check_object_permissions(self.request, city)
return city
def get_queryset(self):
"""Override get_queryset method."""
return self.get_object().crop_gallery
# Region
class RegionListCreateView(common.RegionViewMixin, generics.ListCreateAPIView):
"""Create view for model Region"""

View File

@ -85,18 +85,18 @@ class RegionUpdateView(RegionViewMixin, generics.UpdateAPIView):
# City
class CityCreateView(CityViewMixin, generics.CreateAPIView):
"""Create view for model City"""
serializer_class = serializers.CitySerializer
serializer_class = serializers.CityBaseSerializer
class CityRetrieveView(CityViewMixin, generics.RetrieveAPIView):
"""Retrieve view for model City"""
serializer_class = serializers.CitySerializer
serializer_class = serializers.CityDetailSerializer
class CityListView(CityViewMixin, generics.ListAPIView):
"""List view for model City"""
permission_classes = (permissions.AllowAny,)
serializer_class = serializers.CitySerializer
serializer_class = serializers.CityBaseSerializer
def get_queryset(self):
qs = super().get_queryset()
@ -107,12 +107,12 @@ class CityListView(CityViewMixin, generics.ListAPIView):
class CityDestroyView(CityViewMixin, generics.DestroyAPIView):
"""Destroy view for model City"""
serializer_class = serializers.CitySerializer
serializer_class = serializers.CityBaseSerializer
class CityUpdateView(CityViewMixin, generics.UpdateAPIView):
"""Update view for model City"""
serializer_class = serializers.CitySerializer
serializer_class = serializers.CityBaseSerializer
# Address

View File

@ -14,9 +14,18 @@ class SiteSettingsAdmin(admin.ModelAdmin):
inlines = [SiteSettingsInline, ]
@admin.register(models.SiteFeature)
class SiteFeatureAdmin(admin.ModelAdmin):
"""Site feature admin conf."""
list_display = ['id', 'site_settings', 'feature',
'published', 'main', 'backoffice', ]
raw_id_fields = ['site_settings', 'feature', ]
@admin.register(models.Feature)
class FeatureAdmin(admin.ModelAdmin):
"""Feature admin conf."""
list_display = ['id', '__str__', 'priority', 'route', ]
@admin.register(models.AwardType)
@ -46,6 +55,7 @@ class CarouselAdmin(admin.ModelAdmin):
@admin.register(models.PageType)
class PageTypeAdmin(admin.ModelAdmin):
"""PageType admin."""
list_display = ['id', '__str__', ]
@admin.register(models.Page)
@ -80,3 +90,13 @@ class PanelAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'user', 'created',)
raw_id_fields = ('user',)
list_display_links = ('id', 'name',)
@admin.register(models.NavigationBarPermission)
class NavigationBarPermissionAdmin(admin.ModelAdmin):
"""NavigationBarPermission admin."""
list_display = ('id', 'permission_mode_display', )
def permission_mode_display(self, obj):
"""Get permission mode display."""
return obj.get_permission_mode_display()

View File

@ -0,0 +1,44 @@
# Generated by Django 2.2.7 on 2020-01-14 12:18
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('main', '0045_carousel_is_international'),
]
operations = [
migrations.AddField(
model_name='sitefeature',
name='backoffice',
field=models.BooleanField(default=False, help_text='shows on backoffice page', verbose_name='backoffice'),
),
migrations.AlterField(
model_name='sitefeature',
name='main',
field=models.BooleanField(default=False, help_text='shows on main page', verbose_name='Main'),
),
migrations.AlterField(
model_name='sitefeature',
name='nested',
field=models.ManyToManyField(to='main.SiteFeature', blank=True),
),
migrations.CreateModel(
name='NavigationBarPermission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Date created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
('permission_mode', models.PositiveSmallIntegerField(choices=[(0, 'read'), (1, 'write')], default=0, help_text='READ - allows only retrieve data,WRITE - allows to perform any operations over the object', verbose_name='permission mode')),
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.SiteFeature', verbose_name='section')),
],
options={
'verbose_name': 'Navigation bar item permission',
'verbose_name_plural': 'Navigation bar item permissions',
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.7 on 2020-01-15 10:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0046_auto_20200114_1218'),
]
operations = [
migrations.AlterField(
model_name='feature',
name='priority',
field=models.IntegerField(blank=True, default=None, null=True),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 2.2.7 on 2020-01-15 19:44
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('main', '0047_auto_20200115_1013'),
]
operations = [
migrations.AddField(
model_name='navigationbarpermission',
name='sections',
field=models.ManyToManyField(to='main.SiteFeature', verbose_name='sections'),
),
migrations.AlterField(
model_name='navigationbarpermission',
name='section',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='old_sections', to='main.SiteFeature', verbose_name='section'),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 2.2.7 on 2020-01-15 20:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0048_auto_20200115_1944'),
]
operations = [
migrations.RemoveField(
model_name='navigationbarpermission',
name='section',
),
]

View File

@ -6,16 +6,18 @@ from django.contrib.contenttypes import fields as generic
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.core.validators import EMPTY_VALUES
from django.db import connections, connection
from django.db import connections
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey
from rest_framework import exceptions
from configuration.models import TranslationSettings
from location.models import Country
from main import methods
from review.models import Review
from tag.models import Tag
from utils.exceptions import UnprocessableEntityError
from utils.methods import dictfetchall
from utils.models import (ProjectBaseMixin, TJSONField, URLImageMixin,
@ -112,11 +114,13 @@ class Feature(ProjectBaseMixin, PlatformMixin):
"""Feature model."""
slug = models.SlugField(max_length=255, unique=True)
priority = models.IntegerField(unique=True, null=True, default=None)
priority = models.IntegerField(blank=True, null=True, default=None)
route = models.ForeignKey('PageType', on_delete=models.PROTECT, null=True, default=None)
site_settings = models.ManyToManyField(SiteSettings, through='SiteFeature')
old_id = models.IntegerField(null=True, blank=True)
chosen_tags = generic.GenericRelation(to='tag.ChosenTag')
class Meta:
"""Meta class."""
verbose_name = _('Feature')
@ -125,6 +129,10 @@ class Feature(ProjectBaseMixin, PlatformMixin):
def __str__(self):
return f'{self.slug}'
@property
def get_chosen_tags(self):
return Tag.objects.filter(chosen_tags__in=self.chosen_tags.all()).distinct()
class SiteFeatureQuerySet(models.QuerySet):
"""Extended queryset for SiteFeature model."""
@ -144,9 +152,15 @@ class SiteFeature(ProjectBaseMixin):
site_settings = models.ForeignKey(SiteSettings, on_delete=models.CASCADE)
feature = models.ForeignKey(Feature, on_delete=models.PROTECT)
published = models.BooleanField(default=False, verbose_name=_('Published'))
main = models.BooleanField(default=False, verbose_name=_('Main'))
nested = models.ManyToManyField('self', symmetrical=False)
main = models.BooleanField(default=False,
help_text='shows on main page',
verbose_name=_('Main'),)
backoffice = models.BooleanField(default=False,
help_text='shows on backoffice page',
verbose_name=_('backoffice'),)
nested = models.ManyToManyField('self', blank=True, symmetrical=False)
old_id = models.IntegerField(null=True, blank=True)
objects = SiteFeatureQuerySet.as_manager()
@ -216,6 +230,8 @@ class CarouselQuerySet(models.QuerySet):
def by_country_code(self, code):
"""Filter collection by country code."""
if code in settings.INTERNATIONAL_COUNTRY_CODES:
return self.filter(is_international=True)
return self.filter(country__code=code)
def get_international(self):
@ -520,3 +536,28 @@ class Panel(ProjectBaseMixin):
params = params + new_params
query = self.query + limit_offset
return query, params
class NavigationBarPermission(ProjectBaseMixin):
"""Model for navigation bar item permissions."""
READ = 0
WRITE = 1
PERMISSION_MODES = (
(READ, _('read')),
(WRITE, _('write')),
)
sections = models.ManyToManyField('main.SiteFeature',
verbose_name=_('sections'))
permission_mode = models.PositiveSmallIntegerField(choices=PERMISSION_MODES,
default=READ,
help_text='READ - allows only retrieve data,'
'WRITE - allows to perform any '
'operations over the object',
verbose_name=_('permission mode'))
class Meta:
"""Meta class."""
verbose_name = _('Navigation bar item permission')
verbose_name_plural = _('Navigation bar item permissions')

View File

@ -0,0 +1 @@
from main.serializers.common import *

View File

@ -0,0 +1,29 @@
from rest_framework import serializers
from account.models import User
from account.serializers import BackUserSerializer
from main import models
class PanelSerializer(serializers.ModelSerializer):
"""Serializer for Custom panel."""
user_id = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
source='user',
write_only=True
)
user = BackUserSerializer(read_only=True)
class Meta:
model = models.Panel
fields = [
'id',
'name',
'display',
'description',
'query',
'created',
'modified',
'user',
'user_id'
]

View File

@ -4,9 +4,8 @@ from rest_framework import serializers
from location.serializers import CountrySerializer
from main import models
from tag.serializers import TagBackOfficeSerializer
from utils.serializers import ProjectModelSerializer, TranslatedField, RecursiveFieldSerializer
from account.serializers.back import BackUserSerializer
from account.models import User
class FeatureSerializer(serializers.ModelSerializer):
@ -84,24 +83,46 @@ class FooterBackSerializer(FooterSerializer):
class SiteFeatureSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source='feature.id')
slug = serializers.CharField(source='feature.slug')
priority = serializers.IntegerField(source='feature.priority')
route = serializers.CharField(source='feature.route.name')
source = serializers.IntegerField(source='feature.source')
nested = RecursiveFieldSerializer(many=True, allow_null=True)
id = serializers.IntegerField(source='feature.id', allow_null=True)
slug = serializers.CharField(source='feature.slug', allow_null=True)
priority = serializers.IntegerField(source='feature.priority', allow_null=True)
route = serializers.CharField(source='feature.route.name', allow_null=True)
source = serializers.IntegerField(source='feature.source', allow_null=True)
nested = RecursiveFieldSerializer(many=True, read_only=True, allow_null=True)
chosen_tags = TagBackOfficeSerializer(
source='feature.get_chosen_tags', many=True, read_only=True)
class Meta:
"""Meta class."""
model = models.SiteFeature
fields = ('main',
'id',
'slug',
'priority',
'route',
'source',
'nested',
)
fields = (
'id',
'main',
'slug',
'priority',
'route',
'source',
'nested',
'chosen_tags',
)
class NavigationBarSectionBaseSerializer(SiteFeatureSerializer):
"""Serializer for navigation bar."""
source_display = serializers.CharField(source='feature.get_source_display',
read_only=True)
class Meta(SiteFeatureSerializer.Meta):
model = models.SiteFeature
fields = [
'id',
'slug',
'route',
'source',
'source_display',
'priority',
'nested',
]
class SiteSettingsSerializer(serializers.ModelSerializer):
@ -291,30 +312,6 @@ class ContentTypeBackSerializer(serializers.ModelSerializer):
fields = '__all__'
class PanelSerializer(serializers.ModelSerializer):
"""Serializer for Custom panel."""
user_id = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
source='user',
write_only=True
)
user = BackUserSerializer(read_only=True)
class Meta:
model = models.Panel
fields = [
'id',
'name',
'display',
'description',
'query',
'created',
'modified',
'user',
'user_id'
]
class PanelExecuteSerializer(serializers.ModelSerializer):
"""Panel execute serializer."""
@ -331,3 +328,20 @@ class PanelExecuteSerializer(serializers.ModelSerializer):
'user',
'user_id'
]
class NavigationBarPermissionBaseSerializer(serializers.ModelSerializer):
"""Navigation bar permission serializer."""
sections = NavigationBarSectionBaseSerializer(many=True, read_only=True)
permission_mode_display = serializers.CharField(source='get_permission_mode_display',
read_only=True)
class Meta:
"""Meta class."""
model = models.NavigationBarPermission
fields = [
'id',
'sections',
'permission_mode_display',
]

View File

@ -6,6 +6,7 @@ from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
from main import serializers
from main.serializers.back import PanelSerializer
from main import tasks
from main.filters import AwardFilter
from main.models import Award, Footer, PageType, Panel, SiteFeature, Feature
@ -108,7 +109,7 @@ class PanelsListCreateView(generics.ListCreateAPIView):
permission_classes = (
permissions.IsAdminUser,
)
serializer_class = serializers.PanelSerializer
serializer_class = PanelSerializer
queryset = Panel.objects.all()
@ -117,7 +118,7 @@ class PanelsRUDView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = (
permissions.IsAdminUser,
)
serializer_class = serializers.PanelSerializer
serializer_class = PanelSerializer
queryset = Panel.objects.all()

View File

@ -0,0 +1,68 @@
# coding=utf-8
from django.core.management.base import BaseCommand
from utils.methods import get_url_images_in_text, get_image_meta_by_url
from news.models import News
from sorl.thumbnail import get_thumbnail
class Command(BaseCommand):
IMAGE_MAX_SIZE_IN_BYTES = 1048576 # ~ 1mb
IMAGE_QUALITY_PERCENTS = 50
def add_arguments(self, parser):
parser.add_argument(
'-s',
'--size',
default=self.IMAGE_MAX_SIZE_IN_BYTES,
help='Максимальный размер файла в байтах',
type=int
)
parser.add_argument(
'-q',
'--quality',
default=self.IMAGE_QUALITY_PERCENTS,
help='Качество изображения',
type=int
)
def optimize(self, text, max_size, max_quality):
"""optimize news images"""
for image in get_url_images_in_text(text):
try:
size, width, height = get_image_meta_by_url(image)
except IOError as ie:
self.stdout.write(self.style.NOTICE(f'{ie}\n'))
continue
if size < max_size:
self.stdout.write(self.style.SUCCESS(f'No need to compress images size is {size / (2**20)}Mb\n'))
continue
percents = round(max_size / (size * 0.01))
width = round(width * percents / 100)
height = round(height * percents / 100)
optimized_image = get_thumbnail(
file_=image,
geometry_string=f'{width}x{height}',
upscale=False,
quality=max_quality
).url
text = text.replace(image, optimized_image)
self.stdout.write(self.style.SUCCESS(f'Optimized {image} -> {optimized_image}\n'
f'Quality [{percents}%]\n'))
return text
def handle(self, *args, **options):
size = options['size']
quality = options['quality']
for news in News.objects.all():
if not isinstance(news.description, dict):
continue
news.description = {
locale: self.optimize(text, size, quality)
for locale, text in news.description.items()
}
news.save()

View File

@ -78,7 +78,7 @@ class NewsQuerySet(TranslationQuerysetMixin):
def with_base_related(self):
"""Return qs with related objects."""
return self.select_related('news_type', 'country').prefetch_related('tags', 'tags__translation')
return self.select_related('news_type', 'country').prefetch_related('tags', 'tags__translation', 'gallery')
def with_extended_related(self):
"""Return qs with related objects."""
@ -306,8 +306,9 @@ class News(GalleryMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixin,
@property
def main_image(self):
qs = self.news_gallery.main_image()
if qs.exists():
return qs.order_by('-id').first().image
image_model = qs.order_by('-id').first()
if image_model is not None:
return image_model.image
@property
def image_url(self):

View File

@ -238,9 +238,9 @@ class NewsBackOfficeBaseSerializer(NewsBaseSerializer):
def create(self, validated_data):
slugs = validated_data.get('slugs')
slugs_list = list(map(lambda x: x.lower(), slugs.values()))
slugs_set = set(slugs_list)
if slugs:
slugs_list = list(map(lambda x: x.lower(), slugs.values()))
slugs_set = set(slugs_list)
if models.News.objects.filter(
slugs__values__contains=list(slugs.values())
).exists() or len(slugs_list) != len(slugs_set):
@ -271,6 +271,8 @@ class NewsBackOfficeBaseSerializer(NewsBaseSerializer):
slugs_list = list(map(lambda x: x.lower(), slugs.values() if slugs else ()))
slugs_set = set(slugs_list)
if slugs:
slugs_list = list(map(lambda x: x.lower(), slugs.values()))
slugs_set = set(slugs_list)
if models.News.objects.filter(
slugs__values__contains=list(slugs.values())
).exists() or len(slugs_list) != len(slugs_set):

View File

@ -15,27 +15,35 @@ from notification.models import Subscribe
@shared_task
def send_email_with_news(news_ids):
subscribes = Subscribe.objects.all() \
.prefetch_related('subscriber') \
.prefetch_related('subscription_type')
.prefetch_related('subscriber', 'subscription_type') \
.active()
sent_news = models.News.objects.filter(id__in=news_ids)
htmly = get_template(settings.NEWS_EMAIL_TEMPLATE)
html_template = get_template(settings.NEWS_EMAIL_TEMPLATE)
year = datetime.now().year
socials = list(SiteSettings.objects.with_country().select_related('country'))
socials = dict(zip(map(lambda social: social.country.code, socials), socials))
for subscribe in subscribes.filter(unsubscribe_date=None):
for subscribe in subscribes:
subscriber = subscribe.subscriber
country = subscribe.subscription_type.country
if country is None:
continue
continue # subscription_type has no country
socials_for_subscriber = socials.get(country.code)
subscriber = subscribe.subscriber
else:
country_code = country.code
socials_for_subscriber = socials.get(country_code)
try:
for new in sent_news:
if new.country.code != country_code:
continue
context = {
"title": new.title.get(subscriber.locale),
"subtitle": new.subtitle.get(subscriber.locale),
@ -43,7 +51,7 @@ def send_email_with_news(news_ids):
"code": subscriber.update_code,
"image_url": new.image_url if new.image_url not in EMPTY_VALUES else None,
"domain_uri": settings.DOMAIN_URI,
"slug": new.slug,
"slug": new.slugs.get(subscriber.locale),
"country_code": subscriber.country_code,
"twitter_page_url": socials_for_subscriber.twitter_page_url if socials_for_subscriber else '#',
"instagram_page_url": socials_for_subscriber.instagram_page_url if socials_for_subscriber else '#',
@ -52,9 +60,12 @@ def send_email_with_news(news_ids):
"year": year
}
send_mail(
"G&M News", render_to_string(settings.NEWS_EMAIL_TEMPLATE, context),
settings.EMAIL_HOST_USER, [subscriber.send_to], fail_silently=False,
html_message=htmly.render(context)
subject="G&M News",
message=render_to_string(settings.NEWS_EMAIL_TEMPLATE, context),
from_email=settings.EMAIL_HOST_USER,
recipient_list=[subscriber.send_to],
fail_silently=False,
html_message=html_template.render(context)
)
except SMTPException:
continue

View File

@ -11,6 +11,7 @@ from tag.models import TagCategory, Tag
from translation.models import SiteInterfaceDictionary
from transfer.models import PageTexts, PageCounters, PageMetadata
from transfer.serializers.news import NewsSerializer
from utils.methods import transform_camelcase_to_underscore
def add_locale(locale, data):
@ -36,35 +37,38 @@ def clear_old_news():
images.delete()
news.delete()
# NewsType.objects.all().delete()
print(f'Deleted {img_num} images')
print(f'Deleted {news_num} news')
def transfer_news():
news_type, _ = NewsType.objects.get_or_create(name='news')
migrated_news_types = ('News', 'StaticPage', )
queryset = PageTexts.objects.filter(
page__type='News',
).annotate(
page__id=F('page__id'),
news_type_id=Value(news_type.id, output_field=IntegerField()),
page__created_at=F('page__created_at'),
page__account_id=F('page__account_id'),
page__state=F('page__state'),
page__template=F('page__template'),
page__site__country_code_2=F('page__site__country_code_2'),
page__root_title=F('page__root_title'),
page__attachment_suffix_url=F('page__attachment_suffix_url'),
page__published_at=F('page__published_at'),
)
for news_type in migrated_news_types:
news_type_obj, _ = NewsType.objects.get_or_create(
name=transform_camelcase_to_underscore(news_type))
serialized_data = NewsSerializer(data=list(queryset.values()), many=True)
if serialized_data.is_valid():
serialized_data.save()
else:
pprint(f'News serializer errors: {serialized_data.errors}')
queryset = PageTexts.objects.filter(
page__type=news_type,
).annotate(
page__id=F('page__id'),
news_type_id=Value(news_type_obj.id, output_field=IntegerField()),
page__created_at=F('page__created_at'),
page__account_id=F('page__account_id'),
page__state=F('page__state'),
page__template=F('page__template'),
page__site__country_code_2=F('page__site__country_code_2'),
page__root_title=F('page__root_title'),
page__attachment_suffix_url=F('page__attachment_suffix_url'),
page__published_at=F('page__published_at'),
)
serialized_data = NewsSerializer(data=list(queryset.values()), many=True)
if serialized_data.is_valid():
serialized_data.save()
else:
pprint(f'News serializer errors: {serialized_data.errors}')
def update_en_gb_locales():
@ -166,5 +170,5 @@ data_types = {
update_en_gb_locales,
add_views_count,
add_tags,
]
],
}

View File

@ -1,3 +1,20 @@
from django.contrib import admin
from notification import models
# Register your models here.
@admin.register(models.SubscriptionType)
class SubscriptionTypeAdmin(admin.ModelAdmin):
"""SubscriptionType admin."""
list_display = ['index_name', 'country']
@admin.register(models.Subscriber)
class SubscriberAdmin(admin.ModelAdmin):
"""Subscriber admin."""
raw_id_fields = ('user',)
@admin.register(models.Subscribe)
class SubscribeAdmin(admin.ModelAdmin):
"""Subscribe admin."""
raw_id_fields = ('subscriber',)

View File

@ -9,6 +9,7 @@ from account.models import User
from location.models import Country
from utils.methods import generate_string_code
from utils.models import ProjectBaseMixin, TJSONField, TranslatedFieldsMixin
from notification.tasks import send_unsubscribe_email
class SubscriptionType(ProjectBaseMixin, TranslatedFieldsMixin):
@ -116,6 +117,11 @@ class Subscriber(ProjectBaseMixin):
"""Unsubscribe user."""
self.subscribe_set.update(unsubscribe_date=now())
if settings.USE_CELERY:
send_unsubscribe_email.delay(self.pk)
else:
send_unsubscribe_email(self.pk)
@property
def send_to(self):
"""Actual email."""
@ -135,6 +141,13 @@ class Subscriber(ProjectBaseMixin):
return self.subscription_types.exclude(subscriber__subscribe__unsubscribe_date__isnull=False)
class SubscribeQuerySet(models.QuerySet):
def active(self, switcher=True):
"""Fetches active subscriptions."""
return self.exclude(unsubscribe_date__isnull=not switcher)
class Subscribe(ProjectBaseMixin):
"""Subscribe model."""
@ -144,6 +157,8 @@ class Subscribe(ProjectBaseMixin):
subscriber = models.ForeignKey(Subscriber, on_delete=models.CASCADE)
subscription_type = models.ForeignKey(SubscriptionType, on_delete=models.CASCADE)
objects = SubscribeQuerySet.as_manager()
class Meta:
"""Meta class."""

View File

@ -1,10 +1,11 @@
"""Notification app serializers."""
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from location.serializers import CountrySimpleSerializer
from notification import models
from notification.tasks import send_subscribes_update_email
from utils.methods import get_user_ip
from utils.serializers import TranslatedField
@ -28,10 +29,11 @@ class SubscriptionTypeSerializer(serializers.ModelSerializer):
class CreateSubscribeSerializer(serializers.ModelSerializer):
"""Create Subscribe serializer."""
"""Create and Update Subscribe serializer."""
email = serializers.EmailField(required=False, source='send_to')
subscription_types = serializers.PrimaryKeyRelatedField(many=True, queryset=models.SubscriptionType.objects.all())
country_code = serializers.CharField(required=False, allow_blank=True)
class Meta:
"""Meta class."""
@ -41,7 +43,10 @@ class CreateSubscribeSerializer(serializers.ModelSerializer):
'email',
'subscription_types',
'link_to_unsubscribe',
'country_code',
'update_code'
)
read_only_fields = ('link_to_unsubscribe', 'update_code')
def validate(self, attrs):
"""Validate attrs."""
@ -63,7 +68,13 @@ class CreateSubscribeSerializer(serializers.ModelSerializer):
# append info
attrs['email'] = email
attrs['country_code'] = request.country_code
if request.country_code:
attrs['country_code'] = request.country_code
else:
attrs['country_code'] = attrs.get('country_code')
attrs['locale'] = request.locale
attrs['ip_address'] = get_user_ip(request)
@ -74,7 +85,67 @@ class CreateSubscribeSerializer(serializers.ModelSerializer):
def create(self, validated_data):
"""Create obj."""
return models.Subscriber.objects.make_subscriber(**validated_data)
subscriber = models.Subscriber.objects.make_subscriber(**validated_data)
if settings.USE_CELERY:
send_subscribes_update_email.delay(subscriber.pk)
else:
send_subscribes_update_email(subscriber.pk)
return subscriber
def update(self, instance, validated_data):
if settings.USE_CELERY:
send_subscribes_update_email.delay(instance.pk)
else:
send_subscribes_update_email(instance.pk)
return super().update(instance, validated_data)
class UpdateSubscribeSerializer(serializers.ModelSerializer):
"""Update with code Subscribe serializer."""
subscription_types = serializers.PrimaryKeyRelatedField(many=True, queryset=models.SubscriptionType.objects.all())
class Meta:
"""Meta class."""
model = models.Subscriber
fields = (
'subscription_types',
'link_to_unsubscribe',
'update_code'
)
read_only_fields = ('link_to_unsubscribe', 'update_code')
def validate(self, attrs):
"""Validate attrs."""
request = self.context.get('request')
user = request.user
if request.country_code:
attrs['country_code'] = request.country_code
else:
attrs['country_code'] = attrs.get('country_code')
attrs['locale'] = request.locale
attrs['ip_address'] = get_user_ip(request)
if user.is_authenticated:
attrs['user'] = user
return attrs
def update(self, instance, validated_data):
if settings.USE_CELERY:
send_subscribes_update_email.delay(instance.pk)
else:
send_subscribes_update_email(instance.pk)
return super().update(instance, validated_data)
class SubscribeObjectSerializer(serializers.ModelSerializer):

View File

@ -0,0 +1,84 @@
from datetime import datetime
from celery import shared_task
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import get_template, render_to_string
from main.models import SiteSettings
from notification import models
@shared_task
def send_subscribes_update_email(subscriber_id):
subscriber = models.Subscriber.objects.get(id=subscriber_id)
country_code = subscriber.country_code
html_template = get_template(settings.NOTIFICATION_SUBSCRIBE_TEMPLATE)
year = datetime.now().year
socials = list(SiteSettings.objects.with_country().select_related('country'))
socials = dict(zip(map(lambda social: social.country.code, socials), socials))
socials_for_subscriber = socials.get(country_code)
context = {
"title": "You have subscribed on news G&M",
"description": "<br>".join([
name.get(subscriber.locale)
for name in subscriber.subscription_types.values_list('name', flat=True)
]),
"code": subscriber.update_code,
"link_to_unsubscribe": subscriber.link_to_unsubscribe,
"twitter_page_url": socials_for_subscriber.twitter_page_url if socials_for_subscriber else '#',
"instagram_page_url": socials_for_subscriber.instagram_page_url if socials_for_subscriber else '#',
"facebook_page_url": socials_for_subscriber.facebook_page_url if socials_for_subscriber else '#',
"send_to": subscriber.send_to,
"year": year
}
send_mail(
subject="G&M Subscriptions",
message=render_to_string(settings.NOTIFICATION_SUBSCRIBE_TEMPLATE, context),
from_email=settings.EMAIL_HOST_USER,
recipient_list=[subscriber.send_to],
fail_silently=False,
html_message=html_template.render(context)
)
@shared_task
def send_unsubscribe_email(subscriber_id):
subscriber = models.Subscriber.objects.get(id=subscriber_id)
country_code = subscriber.country_code
html_template = get_template(settings.NOTIFICATION_SUBSCRIBE_TEMPLATE)
year = datetime.now().year
socials = list(SiteSettings.objects.with_country().select_related('country'))
socials = dict(zip(map(lambda social: social.country.code, socials), socials))
socials_for_subscriber = socials.get(country_code)
context = {
"title": "You have successfully unsubscribed from G&M news",
"description": "",
"code": subscriber.update_code,
"link_to_unsubscribe": subscriber.link_to_unsubscribe,
"twitter_page_url": socials_for_subscriber.twitter_page_url if socials_for_subscriber else '#',
"instagram_page_url": socials_for_subscriber.instagram_page_url if socials_for_subscriber else '#',
"facebook_page_url": socials_for_subscriber.facebook_page_url if socials_for_subscriber else '#',
"send_to": subscriber.send_to,
"year": year
}
send_mail(
subject="G&M Subscriptions",
message=render_to_string(settings.NOTIFICATION_SUBSCRIBE_TEMPLATE, context),
from_email=settings.EMAIL_HOST_USER,
recipient_list=[subscriber.send_to],
fail_silently=False,
html_message=html_template.render(context)
)

View File

@ -6,6 +6,7 @@ app_name = "notification"
urlpatterns = [
path('subscribe/', common.CreateSubscribeView.as_view(), name='create-subscribe'),
path('subscribe/<code>/', common.UpdateSubscribeView.as_view(), name='update-subscribe'),
path('subscribe-info/', common.SubscribeInfoAuthUserView.as_view(), name='check-code-auth'),
path('subscribe-info/<code>/', common.SubscribeInfoView.as_view(), name='check-code'),
path('unsubscribe/', common.UnsubscribeAuthUserView.as_view(), name='unsubscribe-auth'),

View File

@ -5,6 +5,7 @@ from rest_framework.response import Response
from notification import models
from notification.serializers import common as serializers
from utils.methods import get_user_ip
class CreateSubscribeView(generics.CreateAPIView):
@ -15,6 +16,16 @@ class CreateSubscribeView(generics.CreateAPIView):
serializer_class = serializers.CreateSubscribeSerializer
class UpdateSubscribeView(generics.UpdateAPIView):
"""Subscribe info view."""
lookup_field = 'update_code'
lookup_url_kwarg = 'code'
permission_classes = (permissions.AllowAny,)
queryset = models.Subscriber.objects.all()
serializer_class = serializers.UpdateSubscribeSerializer
class SubscribeInfoView(generics.RetrieveAPIView):
"""Subscribe info view."""
@ -33,7 +44,22 @@ class SubscribeInfoAuthUserView(generics.RetrieveAPIView):
lookup_field = None
def get_object(self):
return get_object_or_404(models.Subscriber, user=self.request.user)
user = self.request.user
if user.is_authenticated:
try:
subscriber = models.Subscriber.objects.get(user=user)
except models.Subscriber.DoesNotExist:
subscriber = models.Subscriber.objects.make_subscriber(
email=user.email, user=user, ip_address=get_user_ip(self.request),
country_code=self.request.country_code, locale=self.request.locale
)
else:
return get_object_or_404(models.Subscriber, user=user)
return subscriber
class UnsubscribeView(generics.UpdateAPIView):

View File

@ -38,6 +38,13 @@ class ProductType(TypeDefaultImageMixin, TranslatedFieldsMixin, ProjectBaseMixin
(SOUVENIR, 'souvenir'),
(BOOK, 'book')
)
INDEX_PLURAL_ONE = {
'food': 'food',
'wines': 'wine',
'liquors': 'liquor',
}
name = TJSONField(blank=True, null=True, default=None,
verbose_name=_('Name'), help_text='{"en-GB":"some text"}')
index_name = models.CharField(max_length=50, unique=True, db_index=True,
@ -176,7 +183,7 @@ class ProductQuerySet(models.QuerySet):
"""Return objects with geo location."""
return self.filter(establishment__address__coordinates__isnull=False)
def same_subtype(self, product):
def annotate_same_subtype(self, product):
"""Annotate flag same subtype."""
return self.annotate(same_subtype=Case(
models.When(
@ -215,7 +222,7 @@ class ProductQuerySet(models.QuerySet):
similarity_rules['ordering'].append(F('distance').asc())
similarity_rules['distinction'].append('distance')
return self.similar_base(product) \
.same_subtype(product) \
.annotate_same_subtype(product) \
.order_by(*similarity_rules['ordering']) \
.distinct(*similarity_rules['distinction'],
'id')

View File

@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from comment.models import Comment
from comment.serializers import CommentSerializer
from comment.serializers import CommentBaseSerializer
from establishment.serializers import EstablishmentProductShortSerializer
from establishment.serializers.common import _EstablishmentAddressShortSerializer
from location.serializers import WineOriginRegionBaseSerializer,\
@ -200,13 +200,11 @@ class ProductFavoritesCreateSerializer(FavoritesCreateSerializer):
return super().create(validated_data)
class ProductCommentCreateSerializer(CommentSerializer):
"""Create comment serializer"""
mark = serializers.IntegerField()
class ProductCommentBaseSerializer(CommentBaseSerializer):
"""Create comment serializer."""
class Meta:
class Meta(CommentBaseSerializer.Meta):
"""Serializer for model Comment"""
model = Comment
fields = [
'id',
'created',
@ -214,8 +212,14 @@ class ProductCommentCreateSerializer(CommentSerializer):
'mark',
'nickname',
'profile_pic',
'status',
'status_display',
]
class ProductCommentCreateSerializer(ProductCommentBaseSerializer):
"""Serializer for creating comments for product."""
def validate(self, attrs):
"""Override validate method"""
# Check product object

View File

@ -19,10 +19,13 @@ urlpatterns = [
# similar products by type/subtype
# temporary uses single mechanism, bec. description in process
path('slug/<slug:slug>/similar/wines/', views.SimilarListView.as_view(),
name='similar-wine'),
path('slug/<slug:slug>/similar/liquors/', views.SimilarListView.as_view(),
name='similar-liquor'),
path('slug/<slug:slug>/similar/food/', views.SimilarListView.as_view(),
name='similar-food'),
# path('slug/<slug:slug>/similar/wines/', views.SimilarListView.as_view(),
# name='similar-wine'),
# path('slug/<slug:slug>/similar/liquors/', views.SimilarListView.as_view(),
# name='similar-liquor'),
# path('slug/<slug:slug>/similar/food/', views.SimilarListView.as_view(),
# name='similar-food'),
path('slug/<slug:slug>/similar/<str:type>/', views.SimilarListView.as_view(),
name='similar-products')
]

View File

@ -4,12 +4,10 @@ from django.shortcuts import get_object_or_404
from rest_framework import generics, permissions
from comment.models import Comment
from comment.serializers import CommentRUDSerializer
from comment.serializers import CommentBaseSerializer
from product import filters, serializers
from product.models import Product
from product.models import Product, ProductType
from utils.views import FavoritesCreateDestroyMixinView
from utils.pagination import PortionPagination
from django.conf import settings
class ProductBaseView(generics.GenericAPIView):
@ -44,8 +42,16 @@ class ProductSimilarView(ProductListView):
"""
Return base product instance for a getting list of similar products.
"""
product = get_object_or_404(Product.objects.all(),
slug=self.kwargs.get('slug'))
find_by = {
'slug': self.kwargs.get('slug'),
}
if isinstance(self.kwargs.get('type'), str):
if not self.kwargs.get('type') in ProductType.INDEX_PLURAL_ONE:
return None
find_by['product_type'] = get_object_or_404(ProductType.objects.all(), index_name=ProductType.INDEX_PLURAL_ONE[self.kwargs.get('type')])
product = get_object_or_404(Product.objects.all(), **find_by)
return product
@ -73,17 +79,17 @@ class ProductCommentListView(generics.ListAPIView):
"""View for return list of product comments."""
permission_classes = (permissions.AllowAny,)
serializer_class = serializers.ProductCommentCreateSerializer
serializer_class = serializers.ProductCommentBaseSerializer
def get_queryset(self):
"""Override get_queryset method"""
product = get_object_or_404(Product, slug=self.kwargs['slug'])
return product.comments.order_by('-created')
return product.comments.public(self.request.user).order_by('-created')
class ProductCommentRUDView(generics.RetrieveUpdateDestroyAPIView):
"""View for retrieve/update/destroy product comment."""
serializer_class = CommentRUDSerializer
serializer_class = serializers.ProductCommentBaseSerializer
queryset = Product.objects.all()
def get_object(self):

View File

@ -77,6 +77,14 @@ class EstablishmentDocument(Document):
'value': fields.KeywordField(),
},
multi=True, attr='artisan_category_indexing')
distillery_type = fields.ObjectField(
properties={
'id': fields.IntegerField(attr='id'),
'label': fields.ObjectField(attr='label_indexing',
properties=OBJECT_FIELD_PROPERTIES),
'value': fields.KeywordField(),
},
multi=True, attr='distillery_type_indexing')
visible_tags = fields.ObjectField(
properties={
'id': fields.IntegerField(attr='id'),
@ -145,6 +153,7 @@ class EstablishmentDocument(Document):
'id': fields.IntegerField(attr='id'),
'weekday': fields.IntegerField(attr='weekday'),
'weekday_display': fields.KeywordField(attr='get_weekday_display'),
'weekday_display_short': fields.KeywordField(attr='weekday_display_short'),
'closed_at': fields.KeywordField(attr='closed_at_str'),
'opening_at': fields.KeywordField(attr='opening_at_str'),
'closed_at_indexing': fields.DateField(),

View File

@ -71,5 +71,5 @@ class NewsDocument(Document):
The related_models option should be used with caution because it can lead in the index
to the updating of a lot of items.
"""
if isinstance(related_instance, models.NewsType):
if isinstance(related_instance, models.NewsType) and hasattr(related_instance, 'news_set'):
return related_instance.news_set.all()

View File

@ -108,9 +108,9 @@ class CustomFacetedSearchFilterBackend(FacetedSearchFilterBackend):
tag_facets = []
preserve_ids = []
facet_name = '_filter_' + __field
all_tag_categories = TagCategoryDocument.search() \
all_tag_categories = list(TagCategoryDocument.search() \
.filter('term', public=True) \
.filter(Q('term', value_type=TagCategory.LIST) | Q('match', index_name='wine-color'))
.filter(Q('term', value_type=TagCategory.LIST) | Q('match', index_name='wine-color'))[0:1000])
for category in all_tag_categories:
tags_to_remove = list(map(lambda t: str(t.id), category.tags))
qs = queryset.__copy__()

View File

@ -277,6 +277,7 @@ class EstablishmentDocumentSerializer(InFavoritesMixin, DocumentSerializer):
tags = TagsDocumentSerializer(many=True, source='visible_tags')
restaurant_category = TagsDocumentSerializer(many=True, allow_null=True)
restaurant_cuisine = TagsDocumentSerializer(many=True, allow_null=True)
distillery_type = TagsDocumentSerializer(many=True, allow_null=True)
artisan_category = TagsDocumentSerializer(many=True, allow_null=True)
schedule = ScheduleDocumentSerializer(many=True, allow_null=True)
wine_origins = WineOriginSerializer(many=True)
@ -310,6 +311,16 @@ class EstablishmentDocumentSerializer(InFavoritesMixin, DocumentSerializer):
# 'collections',
'type',
'subtypes',
'distillery_type',
)
class EstablishmentBackOfficeDocumentSerializer(EstablishmentDocumentSerializer):
class Meta(EstablishmentDocumentSerializer.Meta):
document = EstablishmentDocumentSerializer.Meta.document
fields = EstablishmentDocumentSerializer.Meta.fields + (
'created',
)

View File

@ -1,5 +1,5 @@
"""Search indexes app views."""
from django_elasticsearch_dsl_drf import constants
from django_elasticsearch_dsl_drf import constants, pagination
from django_elasticsearch_dsl_drf.filter_backends import (
FilteringFilterBackend,
GeoSpatialOrderingFilterBackend,
@ -321,14 +321,26 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet):
class EstablishmentBackOfficeViewSet(EstablishmentDocumentViewSet):
filter_backends = EstablishmentDocumentViewSet.filter_backends + [
OrderingFilterBackend
]
def get_queryset(self):
if not self.request.query_params.get('search'):
self.request.GET._mutable = True
self.request.query_params.update({
'ordering': '-created',
})
self.request.GET._mutable = False
return super(EstablishmentBackOfficeViewSet, self).get_queryset()
serializer_class = serializers.EstablishmentBackOfficeDocumentSerializer
pagination_class = pagination.PageNumberPagination
filter_backends = [
FilteringFilterBackend,
filters.CustomSearchFilterBackend,
filters.CustomGeoSpatialFilteringFilterBackend,
GeoSpatialOrderingFilterBackend,
OrderingFilterBackend,
]
ordering_fields = {
'created': {
'field': 'created'
}
'created': 'created',
}

View File

@ -21,7 +21,6 @@ class TagsBaseFilterSet(filters.FilterSet):
type = filters.MultipleChoiceFilter(choices=TYPE_CHOICES,
method='filter_by_type')
def filter_by_type(self, queryset, name, value):
if self.NEWS in value:
queryset = queryset.for_news()

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.7 on 2020-01-13 13:57
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tag', '0017_auto_20191220_1623'),
]
operations = [
migrations.AlterField(
model_name='tagcategory',
name='country',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='location.Country'),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 2.2.7 on 2020-01-13 08:36
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('main', '0045_carousel_is_international'),
('contenttypes', '0002_remove_content_type_name'),
('tag', '0017_auto_20191220_1623'),
]
operations = [
migrations.CreateModel(
name='ChosenTag',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.SiteSettings', verbose_name='site')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chosen_tags', to='tag.Tag', verbose_name='tag')),
],
),
]

View File

@ -0,0 +1,14 @@
# Generated by Django 2.2.7 on 2020-01-14 07:56
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('tag', '0018_auto_20200113_1357'),
('tag', '0018_chosentag'),
]
operations = [
]

View File

@ -1,4 +1,5 @@
"""Tag app models."""
from django.contrib.contenttypes import fields as generic
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -145,8 +146,8 @@ class TagCategory(models.Model):
(BOOLEAN, _('boolean')),
)
country = models.ForeignKey('location.Country',
on_delete=models.SET_NULL, null=True,
default=None)
on_delete=models.SET_NULL,
blank=True, null=True, default=None)
public = models.BooleanField(default=False)
index_name = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('indexing name'), unique=True)
@ -154,8 +155,9 @@ class TagCategory(models.Model):
value_type = models.CharField(_('value type'), max_length=255,
choices=VALUE_TYPE_CHOICES, default=LIST, )
old_id = models.IntegerField(blank=True, null=True)
translation = models.OneToOneField('translation.SiteInterfaceDictionary', on_delete=models.SET_NULL,
null=True, related_name='tag_category', verbose_name=_('Translation'))
translation = models.OneToOneField(
'translation.SiteInterfaceDictionary', on_delete=models.SET_NULL,
null=True, related_name='tag_category', verbose_name=_('Translation'))
@property
def label_indexing(self):
@ -175,3 +177,20 @@ class TagCategory(models.Model):
def __str__(self):
return self.index_name
class ChosenTag(models.Model):
"""Chosen tag for type."""
tag = models.ForeignKey(
'Tag', verbose_name=_('tag'), related_name='chosen_tags',
on_delete=models.CASCADE)
site = models.ForeignKey(
'main.SiteSettings', verbose_name=_('site'), on_delete=models.CASCADE)
content_type = models.ForeignKey(
generic.ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = generic.GenericForeignKey('content_type', 'object_id')
def __str__(self):
return f'chosen_tag:{self.tag}'

View File

@ -9,6 +9,7 @@ from tag import models
from utils.exceptions import BindingObjectNotFound, ObjectAlreadyAdded, RemovedBindingObjectNotFound
from utils.serializers import TranslatedField
from utils.models import get_default_locale, get_language, to_locale
from main.models import Feature
def translate_obj(obj):
@ -56,7 +57,8 @@ class TagBackOfficeSerializer(TagBaseSerializer):
fields = TagBaseSerializer.Meta.fields + (
'label',
'category'
'category',
'value',
)
@ -191,7 +193,7 @@ class TagCategoryBackOfficeDetailSerializer(TagCategoryBaseSerializer):
class TagBindObjectSerializer(serializers.Serializer):
"""Serializer for binding tag category and objects"""
"""Serializer for binding tag category and objects."""
ESTABLISHMENT = 'establishment'
NEWS = 'news'
@ -216,15 +218,20 @@ class TagBindObjectSerializer(serializers.Serializer):
if obj_type == self.ESTABLISHMENT:
establishment = Establishment.objects.filter(pk=obj_id).first()
if not establishment:
raise BindingObjectNotFound()
if request.method == 'POST' and tag.establishments.filter(
pk=establishment.pk).exists():
raise ObjectAlreadyAdded()
if request.method == 'DELETE' and not tag.establishments.filter(
pk=establishment.pk).exists():
raise RemovedBindingObjectNotFound()
attrs['related_object'] = establishment
elif obj_type == self.NEWS:
news = News.objects.filter(pk=obj_id).first()
if not news:
@ -287,3 +294,41 @@ class TagCategoryBindObjectSerializer(serializers.Serializer):
raise RemovedBindingObjectNotFound()
attrs['related_object'] = news_type
return attrs
class ChosenTagSerializer(serializers.ModelSerializer):
tag = TagBackOfficeSerializer(read_only=True)
class Meta:
model = models.ChosenTag
fields = [
'id',
'tag',
]
class ChosenTagBindObjectSerializer(serializers.Serializer):
"""Serializer for binding chosen tag and objects"""
feature_id = serializers.IntegerField()
def validate(self, attrs):
view = self.context.get('view')
request = self.context.get('request')
obj_id = attrs.get('feature_id')
tag = view.get_object()
attrs['tag'] = tag
feature = Feature.objects.filter(pk=obj_id). \
first()
if not feature:
raise BindingObjectNotFound()
if request.method == 'DELETE' and not feature. \
chosen_tags.filter(tag=tag). \
exists():
raise RemovedBindingObjectNotFound()
attrs['related_object'] = feature
return attrs

View File

@ -1,14 +1,15 @@
"""Tag views."""
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
from rest_framework import generics, mixins, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from django.utils.translation import gettext_lazy as _
from search_indexes import views as search_views
from location.models import WineRegion
from product.models import ProductType
from search_indexes import views as search_views
from tag import filters, models, serializers
@ -89,6 +90,7 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet):
params_type = query_params.get('product_type')
week_days = tuple(map(_, ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")))
short_week_days = tuple(map(_, ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")))
flags = ('toque_number', 'wine_region', 'works_noon', 'works_evening', 'works_now', 'works_at_weekday')
filter_flags = {flag_name: False for flag_name in flags}
additional_flags = []
@ -154,7 +156,7 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet):
"filters": [{
"id": weekday,
"index_name": week_days[weekday].lower(),
"label_translated": week_days[weekday]
"label_translated": short_week_days[weekday],
} for weekday in range(7)]
}
result_list.append(works_noon)
@ -169,7 +171,7 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet):
"filters": [{
"id": weekday,
"index_name": week_days[weekday].lower(),
"label_translated": week_days[weekday]
"label_translated": short_week_days[weekday],
} for weekday in range(7)]
}
result_list.append(works_evening)
@ -192,7 +194,7 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet):
"filters": [{
"id": weekday,
"index_name": week_days[weekday].lower(),
"label_translated": week_days[weekday]
"label_translated": short_week_days[weekday],
} for weekday in range(7)]
}
result_list.append(works_at_weekday)
@ -292,6 +294,8 @@ class BindObjectMixin:
def get_serializer_class(self):
if self.action == 'bind_object':
return self.bind_object_serializer_class
elif self.action == 'chosen':
return self.chosen_serializer_class
return self.serializer_class
def perform_binding(self, serializer):
@ -311,6 +315,17 @@ class BindObjectMixin:
self.perform_unbinding(serializer)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=['post', 'delete'], detail=True, url_path='chosen')
def chosen(self, request, pk=None):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
if request.method == 'POST':
self.perform_binding(serializer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
elif request.method == 'DELETE':
self.perform_unbinding(serializer)
return Response(status=status.HTTP_204_NO_CONTENT)
class TagBackOfficeViewSet(mixins.ListModelMixin, mixins.CreateModelMixin,
mixins.UpdateModelMixin, mixins.DestroyModelMixin,
@ -322,12 +337,27 @@ class TagBackOfficeViewSet(mixins.ListModelMixin, mixins.CreateModelMixin,
queryset = models.Tag.objects.all()
serializer_class = serializers.TagBackOfficeSerializer
bind_object_serializer_class = serializers.TagBindObjectSerializer
chosen_serializer_class = serializers.ChosenTagBindObjectSerializer
def perform_binding(self, serializer):
data = serializer.validated_data
tag = data.pop('tag')
obj_type = data.get('type')
related_object = data.get('related_object')
# for compatible exist code
if self.action == 'chosen':
obj_type = ContentType.objects.get_for_model(models.ChosenTag)
models.ChosenTag.objects.update_or_create(
tag=tag,
content_type=obj_type,
object_id=related_object.id,
defaults={
"content_object": related_object,
"site": self.request.user.last_country
},
)
if obj_type == self.bind_object_serializer_class.ESTABLISHMENT:
tag.establishments.add(related_object)
elif obj_type == self.bind_object_serializer_class.NEWS:
@ -338,6 +368,11 @@ class TagBackOfficeViewSet(mixins.ListModelMixin, mixins.CreateModelMixin,
tag = data.pop('tag')
obj_type = data.get('type')
related_object = data.get('related_object')
# for compatible exist code
if self.action == 'chosen':
related_object.chosen_tags.filter(tag=tag).delete()
if obj_type == self.bind_object_serializer_class.ESTABLISHMENT:
tag.establishments.remove(related_object)
elif obj_type == self.bind_object_serializer_class.NEWS:

View File

@ -1,6 +1,7 @@
from datetime import datetime, time, date, timedelta
from django.db import models
from django.utils.translation import gettext_lazy as _
from datetime import time, datetime
from utils.models import ProjectBaseMixin
@ -24,7 +25,8 @@ class Timetable(ProjectBaseMixin):
(THURSDAY, _('Thursday')),
(FRIDAY, _('Friday')),
(SATURDAY, _('Saturday')),
(SUNDAY, _('Sunday')))
(SUNDAY, _('Sunday'))
)
weekday = models.PositiveSmallIntegerField(choices=WEEKDAYS_CHOICES, verbose_name=_('Week day'))
@ -51,6 +53,13 @@ class Timetable(ProjectBaseMixin):
f'works_at_noon - {self.works_at_noon}, ' \
f'works_at_afternoon: {self.works_at_afternoon})'
@property
def weekday_display_short(self):
"""Translated short day of the week"""
monday = date(2020, 1, 6)
with_weekday = monday + timedelta(days=self.weekday)
return _(with_weekday.strftime("%a"))
@property
def closed_at_str(self):
return str(self.closed_at) if self.closed_at else None
@ -61,11 +70,13 @@ class Timetable(ProjectBaseMixin):
@property
def closed_at_indexing(self):
return datetime.combine(time=self.closed_at, date=datetime(1970, 1, 1 + self.weekday).date()) if self.closed_at else None
return datetime.combine(time=self.closed_at,
date=datetime(1970, 1, 1 + self.weekday).date()) if self.closed_at else None
@property
def opening_at_indexing(self):
return datetime.combine(time=self.opening_at, date=datetime(1970, 1, 1 + self.weekday).date()) if self.opening_at else None
return datetime.combine(time=self.opening_at,
date=datetime(1970, 1, 1 + self.weekday).date()) if self.opening_at else None
@property
def opening_time(self):

View File

@ -1,4 +1,7 @@
"""Serializer for app timetable"""
import datetime
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
@ -11,8 +14,8 @@ class ScheduleRUDSerializer(serializers.ModelSerializer):
NULLABLE_FIELDS = ['lunch_start', 'lunch_end', 'dinner_start',
'dinner_end', 'opening_at', 'closed_at']
weekday_display = serializers.CharField(source='get_weekday_display',
read_only=True)
weekday_display = serializers.CharField(source='get_weekday_display', read_only=True)
weekday_display_short = serializers.CharField(read_only=True)
lunch_start = serializers.TimeField(required=False)
lunch_end = serializers.TimeField(required=False)
@ -29,6 +32,7 @@ class ScheduleRUDSerializer(serializers.ModelSerializer):
fields = [
'id',
'weekday_display',
'weekday_display_short',
'weekday',
'lunch_start',
'lunch_end',
@ -41,13 +45,13 @@ class ScheduleRUDSerializer(serializers.ModelSerializer):
def validate(self, attrs):
"""Override validate method"""
establishment_pk = self.context.get('request')\
.parser_context.get('view')\
.kwargs.get('pk')
establishment_pk = self.context.get('request') \
.parser_context.get('view') \
.kwargs.get('pk')
establishment_slug = self.context.get('request')\
.parser_context.get('view')\
.kwargs.get('slug')
establishment_slug = self.context.get('request') \
.parser_context.get('view') \
.kwargs.get('slug')
search_kwargs = {'pk': establishment_pk} if establishment_pk else {'slug': establishment_slug}
@ -91,13 +95,14 @@ class ScheduleCreateSerializer(ScheduleRUDSerializer):
class TimetableSerializer(serializers.ModelSerializer):
"""Serailzier for Timetable model."""
weekday_display = serializers.CharField(source='get_weekday_display',
read_only=True)
weekday_display = serializers.CharField(source='get_weekday_display', read_only=True)
weekday_display_short = serializers.CharField(read_only=True)
class Meta:
model = Timetable
fields = (
'id',
'weekday_display',
'weekday_display_short',
'works_at_noon',
)

View File

@ -40,7 +40,6 @@ class Command(BaseCommand):
'product_review',
'newsletter_subscriber', # подписчики на рассылку - переносить после переноса пользователей №1
'purchased_plaques', # №6 - перенос купленных тарелок
'fill_city_gallery', # №3 - перенос галереи городов
'guides',
'guide_filters',
'guide_element_sections',
@ -51,7 +50,6 @@ class Command(BaseCommand):
'guide_element_label_photo',
'guide_complete',
'update_city_info',
'migrate_city_gallery',
'fix_location',
'remove_old_locations',
'add_fake_country',

View File

@ -10,6 +10,7 @@ class CommentSerializer(serializers.Serializer):
mark = serializers.DecimalField(max_digits=4, decimal_places=2, allow_null=True)
account_id = serializers.IntegerField()
establishment_id = serializers.CharField()
state = serializers.CharField()
def validate(self, data):
data.update({
@ -18,14 +19,18 @@ class CommentSerializer(serializers.Serializer):
'mark': self.get_mark(data),
'content_object': self.get_content_object(data),
'user': self.get_account(data),
'status': self.get_status(data),
})
data.pop('establishment_id')
data.pop('account_id')
data.pop('state')
return data
def create(self, validated_data):
try:
return Comment.objects.create(**validated_data)
comment, _ = Comment.objects.get_or_create(old_id=validated_data.get('old_id'),
defaults=validated_data)
return comment
except Exception as e:
raise ValueError(f"Error creating comment with {validated_data}: {e}")
@ -48,3 +53,12 @@ class CommentSerializer(serializers.Serializer):
if not data['mark']:
return None
return data['mark'] * -1 if data['mark'] < 0 else data['mark']
@staticmethod
def get_status(data):
if data.get('state'):
state = data.get('state')
if state == 'published':
return Comment.PUBLISHED
elif state == 'deleted':
return Comment.DELETED

View File

@ -431,50 +431,6 @@ class CityMapCorrectSerializer(CityMapSerializer):
city.save()
class CityGallerySerializer(serializers.ModelSerializer):
id = serializers.IntegerField()
city_id = serializers.IntegerField()
attachment_file_name = serializers.CharField()
class Meta:
model = models.CityGallery
fields = ("id", "city_id", "attachment_file_name")
def validate(self, data):
data = self.set_old_id(data)
data = self.set_gallery(data)
data = self.set_city(data)
return data
def create(self, validated_data):
return models.CityGallery.objects.create(**validated_data)
def set_old_id(self, data):
data['old_id'] = data.pop('id')
return data
def set_gallery(self, data):
link_prefix = "city_photos/00baf486523f62cdf131fa1b19c5df2bf21fc9f8/"
try:
data['image'] = Image.objects.create(
image=f"{link_prefix}{data['attachment_file_name']}"
)
except Exception as e:
raise ValueError(f"Cannot create image with {data}: {e}")
del(data['attachment_file_name'])
return data
def set_city(self, data):
try:
data['city'] = models.City.objects.get(old_id=data.pop('city_id'))
except models.City.DoesNotExist as e:
raise ValueError(f"Cannot get city with {data}: {e}")
except MultipleObjectsReturned as e:
raise ValueError(f"Multiple cities find with {data}: {e}")
return data
class CepageWineRegionSerializer(TransferSerializerMixin):
CATEGORY_LABEL = 'Cepage'

View File

@ -4,6 +4,8 @@ import random
import re
import string
from collections import namedtuple
from functools import reduce
from io import BytesIO
import requests
from django.conf import settings
@ -11,6 +13,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.geos import Point
from django.http.request import HttpRequest
from django.utils.timezone import datetime
from PIL import Image
from rest_framework import status
from rest_framework.request import Request
@ -53,6 +56,19 @@ def username_validator(username: str) -> bool:
return True
def username_random():
"""Generate random username"""
username = list('{letters}{digits}'.format(
letters=''.join([random.choice(string.ascii_lowercase) for _ in range(4)]),
digits=random.randrange(100, 1000)
))
random.shuffle(username)
return '{first}{username}'.format(
first=random.choice(string.ascii_lowercase),
username=''.join(username)
)
def image_path(instance, filename):
"""Determine avatar path method."""
filename = '%s.jpeg' % generate_code()
@ -119,6 +135,7 @@ def absolute_url_decorator(func):
return f'{settings.MEDIA_URL}{url_path}/'
else:
return url_path
return get_absolute_image_url
@ -157,6 +174,23 @@ def transform_into_readable_str(raw_string: str, postfix: str = 'SectionNode'):
return f"{''.join([i.capitalize() for i in result])}"
def transform_camelcase_to_underscore(raw_string: str):
"""
Transform str, i.e:
from
"ContentPage"
to
"content_page"
"""
re_exp = r'[A-Z][^A-Z]*'
result = [i.lower() for i in re.findall(re_exp, raw_string) if i]
if result:
return reduce(lambda x, y: f'{x}_{y}', result)
else:
return raw_string
def section_name_into_index_name(section_name: str):
"""
Transform slug into section name, i.e:
@ -169,3 +203,16 @@ def section_name_into_index_name(section_name: str):
result = re.findall(re_exp, section_name)
if result:
return f"{' '.join([word.capitalize() if i == 0 else word for i, word in enumerate(result[:-2])])}"
def get_url_images_in_text(text):
"""Find images urls in text"""
return re.findall(r'[^\"\'=\s]+\.jpe?g|png|gif|svg', text)
def get_image_meta_by_url(url) -> (int, int, int):
"""Returns image size (bytes, width, height)"""
image_raw = requests.get(url)
image = Image.open(BytesIO(image_raw.content))
width, height = image.size
return int(image_raw.headers.get('content-length')), width, height

View File

@ -221,7 +221,7 @@ class SORLImageMixin(models.Model):
"""Get image thumbnail url."""
crop_image = self.get_image(thumbnail_key)
if hasattr(crop_image, 'url'):
return self.get_image(thumbnail_key).url
return crop_image.url
def image_tag(self):
"""Admin preview tag."""

View File

@ -5,14 +5,13 @@ from sorl.thumbnail.engines.pil_engine import Engine as PILEngine
class GMEngine(PILEngine):
def create(self, image, geometry, options):
"""
Processing conductor, returns the thumbnail as an image engine instance
"""
image = self.cropbox(image, geometry, options)
image = self.orientation(image, geometry, options)
image = self.colorspace(image, geometry, options)
image = self.remove_border(image, options)
image = self.scale(image, geometry, options)
image = self.crop(image, geometry, options)
image = self.scale(image, geometry, options)
image = self.rounded(image, geometry, options)
image = self.blur(image, geometry, options)
image = self.padding(image, geometry, options)

View File

@ -27,4 +27,7 @@
./manage.py transfer --overlook
./manage.py transfer --inquiries
./manage.py transfer --product_review
./manage.py transfer --transfer_text_review
./manage.py transfer --transfer_text_review
# оптимизация изображений
/manage.py news_optimize_images # сжимает картинки в описаниях новостей

View File

@ -385,6 +385,7 @@ THUMBNAIL_QUALITY = 85
THUMBNAIL_DEBUG = False
SORL_THUMBNAIL_ALIASES = {
'news_preview': {'geometry_string': '300x260', 'crop': 'center'},
'news_description': {'geometry_string': '100x100'},
'news_promo_horizontal_web': {'geometry_string': '1900x600', 'crop': 'center'},
'news_promo_horizontal_mobile': {'geometry_string': '375x260', 'crop': 'center'},
'news_tile_horizontal_web': {'geometry_string': '300x275', 'crop': 'center'},
@ -411,6 +412,8 @@ SORL_THUMBNAIL_ALIASES = {
'city_detail': {'geometry_string': '1120x1120', 'crop': 'center'},
'city_original': {'geometry_string': '2048x1536', 'crop': 'center'},
'type_preview': {'geometry_string': '300x260', 'crop': 'center'},
'collection_image': {'geometry_string': '940x620', 'upscale': False, 'quality': 100},
'establishment_collection_image': {'geometry_string': '940x620', 'upscale': False, 'quality': 100}
}
@ -454,6 +457,7 @@ CHANGE_EMAIL_TEMPLATE = 'account/change_email.html'
CONFIRM_EMAIL_TEMPLATE = 'authorization/confirm_email.html'
NEWS_EMAIL_TEMPLATE = 'news/news_email.html'
NOTIFICATION_PASSWORD_TEMPLATE = 'account/password_change_email.html'
NOTIFICATION_SUBSCRIBE_TEMPLATE = 'notification/update_email.html'
# COOKIES
@ -532,3 +536,5 @@ COOKIE_DOMAIN = None
ELASTICSEARCH_DSL = {}
ELASTICSEARCH_INDEX_NAMES = {}
THUMBNAIL_FORCE_OVERWRITE = True

View File

@ -74,3 +74,10 @@ CELERY_RESULT_BACKEND = BROKER_URL
CELERY_BROKER_URL = BROKER_URL
COOKIE_DOMAIN = '.id-east.ru'
# Email settings
EMAIL_USE_TLS = True
EMAIL_HOST = 'smtp.gmail.com'
EMAIL_HOST_USER = 'anatolyfeteleu@gmail.com'
EMAIL_HOST_PASSWORD = 'nggrlnbehzksgmbt'
EMAIL_PORT = 587

View File

@ -43,15 +43,15 @@ DATABASES = {
'options': '-c search_path=gm,public'
},
},
'legacy': {
'ENGINE': 'django.db.backends.mysql',
# 'HOST': '172.22.0.1',
'HOST': 'mysql_db',
'PORT': 3306,
'NAME': 'dev',
'USER': 'dev',
'PASSWORD': 'octosecret123'
},
'legacy': {
'ENGINE': 'django.db.backends.mysql',
# 'HOST': '172.22.0.1',
'HOST': 'mysql_db',
'PORT': 3306,
'NAME': 'dev',
'USER': 'dev',
'PASSWORD': 'octosecret123'
},
}
@ -84,11 +84,11 @@ LOGGING = {
'py.warnings': {
'handlers': ['console'],
},
'django.db.backends': {
'handlers': ['console', ],
'level': 'DEBUG',
'propagate': False,
},
# 'django.db.backends': {
# 'handlers': ['console', ],
# 'level': 'DEBUG',
# 'propagate': False,
# },
}
}

View File

@ -36,6 +36,9 @@
{% trans "Please confirm your email address to complete the registration:" %}
https://{{ country_code }}.{{ domain_uri }}/registered/{{ uidb64 }}/{{ token }}/
<br>
{% trans "If you use the mobile app, enter the following code in the form:" %}
{{ token }}
<br>
{% trans "Thanks for using our site!" %}
<br><br>
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}

View File

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en" style="box-sizing: border-box;margin: 0;">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:400,400i,700|PT+Serif&display=swap&subset=cyrillic" type="text/css">
<title>{{ title }}</title>
</head>
<body style="box-sizing: border-box;margin: 0;font-family: &quot;Open Sans&quot;, sans-serif;font-size: 0.875rem;">
<div style="dispaly: none">
</div>
<div style="margin: 0 auto; max-width:38.25rem;" class="letter">
<div class="letter__wrapper">
<div class="letter__inner">
<div class="letter__content" style="position: relative;margin: 0 16px 40px;padding: 0 0 1px;">
<div class="letter__header" style="margin: 1.875rem 0 2.875rem;text-align: center;">
<div class="letter__logo" style="display: block;width: 7.9375rem;height: 4.6875rem;margin: 0 auto 14px auto;">
<a href="https://{{ country_code }}.{{ domain_uri }}/" class="letter__logo-photo" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: block;border: none;">
<img alt="" style="width:100%;" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/1.png" />
</a>
</div>
<div class="letter__sublogo" style="font-size: 21px;line-height: 1;letter-spacing: 0;color: #bcbcbc;text-transform: uppercase;">france</div>
</div>
<div class="letter__title" style="font-family:&quot;Open-Sans&quot;,sans-serif; font-size: 1.5rem;margin: 0 0 10px;padding: 0 0 6px;border-bottom: 4px solid #ffee29;">
<span class="letter__title-txt">{{ title }}</span>
</div>
{% if not image_url is None %}
<div class="letter__image" style="width: 100%;margin: 0 0 1.25rem;max-height:260px;">
<img src="{{ image_url }}" alt="" class="letter__photo" style="display: block; width: 100%;height: 100%;">
</div>
{% endif %}
<div class="letter__text" style="margin: 0 0 30px; font-family:&quot;Open-Sans&quot;,sans-serif; font-size: 14px; line-height: 21px;letter-spacing: -0.34px; overflow-x: hidden;">
{{ description | safe }}
</div>
</div>
<div class="letter__follow" style="padding: 8px;margin: 0 auto 40px auto;background: #ffee29; max-width: 400px;">
<div class="letter__follow-content" style="padding: 1.25rem 0;background: #fff;text-align: center;">
<div class="letter__follow-header" style="display: inline-block;margin: 0 0 18px;font-family: &quot;PT Serif&quot;, sans-serif;font-size: 1.25rem;text-transform: uppercase;">
<img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" />
<span class="letter__follow-title">Follow us</span>
</div>
<div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also us on our social network below
</div>
<div class="letter__follow-social">
<a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">
<img alt="facebook" style="width: 30px; vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/3.png" />
</a>
<a href="{{ instagram_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">
<img alt="instagram" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/4.png" />
</a>
<a href="{{ twitter_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0;background: #ffee29;border: none;">
<img alt="twitter" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/5.png" />
</a>
</div>
</div>
</div>
<div class="letter__unsubscribe" style="margin: 0 0 1.25rem;font-size: 12px;text-align: center;">
<span class="letter__unsubscribe-dscr" style="display: inline-block;">This email has been sent to {{ send_to }} ,</span>
<a href="{{ link_to_unsubscribe }}" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;">click here to unsubscribe</a>
</div>
<div class="letter__footer" style="padding: 24px 0 15px;text-align: center;background: #ffee29;">
<div class="letter__footer-logo" style="width: 71px;height: 42px;margin: 0 auto 14px auto;">
<a href="#" class="letter__footer-logo-photo" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;">
<img alt="" style="width: 100%;" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/6.png" /></a>
</div>
<div class="letter__copyright">GaultMillau © {{ year }}</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -49,7 +49,6 @@ django-storages==1.7.2
sorl-thumbnail==12.5.0
PyYAML==5.1.2
# temp solution

View File

@ -1,4 +1,8 @@
-r base.txt
ipdb
ipython
mysqlclient==1.4.4
mysqlclient==1.4.4
pyparsing
graphviz
pydot