diff --git a/apps/account/serializers/back.py b/apps/account/serializers/back.py index c1a1c6d4..57c3fb42 100644 --- a/apps/account/serializers/back.py +++ b/apps/account/serializers/back.py @@ -1,6 +1,7 @@ """Back account serializers""" from rest_framework import serializers from account import models +from account.models import User class RoleSerializer(serializers.ModelSerializer): @@ -18,4 +19,33 @@ class UserRoleSerializer(serializers.ModelSerializer): fields = [ 'user', 'role' - ] \ No newline at end of file + ] + + +class BackUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = '__all__' + extra_kwargs = { + 'password': {'write_only': True} + } + read_only_fields = ('old_password', 'last_login', 'date_joined') + + def create(self, validated_data): + user = super().create(validated_data) + user.set_password(validated_data['password']) + user.save() + return user + + +class BackDetailUserSerializer(BackUserSerializer): + class Meta: + model = User + exclude = ('password',) + read_only_fields = ('old_password', 'last_login', 'date_joined') + + def create(self, validated_data): + user = super().create(validated_data) + user.set_password(validated_data['password']) + user.save() + return user diff --git a/apps/account/serializers/common.py b/apps/account/serializers/common.py index 20016297..d2933747 100644 --- a/apps/account/serializers/common.py +++ b/apps/account/serializers/common.py @@ -99,6 +99,18 @@ class UserBaseSerializer(serializers.ModelSerializer): read_only_fields = fields +class UserShortSerializer(UserSerializer): + """Compact serializer for model User.""" + + class Meta(UserSerializer.Meta): + """Meta class.""" + fields = [ + 'id', + 'fullname', + 'email', + ] + + class ChangePasswordSerializer(serializers.ModelSerializer): """Serializer for model User.""" diff --git a/apps/account/tests/tests_back.py b/apps/account/tests/tests_back.py index d0178159..71fcd632 100644 --- a/apps/account/tests/tests_back.py +++ b/apps/account/tests/tests_back.py @@ -84,3 +84,52 @@ class UserRoleTests(APITestCase): response = self.client.post(url, data=data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + +class UserTestCase(APITestCase): + + def setUp(self): + self.user_1 = User.objects.create_user( + username='alex', + email='alex@mail.com', + password='alex_password', + is_staff=True, + ) + + self.user_2 = User.objects.create_user( + username='boris', + email='boris@mail.com', + password='boris_password', + ) + + # get tokens + tokens = User.create_jwt_tokens(self.user_1) + self.client.cookies = SimpleCookie( + {'access_token': tokens.get('access_token'), + 'refresh_token': tokens.get('refresh_token')}) + + def test_user_CRUD(self): + response = self.client.get('/api/back/account/user/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = { + 'username': 'roman', + 'email': 'roman@mail.com', + 'password': 'roman_password', + } + + response = self.client.post('/api/back/account/user/', data=data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.get(f'/api/back/account/user/{self.user_2.id}/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + update_data = { + 'first_name': 'Boris' + } + + response = self.client.patch(f'/api/back/account/user/{self.user_2.id}/', data=update_data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.delete(f'/api/back/account/user/{self.user_2.id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) diff --git a/apps/account/urls/back.py b/apps/account/urls/back.py index ee2e4148..630a4cb9 100644 --- a/apps/account/urls/back.py +++ b/apps/account/urls/back.py @@ -8,5 +8,6 @@ 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-list-create'), + path('user//', views.UserRUDView.as_view(), name='user-rud'), ] diff --git a/apps/account/views/back.py b/apps/account/views/back.py index 8799f915..b3d77d1e 100644 --- a/apps/account/views/back.py +++ b/apps/account/views/back.py @@ -1,6 +1,9 @@ -from rest_framework import generics -from account.serializers import back as serializers +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import generics, permissions + from account import models +from account.models import User +from account.serializers import back as serializers class RoleLstView(generics.ListCreateAPIView): @@ -10,4 +13,27 @@ class RoleLstView(generics.ListCreateAPIView): class UserRoleLstView(generics.ListCreateAPIView): serializer_class = serializers.UserRoleSerializer - queryset = models.Role.objects.all() \ No newline at end of file + queryset = models.Role.objects.all() + + +class UserLstView(generics.ListCreateAPIView): + """User list create view.""" + queryset = User.objects.all() + serializer_class = serializers.BackUserSerializer + permission_classes = (permissions.IsAdminUser,) + filter_backends = (DjangoFilterBackend,) + filterset_fields = ( + 'email_confirmed', + 'is_staff', + 'is_active', + 'is_superuser', + 'roles', + ) + + +class UserRUDView(generics.RetrieveUpdateDestroyAPIView): + """User RUD view.""" + queryset = User.objects.all() + serializer_class = serializers.BackDetailUserSerializer + permission_classes = (permissions.IsAdminUser,) + lookup_field = 'id' diff --git a/apps/account/views/common.py b/apps/account/views/common.py index d29ce2bb..8b066742 100644 --- a/apps/account/views/common.py +++ b/apps/account/views/common.py @@ -63,7 +63,7 @@ class SendConfirmationEmailView(generics.GenericAPIView): return Response(status=status.HTTP_200_OK) -class ConfirmEmailView(JWTGenericViewMixin): +class ConfirmEmailView(JWTGenericViewMixin, generics.GenericAPIView): """View for confirm changing email""" permission_classes = (permissions.AllowAny,) diff --git a/apps/account/views/web.py b/apps/account/views/web.py index 9f2ebcfd..0fc762f5 100644 --- a/apps/account/views/web.py +++ b/apps/account/views/web.py @@ -33,7 +33,7 @@ class PasswordResetView(generics.GenericAPIView): return Response(status=status.HTTP_200_OK) -class PasswordResetConfirmView(JWTGenericViewMixin): +class PasswordResetConfirmView(JWTGenericViewMixin, generics.GenericAPIView): """View for confirmation new password""" serializer_class = serializers.PasswordResetConfirmSerializer permission_classes = (permissions.AllowAny,) diff --git a/apps/advertisement/migrations/0008_auto_20191116_1135.py b/apps/advertisement/migrations/0008_auto_20191116_1135.py new file mode 100644 index 00000000..c2a0278c --- /dev/null +++ b/apps/advertisement/migrations/0008_auto_20191116_1135.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.7 on 2019-11-16 11:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('advertisement', '0007_auto_20191115_0750'), + ] + + operations = [ + migrations.AlterModelOptions( + name='advertisement', + options={'verbose_name': 'Advertisement', 'verbose_name_plural': 'Advertisements'}, + ), + ] diff --git a/apps/advertisement/models.py b/apps/advertisement/models.py index 574aff56..920e3389 100644 --- a/apps/advertisement/models.py +++ b/apps/advertisement/models.py @@ -2,11 +2,12 @@ import uuid from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from main.models import Page from translation.models import Language from utils.models import ProjectBaseMixin, ImageMixin, PlatformMixin, URLImageMixin -from main.models import Page class AdvertisementQuerySet(models.QuerySet): @@ -25,6 +26,10 @@ class AdvertisementQuerySet(models.QuerySet): """Filter by locale.""" return self.filter(target_languages__locale=locale) + def valid(self): + """Return only valid advertisements.""" + return self.filter(end__lte=timezone.now()) + class Advertisement(ProjectBaseMixin): """Advertisement model.""" @@ -50,11 +55,17 @@ class Advertisement(ProjectBaseMixin): class Meta: verbose_name = _('Advertisement') - verbose_name_plural = _('Advertisement') + verbose_name_plural = _('Advertisements') def __str__(self): return str(self.url) + def delete(self, using=None, keep_parents=False): + """Overridden delete method.""" + # Delete all related pages. + self.pages.all().delete() + return super().delete(using, keep_parents) + @property def mobile_page(self): """Return mobile page""" diff --git a/apps/advertisement/serializers/__init__.py b/apps/advertisement/serializers/__init__.py index 393379a8..2c9dae42 100644 --- a/apps/advertisement/serializers/__init__.py +++ b/apps/advertisement/serializers/__init__.py @@ -1,3 +1,4 @@ from .common import * from .mobile import * from .web import * +from .back import * diff --git a/apps/advertisement/serializers/back.py b/apps/advertisement/serializers/back.py new file mode 100644 index 00000000..9dc8b029 --- /dev/null +++ b/apps/advertisement/serializers/back.py @@ -0,0 +1,26 @@ +"""Serializers for back office app advertisements""" +from main.serializers import PageBaseSerializer + + +class AdvertisementPageBaseSerializer(PageBaseSerializer): + """Base serializer for linking page w/ advertisement.""" + + class Meta(PageBaseSerializer.Meta): + """Meta class.""" + + PageBaseSerializer.Meta.extra_kwargs.update({ + 'advertisement': {'write_only': True}, + 'image_url': {'required': True}, + 'width': {'required': True}, + 'height': {'required': True}, + }) + + +class AdvertisementPageListCreateSerializer(AdvertisementPageBaseSerializer): + """Serializer for linking page w/ advertisement.""" + + def create(self, validated_data): + """Overridden create method.""" + + validated_data['advertisement'] = self.context.get('view').get_object() + return super().create(validated_data) diff --git a/apps/advertisement/serializers/common.py b/apps/advertisement/serializers/common.py index 8b87abad..0adb74f9 100644 --- a/apps/advertisement/serializers/common.py +++ b/apps/advertisement/serializers/common.py @@ -3,15 +3,28 @@ from rest_framework import serializers from advertisement import models from translation.serializers import LanguageSerializer -from main.serializers import SiteShortSerializer -from main.serializers import PageBaseSerializer +from main.serializers import SiteShortSerializer, PageBaseSerializer +from translation.models import Language +from main.models import SiteSettings class AdvertisementBaseSerializer(serializers.ModelSerializer): """Base serializer for model Advertisement.""" - languages = LanguageSerializer(many=True, read_only=True) + languages = LanguageSerializer(many=True, read_only=True, + source='target_languages') + target_languages = serializers.PrimaryKeyRelatedField( + queryset=Language.objects.all(), + many=True, + write_only=True + ) sites = SiteShortSerializer(many=True, read_only=True) + target_sites = serializers.PrimaryKeyRelatedField( + queryset=SiteSettings.objects.all(), + many=True, + write_only=True, + source='sites' + ) class Meta: model = models.Advertisement @@ -21,7 +34,9 @@ class AdvertisementBaseSerializer(serializers.ModelSerializer): 'url', 'block_level', 'languages', + 'target_languages', 'sites', + 'target_sites', 'start', 'end', ] diff --git a/apps/advertisement/transfer_data.py b/apps/advertisement/transfer_data.py index 64e715ce..f1a5ca38 100644 --- a/apps/advertisement/transfer_data.py +++ b/apps/advertisement/transfer_data.py @@ -1,20 +1,42 @@ from pprint import pprint from transfer.models import Ads -from transfer.serializers.advertisement import AdvertisementSerializer +from transfer.serializers.advertisement import AdvertisementSerializer, AdvertisementImageSerializer def transfer_advertisement(): - queryset = Ads.objects.filter(href__isnull=False).values_list('id', 'href', 'attachment_suffix_url') + errors = [] + queryset = Ads.objects.exclude(href__isnull=True) \ + .exclude(attachment_suffix_url__isnull=True) \ + .exclude(site_id__isnull=True) serialized_data = AdvertisementSerializer(data=list(queryset.values()), many=True) if serialized_data.is_valid(): serialized_data.save() else: - pprint(f"News serializer errors: {serialized_data.errors}") + for d in serialized_data.errors: errors.append(d) if d else None + pprint(f"transfer_product errors: {errors}") + + +def transfer_page(): + errors = [] + queryset = Ads.objects.exclude(href__isnull=True) \ + .exclude(attachment_suffix_url__isnull=True) \ + .exclude(site_id__isnull=True) + + serialized_data = AdvertisementImageSerializer(data=list(queryset.values()), many=True) + + if serialized_data.is_valid(): + serialized_data.save() + else: + for d in serialized_data.errors: errors.append(d) if d else None + pprint(f"transfer_page errors: {errors}") data_types = { - "commercial": [transfer_advertisement] + "commercial": [ + transfer_advertisement, + transfer_page + ] } diff --git a/apps/advertisement/urls/back.py b/apps/advertisement/urls/back.py new file mode 100644 index 00000000..2502da0d --- /dev/null +++ b/apps/advertisement/urls/back.py @@ -0,0 +1,18 @@ +"""Advertisement back office urlpaths.""" +from django.urls import path +from advertisement import views +from advertisement.urls.common import common_urlpatterns + + +app_name = 'advertisements' + +urlpatterns = [ + path('', views.AdvertisementListCreateView.as_view(), name='list-create'), + path('/', views.AdvertisementRUDView.as_view(), name='rud'), + path('/pages/', views.AdvertisementPageListCreateView.as_view(), + name='page-list-create'), + path('/pages//', views.AdvertisementPageRUDView.as_view(), + name='page-rud') +] + +urlpatterns += common_urlpatterns diff --git a/apps/advertisement/urls/common.py b/apps/advertisement/urls/common.py index 323a3b48..420d3d41 100644 --- a/apps/advertisement/urls/common.py +++ b/apps/advertisement/urls/common.py @@ -1,5 +1,6 @@ """Advertisement common urlpaths.""" from django.urls import path +from advertisement import views app_name = 'advertisements' diff --git a/apps/advertisement/views/__init__.py b/apps/advertisement/views/__init__.py index 393379a8..2c9dae42 100644 --- a/apps/advertisement/views/__init__.py +++ b/apps/advertisement/views/__init__.py @@ -1,3 +1,4 @@ from .common import * from .mobile import * from .web import * +from .back import * diff --git a/apps/advertisement/views/back.py b/apps/advertisement/views/back.py new file mode 100644 index 00000000..a2973589 --- /dev/null +++ b/apps/advertisement/views/back.py @@ -0,0 +1,77 @@ +"""Back office views for app advertisement""" +from rest_framework import generics +from rest_framework import permissions +from django.shortcuts import get_object_or_404 + +from advertisement.models import Advertisement +from rest_framework.response import Response +from rest_framework import status +from advertisement.serializers import (AdvertisementBaseSerializer, + AdvertisementPageBaseSerializer, + AdvertisementPageListCreateSerializer) + + +class AdvertisementBackOfficeViewMixin(generics.GenericAPIView): + """Base back office advertisement view.""" + + permission_classes = (permissions.IsAuthenticated, ) + + def get_queryset(self): + """Overridden get queryset method.""" + return Advertisement.objects.with_base_related() + + +class AdvertisementListCreateView(AdvertisementBackOfficeViewMixin, generics.ListCreateAPIView): + """List|Create advertisement view.""" + + serializer_class = AdvertisementBaseSerializer + + +class AdvertisementRUDView(AdvertisementBackOfficeViewMixin, + generics.RetrieveUpdateDestroyAPIView): + """Retrieve|Update|Destroy advertisement page view.""" + + serializer_class = AdvertisementBaseSerializer + + +class AdvertisementPageListCreateView(AdvertisementBackOfficeViewMixin, + generics.ListCreateAPIView): + """Retrieve|Update|Destroy advertisement page view.""" + + serializer_class = AdvertisementPageListCreateSerializer + + def get_object(self): + """Returns the object the view is displaying.""" + ad_qs = Advertisement.objects.all() + filtered_ad_qs = self.filter_queryset(ad_qs) + + ad = get_object_or_404(filtered_ad_qs, pk=self.kwargs.get('pk')) + + # May raise a permission denied + self.check_object_permissions(self.request, ad) + + return ad + + def get_queryset(self): + """Overridden get_queryset method.""" + return self.get_object().pages.all() + + +class AdvertisementPageRUDView(AdvertisementBackOfficeViewMixin, + generics.RetrieveUpdateDestroyAPIView): + """Create|Retrieve|Update|Destroy advertisement page view.""" + + serializer_class = AdvertisementPageBaseSerializer + + def get_object(self): + """Returns the object the view is displaying.""" + ad_qs = Advertisement.objects.all() + filtered_ad_qs = self.filter_queryset(ad_qs) + + ad = get_object_or_404(filtered_ad_qs, pk=self.kwargs.get('ad_pk')) + page = get_object_or_404(ad.pages.all(), pk=self.kwargs.get('page_pk')) + + # May raise a permission denied + self.check_object_permissions(self.request, page) + + return page diff --git a/apps/authorization/views/common.py b/apps/authorization/views/common.py index 9d2069f2..8b466acb 100644 --- a/apps/authorization/views/common.py +++ b/apps/authorization/views/common.py @@ -71,7 +71,7 @@ class OAuth2ViewMixin(CsrfExemptMixin, OAuthLibMixin, BaseOAuth2ViewMixin): # Sign in via Facebook -class OAuth2SignUpView(OAuth2ViewMixin, JWTGenericViewMixin): +class OAuth2SignUpView(OAuth2ViewMixin, JWTGenericViewMixin, generics.GenericAPIView): """ Implements an endpoint to convert a provider token to an access token @@ -142,7 +142,7 @@ class SignUpView(generics.GenericAPIView): return Response(status=status.HTTP_201_CREATED) -class ConfirmationEmailView(JWTGenericViewMixin): +class ConfirmationEmailView(JWTGenericViewMixin, generics.GenericAPIView): """View for confirmation email""" permission_classes = (permissions.AllowAny, ) @@ -174,7 +174,7 @@ class ConfirmationEmailView(JWTGenericViewMixin): # Login by username|email + password -class LoginByUsernameOrEmailView(JWTGenericViewMixin): +class LoginByUsernameOrEmailView(JWTGenericViewMixin, generics.GenericAPIView): """Login by email and password""" permission_classes = (permissions.AllowAny,) serializer_class = serializers.LoginByUsernameOrEmailSerializer @@ -197,7 +197,7 @@ class LoginByUsernameOrEmailView(JWTGenericViewMixin): # Logout -class LogoutView(JWTGenericViewMixin): +class LogoutView(JWTGenericViewMixin, generics.GenericAPIView): """Logout user""" permission_classes = (IsAuthenticatedAndTokenIsValid, ) @@ -215,7 +215,7 @@ class LogoutView(JWTGenericViewMixin): # Refresh token -class RefreshTokenView(JWTGenericViewMixin): +class RefreshTokenView(JWTGenericViewMixin, generics.GenericAPIView): """Refresh access_token""" permission_classes = (permissions.AllowAny, ) serializer_class = serializers.RefreshTokenSerializer diff --git a/apps/booking/views.py b/apps/booking/views.py index 653503e7..73f6f55e 100644 --- a/apps/booking/views.py +++ b/apps/booking/views.py @@ -13,10 +13,50 @@ from utils.methods import get_user_ip class CheckWhetherBookingAvailable(generics.GenericAPIView): """ Checks which service to use if establishmend is managed by any """ + _VALID_GUESTONLINE_PERIODS = {'lunch', 'dinner', 'afternoon', 'breakfast'} + _GUESTONLINE_PERIODS_TO_PRIOR = { + 'breakfast': 1, + 'lunch': 2, + 'afternoon': 3, + 'dinner': 4, + } + permission_classes = (permissions.AllowAny,) serializer_class = CheckBookingSerializer pagination_class = None + def _fill_period_template(self, period_template, period_name): + period_template_copy = period_template.copy() + period_template_copy['period'] = period_name + return period_template_copy + + def _preprocess_guestonline_response(self, response): + periods = response['periods'] + periods_by_name = {period['period']: period for period in periods if 'period' in period} + if not periods_by_name: + raise ValueError('Empty guestonline response') + + period_template = iter(periods_by_name.values()).__next__().copy() + period_template.pop('total_left_seats') + period_template['hours'] = [] + period_template.pop('period') + + processed_periods = [ + periods_by_name[period_name] + if period_name in periods_by_name + else self._fill_period_template(period_template, period_name) + for period_name in CheckWhetherBookingAvailable._VALID_GUESTONLINE_PERIODS + ] + + unnamed_periods = filter(lambda period: 'period' not in period, periods) + for unnamed_period in unnamed_periods: + processed_periods.append(unnamed_period) + + response['periods'] = sorted(processed_periods, + key=lambda x: self._GUESTONLINE_PERIODS_TO_PRIOR[x.get('period', 'lunch')]) + + return response + def get(self, request, *args, **kwargs): is_booking_available = False establishment = get_object_or_404(Establishment, pk=kwargs['establishment_id']) @@ -24,12 +64,12 @@ class CheckWhetherBookingAvailable(generics.GenericAPIView): date = request.query_params.get('date') g_service = GuestonlineService() l_service = LastableService() - if (not establishment.lastable_id is None) and l_service \ + if establishment.lastable_id is not None and l_service \ .check_whether_booking_available(establishment.lastable_id, date): is_booking_available = True service = l_service service.service_id = establishment.lastable_id - elif (not establishment.guestonline_id is None) and g_service \ + elif establishment.guestonline_id is not None and g_service \ .check_whether_booking_available(establishment.guestonline_id, **g_service.get_certain_keys(request.query_params, {'date', 'persons'})): @@ -41,7 +81,11 @@ class CheckWhetherBookingAvailable(generics.GenericAPIView): 'available': is_booking_available, 'type': service.service if service else None, } - response.update({'details': service.response} if service and service.response else {}) + + service_response = self._preprocess_guestonline_response(service.response) \ + if establishment.guestonline_id is not None \ + else service.response + response.update({'details': service_response} if service and service.response else {}) return Response(data=response, status=200) @@ -97,8 +141,9 @@ class UpdatePendingBooking(generics.UpdateAPIView): r = service.update_booking(service.get_certain_keys(data, { 'email', 'phone', 'last_name', 'first_name', 'country_code', 'pending_booking_id', 'note', }, { - 'email', 'phone', 'last_name', 'first_name', 'country_code', 'pending_booking_id', - })) + 'email', 'phone', 'last_name', 'first_name', + 'country_code', 'pending_booking_id', + })) if isinstance(r, Response): return r if data.get('newsletter'): diff --git a/apps/collection/serializers/back.py b/apps/collection/serializers/back.py index 4cc76b2f..bb88a778 100644 --- a/apps/collection/serializers/back.py +++ b/apps/collection/serializers/back.py @@ -1,8 +1,14 @@ from rest_framework import serializers + +from collection import models +from collection.serializers.common import CollectionBaseSerializer +from establishment.models import Establishment from location.models import Country from location.serializers import CountrySimpleSerializer -from collection.serializers.common import CollectionBaseSerializer -from collection import models +from product.models import Product +from utils.exceptions import ( + BindingObjectNotFound, RemovedBindingObjectNotFound, ObjectAlreadyAdded +) class CollectionBackOfficeSerializer(CollectionBaseSerializer): @@ -31,3 +37,54 @@ class CollectionBackOfficeSerializer(CollectionBaseSerializer): 'start', 'end', ] + + +class CollectionBindObjectSerializer(serializers.Serializer): + """Serializer for binding collection and objects""" + + ESTABLISHMENT = 'establishment' + PRODUCT = 'product' + + TYPE_CHOICES = ( + (ESTABLISHMENT, 'Establishment'), + (PRODUCT, 'Product'), + ) + + type = serializers.ChoiceField(TYPE_CHOICES) + object_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') + + collection = view.get_object() + attrs['collection'] = collection + + if obj_type == self.ESTABLISHMENT: + establishment = Establishment.objects.filter(pk=obj_id).\ + first() + if not establishment: + raise BindingObjectNotFound() + if request.method == 'POST' and collection.establishments.\ + filter(pk=establishment.pk).exists(): + raise ObjectAlreadyAdded() + if request.method == 'DELETE' and not collection.\ + establishments.filter(pk=establishment.pk).\ + exists(): + raise RemovedBindingObjectNotFound() + attrs['related_object'] = establishment + elif obj_type == self.PRODUCT: + product = Product.objects.filter(pk=obj_id).first() + if not product: + raise BindingObjectNotFound() + if request.method == 'POST' and collection.products.\ + filter(pk=product.pk).exists(): + raise ObjectAlreadyAdded() + if request.method == 'DELETE' and not collection.products.\ + filter(pk=product.pk).exists(): + raise RemovedBindingObjectNotFound() + attrs['related_object'] = product + return attrs diff --git a/apps/collection/urls/back.py b/apps/collection/urls/back.py index eee40327..6a6dbd54 100644 --- a/apps/collection/urls/back.py +++ b/apps/collection/urls/back.py @@ -1,11 +1,10 @@ """Collection common urlpaths.""" -from django.urls import path +from rest_framework.routers import SimpleRouter from collection.views import back as views app_name = 'collection' +router = SimpleRouter() +router.register(r'', views.CollectionBackOfficeViewSet) -urlpatterns = [ - path('', views.CollectionListCreateView.as_view(), name='list-create'), - path('/', views.CollectionRUDView.as_view(), name='rud-collection'), -] +urlpatterns = router.urls diff --git a/apps/collection/views/back.py b/apps/collection/views/back.py index 78a2dcb0..a989ec56 100644 --- a/apps/collection/views/back.py +++ b/apps/collection/views/back.py @@ -1,19 +1,49 @@ -from rest_framework import generics, permissions +from rest_framework import permissions +from rest_framework import viewsets, mixins + from collection import models -from collection.serializers import back +from collection.serializers import back as serializers +from utils.views import BindObjectMixin -class CollectionListCreateView(generics.ListCreateAPIView): - """Collection list-create view.""" +class CollectionViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): + """ViewSet for Collection model.""" + + pagination_class = None + permission_classes = (permissions.AllowAny,) queryset = models.Collection.objects.all() - serializer_class = back.CollectionBackOfficeSerializer - # todo: conf. permissions by TT - permission_classes = (permissions.IsAuthenticated, ) + serializer_class = serializers.CollectionBackOfficeSerializer -class CollectionRUDView(generics.RetrieveUpdateDestroyAPIView): - """Collection list-create view.""" +class CollectionBackOfficeViewSet(mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.RetrieveModelMixin, + BindObjectMixin, + CollectionViewSet): + """ViewSet for Collection model for BackOffice users.""" + + permission_classes = (permissions.IsAuthenticated,) queryset = models.Collection.objects.all() - serializer_class = back.CollectionBackOfficeSerializer - # todo: conf. permissions by TT - permission_classes = (permissions.IsAuthenticated, ) + serializer_class = serializers.CollectionBackOfficeSerializer + bind_object_serializer_class = serializers.CollectionBindObjectSerializer + + def perform_binding(self, serializer): + data = serializer.validated_data + collection = data.pop('collection') + obj_type = data.get('type') + related_object = data.get('related_object') + if obj_type == self.bind_object_serializer_class.ESTABLISHMENT: + collection.establishments.add(related_object) + elif obj_type == self.bind_object_serializer_class.PRODUCT: + collection.products.add(related_object) + + def perform_unbinding(self, serializer): + data = serializer.validated_data + collection = data.pop('collection') + obj_type = data.get('type') + related_object = data.get('related_object') + if obj_type == self.bind_object_serializer_class.ESTABLISHMENT: + collection.establishments.remove(related_object) + elif obj_type == self.bind_object_serializer_class.PRODUCT: + collection.products.remove(related_object) diff --git a/apps/collection/views/common.py b/apps/collection/views/common.py index 5bf8f70e..8ea20d8d 100644 --- a/apps/collection/views/common.py +++ b/apps/collection/views/common.py @@ -1,11 +1,10 @@ -from rest_framework import generics -from rest_framework import permissions +from django.shortcuts import get_object_or_404 +from rest_framework import generics, permissions, pagination from collection import models -from utils.pagination import ProjectPageNumberPagination -from django.shortcuts import get_object_or_404 -from establishment.serializers import EstablishmentBaseSerializer from collection.serializers import common as serializers +from establishment.serializers import EstablishmentSimilarSerializer +from utils.pagination import ProjectPageNumberPagination, ProjectMobilePagination # Mixins @@ -18,8 +17,8 @@ class CollectionViewMixin(generics.GenericAPIView): def get_queryset(self): """Override get_queryset method.""" return models.Collection.objects.published() \ - .by_country_code(code=self.request.country_code) \ - .order_by('-on_top', '-modified') + .by_country_code(code=self.request.country_code) \ + .order_by('-on_top', '-modified') class GuideViewMixin(generics.GenericAPIView): @@ -40,7 +39,7 @@ class CollectionHomePageView(CollectionListView): def get_queryset(self): """Override get_queryset.""" return super(CollectionHomePageView, self).get_queryset() \ - .filter_all_related_gt(3) + .filter_all_related_gt(3) class CollectionDetailView(CollectionViewMixin, generics.RetrieveAPIView): @@ -52,8 +51,8 @@ class CollectionDetailView(CollectionViewMixin, generics.RetrieveAPIView): class CollectionEstablishmentListView(CollectionListView): """Retrieve list of establishment for collection.""" lookup_field = 'slug' - pagination_class = ProjectPageNumberPagination - serializer_class = EstablishmentBaseSerializer + pagination_class = ProjectMobilePagination + serializer_class = EstablishmentSimilarSerializer def get_queryset(self): """ @@ -66,7 +65,7 @@ class CollectionEstablishmentListView(CollectionListView): # May raise a permission denied self.check_object_permissions(self.request, collection) - return collection.establishments.all() + return collection.establishments.all().annotate_in_favorites(self.request.user) # Guide diff --git a/apps/establishment/admin.py b/apps/establishment/admin.py index 366d54cf..45716f32 100644 --- a/apps/establishment/admin.py +++ b/apps/establishment/admin.py @@ -7,7 +7,7 @@ from comment.models import Comment from utils.admin import BaseModelAdminMixin from establishment import models from main.models import Award -from product.models import Product +from product.models import Product, PurchasedProduct from review import models as review_models @@ -32,6 +32,12 @@ class ContactPhoneInline(admin.TabularInline): extra = 0 +class GalleryImageInline(admin.TabularInline): + """Gallery image inline admin.""" + model = models.EstablishmentGallery + extra = 0 + + class ContactEmailInline(admin.TabularInline): """Contact email inline admin.""" model = models.ContactEmail @@ -53,12 +59,29 @@ class ProductInline(admin.TabularInline): extra = 0 +class CompanyInline(admin.TabularInline): + model = models.Company + extra = 0 + + +class EstablishmentNote(admin.TabularInline): + model = models.EstablishmentNote + extra = 0 + + +class PurchasedProduct(admin.TabularInline): + model = PurchasedProduct + extra = 0 + + @admin.register(models.Establishment) class EstablishmentAdmin(BaseModelAdminMixin, admin.ModelAdmin): """Establishment admin.""" list_display = ['id', '__str__', 'image_tag', ] search_fields = ['id', 'name', 'index_name', 'slug'] list_filter = ['public_mark', 'toque_number'] + inlines = [GalleryImageInline, CompanyInline, EstablishmentNote, + PurchasedProduct] # inlines = [ # AwardInline, ContactPhoneInline, ContactEmailInline, @@ -107,3 +130,9 @@ class SocialChoiceAdmin(BaseModelAdminMixin, admin.ModelAdmin): class SocialNetworkAdmin(BaseModelAdminMixin, admin.ModelAdmin): """Admin conf for SocialNetwork model.""" raw_id_fields = ('establishment',) + + +@admin.register(models.Company) +class CompanyAdmin(BaseModelAdminMixin, admin.ModelAdmin): + """Admin conf for Company model.""" + raw_id_fields = ['establishment', 'address', ] diff --git a/apps/establishment/management/commands/fill_establishment_gallery.py b/apps/establishment/management/commands/fill_establishment_gallery.py new file mode 100644 index 00000000..bb6edc39 --- /dev/null +++ b/apps/establishment/management/commands/fill_establishment_gallery.py @@ -0,0 +1,41 @@ +from django.core.management.base import BaseCommand + +from establishment.models import Establishment, EstablishmentGallery +from gallery.models import Image +import requests +from rest_framework import status + + +class Command(BaseCommand): + help = 'Fill establishment gallery from existing images' + + def handle(self, *args, **kwargs): + count = 0 + not_valid_link_counter = 0 + not_valid_urls = [] + + cdn_prefix = 'https://1dc3f33f6d-3.optimicdn.com/gaultmillau.com/' + establishments = Establishment.objects.exclude(image_url__isnull=True) \ + .exclude(preview_image_url__isnull=True) + for establishment in establishments: + image_url = establishment.image_url.rstrip() + relative_image_path = image_url[len(cdn_prefix):] + + #response = requests.head(image_url, allow_redirects=True) + #if response.status_code != status.HTTP_200_OK: + # not_valid_link_counter += 1 + # not_valid_urls.append(image_url) + + image, image_created = Image.objects.get_or_create( + orientation=Image.HORIZONTAL, + title=f'{establishment.name} - {relative_image_path}', + image=relative_image_path) + gallery, _ = EstablishmentGallery.objects.get_or_create(establishment=establishment, + image=image, + is_main=True) + if image_created: + count += 1 + + self.stdout.write(self.style.WARNING(f'Created/updated {count} objects.\n' + f'Not valid link counter: {not_valid_link_counter}\n' + f'List of non valid image url: {not_valid_urls}')) diff --git a/apps/establishment/migrations/0062_auto_20191117_1117.py b/apps/establishment/migrations/0062_auto_20191117_1117.py new file mode 100644 index 00000000..9d0fc2f9 --- /dev/null +++ b/apps/establishment/migrations/0062_auto_20191117_1117.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.7 on 2019-11-17 11:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0006_merge_20191027_1758'), + ('establishment', '0061_auto_20191114_0550'), + ] + + operations = [ + migrations.AlterModelOptions( + name='establishmentnote', + options={'verbose_name': 'establishment note', 'verbose_name_plural': 'establishment notes'}, + ), + migrations.CreateModel( + name='EstablishmentGallery', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_main', models.BooleanField(default=False, verbose_name='Is the main image')), + ('establishment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='establishment_gallery', to='establishment.Establishment', verbose_name='establishment')), + ('image', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='establishment_gallery', to='gallery.Image', verbose_name='image')), + ], + options={ + 'verbose_name': 'establishment gallery', + 'verbose_name_plural': 'establishment galleries', + 'unique_together': {('establishment', 'is_main'), ('establishment', 'image')}, + }, + ), + migrations.AddField( + model_name='establishment', + name='gallery', + field=models.ManyToManyField(through='establishment.EstablishmentGallery', to='gallery.Image'), + ), + ] diff --git a/apps/establishment/migrations/0063_company.py b/apps/establishment/migrations/0063_company.py new file mode 100644 index 00000000..1f9b4aa3 --- /dev/null +++ b/apps/establishment/migrations/0063_company.py @@ -0,0 +1,40 @@ +# Generated by Django 2.2.7 on 2019-11-18 14:19 + +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ('location', '0027_auto_20191118_1313'), + ('establishment', '0062_auto_20191117_1117'), + ] + + operations = [ + migrations.CreateModel( + name='Company', + 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')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('phones', django.contrib.postgres.fields.ArrayField(base_field=phonenumber_field.modelfields.PhoneNumberField(max_length=128), blank=True, default=None, null=True, size=None, verbose_name='contact phones')), + ('faxes', django.contrib.postgres.fields.ArrayField(base_field=phonenumber_field.modelfields.PhoneNumberField(max_length=128), blank=True, default=None, null=True, size=None, verbose_name='fax numbers')), + ('legal_entity', models.CharField(blank=True, default=None, max_length=255, null=True, verbose_name='legal entity')), + ('registry_number', models.CharField(blank=True, default=None, max_length=255, null=True, verbose_name='registry number')), + ('vat_number', models.CharField(blank=True, default=None, max_length=30, null=True, verbose_name='VAT identification number')), + ('sic_code', models.IntegerField(blank=True, default=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(9999)], verbose_name='sic code')), + ('address', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='companies', to='location.Address', verbose_name='address')), + ('establishment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='companies', to='establishment.Establishment', verbose_name='establishment')), + ], + options={ + 'verbose_name': 'company', + 'verbose_name_plural': 'companies', + }, + ), + ] diff --git a/apps/establishment/migrations/0064_auto_20191119_1546.py b/apps/establishment/migrations/0064_auto_20191119_1546.py new file mode 100644 index 00000000..80d4135b --- /dev/null +++ b/apps/establishment/migrations/0064_auto_20191119_1546.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.7 on 2019-11-19 15:46 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('establishment', '0063_company'), + ] + + operations = [ + migrations.AlterField( + model_name='establishmentnote', + name='establishment', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='notes', to='establishment.Establishment', verbose_name='establishment'), + ), + ] diff --git a/apps/establishment/migrations/0065_establishment_purchased_products.py b/apps/establishment/migrations/0065_establishment_purchased_products.py new file mode 100644 index 00000000..25088455 --- /dev/null +++ b/apps/establishment/migrations/0065_establishment_purchased_products.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.7 on 2019-11-20 12:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0018_purchasedproduct'), + ('establishment', '0064_auto_20191119_1546'), + ] + + operations = [ + migrations.AddField( + model_name='establishment', + name='purchased_products', + field=models.ManyToManyField(blank=True, help_text='Attribute from legacy db.\nMust be deleted after the implementation of the market.', related_name='establishments', through='product.PurchasedProduct', to='product.Product', verbose_name='purchased plaques'), + ), + ] diff --git a/apps/establishment/models.py b/apps/establishment/models.py index 2c85890f..26046953 100644 --- a/apps/establishment/models.py +++ b/apps/establishment/models.py @@ -9,7 +9,9 @@ from django.contrib.contenttypes import fields as generic 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.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 from django.utils import timezone @@ -22,7 +24,8 @@ from location.models import Address from main.models import Award, Currency from review.models import Review from utils.models import (ProjectBaseMixin, TJSONField, URLImageMixin, - TranslatedFieldsMixin, BaseAttributes) + TranslatedFieldsMixin, BaseAttributes, GalleryModelMixin, + IntermediateGalleryModelMixin, HasTagsMixin) # todo: establishment type&subtypes check @@ -316,9 +319,11 @@ class EstablishmentQuerySet(models.QuerySet): return self.exclude(address__city__country__in=countries) -class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): +class Establishment(GalleryModelMixin, ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin, HasTagsMixin): """Establishment model.""" + # todo: delete image URL fields after moving on gallery + old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None) name = models.CharField(_('name'), max_length=255, default='') transliterated_name = models.CharField(default='', max_length=255, @@ -376,6 +381,7 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): related_name='establishments', blank=True, default=None, verbose_name=_('Collections')) + gallery = models.ManyToManyField('gallery.Image', through='EstablishmentGallery') preview_image_url = models.URLField(verbose_name=_('Preview image URL path'), max_length=255, blank=True, null=True, default=None) slug = models.SlugField(unique=True, max_length=255, null=True, @@ -391,6 +397,13 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): currency = models.ForeignKey(Currency, blank=True, null=True, default=None, on_delete=models.PROTECT, verbose_name=_('currency')) + purchased_products = models.ManyToManyField('product.Product', blank=True, + through='product.PurchasedProduct', + related_name='establishments', + verbose_name=_('purchased plaques'), + help_text=_('Attribute from legacy db.\n' + 'Must be deleted after the ' + 'implementation of the market.')) objects = EstablishmentQuerySet.as_manager() @@ -403,6 +416,26 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): def __str__(self): return f'id:{self.id}-{self.name}' + def clean_fields(self, exclude=None): + super().clean_fields(exclude) + if self.purchased_products.filter(product_type__index_name='souvenir').exists(): + raise ValidationError( + _('Only souvenirs.')) + + def delete(self, using=None, keep_parents=False): + """Overridden delete method""" + # Delete all related companies + self.companies.all().delete() + # Delete all related notes + self.notes.all().delete() + return super().delete(using, keep_parents) + + @property + def visible_tags(self): + return super().visible_tags\ + .exclude(category__index_name__in=['guide', 'collection', 'purchased_item', + 'business_tag', 'business_tags_de'])\ + # todo: recalculate toque_number def recalculate_toque_number(self): toque_number = 0 @@ -544,6 +577,12 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): """Return list products with type wine""" return self.products.wines() + @property + def main_image(self): + qs = self.establishment_gallery.main_image() + if qs.exists(): + return qs.first().image + class EstablishmentNoteQuerySet(models.QuerySet): """QuerySet for model EstablishmentNote.""" @@ -554,7 +593,7 @@ class EstablishmentNote(ProjectBaseMixin): old_id = models.PositiveIntegerField(null=True, blank=True) text = models.TextField(verbose_name=_('text')) establishment = models.ForeignKey(Establishment, on_delete=models.PROTECT, - related_name='establishment_notes', + related_name='notes', verbose_name=_('establishment')) user = models.ForeignKey('account.User', on_delete=models.PROTECT, null=True, @@ -565,8 +604,26 @@ class EstablishmentNote(ProjectBaseMixin): class Meta: """Meta class.""" - verbose_name_plural = _('product note') - verbose_name = _('product notes') + verbose_name_plural = _('establishment notes') + verbose_name = _('establishment note') + + +class EstablishmentGallery(IntermediateGalleryModelMixin): + + establishment = models.ForeignKey(Establishment, null=True, + related_name='establishment_gallery', + on_delete=models.CASCADE, + verbose_name=_('establishment')) + image = models.ForeignKey('gallery.Image', null=True, + related_name='establishment_gallery', + on_delete=models.CASCADE, + verbose_name=_('image')) + + class Meta: + """Meta class.""" + verbose_name = _('establishment gallery') + verbose_name_plural = _('establishment galleries') + unique_together = (('establishment', 'is_main'), ('establishment', 'image')) class Position(BaseAttributes, TranslatedFieldsMixin): @@ -837,3 +894,46 @@ class RatingStrategy(ProjectBaseMixin): return f'{self.country.code if self.country else "Other country"}. ' \ f'"{self.toque_number}": {self.public_mark_min_value}-' \ f'{self.public_mark_max_value}' + + +class CompanyQuerySet(models.QuerySet): + """QuerySet for model Company.""" + + +class Company(ProjectBaseMixin): + """Establishment company model.""" + + establishment = models.ForeignKey(Establishment, on_delete=models.PROTECT, + related_name='companies', + verbose_name=_('establishment')) + name = models.CharField(max_length=255, verbose_name=_('name')) + phones = ArrayField(PhoneNumberField(max_length=128), + blank=True, null=True, default=None, + verbose_name=_('contact phones')) + faxes = ArrayField(PhoneNumberField(max_length=128), + blank=True, null=True, default=None, + verbose_name=_('fax numbers')) + legal_entity = models.CharField(max_length=255, + blank=True, null=True, default=None, + verbose_name=_('legal entity')) + registry_number = models.CharField(max_length=255, + blank=True, null=True, default=None, + verbose_name=_('registry number')) + vat_number = models.CharField(max_length=30, + blank=True, null=True, default=None, + verbose_name=_('VAT identification number')) + sic_code = models.IntegerField(validators=[MinValueValidator(1), + MaxValueValidator(9999)], + blank=True, null=True, default=True, + verbose_name=_('sic code')) + address = models.ForeignKey(Address, on_delete=models.PROTECT, + blank=True, null=True, default=None, + related_name='companies', + verbose_name=_('address')) + + objects = CompanyQuerySet.as_manager() + + class Meta: + """Meta class.""" + verbose_name = _('company') + verbose_name_plural = _('companies') diff --git a/apps/establishment/serializers/back.py b/apps/establishment/serializers/back.py index 5957cb16..a78bce07 100644 --- a/apps/establishment/serializers/back.py +++ b/apps/establishment/serializers/back.py @@ -1,27 +1,32 @@ from rest_framework import serializers from establishment import models -from establishment.serializers import ( - EstablishmentBaseSerializer, PlateSerializer, ContactEmailsSerializer, - ContactPhonesSerializer, SocialNetworkRelatedSerializers, - EstablishmentTypeBaseSerializer) +from establishment import serializers as model_serializers from location.serializers import AddressDetailSerializer from main.models import Currency 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 -class EstablishmentListCreateSerializer(EstablishmentBaseSerializer): +class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSerializer): """Establishment create serializer""" type_id = serializers.PrimaryKeyRelatedField( source='establishment_type', - queryset=models.EstablishmentType.objects.all(), write_only=True + queryset=models.EstablishmentType.objects.all(), + write_only=True ) - phones = ContactPhonesSerializer(read_only=True, many=True, ) - emails = ContactEmailsSerializer(read_only=True, many=True, ) - socials = SocialNetworkRelatedSerializers(read_only=True, many=True, ) - type = EstablishmentTypeBaseSerializer(source='establishment_type', read_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, + many=True, ) + type = model_serializers.EstablishmentTypeBaseSerializer(source='establishment_type', + read_only=True) tz = TimeZoneChoiceField() class Meta: @@ -50,7 +55,7 @@ class EstablishmentListCreateSerializer(EstablishmentBaseSerializer): ] -class EstablishmentRUDSerializer(EstablishmentBaseSerializer): +class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer): """Establishment create serializer""" type_id = serializers.PrimaryKeyRelatedField( @@ -58,10 +63,13 @@ class EstablishmentRUDSerializer(EstablishmentBaseSerializer): queryset=models.EstablishmentType.objects.all(), write_only=True ) address = AddressDetailSerializer() - phones = ContactPhonesSerializer(read_only=False, many=True, ) - emails = ContactEmailsSerializer(read_only=False, many=True, ) - socials = SocialNetworkRelatedSerializers(read_only=False, many=True, ) - type = EstablishmentTypeBaseSerializer(source='establishment_type') + 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') class Meta: model = models.Establishment @@ -105,7 +113,7 @@ class SocialNetworkSerializers(serializers.ModelSerializer): ] -class PlatesSerializers(PlateSerializer): +class PlatesSerializers(model_serializers.PlateSerializer): """Plates serializers.""" currency_id = serializers.PrimaryKeyRelatedField( @@ -117,14 +125,14 @@ class PlatesSerializers(PlateSerializer): """Meta class.""" model = models.Plate - fields = PlateSerializer.Meta.fields + [ + fields = model_serializers.PlateSerializer.Meta.fields + [ 'name', 'currency_id', 'menu' ] -class ContactPhoneBackSerializers(PlateSerializer): +class ContactPhoneBackSerializers(model_serializers.PlateSerializer): """ContactPhone serializers.""" class Meta: @@ -136,7 +144,7 @@ class ContactPhoneBackSerializers(PlateSerializer): ] -class ContactEmailBackSerializers(PlateSerializer): +class ContactEmailBackSerializers(model_serializers.PlateSerializer): """ContactEmail serializers.""" class Meta: @@ -160,3 +168,112 @@ class EmployeeBackSerializers(serializers.ModelSerializer): 'user', 'name' ] + + +class EstablishmentBackOfficeGallerySerializer(serializers.ModelSerializer): + """Serializer class for model EstablishmentGallery.""" + + class Meta: + """Meta class""" + + model = models.EstablishmentGallery + fields = [ + 'id', + 'is_main', + ] + + def get_request_kwargs(self): + """Get url kwargs from request.""" + return self.context.get('request').parser_context.get('kwargs') + + def validate(self, attrs): + """Override validate method.""" + establishment_pk = self.get_request_kwargs().get('pk') + image_id = self.get_request_kwargs().get('image_id') + + establishment_qs = models.Establishment.objects.filter(pk=establishment_pk) + image_qs = Image.objects.filter(id=image_id) + + if not establishment_qs.exists(): + raise serializers.ValidationError({'detail': _('Establishment not found')}) + + if not image_qs.exists(): + raise serializers.ValidationError({'detail': _('Image not found')}) + + establishment = establishment_qs.first() + image = image_qs.first() + + if image in establishment.gallery.all(): + raise serializers.ValidationError({'detail': _('Image is already added.')}) + + attrs['establishment'] = establishment + attrs['image'] = image + + return attrs + + +class EstablishmentCompanyListCreateSerializer(model_serializers.CompanyBaseSerializer): + """Serializer for linking page w/ advertisement.""" + + class Meta(model_serializers.CompanyBaseSerializer.Meta): + """Meta class.""" + model_serializers.CompanyBaseSerializer.Meta.extra_kwargs.update({ + 'establishment': {'required': False} + }) + + def create(self, validated_data): + """Overridden create method.""" + validated_data['establishment'] = self.context.get('view').get_object() + return super().create(validated_data) + + +class EstablishmentNoteBaseSerializer(serializers.ModelSerializer): + """Serializer for model EstablishmentNote.""" + + user_detail = UserShortSerializer(read_only=True, source='user') + + class Meta: + """Meta class.""" + model = models.EstablishmentNote + fields = [ + 'id', + 'created', + 'modified', + 'text', + 'user', + 'user_detail', + 'establishment', + ] + extra_kwargs = { + 'created': {'read_only': True}, + 'modified': {'read_only': True}, + 'establishment': {'required': False, 'write_only': True}, + 'user': {'required': False, 'write_only': True}, + } + + @property + def serializer_view(self): + """Return view instance.""" + return self.context.get('view') + + +class EstablishmentNoteListCreateSerializer(EstablishmentNoteBaseSerializer): + """Serializer for List|Create action for model EstablishmentNote.""" + + def create(self, validated_data): + """Overridden create method.""" + validated_data['user'] = self.user + validated_data['establishment'] = self.establishment + return super().create(validated_data) + + @property + def user(self): + """Return user instance from view.""" + if self.serializer_view: + return self.serializer_view.request.user + + @property + def establishment(self): + """Return establishment instance from view.""" + if self.serializer_view: + return self.serializer_view.get_object() diff --git a/apps/establishment/serializers/common.py b/apps/establishment/serializers/common.py index 1d405be7..0c183477 100644 --- a/apps/establishment/serializers/common.py +++ b/apps/establishment/serializers/common.py @@ -1,22 +1,26 @@ """Establishment serializers.""" from django.utils.translation import ugettext_lazy as _ +from phonenumber_field.phonenumber import to_python as str_to_phonenumber 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, CitySerializer, AddressDetailSerializer, \ + CityShortSerializer from main.serializers import AwardSerializer, CurrencySerializer +from review.serializers import ReviewShortSerializer from tag.serializers import TagBaseSerializer from timetable.serialziers import ScheduleRUDSerializer from utils import exceptions as utils_exceptions +from utils.serializers import ImageBaseSerializer from utils.serializers import (ProjectModelSerializer, TranslatedField, FavoritesCreateSerializer) -from review.serializers import ReviewShortSerializer class ContactPhonesSerializer(serializers.ModelSerializer): """Contact phone serializer""" + class Meta: model = models.ContactPhone fields = [ @@ -26,6 +30,7 @@ class ContactPhonesSerializer(serializers.ModelSerializer): class ContactEmailsSerializer(serializers.ModelSerializer): """Contact email serializer""" + class Meta: model = models.ContactEmail fields = [ @@ -35,6 +40,7 @@ class ContactEmailsSerializer(serializers.ModelSerializer): class SocialNetworkRelatedSerializers(serializers.ModelSerializer): """Social network serializers.""" + class Meta: model = models.SocialNetwork fields = [ @@ -45,7 +51,6 @@ class SocialNetworkRelatedSerializers(serializers.ModelSerializer): class PlateSerializer(ProjectModelSerializer): - name_translated = TranslatedField() currency = CurrencySerializer(read_only=True) @@ -176,6 +181,7 @@ class EstablishmentShortSerializer(serializers.ModelSerializer): city = CitySerializer(source='address.city', allow_null=True) establishment_type = EstablishmentTypeGeoSerializer() establishment_subtypes = EstablishmentSubTypeBaseSerializer(many=True) + currency = CurrencySerializer(read_only=True) class Meta: """Meta class.""" @@ -188,6 +194,31 @@ class EstablishmentShortSerializer(serializers.ModelSerializer): 'city', 'establishment_type', 'establishment_subtypes', + 'currency', + ] + + +class EstablishmentProductShortSerializer(serializers.ModelSerializer): + """SHORT Serializer for displaying info about an establishment on product page.""" + establishment_type = EstablishmentTypeGeoSerializer() + establishment_subtypes = EstablishmentSubTypeBaseSerializer(many=True) + address = AddressBaseSerializer() + city = CityShortSerializer(source='address.city', allow_null=True) + currency_detail = CurrencySerializer(source='currency', read_only=True) + + class Meta: + """Meta class.""" + model = models.Establishment + fields = [ + 'id', + 'name', + 'index_name', + 'slug', + 'city', + 'establishment_type', + 'establishment_subtypes', + 'address', + 'currency_detail', ] @@ -206,13 +237,18 @@ class EstablishmentProductSerializer(EstablishmentShortSerializer): class EstablishmentBaseSerializer(ProjectModelSerializer): """Base serializer for Establishment model.""" - preview_image = serializers.URLField(source='preview_image_url') address = AddressBaseSerializer() in_favorites = serializers.BooleanField(allow_null=True) - tags = TagBaseSerializer(read_only=True, many=True) + tags = TagBaseSerializer(read_only=True, many=True, source='visible_tags') 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) + preview_image = serializers.URLField(source='preview_image_url', + allow_null=True, + read_only=True) + + new_image = ImageBaseSerializer(source='crop_main_image', allow_null=True, read_only=True) class Meta: """Meta class.""" @@ -227,13 +263,15 @@ class EstablishmentBaseSerializer(ProjectModelSerializer): 'toque_number', 'public_mark', 'slug', - 'preview_image', 'in_favorites', 'address', 'tags', 'currency', 'type', 'subtypes', + 'image', + 'preview_image', + 'new_image', ] @@ -272,7 +310,6 @@ class EstablishmentDetailSerializer(EstablishmentBaseSerializer): """Serializer for Establishment model.""" description_translated = TranslatedField() - image = serializers.URLField(source='image_url') awards = AwardSerializer(many=True) schedule = ScheduleRUDSerializer(many=True, allow_null=True) phones = ContactPhonesSerializer(read_only=True, many=True) @@ -288,6 +325,7 @@ class EstablishmentDetailSerializer(EstablishmentBaseSerializer): range_price_menu = RangePriceSerializer(read_only=True) range_price_carte = RangePriceSerializer(read_only=True) vintage_year = serializers.ReadOnlyField() + gallery = ImageBaseSerializer(read_only=True, source='crop_gallery', many=True) class Meta(EstablishmentBaseSerializer.Meta): """Meta class.""" @@ -313,9 +351,16 @@ class EstablishmentDetailSerializer(EstablishmentBaseSerializer): 'range_price_carte', 'transportation', 'vintage_year', + 'gallery', ] +class EstablishmentSimilarSerializer(EstablishmentBaseSerializer): + """Serializer for Establishment model.""" + + address = AddressDetailSerializer(read_only=True) + + class EstablishmentCommentCreateSerializer(comment_serializers.CommentSerializer): """Create comment serializer""" mark = serializers.IntegerField() @@ -380,3 +425,56 @@ class EstablishmentFavoritesCreateSerializer(FavoritesCreateSerializer): }) return super().create(validated_data) + +class CompanyBaseSerializer(serializers.ModelSerializer): + """Company base serializer""" + phone_list = serializers.SerializerMethodField(source='phones', read_only=True) + fax_list = serializers.SerializerMethodField(source='faxes', read_only=True) + address_detail = AddressDetailSerializer(source='address', read_only=True) + + class Meta: + """Meta class.""" + model = models.Company + fields = [ + 'id', + 'establishment', + 'name', + 'phones', + 'faxes', + 'legal_entity', + 'registry_number', + 'vat_number', + 'sic_code', + 'address', + 'phone_list', + 'fax_list', + 'address_detail', + ] + extra_kwargs = { + 'establishment': {'write_only': True}, + 'phones': {'write_only': True}, + 'faxes': {'write_only': True}, + 'address': {'write_only': True} + } + + def get_phone_list(self, instance): + """Return list of phone numbers.""" + return instance.phones + + def get_fax_list(self, instance): + """Return list of fax numbers.""" + return instance.faxes + + def validate(self, attrs): + """Overridden validate method""" + phones = [str_to_phonenumber(phone).as_national for phone in attrs.get('phones')] + faxes = [str_to_phonenumber(fax).as_national for fax in attrs.get('faxes')] + + if faxes: + if models.Company.objects.filter(faxes__overlap=faxes).exists(): + raise serializers.ValidationError({'detail': _('Fax is already reserved.')}) + + if phones: + if models.Company.objects.filter(phones__overlap=phones).exists(): + raise serializers.ValidationError({'detail': _('Phones is already reserved.')}) + return attrs diff --git a/apps/establishment/transfer_data.py b/apps/establishment/transfer_data.py index b3dc58f9..741a9989 100644 --- a/apps/establishment/transfer_data.py +++ b/apps/establishment/transfer_data.py @@ -4,7 +4,9 @@ from django.db.models import Q, F from establishment.models import Establishment from location.models import Address -from transfer.models import Establishments, Dishes, EstablishmentNotes +from product.models import PurchasedProduct, Product +from transfer.models import Establishments, Dishes, EstablishmentNotes, \ + EstablishmentMerchandises from transfer.serializers.establishment import EstablishmentSerializer, \ EstablishmentNoteSerializer from transfer.serializers.plate import PlateSerializer @@ -140,6 +142,43 @@ def transfer_establishment_note(): pprint(f"transfer_establishment_note errors: {errors}") +def transfer_purchased_plaques(): + update_products_counter = 0 + already_updated_counter = 0 + not_existed_establishment_counter = 0 + + purchased = EstablishmentMerchandises.objects.values_list( + 'establishment_id', + 'merchandise__vintage', + 'gifted', + 'quantity' + ) + for old_est_id, vintage, gifted, quantity in purchased: + establishment_qs = Establishment.objects.filter(old_id=old_est_id) + product_qs = Product.objects.filter(name='Plaque restaurants', + vintage=vintage) + if establishment_qs.exists() and product_qs.exists(): + product = product_qs.first() + establishment = establishment_qs.first() + + purchases, created = PurchasedProduct.objects.get_or_create( + establishment=establishment, + product=product, + is_gifted=gifted, + quantity=quantity + ) + if created: + update_products_counter += 1 + else: + already_updated_counter += 1 + else: + not_existed_establishment_counter += 1 + + print(f'Updated products: {update_products_counter}\n' + f'Already updated: {already_updated_counter}\n' + f'Not existed establishment: {not_existed_establishment_counter}') + + data_types = { "establishment": [ transfer_establishment, @@ -149,4 +188,7 @@ data_types = { transfer_establishment_addresses ], "menu": [transfer_menu], + "purchased_plaques": [ + transfer_purchased_plaques + ], } diff --git a/apps/establishment/urls/back.py b/apps/establishment/urls/back.py index 14575c46..f06e2187 100644 --- a/apps/establishment/urls/back.py +++ b/apps/establishment/urls/back.py @@ -13,6 +13,19 @@ urlpatterns = [ name='schedule-rud'), path('/schedule/', views.EstablishmentScheduleCreateView.as_view(), name='schedule-create'), + path('/gallery/', views.EstablishmentGalleryListView.as_view(), + name='gallery-list'), + path('/gallery//', + views.EstablishmentGalleryCreateDestroyView.as_view(), + name='gallery-create-destroy'), + path('/companies/', views.EstablishmentCompanyListCreateView.as_view(), + name='company-list-create'), + path('/companies//', views.EstablishmentCompanyRUDView.as_view(), + name='company-rud'), + path('/notes/', views.EstablishmentNoteListCreateView.as_view(), + name='note-list-create'), + path('/notes//', views.EstablishmentNoteRUDView.as_view(), + name='note-rud'), path('menus/', views.MenuListCreateView.as_view(), name='menu-list'), path('menus//', views.MenuRUDView.as_view(), name='menu-rud'), path('plates/', views.PlateListCreateView.as_view(), name='plates'), diff --git a/apps/establishment/views/back.py b/apps/establishment/views/back.py index 1a547032..d1897397 100644 --- a/apps/establishment/views/back.py +++ b/apps/establishment/views/back.py @@ -2,9 +2,13 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics, permissions -from utils.permissions import IsCountryAdmin, IsEstablishmentManager from establishment import filters, models, serializers from timetable.serialziers import ScheduleRUDSerializer, ScheduleCreateSerializer +from utils.permissions import IsCountryAdmin, IsEstablishmentManager +from utils.views import CreateDestroyGalleryViewMixin +from timetable.models import Timetable +from rest_framework import status +from rest_framework.response import Response class EstablishmentMixinViews: @@ -33,13 +37,14 @@ class EstablishmentRUDView(generics.RetrieveUpdateDestroyAPIView): class EstablishmentScheduleRUDView(generics.RetrieveUpdateDestroyAPIView): """Establishment schedule RUD view""" serializer_class = ScheduleRUDSerializer + permission_classes = [IsEstablishmentManager] def get_object(self): """ Returns the object the view is displaying. """ - establishment_pk = self.kwargs['pk'] - schedule_id = self.kwargs['schedule_id'] + establishment_pk = self.kwargs.get('pk') + schedule_id = self.kwargs.get('schedule_id') establishment = get_object_or_404(klass=models.Establishment.objects.all(), pk=establishment_pk) @@ -56,6 +61,8 @@ class EstablishmentScheduleRUDView(generics.RetrieveUpdateDestroyAPIView): class EstablishmentScheduleCreateView(generics.CreateAPIView): """Establishment schedule Create view""" serializer_class = ScheduleCreateSerializer + queryset = Timetable.objects.all() + permission_classes = [IsEstablishmentManager] class MenuListCreateView(generics.ListCreateAPIView): @@ -184,3 +191,130 @@ class EstablishmentSubtypeRUDView(generics.RetrieveUpdateDestroyAPIView): """Establishment subtype retrieve/update/destroy view.""" serializer_class = serializers.EstablishmentSubTypeBaseSerializer queryset = models.EstablishmentSubType.objects.all() + + +class EstablishmentGalleryCreateDestroyView(EstablishmentMixinViews, + CreateDestroyGalleryViewMixin): + """Resource for a create|destroy gallery for establishment for back-office users.""" + serializer_class = serializers.EstablishmentBackOfficeGallerySerializer + + def get_object(self): + """ + Returns the object the view is displaying. + """ + establishment_qs = self.filter_queryset(self.get_queryset()) + + establishment = get_object_or_404(establishment_qs, pk=self.kwargs.get('pk')) + gallery = get_object_or_404(establishment.establishment_gallery, + image_id=self.kwargs.get('image_id')) + + # May raise a permission denied + self.check_object_permissions(self.request, gallery) + + return gallery + + +class EstablishmentGalleryListView(EstablishmentMixinViews, + generics.ListAPIView): + """Resource for returning gallery for establishment for back-office users.""" + serializer_class = serializers.ImageBaseSerializer + + def get_object(self): + """Override get_object method.""" + qs = super(EstablishmentGalleryListView, self).get_queryset() + establishment = get_object_or_404(qs, pk=self.kwargs.get('pk')) + + # May raise a permission denied + self.check_object_permissions(self.request, establishment) + + return establishment + + def get_queryset(self): + """Override get_queryset method.""" + return self.get_object().crop_gallery + + +class EstablishmentCompanyListCreateView(EstablishmentMixinViews, + generics.ListCreateAPIView): + """List|Create establishment company view.""" + + serializer_class = serializers.EstablishmentCompanyListCreateSerializer + + def get_object(self): + """Returns the object the view is displaying.""" + establishment_qs = models.Establishment.objects.all() + filtered_ad_qs = self.filter_queryset(establishment_qs) + + establishment = get_object_or_404(filtered_ad_qs, pk=self.kwargs.get('pk')) + + # May raise a permission denied + self.check_object_permissions(self.request, establishment) + + return establishment + + def get_queryset(self): + """Overridden get_queryset method.""" + return self.get_object().companies.all() + + +class EstablishmentCompanyRUDView(EstablishmentMixinViews, + generics.RetrieveUpdateDestroyAPIView): + """Create|Retrieve|Update|Destroy establishment company view.""" + + serializer_class = serializers.CompanyBaseSerializer + + def get_object(self): + """Returns the object the view is displaying.""" + establishment_qs = models.Establishment.objects.all() + filtered_ad_qs = self.filter_queryset(establishment_qs) + + establishment = get_object_or_404(filtered_ad_qs, pk=self.kwargs.get('pk')) + company = get_object_or_404(establishment.companies.all(), pk=self.kwargs.get('company_pk')) + + # May raise a permission denied + self.check_object_permissions(self.request, company) + + return company + + +class EstablishmentNoteListCreateView(EstablishmentMixinViews, + generics.ListCreateAPIView): + """Retrieve|Update|Destroy establishment note view.""" + + serializer_class = serializers.EstablishmentNoteListCreateSerializer + + def get_object(self): + """Returns the object the view is displaying.""" + establishment_qs = models.Establishment.objects.all() + filtered_establishment_qs = self.filter_queryset(establishment_qs) + + establishment = get_object_or_404(filtered_establishment_qs, pk=self.kwargs.get('pk')) + + # May raise a permission denied + self.check_object_permissions(self.request, establishment) + + return establishment + + def get_queryset(self): + """Overridden get_queryset method.""" + return self.get_object().notes.all() + + +class EstablishmentNoteRUDView(EstablishmentMixinViews, + generics.RetrieveUpdateDestroyAPIView): + """Create|Retrieve|Update|Destroy establishment note view.""" + + serializer_class = serializers.EstablishmentNoteBaseSerializer + + def get_object(self): + """Returns the object the view is displaying.""" + establishment_qs = models.Establishment.objects.all() + filtered_establishment_qs = self.filter_queryset(establishment_qs) + + establishment = get_object_or_404(filtered_establishment_qs, pk=self.kwargs.get('pk')) + note = get_object_or_404(establishment.notes.all(), pk=self.kwargs['note_pk']) + + # May raise a permission denied + self.check_object_permissions(self.request, note) + + return note diff --git a/apps/establishment/views/web.py b/apps/establishment/views/web.py index 2a6fd215..20e8f81a 100644 --- a/apps/establishment/views/web.py +++ b/apps/establishment/views/web.py @@ -72,7 +72,7 @@ class EstablishmentRecentReviewListView(EstablishmentListView): class EstablishmentSimilarListView(EstablishmentListView): """Resource for getting a list of establishments.""" - serializer_class = serializers.EstablishmentBaseSerializer + serializer_class = serializers.EstablishmentSimilarSerializer pagination_class = EstablishmentPortionPagination def get_queryset(self): diff --git a/apps/favorites/views.py b/apps/favorites/views.py index 3cf97246..444048ff 100644 --- a/apps/favorites/views.py +++ b/apps/favorites/views.py @@ -2,7 +2,7 @@ from rest_framework import generics from establishment.models import Establishment from establishment.filters import EstablishmentFilter -from establishment.serializers import EstablishmentBaseSerializer +from establishment.serializers import EstablishmentBaseSerializer, EstablishmentSimilarSerializer from news.filters import NewsListFilterSet from news.models import News from news.serializers import NewsBaseSerializer, NewsListSerializer @@ -23,7 +23,7 @@ class FavoritesBaseView(generics.GenericAPIView): class FavoritesEstablishmentListView(generics.ListAPIView): """List views for establishments in favorites.""" - serializer_class = EstablishmentBaseSerializer + serializer_class = EstablishmentSimilarSerializer filter_class = EstablishmentFilter def get_queryset(self): diff --git a/apps/location/migrations/0027_auto_20191118_1011.py b/apps/location/migrations/0027_auto_20191118_1011.py new file mode 100644 index 00000000..2937ca15 --- /dev/null +++ b/apps/location/migrations/0027_auto_20191118_1011.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.4 on 2019-11-18 10:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('location', '0026_country_is_active'), + ] + + operations = [ + migrations.AlterField( + model_name='winesubregion', + name='wine_region', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='wine_sub_region', to='location.WineRegion', verbose_name='wine sub region'), + ), + ] diff --git a/apps/location/migrations/0027_auto_20191118_1313.py b/apps/location/migrations/0027_auto_20191118_1313.py new file mode 100644 index 00000000..8a8beb0c --- /dev/null +++ b/apps/location/migrations/0027_auto_20191118_1313.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.7 on 2019-11-18 13:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('location', '0026_country_is_active'), + ] + + operations = [ + migrations.AlterField( + model_name='winesubregion', + name='wine_region', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='wine_sub_region', to='location.WineRegion', verbose_name='wine sub region'), + ), + ] diff --git a/apps/location/migrations/0028_merge_20191118_1507.py b/apps/location/migrations/0028_merge_20191118_1507.py new file mode 100644 index 00000000..f7b41a63 --- /dev/null +++ b/apps/location/migrations/0028_merge_20191118_1507.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.7 on 2019-11-18 15:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('location', '0027_auto_20191118_1313'), + ('location', '0027_auto_20191118_1011'), + ] + + operations = [ + ] diff --git a/apps/location/migrations/0028_merge_20191119_0647.py b/apps/location/migrations/0028_merge_20191119_0647.py new file mode 100644 index 00000000..40d79f13 --- /dev/null +++ b/apps/location/migrations/0028_merge_20191119_0647.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.7 on 2019-11-19 06:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('location', '0027_auto_20191118_1313'), + ('location', '0027_auto_20191118_1011'), + ] + + operations = [ + ] diff --git a/apps/location/migrations/0029_merge_20191119_1438.py b/apps/location/migrations/0029_merge_20191119_1438.py new file mode 100644 index 00000000..666abc64 --- /dev/null +++ b/apps/location/migrations/0029_merge_20191119_1438.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.7 on 2019-11-19 14:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('location', '0028_merge_20191119_0647'), + ('location', '0028_merge_20191118_1507'), + ] + + operations = [ + ] diff --git a/apps/location/migrations/0030_auto_20191120_1010.py b/apps/location/migrations/0030_auto_20191120_1010.py new file mode 100644 index 00000000..26c1e5ef --- /dev/null +++ b/apps/location/migrations/0030_auto_20191120_1010.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.7 on 2019-11-20 10:10 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0006_merge_20191027_1758'), + ('location', '0029_merge_20191119_1438'), + ] + + operations = [ + migrations.CreateModel( + name='CityGallery', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_main', models.BooleanField(default=False, verbose_name='Is the main image')), + ('city', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='city_gallery', to='location.City', verbose_name='city')), + ('image', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='city_gallery', to='gallery.Image', verbose_name='image')), + ], + options={ + 'verbose_name': 'city gallery', + 'verbose_name_plural': 'city galleries', + 'unique_together': {('city', 'is_main'), ('city', 'image')}, + }, + ), + migrations.AddField( + model_name='city', + name='gallery', + field=models.ManyToManyField(through='location.CityGallery', to='gallery.Image'), + ), + ] diff --git a/apps/location/models.py b/apps/location/models.py index c979b2d9..02ae3764 100644 --- a/apps/location/models.py +++ b/apps/location/models.py @@ -10,7 +10,8 @@ from django.contrib.postgres.fields import ArrayField from translation.models import Language from utils.models import (ProjectBaseMixin, SVGImageMixin, TJSONField, - TranslatedFieldsMixin, get_current_locale) + TranslatedFieldsMixin, get_current_locale, + IntermediateGalleryModelMixin, GalleryModelMixin) class CountryQuerySet(models.QuerySet): @@ -101,9 +102,8 @@ class CityQuerySet(models.QuerySet): return self.filter(country__code=code) -class City(models.Model): +class City(GalleryModelMixin): """Region model.""" - name = models.CharField(_('name'), max_length=250) name_translated = TJSONField(blank=True, null=True, default=None, verbose_name=_('Translated name'), help_text='{"en-GB":"some text"}') @@ -138,16 +138,8 @@ class City(models.Model): return self.name -class CityGalleryQuerySet(models.QuerySet): - """QuerySet for model News""" - - def main_image(self): - """Return objects with flag is_main is True""" - return self.filter(is_main=True) - - -class CityGallery(models.Model): - old_id = models.IntegerField(blank=True, null=True) +class CityGallery(IntermediateGalleryModelMixin): + """Gallery for model City.""" city = models.ForeignKey(City, null=True, related_name='city_gallery', on_delete=models.CASCADE, @@ -155,14 +147,10 @@ class CityGallery(models.Model): image = models.ForeignKey('gallery.Image', null=True, related_name='city_gallery', on_delete=models.CASCADE, - verbose_name=_('gallery')) - is_main = models.BooleanField(default=False, - verbose_name=_('Is the main image')) - - objects = CityGalleryQuerySet.as_manager() + verbose_name=_('image')) class Meta: - """NewsGallery meta class.""" + """CityGallery meta class.""" verbose_name = _('city gallery') verbose_name_plural = _('city galleries') unique_together = (('city', 'is_main'), ('city', 'image')) @@ -216,6 +204,13 @@ class Address(models.Model): class WineRegionQuerySet(models.QuerySet): """Wine region queryset.""" + def with_sub_region_related(self): + return self.prefetch_related('wine_sub_region') + + def having_wines(self, value = True): + """Return qs with regions, which have any wine related to them""" + return self.exclude(wines__isnull=value) + class WineRegion(models.Model, TranslatedFieldsMixin): """Wine region model.""" @@ -254,6 +249,7 @@ class WineSubRegion(models.Model): """Wine sub region model.""" name = models.CharField(_('name'), max_length=255) wine_region = models.ForeignKey(WineRegion, on_delete=models.PROTECT, + related_name='wine_sub_region', verbose_name=_('wine sub region')) old_id = models.PositiveIntegerField(_('old id'), default=None, blank=True, null=True) diff --git a/apps/location/serializers/back.py b/apps/location/serializers/back.py index c178f7fd..9a263acd 100644 --- a/apps/location/serializers/back.py +++ b/apps/location/serializers/back.py @@ -1,5 +1,8 @@ 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): @@ -18,3 +21,45 @@ 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', + ] + + def get_request_kwargs(self): + """Get url kwargs from request.""" + return self.context.get('request').parser_context.get('kwargs') + + def validate(self, attrs): + """Override validate method.""" + city_pk = self.get_request_kwargs().get('pk') + image_id = self.get_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 69feadb8..9556aa46 100644 --- a/apps/location/serializers/common.py +++ b/apps/location/serializers/common.py @@ -55,6 +55,20 @@ class RegionSerializer(serializers.ModelSerializer): 'country_id' ] +class CityShortSerializer(serializers.ModelSerializer): + """Short city serializer""" + country = CountrySerializer(read_only=True) + + class Meta: + """Meta class""" + model = models.City + fields = ( + 'id', + 'name', + 'code', + 'country', + ) + class CropImageSerializer(serializers.Serializer): """Serializer for crop images for City object.""" @@ -273,3 +287,14 @@ class WineSubRegionBaseSerializer(serializers.ModelSerializer): 'id', 'name', ] + + +class WineRegionSerializer(WineRegionBaseSerializer): + """Wine region w/ subregion serializer""" + + wine_sub_region = WineSubRegionBaseSerializer(allow_null=True, many=True) + + class Meta(WineRegionBaseSerializer.Meta): + fields = WineRegionBaseSerializer.Meta.fields + [ + 'wine_sub_region' + ] diff --git a/apps/location/transfer_data.py b/apps/location/transfer_data.py index e14f7153..1d89e5cd 100644 --- a/apps/location/transfer_data.py +++ b/apps/location/transfer_data.py @@ -1,9 +1,12 @@ from transfer.serializers import location as location_serializers from transfer import models as transfer_models -from location.models import Country, Region, City, Address, WineRegion +from location.models import Country, Region, City, Address, WineRegion, CityGallery from pprint import pprint import json +from gallery.models import Image +from pprint import pprint + from django.conf import settings from django.core.exceptions import MultipleObjectsReturned from collection.models import Collection @@ -494,6 +497,42 @@ def fix_location_models(): fix_chosen_tag() +def transfer_city_gallery(): + created_counter = 0 + cities_not_exists = {} + gallery_obj_exists_counter = 0 + + city_gallery = transfer_models.CityPhotos.objects.exclude(city__isnull=True) \ + .exclude(city__country_code_2__isnull=True) \ + .exclude(city__country_code_2__iexact='') \ + .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: + city = City.objects.filter(old_id=old_city_id) + if city.exists(): + city = city.first() + image, _ = Image.objects.get_or_create(image=image_suffix_url, + defaults={ + 'image': image_suffix_url, + '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: + created_counter += 1 + else: + gallery_obj_exists_counter += 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}') + + data_types = { "dictionaries": [ transfer_countries, @@ -515,6 +554,8 @@ data_types = { ], "fix_location": [ fix_location_models - ] + ], + + "fill_city_gallery": [transfer_city_gallery] } diff --git a/apps/location/urls/back.py b/apps/location/urls/back.py index 8fd87dc9..c5ef027b 100644 --- a/apps/location/urls/back.py +++ b/apps/location/urls/back.py @@ -11,6 +11,11 @@ urlpatterns = [ path('cities/', views.CityListCreateView.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/urls/common.py b/apps/location/urls/common.py index e2840590..3f86dd29 100644 --- a/apps/location/urls/common.py +++ b/apps/location/urls/common.py @@ -22,4 +22,6 @@ urlpatterns = [ path('regions/', views.RegionListView.as_view(), name='region-list'), path('regions//', views.RegionRetrieveView.as_view(), name='region-retrieve'), + + path('wine-regions/', views.WineRegionListView.as_view(), name='wine-region-list'), ] diff --git a/apps/location/views/back.py b/apps/location/views/back.py index a4eee929..4d420154 100644 --- a/apps/location/views/back.py +++ b/apps/location/views/back.py @@ -4,7 +4,11 @@ from rest_framework import generics from location import models, serializers from location.views import common from utils.permissions import IsCountryAdmin -from rest_framework.permissions import IsAuthenticatedOrReadOnly +from utils.views import CreateDestroyGalleryViewMixin +from rest_framework.permissions import IsAuthenticatedOrReadOnly +from django.shortcuts import get_object_or_404 +from utils.serializers import ImageBaseSerializer + # Address @@ -35,6 +39,48 @@ class CityRUDView(common.CityViewMixin, generics.RetrieveUpdateDestroyAPIView): 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 + + +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 fab7662c..11ed51da 100644 --- a/apps/location/views/common.py +++ b/apps/location/views/common.py @@ -5,8 +5,11 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics, permissions, status from rest_framework.response import Response from gallery.tasks import delete_image - +from rest_framework import generics +from rest_framework import permissions +from django.db.models.expressions import RawSQL from location import models, serializers +from utils.models import get_current_locale # Mixins @@ -41,7 +44,9 @@ class CountryListView(CountryViewMixin, generics.ListAPIView): """List view for model Country.""" pagination_class = None - + def get_queryset(self): + qs = super().get_queryset().order_by(RawSQL("name->>%s", (get_current_locale(),))) + return qs class CountryRetrieveView(CountryViewMixin, generics.RetrieveAPIView): """Retrieve view for model Country.""" @@ -64,6 +69,15 @@ class RegionListView(RegionViewMixin, generics.ListAPIView): serializer_class = serializers.CountrySerializer +class WineRegionListView(generics.ListAPIView): + """List view for model WineRegion""" + pagination_class = None + model = models.WineRegion + permission_classes = (permissions.AllowAny,) + queryset = models.WineRegion.objects.with_sub_region_related().having_wines() + serializer_class = serializers.WineRegionSerializer + + class RegionDestroyView(RegionViewMixin, generics.DestroyAPIView): """Destroy view for model Country""" serializer_class = serializers.CountrySerializer diff --git a/apps/main/admin.py b/apps/main/admin.py index 057515e8..9ec76164 100644 --- a/apps/main/admin.py +++ b/apps/main/admin.py @@ -40,3 +40,8 @@ class CarouselAdmin(admin.ModelAdmin): @admin.register(models.PageType) class PageTypeAdmin(admin.ModelAdmin): """PageType admin.""" + + +@admin.register(models.Page) +class PageAdmin(admin.ModelAdmin): + """Page admin.""" diff --git a/apps/main/filters.py b/apps/main/filters.py new file mode 100644 index 00000000..72636d9e --- /dev/null +++ b/apps/main/filters.py @@ -0,0 +1,40 @@ +from django.core.validators import EMPTY_VALUES +from django_filters import rest_framework as filters + +from main import models + + +class AwardFilter(filters.FilterSet): + """Award filter set.""" + + establishment_id = filters.NumberFilter(field_name='object_id', ) + product_id = filters.NumberFilter(field_name='object_id', ) + employee_id = filters.NumberFilter(field_name='object_id', ) + + class Meta: + """Meta class.""" + + model = models.Award + fields = ( + 'establishment_id', + 'product_id', + 'employee_id', + 'state', + 'award_type', + 'vintage_year', + ) + + def by_establishment_id(self, queryset, name, value): + if value not in EMPTY_VALUES: + return queryset.by_establishment_id(value, content_type='establishment') + return queryset + + def by_product_id(self, queryset, name, value): + if value not in EMPTY_VALUES: + return queryset.by_product_id(value, content_type='product') + return queryset + + def by_employee_id(self, queryset, name, value): + if value not in EMPTY_VALUES: + return queryset.by_employee_id(value, content_type='establishmentemployee') + return queryset diff --git a/apps/main/models.py b/apps/main/models.py index 1bd39a6d..61a4d447 100644 --- a/apps/main/models.py +++ b/apps/main/models.py @@ -153,7 +153,7 @@ class Award(TranslatedFieldsMixin, URLImageMixin, models.Model): PUBLISHED = 1 STATE_CHOICES = ( - (WAITING,'waiting'), + (WAITING, 'waiting'), (PUBLISHED, 'published') ) diff --git a/apps/main/serializers.py b/apps/main/serializers.py index 0ed2f026..572aff31 100644 --- a/apps/main/serializers.py +++ b/apps/main/serializers.py @@ -38,7 +38,7 @@ class SiteFeatureSerializer(serializers.ModelSerializer): 'route', 'source', 'nested', - ) + ) class CurrencySerializer(ProjectModelSerializer): @@ -145,6 +145,19 @@ class AwardSerializer(AwardBaseSerializer): fields = AwardBaseSerializer.Meta.fields + ['award_type', ] +class BackAwardSerializer(AwardBaseSerializer): + """Award serializer.""" + + class Meta: + model = models.Award + fields = AwardBaseSerializer.Meta.fields + [ + 'award_type', + 'state', + 'content_type', + 'object_id', + ] + + class CarouselListSerializer(serializers.ModelSerializer): """Serializer for retrieving list of carousel items.""" @@ -186,7 +199,11 @@ class PageBaseSerializer(serializers.ModelSerializer): 'image_url', 'width', 'height', + 'advertisement', ] + extra_kwargs = { + 'establishment': {'write_only': True} + } class PageTypeBaseSerializer(serializers.ModelSerializer): @@ -198,4 +215,4 @@ class PageTypeBaseSerializer(serializers.ModelSerializer): fields = [ 'id', 'name', - ] \ No newline at end of file + ] diff --git a/apps/main/tests.py b/apps/main/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/apps/main/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/main/tests/__init__.py b/apps/main/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/main/tests/tests_back.py b/apps/main/tests/tests_back.py new file mode 100644 index 00000000..e09c7b4b --- /dev/null +++ b/apps/main/tests/tests_back.py @@ -0,0 +1,73 @@ +from http.cookies import SimpleCookie + +from django.contrib.contenttypes.models import ContentType +from rest_framework import status +from rest_framework.test import APITestCase + +from account.models import User +from location.models import Country +from main.models import Award, AwardType + + +class AwardTestCase(APITestCase): + + def setUp(self): + self.user = User.objects.create_user( + username='alex', + email='alex@mail.com', + password='alex_password', + is_staff=True, + ) + + # get tokens + tokens = User.create_jwt_tokens(self.user) + self.client.cookies = SimpleCookie( + {'access_token': tokens.get('access_token'), + 'refresh_token': tokens.get('refresh_token')}) + + self.country_ru = Country.objects.create( + name={'en-GB': 'Russian'}, + code='RU', + ) + + self.content_type = ContentType.objects.get(app_label="establishment", model="establishment") + + self.award_type = AwardType.objects.create( + country=self.country_ru, + name="Test award type", + ) + + self.award = Award.objects.create( + award_type=self.award_type, + vintage_year='2017', + state=Award.PUBLISHED, + object_id=1, + content_type_id=1, + ) + + def test_award_CRUD(self): + response = self.client.get('/api/back/main/awards/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = { + 'award_type': self.award_type.pk, + 'state': 1, + 'object_id': 1, + 'content_type': 1, + } + + response = self.client.post('/api/back/main/awards/', data=data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.get(f'/api/back/main/awards/{self.award.id}/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + update_data = { + 'vintage_year': '2019' + } + + response = self.client.patch(f'/api/back/main/awards/{self.award.id}/', data=update_data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.delete(f'/api/back/main/awards/{self.award.id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) diff --git a/apps/main/urls/back.py b/apps/main/urls/back.py new file mode 100644 index 00000000..d92bddf8 --- /dev/null +++ b/apps/main/urls/back.py @@ -0,0 +1,11 @@ +"""Back main URLs""" +from django.urls import path + +from main.views import back as views + +app_name = 'main' + +urlpatterns = [ + path('awards/', views.AwardLstView.as_view(), name='awards-list-create'), + path('awards//', views.AwardRUDView.as_view(), name='awards-rud'), +] diff --git a/apps/main/views/back.py b/apps/main/views/back.py new file mode 100644 index 00000000..bbbfad53 --- /dev/null +++ b/apps/main/views/back.py @@ -0,0 +1,21 @@ +from rest_framework import generics, permissions + +from main import serializers +from main.filters import AwardFilter +from main.models import Award + + +class AwardLstView(generics.ListCreateAPIView): + """Award list create view.""" + queryset = Award.objects.all() + serializer_class = serializers.BackAwardSerializer + permission_classes = (permissions.IsAdminUser,) + filterset_class = AwardFilter + + +class AwardRUDView(generics.RetrieveUpdateDestroyAPIView): + """Award RUD view.""" + queryset = Award.objects.all() + serializer_class = serializers.BackAwardSerializer + permission_classes = (permissions.IsAdminUser,) + lookup_field = 'id' diff --git a/apps/news/admin.py b/apps/news/admin.py index cc48a887..7687fa92 100644 --- a/apps/news/admin.py +++ b/apps/news/admin.py @@ -24,12 +24,19 @@ def send_email_action(modeladmin, request, queryset): send_email_action.short_description = "Send the selected news by email" +class NewsGalleryInline(admin.TabularInline): + """News gallery inline.""" + model = models.NewsGallery + extra = 0 + + @admin.register(models.News) class NewsAdmin(BaseModelAdminMixin, admin.ModelAdmin): """News admin.""" raw_id_fields = ('address',) actions = [send_email_action] raw_id_fields = ('news_type', 'address', 'country') + inlines = [NewsGalleryInline, ] @admin.register(models.NewsGallery) diff --git a/apps/news/filters.py b/apps/news/filters.py index 084c570f..6ade7eeb 100644 --- a/apps/news/filters.py +++ b/apps/news/filters.py @@ -16,6 +16,9 @@ class NewsListFilterSet(filters.FilterSet): ), method='by_tag_group' ) + tag_value__exclude = filters.CharFilter(method='exclude_tags') + tag_value__in = filters.CharFilter(method='in_tags') + type = filters.CharFilter(method='by_type') class Meta: """Meta class""" @@ -24,8 +27,18 @@ class NewsListFilterSet(filters.FilterSet): 'title', 'is_highlighted', 'tag_group', + 'tag_value__exclude', + 'tag_value__in', ) + def in_tags(self, queryset, name, value): + tags = value.split('__') + return queryset.filter(tags__value__in=tags) + + def exclude_tags(self, queryset, name, value): + tags = value.split('__') + return queryset.exclude(tags__value__in=tags) + def by_tag_group(self, queryset, name, value): if value == models.News.RECIPES_TAG_VALUE: queryset = queryset.recipe_news() @@ -39,3 +52,9 @@ class NewsListFilterSet(filters.FilterSet): return queryset.filter(**filters) else: return queryset + + def by_type(self, queryset, name, value): + if value: + return queryset.filter(news_type__name=value) + else: + return queryset diff --git a/apps/news/models.py b/apps/news/models.py index 550d5baa..d0e79c64 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -7,8 +7,10 @@ from django.utils.translation import gettext_lazy as _ from rest_framework.reverse import reverse from rating.models import Rating, ViewCount -from utils.models import BaseAttributes, TJSONField, TranslatedFieldsMixin, ProjectBaseMixin +from utils.models import (BaseAttributes, TJSONField, TranslatedFieldsMixin, HasTagsMixin, + ProjectBaseMixin, GalleryModelMixin, IntermediateGalleryModelMixin) from utils.querysets import TranslationQuerysetMixin +from django.conf import settings class Agenda(ProjectBaseMixin, TranslatedFieldsMixin): @@ -86,6 +88,10 @@ class NewsQuerySet(TranslationQuerysetMixin): """Returns news with tag 'cook' qs.""" return self.filter(tags__value=News.RECIPES_TAG_VALUE) + def international_news(self): + """Returns only international news qs.""" + return self.filter(tags__value=News.INTERNATIONAL_TAG_VALUE) + def published(self): """Return only published news""" now = timezone.now() @@ -120,7 +126,7 @@ class NewsQuerySet(TranslationQuerysetMixin): ) -class News(BaseAttributes, TranslatedFieldsMixin): +class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixin): """News model.""" STR_FIELD_NAME = 'title' @@ -151,6 +157,7 @@ class News(BaseAttributes, TranslatedFieldsMixin): (PUBLISHED_EXCLUSIVE, _('Published exclusive')), ) + INTERNATIONAL_TAG_VALUE = 'international' RECIPES_TAG_VALUE = 'cook' old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None) @@ -242,16 +249,51 @@ class News(BaseAttributes, TranslatedFieldsMixin): count_value = self.views_count.count return count_value + # todo: remove in future + @property + def crop_gallery(self): + if hasattr(self, 'gallery'): + gallery = [] + images = self.gallery.all() + model_name = self._meta.model_name.lower() + crop_parameters = [p for p in settings.SORL_THUMBNAIL_ALIASES + if p.startswith(model_name)] + for image in images: + d = { + 'id': image.id, + 'title': image.title, + 'original_url': image.image.url, + 'orientation_display': image.get_orientation_display(), + 'auto_crop_images': {}, + } + for crop in crop_parameters: + d['auto_crop_images'].update( + {f'{crop[len(f"{model_name}_"):]}_url': image.get_image_url(crop)}) + gallery.append(d) + return gallery -class NewsGalleryQuerySet(models.QuerySet): - """QuerySet for model News""" - - def main_image(self): - """Return objects with flag is_main is True""" - return self.filter(is_main=True) + @property + def crop_main_image(self): + if hasattr(self, 'main_image') and self.main_image: + image = self.main_image + model_name = self._meta.model_name.lower() + 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( + {f'{crop[len(f"{model_name}_"):]}_url': image.get_image_url(crop)}) + return image_property -class NewsGallery(models.Model): +class NewsGallery(IntermediateGalleryModelMixin): + news = models.ForeignKey(News, null=True, related_name='news_gallery', on_delete=models.CASCADE, @@ -260,10 +302,6 @@ class NewsGallery(models.Model): related_name='news_gallery', on_delete=models.CASCADE, verbose_name=_('gallery')) - is_main = models.BooleanField(default=False, - verbose_name=_('Is the main image')) - - objects = NewsGalleryQuerySet.as_manager() class Meta: """NewsGallery meta class.""" diff --git a/apps/news/serializers.py b/apps/news/serializers.py index 7638847f..4eaeaeb4 100644 --- a/apps/news/serializers.py +++ b/apps/news/serializers.py @@ -10,7 +10,8 @@ from location.serializers import CountrySimpleSerializer, AddressBaseSerializer from news import models from tag.serializers import TagBaseSerializer from utils import exceptions as utils_exceptions -from utils.serializers import TranslatedField, ProjectModelSerializer, FavoritesCreateSerializer +from utils.serializers import (TranslatedField, ProjectModelSerializer, + FavoritesCreateSerializer, ImageBaseSerializer) class AgendaSerializer(ProjectModelSerializer): @@ -47,78 +48,6 @@ class NewsBannerSerializer(ProjectModelSerializer): ) -class CropImageSerializer(serializers.Serializer): - """Serializer for crop images for News object.""" - - preview_url = serializers.SerializerMethodField() - promo_horizontal_web_url = serializers.SerializerMethodField() - promo_horizontal_mobile_url = serializers.SerializerMethodField() - tile_horizontal_web_url = serializers.SerializerMethodField() - tile_horizontal_mobile_url = serializers.SerializerMethodField() - tile_vertical_web_url = serializers.SerializerMethodField() - highlight_vertical_web_url = serializers.SerializerMethodField() - editor_web_url = serializers.SerializerMethodField() - editor_mobile_url = serializers.SerializerMethodField() - - def get_preview_url(self, obj): - """Get crop preview.""" - return obj.instance.get_image_url('news_preview') - - def get_promo_horizontal_web_url(self, obj): - """Get crop promo_horizontal_web.""" - return obj.instance.get_image_url('news_promo_horizontal_web') - - def get_promo_horizontal_mobile_url(self, obj): - """Get crop promo_horizontal_mobile.""" - return obj.instance.get_image_url('news_promo_horizontal_mobile') - - def get_tile_horizontal_web_url(self, obj): - """Get crop tile_horizontal_web.""" - return obj.instance.get_image_url('news_tile_horizontal_web') - - def get_tile_horizontal_mobile_url(self, obj): - """Get crop tile_horizontal_mobile.""" - return obj.instance.get_image_url('news_tile_horizontal_mobile') - - def get_tile_vertical_web_url(self, obj): - """Get crop tile_vertical_web.""" - return obj.instance.get_image_url('news_tile_vertical_web') - - def get_highlight_vertical_web_url(self, obj): - """Get crop highlight_vertical_web.""" - return obj.instance.get_image_url('news_highlight_vertical_web') - - def get_editor_web_url(self, obj): - """Get crop editor_web.""" - return obj.instance.get_image_url('news_editor_web') - - def get_editor_mobile_url(self, obj): - """Get crop editor_mobile.""" - return obj.instance.get_image_url('news_editor_mobile') - - -class NewsImageSerializer(serializers.ModelSerializer): - """Serializer for returning crop images of news image.""" - - orientation_display = serializers.CharField(source='get_orientation_display', - read_only=True) - original_url = serializers.URLField(source='image.url') - auto_crop_images = CropImageSerializer(source='image', allow_null=True) - - class Meta: - model = Image - fields = [ - 'id', - 'title', - 'orientation_display', - 'original_url', - 'auto_crop_images', - ] - extra_kwargs = { - 'orientation': {'write_only': True} - } - - class NewsTypeSerializer(serializers.ModelSerializer): """News type serializer.""" @@ -135,7 +64,7 @@ class NewsBaseSerializer(ProjectModelSerializer): title_translated = TranslatedField() subtitle_translated = TranslatedField() news_type = NewsTypeSerializer(read_only=True) - tags = TagBaseSerializer(read_only=True, many=True) + tags = TagBaseSerializer(read_only=True, many=True, source='visible_tags') in_favorites = serializers.BooleanField(allow_null=True) view_counter = serializers.IntegerField(read_only=True) @@ -170,7 +99,7 @@ class NewsSimilarListSerializer(NewsBaseSerializer): class NewsListSerializer(NewsBaseSerializer): """List serializer for News model.""" - image = NewsImageSerializer(source='main_image', allow_null=True) + image = ImageBaseSerializer(source='crop_main_image', allow_null=True) class Meta(NewsBaseSerializer.Meta): """Meta class.""" @@ -188,7 +117,7 @@ class NewsDetailSerializer(NewsBaseSerializer): author = UserBaseSerializer(source='created_by', read_only=True) state_display = serializers.CharField(source='get_state_display', read_only=True) - gallery = NewsImageSerializer(read_only=True, many=True) + gallery = ImageBaseSerializer(read_only=True, source='crop_gallery', many=True) class Meta(NewsBaseSerializer.Meta): """Meta class.""" diff --git a/apps/news/views.py b/apps/news/views.py index fbc0e455..638f208b 100644 --- a/apps/news/views.py +++ b/apps/news/views.py @@ -1,15 +1,13 @@ """News app views.""" from django.conf import settings -from django.db.transaction import on_commit from django.shortcuts import get_object_or_404 -from rest_framework import generics, permissions, status -from rest_framework.response import Response +from rest_framework import generics, permissions -from gallery.tasks import delete_image from news import filters, models, serializers from rating.tasks import add_rating from utils.permissions import IsCountryAdmin, IsContentPageManager from utils.views import CreateDestroyGalleryViewMixin +from utils.serializers import ImageBaseSerializer class NewsMixinView: @@ -18,7 +16,7 @@ class NewsMixinView: permission_classes = (permissions.AllowAny,) serializer_class = serializers.NewsBaseSerializer - def get_queryset(self): + def get_queryset(self, *args, **kwargs): """Override get_queryset method.""" qs = models.News.objects.published() \ .with_base_related() \ @@ -27,7 +25,10 @@ class NewsMixinView: country_code = self.request.country_code if country_code: - qs = qs.by_country_code(country_code) + if kwargs.get('international_preferred') and country_code in settings.INTERNATIONAL_COUNTRY_CODES: + qs = qs.international_news() + else: + qs = qs.by_country_code(country_code) return qs @@ -37,6 +38,10 @@ class NewsListView(NewsMixinView, generics.ListAPIView): serializer_class = serializers.NewsListSerializer filter_class = filters.NewsListFilterSet + def get_queryset(self, *args, **kwargs): + kwargs.update({'international_preferred': True}) + return super().get_queryset(*args, **kwargs) + class NewsDetailView(NewsMixinView, generics.RetrieveAPIView): """News detail view.""" @@ -77,6 +82,7 @@ class NewsBackOfficeLCView(NewsBackOfficeMixinView, """Resource for a list of news for back-office users.""" serializer_class = serializers.NewsBackOfficeBaseSerializer + filter_class = filters.NewsListFilterSet create_serializers_class = serializers.NewsBackOfficeDetailSerializer permission_classes = [IsCountryAdmin | IsContentPageManager] @@ -102,8 +108,8 @@ class NewsBackOfficeGalleryCreateDestroyView(NewsBackOfficeMixinView, """ news_qs = self.filter_queryset(self.get_queryset()) - news = get_object_or_404(news_qs, pk=self.kwargs['pk']) - gallery = get_object_or_404(news.news_gallery, image_id=self.kwargs['image_id']) + news = get_object_or_404(news_qs, pk=self.kwargs.get('pk')) + gallery = get_object_or_404(news.news_gallery, image_id=self.kwargs.get('image_id')) # May raise a permission denied self.check_object_permissions(self.request, gallery) @@ -111,14 +117,15 @@ class NewsBackOfficeGalleryCreateDestroyView(NewsBackOfficeMixinView, return gallery -class NewsBackOfficeGalleryListView(NewsBackOfficeMixinView, generics.ListAPIView): +class NewsBackOfficeGalleryListView(NewsBackOfficeMixinView, + generics.ListAPIView): """Resource for returning gallery for news for back-office users.""" - serializer_class = serializers.NewsImageSerializer + serializer_class = ImageBaseSerializer def get_object(self): """Override get_object method.""" qs = super(NewsBackOfficeGalleryListView, self).get_queryset() - news = get_object_or_404(qs, pk=self.kwargs['pk']) + news = get_object_or_404(qs, pk=self.kwargs.get('pk')) # May raise a permission denied self.check_object_permissions(self.request, news) @@ -127,7 +134,7 @@ class NewsBackOfficeGalleryListView(NewsBackOfficeMixinView, generics.ListAPIVie def get_queryset(self): """Override get_queryset method.""" - return self.get_object().gallery.all() + return self.get_object().crop_gallery class NewsBackOfficeRUDView(NewsBackOfficeMixinView, @@ -153,7 +160,7 @@ class NewsFavoritesCreateDestroyView(generics.CreateAPIView, generics.DestroyAPI """ Returns the object the view is displaying. """ - news = get_object_or_404(models.News, slug=self.kwargs['slug']) + news = get_object_or_404(models.News, slug=self.kwargs.get('slug')) favorites = get_object_or_404(news.favorites.filter(user=self.request.user)) # May raise a permission denied self.check_object_permissions(self.request, favorites) diff --git a/apps/notification/migrations/0003_auto_20191116_1248.py b/apps/notification/migrations/0003_auto_20191116_1248.py new file mode 100644 index 00000000..2af6a7ae --- /dev/null +++ b/apps/notification/migrations/0003_auto_20191116_1248.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.7 on 2019-11-16 12:48 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('notification', '0002_subscriber_old_id'), + ] + + operations = [ + migrations.AlterField( + model_name='subscriber', + name='user', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscriber', to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + ] diff --git a/apps/notification/models.py b/apps/notification/models.py index 3e6f7f3a..a5cde50b 100644 --- a/apps/notification/models.py +++ b/apps/notification/models.py @@ -74,7 +74,7 @@ class Subscriber(ProjectBaseMixin): (USABLE, _('Usable')), ) - user = models.OneToOneField( + user = models.ForeignKey( User, blank=True, null=True, diff --git a/apps/notification/transfer_data.py b/apps/notification/transfer_data.py index 487501c3..1712b6ea 100644 --- a/apps/notification/transfer_data.py +++ b/apps/notification/transfer_data.py @@ -1,7 +1,5 @@ from pprint import pprint -from django.db.models import Count - from transfer.models import EmailAddresses, NewsletterSubscriber from transfer.serializers.notification import SubscriberSerializer, NewsletterSubscriberSerializer @@ -25,14 +23,14 @@ def transfer_newsletter_subscriber(): 'email_address__ip', 'email_address__country_code', 'email_address__locale', - 'created_at', + 'updated_at', ) - # serialized_data = NewsletterSubscriberSerializer(data=list(queryset.values()), many=True) - # if serialized_data.is_valid(): - # serialized_data.save() - # else: - # pprint(f'NewsletterSubscriber serializer errors: {serialized_data.errors}') + serialized_data = NewsletterSubscriberSerializer(data=list(queryset), many=True) + if serialized_data.is_valid(): + serialized_data.save() + else: + pprint(f'NewsletterSubscriber serializer errors: {serialized_data.errors}') data_types = { diff --git a/apps/partner/admin.py b/apps/partner/admin.py index f2973f75..9161837b 100644 --- a/apps/partner/admin.py +++ b/apps/partner/admin.py @@ -6,3 +6,4 @@ from partner import models @admin.register(models.Partner) class PartnerModelAdmin(admin.ModelAdmin): """Model admin for Partner model.""" + raw_id_fields = ('establishment',) diff --git a/apps/partner/migrations/0003_auto_20191121_1059.py b/apps/partner/migrations/0003_auto_20191121_1059.py new file mode 100644 index 00000000..c2e98fbe --- /dev/null +++ b/apps/partner/migrations/0003_auto_20191121_1059.py @@ -0,0 +1,50 @@ +# Generated by Django 2.2.7 on 2019-11-21 10:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('establishment', '0065_establishment_purchased_products'), + ('partner', '0002_auto_20191101_0939'), + ] + + operations = [ + migrations.AddField( + model_name='partner', + name='establishment', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='partners', to='establishment.Establishment', verbose_name='Establishment'), + ), + migrations.AddField( + model_name='partner', + name='expiry_date', + field=models.DateField(blank=True, null=True, verbose_name='expiry date'), + ), + migrations.AddField( + model_name='partner', + name='name', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='name'), + ), + migrations.AddField( + model_name='partner', + name='old_id', + field=models.PositiveIntegerField(blank=True, default=None, null=True, verbose_name='old id'), + ), + migrations.AddField( + model_name='partner', + name='price_per_month', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='price per month'), + ), + migrations.AddField( + model_name='partner', + name='starting_date', + field=models.DateField(blank=True, null=True, verbose_name='starting date'), + ), + migrations.AddField( + model_name='partner', + name='type', + field=models.PositiveSmallIntegerField(choices=[(0, 'Partner'), (1, 'Sponsor')], default=0), + ), + ] diff --git a/apps/partner/models.py b/apps/partner/models.py index 1eb70845..5c5766c8 100644 --- a/apps/partner/models.py +++ b/apps/partner/models.py @@ -1,13 +1,36 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from establishment.models import Establishment from utils.models import ImageMixin, ProjectBaseMixin class Partner(ProjectBaseMixin): """Partner model.""" + + PARTNER = 0 + SPONSOR = 1 + MODEL_TYPES = ( + (PARTNER, _('Partner')), + (SPONSOR, _('Sponsor')), + ) + + old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None) + name = models.CharField(_('name'), max_length=255, blank=True, null=True) url = models.URLField(verbose_name=_('Partner URL')) image = models.URLField(verbose_name=_('Partner image URL'), null=True) + establishment = models.ForeignKey( + Establishment, + verbose_name=_('Establishment'), + related_name='partners', + on_delete=models.CASCADE, + blank=True, + null=True, + ) + type = models.PositiveSmallIntegerField(choices=MODEL_TYPES, default=PARTNER) + starting_date = models.DateField(_('starting date'), blank=True, null=True) + expiry_date = models.DateField(_('expiry date'), blank=True, null=True) + price_per_month = models.DecimalField(_('price per month'), max_digits=10, decimal_places=2, blank=True, null=True) class Meta: verbose_name = _('partner') diff --git a/apps/partner/transfer_data.py b/apps/partner/transfer_data.py index e3219838..868345a8 100644 --- a/apps/partner/transfer_data.py +++ b/apps/partner/transfer_data.py @@ -1,19 +1,32 @@ -from django.db.models import Value, IntegerField, F from pprint import pprint + +from establishment.models import Establishment from transfer.models import EstablishmentBacklinks from transfer.serializers.partner import PartnerSerializer def transfer_partner(): - queryset = EstablishmentBacklinks.objects.filter(type="Partner") + establishments = Establishment.objects.filter(old_id__isnull=False).values_list('old_id', flat=True) + queryset = EstablishmentBacklinks.objects.filter( + establishment_id__in=list(establishments), + ).values( + 'id', + 'establishment_id', + 'partnership_name', + 'partnership_icon', + 'backlink_url', + 'created_at', + 'type', + 'starting_date', + 'expiry_date', + 'price_per_month', + ) - # queryset = EstablishmentBacklinks.objects.all() # Partner and Sponsor - - serialized_data = PartnerSerializer(data=list(queryset.values()), many=True) + serialized_data = PartnerSerializer(data=list(queryset), many=True) if serialized_data.is_valid(): serialized_data.save() else: - pprint(f"News serializer errors: {serialized_data.errors}") + pprint(f"Partner serializer errors: {serialized_data.errors}") data_types = { diff --git a/apps/product/admin.py b/apps/product/admin.py index dafbdbcb..3becfb2d 100644 --- a/apps/product/admin.py +++ b/apps/product/admin.py @@ -4,22 +4,23 @@ from utils.admin import BaseModelAdminMixin from .models import Product, ProductType, ProductSubType, ProductGallery, Unit +class ProductGalleryInline(admin.TabularInline): + """Product gallery inline.""" + model = ProductGallery + extra = 0 + + @admin.register(Product) class ProductAdmin(BaseModelAdminMixin, admin.ModelAdmin): """Admin page for model Product.""" search_fields = ('name', ) list_filter = ('available', 'product_type') list_display = ('id', '__str__', 'get_category_display', 'product_type') + inlines = [ProductGalleryInline, ] raw_id_fields = ('subtypes', 'classifications', 'standards', 'tags', 'gallery', 'establishment',) -@admin.register(ProductGallery) -class ProductGalleryAdmin(admin.ModelAdmin): - """Admin page for model ProductGallery.""" - raw_id_fields = ('product', 'image', ) - - @admin.register(ProductType) class ProductTypeAdmin(admin.ModelAdmin): """Admin page for model ProductType.""" diff --git a/apps/product/management/commands/add_average_price.py b/apps/product/management/commands/add_average_price.py new file mode 100644 index 00000000..38024d81 --- /dev/null +++ b/apps/product/management/commands/add_average_price.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand + +from product.models import Product +from transfer.models import Products + + +class Command(BaseCommand): + help = """Add average price to product from legacy table products.""" + + def handle(self, *args, **kwarg): + update_products = [] + old_products = Products.objects.values_list('id', 'price') + for old_id, price in old_products: + product = Product.objects.get(old_id=old_id) + product.average_price = price + update_products.append(product) + + Product.objects.bulk_update(update_products, ['average_price', ]) + self.stdout.write(self.style.WARNING(f'Updated products: {len(update_products)}')) diff --git a/apps/product/management/commands/add_product_tag.py b/apps/product/management/commands/add_product_tag.py index ee4829f5..6377fcac 100644 --- a/apps/product/management/commands/add_product_tag.py +++ b/apps/product/management/commands/add_product_tag.py @@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand from django.db import connections from establishment.management.commands.add_position import namedtuplefetchall from tag.models import Tag, TagCategory -from product.models import Product +from product.models import Product, ProductType from tqdm import tqdm @@ -26,24 +26,47 @@ class Command(BaseCommand): def add_category_tag(self): objects = [] for c in tqdm(self.category_sql(), desc='Add category tags'): - categories = TagCategory.objects.filter(index_name=c.category - ) + categories = TagCategory.objects.filter(index_name=c.category) if not categories.exists(): objects.append( TagCategory(label={"en-GB": c.category}, value_type=c.value_type, - index_name=c.category + index_name=c.category, + public=True ) ) + else: + categories.update(public=True) TagCategory.objects.bulk_create(objects) self.stdout.write(self.style.WARNING(f'Add or get tag category objects.')) + def product_type_category_sql(self): + with connections['legacy'].cursor() as cursor: + cursor.execute(''' + select + DISTINCT + trim(CONVERT(v.key_name USING utf8)) as tag_category + FROM product_metadata m + join product_key_value_metadata v on v.id = m.product_key_value_metadatum_id + join products p on p.id = m.product_id + where UPPER(trim(p.type)) = 'WINE' + ''') + return namedtuplefetchall(cursor) + + def add_type_product_category(self): + for c in tqdm(self.product_type_category_sql(), desc='Add type product category'): + type = ProductType.objects.get(index_name=ProductType.WINE) + category = TagCategory.objects.get(index_name=c.tag_category) + if category not in type.tag_categories.all(): + type.tag_categories.add(category) + + self.stdout.write(self.style.WARNING(f'Add type product category objects.')) + def tag_sql(self): with connections['legacy'].cursor() as cursor: cursor.execute(''' select - DISTINCT - m.id as old_id, + DISTINCT trim(CONVERT(m.value USING utf8)) as tag_value, trim(CONVERT(v.key_name USING utf8)) as tag_category FROM product_metadata m @@ -64,24 +87,35 @@ class Command(BaseCommand): if not tags.exists(): objects.append(Tag(label={"en-GB": t.tag_value}, category=category, - value=t.tag_value, - old_id_meta_product=t.old_id - )) - else: - qs = tags.filter(old_id_meta_product__isnull=True)\ - .update(old_id_meta_product=t.old_id) + value=t.tag_value) + ) + Tag.objects.bulk_create(objects) self.stdout.write(self.style.WARNING(f'Add or get tag objects.')) + def remove_tags_product(self): + print('Begin clear tags product') + products = Product.objects.all() + for p in tqdm(products, desc='Clear tags product'): + p.tags.clear() + print('End clear tags product') + + + def remove_tags(self): + print('Begin delete many tags') + Tag.objects.\ + filter(news__isnull=True, establishments__isnull=True).delete() + print('End delete many tags') + + def product_sql(self): with connections['legacy'].cursor() as cursor: cursor.execute(''' select - DISTINCT - m.id as old_id_tag, + DISTINCT m.product_id, - lower(trim(CONVERT(m.value USING utf8))) as tag_value, + trim(CONVERT(m.value USING utf8)) as tag_value, trim(CONVERT(v.key_name USING utf8)) as tag_category FROM product_metadata m JOIN product_key_value_metadata v on v.id = m.product_key_value_metadatum_id @@ -90,10 +124,15 @@ class Command(BaseCommand): def add_product_tag(self): for t in tqdm(self.product_sql(), desc='Add product tag'): - tags = Tag.objects.filter(old_id_meta_product=t.old_id_tag) + category = TagCategory.objects.get(index_name=t.tag_category) + + tags = Tag.objects.filter( + category=category, + value=t.tag_value + ) product = Product.objects.get(old_id=t.product_id) for tag in tags: - if product not in tag.products.all(): + if tag not in product.tags.all(): product.tags.add(tag) self.stdout.write(self.style.WARNING(f'Add or get tag objects.')) @@ -111,7 +150,10 @@ class Command(BaseCommand): tag.save() def handle(self, *args, **kwargs): + self.remove_tags_product() + self.remove_tags() self.add_category_tag() + self.add_type_product_category() self.add_tag() self.check_tag() self.add_product_tag() diff --git a/apps/product/migrations/0014_auto_20191117_1117.py b/apps/product/migrations/0014_auto_20191117_1117.py new file mode 100644 index 00000000..70476eb5 --- /dev/null +++ b/apps/product/migrations/0014_auto_20191117_1117.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.7 on 2019-11-17 11:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0013_auto_20191113_1512'), + ] + + operations = [ + migrations.AlterField( + model_name='productgallery', + name='image', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='product_gallery', to='gallery.Image', verbose_name='image'), + ), + ] diff --git a/apps/product/migrations/0015_auto_20191117_1954.py b/apps/product/migrations/0015_auto_20191117_1954.py new file mode 100644 index 00000000..78fbfee0 --- /dev/null +++ b/apps/product/migrations/0015_auto_20191117_1954.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-11-17 19:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0014_auto_20191117_1117'), + ] + + operations = [ + migrations.AlterField( + model_name='producttype', + name='tag_categories', + field=models.ManyToManyField(related_name='product_types', to='tag.TagCategory', verbose_name='Tag categories'), + ), + ] diff --git a/apps/product/migrations/0016_product_average_price.py b/apps/product/migrations/0016_product_average_price.py new file mode 100644 index 00000000..983e8072 --- /dev/null +++ b/apps/product/migrations/0016_product_average_price.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-11-19 13:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0015_auto_20191117_1954'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='average_price', + field=models.DecimalField(blank=True, decimal_places=2, default=None, max_digits=14, null=True, verbose_name='average price'), + ), + ] diff --git a/apps/product/migrations/0017_auto_20191119_1546.py b/apps/product/migrations/0017_auto_20191119_1546.py new file mode 100644 index 00000000..38eff085 --- /dev/null +++ b/apps/product/migrations/0017_auto_20191119_1546.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.7 on 2019-11-19 15:46 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0016_product_average_price'), + ] + + operations = [ + migrations.AlterField( + model_name='productnote', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='notes', to='product.Product', verbose_name='product'), + ), + ] diff --git a/apps/product/migrations/0018_purchasedproduct.py b/apps/product/migrations/0018_purchasedproduct.py new file mode 100644 index 00000000..b1c23646 --- /dev/null +++ b/apps/product/migrations/0018_purchasedproduct.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.7 on 2019-11-20 12:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('establishment', '0064_auto_20191119_1546'), + ('product', '0017_auto_20191119_1546'), + ] + + operations = [ + migrations.CreateModel( + name='PurchasedProduct', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_gifted', models.NullBooleanField(default=None, verbose_name='is gifted')), + ('quantity', models.PositiveSmallIntegerField(verbose_name='quantity')), + ('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchased_plaques', to='establishment.Establishment', verbose_name='establishment')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchased_by_establishments', to='product.Product', verbose_name='plaque')), + ], + options={ + 'verbose_name': 'purchased plaque', + 'verbose_name_plural': 'purchased plaques', + 'unique_together': {('establishment', 'product')}, + }, + ), + ] diff --git a/apps/product/models.py b/apps/product/models.py index ba9ccd38..f499afee 100644 --- a/apps/product/models.py +++ b/apps/product/models.py @@ -7,8 +7,9 @@ from django.db.models import Case, When from django.utils.translation import gettext_lazy as _ from django.core.validators import MaxValueValidator, MinValueValidator -from utils.models import (BaseAttributes, ProjectBaseMixin, - TranslatedFieldsMixin, TJSONField) +from utils.models import (BaseAttributes, ProjectBaseMixin, HasTagsMixin, + TranslatedFieldsMixin, TJSONField, + GalleryModelMixin, IntermediateGalleryModelMixin) class ProductType(TranslatedFieldsMixin, ProjectBaseMixin): @@ -30,7 +31,7 @@ class ProductType(TranslatedFieldsMixin, ProjectBaseMixin): use_subtypes = models.BooleanField(_('Use subtypes'), default=True) tag_categories = models.ManyToManyField('tag.TagCategory', related_name='product_types', - verbose_name=_('Tag')) + verbose_name=_('Tag categories')) class Meta: """Meta class.""" @@ -82,7 +83,12 @@ class ProductQuerySet(models.QuerySet): def with_extended_related(self): """Returns qs with almost all related objects.""" return self.with_base_related() \ - .prefetch_related('tags', 'standards', 'classifications', 'classifications__standard', + .prefetch_related('tags', 'tags__category', 'tags__category__country', + 'standards', 'classifications', 'classifications__standard', + 'establishment__address', 'establishment__establishment_type', + 'establishment__address__city', 'establishment__address__city__country', + 'establishment__establishment_subtypes', 'product_gallery', + 'gallery', 'product_type', 'subtypes', 'classifications__classification_type', 'classifications__tags') \ .select_related('wine_region', 'wine_sub_region') @@ -125,7 +131,7 @@ class ProductQuerySet(models.QuerySet): ) -class Product(TranslatedFieldsMixin, BaseAttributes): +class Product(GalleryModelMixin, TranslatedFieldsMixin, BaseAttributes, HasTagsMixin): """Product models.""" EARLIEST_VINTAGE_YEAR = 1700 @@ -205,6 +211,9 @@ class Product(TranslatedFieldsMixin, BaseAttributes): null=True, blank=True, default=None, validators=[MinValueValidator(EARLIEST_VINTAGE_YEAR), MaxValueValidator(LATEST_VINTAGE_YEAR)]) + average_price = models.DecimalField(max_digits=14, decimal_places=2, + blank=True, null=True, default=None, + verbose_name=_('average price')) gallery = models.ManyToManyField('gallery.Image', through='ProductGallery') reviews = generic.GenericRelation(to='review.Review') comments = generic.GenericRelation(to='comment.Comment') @@ -222,15 +231,11 @@ class Product(TranslatedFieldsMixin, BaseAttributes): """Override str dunder method.""" return f'{self.name}' - def clean_fields(self, exclude=None): - super().clean_fields(exclude=exclude) - if self.product_type.index_name == ProductType.WINE and not self.wine_region: - raise ValidationError(_('wine_region field must be specified.')) - if not self.product_type.index_name == ProductType.WINE and self.wine_region: - raise ValidationError(_('wine_region field must not be specified.')) - # if (self.wine_region and self.wine_appellation) and \ - # self.wine_appellation not in self.wine_region.appellations.all(): - # raise ValidationError(_('Wine appellation not exists in wine region.')) + def delete(self, using=None, keep_parents=False): + """Overridden delete method""" + # Delete all related notes + self.notes.all().delete() + return super().delete(using, keep_parents) @property def product_type_translated_name(self): @@ -255,33 +260,50 @@ class Product(TranslatedFieldsMixin, BaseAttributes): return self.tags.filter(category__index_name='bottles-produced') @property - def main_image(self): - qs = ProductGallery.objects.filter(product=self, is_main=True) + def grape_variety(self): + return self.tags.filter(category__index_name='grape-variety') + + @property + def bottle_sizes(self): + return self.tags.filter(category__index_name='bottle_size') + + @property + def alcohol_percentage(self): + qs = self.tags.filter(category__index_name='alcohol_percentage') if qs.exists(): - return qs.first().image - - @property - def main_image_url(self): - return self.main_image.image if self.main_image else None - - @property - def preview_main_image_url(self): - return self.main_image.get_image_url('product_preview') if self.main_image else None + return qs.first() @property def related_tags(self): - return self.tags.exclude( - category__index_name__in=['sugar-content', 'wine-color', 'bottles-produced', - 'serial-number', 'grape-variety']) + return super().visible_tags.exclude(category__index_name__in=[ + 'sugar-content', 'wine-color', 'bottles-produced', + 'serial-number', 'grape-variety', 'serial_number', + 'alcohol_percentage', 'bottle_size', + ]) @property def display_name(self): name = f'{self.name} ' \ f'({self.vintage if self.vintage else "BSA"})' - if self.establishment.name: + if self.establishment and self.establishment.name: name = f'{self.establishment.name} - ' + name return name + @property + def main_image(self): + qs = self.product_gallery.main_image() + if qs.exists(): + return qs.first().image + + @property + def image_url(self): + return self.main_image.image.url if self.main_image else None + + @property + def preview_image_url(self): + if self.main_image: + return self.main_image.get_image_url(thumbnail_key='product_preview') + class OnlineProductManager(ProductManager): """Extended manger for OnlineProduct model.""" @@ -304,6 +326,26 @@ class OnlineProduct(Product): verbose_name_plural = _('Online products') +class PurchasedProduct(models.Model): + """Model for storing establishment purchased plaques.""" + + establishment = models.ForeignKey('establishment.Establishment', on_delete=models.CASCADE, + related_name='purchased_plaques', + verbose_name=_('establishment')) + product = models.ForeignKey('product.Product', on_delete=models.CASCADE, + related_name='purchased_by_establishments', + verbose_name=_('plaque')) + is_gifted = models.NullBooleanField(default=None, + verbose_name=_('is gifted')) + quantity = models.PositiveSmallIntegerField(verbose_name=_('quantity')) + + class Meta: + """Meta class.""" + verbose_name = _('purchased plaque') + verbose_name_plural = _('purchased plaques') + unique_together = ('establishment', 'product') + + class Unit(models.Model): """Product unit model.""" name = models.CharField(max_length=255, @@ -353,15 +395,8 @@ class ProductStandard(models.Model): verbose_name = _('wine standard') -class ProductGalleryQuerySet(models.QuerySet): - """QuerySet for model Product""" - - def main_image(self): - """Return objects with flag is_main is True""" - return self.filter(is_main=True) - - -class ProductGallery(models.Model): +class ProductGallery(IntermediateGalleryModelMixin): + """Gallery for model Product.""" product = models.ForeignKey(Product, null=True, related_name='product_gallery', on_delete=models.CASCADE, @@ -369,11 +404,7 @@ class ProductGallery(models.Model): image = models.ForeignKey('gallery.Image', null=True, related_name='product_gallery', on_delete=models.CASCADE, - verbose_name=_('gallery')) - is_main = models.BooleanField(default=False, - verbose_name=_('Is the main image')) - - objects = ProductGalleryQuerySet.as_manager() + verbose_name=_('image')) class Meta: """ProductGallery meta class.""" @@ -439,7 +470,7 @@ class ProductNote(ProjectBaseMixin): old_id = models.PositiveIntegerField(null=True, blank=True) text = models.TextField(verbose_name=_('text')) product = models.ForeignKey(Product, on_delete=models.PROTECT, - related_name='product_notes', + related_name='notes', verbose_name=_('product')) user = models.ForeignKey('account.User', on_delete=models.PROTECT, null=True, diff --git a/apps/product/serializers/back.py b/apps/product/serializers/back.py index 630a815a..ffbf690d 100644 --- a/apps/product/serializers/back.py +++ b/apps/product/serializers/back.py @@ -7,6 +7,7 @@ from product import models from product.serializers import ProductDetailSerializer, ProductTypeBaseSerializer, \ ProductSubTypeBaseSerializer from tag.models import TagCategory +from account.serializers.common import UserShortSerializer class ProductBackOfficeGallerySerializer(serializers.ModelSerializer): @@ -127,3 +128,55 @@ class ProductSubTypeBackOfficeDetailSerializer(ProductSubTypeBaseSerializer): 'name', 'index_name', ] + + +class ProductNoteBaseSerializer(serializers.ModelSerializer): + """Serializer for model ProductNote.""" + + user_detail = UserShortSerializer(read_only=True, source='user') + + class Meta: + """Meta class.""" + model = models.ProductNote + fields = [ + 'id', + 'created', + 'modified', + 'text', + 'user', + 'user_detail', + 'product', + ] + extra_kwargs = { + 'created': {'read_only': True}, + 'modified': {'read_only': True}, + 'product': {'required': False, 'write_only': True}, + 'user': {'required': False, 'write_only': True}, + } + + @property + def serializer_view(self): + """Return view instance.""" + return self.context.get('view') + + +class ProductNoteListCreateSerializer(ProductNoteBaseSerializer): + """Serializer for List|Create action for model ProductNote.""" + + def create(self, validated_data): + """Overridden create method.""" + validated_data['user'] = self.user + validated_data['product'] = self.product + return super().create(validated_data) + + @property + def user(self): + """Return user instance from view.""" + if self.serializer_view: + return self.serializer_view.request.user + + @property + def product(self): + """Return product instance from view.""" + if self.serializer_view: + return self.serializer_view.get_object() diff --git a/apps/product/serializers/common.py b/apps/product/serializers/common.py index da2a2344..14eff642 100644 --- a/apps/product/serializers/common.py +++ b/apps/product/serializers/common.py @@ -4,21 +4,21 @@ from rest_framework import serializers from comment.models import Comment from comment.serializers import CommentSerializer -from establishment.serializers import EstablishmentShortSerializer, EstablishmentProductSerializer +from establishment.serializers import EstablishmentShortSerializer, EstablishmentProductSerializer, EstablishmentProductShortSerializer from gallery.models import Image from product import models from review.serializers import ReviewShortSerializer from utils import exceptions as utils_exceptions -from utils.serializers import TranslatedField, FavoritesCreateSerializer +from utils.serializers import TranslatedField, FavoritesCreateSerializer, ImageBaseSerializer from main.serializers import AwardSerializer from location.serializers import WineRegionBaseSerializer, WineSubRegionBaseSerializer -from tag.serializers import TagBaseSerializer, TagCategoryShortSerializer +from tag.serializers import TagBaseSerializer, TagCategoryProductSerializer class ProductTagSerializer(TagBaseSerializer): """Serializer for model Tag.""" - category = TagCategoryShortSerializer(read_only=True) + category = TagCategoryProductSerializer(read_only=True) class Meta(TagBaseSerializer.Meta): """Meta class.""" @@ -88,12 +88,11 @@ class ProductBaseSerializer(serializers.ModelSerializer): name = serializers.CharField(source='display_name', read_only=True) product_type = ProductTypeBaseSerializer(read_only=True) subtypes = ProductSubTypeBaseSerializer(many=True, read_only=True) - establishment_detail = EstablishmentShortSerializer(source='establishment', read_only=True) + establishment_detail = EstablishmentProductShortSerializer(source='establishment', read_only=True) tags = ProductTagSerializer(source='related_tags', many=True, read_only=True) wine_region = WineRegionBaseSerializer(read_only=True) wine_colors = TagBaseSerializer(many=True, read_only=True) - preview_image_url = serializers.URLField(source='preview_main_image_url', - allow_null=True, + preview_image_url = serializers.URLField(allow_null=True, read_only=True) in_favorites = serializers.BooleanField(allow_null=True) @@ -120,6 +119,7 @@ class ProductBaseSerializer(serializers.ModelSerializer): class ProductDetailSerializer(ProductBaseSerializer): """Product detail serializer.""" description_translated = TranslatedField() + establishment_detail = EstablishmentShortSerializer(source='establishment', read_only=True) review = ReviewShortSerializer(source='last_published_review', read_only=True) awards = AwardSerializer(many=True, read_only=True) classifications = ProductClassificationBaseSerializer(many=True, read_only=True) @@ -127,9 +127,12 @@ class ProductDetailSerializer(ProductBaseSerializer): wine_sub_region = WineSubRegionBaseSerializer(read_only=True) bottles_produced = TagBaseSerializer(many=True, read_only=True) sugar_contents = TagBaseSerializer(many=True, read_only=True) - image_url = serializers.ImageField(source='main_image_url', - allow_null=True, - read_only=True) + grape_variety = TagBaseSerializer(many=True, read_only=True) + bottle_sizes = TagBaseSerializer(many=True, read_only=True) + alcohol_percentage = TagBaseSerializer(read_only=True) + image_url = serializers.URLField(allow_null=True, + read_only=True) + new_image = ImageBaseSerializer(source='crop_main_image', allow_null=True, read_only=True) class Meta(ProductBaseSerializer.Meta): fields = ProductBaseSerializer.Meta.fields + [ @@ -142,6 +145,11 @@ class ProductDetailSerializer(ProductBaseSerializer): 'bottles_produced', 'sugar_contents', 'image_url', + 'new_image', + 'grape_variety', + 'average_price', + 'bottle_sizes', + 'alcohol_percentage', ] @@ -175,78 +183,6 @@ class ProductFavoritesCreateSerializer(FavoritesCreateSerializer): return super().create(validated_data) -# class CropImageSerializer(serializers.Serializer): -# """Serializer for crop images for News object.""" -# -# preview_url = serializers.SerializerMethodField() -# promo_horizontal_web_url = serializers.SerializerMethodField() -# promo_horizontal_mobile_url = serializers.SerializerMethodField() -# tile_horizontal_web_url = serializers.SerializerMethodField() -# tile_horizontal_mobile_url = serializers.SerializerMethodField() -# tile_vertical_web_url = serializers.SerializerMethodField() -# highlight_vertical_web_url = serializers.SerializerMethodField() -# editor_web_url = serializers.SerializerMethodField() -# editor_mobile_url = serializers.SerializerMethodField() -# -# def get_preview_url(self, obj): -# """Get crop preview.""" -# return obj.instance.get_image_url('news_preview') -# -# def get_promo_horizontal_web_url(self, obj): -# """Get crop promo_horizontal_web.""" -# return obj.instance.get_image_url('news_promo_horizontal_web') -# -# def get_promo_horizontal_mobile_url(self, obj): -# """Get crop promo_horizontal_mobile.""" -# return obj.instance.get_image_url('news_promo_horizontal_mobile') -# -# def get_tile_horizontal_web_url(self, obj): -# """Get crop tile_horizontal_web.""" -# return obj.instance.get_image_url('news_tile_horizontal_web') -# -# def get_tile_horizontal_mobile_url(self, obj): -# """Get crop tile_horizontal_mobile.""" -# return obj.instance.get_image_url('news_tile_horizontal_mobile') -# -# def get_tile_vertical_web_url(self, obj): -# """Get crop tile_vertical_web.""" -# return obj.instance.get_image_url('news_tile_vertical_web') -# -# def get_highlight_vertical_web_url(self, obj): -# """Get crop highlight_vertical_web.""" -# return obj.instance.get_image_url('news_highlight_vertical_web') -# -# def get_editor_web_url(self, obj): -# """Get crop editor_web.""" -# return obj.instance.get_image_url('news_editor_web') -# -# def get_editor_mobile_url(self, obj): -# """Get crop editor_mobile.""" -# return obj.instance.get_image_url('news_editor_mobile') - - -class ProductImageSerializer(serializers.ModelSerializer): - """Serializer for returning crop images of product image.""" - - orientation_display = serializers.CharField(source='get_orientation_display', - read_only=True) - original_url = serializers.URLField(source='image.url') - # auto_crop_images = CropImageSerializer(source='image', allow_null=True) - - class Meta: - model = Image - fields = [ - 'id', - 'title', - 'orientation_display', - 'original_url', - # 'auto_crop_images', - ] - extra_kwargs = { - 'orientation': {'write_only': True} - } - - class ProductCommentCreateSerializer(CommentSerializer): """Create comment serializer""" mark = serializers.IntegerField() diff --git a/apps/product/urls/back.py b/apps/product/urls/back.py index 7d3b1611..fc2aaad0 100644 --- a/apps/product/urls/back.py +++ b/apps/product/urls/back.py @@ -6,6 +6,8 @@ from product import views urlpatterns = [ path('', views.ProductListCreateBackOfficeView.as_view(), name='list-create'), path('/', views.ProductDetailBackOfficeView.as_view(), name='rud'), + path('/notes/', views.ProductNoteListCreateView.as_view(), name='note-list-create'), + path('/notes//', views.ProductNoteRUDView.as_view(), name='note-rud'), path('/gallery/', views.ProductBackOfficeGalleryListView.as_view(), name='gallery-list'), path('/gallery//', views.ProductBackOfficeGalleryCreateDestroyView.as_view(), diff --git a/apps/product/views/back.py b/apps/product/views/back.py index 70ac9c57..fc5e108f 100644 --- a/apps/product/views/back.py +++ b/apps/product/views/back.py @@ -5,13 +5,14 @@ from rest_framework.response import Response from product import serializers, models from product.views import ProductBaseView +from utils.serializers import ImageBaseSerializer from utils.views import CreateDestroyGalleryViewMixin class ProductBackOfficeMixinView(ProductBaseView): """Product back-office mixin view.""" - permission_classes = (permissions.IsAuthenticated,) + permission_classes = (permissions.IsAuthenticated, ) def get_queryset(self): """Override get_queryset method.""" @@ -56,8 +57,8 @@ class ProductBackOfficeGalleryCreateDestroyView(ProductBackOfficeMixinView, """ product_qs = self.filter_queryset(self.get_queryset()) - product = get_object_or_404(product_qs, pk=self.kwargs['pk']) - gallery = get_object_or_404(product.product_gallery, image_id=self.kwargs['image_id']) + product = get_object_or_404(product_qs, pk=self.kwargs.get('pk')) + gallery = get_object_or_404(product.product_gallery, image_id=self.kwargs.get('image_id')) # May raise a permission denied self.check_object_permissions(self.request, gallery) @@ -65,14 +66,16 @@ class ProductBackOfficeGalleryCreateDestroyView(ProductBackOfficeMixinView, return gallery -class ProductBackOfficeGalleryListView(ProductBackOfficeMixinView, generics.ListAPIView): +class ProductBackOfficeGalleryListView(ProductBackOfficeMixinView, + generics.ListAPIView): """Resource for returning gallery for product for back-office users.""" - serializer_class = serializers.ProductImageSerializer + serializer_class = ImageBaseSerializer + permission_classes = (permissions.IsAuthenticated,) def get_object(self): """Override get_object method.""" qs = super(ProductBackOfficeGalleryListView, self).get_queryset() - product = get_object_or_404(qs, pk=self.kwargs['pk']) + product = get_object_or_404(qs, pk=self.kwargs.get('pk')) # May raise a permission denied self.check_object_permissions(self.request, product) @@ -81,10 +84,11 @@ class ProductBackOfficeGalleryListView(ProductBackOfficeMixinView, generics.List def get_queryset(self): """Override get_queryset method.""" - return self.get_object().gallery.all() + return self.get_object().crop_gallery -class ProductDetailBackOfficeView(ProductBackOfficeMixinView, generics.RetrieveUpdateDestroyAPIView): +class ProductDetailBackOfficeView(ProductBackOfficeMixinView, + generics.RetrieveUpdateDestroyAPIView): """Product back-office R/U/D view.""" serializer_class = serializers.ProductBackOfficeDetailSerializer @@ -131,3 +135,48 @@ class ProductSubTypeRUDBackOfficeView(BackOfficeListCreateMixin, generics.RetrieveUpdateDestroyAPIView): """Product sub type back-office retrieve-update-destroy view.""" serializer_class = serializers.ProductSubTypeBackOfficeDetailSerializer + + +class ProductNoteListCreateView(ProductBackOfficeMixinView, + BackOfficeListCreateMixin, + generics.ListCreateAPIView): + """Retrieve|Update|Destroy product note view.""" + + serializer_class = serializers.ProductNoteListCreateSerializer + + def get_object(self): + """Returns the object the view is displaying.""" + product_qs = models.Product.objects.all() + filtered_product_qs = self.filter_queryset(product_qs) + + product = get_object_or_404(filtered_product_qs, pk=self.kwargs.get('pk')) + + # May raise a permission denied + self.check_object_permissions(self.request, product) + + return product + + def get_queryset(self): + """Overridden get_queryset method.""" + return self.get_object().notes.all() + + +class ProductNoteRUDView(ProductBackOfficeMixinView, + BackOfficeListCreateMixin, + generics.RetrieveUpdateDestroyAPIView): + """Create|Retrieve|Update|Destroy product note view.""" + + serializer_class = serializers.ProductNoteBaseSerializer + + def get_object(self): + """Returns the object the view is displaying.""" + product_qs = models.Product.objects.all() + filtered_product_qs = self.filter_queryset(product_qs) + + product = get_object_or_404(filtered_product_qs, pk=self.kwargs.get('pk')) + note = get_object_or_404(product.notes.all(), pk=self.kwargs.get('note_pk')) + + # May raise a permission denied + self.check_object_permissions(self.request, note) + + return note diff --git a/apps/product/views/common.py b/apps/product/views/common.py index daa46fd7..8b857ddb 100644 --- a/apps/product/views/common.py +++ b/apps/product/views/common.py @@ -17,7 +17,6 @@ class ProductBaseView(generics.GenericAPIView): return Product.objects.published() \ .with_base_related() \ .annotate_in_favorites(self.request.user) \ - .by_country_code(self.request.country_code) \ .order_by('-created') @@ -26,6 +25,11 @@ class ProductListView(ProductBaseView, generics.ListAPIView): serializer_class = serializers.ProductBaseSerializer filter_class = filters.ProductFilterSet + def get_queryset(self): + qs = super().get_queryset().with_extended_related() \ + .by_country_code(self.request.country_code) + return qs + class ProductDetailView(ProductBaseView, generics.RetrieveAPIView): """Detail view fro model Product.""" diff --git a/apps/review/filters.py b/apps/review/filters.py new file mode 100644 index 00000000..8e85a906 --- /dev/null +++ b/apps/review/filters.py @@ -0,0 +1,30 @@ +from django.core.validators import EMPTY_VALUES +from django_filters import rest_framework as filters + +from review import models + + +class ReviewFilter(filters.FilterSet): + """Review filter set.""" + + establishment_id = filters.NumberFilter(field_name='object_id', ) + product_id = filters.NumberFilter(field_name='object_id', ) + + class Meta: + """Meta class.""" + + model = models.Review + fields = ( + 'establishment_id', + 'product_id', + ) + + def by_establishment_id(self, queryset, name, value): + if value not in EMPTY_VALUES: + return queryset.by_establishment_id(value, content_type='establishment') + return queryset + + def by_product_id(self, queryset, name, value): + if value not in EMPTY_VALUES: + return queryset.by_product_id(value, content_type='product') + return queryset diff --git a/apps/review/migrations/0018_auto_20191117_1117.py b/apps/review/migrations/0018_auto_20191117_1117.py new file mode 100644 index 00000000..a4e4b7b1 --- /dev/null +++ b/apps/review/migrations/0018_auto_20191117_1117.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.7 on 2019-11-17 11:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('review', '0017_auto_20191115_0737'), + ] + + operations = [ + migrations.AlterField( + model_name='inquiriesgallery', + name='image', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inquiries_gallery', to='gallery.Image', verbose_name='image'), + ), + ] diff --git a/apps/review/models.py b/apps/review/models.py index 1dadc104..8734f4f6 100644 --- a/apps/review/models.py +++ b/apps/review/models.py @@ -4,8 +4,9 @@ from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ -from utils.models import BaseAttributes, TranslatedFieldsMixin, ProjectBaseMixin -from utils.models import TJSONField +from utils.models import (BaseAttributes, TranslatedFieldsMixin, + ProjectBaseMixin, GalleryModelMixin, + TJSONField, IntermediateGalleryModelMixin) class ReviewQuerySet(models.QuerySet): @@ -92,7 +93,7 @@ class Review(BaseAttributes, TranslatedFieldsMixin): verbose_name_plural = _('Reviews') -class Inquiries(ProjectBaseMixin): +class Inquiries(GalleryModelMixin, ProjectBaseMixin): NONE = 0 DINER = 1 LUNCH = 2 @@ -145,15 +146,7 @@ class GridItems(ProjectBaseMixin): return f'inquiry: {self.inquiry.id}, grid id: {self.id}' -class InquiriesGalleryQuerySet(models.QuerySet): - """QuerySet for model Inquiries""" - - def main_image(self): - """Return objects with flag is_main is True""" - return self.filter(is_main=True) - - -class InquiriesGallery(models.Model): +class InquiriesGallery(IntermediateGalleryModelMixin): old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None) inquiry = models.ForeignKey( Inquiries, @@ -167,11 +160,8 @@ class InquiriesGallery(models.Model): null=True, related_name='inquiries_gallery', on_delete=models.CASCADE, - verbose_name=_('gallery'), + verbose_name=_('image'), ) - is_main = models.BooleanField(default=False, verbose_name=_('Is the main image')) - - objects = InquiriesGalleryQuerySet.as_manager() class Meta: verbose_name = _('inquiry gallery') diff --git a/apps/review/serializers/common.py b/apps/review/serializers/common.py index 2889db6e..da7b624b 100644 --- a/apps/review/serializers/common.py +++ b/apps/review/serializers/common.py @@ -14,7 +14,9 @@ class ReviewBaseSerializer(serializers.ModelSerializer): 'child', 'published_at', 'vintage', - 'country' + 'country', + 'content_type', + 'object_id', ) diff --git a/apps/review/tests.py b/apps/review/tests.py index e04f5281..eebc1e86 100644 --- a/apps/review/tests.py +++ b/apps/review/tests.py @@ -19,6 +19,7 @@ class BaseTestCase(APITestCase): username=self.username, email=self.email, password=self.password, + is_staff=True, ) tokens = User.create_jwt_tokens(self.user) @@ -61,6 +62,49 @@ class BaseTestCase(APITestCase): ) +class ReviewTestCase(BaseTestCase): + def setUp(self): + super().setUp() + + def test_review_list(self): + response = self.client.get('/api/back/review/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_review_post(self): + test_review = { + 'reviewer': self.user.id, + 'status': Review.READY, + 'vintage': 2019, + 'country': self.country_ru.id, + 'object_id': 1, + 'content_type': 1, + } + response = self.client.post('/api/back/review/', data=test_review) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_review_detail(self): + response = self.client.get(f'/api/back/review/{self.test_review.id}/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_review_detail_put(self): + data = { + 'id': self.test_review.id, + 'vintage': 2018, + 'reviewer': self.user.id, + 'status': Review.READY, + 'country': self.country_ru.id, + 'object_id': 1, + 'content_type': 1, + } + + response = self.client.put(f'/api/back/review/{self.test_review.id}/', data=data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_review_delete(self): + response = self.client.delete(f'/api/back/review/{self.test_review.id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + class InquiriesTestCase(BaseTestCase): def setUp(self): super().setUp() diff --git a/apps/review/transfer_data.py b/apps/review/transfer_data.py index 9ccb6c80..af70873e 100644 --- a/apps/review/transfer_data.py +++ b/apps/review/transfer_data.py @@ -2,10 +2,10 @@ from pprint import pprint from django.db.models import Q -from product.models import Product from account.models import User from account.transfer_data import STOP_LIST from establishment.models import Establishment +from product.models import Product from review.models import Inquiries as NewInquiries, Review from transfer.models import Reviews, ReviewTexts, Inquiries, GridItems, InquiryPhotos from transfer.serializers.grid import GridItemsSerializer @@ -34,11 +34,11 @@ def transfer_languages(): def transfer_reviews(): establishments = Establishment.objects.filter(old_id__isnull=False).values_list('old_id', flat=True) - queryset = Reviews.objects.filter( + queryset = Reviews.objects.exclude(product_id__isnull=False).filter( establishment_id__in=list(establishments), ).values('id', 'reviewer_id', 'aasm_state', 'created_at', 'establishment_id', 'mark', 'vintage') - serialized_data = ReviewSerializer(data=list(queryset.values()), many=True) + serialized_data = ReviewSerializer(data=list(queryset), many=True) if serialized_data.is_valid(): serialized_data.save() else: @@ -48,17 +48,23 @@ def transfer_reviews(): def transfer_text_review(): reviews = Review.objects.filter(old_id__isnull=False).values_list('old_id', flat=True) queryset = ReviewTexts.objects.filter( - review_id__in=list(reviews), + review_id__in=list(reviews) ).exclude( - Q(text__isnull=True) | Q(text='') + id__in=(23183, 25348, 43930, 23199, 26226, 34006) # пробелы вместо текста + ).exclude( + text__isnull=True + ).exclude( + text__iexact='' ).values('review_id', 'locale', 'text') - serialized_data = ReviewTextSerializer(data=list(queryset.values()), many=True) + serialized_data = ReviewTextSerializer(data=list(queryset), many=True) if serialized_data.is_valid(): serialized_data.save() else: pprint(f"ReviewTextSerializer serializer errors: {serialized_data.errors}") + +def make_en_text_review(): for review in Review.objects.filter(old_id__isnull=False): text = review.text if text and 'en-GB' not in text: @@ -106,7 +112,6 @@ def transfer_inquiry_photos(): def transfer_product_reviews(): - products = Product.objects.filter( old_id__isnull=False).values_list('old_id', flat=True) @@ -130,6 +135,7 @@ data_types = { # transfer_languages, transfer_reviews, transfer_text_review, + make_en_text_review, ], 'inquiries': [ transfer_inquiries, diff --git a/apps/review/views/back.py b/apps/review/views/back.py index c6ec6b67..27f9af0d 100644 --- a/apps/review/views/back.py +++ b/apps/review/views/back.py @@ -1,5 +1,6 @@ from rest_framework import generics, permissions +from review import filters from review import models from review import serializers from utils.permissions import IsReviewerManager, IsRestaurantReviewer @@ -10,13 +11,14 @@ class ReviewLstView(generics.ListCreateAPIView): serializer_class = serializers.ReviewBaseSerializer queryset = models.Review.objects.all() permission_classes = [permissions.IsAuthenticatedOrReadOnly, ] + filterset_class = filters.ReviewFilter class ReviewRUDView(generics.RetrieveUpdateDestroyAPIView): """Comment RUD view.""" serializer_class = serializers.ReviewBaseSerializer queryset = models.Review.objects.all() - permission_classes = [IsReviewerManager | IsRestaurantReviewer] + permission_classes = [permissions.IsAdminUser | IsReviewerManager | IsRestaurantReviewer] lookup_field = 'id' diff --git a/apps/search_indexes/documents/establishment.py b/apps/search_indexes/documents/establishment.py index 2d2706ff..8b4e5c3c 100644 --- a/apps/search_indexes/documents/establishment.py +++ b/apps/search_indexes/documents/establishment.py @@ -48,6 +48,34 @@ class EstablishmentDocument(Document): properties=OBJECT_FIELD_PROPERTIES), }, multi=True) + visible_tags = fields.ObjectField( + properties={ + 'id': fields.IntegerField(attr='id'), + 'label': fields.ObjectField(attr='label_indexing', + properties=OBJECT_FIELD_PROPERTIES), + }, + multi=True) + products = fields.ObjectField( + properties={ + 'wine_region': fields.ObjectField(properties={ + 'id': fields.IntegerField(), + 'name': fields.KeywordField(), + 'country': fields.ObjectField(properties={ + 'id': fields.IntegerField(), + 'name': fields.ObjectField(attr='name_indexing', + properties=OBJECT_FIELD_PROPERTIES), + 'code': fields.KeywordField(), + }), + # 'coordinates': fields.GeoPointField(), + 'description': fields.ObjectField(attr='description_indexing', properties=OBJECT_FIELD_PROPERTIES), + }), + 'wine_sub_region': fields.ObjectField(properties={ + 'id': fields.IntegerField(), + 'name': fields.KeywordField(), + }), + }, + multi=True + ) schedule = fields.ListField(fields.ObjectField( properties={ 'id': fields.IntegerField(attr='id'), diff --git a/apps/search_indexes/documents/news.py b/apps/search_indexes/documents/news.py index ff659416..e39036d3 100644 --- a/apps/search_indexes/documents/news.py +++ b/apps/search_indexes/documents/news.py @@ -34,6 +34,13 @@ class NewsDocument(Document): 'value': fields.KeywordField() }, multi=True) + visible_tags = fields.ObjectField( + properties={ + 'id': fields.IntegerField(attr='id'), + 'label': fields.ObjectField(attr='label_indexing', + properties=OBJECT_FIELD_PROPERTIES), + }, + multi=True) class Django: diff --git a/apps/search_indexes/documents/product.py b/apps/search_indexes/documents/product.py index b00f3f50..1a092dac 100644 --- a/apps/search_indexes/documents/product.py +++ b/apps/search_indexes/documents/product.py @@ -28,12 +28,43 @@ class ProductDocument(Document): }, multi=True ) + preview_image_url = fields.KeywordField(attr='preview_image_url') establishment = fields.ObjectField( properties={ 'id': fields.IntegerField(), 'name': fields.KeywordField(), + 'index_name': fields.KeywordField(), 'slug': fields.KeywordField(), - # 'city' TODO: city indexing + 'city': fields.ObjectField( + attr='address.city', + properties={ + 'id': fields.IntegerField(), + 'name': fields.KeywordField(), + 'code': fields.KeywordField(), + 'country': fields.ObjectField( + properties={ + 'id': fields.IntegerField(), + 'name': fields.ObjectField(attr='name_indexing', + properties=OBJECT_FIELD_PROPERTIES), + 'code': fields.KeywordField(), + 'svg_image': fields.KeywordField(attr='svg_image_indexing') + } + ), + } + ), + 'address': fields.ObjectField( + properties={ + 'city': fields.ObjectField( + properties={ + 'country': fields.ObjectField( + properties={ + 'code': fields.KeywordField() + } + ) + } + ) + } + ) } ) wine_colors = fields.ObjectField( @@ -44,6 +75,14 @@ class ProductDocument(Document): }, multi=True, ) + grape_variety = fields.ObjectField( + properties={ + 'id': fields.IntegerField(), + 'label': fields.ObjectField(attr='label_indexing', properties=OBJECT_FIELD_PROPERTIES), + 'value': fields.KeywordField(), + }, + multi=True, + ) wine_region = fields.ObjectField(properties={ 'id': fields.IntegerField(), 'name': fields.KeywordField(), @@ -56,7 +95,10 @@ class ProductDocument(Document): # 'coordinates': fields.GeoPointField(), 'description': fields.ObjectField(attr='description_indexing', properties=OBJECT_FIELD_PROPERTIES), }) - wine_sub_region = fields.ObjectField(properties={'name': fields.KeywordField()}) + wine_sub_region = fields.ObjectField(properties={ + 'id': fields.IntegerField(), + 'name': fields.KeywordField(), + }) classifications = fields.ObjectField( # TODO properties={ 'classification_type': fields.ObjectField(properties={}), @@ -95,13 +137,23 @@ class ProductDocument(Document): }, multi=True ) + related_tags = fields.ObjectField( + properties={ + 'id': fields.IntegerField(), + 'label': fields.ObjectField(attr='label_indexing', properties=OBJECT_FIELD_PROPERTIES), + 'value': fields.KeywordField(), + }, + multi=True + ) + name = fields.TextField(attr='display_name', analyzer='english') + name_ru = fields.TextField(attr='display_name', analyzer='russian') + name_fr = fields.TextField(attr='display_name', analyzer='french') class Django: model = models.Product fields = ( 'id', 'category', - 'name', 'available', 'public_mark', 'slug', @@ -109,6 +161,7 @@ class ProductDocument(Document): 'state', 'old_unique_key', 'vintage', + 'average_price', ) related_models = [models.ProductType] diff --git a/apps/search_indexes/filters.py b/apps/search_indexes/filters.py index 93208a71..ab47ef84 100644 --- a/apps/search_indexes/filters.py +++ b/apps/search_indexes/filters.py @@ -1,19 +1,22 @@ """Search indexes filters.""" from elasticsearch_dsl.query import Q from django_elasticsearch_dsl_drf.filter_backends import SearchFilterBackend -from utils.models import get_current_locale +from search_indexes.utils import OBJECT_FIELD_PROPERTIES class CustomSearchFilterBackend(SearchFilterBackend): """Custom SearchFilterBackend.""" @staticmethod - def get_field_name(view, field): - field_name = field + def search_among_all_locales(view, search_kwargs: dict): if hasattr(view, 'search_fields') and hasattr(view, 'translated_search_fields'): - if field in view.translated_search_fields: - field_name = f'{field}.{get_current_locale()}' - return field_name + all_supported_locales = OBJECT_FIELD_PROPERTIES.keys() + fields = search_kwargs.copy().keys() + for field in fields: + if field in view.translated_search_fields: + value = search_kwargs.pop(field) + search_kwargs.update({f'{field}.{locale}': value for locale in all_supported_locales}) + def construct_search(self, request, view): """Construct search. @@ -54,29 +57,69 @@ class CustomSearchFilterBackend(SearchFilterBackend): field, value = __values if field in view.search_fields: # Initial kwargs for the match query - field_kwargs = {self.get_field_name(view, field): {'query': value}} + field_kwargs = {field: {'query': value}} # In case if we deal with structure 2 if isinstance(view.search_fields, dict): extra_field_kwargs = view.search_fields[field] if extra_field_kwargs: - field_kwargs[self.get_field_name(view, field)].update(extra_field_kwargs) + field_kwargs[field].update(extra_field_kwargs) # The match query - __queries.append( - Q("match", **field_kwargs) - ) + self.search_among_all_locales(view, field_kwargs) + if len(field_kwargs.keys()) > 1: + for k, v in field_kwargs.items(): + __queries.append( + Q("match", **{k: v}) + ) + __queries.append( + Q('wildcard', + **{k: { + 'value': f'*{search_term.lower()}*', + 'boost': v.get('boost', 1) + 30 + } + } + ) + ) + else: + __queries.append( + Q("match", **field_kwargs) + ) + __queries.append( + Q('wildcard', **{field: {'value': f'*{search_term.lower()}*', + 'boost': field_kwargs[field].get('boost', 1) + 30}}) + ) else: for field in view.search_fields: # Initial kwargs for the match query - field_kwargs = {self.get_field_name(view, field): {'query': search_term}} + field_kwargs = {field: {'query': search_term}} # In case if we deal with structure 2 if isinstance(view.search_fields, dict): extra_field_kwargs = view.search_fields[field] if extra_field_kwargs: - field_kwargs[self.get_field_name(view, field)].update(extra_field_kwargs) + field_kwargs[field].update(extra_field_kwargs) # The match query - __queries.append( - Q("match", **field_kwargs) - ) + self.search_among_all_locales(view, field_kwargs) + if len(field_kwargs.keys()) > 1: + for k, v in field_kwargs.items(): + __queries.append( + Q("match", **{k: v}) + ) + __queries.append( + Q('wildcard', + **{k: { + 'value': f'*{search_term.lower()}*', + 'boost': v.get('boost', 1) + 30 + } + } + ) + ) + else: + __queries.append( + Q("match", **field_kwargs) + ) + __queries.append( + Q('wildcard', **{field: {'value': f'*{search_term.lower()}*', + 'boost': field_kwargs[field].get('boost', 1) + 30}}) + ) return __queries \ No newline at end of file diff --git a/apps/search_indexes/serializers.py b/apps/search_indexes/serializers.py index dffd6e77..cac4e336 100644 --- a/apps/search_indexes/serializers.py +++ b/apps/search_indexes/serializers.py @@ -39,7 +39,8 @@ class ProductSubtypeDocumentSerializer(serializers.Serializer): id = serializers.IntegerField() name_translated = serializers.SerializerMethodField() - get_name_translated = lambda obj: get_translated_value(obj.name) + def get_name_translated(self, obj): + return get_translated_value(obj.name) class WineRegionCountryDocumentSerialzer(serializers.Serializer): @@ -64,9 +65,12 @@ class WineRegionDocumentSerializer(serializers.Serializer): name = serializers.CharField() country = WineRegionCountryDocumentSerialzer(allow_null=True) + def get_attribute(self, instance): + return instance.wine_region if instance and instance.wine_region else None -class WineColorDocumentSerializer(serializers.Serializer): - """Wine color ES document serializer,""" + +class TagDocumentSerializer(serializers.Serializer): + """Tag ES document serializer,""" id = serializers.IntegerField() label_translated = serializers.SerializerMethodField() @@ -79,12 +83,16 @@ class WineColorDocumentSerializer(serializers.Serializer): return get_translated_value(obj.label) -class ProductEstablishmentDocumentSerializer(serializers.Serializer): - """Related to Product Establishment ES document serializer.""" +class ProductTypeDocumentSerializer(serializers.Serializer): + """Product type ES document serializer.""" id = serializers.IntegerField() - name = serializers.CharField() - slug = serializers.CharField() + index_name = serializers.CharField() + name_translated = serializers.SerializerMethodField() + + @staticmethod + def get_name_translated(obj): + return get_translated_value(obj.name) class CityDocumentShortSerializer(serializers.Serializer): @@ -95,6 +103,39 @@ class CityDocumentShortSerializer(serializers.Serializer): name = serializers.CharField() +class CountryDocumentSerializer(serializers.Serializer): + + id = serializers.IntegerField() + code = serializers.CharField(allow_null=True) + svg_image = serializers.CharField() + name_translated = serializers.SerializerMethodField() + + @staticmethod + def get_name_translated(obj): + return get_translated_value(obj.name) + + +class AnotherCityDocumentShortSerializer(CityDocumentShortSerializer): + + country = CountryDocumentSerializer() + + def to_representation(self, instance): + if instance != AttrDict(d={}) or \ + (isinstance(instance, dict) and len(instance) != 0): + return super().to_representation(instance) + return None + + +class ProductEstablishmentDocumentSerializer(serializers.Serializer): + """Related to Product Establishment ES document serializer.""" + + id = serializers.IntegerField() + name = serializers.CharField() + slug = serializers.CharField() + index_name = serializers.CharField() + city = AnotherCityDocumentShortSerializer() + + class AddressDocumentSerializer(serializers.Serializer): """Address serializer for ES Document.""" @@ -132,7 +173,7 @@ class NewsDocumentSerializer(DocumentSerializer): title_translated = serializers.SerializerMethodField(allow_null=True) subtitle_translated = serializers.SerializerMethodField(allow_null=True) news_type = NewsTypeSerializer() - tags = TagsDocumentSerializer(many=True) + tags = TagsDocumentSerializer(many=True, source='visible_tags') class Meta: """Meta class.""" @@ -165,7 +206,7 @@ class EstablishmentDocumentSerializer(DocumentSerializer): establishment_type = EstablishmentTypeSerializer() establishment_subtypes = EstablishmentTypeSerializer(many=True) address = AddressDocumentSerializer(allow_null=True) - tags = TagsDocumentSerializer(many=True) + tags = TagsDocumentSerializer(many=True, source='visible_tags') schedule = ScheduleDocumentSerializer(many=True, allow_null=True) class Meta: @@ -198,17 +239,14 @@ class EstablishmentDocumentSerializer(DocumentSerializer): class ProductDocumentSerializer(DocumentSerializer): """Product document serializer""" - tags = TagsDocumentSerializer(many=True) - subtypes = ProductSubtypeDocumentSerializer(many=True) + tags = TagsDocumentSerializer(many=True, source='related_tags') + subtypes = ProductSubtypeDocumentSerializer(many=True, allow_null=True) wine_region = WineRegionDocumentSerializer(allow_null=True) - wine_colors = WineColorDocumentSerializer(many=True) - product_type = serializers.SerializerMethodField() + wine_colors = TagDocumentSerializer(many=True) + grape_variety = TagDocumentSerializer(many=True) + product_type = ProductTypeDocumentSerializer(allow_null=True) establishment_detail = ProductEstablishmentDocumentSerializer(source='establishment', allow_null=True) - @staticmethod - def get_product_type(obj): - return get_translated_value(obj.product_type.name if obj.product_type else {}) - class Meta: """Meta class.""" @@ -216,6 +254,7 @@ class ProductDocumentSerializer(DocumentSerializer): fields = ( 'id', 'category', + 'preview_image_url', 'name', 'available', 'public_mark', @@ -229,5 +268,7 @@ class ProductDocumentSerializer(DocumentSerializer): 'subtypes', 'wine_region', 'wine_colors', + 'grape_variety', 'establishment_detail', + 'average_price', ) diff --git a/apps/search_indexes/views.py b/apps/search_indexes/views.py index 21b9b675..783754c7 100644 --- a/apps/search_indexes/views.py +++ b/apps/search_indexes/views.py @@ -28,8 +28,10 @@ class NewsDocumentViewSet(BaseDocumentViewSet): ] search_fields = { - 'title': {'fuzziness': 'auto:2,5'}, - 'subtitle': {'fuzziness': 'auto:2,5'}, + 'title': {'fuzziness': 'auto:2,5', + 'boost': 3}, + 'subtitle': {'fuzziness': 'auto:2,5', + 'boost': 2}, 'description': {'fuzziness': 'auto:2,5'}, } translated_search_fields = ( @@ -86,11 +88,9 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): search_fields = { 'name': {'fuzziness': 'auto:2,5', - 'boost': '2'}, + 'boost': 4}, 'transliterated_name': {'fuzziness': 'auto:2,5', - 'boost': '2'}, - 'index_name': {'fuzziness': 'auto:2,5', - 'boost': '2'}, + 'boost': 3}, 'description': {'fuzziness': 'auto:2,5'}, } translated_search_fields = ( @@ -124,6 +124,20 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): constants.LOOKUP_QUERY_IN, ] }, + 'wine_region_id': { + 'field': 'products.wine_region.id', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + constants.LOOKUP_QUERY_EXCLUDE, + ], + }, + 'wine_sub_region_id': { + 'field': 'products.wine_sub_region_id', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + constants.LOOKUP_QUERY_EXCLUDE, + ], + }, 'country_id': { 'field': 'address.city.country.id' }, @@ -197,32 +211,27 @@ class ProductDocumentViewSet(BaseDocumentViewSet): """Product document ViewSet.""" document = ProductDocument - lookup_field = 'slug' pagination_class = ProjectMobilePagination permission_classes = (permissions.AllowAny,) serializer_class = serializers.ProductDocumentSerializer - # def get_queryset(self): - # qs = super(ProductDocumentViewSet, self).get_queryset() - # qs = qs.filter('match', is_publish=True) - # return qs - filter_backends = [ FilteringFilterBackend, filters.CustomSearchFilterBackend, - GeoSpatialFilteringFilterBackend, - DefaultOrderingFilterBackend, ] search_fields = { 'name': {'fuzziness': 'auto:2,5', - 'boost': '2'}, + 'boost': 8}, + 'name_ru': {'fuzziness': 'auto:2,5', + 'boost': 6}, + 'name_fr': {'fuzziness': 'auto:2,5', + 'boost': 7}, 'transliterated_name': {'fuzziness': 'auto:2,5', - 'boost': '2'}, - 'index_name': {'fuzziness': 'auto:2,5', - 'boost': '2'}, + 'boost': 3}, 'description': {'fuzziness': 'auto:2,5'}, } + translated_search_fields = ( 'description', ) @@ -231,10 +240,34 @@ class ProductDocumentViewSet(BaseDocumentViewSet): 'slug': 'slug', 'tags_id': { 'field': 'tags.id', - 'lookups': [constants.LOOKUP_QUERY_IN] + 'lookups': [constants.LOOKUP_QUERY_IN], + }, + 'country': { + 'field': 'establishment.address.city.country.code', }, 'wine_colors_id': { 'field': 'wine_colors.id', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + constants.LOOKUP_QUERY_EXCLUDE, + ], + }, + 'wine_region_id': { + 'field': 'wine_region.id', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + constants.LOOKUP_QUERY_EXCLUDE, + ], + }, + 'wine_sub_region_id': { + 'field': 'wine_sub_region_id', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + constants.LOOKUP_QUERY_EXCLUDE, + ], + }, + 'grape_variety_id': { + 'field': 'grape_variety.id', 'lookups': [ constants.LOOKUP_QUERY_IN, constants.LOOKUP_QUERY_EXCLUDE, @@ -246,7 +279,7 @@ class ProductDocumentViewSet(BaseDocumentViewSet): 'for_establishment': { 'field': 'establishment.slug', }, - 'type': { + 'product_type': { 'field': 'product_type.index_name', }, 'subtype': { @@ -254,8 +287,6 @@ class ProductDocumentViewSet(BaseDocumentViewSet): 'lookups': [ constants.LOOKUP_QUERY_IN, constants.LOOKUP_QUERY_EXCLUDE, - ] - } - } - geo_spatial_filter_fields = { + ], + }, } \ No newline at end of file diff --git a/apps/tag/filters.py b/apps/tag/filters.py index 0b1fb829..7bd22ec2 100644 --- a/apps/tag/filters.py +++ b/apps/tag/filters.py @@ -3,6 +3,7 @@ from django_filters import rest_framework as filters from establishment.models import EstablishmentType from django.conf import settings from tag import models +from product import models as product_models class TagsBaseFilterSet(filters.FilterSet): @@ -31,27 +32,49 @@ class TagCategoryFilterSet(TagsBaseFilterSet): """TagCategory filterset.""" establishment_type = filters.CharFilter(method='by_establishment_type') + product_type = filters.CharFilter(method='by_product_type') class Meta: """Meta class.""" model = models.TagCategory fields = ('type', - 'establishment_type', ) + 'establishment_type', + 'product_type', ) + + def by_product_type(self, queryset, name, value): + # if value == product_models.ProductType.WINE: + # queryset = queryset.filter(index_name='wine-color').filter(tags__products__isnull=False) + queryset = queryset.by_product_type(value) + return queryset # todo: filter by establishment type def by_establishment_type(self, queryset, name, value): - return queryset.by_establishment_type(value) + if value == EstablishmentType.ARTISAN: + qs = models.TagCategory.objects.filter(index_name='shop_category') + else: + qs = queryset.by_establishment_type(value) + return qs class TagsFilterSet(TagsBaseFilterSet): """Chosen tags filterset.""" + establishment_type = filters.CharFilter(method='by_establishment_type') + class Meta: """Meta class.""" model = models.Tag - fields = ('type',) + fields = ( + 'type', + 'establishment_type', + ) + + def by_establishment_type(self, queryset, name, value): + if value == EstablishmentType.ARTISAN: + return models.Tag.objects.by_category_index_name('shop_category')[0:8] + return queryset.by_establishment_type(value) # TMP TODO remove it later # Временный хардкод для демонстрации 4 ноября, потом удалить! @@ -66,3 +89,4 @@ class TagsFilterSet(TagsBaseFilterSet): queryset = queryset.for_establishments().filter(value__in=settings.ESTABLISHMENT_CHOSEN_TAGS).distinct( 'value') return queryset + diff --git a/apps/tag/management/commands/add_tags.py b/apps/tag/management/commands/add_tags.py index 37fdbbea..fd3443ec 100644 --- a/apps/tag/management/commands/add_tags.py +++ b/apps/tag/management/commands/add_tags.py @@ -3,87 +3,116 @@ from django.core.management.base import BaseCommand from establishment.models import Establishment, EstablishmentType from transfer import models as legacy from tag.models import Tag, TagCategory +from tqdm import tqdm +from django.db import connections +from collections import namedtuple + + +def namedtuplefetchall(cursor): + "Return all rows from a cursor as a namedtuple" + desc = cursor.description + nt_result = namedtuple('Result', [col[0] for col in desc]) + return [nt_result(*row) for row in cursor.fetchall()] + + +def metadata_category_sql(): + with connections['legacy'].cursor() as cursor: + cursor.execute( + '''SELECT + `key`, + establishments.type, + key_value_metadata.`value_type`, + public, + key_value_metadata.id as 'old_id' + FROM metadata + LEFT JOIN establishments + ON metadata.establishment_id=establishments.id + LEFT JOIN key_value_metadata + ON metadata.key=key_value_metadata.key_name + GROUP BY + establishments.type, + `key`, + key_value_metadata.`value_type`, + public, old_id;''' + ) + return namedtuplefetchall(cursor) + + +def metadata_tags_sql(): + with connections['legacy'].cursor() as cursor: + cursor.execute( + """ + SELECT + value, + `key` as category, + establishment_id + FROM metadata + WHERE establishment_id is not null""" + ) + return namedtuplefetchall(cursor) class Command(BaseCommand): help = 'Add tags values from old db to new db' + def get_type(self, meta): + meta_type = meta.value_type + if not meta.value_type: + if meta.key == 'wineyard_visits': + meta_type = 'list' + elif meta.key in ['private_room', 'outside_sits']: + meta_type = 'bool' + return meta_type + + def get_label(self, text): + sp = text.split('_') + label = ' '.join([sp[0].capitalize()] + sp[1:]) + return label + def handle(self, *args, **kwargs): - existing_establishment = Establishment.objects.filter( - old_id__isnull=False, tags__isnull=True + old_id__isnull=False ) - ESTABLISHMENT = 1 - SHOP = 2 - RESTAURANT = 3 - WINEYARD = 4 - MAPPER = { - RESTAURANT: EstablishmentType.RESTAURANT, - WINEYARD: EstablishmentType.PRODUCER, - SHOP: EstablishmentType.ARTISAN + 'Restaurant': EstablishmentType.RESTAURANT, + 'Wineyard': EstablishmentType.PRODUCER, + 'Shop': EstablishmentType.ARTISAN } + # remove old black category + for establishment_tag in tqdm(EstablishmentType.objects.all()): + establishment_tag.tag_categories.remove(*list( + establishment_tag.tag_categories.exclude( + tags__establishments__isnull=False).distinct())) - mapper_values_meta = legacy.KeyValueMetadatumKeyValueMetadatumEstablishments.objects.all() - for key, value in MAPPER.items(): - values_meta_id_list = mapper_values_meta.filter( - key_value_metadatum_establishment_id=key - ).values_list('key_value_metadatum_id') + # created Tag Category + for meta in tqdm(metadata_category_sql()): + category, _ = TagCategory.objects.update_or_create( + index_name=meta.key, + defaults={ + "public": True if meta.public == 1 else False, + "value_type": self.get_type(meta), + "label": {"en-GB": self.get_label(meta.key)} + } + ) - est_type, _ = EstablishmentType.objects.get_or_create(index_name=value) + # add to EstablishmentType + est_type = EstablishmentType.objects.get(index_name=MAPPER[meta.type]) + if category not in est_type.tag_categories.all(): + est_type.tag_categories.add(category) - key_value_metadata = legacy.KeyValueMetadata.objects.filter( - id__in=values_meta_id_list) + count = 0 + for meta_tag in tqdm(metadata_tags_sql()): - # create TagCategory - for key_value in key_value_metadata: - tag_category, created = TagCategory.objects.get_or_create( - index_name=key_value.key_name, - ) - - if created: - tag_category.label = { - 'en-GB': key_value.key_name, - 'fr-FR': key_value.key_name, - 'ru-RU': key_value.key_name, - } - tag_category.value_type = key_value.value_type - tag_category.save() - est_type.tag_categories.add( - tag_category - ) - - # create Tag - for tag in key_value.metadata_set.filter( - establishment__id__in=list( - existing_establishment.values_list('old_id', flat=True) - )): - - new_tag, created = Tag.objects.get_or_create( - value=tag.value, - category=tag_category, - ) - if created: - - sp = tag.value.split('_') - value = ' '.join([sp[0].capitalize()] + sp[1:]) - - trans = { - 'en-GB': value, - 'fr-FR': value, - 'ru-RU': value, - } - - aliases = legacy.MetadatumAliases.objects.filter(value=tag.value) - - for alias in aliases: - trans[alias.locale] = alias.meta_alias - - new_tag.label = trans - new_tag.save() - - est = existing_establishment.filter( - old_id=tag.establishment_id).first() - if est: - est.tags.add(new_tag) - est.save() + tag, _ = Tag.objects.update_or_create( + category=TagCategory.objects.get(index_name=meta_tag.category), + value=meta_tag.value, + defaults={ + "label": {"en-GB": self.get_label(meta_tag.value)} + } + ) + establishment = existing_establishment.filter(old_id=meta_tag.establishment_id).first() + if establishment: + if tag not in establishment.tags.all(): + establishment.tags.add(tag) + count += 1 + self.stdout.write(self.style.WARNING(f'Created {count} tags to Establishment')) diff --git a/apps/tag/management/commands/add_tags_translation.py b/apps/tag/management/commands/add_tags_translation.py index a0f19e4c..a168d35e 100644 --- a/apps/tag/management/commands/add_tags_translation.py +++ b/apps/tag/management/commands/add_tags_translation.py @@ -1,19 +1,18 @@ from django.core.management.base import BaseCommand -from establishment.models import Establishment, EstablishmentType +from tag.models import Tag from transfer import models as legacy -from tag.models import Tag, TagCategory +from tqdm import tqdm class Command(BaseCommand): help = 'Add tags translation from old db to new db' - def handle(self, *args, **kwargs): - translation = legacy.MetadatumAliases.objects.all() - # Humanisation for default values - + @staticmethod + def humanisation_tag(self): + """Humanisation for default values.""" tags = Tag.objects.all() - for tag in tags: + for tag in tqdm(tags): value = tag.label for k, v in value.items(): if isinstance(v, str) and '_' in v: @@ -22,10 +21,14 @@ class Command(BaseCommand): tag.label[k] = v tag.save() - for trans in translation: + def handle(self, *args, **kwargs): + """Translation for existed tags.""" + translation = legacy.MetadatumAliases.objects.all() + # self.humanisation_tag() + for trans in tqdm(translation): tag = Tag.objects.filter(value=trans.value).first() if tag: tag.label.update( {trans.locale: trans.meta_alias} ) - tag.save() \ No newline at end of file + tag.save() diff --git a/apps/tag/migrations/0015_auto_20191118_1210.py b/apps/tag/migrations/0015_auto_20191118_1210.py new file mode 100644 index 00000000..7579059b --- /dev/null +++ b/apps/tag/migrations/0015_auto_20191118_1210.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.7 on 2019-11-18 12:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tag', '0014_tag_old_id_meta_product'), + ] + + operations = [ + migrations.AlterField( + model_name='tag', + name='value', + field=models.CharField(blank=True, db_index=True, default=None, max_length=255, null=True, verbose_name='indexing name'), + ), + migrations.AlterField( + model_name='tagcategory', + name='value_type', + field=models.CharField(choices=[('string', 'string'), ('list', 'list'), ('integer', 'integer'), ('float', 'float'), ('percentage', 'percentage'), ('bool', 'boolean')], default='list', max_length=255, verbose_name='value type'), + ), + ] diff --git a/apps/tag/models.py b/apps/tag/models.py index a35425e8..a93c4a1f 100644 --- a/apps/tag/models.py +++ b/apps/tag/models.py @@ -19,9 +19,15 @@ class TagQuerySet(models.QuerySet): return self.filter(models.Q(category__establishment_types__isnull=False) | models.Q(category__establishment_subtypes__isnull=False)) + def by_category_index_name(self, index_name): + return self.filter(category__index_name=index_name) + def order_by_priority(self): return self.order_by('chosentagsettings__priority') + def by_establishment_type(self, index_name): + return self.filter(category__establishment_types__index_name=index_name) + class Tag(TranslatedFieldsMixin, models.Model): """Tag model.""" @@ -29,7 +35,7 @@ class Tag(TranslatedFieldsMixin, models.Model): label = TJSONField(blank=True, null=True, default=None, verbose_name=_('label'), help_text='{"en-GB":"some text"}') - value = models.CharField(_('indexing name'), max_length=255, blank=True, + value = models.CharField(_('indexing name'), max_length=255, blank=True, db_index=True, null=True, default=None) category = models.ForeignKey('TagCategory', on_delete=models.CASCADE, null=True, related_name='tags', @@ -37,6 +43,7 @@ class Tag(TranslatedFieldsMixin, models.Model): chosen_tag_settings = models.ManyToManyField(Country, through='ChosenTagSettings') priority = models.PositiveIntegerField(null=True, default=0) + # It does not make sense since in the old base another structure with duplicates old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None) old_id_meta_product = models.PositiveIntegerField(_('old id metadata product'), @@ -100,6 +107,10 @@ class TagCategoryQuerySet(models.QuerySet): """Filter by establishment type index name.""" return self.filter(establishment_types__index_name=index_name) + def by_product_type(self, index_name): + """Filter by product type index name.""" + return self.filter(tags__products__product_type__index_name=index_name) + def with_tags(self, switcher=True): """Filter by existing tags.""" return self.exclude(tags__isnull=switcher) @@ -113,6 +124,7 @@ class TagCategory(TranslatedFieldsMixin, models.Model): INTEGER = 'integer' FLOAT = 'float' PERCENTAGE = 'percentage' + BOOLEAN = 'bool' VALUE_TYPE_CHOICES = ( (STRING, _('string')), @@ -120,6 +132,7 @@ class TagCategory(TranslatedFieldsMixin, models.Model): (INTEGER, _('integer')), (FLOAT, _('float')), (PERCENTAGE, _('percentage')), + (BOOLEAN, _('boolean')), ) label = TJSONField(blank=True, null=True, default=None, diff --git a/apps/tag/serializers.py b/apps/tag/serializers.py index 02f2e4d3..2cc818a9 100644 --- a/apps/tag/serializers.py +++ b/apps/tag/serializers.py @@ -37,6 +37,21 @@ class TagBackOfficeSerializer(TagBaseSerializer): 'category' ) +class TagCategoryProductSerializer(serializers.ModelSerializer): + """SHORT Serializer for TagCategory""" + + label_translated = TranslatedField() + + class Meta: + """Meta class.""" + + model = models.TagCategory + fields = ( + 'id', + 'label_translated', + 'index_name', + ) + class TagCategoryBaseSerializer(serializers.ModelSerializer): """Serializer for model TagCategory.""" diff --git a/apps/timetable/serialziers.py b/apps/timetable/serialziers.py index 37725e1d..533bca70 100644 --- a/apps/timetable/serialziers.py +++ b/apps/timetable/serialziers.py @@ -8,6 +8,9 @@ from timetable.models import Timetable class ScheduleRUDSerializer(serializers.ModelSerializer): """Serializer for Establishment model.""" + 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) @@ -18,9 +21,6 @@ class ScheduleRUDSerializer(serializers.ModelSerializer): opening_at = serializers.TimeField(required=False) closed_at = serializers.TimeField(required=False) - NULLABLE_FIELDS = ['lunch_start', 'lunch_end', 'dinner_start', - 'dinner_end', 'opening_at', 'closed_at'] - class Meta: """Meta class.""" model = Timetable diff --git a/apps/transfer/management/commands/transfer.py b/apps/transfer/management/commands/transfer.py index 703b544a..cf8c040d 100644 --- a/apps/transfer/management/commands/transfer.py +++ b/apps/transfer/management/commands/transfer.py @@ -30,15 +30,17 @@ class Command(BaseCommand): 'update_country_flag', 'comment', #!!!! 'inquiries', # №6 - перенос запросов оценок - 'wine_characteristics', - 'product', - 'product_note', - 'souvenir', - 'establishment_note', - 'assemblage', + 'wine_characteristics', # №5 - перенос характиристик вин + 'product', # №5 - перенос продуктов + 'product_note', # №6 - перенос заметок продуктов + 'souvenir', # №5 - перенос продуктов типа - сувениры + 'establishment_note', # №5 - перенос заметок заведений + 'assemblage', # №6 - перенос тегов для типа продуктов - вино 'rating_count', 'product_review', 'newsletter_subscriber', # подписчики на рассылку - переносить после переноса пользователей №1 + 'purchased_plaques', # №6 - перенос купленных тарелок + 'fill_city_gallery', # №3 - перенос галереи городов 'update_city_info', 'migrate_city_gallery', 'fix_location' diff --git a/apps/transfer/models.py b/apps/transfer/models.py index 29ccb4bd..a8190879 100644 --- a/apps/transfer/models.py +++ b/apps/transfer/models.py @@ -217,10 +217,10 @@ class CityNames(MigrateMixin): class CityPhotos(MigrateMixin): using = 'legacy' - # city_id = models.IntegerField(blank=True, null=True) city = models.ForeignKey(Cities, models.DO_NOTHING, blank=True, null=True) attachment_file_name = models.CharField(max_length=255, blank=True, null=True) attachment_content_type = models.CharField(max_length=255, blank=True, null=True) + attachment_suffix_url = models.CharField(max_length=255) geometries = models.CharField(max_length=1024, blank=True, null=True) attachment_file_size = models.IntegerField(blank=True, null=True) attachment_updated_at = models.DateTimeField(blank=True, null=True) @@ -581,22 +581,19 @@ class EstablishmentInfos(MigrateMixin): db_table = 'establishment_infos' -# class EstablishmentMerchandises(MigrateMixin): -# using = 'legacy' -# -# establishment = models.ForeignKey('Establishments', models.DO_NOTHING, blank=True, null=True) +class EstablishmentMerchandises(MigrateMixin): + using = 'legacy' -# TODO: модели Merchandises нету в гугл таблице Check Migrations + establishment = models.ForeignKey('Establishments', models.DO_NOTHING, blank=True, null=True) + merchandise = models.ForeignKey('Merchandise', models.DO_NOTHING, blank=True, null=True) + gifted = models.NullBooleanField(blank=True, null=True) + quantity = models.IntegerField(blank=True, null=True) + created_at = models.DateTimeField() + updated_at = models.DateTimeField() -# merchandise = models.ForeignKey('Merchandises', models.DO_NOTHING, blank=True, null=True) -# gifted = models.IntegerField(blank=True, null=True) -# quantity = models.IntegerField(blank=True, null=True) -# created_at = models.DateTimeField() -# updated_at = models.DateTimeField() -# -# class Meta: -# managed = False -# db_table = 'establishment_merchandises' + class Meta: + managed = False + db_table = 'establishment_merchandises' class Menus(MigrateMixin): @@ -839,7 +836,7 @@ class PageMetadata(MigrateMixin): class Ads(MigrateMixin): using = 'legacy' - site_id = models.IntegerField(blank=True, null=True) + site = models.ForeignKey('Sites', on_delete=models.DO_NOTHING) href = models.CharField(max_length=255, blank=True, null=True) start_at = models.DateTimeField(blank=True, null=True) expire_at = models.DateTimeField(blank=True, null=True) diff --git a/apps/transfer/serializers/advertisement.py b/apps/transfer/serializers/advertisement.py index a25ee51e..761a1879 100644 --- a/apps/transfer/serializers/advertisement.py +++ b/apps/transfer/serializers/advertisement.py @@ -1,22 +1,89 @@ from rest_framework import serializers +from main.models import SiteSettings +from transfer.models import Sites, Ads from advertisement.models import Advertisement +from main.models import Page +import requests +from rest_framework import status -class AdvertisementSerializer(serializers.Serializer): +class AdvertisementSerializer(serializers.ModelSerializer): + id = serializers.IntegerField() href = serializers.CharField() - attachment_suffix_url = serializers.CharField(allow_null=True) + site_id = serializers.PrimaryKeyRelatedField( + queryset=Sites.objects.all()) + + class Meta: + """Meta class.""" + model = Advertisement + fields = [ + 'id', + 'href', + 'site_id', + ] def validate(self, data): data.update({ 'old_id': data.pop('id'), 'url': data.pop('href'), - 'image_url': data.pop('attachment_suffix_url'), - 'width': 300, - 'height': 250, + 'site_settings': self.get_site_settings(data.pop('site_id')), }) return data def create(self, validated_data): - return Advertisement.objects.create(**validated_data) \ No newline at end of file + site = validated_data.pop('site_settings') + url = validated_data.get('url') + + obj, _ = self.Meta.model.objects.get_or_create(url=url, defaults=validated_data) + + if site and site not in obj.sites.all(): + obj.sites.add(site) + return obj + + def get_site_settings(self, subdomain): + subdomain = subdomain.country_code_2 if isinstance(subdomain, Sites) else subdomain + qs = SiteSettings.objects.filter(subdomain=subdomain) + if qs.exists(): + return qs.first() + + +class AdvertisementImageSerializer(AdvertisementSerializer): + + attachment_suffix_url = serializers.CharField() + + class Meta(AdvertisementSerializer.Meta): + model = Page + fields = [ + 'id', + 'attachment_suffix_url', + ] + + def validate(self, data): + data.update({ + 'image_url': self.get_absolute_image_url(data.pop('attachment_suffix_url')), + 'advertisement': self.get_advertisement(data.pop('id')), + }) + return data + + def get_advertisement(self, old_id): + qs = Advertisement.objects.filter(old_id=old_id) + if qs.exists(): + return qs.first() + + def get_absolute_image_url(self, relative_path): + if relative_path: + absolute_image_url = f'https://1dc3f33f6d-3.optimicdn.com/gaultmillau.com/' \ + f'{relative_path}' + response = requests.head(absolute_image_url) + if response.status_code == status.HTTP_200_OK: + return absolute_image_url + + def create(self, validated_data): + advertisement = validated_data.get('advertisement') + image_url = validated_data.get('image_url') + + if advertisement and image_url: + self.Meta.model.objects.get_or_create(source=Page.MOBILE, **validated_data) + self.Meta.model.objects.get_or_create(source=Page.WEB, **validated_data) diff --git a/apps/transfer/serializers/notification.py b/apps/transfer/serializers/notification.py index 7eb7bdac..dc0ca4f3 100644 --- a/apps/transfer/serializers/notification.py +++ b/apps/transfer/serializers/notification.py @@ -1,3 +1,4 @@ +from django.db import IntegrityError from rest_framework import serializers from account.models import User @@ -41,10 +42,10 @@ class NewsletterSubscriberSerializer(serializers.Serializer): id = serializers.IntegerField() email_address__email = serializers.CharField() email_address__account_id = serializers.IntegerField(allow_null=True) - email_address__ip = serializers.CharField(allow_null=True) - email_address__country_code = serializers.CharField(allow_null=True) - email_address__locale = serializers.CharField(allow_null=True) - created_at = serializers.DateTimeField(format='%m-%d-%Y %H:%M:%S') + email_address__ip = serializers.CharField(allow_null=True, allow_blank=True) + email_address__country_code = serializers.CharField(allow_null=True, allow_blank=True) + email_address__locale = serializers.CharField(allow_null=True, allow_blank=True) + updated_at = serializers.DateTimeField(format='%m-%d-%Y %H:%M:%S') def validate(self, data): data.update({ @@ -53,18 +54,28 @@ class NewsletterSubscriberSerializer(serializers.Serializer): 'ip_address': data.pop('email_address__ip'), 'country_code': data.pop('email_address__country_code'), 'locale': data.pop('email_address__locale'), - 'created': data.pop('created_at'), + 'created': data.pop('updated_at'), 'user_id': self.get_user(data), }) data.pop('email_address__account_id') return data - # def create(self, validated_data): - # obj, _ = Review.objects.update_or_create( - # old_id=validated_data['old_id'], - # defaults=validated_data, - # ) - # return obj + def create(self, validated_data): + try: + obj = Subscriber.objects.get(email=validated_data['email']) + except Subscriber.DoesNotExist: + obj = Subscriber.objects.create(**validated_data) + else: + current_data = obj.created + if validated_data['created'] > current_data: + obj.ip_address = validated_data['ip_address'] + obj.locale = validated_data['locale'] + obj.country_code = validated_data['country_code'] + obj.old_id = validated_data['old_id'] + obj.created = validated_data['created'] + obj.user_id = validated_data['user_id'] + obj.save() + return obj @staticmethod def get_user(data): @@ -73,6 +84,6 @@ class NewsletterSubscriberSerializer(serializers.Serializer): return None user = User.objects.filter(old_id=data['email_address__account_id']).first() - if not user: - raise ValueError(f"User account not found with old_id {data['email_address__account_id']}") - return user.id + if user: + return user.id + return None diff --git a/apps/transfer/serializers/partner.py b/apps/transfer/serializers/partner.py index 7de61486..69cf308e 100644 --- a/apps/transfer/serializers/partner.py +++ b/apps/transfer/serializers/partner.py @@ -2,27 +2,28 @@ from rest_framework import serializers from partner.models import Partner -class PartnerSerializer(serializers.ModelSerializer): - backlink_url = serializers.CharField(source="url") - partnership_icon = serializers.CharField() - partnership_name = serializers.CharField() +class PartnerSerializer(serializers.Serializer): + pass + # 'id', + # 'establishment_id', + # 'partnership_name', + # 'partnership_icon', + # 'backlink_url', + # 'created_at', + # 'type', + # 'starting_date', + # 'expiry_date', + # 'price_per_month', - class Meta: - model = Partner - fields = ( - "backlink_url", - "partnership_icon", - "partnership_name" - ) - def validate(self, data): - data["image"] = partnership_to_image_url.get(data["partnership_name"]).get(data["partnership_icon"]) - data.pop("partnership_name") - data.pop("partnership_icon") - return data - - def create(self, validated_data): - return Partner.objects.create(**validated_data) + # def validate(self, data): + # data["image"] = partnership_to_image_url.get(data["partnership_name"]).get(data["partnership_icon"]) + # data.pop("partnership_name") + # data.pop("partnership_icon") + # return data + # + # def create(self, validated_data): + # return Partner.objects.create(**validated_data) partnership_to_image_url = { diff --git a/apps/transfer/serializers/product.py b/apps/transfer/serializers/product.py index f0871c41..86c6720a 100644 --- a/apps/transfer/serializers/product.py +++ b/apps/transfer/serializers/product.py @@ -265,6 +265,7 @@ class ProductSerializer(TransferSerializerMixin): state = serializers.CharField() bottles_produced = serializers.CharField(allow_null=True, allow_blank=True) unique_key = serializers.CharField(allow_null=True) + price = serializers.DecimalField(max_digits=14, decimal_places=2, allow_null=True) class Meta: model = models.Product @@ -287,6 +288,7 @@ class ProductSerializer(TransferSerializerMixin): 'state', # done 'bottles_produced', # done 'unique_key', # done + 'price', ) def validate(self, attrs): @@ -308,6 +310,7 @@ class ProductSerializer(TransferSerializerMixin): old_id = attrs.pop('id') state = self.get_state(attrs.pop('state', None)) + attrs['old_id'] = old_id attrs['name'] = name attrs['old_unique_key'] = attrs.pop('unique_key') @@ -332,6 +335,7 @@ class ProductSerializer(TransferSerializerMixin): attrs['wine_village'] = self.get_wine_village(village) attrs['available'] = self.get_availability(state) attrs['slug'] = self.get_slug(name, old_id) + attrs['average_price'] = attrs.pop('price') return attrs def create(self, validated_data): diff --git a/apps/transfer/serializers/reviews.py b/apps/transfer/serializers/reviews.py index a6cb1124..6e0db860 100644 --- a/apps/transfer/serializers/reviews.py +++ b/apps/transfer/serializers/reviews.py @@ -69,6 +69,7 @@ class ProductReviewSerializer(ReviewSerializer): data.pop('reviewer_id') data.pop('product_id') data.pop('aasm_state') + data.pop('establishment_id') return data def create(self, validated_data): @@ -103,6 +104,9 @@ class ReviewTextSerializer(serializers.Serializer): 'new_text': self.get_text(data), 'review': self.get_review(data), }) + data.pop('review_id') + data.pop('locale') + data.pop('text') return data def create(self, validated_data): @@ -117,7 +121,8 @@ class ReviewTextSerializer(serializers.Serializer): @staticmethod def get_text(data): locale = data['locale'] or 'en-GB' - return {locale: data['text']} + text = data['text'] + return {locale: text} @staticmethod def get_review(data): diff --git a/apps/utils/models.py b/apps/utils/models.py index cb51b5ca..f86093af 100644 --- a/apps/utils/models.py +++ b/apps/utils/models.py @@ -238,6 +238,10 @@ class SVGImageMixin(models.Model): validators=[svg_image_validator, ], verbose_name=_('SVG image')) + @property + def svg_image_indexing(self): + return self.svg_image.url if self.svg_image else None + class Meta: abstract = True @@ -345,4 +349,90 @@ class GMTokenGenerator(PasswordResetTokenGenerator): return self.get_fields(user, timestamp) +class GalleryModelMixin(models.Model): + """Mixin for models that has gallery.""" + + @property + def crop_gallery(self): + if hasattr(self, 'gallery'): + gallery = [] + images = self.gallery.all() + crop_parameters = [p for p in settings.SORL_THUMBNAIL_ALIASES + if p.startswith(self._meta.model_name.lower())] + for image in images: + d = { + 'id': image.id, + 'title': image.title, + 'original_url': image.image.url, + 'orientation_display': image.get_orientation_display(), + 'auto_crop_images': {}, + } + for crop in crop_parameters: + d['auto_crop_images'].update({crop: image.get_image_url(crop)}) + gallery.append(d) + return gallery + + @property + def crop_main_image(self): + if hasattr(self, 'main_image') and self.main_image: + image = self.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.""" + abstract = True + + +class IntermediateGalleryModelQuerySet(models.QuerySet): + """Extended QuerySet.""" + + def main_image(self): + """Return objects with flag is_main is True""" + return self.filter(is_main=True) + + +class IntermediateGalleryModelMixin(models.Model): + """Mixin for intermediate gallery model.""" + + is_main = models.BooleanField(default=False, + verbose_name=_('Is the main image')) + + objects = IntermediateGalleryModelQuerySet.as_manager() + + class Meta: + """Meta class.""" + abstract = True + + def __str__(self): + """Overridden str method.""" + if hasattr(self, 'image'): + return self.image.title if self.image.title else self.id + + +class HasTagsMixin(models.Model): + """Mixin for filtering tags""" + + @property + def visible_tags(self): + return self.tags.filter(category__public=True).prefetch_related('category')\ + .exclude(category__value_type='bool') + + class Meta: + """Meta class.""" + abstract = True + + timezone.datetime.now().date().isoformat() \ No newline at end of file diff --git a/apps/utils/serializers.py b/apps/utils/serializers.py index 9a7be59d..b78c202c 100644 --- a/apps/utils/serializers.py +++ b/apps/utils/serializers.py @@ -5,6 +5,7 @@ from rest_framework import serializers from utils import models from translation.models import Language from favorites.models import Favorites +from gallery.models import Image class EmptySerializer(serializers.Serializer): @@ -104,3 +105,13 @@ class RecursiveFieldSerializer(serializers.Serializer): def to_representation(self, value): serializer = self.parent.parent.__class__(value, context=self.context) return serializer.data + + +class ImageBaseSerializer(serializers.Serializer): + """Serializer for returning crop images of model image.""" + + id = serializers.IntegerField() + title = serializers.CharField() + original_url = serializers.URLField() + orientation_display = serializers.CharField() + auto_crop_images = serializers.DictField(allow_null=True) diff --git a/apps/utils/views.py b/apps/utils/views.py index d3d09079..a8580f59 100644 --- a/apps/utils/views.py +++ b/apps/utils/views.py @@ -4,6 +4,7 @@ from django.conf import settings from django.db.transaction import on_commit from rest_framework import generics from rest_framework import status +from rest_framework.decorators import action from rest_framework.response import Response from gallery.tasks import delete_image @@ -11,7 +12,7 @@ from gallery.tasks import delete_image # JWT # Login base view mixins -class JWTGenericViewMixin(generics.GenericAPIView): +class JWTGenericViewMixin: """JWT view mixin""" ACCESS_TOKEN_HTTP_ONLY = False @@ -38,30 +39,31 @@ class JWTGenericViewMixin(generics.GenericAPIView): """ COOKIES = [] - if hasattr(self.request, 'locale'): - COOKIES.append(self.COOKIE(key='locale', - value=self.request.locale, - http_only=self.ACCESS_TOKEN_HTTP_ONLY, - secure=self.LOCALE_SECURE, - max_age=settings.COOKIES_MAX_AGE if permanent else None)) - if hasattr(self.request, 'country_code'): - COOKIES.append(self.COOKIE(key='country_code', - value=self.request.country_code, - http_only=self.COUNTRY_CODE_HTTP_ONLY, - secure=self.COUNTRY_CODE_SECURE, - max_age=settings.COOKIES_MAX_AGE if permanent else None)) - if access_token: - COOKIES.append(self.COOKIE(key='access_token', - value=access_token, - http_only=self.ACCESS_TOKEN_HTTP_ONLY, - secure=self.ACCESS_TOKEN_SECURE, - max_age=settings.COOKIES_MAX_AGE if permanent else None)) - if refresh_token: - COOKIES.append(self.COOKIE(key='refresh_token', - value=refresh_token, - http_only=self.REFRESH_TOKEN_HTTP_ONLY, - secure=self.REFRESH_TOKEN_SECURE, - max_age=settings.COOKIES_MAX_AGE if permanent else None)) + if hasattr(self, 'request'): + if hasattr(self.request, 'locale'): + COOKIES.append(self.COOKIE(key='locale', + value=self.request.locale, + http_only=self.ACCESS_TOKEN_HTTP_ONLY, + secure=self.LOCALE_SECURE, + max_age=settings.COOKIES_MAX_AGE if permanent else None)) + if hasattr(self.request, 'country_code'): + COOKIES.append(self.COOKIE(key='country_code', + value=self.request.country_code, + http_only=self.COUNTRY_CODE_HTTP_ONLY, + secure=self.COUNTRY_CODE_SECURE, + max_age=settings.COOKIES_MAX_AGE if permanent else None)) + if access_token: + COOKIES.append(self.COOKIE(key='access_token', + value=access_token, + http_only=self.ACCESS_TOKEN_HTTP_ONLY, + secure=self.ACCESS_TOKEN_SECURE, + max_age=settings.COOKIES_MAX_AGE if permanent else None)) + if refresh_token: + COOKIES.append(self.COOKIE(key='refresh_token', + value=refresh_token, + http_only=self.REFRESH_TOKEN_HTTP_ONLY, + secure=self.REFRESH_TOKEN_SECURE, + max_age=settings.COOKIES_MAX_AGE if permanent else None)) return COOKIES def _put_cookies_in_response(self, cookies: list, response: Response): @@ -121,3 +123,30 @@ class CreateDestroyGalleryViewMixin(generics.CreateAPIView, # Delete an instances of Gallery model gallery_obj.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +# BackOffice user`s views & viewsets +class BindObjectMixin: + """Bind object mixin.""" + + def get_serializer_class(self): + if self.action == 'bind_object': + return self.bind_object_serializer_class + return self.serializer_class + + def perform_binding(self, serializer): + raise NotImplemented + + def perform_unbinding(self, serializer): + raise NotImplemented + + @action(methods=['post', 'delete'], detail=True, url_path='bind-object') + def bind_object(self, request, pk=None): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + if request.method == 'POST': + self.perform_binding(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + elif request.method == 'DELETE': + self.perform_unbinding(serializer) + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/docker-compose.elasticsearch.yml b/docker-compose.elasticsearch.yml new file mode 100644 index 00000000..3b412ab1 --- /dev/null +++ b/docker-compose.elasticsearch.yml @@ -0,0 +1,21 @@ +version: '3.5' +services: + elasticsearch: + image: elasticsearch:7.3.1 + volumes: + - gm-esdata:/usr/share/elasticsearch/data + hostname: elasticsearch + network_mode: 'host' + environment: + - "ES_JAVA_OPTS=-Xms4g -Xmx4g" + - discovery.type=single-node + - xpack.security.enabled=false + restart: always + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + mem_limit: 4g \ No newline at end of file diff --git a/project/settings/base.py b/project/settings/base.py index bc162a74..80c2af97 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -388,6 +388,14 @@ SORL_THUMBNAIL_ALIASES = { 'news_editor_mobile': {'geometry_string': '343x260', 'crop': 'center'}, # при загрузке через контент эдитор 'avatar_comments_web': {'geometry_string': '116x116', 'crop': 'center'}, # через контент эдитор в мобильном браузерe 'product_preview': {'geometry_string': '300x260', 'crop': 'center'}, + 'establishment_preview': {'geometry_string': '300x260', 'crop': 'center'}, + 'establishment_xsmall': {'geometry_string': '60x34', 'crop': 'center'}, + 'establishment_small': {'geometry_string': '80x45', 'crop': 'center'}, + 'establishment_medium': {'geometry_string': '280x158', 'crop': 'center'}, + 'establishment_large': {'geometry_string': '440x248', 'crop': 'center'}, + 'establishment_xlarge': {'geometry_string': '640x360', 'crop': 'center'}, + 'establishment_detail': {'geometry_string': '2048x1152', 'crop': 'center'}, + 'establishment_original': {'geometry_string': '1920x1080', 'crop': 'center'}, } @@ -501,3 +509,5 @@ FALLBACK_LOCALE = 'en-GB' CAROUSEL_ITEMS = [230, 231, 232] ESTABLISHMENT_CHOSEN_TAGS = ['gastronomic', 'en_vogue', 'terrace', 'streetfood', 'business', 'bar_cocktail', 'brunch', 'pop'] NEWS_CHOSEN_TAGS = ['eat', 'drink', 'cook', 'style', 'international', 'event', 'partnership'] +INTERNATIONAL_COUNTRY_CODES = ['www', 'main', 'next'] + diff --git a/project/settings/development.py b/project/settings/development.py index 3bc258a1..06f1199b 100644 --- a/project/settings/development.py +++ b/project/settings/development.py @@ -21,7 +21,7 @@ DOMAIN_URI = 'gm.id-east.ru' # ELASTICSEARCH SETTINGS ELASTICSEARCH_DSL = { 'default': { - 'hosts': 'localhost:9200' + 'hosts': '188.68.209.124:9200' # 'hosts': 'elasticsearch:9200' } } @@ -33,6 +33,7 @@ ELASTICSEARCH_INDEX_NAMES = { 'search_indexes.documents.product': 'development_product', } +# ELASTICSEARCH_DSL_AUTOSYNC = False sentry_sdk.init( dsn="https://35d9bb789677410ab84a822831c6314f@sentry.io/1729093", diff --git a/project/settings/local.py b/project/settings/local.py index 04dbafb6..959e6149 100644 --- a/project/settings/local.py +++ b/project/settings/local.py @@ -43,6 +43,7 @@ INSTALLED_APPS.append('transfer.apps.TransferConfig') DATABASES.update({ 'legacy': { 'ENGINE': 'django.db.backends.mysql', + # 'HOST': '172.22.0.1', 'HOST': 'mysql_db', 'PORT': 3306, 'NAME': 'dev', diff --git a/project/urls/back.py b/project/urls/back.py index 8e983ebc..fdd3d10a 100644 --- a/project/urls/back.py +++ b/project/urls/back.py @@ -13,5 +13,7 @@ urlpatterns = [ path('review/', include('review.urls.back')), path('tags/', include(('tag.urls.back', 'tag'), namespace='tag')), path('products/', include(('product.urls.back', 'product'), namespace='product')), + path('re_blocks/', include(('advertisement.urls.back', 'advertisement'), + namespace='advertisement')), + path('main/', include('main.urls.back')), ] -