diff --git a/apps/account/admin.py b/apps/account/admin.py index 0a6de6a0..b3302eae 100644 --- a/apps/account/admin.py +++ b/apps/account/admin.py @@ -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) diff --git a/apps/account/filters.py b/apps/account/filters.py new file mode 100644 index 00000000..996c09b8 --- /dev/null +++ b/apps/account/filters.py @@ -0,0 +1,29 @@ +"""Account app filters.""" +from django.core.validators import EMPTY_VALUES +from django_filters import rest_framework as filters + +from account import models + + +class AccountBackOfficeFilter(filters.FilterSet): + """Account filter set.""" + + role = filters.MultipleChoiceFilter(choices=models.Role.ROLE_CHOICES, + method='filter_by_roles') + + class Meta: + """Meta class.""" + + model = models.User + fields = ( + 'role', + 'email_confirmed', + 'is_staff', + 'is_active', + 'is_superuser', + ) + + def filter_by_roles(self, queryset, name, value): + if value not in EMPTY_VALUES: + return queryset.by_roles(value) + return queryset diff --git a/apps/account/management/commands/add_affilations.py b/apps/account/management/commands/add_affilations.py index 591e676d..c59aaafe 100644 --- a/apps/account/management/commands/add_affilations.py +++ b/apps/account/management/commands/add_affilations.py @@ -70,10 +70,12 @@ class Command(BaseCommand): role_choice = getattr(Role, old_role.new_role) sites = SiteSettings.objects.filter(old_id=s.site_id) for site in sites: - role = Role.objects.filter(site=site, role=role_choice) + data = {'site': site, 'role': role_choice} + + role = Role.objects.filter(**data) if not role.exists(): objects.append( - Role(site=site, role=role_choice) + Role(**data) ) Role.objects.bulk_create(objects) @@ -81,7 +83,7 @@ class Command(BaseCommand): def update_site_role(self): roles = Role.objects.filter(country__isnull=True).select_related('site')\ - .filter(site__id__isnull=False).select_for_update() + .filter(site__id__isnull=False).select_for_update() with transaction.atomic(): for role in tqdm(roles, desc='Update role country'): role.country = role.site.country @@ -114,8 +116,7 @@ class Command(BaseCommand): users = User.objects.filter(old_id=s.account_id) for user in users: for role in roles: - user_role = UserRole.objects.get_or_create(user=user, - role=role) + UserRole.objects.get_or_create(user=user, role=role, state=UserRole.VALIDATED) self.stdout.write(self.style.WARNING(f'Added users roles.')) def superuser_role_sql(self): diff --git a/apps/account/migrations/0032_auto_20200114_1311.py b/apps/account/migrations/0032_auto_20200114_1311.py new file mode 100644 index 00000000..8e0412a3 --- /dev/null +++ b/apps/account/migrations/0032_auto_20200114_1311.py @@ -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), + ), + ] diff --git a/apps/account/models.py b/apps/account/models.py index 5bf66321..e39e77a4 100644 --- a/apps/account/models.py +++ b/apps/account/models.py @@ -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 @@ -24,6 +22,19 @@ from utils.models import ImageMixin, ProjectBaseMixin, PlatformMixin from utils.tokens import GMRefreshToken +class RoleQuerySet(models.QuerySet): + + def annotate_role_name(self): + return self.annotate(role_name=models.Case(*self.model.role_condition_expressions(), + output_field=models.CharField())) + + def annotate_role_counter(self): + return self.annotate( + role_counter=models.Count('userrole', + distinct=True, + filter=models.Q(userrole__state=UserRole.VALIDATED))) + + class Role(ProjectBaseMixin): """Base Role model.""" STANDARD_USER = 1 @@ -46,13 +57,14 @@ class Role(ProjectBaseMixin): (CONTENT_PAGE_MANAGER, _('Content page manager')), (ESTABLISHMENT_MANAGER, _('Establishment manager')), (REVIEWER_MANGER, _('Reviewer manager')), - (RESTAURANT_REVIEWER, 'Restaurant reviewer'), - (SALES_MAN, 'Sales man'), - (WINERY_REVIEWER, 'Winery reviewer'), - (SELLER, 'Seller'), - (LIQUOR_REVIEWER, 'Liquor reviewer'), - (PRODUCT_REVIEWER, 'Product reviewer'), + (RESTAURANT_REVIEWER, _('Restaurant reviewer')), + (SALES_MAN, _('Sales man')), + (WINERY_REVIEWER, _('Winery reviewer')), + (SELLER, _('Seller')), + (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 +74,27 @@ 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')) + + objects = RoleQuerySet.as_manager() + + @classmethod + def role_names(cls): + return [role_name._proxy____args[0] + for role_name in dict(cls.ROLE_CHOICES).values()] + + @classmethod + def role_condition_expressions(cls) -> list: + role_choices = {role_id: role_name._proxy____args[0] + for role_id, role_name in dict(cls.ROLE_CHOICES).items()} + + whens = [models.When(role=role_id, then=models.Value(role_name)) + for role_id, role_name in role_choices.items()] + return whens class UserManager(BaseUserManager): @@ -238,7 +271,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 +367,22 @@ 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 UserRoleQueryset(models.QuerySet): + """QuerySet for model UserRole.""" + + def country_admin_role(self): + return self.filter(role__role=self.model.role.field.target_field.model.COUNTRY_ADMIN, + state=self.model.VALIDATED) + class UserRole(ProjectBaseMixin): """UserRole model.""" @@ -348,6 +397,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 +408,13 @@ 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') + + objects = UserRoleQueryset.as_manager() class Meta: unique_together = ['user', 'role', 'establishment', 'state'] @@ -371,4 +425,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') \ No newline at end of file + unique_together = ('new_role', 'old_role') diff --git a/apps/account/serializers/__init__.py b/apps/account/serializers/__init__.py index e69de29b..b0ba735d 100644 --- a/apps/account/serializers/__init__.py +++ b/apps/account/serializers/__init__.py @@ -0,0 +1,3 @@ +from account.serializers.common import * +from account.serializers.web import * +from account.serializers.back import * diff --git a/apps/account/serializers/back.py b/apps/account/serializers/back.py index 4c810ee7..05f4ece1 100644 --- a/apps/account/serializers/back.py +++ b/apps/account/serializers/back.py @@ -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: @@ -85,3 +148,9 @@ class UserRoleSerializer(serializers.ModelSerializer): 'user', 'establishment' ] + + +class RoleTabRetrieveSerializer(serializers.Serializer): + """Serializer for BackOffice role tab.""" + role_name = serializers.CharField() + role_counter = serializers.IntegerField() diff --git a/apps/account/serializers/common.py b/apps/account/serializers/common.py index 86b57e95..02d75950 100644 --- a/apps/account/serializers/common.py +++ b/apps/account/serializers/common.py @@ -1,18 +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 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 @@ -25,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 @@ -38,6 +88,8 @@ class UserSerializer(serializers.ModelSerializer): 'email', 'email_confirmed', 'newsletter', + 'roles', + 'subscriptions', ] extra_kwargs = { 'first_name': {'required': False, 'write_only': True, }, @@ -49,8 +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): @@ -68,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: @@ -80,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 @@ -212,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', @@ -231,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 diff --git a/apps/account/urls/back.py b/apps/account/urls/back.py index a46b39bf..3635c1a6 100644 --- a/apps/account/urls/back.py +++ b/apps/account/urls/back.py @@ -6,9 +6,10 @@ 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('role-tab/', views.RoleTabRetrieveView.as_view(), name='role-tab'), + path('user-role/', views.UserRoleListView.as_view(), name='user-role-list-create'), + path('user/', views.UserListView.as_view(), name='user-create-list'), path('user//', views.UserRUDView.as_view(), name='user-rud'), path('user//csv/', views.get_user_csv, name='user-csv'), ] diff --git a/apps/account/views/back.py b/apps/account/views/back.py index ded254dc..1a3f5ccd 100644 --- a/apps/account/views/back.py +++ b/apps/account/views/back.py @@ -1,45 +1,71 @@ from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import generics, permissions +from rest_framework import generics, permissions, status +from rest_framework.response import Response from rest_framework.filters import OrderingFilter import csv from django.http import HttpResponse, HttpResponseNotFound from rest_framework.authtoken.models import Token -from account import models +from account import models, filters 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 RoleTabRetrieveView(generics.GenericAPIView): + permission_classes = [permissions.IsAdminUser] + + def get_queryset(self): + """Overridden get_queryset method.""" + additional_filters = {} + + if (self.request.user.userrole_set.country_admin_role().exists() and + hasattr(self.request, 'country_code')): + additional_filters.update({'country__code': self.request.country_code}) + + return models.Role.objects.filter(**additional_filters)\ + .annotate_role_name()\ + .values('role_name')\ + .annotate_role_counter()\ + .values('role_name', 'role_counter') + + def get(self, request, *args, **kwargs): + """Implement GET-method""" + data = list(self.get_queryset()) + + # todo: Need refactoring. Extend data list with non-existed role. + for role in models.Role.role_names(): + if role not in [role.get('role_name') for role in data]: + data.append({'role_name': role, 'role_number': 0}) + + return Response(data, status=status.HTTP_200_OK) + + +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', - 'is_active', - 'is_superuser', - 'roles', - ) + filter_class = filters.AccountBackOfficeFilter + filter_backends = (OrderingFilter, DjangoFilterBackend) + ordering_fields = ( 'email_confirmed', 'is_staff', 'is_active', 'is_superuser', - 'roles', - 'last_login' + 'last_login', + 'date_joined', ) diff --git a/apps/account/views/web.py b/apps/account/views/web.py index 0fc762f5..f8853d3e 100644 --- a/apps/account/views/web.py +++ b/apps/account/views/web.py @@ -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""" diff --git a/apps/authorization/serializers/common.py b/apps/authorization/serializers/common.py index 2b4eeb92..8f5bfc57 100644 --- a/apps/authorization/serializers/common.py +++ b/apps/authorization/serializers/common.py @@ -20,6 +20,7 @@ from utils.tokens import GMRefreshToken # Serializers class SignupSerializer(serializers.ModelSerializer): """Signup serializer serializer mixin""" + class Meta: model = account_models.User fields = ( @@ -37,11 +38,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): @@ -63,6 +66,13 @@ class SignupSerializer(serializers.ModelSerializer): request = self.context.get('request') """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'), diff --git a/apps/collection/management/commands/collection_optimize_images.py b/apps/collection/management/commands/collection_optimize_images.py new file mode 100644 index 00000000..6da4c04a --- /dev/null +++ b/apps/collection/management/commands/collection_optimize_images.py @@ -0,0 +1,36 @@ +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): + max_size = 1048576 + + with transaction.atomic(): + for collection in Collection.objects.all(): + if not image_url_valid(collection.image_url): + continue + + size, width, height = get_image_meta_by_url(collection.image_url) + + 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) + collection.image_url = get_thumbnail( + file_=collection.image_url, + geometry_string=f'{width}x{height}', + upscale=False + ).url + + collection.save() diff --git a/apps/collection/models.py b/apps/collection/models.py index e7f02591..0457f38d 100644 --- a/apps/collection/models.py +++ b/apps/collection/models.py @@ -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.""" diff --git a/apps/collection/views/common.py b/apps/collection/views/common.py index da9ded41..a6f1297f 100644 --- a/apps/collection/views/common.py +++ b/apps/collection/views/common.py @@ -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 diff --git a/apps/comment/management/commands/add_status_to_comments.py b/apps/comment/management/commands/add_status_to_comments.py new file mode 100644 index 00000000..932e80df --- /dev/null +++ b/apps/comment/management/commands/add_status_to_comments.py @@ -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.')) diff --git a/apps/comment/migrations/0008_comment_status.py b/apps/comment/migrations/0008_comment_status.py new file mode 100644 index 00000000..8441d81e --- /dev/null +++ b/apps/comment/migrations/0008_comment_status.py @@ -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'), + ), + ] diff --git a/apps/comment/models.py b/apps/comment/models.py index fa27d3f9..7844b29c 100644 --- a/apps/comment/models.py +++ b/apps/comment/models.py @@ -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 + """ + 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') diff --git a/apps/comment/serializers/__init__.py b/apps/comment/serializers/__init__.py index 1a751cee..5c0d260f 100644 --- a/apps/comment/serializers/__init__.py +++ b/apps/comment/serializers/__init__.py @@ -1,4 +1,4 @@ +from .common import * from .mobile import * from .back import * from .web import * -from .common import * diff --git a/apps/comment/serializers/back.py b/apps/comment/serializers/back.py index 325086c0..8abdddf2 100644 --- a/apps/comment/serializers/back.py +++ b/apps/comment/serializers/back.py @@ -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') \ No newline at end of file diff --git a/apps/comment/serializers/common.py b/apps/comment/serializers/common.py index 8e35d3dc..c92e4266 100644 --- a/apps/comment/serializers/common.py +++ b/apps/comment/serializers/common.py @@ -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}, + } diff --git a/apps/comment/transfer_data.py b/apps/comment/transfer_data.py index c9d07585..a03cd163 100644 --- a/apps/comment/transfer_data.py +++ b/apps/comment/transfer_data.py @@ -20,6 +20,7 @@ def transfer_comments(): 'mark', 'establishment_id', 'account_id', + 'state', ) serialized_data = CommentSerializer(data=list(queryset.values()), many=True) diff --git a/apps/comment/views/back.py b/apps/comment/views/back.py index a46b70cb..e5e1ee51 100644 --- a/apps/comment/views/back.py +++ b/apps/comment/views/back.py @@ -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] diff --git a/apps/establishment/filters.py b/apps/establishment/filters.py index 6dd70222..3a7d3398 100644 --- a/apps/establishment/filters.py +++ b/apps/establishment/filters.py @@ -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(method='search_by_toque_number') + username = filters.CharFilter(method='search_by_username_or_name') class Meta: """Meta class.""" @@ -72,10 +78,47 @@ 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): """Search by name or last name.""" if value not in EMPTY_VALUES: - return queryset.search_by_name_or_last_name(value) + return queryset.trigram_search(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_toque_number(self, queryset, name, value): + """Search by establishment toque_number.""" + if value not in EMPTY_VALUES: + return queryset.search_by_toque_number(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 diff --git a/apps/establishment/management/commands/establishment_optimize_preview_image.py b/apps/establishment/management/commands/establishment_optimize_preview_image.py new file mode 100644 index 00000000..7ab53a33 --- /dev/null +++ b/apps/establishment/management/commands/establishment_optimize_preview_image.py @@ -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() diff --git a/apps/establishment/management/commands/update_establishment_image_urls.py b/apps/establishment/management/commands/update_establishment_image_urls.py new file mode 100644 index 00000000..3256f18b --- /dev/null +++ b/apps/establishment/management/commands/update_establishment_image_urls.py @@ -0,0 +1,47 @@ +import math + +from django.core.management.base import BaseCommand +from celery import group +from establishment.models import Establishment +from establishment.tasks import update_establishment_image_urls + + +class Command(BaseCommand): + help = """ + Updating image links for establishments. + Run command ./manage.py update_establishment_image_urls + """ + + def add_arguments(self, parser): + parser.add_argument( + '--bucket_size', + type=int, + default=128, + help='Size of one basket to update' + ) + + def handle(self, *args, **kwargs): + bucket_size = kwargs.get('bucket_size', 128) + + objects = Establishment.objects.all() + objects_size = objects.count() + summary_tasks = math.ceil(objects_size / bucket_size) + + tasks = [] + + for index in range(0, objects_size, bucket_size): + bucket = objects[index: index + bucket_size] + + task = update_establishment_image_urls.s( + (index + bucket_size) / bucket_size, summary_tasks, + list(bucket.values_list('id', flat=True)) + ) + + tasks.append(task) + + self.stdout.write(self.style.WARNING(f'Created all celery update tasks.\n')) + + job = group(*tasks) + job.delay() + + self.stdout.write(self.style.SUCCESS(f'Done all celery update tasks.\n')) diff --git a/apps/establishment/migrations/0072_auto_20200115_1702.py b/apps/establishment/migrations/0072_auto_20200115_1702.py new file mode 100644 index 00000000..4ea732fb --- /dev/null +++ b/apps/establishment/migrations/0072_auto_20200115_1702.py @@ -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(), + ] diff --git a/apps/establishment/migrations/0073_auto_20200115_1710.py b/apps/establishment/migrations/0073_auto_20200115_1710.py new file mode 100644 index 00000000..375c563e --- /dev/null +++ b/apps/establishment/migrations/0073_auto_20200115_1710.py @@ -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'), + ), + ] diff --git a/apps/establishment/migrations/0074_employee_available_for_events.py b/apps/establishment/migrations/0074_employee_available_for_events.py new file mode 100644 index 00000000..cd5622e8 --- /dev/null +++ b/apps/establishment/migrations/0074_employee_available_for_events.py @@ -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'), + ), + ] diff --git a/apps/establishment/migrations/0075_employee_photo.py b/apps/establishment/migrations/0075_employee_photo.py new file mode 100644 index 00000000..3b505680 --- /dev/null +++ b/apps/establishment/migrations/0075_employee_photo.py @@ -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'), + ), + ] diff --git a/apps/establishment/models.py b/apps/establishment/models.py index f5c2d616..7f20c529 100644 --- a/apps/establishment/models.py +++ b/apps/establishment/models.py @@ -11,10 +11,12 @@ 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, TrigramSimilarity +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 -from django.db.models import When, Case, F, ExpressionWrapper, Subquery, Q, Prefetch +from django.db.models import When, Case, F, ExpressionWrapper, Subquery, Q, Prefetch, Sum from django.utils import timezone from django.utils.translation import gettext_lazy as _ from phonenumber_field.modelfields import PhoneNumberField @@ -58,8 +60,6 @@ class EstablishmentType(TypeDefaultImageMixin, TranslatedFieldsMixin, ProjectBas blank=True, null=True, default=None, verbose_name='default image') - chosen_tags = generic.GenericRelation(to='tag.ChosenTag') - class Meta: """Meta class.""" @@ -122,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.""" @@ -266,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): @@ -293,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( @@ -314,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', ] @@ -325,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): """ @@ -360,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) @@ -378,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): """ @@ -502,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): @@ -649,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( @@ -769,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): @@ -786,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): @@ -856,11 +872,6 @@ class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin, metadata.append(category_tags) return metadata - @property - def distillery_types(self): - """Tags from tag category - distillery_type.""" - return self.tags.filter(category__index_name='distillery_type') - class EstablishmentNoteQuerySet(models.QuerySet): """QuerySet for model EstablishmentNote.""" @@ -976,16 +987,97 @@ 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( + search_exact_match=models.Case( + models.When(Q(name__iexact=search_value) | Q(last_name__iexact=search_value), + then=100), + default=0, + output_field=models.FloatField() + ), + search_contains_match=models.Case( + models.When(Q(name__icontains=search_value) | Q(last_name__icontains=search_value), + then=50), + default=0, + output_field=models.FloatField() + ), + search_name_similarity=models.Case( + models.When( + Q(name__isnull=False), + then=TrigramSimilarity('name', search_value.lower()) + ), + default=0, + output_field=models.FloatField() + ), + search_last_name_similarity=models.Case( + models.When( + Q(last_name__isnull=False), + then=TrigramSimilarity('last_name', search_value.lower()) + ), + default=0, + output_field=models.FloatField() + ), + relevance=(F('search_name_similarity') + F('search_exact_match') + + F('search_contains_match') + F('search_last_name_similarity')) + ).filter(relevance__gte=0.3).order_by('-relevance') + 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.filter( + Q(establishmentemployee__position_id=value), + ) + + def search_by_public_mark(self, value): + """Search by establishment public_mark.""" + return self.filter( + Q(establishmentemployee__establishment__public_mark=value), + ) + + def search_by_toque_number(self, value): + """Search by establishment toque_number.""" + return self.filter( + Q(establishmentemployee__establishment__toque_number=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') + + def with_back_office_related(self): + return self.prefetch_related( + Prefetch('establishmentemployee_set', + queryset=EstablishmentEmployee.objects.actual() + .prefetch_related('establishment', 'position').order_by('-from_date'), + to_attr='prefetched_establishment_employee'), + 'awards' + ) + class Employee(BaseAttributes): """Employee model.""" @@ -1021,6 +1113,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() @@ -1029,6 +1126,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): diff --git a/apps/establishment/serializers/back.py b/apps/establishment/serializers/back.py index 23283981..be59c73c 100644 --- a/apps/establishment/serializers/back.py +++ b/apps/establishment/serializers/back.py @@ -1,16 +1,30 @@ +from functools import lru_cache + +from django.db.models import F +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, EstablishmentEmployee +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 +40,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 +49,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 +83,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 +101,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 +135,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 +209,6 @@ class ContactEmailBackSerializers(model_serializers.PlateSerializer): ] - class PositionBackSerializer(serializers.ModelSerializer): """Position Back serializer.""" @@ -181,6 +223,7 @@ class PositionBackSerializer(serializers.ModelSerializer): 'index_name', ] + # TODO: test decorator @with_base_attributes class EmployeeBackSerializers(serializers.ModelSerializer): @@ -189,25 +232,52 @@ 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) + @staticmethod + @lru_cache(maxsize=32) + def get_qs(obj): + return obj.establishmentemployee_set.actual().annotate( + public_mark=F('establishment__public_mark'), + est_id=F('establishment__id'), + est_slug=F('establishment__slug'), + toque_number=F('establishment__toque_number'), + ).order_by('-from_date').first() def get_public_mark(self, obj): """Get last list actual public_mark""" - qs = obj.establishmentemployee_set.actual().order_by('-from_date')\ - .values('establishment__public_mark').first() - return qs['establishment__public_mark'] if qs else None + if hasattr(obj, 'prefetched_establishment_employee'): + return obj.prefetched_establishment_employee[0].establishment.public_mark if len( + obj.prefetched_establishment_employee) else None + qs = self.get_qs(obj) + if qs: + return qs.public_mark + return None + def get_toque_number(self, obj): + if hasattr(obj, 'prefetched_establishment_employee'): + return obj.prefetched_establishment_employee[0].establishment.toque_number if len( + obj.prefetched_establishment_employee) else None + qs = self.get_qs(obj) + if qs: + return qs.toque_number + return None def get_positions(self, obj): """Get last list actual positions""" - est_id = obj.establishmentemployee_set.actual().\ - order_by('-from_date').first() + if hasattr(obj, 'prefetched_establishment_employee'): + if not len(obj.prefetched_establishment_employee): + return [] + return [PositionBackSerializer(ee.position).data for ee in obj.prefetched_establishment_employee] + + est_id = self.get_qs(obj) 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,15 +286,19 @@ class EmployeeBackSerializers(serializers.ModelSerializer): def get_establishment(self, obj): """Get last actual establishment""" - est = obj.establishmentemployee_set.actual().order_by('-from_date')\ - .first() + if hasattr(obj, 'prefetched_establishment_employee'): + return { + 'id': obj.prefetched_establishment_employee[0].establishment.pk, + 'slug': obj.prefetched_establishment_employee[0].establishment.slug, + } if len(obj.prefetched_establishment_employee) else None + est = self.get_qs(obj) if not est: return None return { - "id": est.establishment.id, - "slug": est.establishment.slug + "id": est.est_id, + "slug": est.est_slug } class Meta: @@ -242,7 +316,9 @@ class EmployeeBackSerializers(serializers.ModelSerializer): 'birth_date', 'email', 'phone', - 'toque_number' + 'toque_number', + 'available_for_events', + 'photo', ] @@ -264,6 +340,53 @@ class EstablishmentEmployeeBackSerializer(serializers.ModelSerializer): ] +class EstEmployeeBackSerializer(EmployeeBackSerializers): + @property + def request_kwargs(self): + """Get url kwargs from request.""" + return self.context.get('request').parser_context.get('kwargs') + + def get_positions(self, obj): + establishment_id = self.request_kwargs.get('establishment_id') + es_emp = EstablishmentEmployee.objects.filter( + employee=obj, + establishment_id=establishment_id, + ).distinct().order_by('position_id') + result = [] + for item in es_emp: + result.append({ + 'id': item.id, + 'from_date': item.from_date, + 'to_date': item.to_date, + 'status': item.status, + 'position_id': item.position_id, + 'position_priority': item.position.priority, + 'position_index_name': item.position.index_name, + 'position_name_translated': item.position.name_translated, + }) + + return result + + class Meta: + model = models.Employee + fields = [ + 'id', + 'name', + 'last_name', + 'user', + 'public_mark', + 'positions', + 'awards', + 'sex', + 'birth_date', + 'email', + 'phone', + 'toque_number', + 'available_for_events', + 'photo', + ] + + class EstablishmentBackOfficeGallerySerializer(serializers.ModelSerializer): """Serializer class for model EstablishmentGallery.""" @@ -380,6 +503,7 @@ class EstablishmentNoteListCreateSerializer(EstablishmentNoteBaseSerializer): class EstablishmentAdminListSerializer(UserShortSerializer): """Establishment admin serializer.""" + class Meta: model = UserShortSerializer.Meta.model fields = [ diff --git a/apps/establishment/serializers/common.py b/apps/establishment/serializers/common.py index ada87016..066452a7 100644 --- a/apps/establishment/serializers/common.py +++ b/apps/establishment/serializers/common.py @@ -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""" @@ -198,7 +202,7 @@ class EstablishmentEmployeeCreateSerializer(serializers.ModelSerializer): """Meta class.""" model = models.EstablishmentEmployee - fields = ('id',) + fields = ('id', 'from_date', 'to_date') def _validate_entity(self, entity_id_param: str, entity_class): entity_id = self.context.get('request').parser_context.get('kwargs').get(entity_id_param) @@ -231,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) @@ -253,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: @@ -320,15 +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) - distillery_types = TagBaseSerializer(read_only=True, many=True, allow_null=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,7 +387,7 @@ class EstablishmentBaseSerializer(ProjectModelSerializer): 'new_image', 'tz', 'wine_regions', - 'distillery_types', + 'distillery_type', ] @@ -366,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.""" @@ -375,6 +409,7 @@ class EstablishmentListRetrieveSerializer(EstablishmentBaseSerializer): 'restaurant_category', 'restaurant_cuisine', 'artisan_category', + 'distillery_type', ] @@ -449,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.""" @@ -468,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 + [ @@ -476,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', @@ -493,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 @@ -514,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: diff --git a/apps/establishment/tasks.py b/apps/establishment/tasks.py index 4197b65d..d3816fa1 100644 --- a/apps/establishment/tasks.py +++ b/apps/establishment/tasks.py @@ -1,17 +1,14 @@ """Establishment app tasks.""" import logging +import requests from celery import shared_task from celery.schedules import crontab from celery.task import periodic_task - -from django.core import management -from django_elasticsearch_dsl.management.commands import search_index - from django_elasticsearch_dsl.registries import registry + from establishment import models from location.models import Country -from search_indexes.documents.establishment import EstablishmentDocument logger = logging.getLogger(__name__) @@ -28,6 +25,7 @@ def recalculate_price_levels_by_country(country_id): establishment.recalculate_price_level(low_price=country.low_price, high_price=country.high_price) + # @periodic_task(run_every=crontab(minute=59)) # def rebuild_establishment_indices(): # management.call_command(search_index.Command(), action='populate', models=[models.Establishment.__name__], @@ -50,3 +48,47 @@ def recalculation_public_mark(establishment_id): establishment = models.Establishment.objects.get(id=establishment_id) establishment.recalculate_public_mark() establishment.recalculate_toque_number() + + +@shared_task +def update_establishment_image_urls(part_number: int, summary_tasks: int, bucket_ids: list): + queryset = models.Establishment.objects.filter(id__in=bucket_ids) + + for establishment in queryset: + live_link = None + + image_urls = [ + ('image_url', establishment.image_url), + ('preview_image_url', establishment.preview_image_url) + ] + + for data in image_urls: + attr, url = data + + if establishment.image_url is not None: + try: + response = requests.get(url, allow_redirects=True) + + if response.status_code != 200: + setattr(establishment, attr, None) + + else: + live_link = url + + except ( + requests.exceptions.ConnectionError, + requests.exceptions.ConnectTimeout + ): + setattr(establishment, attr, None) + + if live_link is not None: + if establishment.image_url is None: + establishment.image_url = live_link + + elif establishment.preview_image_url is None: + establishment.preview_image_url = live_link + + establishment.save() + + logger.info(f'The {part_number}th part of the image update ' + f'from {summary_tasks} parts was completed') diff --git a/apps/establishment/urls/back.py b/apps/establishment/urls/back.py index b2a30917..10351fe5 100644 --- a/apps/establishment/urls/back.py +++ b/apps/establishment/urls/back.py @@ -45,6 +45,7 @@ urlpatterns = [ path('/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//', views.EmployeeRUDView.as_view(), name='employees-rud'), path('/employee//position/', views.EstablishmentEmployeeCreateView.as_view(), diff --git a/apps/establishment/views/back.py b/apps/establishment/views/back.py index 19a75fd5..8b446ac6 100644 --- a/apps/establishment/views/back.py +++ b/apps/establishment/views/back.py @@ -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 @@ -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,49 +174,61 @@ 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().with_back_office_related() + + +class EmployeesListSearchViews(generics.ListAPIView): + """Employee search view""" + pagination_class = None + permission_classes = (permissions.AllowAny,) + queryset = models.Employee.objects.all().with_back_office_related().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 + serializer_class = serializers.EstEmployeeBackSerializer + pagination_class = None def get_queryset(self): establishment_id = self.kwargs['establishment_id'] - return models.EstablishmentEmployee.objects.filter(establishment__id=establishment_id) + return models.Employee.objects.filter( + establishmentemployee__establishment_id=establishment_id, + ).distinct() class EmployeeRUDView(generics.RetrieveUpdateDestroyAPIView): """Employee RUD view.""" serializer_class = serializers.EmployeeBackSerializers - queryset = models.Employee.objects.all() + queryset = models.Employee.objects.all().with_back_office_related() 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 +400,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( diff --git a/apps/establishment/views/web.py b/apps/establishment/views/web.py index e2c8fa5e..6ce8428b 100644 --- a/apps/establishment/views/web.py +++ b/apps/establishment/views/web.py @@ -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): diff --git a/apps/favorites/views.py b/apps/favorites/views.py index 4faa3e07..1e7a12b3 100644 --- a/apps/favorites/views.py +++ b/apps/favorites/views.py @@ -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): diff --git a/apps/location/migrations/0034_city_image.py b/apps/location/migrations/0034_city_image.py new file mode 100644 index 00000000..9c27287c --- /dev/null +++ b/apps/location/migrations/0034_city_image.py @@ -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'), + ), + ] diff --git a/apps/location/migrations/0035_auto_20200115_1117.py b/apps/location/migrations/0035_auto_20200115_1117.py new file mode 100644 index 00000000..1f88538d --- /dev/null +++ b/apps/location/migrations/0035_auto_20200115_1117.py @@ -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', + ), + ] diff --git a/apps/location/models.py b/apps/location/models.py index cdc9ada5..0fa3cdd1 100644 --- a/apps/location/models.py +++ b/apps/location/models.py @@ -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): diff --git a/apps/location/serializers/back.py b/apps/location/serializers/back.py index 8f231d69..c178f7fd 100644 --- a/apps/location/serializers/back.py +++ b/apps/location/serializers/back.py @@ -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 diff --git a/apps/location/serializers/common.py b/apps/location/serializers/common.py index 65c30b46..ad60a7a2 100644 --- a/apps/location/serializers/common.py +++ b/apps/location/serializers/common.py @@ -3,7 +3,8 @@ 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 gallery import models as gallery_models +from utils.serializers import TranslatedField, ImageBaseSerializer class CountrySerializer(serializers.ModelSerializer): @@ -70,7 +71,7 @@ class CityShortSerializer(serializers.ModelSerializer): ) -class CitySerializer(serializers.ModelSerializer): +class CityBaseSerializer(serializers.ModelSerializer): """City serializer.""" region = RegionSerializer(read_only=True) region_id = serializers.PrimaryKeyRelatedField( @@ -83,6 +84,11 @@ class CitySerializer(serializers.ModelSerializer): queryset=models.Country.objects.all(), write_only=True ) + image_id = serializers.PrimaryKeyRelatedField( + source='image', + queryset=gallery_models.Image.objects.all(), + write_only=True + ) country = CountrySerializer(read_only=True) class Meta: @@ -96,6 +102,22 @@ class CitySerializer(serializers.ModelSerializer): 'country', 'postal_code', 'is_island', + 'image', + 'image_id', + ] + extra_fields = { + 'image': {'read_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 +176,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.""" diff --git a/apps/location/transfer_data.py b/apps/location/transfer_data.py index 54bf2301..caf04c92 100644 --- a/apps/location/transfer_data.py +++ b/apps/location/transfer_data.py @@ -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] diff --git a/apps/location/urls/back.py b/apps/location/urls/back.py index 2434dd26..b1c0d5ca 100644 --- a/apps/location/urls/back.py +++ b/apps/location/urls/back.py @@ -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//', views.CityRUDView.as_view(), name='city-retrieve'), - path('cities//gallery/', views.CityGalleryListView.as_view(), - name='gallery-list'), - path('cities//gallery//', - views.CityGalleryCreateDestroyView.as_view(), - name='gallery-create-destroy'), path('countries/', views.CountryListCreateView.as_view(), name='country-list-create'), path('countries//', views.CountryRUDView.as_view(), name='country-retrieve'), diff --git a/apps/location/views/back.py b/apps/location/views/back.py index 125c2b0b..a0d1bd36 100644 --- a/apps/location/views/back.py +++ b/apps/location/views/back.py @@ -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""" diff --git a/apps/location/views/common.py b/apps/location/views/common.py index 660a1dbe..d9d5520b 100644 --- a/apps/location/views/common.py +++ b/apps/location/views/common.py @@ -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 diff --git a/apps/main/admin.py b/apps/main/admin.py index d7f702e9..b0fc63a3 100644 --- a/apps/main/admin.py +++ b/apps/main/admin.py @@ -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() diff --git a/apps/main/migrations/0046_auto_20200114_1218.py b/apps/main/migrations/0046_auto_20200114_1218.py new file mode 100644 index 00000000..9d97fb2c --- /dev/null +++ b/apps/main/migrations/0046_auto_20200114_1218.py @@ -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', + }, + ), + ] diff --git a/apps/main/migrations/0047_auto_20200115_1013.py b/apps/main/migrations/0047_auto_20200115_1013.py new file mode 100644 index 00000000..83d025b5 --- /dev/null +++ b/apps/main/migrations/0047_auto_20200115_1013.py @@ -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), + ), + ] diff --git a/apps/main/migrations/0048_auto_20200115_1944.py b/apps/main/migrations/0048_auto_20200115_1944.py new file mode 100644 index 00000000..16e35d8f --- /dev/null +++ b/apps/main/migrations/0048_auto_20200115_1944.py @@ -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'), + ), + ] diff --git a/apps/main/migrations/0049_remove_navigationbarpermission_section.py b/apps/main/migrations/0049_remove_navigationbarpermission_section.py new file mode 100644 index 00000000..97358610 --- /dev/null +++ b/apps/main/migrations/0049_remove_navigationbarpermission_section.py @@ -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', + ), + ] diff --git a/apps/main/models.py b/apps/main/models.py index 63a65062..ea853c04 100644 --- a/apps/main/models.py +++ b/apps/main/models.py @@ -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') diff --git a/apps/main/serializers/__init__.py b/apps/main/serializers/__init__.py new file mode 100644 index 00000000..ffc04a8d --- /dev/null +++ b/apps/main/serializers/__init__.py @@ -0,0 +1 @@ +from main.serializers.common import * diff --git a/apps/main/serializers/back.py b/apps/main/serializers/back.py new file mode 100644 index 00000000..58221fc0 --- /dev/null +++ b/apps/main/serializers/back.py @@ -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' + ] diff --git a/apps/main/serializers.py b/apps/main/serializers/common.py similarity index 80% rename from apps/main/serializers.py rename to apps/main/serializers/common.py index 4b41eae4..6a556ee6 100644 --- a/apps/main/serializers.py +++ b/apps/main/serializers/common.py @@ -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', + ] diff --git a/apps/main/views/back.py b/apps/main/views/back.py index b17a692c..a8974721 100644 --- a/apps/main/views/back.py +++ b/apps/main/views/back.py @@ -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() diff --git a/apps/news/management/commands/news_optimize_images.py b/apps/news/management/commands/news_optimize_images.py new file mode 100644 index 00000000..011bf6ae --- /dev/null +++ b/apps/news/management/commands/news_optimize_images.py @@ -0,0 +1,69 @@ +# 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""" + if isinstance(text, str): + for image in get_url_images_in_text(text): + try: + size, width, height = get_image_meta_by_url(image) + except IOError as ie: + self.stdout.write(self.style.NOTICE(f'{ie}\n')) + continue + + if size < max_size: + self.stdout.write(self.style.SUCCESS(f'No need to compress images size is {size / (2**20)}Mb\n')) + continue + + percents = round(max_size / (size * 0.01)) + width = round(width * percents / 100) + height = round(height * percents / 100) + optimized_image = get_thumbnail( + file_=image, + geometry_string=f'{width}x{height}', + upscale=False, + quality=max_quality + ).url + text = text.replace(image, optimized_image) + self.stdout.write(self.style.SUCCESS(f'Optimized {image} -> {optimized_image}\n' + f'Quality [{percents}%]\n')) + + return text + + def handle(self, *args, **options): + size = options['size'] + quality = options['quality'] + + for news in News.objects.all(): + if not isinstance(news.description, dict): + continue + news.description = { + locale: self.optimize(text, size, quality) + for locale, text in news.description.items() + } + news.save() diff --git a/apps/news/models.py b/apps/news/models.py index 3beb0d80..fffed321 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import HStoreField from django.db import models from django.db.models import Case, When +from django.urls.exceptions import NoReverseMatch from django.utils import timezone from django.utils.translation import gettext_lazy as _ from rest_framework.reverse import reverse @@ -54,7 +55,6 @@ class NewsType(models.Model): name = models.CharField(_('name'), max_length=250) tag_categories = models.ManyToManyField('tag.TagCategory', related_name='news_types') - chosen_tags = generic.GenericRelation(to='tag.ChosenTag') class Meta: """Meta class.""" @@ -79,7 +79,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.""" @@ -296,7 +296,10 @@ class News(GalleryMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixin, @property def web_url(self): - return reverse('web:news:rud', kwargs={'slug': next(iter(self.slugs.values()))}) + try: + return reverse('web:news:rud', kwargs={'slug': next(iter(self.slugs.values()))}) + except NoReverseMatch as e: + return None # no active links def should_read(self, user): return self.__class__.objects.should_read(self, user)[:3] @@ -307,8 +310,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): diff --git a/apps/news/serializers.py b/apps/news/serializers.py index c0df73a4..672334dd 100644 --- a/apps/news/serializers.py +++ b/apps/news/serializers.py @@ -22,7 +22,9 @@ from utils.serializers import ( class AgendaSerializer(ProjectModelSerializer): start_datetime = serializers.DateTimeField() end_datetime = serializers.DateTimeField() - address = AddressBaseSerializer() + address = AddressBaseSerializer(read_only=True) + address_id = serializers.PrimaryKeyRelatedField(write_only=True, queryset=location_models.Address.objects.all(), + source='address') event_name_translated = TranslatedField() content_translated = TranslatedField() @@ -36,7 +38,8 @@ class AgendaSerializer(ProjectModelSerializer): 'end_datetime', 'address', 'content_translated', - 'event_name_translated' + 'event_name_translated', + 'address_id', ) @@ -158,6 +161,7 @@ class NewsDetailWebSerializer(NewsDetailSerializer): should_read = SerializerMethodField() agenda = AgendaSerializer() banner = NewsBannerSerializer() + in_favorites = serializers.BooleanField(read_only=True) class Meta(NewsDetailSerializer.Meta): """Meta class.""" @@ -167,6 +171,7 @@ class NewsDetailWebSerializer(NewsDetailSerializer): 'should_read', 'agenda', 'banner', + 'in_favorites', ) def get_same_theme(self, obj): @@ -176,10 +181,31 @@ class NewsDetailWebSerializer(NewsDetailSerializer): return NewsSimilarListSerializer(obj.should_read(self.context['request'].user), many=True, read_only=True).data +class NewsPreviewWebSerializer(NewsDetailSerializer): + """News preview serializer for web users..""" + + same_theme = SerializerMethodField() + agenda = AgendaSerializer() + banner = NewsBannerSerializer() + + class Meta(NewsDetailSerializer.Meta): + """Meta class.""" + + fields = NewsDetailSerializer.Meta.fields + ( + 'same_theme', + 'agenda', + 'banner', + ) + + def get_same_theme(self, obj): + return NewsSimilarListSerializer(obj.same_theme(self.context['request'].user), many=True, read_only=True).data + + class NewsBackOfficeBaseSerializer(NewsBaseSerializer): """News back office base serializer.""" is_published = serializers.BooleanField(source='is_publish', read_only=True) descriptions = serializers.ListField(required=False) + agenda = AgendaSerializer() class Meta(NewsBaseSerializer.Meta): """Meta class.""" @@ -198,6 +224,7 @@ class NewsBackOfficeBaseSerializer(NewsBaseSerializer): 'created', 'modified', 'descriptions', + 'agenda' ) extra_kwargs = { 'created': {'read_only': True}, @@ -228,6 +255,7 @@ class NewsBackOfficeBaseSerializer(NewsBaseSerializer): for locale in locales: if not attrs[key].get(locale): attrs[key][locale] = getattr(instance, key).get(locale) + return attrs def create(self, validated_data): @@ -245,10 +273,25 @@ class NewsBackOfficeBaseSerializer(NewsBaseSerializer): user = request.user validated_data['created_by'] = user - return super().create(validated_data) + agenda_data = validated_data.get('agenda') + agenda = None + + if agenda_data is not None: + agenda_data['address_id'] = agenda_data.pop('address').pk + agenda_serializer = AgendaSerializer(data=agenda_data) + agenda_serializer.is_valid(raise_exception=True) + agenda = agenda_serializer.save() + + instance = super().create(validated_data) + instance.agenda = agenda + instance.save() + + return instance def update(self, instance, validated_data): slugs = validated_data.get('slugs') + 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) @@ -256,6 +299,29 @@ class NewsBackOfficeBaseSerializer(NewsBaseSerializer): slugs__values__contains=list(slugs.values()) ).exists() or len(slugs_list) != len(slugs_set): raise serializers.ValidationError({'slugs': _('Slug should be unique')}) + + agenda_data = validated_data.get('agenda') + agenda = instance.agenda + + if agenda is None and agenda_data is not None: + agenda_data['address_id'] = agenda_data.pop('address').pk + agenda_serializer = AgendaSerializer(data=agenda_data) + agenda_serializer.is_valid(raise_exception=True) + agenda_serializer.save() + + elif agenda_data is not None: + agenda.start_datetime = agenda_data.pop( + 'start_datetime') if 'start_datetime' in agenda_data else agenda.start_datetime + agenda.end_datetime = agenda_data.pop( + 'end_datetime') if 'end_datetime' in agenda_data else agenda.end_datetime + agenda.address = agenda_data.pop( + 'address') if 'address' in agenda_data else agenda.address + agenda.event_name = agenda_data.pop( + 'event_name') if 'event_time' in agenda_data else agenda.event_name + agenda.content = agenda_data.pop( + 'content') if 'content' in agenda_data else agenda.content + agenda.save() + return super().update(instance, validated_data) diff --git a/apps/news/transfer_data.py b/apps/news/transfer_data.py index f4e16739..3586e357 100644 --- a/apps/news/transfer_data.py +++ b/apps/news/transfer_data.py @@ -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, - ] + ], } diff --git a/apps/news/urls/common.py b/apps/news/urls/common.py index f5c809de..4668bbfc 100644 --- a/apps/news/urls/common.py +++ b/apps/news/urls/common.py @@ -7,4 +7,5 @@ common_urlpatterns = [ path('slug//', views.NewsDetailView.as_view(), name='rud'), path('slug//favorites/', views.NewsFavoritesCreateDestroyView.as_view(), name='create-destroy-favorites'), + path('preview/slug//', views.NewsPreviewView.as_view(), name='preview'), ] diff --git a/apps/news/views.py b/apps/news/views.py index 80f70b79..178c359f 100644 --- a/apps/news/views.py +++ b/apps/news/views.py @@ -70,6 +70,18 @@ class NewsDetailView(NewsMixinView, generics.RetrieveAPIView): return qs +class NewsPreviewView(NewsMixinView, generics.RetrieveAPIView): + """News preview view.""" + + lookup_field = None + serializer_class = serializers.NewsPreviewWebSerializer + + def get_queryset(self): + """Override get_queryset method.""" + qs = models.News.objects.all().annotate_in_favorites(self.request.user) + return qs + + class NewsTypeListView(generics.ListAPIView): """NewsType list view.""" diff --git a/apps/notification/admin.py b/apps/notification/admin.py index 8c38f3f3..5c8a01ec 100644 --- a/apps/notification/admin.py +++ b/apps/notification/admin.py @@ -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',) diff --git a/apps/product/models.py b/apps/product/models.py index b8195fa8..8be6ddd4 100644 --- a/apps/product/models.py +++ b/apps/product/models.py @@ -38,6 +38,13 @@ class ProductType(TypeDefaultImageMixin, TranslatedFieldsMixin, ProjectBaseMixin (SOUVENIR, 'souvenir'), (BOOK, 'book') ) + + INDEX_PLURAL_ONE = { + 'food': 'food', + 'wines': 'wine', + 'liquors': 'liquor', + } + name = TJSONField(blank=True, null=True, default=None, verbose_name=_('Name'), help_text='{"en-GB":"some text"}') index_name = models.CharField(max_length=50, unique=True, db_index=True, @@ -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') diff --git a/apps/product/serializers/common.py b/apps/product/serializers/common.py index e85bebe9..63766f69 100644 --- a/apps/product/serializers/common.py +++ b/apps/product/serializers/common.py @@ -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 diff --git a/apps/product/urls/common.py b/apps/product/urls/common.py index 4d64b93e..fe0fcd66 100644 --- a/apps/product/urls/common.py +++ b/apps/product/urls/common.py @@ -19,10 +19,13 @@ urlpatterns = [ # similar products by type/subtype # temporary uses single mechanism, bec. description in process - path('slug//similar/wines/', views.SimilarListView.as_view(), - name='similar-wine'), - path('slug//similar/liquors/', views.SimilarListView.as_view(), - name='similar-liquor'), - path('slug//similar/food/', views.SimilarListView.as_view(), - name='similar-food'), + # path('slug//similar/wines/', views.SimilarListView.as_view(), + # name='similar-wine'), + # path('slug//similar/liquors/', views.SimilarListView.as_view(), + # name='similar-liquor'), + # path('slug//similar/food/', views.SimilarListView.as_view(), + # name='similar-food'), + + path('slug//similar//', views.SimilarListView.as_view(), + name='similar-products') ] diff --git a/apps/product/views/common.py b/apps/product/views/common.py index 34990cc3..802d04bf 100644 --- a/apps/product/views/common.py +++ b/apps/product/views/common.py @@ -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): diff --git a/apps/search_indexes/documents/establishment.py b/apps/search_indexes/documents/establishment.py index 20185b9c..0b221102 100644 --- a/apps/search_indexes/documents/establishment.py +++ b/apps/search_indexes/documents/establishment.py @@ -77,15 +77,15 @@ class EstablishmentDocument(Document): 'value': fields.KeywordField(), }, multi=True, attr='artisan_category_indexing') - visible_tags = fields.ObjectField( + 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) - distillery_types = fields.ObjectField( + multi=True, attr='distillery_type_indexing') + visible_tags = fields.ObjectField( properties={ 'id': fields.IntegerField(attr='id'), 'label': fields.ObjectField(attr='label_indexing', @@ -153,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(), diff --git a/apps/search_indexes/documents/news.py b/apps/search_indexes/documents/news.py index 75174fae..6e287b0a 100644 --- a/apps/search_indexes/documents/news.py +++ b/apps/search_indexes/documents/news.py @@ -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() diff --git a/apps/search_indexes/filters.py b/apps/search_indexes/filters.py index 63f1dbb8..988b8a54 100644 --- a/apps/search_indexes/filters.py +++ b/apps/search_indexes/filters.py @@ -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__() diff --git a/apps/search_indexes/serializers.py b/apps/search_indexes/serializers.py index b3c55ab1..b909fb1b 100644 --- a/apps/search_indexes/serializers.py +++ b/apps/search_indexes/serializers.py @@ -277,7 +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_types = 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) @@ -311,7 +311,7 @@ class EstablishmentDocumentSerializer(InFavoritesMixin, DocumentSerializer): # 'collections', 'type', 'subtypes', - 'distillery_types', + 'distillery_type', ) diff --git a/apps/tag/filters.py b/apps/tag/filters.py index 7d82bf84..a9f675c6 100644 --- a/apps/tag/filters.py +++ b/apps/tag/filters.py @@ -5,6 +5,7 @@ from django.conf import settings from tag import models from product import models as product_models + class TagsBaseFilterSet(filters.FilterSet): # Object type choices diff --git a/apps/tag/serializers.py b/apps/tag/serializers.py index 1450c7dc..9722e87a 100644 --- a/apps/tag/serializers.py +++ b/apps/tag/serializers.py @@ -9,6 +9,7 @@ from tag import models from utils.exceptions import BindingObjectNotFound, ObjectAlreadyAdded, RemovedBindingObjectNotFound from utils.serializers import TranslatedField from utils.models import get_default_locale, get_language, to_locale +from main.models import Feature def translate_obj(obj): @@ -309,48 +310,25 @@ class ChosenTagSerializer(serializers.ModelSerializer): class ChosenTagBindObjectSerializer(serializers.Serializer): """Serializer for binding chosen tag and objects""" - ESTABLISHMENT_TYPE = 'establishment_type' - NEWS_TYPE = 'news_type' - - TYPE_CHOICES = ( - (ESTABLISHMENT_TYPE, 'Establishment type'), - (NEWS_TYPE, 'News type'), - ) - - type = serializers.ChoiceField(TYPE_CHOICES) - object_id = serializers.IntegerField() + feature_id = serializers.IntegerField() def validate(self, attrs): view = self.context.get('view') request = self.context.get('request') - obj_type = attrs.get('type') - obj_id = attrs.get('object_id') + obj_id = attrs.get('feature_id') tag = view.get_object() attrs['tag'] = tag - if obj_type == self.ESTABLISHMENT_TYPE: - establishment_type = EstablishmentType.objects.filter(pk=obj_id). \ - first() - if not establishment_type: - raise BindingObjectNotFound() - if request.method == 'DELETE' and not establishment_type. \ - chosen_tags.filter(tag=tag). \ - exists(): - raise RemovedBindingObjectNotFound() - attrs['related_object'] = establishment_type - - elif obj_type == self.NEWS_TYPE: - news_type = NewsType.objects.filter(pk=obj_id).first() - if not news_type: - raise BindingObjectNotFound() - if request.method == 'POST' and news_type.chosen_tags. \ - filter(tag=tag).exists(): - raise ObjectAlreadyAdded() - if request.method == 'DELETE' and not news_type.chosen_tags. \ - filter(tag=tag).exists(): - raise RemovedBindingObjectNotFound() - attrs['related_object'] = news_type + feature = Feature.objects.filter(pk=obj_id). \ + first() + if not feature: + raise BindingObjectNotFound() + if request.method == 'DELETE' and not feature. \ + chosen_tags.filter(tag=tag). \ + exists(): + raise RemovedBindingObjectNotFound() + attrs['related_object'] = feature return attrs diff --git a/apps/tag/views.py b/apps/tag/views.py index 4f1cd9ba..c621cd71 100644 --- a/apps/tag/views.py +++ b/apps/tag/views.py @@ -90,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 = [] @@ -155,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) @@ -170,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) @@ -193,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) diff --git a/apps/timetable/models.py b/apps/timetable/models.py index c9295b3b..ba4fadad 100644 --- a/apps/timetable/models.py +++ b/apps/timetable/models.py @@ -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): diff --git a/apps/timetable/serialziers.py b/apps/timetable/serialziers.py index 305be0ec..a3183543 100644 --- a/apps/timetable/serialziers.py +++ b/apps/timetable/serialziers.py @@ -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', ) diff --git a/apps/transfer/management/commands/transfer.py b/apps/transfer/management/commands/transfer.py index 7dc76d45..2f747300 100644 --- a/apps/transfer/management/commands/transfer.py +++ b/apps/transfer/management/commands/transfer.py @@ -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', diff --git a/apps/transfer/serializers/comments.py b/apps/transfer/serializers/comments.py index 73c90802..8322f89f 100644 --- a/apps/transfer/serializers/comments.py +++ b/apps/transfer/serializers/comments.py @@ -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 diff --git a/apps/transfer/serializers/location.py b/apps/transfer/serializers/location.py index 184a0bf5..b2130ad5 100644 --- a/apps/transfer/serializers/location.py +++ b/apps/transfer/serializers/location.py @@ -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' diff --git a/apps/translation/migrations/0008_index_siteifdict_text.py b/apps/translation/migrations/0008_index_siteifdict_text.py new file mode 100644 index 00000000..b5b485ec --- /dev/null +++ b/apps/translation/migrations/0008_index_siteifdict_text.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2020-01-17 22:01 + +import django.contrib.postgres.indexes +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('translation', '0007_language_is_active'), + ] + + operations = [ + migrations.AddIndex( + model_name='siteinterfacedictionary', + index=django.contrib.postgres.indexes.GinIndex(fields=['text'], name='translation_text_0b2bfa_gin'), + ), + ] diff --git a/apps/translation/models.py b/apps/translation/models.py index a288b334..86e5e52a 100644 --- a/apps/translation/models.py +++ b/apps/translation/models.py @@ -1,5 +1,6 @@ """Translation app models.""" from django.contrib.postgres.fields import JSONField +from django.contrib.postgres.indexes import GinIndex from django.db import models from django.utils.translation import gettext_lazy as _ from django.apps import apps @@ -105,6 +106,9 @@ class SiteInterfaceDictionary(ProjectBaseMixin): verbose_name = _('Site interface dictionary') verbose_name_plural = _('Site interface dictionary') + indexes = [ + GinIndex(fields=['text']) + ] def __str__(self): return f'{self.page}: {self.keywords}' diff --git a/apps/utils/methods.py b/apps/utils/methods.py index e06881e5..58cc3f52 100644 --- a/apps/utils/methods.py +++ b/apps/utils/methods.py @@ -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'\', 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 diff --git a/apps/utils/middleware.py b/apps/utils/middleware.py index c772e645..7ab5f532 100644 --- a/apps/utils/middleware.py +++ b/apps/utils/middleware.py @@ -1,5 +1,8 @@ """Custom middlewares.""" +import logging + from django.utils import translation, timezone +from django.db import connection from account.models import User from configuration.models import TranslationSettings @@ -7,6 +10,8 @@ from main.methods import determine_user_city from main.models import SiteSettings from translation.models import Language +logger = logging.getLogger(__name__) + def get_locale(cookie_dict): return cookie_dict.get('locale') @@ -92,3 +97,26 @@ def user_last_ip(get_response): return response return middleware + + +def log_db_queries_per_API_request(get_response): + """Middleware-helper to optimize requests performance""" + + def middleware(request): + total_time = 0 + response = get_response(request) + for query in connection.queries: + query_time = query.get('time') + if query_time is None: + query_time = query.get('duration', 0) / 1000 + total_time += float(query_time) + + total_queries = len(connection.queries) + if total_queries > 10: + logger.error( + f'\t{len(connection.queries)} queries run, total {total_time} seconds \t' + f'URL: "{request.method} {request.get_full_path_info()}"' + ) + return response + + return middleware diff --git a/apps/utils/models.py b/apps/utils/models.py index e3faed4e..b7a2be49 100644 --- a/apps/utils/models.py +++ b/apps/utils/models.py @@ -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.""" diff --git a/apps/utils/thumbnail_engine.py b/apps/utils/thumbnail_engine.py index f55d58f8..8e3b50ba 100644 --- a/apps/utils/thumbnail_engine.py +++ b/apps/utils/thumbnail_engine.py @@ -5,14 +5,13 @@ from sorl.thumbnail.engines.pil_engine import Engine as PILEngine class GMEngine(PILEngine): def create(self, image, geometry, options): - """ - Processing conductor, returns the thumbnail as an image engine instance - """ image = self.cropbox(image, geometry, options) image = self.orientation(image, geometry, options) image = self.colorspace(image, geometry, options) image = self.remove_border(image, options) + image = self.scale(image, geometry, options) image = self.crop(image, geometry, options) + image = self.scale(image, geometry, options) image = self.rounded(image, geometry, options) image = self.blur(image, geometry, options) image = self.padding(image, geometry, options) diff --git a/make_data_migration.sh b/make_data_migration.sh index 933639fa..d5edc793 100755 --- a/make_data_migration.sh +++ b/make_data_migration.sh @@ -27,4 +27,14 @@ ./manage.py transfer --overlook ./manage.py transfer --inquiries ./manage.py transfer --product_review -./manage.py transfer --transfer_text_review \ No newline at end of file +./manage.py transfer --transfer_text_review + +# оптимизация изображений +/manage.py news_optimize_images # сжимает картинки в описаниях новостей +/manage.py update_establishment_image_urls # удаляет неотображаемые картинки из модели заведения + +# сотрудники с позициями для заведений +./manage.py add_employee +./manage.py add_position +./manage.py add_empl_position +./manage.py update_employee \ No newline at end of file diff --git a/project/settings/base.py b/project/settings/base.py index 08a36609..755aa830 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -385,6 +385,7 @@ THUMBNAIL_QUALITY = 85 THUMBNAIL_DEBUG = False SORL_THUMBNAIL_ALIASES = { 'news_preview': {'geometry_string': '300x260', 'crop': 'center'}, + 'news_description': {'geometry_string': '100x100'}, 'news_promo_horizontal_web': {'geometry_string': '1900x600', 'crop': 'center'}, 'news_promo_horizontal_mobile': {'geometry_string': '375x260', 'crop': 'center'}, 'news_tile_horizontal_web': {'geometry_string': '300x275', 'crop': 'center'}, @@ -411,6 +412,8 @@ SORL_THUMBNAIL_ALIASES = { 'city_detail': {'geometry_string': '1120x1120', 'crop': 'center'}, 'city_original': {'geometry_string': '2048x1536', 'crop': 'center'}, 'type_preview': {'geometry_string': '300x260', 'crop': 'center'}, + 'collection_image': {'geometry_string': '940x620', 'upscale': False, 'quality': 100}, + 'establishment_collection_image': {'geometry_string': '940x620', 'upscale': False, 'quality': 100} } @@ -533,3 +536,5 @@ COOKIE_DOMAIN = None ELASTICSEARCH_DSL = {} ELASTICSEARCH_INDEX_NAMES = {} + +THUMBNAIL_FORCE_OVERWRITE = True diff --git a/project/settings/development.py b/project/settings/development.py index 24a1a795..3048541b 100644 --- a/project/settings/development.py +++ b/project/settings/development.py @@ -80,4 +80,7 @@ EMAIL_USE_TLS = True EMAIL_HOST = 'smtp.gmail.com' EMAIL_HOST_USER = 'anatolyfeteleu@gmail.com' EMAIL_HOST_PASSWORD = 'nggrlnbehzksgmbt' -EMAIL_PORT = 587 \ No newline at end of file +EMAIL_PORT = 587 + + +MIDDLEWARE.append('utils.middleware.log_db_queries_per_API_request') diff --git a/project/settings/local.py b/project/settings/local.py index a5c0ec8a..2ad132f1 100644 --- a/project/settings/local.py +++ b/project/settings/local.py @@ -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, + # }, } } diff --git a/project/templates/authorization/confirm_email.html b/project/templates/authorization/confirm_email.html index 8b1332d0..cbe340dc 100644 --- a/project/templates/authorization/confirm_email.html +++ b/project/templates/authorization/confirm_email.html @@ -36,6 +36,9 @@ {% trans "Please confirm your email address to complete the registration:" %} https://{{ country_code }}.{{ domain_uri }}/registered/{{ uidb64 }}/{{ token }}/
+ {% trans "If you use the mobile app, enter the following code in the form:" %} + {{ token }} +
{% trans "Thanks for using our site!" %}

{% blocktrans %}The {{ site_name }} team{% endblocktrans %} diff --git a/requirements/base.txt b/requirements/base.txt index f5bbf2d5..25a0256c 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -49,7 +49,6 @@ django-storages==1.7.2 sorl-thumbnail==12.5.0 - PyYAML==5.1.2 # temp solution diff --git a/requirements/development.txt b/requirements/development.txt index 77c3d73c..4763063b 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -1,4 +1,8 @@ -r base.txt ipdb ipython -mysqlclient==1.4.4 \ No newline at end of file +mysqlclient==1.4.4 + +pyparsing +graphviz +pydot \ No newline at end of file