Refactored mechanism to find similar objects. Added endpoints to product app.

This commit is contained in:
Anatoly 2019-12-17 13:02:46 +03:00
parent 6a987532ba
commit dbbb526902
8 changed files with 138 additions and 38 deletions

View File

@ -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()

View File

@ -17,12 +17,14 @@ urlpatterns = [
path('slug/<slug:slug>/favorites/', views.EstablishmentFavoritesCreateDestroyView.as_view(),
name='create-destroy-favorites'),
# similar establishments
# similar establishments by type/subtype
path('slug/<slug:slug>/similar/', views.RestaurantSimilarListView.as_view(),
name='similar-restaurants'),
path('slug/<slug:slug>/similar/wineries/', views.WinerySimilarListView.as_view(),
name='similar-wineries'),
path('slug/<slug:slug>/similar/artisans/', views.ArtisanSimilarListView.as_view(),
# temporary uses single mechanism, bec. description in process
path('slug/<slug:slug>/similar/artisans/', views.ArtisanProducerSimilarListView.as_view(),
name='similar-artisans'),
path('slug/<slug:slug>/similar/producers/', views.ArtisanProducerSimilarListView.as_view(),
name='similar-producers'),
]

View File

@ -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):

View File

@ -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)()

View File

@ -218,4 +218,4 @@ class ProductCommentCreateSerializer(CommentSerializer):
'user': self.context.get('request').user,
'content_object': validated_data.pop('product')
})
return super().create(validated_data)
return super().create(validated_data)

View File

@ -16,4 +16,13 @@ urlpatterns = [
name='create-comment'),
path('slug/<slug:slug>/comments/<int:comment_id>/', views.ProductCommentRUDView.as_view(),
name='rud-comment'),
# similar products by type/subtype
# temporary uses single mechanism, bec. description in process
path('slug/<slug:slug>/similar/wines/', views.SimilarListView.as_view(),
name='similar-wine'),
path('slug/<slug:slug>/similar/liquors/', views.SimilarListView.as_view(),
name='similar-liquor'),
path('slug/<slug:slug>/similar/food/', views.SimilarListView.as_view(),
name='similar-food'),
]

View File

@ -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'))

View File

@ -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
"""