diff --git a/apps/booking/views.py b/apps/booking/views.py index b3f85bff..89b9dd05 100644 --- a/apps/booking/views.py +++ b/apps/booking/views.py @@ -1,4 +1,5 @@ from django.shortcuts import get_object_or_404 +from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, permissions, status, serializers from rest_framework.response import Response @@ -96,6 +97,13 @@ class CreatePendingBooking(generics.CreateAPIView): permission_classes = (permissions.AllowAny,) serializer_class = PendingBookingSerializer + @swagger_auto_schema(operation_description="Request body params\n\n" + "IN GUESTONLINE (type:G): {" + "'restaurant_id', 'booking_time', " + "'booking_date', 'booked_persons_number'}\n" + "IN LASTABLE (type:L): {'booking_time', " + "'booked_persons_number', 'offer_id' (Req), " + "'email', 'phone', 'first_name', 'last_name'}") def post(self, request, *args, **kwargs): data = request.data.copy() if data.get('type') == Booking.LASTABLE and data.get("offer_id") is None: @@ -135,6 +143,10 @@ class UpdatePendingBooking(generics.UpdateAPIView): permission_classes = (permissions.AllowAny,) serializer_class = UpdateBookingSerializer + @swagger_auto_schema(operation_description="Request body params\n\n" + "Required: 'email', 'phone', 'last_name', " + "'first_name', 'country_code', 'pending_booking_id'," + "Not req: 'note'") def patch(self, request, *args, **kwargs): instance = self.get_object() data = request.data.copy() diff --git a/apps/collection/migrations/0024_auto_20191213_0859.py b/apps/collection/migrations/0024_auto_20191213_0859.py new file mode 100644 index 00000000..93951057 --- /dev/null +++ b/apps/collection/migrations/0024_auto_20191213_0859.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.7 on 2019-12-13 08:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0006_merge_20191027_1758'), + ('collection', '0023_advertorial'), + ] + + operations = [ + migrations.CreateModel( + name='AdvertorialGallery', + 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')), + ('advertorial', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='advertorial_gallery', to='collection.Advertorial', verbose_name='advertorial')), + ('image', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='advertorial_gallery', to='gallery.Image', verbose_name='image')), + ], + options={ + 'verbose_name': 'advertorial gallery', + 'verbose_name_plural': 'advertorial galleries', + 'unique_together': {('advertorial', 'image')}, + }, + ), + migrations.AddField( + model_name='advertorial', + name='gallery', + field=models.ManyToManyField(through='collection.AdvertorialGallery', to='gallery.Image'), + ), + ] diff --git a/apps/collection/migrations/0024_auto_20191215_2156.py b/apps/collection/migrations/0024_auto_20191215_2156.py new file mode 100644 index 00000000..1b494867 --- /dev/null +++ b/apps/collection/migrations/0024_auto_20191215_2156.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.7 on 2019-12-15 21:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('collection', '0023_advertorial'), + ] + + operations = [ + migrations.AddField( + model_name='collection', + name='rank', + field=models.IntegerField(default=None, null=True), + ), + migrations.AlterField( + model_name='collection', + name='start', + field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='start'), + ), + migrations.RemoveField( + model_name='collection', + name='description', + ) + ] diff --git a/apps/collection/migrations/0025_collection_description.py b/apps/collection/migrations/0025_collection_description.py new file mode 100644 index 00000000..d7638db3 --- /dev/null +++ b/apps/collection/migrations/0025_collection_description.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.7 on 2019-12-16 17:25 + +from django.db import migrations + +import utils.models + +class Migration(migrations.Migration): + dependencies = [ + ('collection', '0024_auto_20191215_2156'), + ] + + operations = [ + migrations.AddField( + model_name='collection', + name='description', + field=utils.models.TJSONField(blank=True, default=None, + help_text='{"en-GB":"some text"}', null=True, + verbose_name='description'), + ), + ] diff --git a/apps/collection/migrations/0026_merge_20191217_1151.py b/apps/collection/migrations/0026_merge_20191217_1151.py new file mode 100644 index 00000000..52f14bb7 --- /dev/null +++ b/apps/collection/migrations/0026_merge_20191217_1151.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.7 on 2019-12-17 11:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('collection', '0024_auto_20191213_0859'), + ('collection', '0025_collection_description'), + ] + + operations = [ + ] diff --git a/apps/collection/migrations/0027_auto_20191218_0753.py b/apps/collection/migrations/0027_auto_20191218_0753.py new file mode 100644 index 00000000..3314a01f --- /dev/null +++ b/apps/collection/migrations/0027_auto_20191218_0753.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.7 on 2019-12-18 07:53 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0007_auto_20191211_1528'), + ('collection', '0026_merge_20191217_1151'), + ] + + operations = [ + migrations.RemoveField( + model_name='advertorial', + name='gallery', + ), + migrations.AddField( + model_name='guideelement', + name='label_photo', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='gallery.Image', verbose_name='label photo'), + ), + migrations.DeleteModel( + name='AdvertorialGallery', + ), + ] diff --git a/apps/collection/models.py b/apps/collection/models.py index 7acd9991..90837cd7 100644 --- a/apps/collection/models.py +++ b/apps/collection/models.py @@ -6,10 +6,12 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ -from utils.models import ProjectBaseMixin, URLImageMixin -from utils.models import TJSONField -from utils.models import TranslatedFieldsMixin +from utils.models import ( + ProjectBaseMixin, TJSONField, TranslatedFieldsMixin, + URLImageMixin, +) from utils.querysets import RelatedObjectsCountMixin +from utils.models import IntermediateGalleryModelMixin, GalleryModelMixin # Mixins @@ -24,7 +26,8 @@ class CollectionNameMixin(models.Model): class CollectionDateMixin(models.Model): """CollectionDate mixin""" - start = models.DateTimeField(_('start')) + start = models.DateTimeField(blank=True, null=True, default=None, + verbose_name=_('start')) end = models.DateTimeField(blank=True, null=True, default=None, verbose_name=_('end')) @@ -80,6 +83,8 @@ class Collection(ProjectBaseMixin, CollectionDateMixin, verbose_name=_('Collection slug'), editable=True, null=True) old_id = models.IntegerField(null=True, blank=True) + rank = models.IntegerField(null=True, default=None) + objects = CollectionQuerySet.as_manager() class Meta: @@ -108,20 +113,32 @@ class Collection(ProjectBaseMixin, CollectionDateMixin, @property def related_object_names(self) -> list: """Return related object names.""" - raw_object_names = [] + raw_objects = [] for related_object in [related_object.name for related_object in self._related_objects]: instances = getattr(self, f'{related_object}') if instances.exists(): for instance in instances.all(): - raw_object_names.append(instance.slug if hasattr(instance, 'slug') else None) + raw_object = (instance.id, instance.slug) if hasattr(instance, 'slug') else ( + instance.id, None + ) + raw_objects.append(raw_object) # parse slugs - object_names = [] + related_objects = [] + object_names = set() re_pattern = r'[\w]+' - for raw_name in raw_object_names: + for object_id, raw_name, in raw_objects: result = re.findall(re_pattern, raw_name) - if result: object_names.append(' '.join(result).capitalize()) - return set(object_names) + if result: + name = ' '.join(result).capitalize() + if name not in object_names: + related_objects.append({ + 'id': object_id, + 'name': name + }) + object_names.add(name) + + return related_objects class GuideTypeQuerySet(models.QuerySet): @@ -194,6 +211,17 @@ class Guide(ProjectBaseMixin, CollectionNameMixin, CollectionDateMixin): """String method.""" return f'{self.name}' + @property + def entities(self): + """Return entities and its count.""" + # todo: to work + return { + 'Current': 0, + 'Initial': 0, + 'Restaurants': 0, + 'Shops': 0, + } + class AdvertorialQuerySet(models.QuerySet): """QuerySet for model Advertorial.""" @@ -364,6 +392,9 @@ class GuideElement(ProjectBaseMixin, MPTTModel): parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children') + label_photo = models.ForeignKey('gallery.Image', on_delete=models.SET_NULL, + null=True, blank=True, default=None, + verbose_name=_('label photo')) old_id = models.PositiveIntegerField(blank=True, null=True, default=None, verbose_name=_('old id')) diff --git a/apps/collection/serializers/__init__.py b/apps/collection/serializers/__init__.py index e69de29b..0a46cb96 100644 --- a/apps/collection/serializers/__init__.py +++ b/apps/collection/serializers/__init__.py @@ -0,0 +1,3 @@ +from .back import * +from .web import * +from .common import * diff --git a/apps/collection/serializers/back.py b/apps/collection/serializers/back.py index 48c25f6c..35917142 100644 --- a/apps/collection/serializers/back.py +++ b/apps/collection/serializers/back.py @@ -7,7 +7,8 @@ from location.models import Country from location.serializers import CountrySimpleSerializer from product.models import Product from utils.exceptions import ( - BindingObjectNotFound, RemovedBindingObjectNotFound, ObjectAlreadyAdded + BindingObjectNotFound, ObjectAlreadyAdded, + RemovedBindingObjectNotFound, ) @@ -33,13 +34,14 @@ class CollectionBackOfficeSerializer(CollectionBaseSerializer): 'on_top', 'country', 'country_id', - 'block_size', + # 'block_size', 'description', 'slug', - 'start', - 'end', + # 'start', + # 'end', 'count_related_objects', 'related_object_names', + 'rank', ] @@ -68,15 +70,15 @@ class CollectionBindObjectSerializer(serializers.Serializer): attrs['collection'] = collection if obj_type == self.ESTABLISHMENT: - establishment = Establishment.objects.filter(pk=obj_id).\ + establishment = Establishment.objects.filter(pk=obj_id). \ first() if not establishment: raise BindingObjectNotFound() - if request.method == 'POST' and collection.establishments.\ + 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).\ + if request.method == 'DELETE' and not collection. \ + establishments.filter(pk=establishment.pk). \ exists(): raise RemovedBindingObjectNotFound() attrs['related_object'] = establishment @@ -84,10 +86,10 @@ class CollectionBindObjectSerializer(serializers.Serializer): product = Product.objects.filter(pk=obj_id).first() if not product: raise BindingObjectNotFound() - if request.method == 'POST' and collection.products.\ + if request.method == 'POST' and collection.products. \ filter(pk=product.pk).exists(): raise ObjectAlreadyAdded() - if request.method == 'DELETE' and not collection.products.\ + if request.method == 'DELETE' and not collection.products. \ filter(pk=product.pk).exists(): raise RemovedBindingObjectNotFound() attrs['related_object'] = product diff --git a/apps/collection/serializers/common.py b/apps/collection/serializers/common.py index 1b043f3c..59ac5065 100644 --- a/apps/collection/serializers/common.py +++ b/apps/collection/serializers/common.py @@ -2,6 +2,7 @@ from rest_framework import serializers from collection import models from location import models as location_models +from main.serializers import SiteShortSerializer from utils.serializers import TranslatedField @@ -47,8 +48,28 @@ class CollectionSerializer(CollectionBaseSerializer): ] -class GuideSerializer(serializers.ModelSerializer): +class GuideTypeBaseSerializer(serializers.ModelSerializer): + """GuideType serializer.""" + + class Meta: + """Meta class.""" + model = models.GuideType + fields = [ + 'id', + 'name', + ] + + +class GuideBaseSerializer(serializers.ModelSerializer): """Guide serializer""" + state_display = serializers.CharField(source='get_state_display', + read_only=True) + guide_type_detail = GuideTypeBaseSerializer(read_only=True, + source='guide_type') + site_detail = SiteShortSerializer(read_only=True, + source='site') + entities = serializers.DictField(read_only=True) + class Meta: model = models.Guide fields = [ @@ -56,4 +77,60 @@ class GuideSerializer(serializers.ModelSerializer): 'name', 'start', 'end', + 'vintage', + 'slug', + 'guide_type', + 'guide_type_detail', + 'site', + 'site_detail', + 'state', + 'state_display', + 'entities', ] + extra_kwargs = { + 'guide_type': {'write_only': True}, + 'site': {'write_only': True}, + 'state': {'write_only': True}, + 'start': {'required': True}, + 'slug': {'required': True}, + } + + +class GuideFilterBaseSerializer(serializers.ModelSerializer): + """GuideFilter serializer""" + + class Meta: + """Meta class.""" + model = models.GuideFilter + fields = [ + 'id', + 'establishment_type_json', + 'country_json', + 'region_json', + 'sub_region_json', + 'wine_region_json', + 'with_mark', + 'locale_json', + 'max_mark', + 'min_mark', + 'review_vintage_json', + 'review_state_json', + 'guide', + ] + extra_kwargs = { + 'guide': {'write_only': True} + } + + @property + def request_kwargs(self): + """Get url kwargs from request.""" + return self.context.get('request').parser_context.get('kwargs') + + def get_guide(self): + """Get guide instance from kwargs.""" + return self.request_kwargs.get() + + def create(self, validated_data): + """Overridden create method.""" + validated_data['guide'] = self.get_guide(validated_data.pop('guide', None)) + return super().create(validated_data) diff --git a/apps/collection/transfer_data.py b/apps/collection/transfer_data.py index 5d705b53..a268f62b 100644 --- a/apps/collection/transfer_data.py +++ b/apps/collection/transfer_data.py @@ -1,15 +1,19 @@ from pprint import pprint + from tqdm import tqdm + +from collection.models import GuideElementSection, GuideElementSectionCategory, \ + GuideWineColorSection, GuideElementType, GuideElement, \ + Guide, Advertorial from establishment.models import Establishment -from review.models import Review +from gallery.models import Image from location.models import WineRegion, City from product.models import Product +from review.models import Review from transfer.models import Guides, GuideFilters, GuideSections, GuideElements, \ - GuideAds + GuideAds, LabelPhotos from transfer.serializers.guide import GuideSerializer, GuideFilterSerializer -from collection.models import GuideElementSection, GuideElementSectionCategory, \ - GuideWineColorSection, GuideElementType, GuideElement, \ - Guide, Advertorial +from django.db.models import Subquery def transfer_guide(): @@ -252,7 +256,7 @@ def transfer_guide_element_advertorials(): qs = GuideElement.objects.filter(old_id=old_id) legacy_qs = GuideElements.objects.exclude(guide__isnull=True) \ .exclude(guide__title__icontains='test') \ - .filter(id=guide_ad_node_id) + .filter(id=old_id) if qs.exists() and legacy_qs.exists(): return qs.first() elif legacy_qs.exists() and not qs.exists(): @@ -285,6 +289,55 @@ def transfer_guide_element_advertorials(): print(f'COUNT OF CREATED OBJECTS: {len(objects_to_update)}') +def transfer_guide_element_label_photo(): + """Transfer galleries for Guide Advertorial model.""" + def get_guide_element(guide_ad): + legacy_guide_element_id = guide_ad.guide_ad_node.id + + legacy_guide_element_qs = GuideElements.objects.filter(id=legacy_guide_element_id) + guide_element_qs = GuideElement.objects.filter(old_id=legacy_guide_element_id) + + if guide_element_qs.exists() and legacy_guide_element_qs.exists(): + return guide_element_qs.first() + else: + raise ValueError(f'Guide element was not transfer correctly - ' + f'{legacy_guide_element_id}.') + + to_update = [] + not_updated = 0 + guide_element_label_photos = LabelPhotos.objects.exclude(guide_ad__isnull=True) \ + .filter(guide_ad__type='GuideAdLabel') \ + .distinct() \ + .values_list('guide_ad', 'attachment_suffix_url') + for guide_ad_id, attachment_suffix_url in tqdm(guide_element_label_photos): + legacy_guide_element_ids = Subquery( + GuideElements.objects.exclude(guide__isnull=True) + .exclude(guide__title__icontains='test') + .values_list('id', flat=True) + ) + legacy_guide_ad_qs = GuideAds.objects.filter(id=guide_ad_id, + guide_ad_node_id__in=legacy_guide_element_ids) + if legacy_guide_ad_qs.exists(): + guide_element = get_guide_element(legacy_guide_ad_qs.first()) + if guide_element: + image, _ = Image.objects.get_or_create(image=attachment_suffix_url, + defaults={ + 'image': attachment_suffix_url, + 'orientation': Image.HORIZONTAL, + 'title': f'{guide_element.__str__()} ' + f'{guide_element.id} - ' + f'{attachment_suffix_url}'}) + if not guide_element.label_photo: + guide_element.label_photo = image + to_update.append(guide_element) + else: + not_updated += 1 + + GuideElement.objects.bulk_update(to_update, ['label_photo', ]) + print(f'Added label photo to {len(to_update)} objects\n' + f'Objects {not_updated} not updated') + + data_types = { 'guides': [ transfer_guide, @@ -305,7 +358,10 @@ data_types = { transfer_guide_elements_bulk, ], 'guide_element_advertorials': [ - transfer_guide_element_advertorials + transfer_guide_element_advertorials, + ], + 'guide_element_label_photo': [ + transfer_guide_element_label_photo, ], 'guide_complete': [ transfer_guide, # transfer guides from Guides @@ -315,5 +371,6 @@ data_types = { transfer_guide_element_type, # partial transfer section types from GuideElements transfer_guide_elements_bulk, # transfer result of GuideFilters from GuideElements transfer_guide_element_advertorials, # transfer advertorials that linked to GuideElements + transfer_guide_element_label_photo, # transfer guide element label photos ] } diff --git a/apps/collection/urls/back.py b/apps/collection/urls/back.py index 6a6dbd54..6db5bde2 100644 --- a/apps/collection/urls/back.py +++ b/apps/collection/urls/back.py @@ -1,10 +1,17 @@ """Collection common urlpaths.""" from rest_framework.routers import SimpleRouter +from django.urls import path from collection.views import back as views app_name = 'collection' -router = SimpleRouter() -router.register(r'', views.CollectionBackOfficeViewSet) -urlpatterns = router.urls +router = SimpleRouter() +router.register(r'collections', views.CollectionBackOfficeViewSet) + +urlpatterns = [ + path('guides/', views.GuideListCreateView.as_view(), + name='guide-list-create'), + path('guides//filters/', views.GuideFilterCreateView.as_view(), + name='guide-filter-list-create'), +] + router.urls diff --git a/apps/collection/urls/common.py b/apps/collection/urls/common.py index 36801ac5..35c52cc2 100644 --- a/apps/collection/urls/common.py +++ b/apps/collection/urls/common.py @@ -7,10 +7,7 @@ app_name = 'collection' urlpatterns = [ path('', views.CollectionHomePageView.as_view(), name='list'), - path('/', views.CollectionDetailView.as_view(), name='detail'), - path('/establishments/', views.CollectionEstablishmentListView.as_view(), + path('slug//', views.CollectionDetailView.as_view(), name='detail'), + path('slug//establishments/', views.CollectionEstablishmentListView.as_view(), name='detail'), - - path('guides/', views.GuideListView.as_view(), name='guides-list'), - path('guides//', views.GuideRetrieveView.as_view(), name='guides-detail'), ] diff --git a/apps/collection/urls/web.py b/apps/collection/urls/web.py index 85cdf1a5..5b5d34d4 100644 --- a/apps/collection/urls/web.py +++ b/apps/collection/urls/web.py @@ -2,6 +2,8 @@ from collection.urls.common import urlpatterns as common_url_patterns +app_name = 'web' + urlpatterns_api = [] diff --git a/apps/collection/views/back.py b/apps/collection/views/back.py index ff924073..b1f25628 100644 --- a/apps/collection/views/back.py +++ b/apps/collection/views/back.py @@ -1,8 +1,11 @@ -from rest_framework import permissions -from rest_framework import viewsets, mixins +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import generics +from rest_framework import mixins, permissions, viewsets +from rest_framework import status +from rest_framework.filters import OrderingFilter +from rest_framework.response import Response -from collection import models -from collection.serializers import back as serializers +from collection import models, serializers from utils.views import BindObjectMixin @@ -21,6 +24,22 @@ class CollectionViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): return qs +class GuideBaseView(generics.GenericAPIView): + """ViewSet for Guide model.""" + pagination_class = None + queryset = models.Guide.objects.all() + serializer_class = serializers.GuideBaseSerializer + permission_classes = (permissions.IsAuthenticated,) + + +class GuideFilterBaseView(generics.GenericAPIView): + """ViewSet for GuideFilter model.""" + pagination_class = None + queryset = models.GuideFilter.objects.all() + serializer_class = serializers.GuideFilterBaseSerializer + permission_classes = (permissions.IsAuthenticated,) + + class CollectionBackOfficeViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, @@ -31,9 +50,13 @@ class CollectionBackOfficeViewSet(mixins.CreateModelMixin, permission_classes = (permissions.IsAuthenticated,) queryset = models.Collection.objects.all() + filter_backends = [DjangoFilterBackend, OrderingFilter] serializer_class = serializers.CollectionBackOfficeSerializer bind_object_serializer_class = serializers.CollectionBindObjectSerializer + ordering_fields = ('rank', 'start') + ordering = ('-start', ) + def perform_binding(self, serializer): data = serializer.validated_data collection = data.pop('collection') @@ -53,3 +76,19 @@ class CollectionBackOfficeViewSet(mixins.CreateModelMixin, collection.establishments.remove(related_object) elif obj_type == self.bind_object_serializer_class.PRODUCT: collection.products.remove(related_object) + + +class GuideListCreateView(GuideBaseView, + generics.ListCreateAPIView): + """ViewSet for Guide model for BackOffice users.""" + def post(self, request, *args, **kwargs): + super().create(request, *args, **kwargs) + return Response(status=status.HTTP_200_OK) + + +class GuideFilterCreateView(GuideFilterBaseView, + generics.CreateAPIView): + """ViewSet for GuideFilter model for BackOffice users.""" + def post(self, request, *args, **kwargs): + super().create(request, *args, **kwargs) + return Response(status=status.HTTP_200_OK) diff --git a/apps/collection/views/common.py b/apps/collection/views/common.py index 8ea20d8d..aa3c991a 100644 --- a/apps/collection/views/common.py +++ b/apps/collection/views/common.py @@ -17,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', '-created') class GuideViewMixin(generics.GenericAPIView): @@ -39,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): @@ -72,10 +72,10 @@ class CollectionEstablishmentListView(CollectionListView): class GuideListView(GuideViewMixin, generics.ListAPIView): """List Guide view""" permission_classes = (permissions.AllowAny,) - serializer_class = serializers.GuideSerializer + serializer_class = serializers.GuideBaseSerializer class GuideRetrieveView(GuideViewMixin, generics.RetrieveAPIView): """Retrieve Guide view""" permission_classes = (permissions.AllowAny,) - serializer_class = serializers.GuideSerializer + serializer_class = serializers.GuideBaseSerializer diff --git a/apps/establishment/management/commands/add_establishment_description.py b/apps/establishment/management/commands/add_establishment_description.py index 533a8bd7..f0d7da59 100644 --- a/apps/establishment/management/commands/add_establishment_description.py +++ b/apps/establishment/management/commands/add_establishment_description.py @@ -1,5 +1,5 @@ from django.core.management.base import BaseCommand - +from tqdm import tqdm from establishment.models import Establishment from transfer.models import Reviews, ReviewTexts @@ -22,7 +22,7 @@ class Command(BaseCommand): 'updated_at', ) - for r_id, establishment_id, new_date in queryset: + for r_id, establishment_id, new_date in tqdm(queryset): try: review_id, date = valid_reviews[establishment_id] except KeyError: @@ -41,7 +41,7 @@ class Command(BaseCommand): 'text', ) - for es_id, locale, text in text_qs: + for es_id, locale, text in tqdm(text_qs): establishment = Establishment.objects.filter(old_id=es_id).first() if establishment: description = establishment.description @@ -53,7 +53,7 @@ class Command(BaseCommand): count += 1 # Если нет en-GB в поле - for establishment in Establishment.objects.filter(old_id__isnull=False): + for establishment in tqdm(Establishment.objects.filter(old_id__isnull=False)): description = establishment.description if len(description) and 'en-GB' not in description: description.update({ diff --git a/apps/establishment/management/commands/add_true_description.py b/apps/establishment/management/commands/add_true_description.py new file mode 100644 index 00000000..ab810507 --- /dev/null +++ b/apps/establishment/management/commands/add_true_description.py @@ -0,0 +1,45 @@ +from django.core.management.base import BaseCommand +from tqdm import tqdm + +from establishment.models import Establishment +from transfer.models import Descriptions + + +class Command(BaseCommand): + help = """Add description to establishment from old db.""" + + def handle(self, *args, **kwarg): + establishments = Establishment.objects.exclude(old_id__isnull=True) + + self.stdout.write(self.style.WARNING(f'Clear old descriptions')) + for item in tqdm(establishments): + item.description = None + item.save() + + queryset = Descriptions.objects.filter( + establishment_id__in=list(establishments.values_list('old_id', flat=True)), + ).values_list('establishment_id', 'locale', 'text') + + self.stdout.write(self.style.WARNING(f'Update new description')) + for establishment_id, locale, text in tqdm(queryset): + establishment = Establishment.objects.filter(old_id=establishment_id).first() + if establishment: + if establishment.description: + establishment.description.update({ + locale: text + }) + else: + establishment.description = {locale: text} + establishment.save() + + self.stdout.write(self.style.WARNING(f'Update en-GB description')) + for establishment in tqdm(establishments.filter(description__isnull=False)): + description = establishment.description + if len(description) and 'en-GB' not in description: + description.update({ + 'en-GB': next(iter(description.values())) + }) + establishment.description = description + establishment.save() + + self.stdout.write(self.style.WARNING(f'Done')) diff --git a/apps/establishment/models.py b/apps/establishment/models.py index ab7f14fa..8162aab1 100644 --- a/apps/establishment/models.py +++ b/apps/establishment/models.py @@ -212,6 +212,10 @@ class EstablishmentQuerySet(models.QuerySet): output_field=models.FloatField(default=0) )) + def has_location(self): + """Return objects with geo location.""" + return self.filter(address__coordinates__isnull=False) + def similar_base(self, establishment): """ Return filtered QuerySet by base filters. @@ -267,25 +271,30 @@ class EstablishmentQuerySet(models.QuerySet): else: return self.none() - def similar_artisans(self, slug): + def same_subtype(self, establishment): + """Annotate flag same subtype.""" + return self.annotate(same_subtype=Case( + models.When( + establishment_subtypes__in=establishment.establishment_subtypes.all(), + then=True + ), + default=False, + output_field=models.BooleanField(default=False) + )) + + def similar_artisans_producers(self, slug): """ - Return QuerySet with objects that similar to Artisan. - :param slug: str artisan slug + Return QuerySet with objects that similar to Artisan/Producer(s). + :param slug: str artisan/producer slug """ - artisan_qs = self.filter(slug=slug) - if artisan_qs.exists(): - artisan = artisan_qs.first() - ids_by_subquery = self.similar_base_subquery( - establishment=artisan, - filters={ - 'public_mark__gte': 10, - } - ) - return self.filter(id__in=ids_by_subquery) \ - .annotate_intermediate_public_mark() \ - .annotate_mark_similarity(mark=artisan.public_mark) \ - .order_by('mark_similarity') \ - .distinct('mark_similarity', 'id') + establishment_qs = self.filter(slug=slug) + if establishment_qs.exists(): + establishment = establishment_qs.first() + return self.similar_base(establishment) \ + .same_subtype(establishment) \ + .order_by(F('same_subtype').desc(), + F('distance').asc()) \ + .distinct('same_subtype', 'distance', 'id') else: return self.none() @@ -541,9 +550,15 @@ class Establishment(GalleryModelMixin, ProjectBaseMixin, URLImageMixin, def visible_tags(self): return super().visible_tags \ .exclude(category__index_name__in=['guide', 'collection', 'purchased_item', - 'business_tag', 'business_tags_de', 'tag']) + 'business_tag', 'business_tags_de']) \ + .exclude(value__in=['rss', 'rss_selection']) # todo: recalculate toque_number + @property + def visible_tags_detail(self): + """Removes some tags from detail Establishment representation""" + return self.visible_tags.exclude(category__index_name__in=['tag']) + def recalculate_toque_number(self): toque_number = 0 if self.address and self.public_mark: diff --git a/apps/establishment/serializers/back.py b/apps/establishment/serializers/back.py index b7d429da..fbdd7a10 100644 --- a/apps/establishment/serializers/back.py +++ b/apps/establishment/serializers/back.py @@ -225,18 +225,19 @@ class EstablishmentBackOfficeGallerySerializer(serializers.ModelSerializer): 'is_main', ] - def get_request_kwargs(self): + @property + def request_kwargs(self): """Get url kwargs from request.""" return self.context.get('request').parser_context.get('kwargs') def validate(self, attrs): """Override validate method.""" - establishment_pk = self.get_request_kwargs().get('pk') - establishment_slug = self.get_request_kwargs().get('slug') + establishment_pk = self.request_kwargs.get('pk') + establishment_slug = self.request_kwargs.get('slug') search_kwargs = {'pk': establishment_pk} if establishment_pk else {'slug': establishment_slug} - image_id = self.get_request_kwargs().get('image_id') + image_id = self.request_kwargs.get('image_id') establishment_qs = models.Establishment.objects.filter(**search_kwargs) image_qs = Image.objects.filter(id=image_id) diff --git a/apps/establishment/urls/common.py b/apps/establishment/urls/common.py index 046667df..54944a0a 100644 --- a/apps/establishment/urls/common.py +++ b/apps/establishment/urls/common.py @@ -17,12 +17,14 @@ urlpatterns = [ path('slug//favorites/', views.EstablishmentFavoritesCreateDestroyView.as_view(), name='create-destroy-favorites'), - # similar establishments + # similar establishments by type/subtype path('slug//similar/', views.RestaurantSimilarListView.as_view(), name='similar-restaurants'), path('slug//similar/wineries/', views.WinerySimilarListView.as_view(), name='similar-wineries'), - path('slug//similar/artisans/', views.ArtisanSimilarListView.as_view(), + # temporary uses single mechanism, bec. description in process + path('slug//similar/artisans/', views.ArtisanProducerSimilarListView.as_view(), name='similar-artisans'), - + path('slug//similar/producers/', views.ArtisanProducerSimilarListView.as_view(), + name='similar-producers'), ] diff --git a/apps/establishment/views/web.py b/apps/establishment/views/web.py index 4253b6a6..7eba8607 100644 --- a/apps/establishment/views/web.py +++ b/apps/establishment/views/web.py @@ -8,7 +8,7 @@ from comment import models as comment_models from comment.serializers import CommentRUDSerializer from establishment import filters, models, serializers from main import methods -from utils.pagination import EstablishmentPortionPagination +from utils.pagination import PortionPagination from utils.views import FavoritesCreateDestroyMixinView, CarouselCreateDestroyMixinView @@ -41,6 +41,12 @@ class EstablishmentListView(EstablishmentMixinView, generics.ListAPIView): .with_certain_tag_category_related('shop_category', 'artisan_category') +class EstablishmentSimilarView(EstablishmentListView): + """Resource for getting a list of similar establishments.""" + serializer_class = serializers.EstablishmentSimilarSerializer + pagination_class = PortionPagination + + class EstablishmentRetrieveView(EstablishmentMixinView, generics.RetrieveAPIView): """Resource for getting a establishment.""" @@ -61,7 +67,7 @@ class EstablishmentMobileRetrieveView(EstablishmentRetrieveView): class EstablishmentRecentReviewListView(EstablishmentListView): """List view for last reviewed establishments.""" - pagination_class = EstablishmentPortionPagination + pagination_class = PortionPagination def get_queryset(self): """Overridden method 'get_queryset'.""" @@ -77,37 +83,34 @@ class EstablishmentRecentReviewListView(EstablishmentListView): return qs.last_reviewed(point=point) -class EstablishmentSimilarList(EstablishmentListView): - """Resource for getting a list of similar establishments.""" - serializer_class = serializers.EstablishmentSimilarSerializer - pagination_class = EstablishmentPortionPagination - - -class RestaurantSimilarListView(EstablishmentSimilarList): +class RestaurantSimilarListView(EstablishmentSimilarView): """Resource for getting a list of similar restaurants.""" def get_queryset(self): """Overridden get_queryset method""" return EstablishmentMixinView.get_queryset(self) \ + .has_location() \ .similar_restaurants(slug=self.kwargs.get('slug')) -class WinerySimilarListView(EstablishmentSimilarList): +class WinerySimilarListView(EstablishmentSimilarView): """Resource for getting a list of similar wineries.""" def get_queryset(self): """Overridden get_queryset method""" return EstablishmentMixinView.get_queryset(self) \ + .has_location() \ .similar_wineries(slug=self.kwargs.get('slug')) -class ArtisanSimilarListView(EstablishmentSimilarList): - """Resource for getting a list of similar artisans.""" +class ArtisanProducerSimilarListView(EstablishmentSimilarView): + """Resource for getting a list of similar artisan/producer(s).""" def get_queryset(self): """Overridden get_queryset method""" return EstablishmentMixinView.get_queryset(self) \ - .similar_artisans(slug=self.kwargs.get('slug')) + .has_location() \ + .similar_artisans_producers(slug=self.kwargs.get('slug')) class EstablishmentTypeListView(generics.ListAPIView): diff --git a/apps/location/serializers/back.py b/apps/location/serializers/back.py index 9a263acd..8f231d69 100644 --- a/apps/location/serializers/back.py +++ b/apps/location/serializers/back.py @@ -35,14 +35,15 @@ class CityGallerySerializer(serializers.ModelSerializer): 'is_main', ] - def get_request_kwargs(self): + @property + def request_kwargs(self): """Get url kwargs from request.""" return self.context.get('request').parser_context.get('kwargs') def validate(self, attrs): """Override validate method.""" - city_pk = self.get_request_kwargs().get('pk') - image_id = self.get_request_kwargs().get('image_id') + city_pk = self.request_kwargs.get('pk') + image_id = self.request_kwargs.get('image_id') city_qs = models.City.objects.filter(pk=city_pk) image_qs = Image.objects.filter(id=image_id) diff --git a/apps/location/serializers/common.py b/apps/location/serializers/common.py index 6255e67f..65c30b46 100644 --- a/apps/location/serializers/common.py +++ b/apps/location/serializers/common.py @@ -54,6 +54,7 @@ class RegionSerializer(serializers.ModelSerializer): 'country_id' ] + class CityShortSerializer(serializers.ModelSerializer): """Short city serializer""" country = CountrySerializer(read_only=True) @@ -89,7 +90,6 @@ class CitySerializer(serializers.ModelSerializer): fields = [ 'id', 'name', - 'code', 'region', 'region_id', 'country_id', diff --git a/apps/location/views/back.py b/apps/location/views/back.py index fc4499ae..5dcb55bf 100644 --- a/apps/location/views/back.py +++ b/apps/location/views/back.py @@ -1,4 +1,5 @@ """Location app views.""" +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import generics from location import models, serializers @@ -11,6 +12,7 @@ from utils.serializers import ImageBaseSerializer from location import filters + # Address @@ -18,29 +20,36 @@ class AddressListCreateView(common.AddressViewMixin, generics.ListCreateAPIView) """Create view for model Address.""" serializer_class = serializers.AddressDetailSerializer queryset = models.Address.objects.all() - permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin] class AddressRUDView(common.AddressViewMixin, generics.RetrieveUpdateDestroyAPIView): """RUD view for model Address.""" serializer_class = serializers.AddressDetailSerializer queryset = models.Address.objects.all() - permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin] # City class CityListCreateView(common.CityViewMixin, generics.ListCreateAPIView): """Create view for model City.""" serializer_class = serializers.CitySerializer - permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] - queryset = models.City.objects.all() + permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin] + # queryset = models.City.objects.all() filter_class = filters.CityBackFilter + def get_queryset(self): + """Overridden method 'get_queryset'.""" + qs = models.City.objects.all() + if self.request.country_code: + qs = qs.by_country_code(self.request.country_code) + return qs + class CityListSearchView(common.CityViewMixin, generics.ListCreateAPIView): """Create view for model City.""" serializer_class = serializers.CitySerializer - permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin] queryset = models.City.objects.all() filter_class = filters.CityBackFilter pagination_class = None @@ -49,14 +58,14 @@ class CityListSearchView(common.CityViewMixin, generics.ListCreateAPIView): class CityRUDView(common.CityViewMixin, generics.RetrieveUpdateDestroyAPIView): """RUD view for model City.""" serializer_class = serializers.CitySerializer - permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] + 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] + permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin] def get_object(self): """ @@ -77,7 +86,7 @@ class CityGalleryListView(common.CityViewMixin, generics.ListAPIView): """Resource for returning gallery for product for back-office users.""" serializer_class = ImageBaseSerializer - permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin] def get_object(self): """Override get_object method.""" @@ -99,13 +108,18 @@ class RegionListCreateView(common.RegionViewMixin, generics.ListCreateAPIView): """Create view for model Region""" pagination_class = None serializer_class = serializers.RegionSerializer - permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin] + filter_backends = (DjangoFilterBackend,) + ordering_fields = '__all__' + filterset_fields = ( + 'country', + ) class RegionRUDView(common.RegionViewMixin, generics.RetrieveUpdateDestroyAPIView): """Retrieve view for model Region""" serializer_class = serializers.RegionSerializer - permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin] # Country @@ -114,11 +128,11 @@ class CountryListCreateView(generics.ListCreateAPIView): queryset = models.Country.objects.all() serializer_class = serializers.CountryBackSerializer pagination_class = None - permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin] class CountryRUDView(generics.RetrieveUpdateDestroyAPIView): """RUD view for model Country.""" serializer_class = serializers.CountryBackSerializer - permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] - queryset = models.Country.objects.all() \ No newline at end of file + permission_classes = [IsAuthenticatedOrReadOnly | IsCountryAdmin] + queryset = models.Country.objects.all() diff --git a/apps/main/admin.py b/apps/main/admin.py index 6a12541d..6009f404 100644 --- a/apps/main/admin.py +++ b/apps/main/admin.py @@ -11,7 +11,7 @@ class SiteSettingsInline(admin.TabularInline): @admin.register(models.SiteSettings) class SiteSettingsAdmin(admin.ModelAdmin): """Site settings admin conf.""" - inlines = [SiteSettingsInline,] + inlines = [SiteSettingsInline, ] @admin.register(models.Feature) @@ -62,6 +62,11 @@ class FooterAdmin(admin.ModelAdmin): list_display = ('id', 'site', ) +@admin.register(models.FooterLink) +class FooterLinkAdmin(admin.ModelAdmin): + """FooterLink admin.""" + + @admin.register(models.Panel) class PanelAdmin(admin.ModelAdmin): """Panel admin.""" diff --git a/apps/main/methods.py b/apps/main/methods.py index f19d595a..14583660 100644 --- a/apps/main/methods.py +++ b/apps/main/methods.py @@ -37,6 +37,13 @@ def determine_country_code(request): return country_code.lower() +def determine_country_name(request): + """Determine country name.""" + META = request.META + return META.get('X-GeoIP-Country-Name', + META.get('HTTP_X_GEOIP_COUNTRY_NAME')) + + def determine_coordinates(request): META = request.META longitude = META.get('X-GeoIP-Longitude', diff --git a/apps/main/migrations/0043_auto_20191217_1120.py b/apps/main/migrations/0043_auto_20191217_1120.py new file mode 100644 index 00000000..79bbf767 --- /dev/null +++ b/apps/main/migrations/0043_auto_20191217_1120.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.7 on 2019-12-17 11:20 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0042_panel'), + ] + + operations = [ + migrations.CreateModel( + name='FooterLinks', + 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')), + ('link', models.URLField(verbose_name='link')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='footer', + name='links', + field=models.ManyToManyField(related_name='link_footer', to='main.FooterLinks', verbose_name='links'), + ), + ] diff --git a/apps/main/migrations/0044_auto_20191217_1125.py b/apps/main/migrations/0044_auto_20191217_1125.py new file mode 100644 index 00000000..90f51d51 --- /dev/null +++ b/apps/main/migrations/0044_auto_20191217_1125.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.7 on 2019-12-17 11:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0043_auto_20191217_1120'), + ] + + operations = [ + migrations.RenameModel( + old_name='FooterLinks', + new_name='FooterLink', + ), + ] diff --git a/apps/main/models.py b/apps/main/models.py index 80fc5a1a..83109f40 100644 --- a/apps/main/models.py +++ b/apps/main/models.py @@ -283,6 +283,11 @@ class Carousel(models.Model): @property def slug(self): + if hasattr(self.content_object, 'slugs'): + try: + return next(iter(self.content_object.slugs.values())) + except StopIteration: + return None if hasattr(self.content_object, 'slug'): return self.content_object.slug @@ -358,6 +363,11 @@ class PageType(ProjectBaseMixin): return self.name +class FooterLink(ProjectBaseMixin): + link = models.URLField(_('link')) + title = models.CharField(_('title'), max_length=255) + + class Footer(ProjectBaseMixin): site = models.ForeignKey( 'main.SiteSettings', related_name='footers', verbose_name=_('footer'), @@ -365,6 +375,7 @@ class Footer(ProjectBaseMixin): ) about_us = models.TextField(_('about_us')) copyright = models.TextField(_('copyright')) + links = models.ManyToManyField(FooterLink, verbose_name=_('links'), related_name='link_footer') class PanelQuerySet(models.QuerySet): diff --git a/apps/main/views/common.py b/apps/main/views/common.py index 20033661..c565998d 100644 --- a/apps/main/views/common.py +++ b/apps/main/views/common.py @@ -6,7 +6,6 @@ from rest_framework.response import Response from main import methods, models, serializers - # # class FeatureViewMixin: # """Feature view mixin.""" @@ -85,8 +84,14 @@ class DetermineLocation(generics.GenericAPIView): def get(self, request, *args, **kwargs): longitude, latitude = methods.determine_coordinates(request) city = methods.determine_user_city(request) - if longitude and latitude and city: - return Response(data={'latitude': latitude, 'longitude': longitude, 'city': city}) - else: - raise Http404 - + country_name = methods.determine_country_name(request) + country_code = methods.determine_country_code(request) + if longitude and latitude and city and country_name: + return Response(data={ + 'latitude': latitude, + 'longitude': longitude, + 'city': city, + 'country_name': country_name, + 'country_code': country_code, + }) + raise Http404 diff --git a/apps/news/filters.py b/apps/news/filters.py index e8e35307..44583a35 100644 --- a/apps/news/filters.py +++ b/apps/news/filters.py @@ -72,4 +72,6 @@ class NewsListFilterSet(filters.FilterSet): return queryset def sort_by_field(self, queryset, name, value): + if value == self.SORT_BY_START_CHOICE: + return queryset.order_by('-publication_date', '-publication_time') return queryset.order_by(f'-{value}') diff --git a/apps/news/migrations/0043_auto_20191216_1920.py b/apps/news/migrations/0043_auto_20191216_1920.py new file mode 100644 index 00000000..03f5a991 --- /dev/null +++ b/apps/news/migrations/0043_auto_20191216_1920.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.7 on 2019-12-16 19:20 + +import django.contrib.postgres.fields.hstore +from django.db import migrations, models +import uuid + +def fill_uuid(apps, schemaeditor): + News = apps.get_model('news', 'News') + for news in News.objects.all(): + news.duplication_uuid = uuid.uuid4() + news.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0042_news_duplication_date'), + ] + + operations = [ + migrations.AddField( + model_name='news', + name='description_to_locale_is_active', + field=django.contrib.postgres.fields.hstore.HStoreField(blank=True, default=dict, help_text='{"en-GB": true, "fr-FR": false}', null=True, verbose_name='Is description for certain locale active'), + ), + migrations.AddField( + model_name='news', + name='duplication_uuid', + field=models.UUIDField(default=uuid.uuid4, verbose_name='Field to detect doubles'), + ), + migrations.AlterField( + model_name='news', + name='slugs', + field=django.contrib.postgres.fields.hstore.HStoreField(blank=True, default=dict, help_text='{"en-GB":"some slug"}', null=True, verbose_name='Slugs for current news obj'), + ), + migrations.RunPython(fill_uuid, migrations.RunPython.noop), + ] diff --git a/apps/news/migrations/0044_auto_20191216_2044.py b/apps/news/migrations/0044_auto_20191216_2044.py new file mode 100644 index 00000000..3854cc70 --- /dev/null +++ b/apps/news/migrations/0044_auto_20191216_2044.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-12-16 20:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0043_auto_20191216_1920'), + ] + + operations = [ + migrations.RenameField( + model_name='news', + old_name='description_to_locale_is_active', + new_name='locale_to_description_is_active', + ), + ] diff --git a/apps/news/migrations/0045_news_must_of_the_week.py b/apps/news/migrations/0045_news_must_of_the_week.py new file mode 100644 index 00000000..57f5f351 --- /dev/null +++ b/apps/news/migrations/0045_news_must_of_the_week.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-12-17 17:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0044_auto_20191216_2044'), + ] + + operations = [ + migrations.AddField( + model_name='news', + name='must_of_the_week', + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/news/migrations/0046_auto_20191218_1437.py b/apps/news/migrations/0046_auto_20191218_1437.py new file mode 100644 index 00000000..5fe1c3d6 --- /dev/null +++ b/apps/news/migrations/0046_auto_20191218_1437.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.7 on 2019-12-18 14:37 + +from django.db import migrations, models + + +def fill_publication_date_and_time(apps, schema_editor): + News = apps.get_model('news', 'News') + for news in News.objects.all(): + if news.start is not None: + news.publication_date = news.start.date() + news.publication_time = news.start.time() + news.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0045_news_must_of_the_week'), + ] + + operations = [ + migrations.AddField( + model_name='news', + name='publication_date', + field=models.DateField(blank=True, help_text='date since when news item is published', null=True, verbose_name='News publication date'), + ), + migrations.AddField( + model_name='news', + name='publication_time', + field=models.TimeField(blank=True, help_text='time since when news item is published', null=True, verbose_name='News publication time'), + ), + migrations.AlterField( + model_name='news', + name='must_of_the_week', + field=models.BooleanField(default=False, verbose_name='Show in the carousel'), + ), + migrations.RunPython(fill_publication_date_and_time, migrations.RunPython.noop), + ] diff --git a/apps/news/migrations/0047_remove_news_start.py b/apps/news/migrations/0047_remove_news_start.py new file mode 100644 index 00000000..4490ea23 --- /dev/null +++ b/apps/news/migrations/0047_remove_news_start.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.7 on 2019-12-18 16:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0046_auto_20191218_1437'), + ] + + operations = [ + migrations.RemoveField( + model_name='news', + name='start', + ), + ] diff --git a/apps/news/migrations/0048_remove_news_must_of_the_week.py b/apps/news/migrations/0048_remove_news_must_of_the_week.py new file mode 100644 index 00000000..b7186901 --- /dev/null +++ b/apps/news/migrations/0048_remove_news_must_of_the_week.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.7 on 2019-12-19 13:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0047_remove_news_start'), + ] + + operations = [ + migrations.RemoveField( + model_name='news', + name='must_of_the_week', + ), + ] diff --git a/apps/news/models.py b/apps/news/models.py index 6ebdca76..ab65ed88 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -1,18 +1,23 @@ """News app models.""" +import uuid + +from django.conf import settings from django.contrib.contenttypes import fields as generic +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import HStoreField from django.db import models from django.db.models import Case, When from django.utils import timezone from django.utils.translation import gettext_lazy as _ from rest_framework.reverse import reverse +from main.models import Carousel from rating.models import Rating, ViewCount from utils.models import (BaseAttributes, TJSONField, TranslatedFieldsMixin, HasTagsMixin, ProjectBaseMixin, GalleryModelMixin, IntermediateGalleryModelMixin, FavoritesMixin) from utils.querysets import TranslationQuerysetMixin -from django.conf import settings -from django.contrib.postgres.fields import HStoreField +from datetime import datetime class Agenda(ProjectBaseMixin, TranslatedFieldsMixin): @@ -62,7 +67,7 @@ class NewsQuerySet(TranslationQuerysetMixin): def sort_by_start(self): """Return qs sorted by start DESC""" - return self.order_by('-start') + return self.order_by('-publication_date', '-publication_time') def rating_value(self): return self.annotate(rating=models.Count('ratings__ip', distinct=True)) @@ -97,9 +102,13 @@ class NewsQuerySet(TranslationQuerysetMixin): def published(self): """Return only published news""" now = timezone.now() - return self.filter(models.Q(models.Q(end__gte=now) | + date_now = now.date() + time_now = now.time() + return self.exclude(models.Q(publication_date__isnull=True) | models.Q(publication_time__isnull=True)). \ + filter(models.Q(models.Q(end__gte=now) | models.Q(end__isnull=True)), - state__in=self.model.PUBLISHED_STATES, start__lte=now) + state__in=self.model.PUBLISHED_STATES, publication_date__lte=date_now, + publication_time__lte=time_now) # todo: filter by best score # todo: filter by country? @@ -112,7 +121,7 @@ class NewsQuerySet(TranslationQuerysetMixin): return self.model.objects.exclude(pk=news.pk).published(). \ annotate_in_favorites(user). \ with_base_related().by_type(news.news_type). \ - by_tags(news.tags.all()).distinct().order_by('-start') + by_tags(news.tags.all()).distinct().sort_by_start() def annotate_in_favorites(self, user): """Annotate flag in_favorites""" @@ -127,6 +136,9 @@ class NewsQuerySet(TranslationQuerysetMixin): ) ) + def by_locale(self, locale): + return self.filter(title__icontains=locale) + class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixin, FavoritesMixin): @@ -170,20 +182,25 @@ class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixi verbose_name=_('title'), help_text='{"en-GB":"some text"}') backoffice_title = models.TextField(null=True, default=None, - verbose_name=_('Title for searching via BO')) + verbose_name=_('Title for searching via BO')) subtitle = TJSONField(blank=True, null=True, default=None, verbose_name=_('subtitle'), help_text='{"en-GB":"some text"}') description = TJSONField(blank=True, null=True, default=None, verbose_name=_('description'), help_text='{"en-GB":"some text"}') - start = models.DateTimeField(blank=True, null=True, default=None, - verbose_name=_('Start')) + locale_to_description_is_active = HStoreField(null=True, default=dict, blank=True, + verbose_name=_('Is description for certain locale active'), + help_text='{"en-GB": true, "fr-FR": false}') + publication_date = models.DateField(blank=True, null=True, verbose_name=_('News publication date'), + help_text=_('date since when news item is published')) + publication_time = models.TimeField(blank=True, null=True, verbose_name=_('News publication time'), + help_text=_('time since when news item is published')) end = models.DateTimeField(blank=True, null=True, default=None, verbose_name=_('End')) - slugs = HStoreField(null=True, blank=True, default=None, - verbose_name=_('Slugs for current news obj'), - help_text='{"en-GB":"some slug"}') + slugs = HStoreField(null=True, blank=True, default=dict, + verbose_name=_('Slugs for current news obj'), + help_text='{"en-GB":"some slug"}') state = models.PositiveSmallIntegerField(default=WAITING, choices=STATE_CHOICES, verbose_name=_('State')) is_highlighted = models.BooleanField(default=False, @@ -213,6 +230,8 @@ class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixi on_delete=models.SET_NULL, verbose_name=_('site settings')) duplication_date = models.DateTimeField(blank=True, null=True, default=None, verbose_name=_('Duplication datetime')) + duplication_uuid = models.UUIDField(default=uuid.uuid4, editable=True, unique=False, + verbose_name=_('Field to detect doubles')) objects = NewsQuerySet.as_manager() class Meta: @@ -233,6 +252,34 @@ class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixi self.duplication_date = timezone.now() self.save() + @property + def must_of_the_week(self) -> bool: + """Detects whether current item in carousel""" + kwargs = { + 'content_type': ContentType.objects.get_for_model(self), + 'object_id': self.pk, + 'country': self.country, + } + return Carousel.objects.filter(**kwargs).exists() + + @property + def publication_datetime(self): + """Represents datetime object combined from `publication_date` & `publication_time` fields""" + try: + return datetime.combine(date=self.publication_date, time=self.publication_time) + except TypeError: + return None + + @property + def duplicates(self): + """Duplicates for this news item excluding same country code labeled""" + return News.objects.filter(duplication_uuid=self.duplication_uuid).exclude(country=self.country) + + @property + def has_any_desc_active(self): + """Detects whether news item has any active description""" + return any(list(map(lambda v: v.lower() == 'true', self.locale_to_description_is_active.values()))) + @property def is_publish(self): return self.state in self.PUBLISHED_STATES @@ -317,7 +364,6 @@ class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixi class NewsGallery(IntermediateGalleryModelMixin): - news = models.ForeignKey(News, null=True, related_name='news_gallery', on_delete=models.CASCADE, @@ -331,4 +377,4 @@ class NewsGallery(IntermediateGalleryModelMixin): """NewsGallery meta class.""" verbose_name = _('news gallery') verbose_name_plural = _('news galleries') - unique_together = [['news', 'image'],] + unique_together = [['news', 'image'], ] diff --git a/apps/news/serializers.py b/apps/news/serializers.py index 5b9a8162..6841b954 100644 --- a/apps/news/serializers.py +++ b/apps/news/serializers.py @@ -5,7 +5,7 @@ from rest_framework.fields import SerializerMethodField from account.serializers.common import UserBaseSerializer from gallery.models import Image -from main.models import SiteSettings +from main.models import SiteSettings, Carousel from location import models as location_models from location.serializers import CountrySimpleSerializer, AddressBaseSerializer from news import models @@ -128,6 +128,7 @@ class NewsDetailSerializer(NewsBaseSerializer): state_display = serializers.CharField(source='get_state_display', read_only=True) gallery = ImageBaseSerializer(read_only=True, source='crop_gallery', many=True) + start = serializers.DateTimeField(source='publication_datetime', read_only=True) class Meta(NewsBaseSerializer.Meta): """Meta class.""" @@ -182,12 +183,17 @@ class NewsBackOfficeBaseSerializer(NewsBaseSerializer): 'backoffice_title', 'subtitle', 'slugs', + 'locale_to_description_is_active', 'is_published', 'duplication_date', + 'must_of_the_week', + 'publication_date', + 'publication_time', ) extra_kwargs = { - 'backoffice_title': {'allow_null': False}, 'duplication_date': {'read_only': True}, + 'locale_to_description_is_active': {'allow_null': False}, + 'must_of_the_week': {'read_only': True}, } def create(self, validated_data): @@ -209,6 +215,20 @@ class NewsBackOfficeBaseSerializer(NewsBaseSerializer): return super().update(instance, validated_data) +class NewsBackOfficeDuplicationInfoSerializer(serializers.ModelSerializer): + """Duplication info for news detail.""" + + country = CountrySimpleSerializer(read_only=True) + + class Meta: + model = models.News + fields = ( + 'id', + 'duplication_date', + 'country', + ) + + class NewsBackOfficeDetailSerializer(NewsBackOfficeBaseSerializer, NewsDetailSerializer): """News detail serializer for back-office users.""" @@ -224,6 +244,7 @@ class NewsBackOfficeDetailSerializer(NewsBackOfficeBaseSerializer, queryset=SiteSettings.objects.all()) template_display = serializers.CharField(source='get_template_display', read_only=True) + duplicates = NewsBackOfficeDuplicationInfoSerializer(many=True, allow_null=True, read_only=True) class Meta(NewsBackOfficeBaseSerializer.Meta, NewsDetailSerializer.Meta): """Meta class.""" @@ -237,6 +258,7 @@ class NewsBackOfficeDetailSerializer(NewsBackOfficeBaseSerializer, 'template', 'template_display', 'is_international', + 'duplicates', ) @@ -252,13 +274,14 @@ class NewsBackOfficeGallerySerializer(serializers.ModelSerializer): 'is_main', ] - def get_request_kwargs(self): + @property + def request_kwargs(self): """Get url kwargs from request.""" return self.context.get('request').parser_context.get('kwargs') def create(self, validated_data): - news_pk = self.get_request_kwargs().get('pk') - image_id = self.get_request_kwargs().get('image_id') + news_pk = self.request_kwargs.get('pk') + image_id = self.request_kwargs.get('image_id') qs = models.NewsGallery.objects.filter(image_id=image_id, news_id=news_pk) instance = qs.first() if instance: @@ -268,8 +291,8 @@ class NewsBackOfficeGallerySerializer(serializers.ModelSerializer): def validate(self, attrs): """Override validate method.""" - news_pk = self.get_request_kwargs().get('pk') - image_id = self.get_request_kwargs().get('image_id') + news_pk = self.request_kwargs.get('pk') + image_id = self.request_kwargs.get('image_id') news_qs = models.News.objects.filter(pk=news_pk) image_qs = Image.objects.filter(id=image_id) @@ -337,7 +360,12 @@ class NewsCarouselCreateSerializer(CarouselCreateSerializer): def create(self, validated_data, *args, **kwargs): validated_data.update({ - 'content_object': validated_data.pop('news') + 'country': validated_data['news'].country + }) + validated_data.update({ + 'content_object': validated_data.pop('news'), + 'is_parse': True, + 'active': True, }) return super().create(validated_data) @@ -347,9 +375,11 @@ class NewsCloneCreateSerializer(NewsBackOfficeBaseSerializer, """Serializer for creating news clone.""" template_display = serializers.CharField(source='get_template_display', read_only=True) + duplicates = NewsBackOfficeDuplicationInfoSerializer(many=True, allow_null=True, read_only=True) class Meta(NewsBackOfficeBaseSerializer.Meta, NewsDetailSerializer.Meta): fields = NewsBackOfficeBaseSerializer.Meta.fields + NewsDetailSerializer.Meta.fields + ( 'template_display', + 'duplicates', ) read_only_fields = fields @@ -359,5 +389,5 @@ class NewsCloneCreateSerializer(NewsBackOfficeBaseSerializer, new_country = get_object_or_404(location_models.Country, code=kwargs['country_code']) view_count_model = rating_models.ViewCount.objects.create(count=0) instance.create_duplicate(new_country, view_count_model) - return instance + return get_object_or_404(models.News, pk=kwargs['pk']) diff --git a/apps/news/urls/back.py b/apps/news/urls/back.py index e45a7337..3aabeeac 100644 --- a/apps/news/urls/back.py +++ b/apps/news/urls/back.py @@ -14,5 +14,5 @@ urlpatterns = [ path('/gallery//', views.NewsBackOfficeGalleryCreateDestroyView.as_view(), name='gallery-create-destroy'), path('/carousels/', views.NewsCarouselCreateDestroyView.as_view(), name='create-destroy-carousels'), - path('/clone/', views.NewsCloneView.as_view(), name='create-destroy-carousels'), + path('/clone/', views.NewsCloneView.as_view(), name='clone-news-item'), ] diff --git a/apps/news/views.py b/apps/news/views.py index c8acb3ac..6f301451 100644 --- a/apps/news/views.py +++ b/apps/news/views.py @@ -1,7 +1,8 @@ """News app views.""" from django.conf import settings from django.shortcuts import get_object_or_404 -from rest_framework import generics, permissions +from django.utils import translation +from rest_framework import generics, permissions, response from news import filters, models, serializers from rating.tasks import add_rating @@ -21,7 +22,7 @@ class NewsMixinView: qs = models.News.objects.published() \ .with_base_related() \ .annotate_in_favorites(self.request.user) \ - .order_by('-is_highlighted', '-start') + .order_by('-is_highlighted', '-publication_date', '-publication_time') country_code = self.request.country_code if country_code: @@ -29,6 +30,11 @@ class NewsMixinView: qs = qs.international_news() else: qs = qs.by_country_code(country_code) + + # locale = kwargs.get('locale') + # if locale: + # qs = qs.by_locale(locale) + return qs def get_object(self): @@ -43,8 +49,13 @@ class NewsListView(NewsMixinView, generics.ListAPIView): filter_class = filters.NewsListFilterSet def get_queryset(self, *args, **kwargs): - kwargs.update({'international_preferred': True}) - return super().get_queryset(*args, **kwargs) + locale = translation.get_language() + kwargs.update({ + 'international_preferred': True, + 'locale': locale, + }) + return super().get_queryset(*args, **kwargs)\ + .filter(locale_to_description_is_active__values__contains=['True']) class NewsDetailView(NewsMixinView, generics.RetrieveAPIView): @@ -99,7 +110,10 @@ class NewsBackOfficeLCView(NewsBackOfficeMixinView, def get_queryset(self): """Override get_queryset method.""" - return super().get_queryset().with_extended_related() + qs = super().get_queryset().with_extended_related() + if self.request.country_code: + qs = qs.by_country_code(self.request.country_code) + return qs class NewsBackOfficeGalleryCreateDestroyView(NewsBackOfficeMixinView, @@ -107,6 +121,13 @@ class NewsBackOfficeGalleryCreateDestroyView(NewsBackOfficeMixinView, """Resource for a create gallery for news for back-office users.""" serializer_class = serializers.NewsBackOfficeGallerySerializer + def create(self, request, *args, **kwargs): + _ = super().create(request, *args, **kwargs) + news_qs = self.filter_queryset(self.get_queryset()) + return response.Response( + data=serializers.NewsDetailSerializer(get_object_or_404(news_qs, pk=kwargs.get('pk'))).data + ) + def get_object(self): """ Returns the object the view is displaying. @@ -171,6 +192,6 @@ class NewsCarouselCreateDestroyView(CarouselCreateDestroyMixinView): class NewsCloneView(generics.CreateAPIView): """View for creating clone News""" - permission_classes = (permissions.AllowAny, ) + permission_classes = (permissions.AllowAny,) serializer_class = serializers.NewsCloneCreateSerializer queryset = models.News.objects.all() diff --git a/apps/product/models.py b/apps/product/models.py index 6923d6dd..7aeacdf2 100644 --- a/apps/product/models.py +++ b/apps/product/models.py @@ -1,13 +1,17 @@ """Product app models.""" +from django.conf import settings from django.contrib.contenttypes import fields as generic from django.contrib.gis.db import models as gis_models +from django.contrib.gis.db.models.functions import Distance +from django.contrib.gis.geos import Point from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import Case, When +from django.db.models import Case, When, F from django.utils.translation import gettext_lazy as _ from location.models import WineOriginAddressMixin +from review.models import Review from utils.models import (BaseAttributes, ProjectBaseMixin, HasTagsMixin, TranslatedFieldsMixin, TJSONField, FavoritesMixin, GalleryModelMixin, IntermediateGalleryModelMixin) @@ -136,6 +140,60 @@ class ProductQuerySet(models.QuerySet): ) ) + def annotate_distance(self, point: Point = None): + """ + Return QuerySet with annotated field - distance + Description: + + """ + return self.annotate(distance=Distance('establishment__address__coordinates', + point, + srid=settings.GEO_DEFAULT_SRID)) + + def has_location(self): + """Return objects with geo location.""" + return self.filter(establishment__address__coordinates__isnull=False) + + def same_subtype(self, product): + """Annotate flag same subtype.""" + return self.annotate(same_subtype=Case( + models.When( + subtypes__in=product.subtypes.all(), + then=True + ), + default=False, + output_field=models.BooleanField(default=False) + )) + + def similar_base(self, product): + """Return QuerySet filtered by base filters for Product model.""" + filters = { + 'reviews__status': Review.READY, + 'product_type': product.product_type, + } + if product.subtypes.exists(): + filters.update( + {'subtypes__in': product.subtypes.all()}) + return self.exclude(id=product.id) \ + .filter(**filters) \ + .annotate_distance(point=product.establishment.location) + + def similar(self, slug): + """ + Return QuerySet with objects that similar to Product. + :param slug: str product slug + """ + product_qs = self.filter(slug=slug) + if product_qs.exists(): + product = product_qs.first() + return self.similar_base(product) \ + .same_subtype(product) \ + .order_by(F('same_subtype').desc(), + F('distance').asc()) \ + .distinct('same_subtype', 'distance', 'id') + else: + return self.none() + class Product(GalleryModelMixin, TranslatedFieldsMixin, BaseAttributes, HasTagsMixin, FavoritesMixin): @@ -219,8 +277,8 @@ class Product(GalleryModelMixin, TranslatedFieldsMixin, BaseAttributes, awards = generic.GenericRelation(to='main.Award', related_query_name='product') serial_number = models.CharField(max_length=255, - default=None, null=True, - verbose_name=_('Serial number')) + default=None, null=True, + verbose_name=_('Serial number')) objects = ProductManager.from_queryset(ProductQuerySet)() diff --git a/apps/product/serializers/back.py b/apps/product/serializers/back.py index 55dc5ebc..01a1d7fe 100644 --- a/apps/product/serializers/back.py +++ b/apps/product/serializers/back.py @@ -22,14 +22,15 @@ class ProductBackOfficeGallerySerializer(serializers.ModelSerializer): 'is_main', ] - def get_request_kwargs(self): + @property + def request_kwargs(self): """Get url kwargs from request.""" return self.context.get('request').parser_context.get('kwargs') def validate(self, attrs): """Override validate method.""" - product_pk = self.get_request_kwargs().get('pk') - image_id = self.get_request_kwargs().get('image_id') + product_pk = self.request_kwargs.get('pk') + image_id = self.request_kwargs.get('image_id') product_qs = models.Product.objects.filter(pk=product_pk) image_qs = Image.objects.filter(id=image_id) diff --git a/apps/product/serializers/common.py b/apps/product/serializers/common.py index e0617e63..86344a36 100644 --- a/apps/product/serializers/common.py +++ b/apps/product/serializers/common.py @@ -218,4 +218,4 @@ class ProductCommentCreateSerializer(CommentSerializer): 'user': self.context.get('request').user, 'content_object': validated_data.pop('product') }) - return super().create(validated_data) \ No newline at end of file + return super().create(validated_data) diff --git a/apps/product/urls/common.py b/apps/product/urls/common.py index bd6c331d..4d64b93e 100644 --- a/apps/product/urls/common.py +++ b/apps/product/urls/common.py @@ -16,4 +16,13 @@ urlpatterns = [ name='create-comment'), path('slug//comments//', views.ProductCommentRUDView.as_view(), name='rud-comment'), + + # similar products by type/subtype + # temporary uses single mechanism, bec. description in process + path('slug//similar/wines/', views.SimilarListView.as_view(), + name='similar-wine'), + path('slug//similar/liquors/', views.SimilarListView.as_view(), + name='similar-liquor'), + path('slug//similar/food/', views.SimilarListView.as_view(), + name='similar-food'), ] diff --git a/apps/product/views/common.py b/apps/product/views/common.py index 650c1dfe..dbb24e53 100644 --- a/apps/product/views/common.py +++ b/apps/product/views/common.py @@ -6,6 +6,7 @@ from comment.models import Comment from product import filters, serializers from comment.serializers import CommentRUDSerializer from utils.views import FavoritesCreateDestroyMixinView +from utils.pagination import PortionPagination class ProductBaseView(generics.GenericAPIView): @@ -31,6 +32,12 @@ class ProductListView(ProductBaseView, generics.ListAPIView): return qs +class ProductSimilarView(ProductListView): + """Resource for getting a list of similar product.""" + serializer_class = serializers.ProductBaseSerializer + pagination_class = PortionPagination + + class ProductDetailView(ProductBaseView, generics.RetrieveAPIView): """Detail view fro model Product.""" lookup_field = 'slug' @@ -81,3 +88,14 @@ class ProductCommentRUDView(generics.RetrieveUpdateDestroyAPIView): self.check_object_permissions(self.request, comment_obj) return comment_obj + + +class SimilarListView(ProductSimilarView): + """Return similar products.""" + + def get_queryset(self): + """Overridden get_queryset method.""" + return super().get_queryset() \ + .has_location() \ + .similar(slug=self.kwargs.get('slug')) + diff --git a/apps/recipe/migrations/0002_recipe_old_id.py b/apps/recipe/migrations/0002_recipe_old_id.py new file mode 100644 index 00000000..d5313561 --- /dev/null +++ b/apps/recipe/migrations/0002_recipe_old_id.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-12-16 06:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recipe', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='recipe', + name='old_id', + field=models.PositiveIntegerField(blank=True, default=None, null=True, verbose_name='old id'), + ), + ] diff --git a/apps/recipe/migrations/0003_recipe_slug.py b/apps/recipe/migrations/0003_recipe_slug.py new file mode 100644 index 00000000..11a5a102 --- /dev/null +++ b/apps/recipe/migrations/0003_recipe_slug.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-12-16 13:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recipe', '0002_recipe_old_id'), + ] + + operations = [ + migrations.AddField( + model_name='recipe', + name='slug', + field=models.SlugField(max_length=255, null=True, unique=True, verbose_name='Slug'), + ), + ] diff --git a/apps/recipe/models.py b/apps/recipe/models.py index 349fed7b..c419be4c 100644 --- a/apps/recipe/models.py +++ b/apps/recipe/models.py @@ -25,6 +25,9 @@ class RecipeQuerySet(models.QuerySet): default=False, output_field=models.BooleanField(default=False))) + def by_locale(self, locale): + return self.filter(title__icontains=locale) + class Recipe(TranslatedFieldsMixin, ImageMixin, BaseAttributes): """Recipe model.""" @@ -43,22 +46,19 @@ class Recipe(TranslatedFieldsMixin, ImageMixin, BaseAttributes): STR_FIELD_NAME = 'title' - title = TJSONField(blank=True, null=True, default=None, verbose_name=_('Title'), - help_text='{"en-GB": "some text"}') + title = TJSONField(blank=True, null=True, default=None, verbose_name=_('Title'), help_text='{"en-GB": "some text"}') subtitle = TJSONField(blank=True, null=True, default=None, verbose_name=_('Subtitle'), help_text='{"en-GB": "some text"}') description = TJSONField(blank=True, null=True, default=None, verbose_name=_('Description'), help_text='{"en-GB": "some text"}') - state = models.PositiveSmallIntegerField(default=WAITING, choices=STATE_CHOICES, - verbose_name=_('State')) - author = models.CharField(max_length=255, blank=True, null=True, default=None, - verbose_name=_('Author')) - published_at = models.DateTimeField(verbose_name=_('Published at'), - blank=True, default=None, null=True, + state = models.PositiveSmallIntegerField(default=WAITING, choices=STATE_CHOICES, verbose_name=_('State')) + author = models.CharField(max_length=255, blank=True, null=True, default=None, verbose_name=_('Author')) + published_at = models.DateTimeField(verbose_name=_('Published at'), blank=True, default=None, null=True, help_text=_('Published at')) - published_scheduled_at = models.DateTimeField(verbose_name=_('Published scheduled at'), - blank=True, default=None, null=True, - help_text=_('Published scheduled at')) + published_scheduled_at = models.DateTimeField(verbose_name=_('Published scheduled at'), blank=True, default=None, + null=True, help_text=_('Published scheduled at')) + old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None) + slug = models.SlugField(unique=True, max_length=255, null=True, verbose_name=_('Slug')) objects = RecipeQuerySet.as_manager() diff --git a/apps/recipe/serializers/common.py b/apps/recipe/serializers/common.py index fec5978d..a0ec4363 100644 --- a/apps/recipe/serializers/common.py +++ b/apps/recipe/serializers/common.py @@ -14,8 +14,15 @@ class RecipeListSerializer(serializers.ModelSerializer): """Meta class.""" model = models.Recipe - fields = ('id', 'title_translated', 'subtitle_translated', 'author', - 'published_at', 'in_favorites') + fields = ( + 'id', + 'title_translated', + 'subtitle_translated', + 'author', + 'created_by', + 'published_at', + 'in_favorites', + ) read_only_fields = fields diff --git a/apps/recipe/transfer_data.py b/apps/recipe/transfer_data.py index 4a2e97d6..4c1c3a5a 100644 --- a/apps/recipe/transfer_data.py +++ b/apps/recipe/transfer_data.py @@ -1,19 +1,49 @@ -from django.db.models import Value, IntegerField, F from pprint import pprint + +from django.db.models import Count + +from recipe.models import Recipe from transfer.models import PageTexts from transfer.serializers.recipe import RecipeSerializer def transfer_recipe(): - queryset = PageTexts.objects.filter(page__type="Recipe") + queryset = PageTexts.objects.filter( + page__type='Recipe', + ).values( + 'id', + 'title', + 'summary', + 'body', + 'locale', + 'state', + 'slug', + 'created_at', + 'page__attachment_suffix_url', + 'page__account_id', + ) - serialized_data = RecipeSerializer(data=list(queryset.values()), many=True) + serialized_data = RecipeSerializer(data=list(queryset), many=True) if serialized_data.is_valid(): serialized_data.save() else: - pprint(f"News serializer errors: {serialized_data.errors}") + pprint(f'Recipe serializer errors: {serialized_data.errors}') + return + + # Удаление дубликатов рецептов по одинаковым description + duplicate_descriptions = Recipe.objects.values( + 'description' + ).annotate( + description_count=Count('description') + ).filter( + description_count__gt=1 + ) + for data in duplicate_descriptions: + description = data['description'] + _list = list(Recipe.objects.filter(description=description).values_list('pk', flat=True)[1:]) + Recipe.objects.filter(id__in=_list).delete() data_types = { - "recipe": [transfer_recipe] + 'recipe': [transfer_recipe] } diff --git a/apps/recipe/views/common.py b/apps/recipe/views/common.py index f268107e..31e74f20 100644 --- a/apps/recipe/views/common.py +++ b/apps/recipe/views/common.py @@ -1,5 +1,7 @@ """Recipe app common views.""" +from django.utils import translation from rest_framework import generics, permissions + from recipe import models from recipe.serializers import common as serializers @@ -10,9 +12,14 @@ class RecipeViewMixin(generics.GenericAPIView): pagination_class = None permission_classes = (permissions.AllowAny,) - def get_queryset(self): + def get_queryset(self, *args, **kwargs): user = self.request.user qs = models.Recipe.objects.published().annotate_in_favorites(user) + + locale = kwargs.get('locale') + if locale: + qs = qs.by_locale(locale) + return qs @@ -21,6 +28,11 @@ class RecipeListView(RecipeViewMixin, generics.ListAPIView): serializer_class = serializers.RecipeListSerializer + def get_queryset(self, *args, **kwargs): + locale = translation.get_language() + kwargs.update({'locale': locale}) + return super().get_queryset(*args, **kwargs) + class RecipeDetailView(RecipeViewMixin, generics.RetrieveAPIView): """Resource for detailed recipe information.""" diff --git a/apps/review/transfer_data.py b/apps/review/transfer_data.py index 20c5712d..ac3749f6 100644 --- a/apps/review/transfer_data.py +++ b/apps/review/transfer_data.py @@ -127,8 +127,10 @@ def transfer_product_reviews(): data_types = { + "languages": [ + transfer_languages, + ], "overlook": [ - # transfer_languages, transfer_reviews, transfer_text_review, make_en_text_review, diff --git a/apps/search_indexes/documents/news.py b/apps/search_indexes/documents/news.py index 62e3e984..75174fae 100644 --- a/apps/search_indexes/documents/news.py +++ b/apps/search_indexes/documents/news.py @@ -3,6 +3,7 @@ from django.conf import settings from django_elasticsearch_dsl import Document, Index, fields from search_indexes.utils import OBJECT_FIELD_PROPERTIES from news import models +from json import dumps NewsIndex = Index(settings.ELASTICSEARCH_INDEX_NAMES.get(__name__, 'news')) @@ -17,7 +18,7 @@ class NewsDocument(Document): 'name': fields.KeywordField()}) title = fields.ObjectField(attr='title_indexing', properties=OBJECT_FIELD_PROPERTIES) - slugs = fields.ObjectField(properties=OBJECT_FIELD_PROPERTIES) + slugs = fields.KeywordField() backoffice_title = fields.TextField(analyzer='english') subtitle = fields.ObjectField(attr='subtitle_indexing', properties=OBJECT_FIELD_PROPERTIES) @@ -44,10 +45,11 @@ class NewsDocument(Document): }, multi=True) favorites_for_users = fields.ListField(field=fields.IntegerField()) - start = fields.DateField(attr='start') + start = fields.DateField(attr='publication_datetime') + has_any_desc_active = fields.BooleanField() def prepare_slugs(self, instance): - return {locale: instance.slugs.get(locale) for locale in OBJECT_FIELD_PROPERTIES} + return dumps(instance.slugs or {}) class Django: diff --git a/apps/search_indexes/serializers.py b/apps/search_indexes/serializers.py index 4cad05fc..2a183c10 100644 --- a/apps/search_indexes/serializers.py +++ b/apps/search_indexes/serializers.py @@ -6,6 +6,7 @@ from news.serializers import NewsTypeSerializer from search_indexes.documents import EstablishmentDocument, NewsDocument from search_indexes.documents.product import ProductDocument from search_indexes.utils import get_translated_value +from json import loads class TagsDocumentSerializer(serializers.Serializer): @@ -243,7 +244,7 @@ class NewsDocumentSerializer(InFavoritesMixin, DocumentSerializer): @staticmethod def get_slug(obj): - return get_translated_value(obj.slugs) + return get_translated_value(loads(obj.slugs)) @staticmethod def get_title_translated(obj): diff --git a/apps/search_indexes/views.py b/apps/search_indexes/views.py index cb5b448c..e0735b2d 100644 --- a/apps/search_indexes/views.py +++ b/apps/search_indexes/views.py @@ -1,13 +1,15 @@ """Search indexes app views.""" -from rest_framework import permissions from django_elasticsearch_dsl_drf import constants from django_elasticsearch_dsl_drf.filter_backends import ( FilteringFilterBackend, GeoSpatialOrderingFilterBackend, OrderingFilterBackend, ) -from elasticsearch_dsl import TermsFacet from django_elasticsearch_dsl_drf.viewsets import BaseDocumentViewSet +from elasticsearch_dsl import TermsFacet +from rest_framework import permissions + +from product.models import Product from search_indexes import serializers, filters, utils from search_indexes.documents import EstablishmentDocument, NewsDocument from search_indexes.documents.product import ProductDocument @@ -17,6 +19,11 @@ from utils.pagination import ESDocumentPagination class NewsDocumentViewSet(BaseDocumentViewSet): """News document ViewSet.""" + def get_queryset(self): + qs = super(NewsDocumentViewSet, self).get_queryset() + qs = qs.filter('match', has_any_desc_active=True) + return qs + document = NewsDocument lookup_field = 'slug' pagination_class = ESDocumentPagination @@ -61,11 +68,18 @@ class NewsDocumentViewSet(BaseDocumentViewSet): ) filter_fields = { + 'tags_id': { + 'field': 'tags.id', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + constants.LOOKUP_QUERY_EXCLUDE, + ], + }, 'tag': { 'field': 'tags.id', 'lookups': [ constants.LOOKUP_QUERY_IN, - constants.LOOKUP_QUERY_EXCLUDE + constants.LOOKUP_QUERY_EXCLUDE, ] }, 'tag_value': { @@ -334,6 +348,12 @@ class ProductDocumentViewSet(BaseDocumentViewSet): # GeoSpatialOrderingFilterBackend, ] + + def get_queryset(self): + qs = super(ProductDocumentViewSet, self).get_queryset() + qs = qs.filter('match', state=Product.PUBLISHED) + return qs + ordering_fields = { 'created': { 'field': 'created', @@ -390,7 +410,7 @@ class ProductDocumentViewSet(BaseDocumentViewSet): 'lookups': [constants.LOOKUP_QUERY_IN], }, 'country': { - 'field': 'establishment.address.city.country.code', + 'field': 'establishment.city.country.code', }, 'wine_colors_id': { 'field': 'wine_colors.id', diff --git a/apps/tag/views.py b/apps/tag/views.py index 3bb33975..2b8eb4ef 100644 --- a/apps/tag/views.py +++ b/apps/tag/views.py @@ -63,9 +63,20 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet): """ViewSet for TagCategory model.""" serializer_class = serializers.FiltersTagCategoryBaseSerializer + index_name_to_order = { + 'open_now': 9, + 'works_noon': 8, + 'works_evening': 7, + 'pop': 6, + 'category': 5, + 'toque_number': 4, + 'cuisine': 3, + 'moment': 2, + 'service': 1, + } def list(self, request, *args, **kwargs): - queryset = self.filter_queryset(self.get_queryset()) + queryset = self.filter_queryset(self.get_queryset().exclude(public=False)) serializer = self.get_serializer(queryset, many=True) result_list = serializer.data @@ -77,7 +88,7 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet): elif query_params.get('product_type'): params_type = query_params.get('product_type') - week_days = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") + week_days = tuple(map(_, ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))) flags = ('toque_number', 'wine_region', 'works_noon', 'works_evening', 'works_now', 'works_at_weekday') filter_flags = {flag_name: False for flag_name in flags} additional_flags = [] @@ -94,19 +105,6 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet): for flag_name in additional_flags: filter_flags[flag_name] = True - if filter_flags['toque_number']: - toques = { - "index_name": "toque_number", - "label_translated": "Toques", - "param_name": "toque_number__in", - "filters": [{ - "id": toque_id, - "index_name": "toque_%d" % toque_id, - "label_translated": "Toque %d" % toque_id - } for toque_id in range(6)] - } - result_list.append(toques) - if request.query_params.get('product_type') == ProductType.WINE: wine_region_id = query_params.get('wine_region_id__in') @@ -129,11 +127,30 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet): result_list.append(wine_regions) + for item in result_list: + if 'filters' in item: + item['filters'].sort(key=lambda x: x.get('label_translated')) + + if filter_flags['toque_number']: + toques = { + "index_name": "toque_number", + "label_translated": "Toques", + "param_name": "toque_number__in", + 'type': 'toque', + "filters": [{ + "id": toque_id, + "index_name": "toque_%d" % toque_id, + "label_translated": "Toque %d" % toque_id + } for toque_id in range(6)] + } + result_list.append(toques) + if filter_flags['works_noon']: works_noon = { "index_name": "works_noon", "label_translated": "Open noon", "param_name": "works_noon__in", + 'type': 'weekday', "filters": [{ "id": weekday, "index_name": week_days[weekday].lower(), @@ -148,6 +165,7 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet): "index_name": "works_evening", "label_translated": "Open evening", "param_name": "works_evening__in", + 'type': 'weekday', "filters": [{ "id": weekday, "index_name": week_days[weekday].lower(), @@ -161,7 +179,7 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet): "index_name": "open_now", "label_translated": "Open now", "param_name": "open_now", - "type": True + "type": 'bool', } result_list.append(works_now) @@ -170,6 +188,7 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet): "index_name": "works_at_weekday", "label_translated": "Works at weekday", "param_name": "works_at_weekday__in", + 'type': 'weekday', "filters": [{ "id": weekday, "index_name": week_days[weekday].lower(), @@ -180,7 +199,17 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet): search_view_class = self.define_search_view_by_request(request) facets = search_view_class.as_view({'get': 'list'})(self.mutate_request(self.request)).data['facets'] - return Response(self.remove_empty_filters(result_list, facets)) + result_list = self.remove_empty_filters(result_list, facets) + tag_category = list(filter(lambda x: x.get('index_name') == 'tag', result_list)) + result_list = [category for category in result_list if category.get('index_name') != 'tag'] + if len(tag_category): + tag_category = list(filter(lambda x: x.get('index_name') == 'pop', tag_category[0]['filters'])) + if len(tag_category): # we have Pop tag in our results + tag_category = tag_category[0] + tag_category['param_name'] = 'tags_id__in' + result_list.append(tag_category) + result_list.sort(key=lambda x: self.index_name_to_order.get(x.get('index_name'), 0), reverse=True) + return Response(result_list) @staticmethod def mutate_request(request): @@ -217,9 +246,11 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet): if facets.get('_filter_tag'): tags_to_preserve = list(map(lambda el: el['key'], facets['_filter_tag']['tag']['buckets'])) if facets.get('_filter_wine_colors'): - wine_colors_to_preserve = list(map(lambda el: el['key'], facets['_filter_wine_colors']['wine_colors']['buckets'])) + wine_colors_to_preserve = list( + map(lambda el: el['key'], facets['_filter_wine_colors']['wine_colors']['buckets'])) if facets.get('_filter_wine_region_id'): - wine_regions_to_preserve = list(map(lambda el: el['key'], facets['_filter_wine_region_id']['wine_region_id']['buckets'])) + wine_regions_to_preserve = list( + map(lambda el: el['key'], facets['_filter_wine_region_id']['wine_region_id']['buckets'])) if facets.get('_filter_toque_number'): toque_numbers = list(map(lambda el: el['key'], facets['_filter_toque_number']['toque_number']['buckets'])) if facets.get('_filter_works_noon'): @@ -227,7 +258,8 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet): if facets.get('_filter_works_evening'): works_evening = list(map(lambda el: el['key'], facets['_filter_works_evening']['works_evening']['buckets'])) if facets.get('_filter_works_at_weekday'): - works_at_weekday = list(map(lambda el: el['key'], facets['_filter_works_at_weekday']['works_at_weekday']['buckets'])) + works_at_weekday = list( + map(lambda el: el['key'], facets['_filter_works_at_weekday']['works_at_weekday']['buckets'])) if facets.get('_filter_works_now'): works_now = list(map(lambda el: el['key'], facets['_filter_works_now']['works_now']['buckets'])) @@ -237,9 +269,11 @@ class FiltersTagCategoryViewSet(TagCategoryViewSet): if param_name == 'tags_id__in': category['filters'] = list(filter(lambda tag: tag['id'] in tags_to_preserve, category['filters'])) elif param_name == 'wine_colors_id__in': - category['filters'] = list(filter(lambda tag: tag['id'] in wine_colors_to_preserve, category['filters'])) + category['filters'] = list( + filter(lambda tag: tag['id'] in wine_colors_to_preserve, category['filters'])) elif param_name == 'wine_region_id__in': - category['filters'] = list(filter(lambda tag: tag['id'] in wine_regions_to_preserve, category['filters'])) + category['filters'] = list( + filter(lambda tag: tag['id'] in wine_regions_to_preserve, category['filters'])) elif param_name == 'toque_number__in': category['filters'] = list(filter(lambda tag: tag['id'] in toque_numbers, category['filters'])) elif param_name == 'works_noon__in': diff --git a/apps/transfer/management/commands/transfer.py b/apps/transfer/management/commands/transfer.py index d59bacfc..0c28a581 100644 --- a/apps/transfer/management/commands/transfer.py +++ b/apps/transfer/management/commands/transfer.py @@ -13,7 +13,7 @@ class Command(BaseCommand): 'news', # перенос новостей (после №2) 'account', # №1 - перенос пользователей 'subscriber', - 'recipe', + 'recipe', # №2 - рецепты 'partner', 'establishment', # №3 - перенос заведений 'gallery', @@ -48,7 +48,9 @@ class Command(BaseCommand): 'guide_element_types', 'guide_elements_bulk', 'guide_element_advertorials', + 'guide_element_label_photo', 'guide_complete', + 'languages', # №4 - перенос языков ] def handle(self, *args, **options): diff --git a/apps/transfer/models.py b/apps/transfer/models.py index 420e41bc..6a9ba3af 100644 --- a/apps/transfer/models.py +++ b/apps/transfer/models.py @@ -343,7 +343,7 @@ class GuideAds(MigrateMixin): nb_right_pages = models.IntegerField(blank=True, null=True) created_at = models.DateTimeField() updated_at = models.DateTimeField() - guide_ad_node_id = models.IntegerField(blank=True, null=True) + guide_ad_node = models.ForeignKey('GuideElements', on_delete=models.DO_NOTHING, blank=True, null=True) type = models.CharField(max_length=255, blank=True, null=True) class Meta: @@ -1224,6 +1224,22 @@ class Footers(MigrateMixin): db_table = 'footers' +class LabelPhotos(MigrateMixin): + using = 'legacy' + + guide_ad = models.ForeignKey(GuideAds, models.DO_NOTHING, blank=True, null=True) + attachment_file_name = models.CharField(max_length=255) + attachment_content_type = models.CharField(max_length=255) + attachment_file_size = models.IntegerField() + attachment_updated_at = models.DateTimeField() + attachment_suffix_url = models.CharField(max_length=255) + geometries = models.CharField(max_length=1024) + + class Meta: + managed = False + db_table = 'label_photos' + + class OwnershipAffs(MigrateMixin): using = 'legacy' diff --git a/apps/transfer/serializers/establishment.py b/apps/transfer/serializers/establishment.py index a287d61b..1db16711 100644 --- a/apps/transfer/serializers/establishment.py +++ b/apps/transfer/serializers/establishment.py @@ -77,7 +77,11 @@ class EstablishmentSerializer(serializers.ModelSerializer): schedules = validated_data.pop('schedules') subtypes = [validated_data.pop('subtype', None)] - establishment = Establishment.objects.create(**validated_data) + # establishment = Establishment.objects.create(**validated_data) + establishment, _ = Establishment.objects.update_or_create( + old_id=validated_data['old_id'], + defaults=validated_data, + ) if email: ContactEmail.objects.get_or_create( email=email, diff --git a/apps/transfer/serializers/guide.py b/apps/transfer/serializers/guide.py index 8a083e55..f0cddd02 100644 --- a/apps/transfer/serializers/guide.py +++ b/apps/transfer/serializers/guide.py @@ -68,7 +68,7 @@ class GuideSerializer(TransferSerializerMixin): class GuideFilterSerializer(TransferSerializerMixin): id = serializers.IntegerField() year = serializers.CharField(allow_null=True) - type = serializers.CharField(allow_null=True, source='establishment_type') + establishment_type = serializers.CharField(allow_null=True) countries = serializers.CharField(allow_null=True) regions = serializers.CharField(allow_null=True) subregions = serializers.CharField(allow_null=True) @@ -86,7 +86,7 @@ class GuideFilterSerializer(TransferSerializerMixin): fields = ( 'id', 'year', - 'type', + 'establishment_type', 'countries', 'regions', 'subregions', diff --git a/apps/transfer/serializers/recipe.py b/apps/transfer/serializers/recipe.py index 11698ba1..ad2d34cb 100644 --- a/apps/transfer/serializers/recipe.py +++ b/apps/transfer/serializers/recipe.py @@ -1,55 +1,87 @@ from rest_framework import serializers + +from account.models import User from recipe.models import Recipe from utils.legacy_parser import parse_legacy_news_content -class RecipeSerializer(serializers.ModelSerializer): - locale = serializers.CharField() +class RecipeSerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField(allow_null=True) + summary = serializers.CharField(allow_null=True, allow_blank=True) body = serializers.CharField(allow_null=True) - title = serializers.CharField() - state = serializers.CharField() - created_at = serializers.DateTimeField(source="published_at", format='%m-%d-%Y %H:%M:%S') - - class Meta: - model = Recipe - fields = ( - "body", - "title", - "state", - "created_at", - 'locale', - ) + locale = serializers.CharField(allow_null=True) + state = serializers.CharField(allow_null=True) + slug = serializers.CharField(allow_null=True) + created_at = serializers.DateTimeField(format='%m-%d-%Y %H:%M:%S') + page__attachment_suffix_url = serializers.CharField(allow_null=True) + page__account_id = serializers.IntegerField(allow_null=True) def validate(self, data): - data["state"] = self.get_state(data) - data["title"] = self.get_title(data) - data["description"] = self.get_description(data) - data.pop("body") - data.pop("locale") + data.update({ + 'old_id': data.pop('id'), + 'title': self.get_title(data), + 'subtitle': self.get_subtitle(data), + 'description': self.get_description(data), + 'state': self.get_state(data), + 'created': data.pop('created_at'), + 'image': self.get_image(data), + 'created_by': self.get_account(data), + 'modified_by': self.get_account(data), + }) + + data.pop('page__account_id') + data.pop('page__attachment_suffix_url') + data.pop('summary') + data.pop('body') + data.pop('locale') return data def create(self, validated_data): - return Recipe.objects.create(**validated_data) + obj, _ = Recipe.objects.update_or_create( + old_id=validated_data['old_id'], + defaults=validated_data, + ) + return obj - def get_state(self, obj): - if obj["state"] == "published": - return Recipe.PUBLISHED - elif obj["state"] == "hidden": - return Recipe.HIDDEN - elif obj["state"] == "published_exclusive": - return Recipe.PUBLISHED_EXCLUSIVE - else: - return Recipe.WAITING + @staticmethod + def get_title(data): + if data.get('title') and data.get('locale'): + return {data['locale']: data['title']} + return None - def get_title(self, obj): - # tit = obj.get("title") - # return {"en-GB": tit} - return {obj['locale']: obj['title']} + @staticmethod + def get_subtitle(data): + if data.get('summary') and data.get('locale'): + return {data['locale']: data['summary']} + return None - def get_description(self, obj): - # desc = obj.get("body") - # return {"en-GB": desc} - content = None - if obj['body']: - content = parse_legacy_news_content(obj['body']) - return {obj['locale']: content} + @staticmethod + def get_description(data): + if data.get('body') and data.get('locale'): + content = parse_legacy_news_content(data['body']) + return {data['locale']: content} + return None + + @staticmethod + def get_state(data): + value = data.get('state') + states = { + 'published': Recipe.PUBLISHED, + 'hidden': Recipe.HIDDEN, + 'published_exclusive': Recipe.PUBLISHED_EXCLUSIVE + } + return states.get(value, Recipe.WAITING) + + @staticmethod + def get_image(data): + values = (None, 'default/missing.png') + if data.get('page__attachment_suffix_url') not in values: + return data['page__attachment_suffix_url'] + return None + + @staticmethod + def get_account(data): + if data.get('page__account_id'): + return User.objects.filter(old_id=data['page__account_id']).first() + return None diff --git a/apps/utils/models.py b/apps/utils/models.py index b4b64d9f..07891330 100644 --- a/apps/utils/models.py +++ b/apps/utils/models.py @@ -67,16 +67,23 @@ def get_default_locale(): settings.FALLBACK_LOCALE -def translate_field(self, field_name): +def translate_field(self, field_name, toggle_field_name=None): def translate(self): field = getattr(self, field_name) + toggler = getattr(self, toggle_field_name, None) if isinstance(field, dict): + if toggler: + field = {locale: v for locale, v in field.items() if toggler.get(locale) in [True, 'True', 'true']} value = field.get(to_locale(get_language())) # fallback if value is None: value = field.get(get_default_locale()) if value is None: - value = field.get(next(iter(field.keys()), None)) + try: + value = next(iter(field.values())) + except StopIteration: + # field values are absent + return None return value return None return translate @@ -114,7 +121,7 @@ class TranslatedFieldsMixin: field_name = field.name if isinstance(field, TJSONField): setattr(cls, f'{field.name}_translated', - property(translate_field(self, field_name))) + property(translate_field(self, field_name, f'locale_to_{field_name}_is_active'))) setattr(cls, f'{field_name}_indexing', property(index_field(self, field_name))) @@ -361,6 +368,10 @@ class GMTokenGenerator(PasswordResetTokenGenerator): class GalleryModelMixin(models.Model): """Mixin for models that has gallery.""" + class Meta: + """Meta class.""" + abstract = True + @property def crop_gallery(self): if hasattr(self, 'gallery'): @@ -400,10 +411,6 @@ class GalleryModelMixin(models.Model): ) return image_property - class Meta: - """Meta class.""" - abstract = True - class IntermediateGalleryModelQuerySet(models.QuerySet): """Extended QuerySet.""" diff --git a/apps/utils/pagination.py b/apps/utils/pagination.py index 199d55b6..77e67d75 100644 --- a/apps/utils/pagination.py +++ b/apps/utils/pagination.py @@ -6,6 +6,7 @@ from django.conf import settings from rest_framework.pagination import CursorPagination, PageNumberPagination from django_elasticsearch_dsl_drf.pagination import PageNumberPagination as ESPagination + class ProjectPageNumberPagination(PageNumberPagination): """Customized pagination class.""" @@ -82,7 +83,7 @@ class ESDocumentPagination(ESPagination): return page.facets._d_ -class EstablishmentPortionPagination(ProjectMobilePagination): +class PortionPagination(ProjectMobilePagination): """ Pagination for app establishments with limit page size equal to 12 """ diff --git a/project/urls/mobile.py b/project/urls/mobile.py index 4368189e..16f5a1a2 100644 --- a/project/urls/mobile.py +++ b/project/urls/mobile.py @@ -11,7 +11,7 @@ urlpatterns = [ path('timetables/', include('timetable.urls.mobile')), # path('account/', include('account.urls.web')), path('re_blocks/', include('advertisement.urls.mobile')), - # path('collection/', include('collection.urls.web')), + # path('collection/', include('collection.urls.mobile')), # path('establishments/', include('establishment.urls.web')), path('news/', include('news.urls.mobile')), # path('partner/', include('partner.urls.web')),