From dbbb526902ace414e009881b2d03ef82732a237a Mon Sep 17 00:00:00 2001 From: Anatoly Date: Tue, 17 Dec 2019 13:02:46 +0300 Subject: [PATCH] Refactored mechanism to find similar objects. Added endpoints to product app. --- apps/establishment/models.py | 43 ++++++++++++-------- apps/establishment/urls/common.py | 8 ++-- apps/establishment/views/web.py | 29 ++++++++------ apps/product/models.py | 64 ++++++++++++++++++++++++++++-- apps/product/serializers/common.py | 2 +- apps/product/urls/common.py | 9 +++++ apps/product/views/common.py | 18 +++++++++ apps/utils/pagination.py | 3 +- 8 files changed, 138 insertions(+), 38 deletions(-) diff --git a/apps/establishment/models.py b/apps/establishment/models.py index 5ff6f921..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() 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/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/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/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 """