"""Establishment models.""" from datetime import datetime from functools import reduce import elasticsearch_dsl from django.conf import settings from django.contrib.contenttypes import fields as generic from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance as DistanceMeasure from django.core.exceptions import ValidationError from django.db import models from django.db.models import When, Case, F, ExpressionWrapper, Subquery, Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ from phonenumber_field.modelfields import PhoneNumberField from timezone_field import TimeZoneField from collection.models import Collection from location.models import Address from main.models import Award, Currency from review.models import Review from utils.models import (ProjectBaseMixin, TJSONField, URLImageMixin, TranslatedFieldsMixin, BaseAttributes) # todo: establishment type&subtypes check class EstablishmentType(TranslatedFieldsMixin, ProjectBaseMixin): """Establishment type model.""" STR_FIELD_NAME = 'name' # INDEX NAME CHOICES RESTAURANT = 'restaurant' ARTISAN = 'artisan' PRODUCER = 'producer' INDEX_NAME_TYPES = ( (RESTAURANT, _('Restaurant')), (ARTISAN, _('Artisan')), (PRODUCER, _('Producer')), ) name = TJSONField(blank=True, null=True, default=None, verbose_name=_('Description'), help_text='{"en-GB":"some text"}') index_name = models.CharField(max_length=50, choices=INDEX_NAME_TYPES, unique=True, db_index=True, verbose_name=_('Index name')) use_subtypes = models.BooleanField(_('Use subtypes'), default=True) tag_categories = models.ManyToManyField('tag.TagCategory', related_name='establishment_types', verbose_name=_('Tag')) class Meta: """Meta class.""" verbose_name = _('Establishment type') verbose_name_plural = _('Establishment types') class EstablishmentSubTypeManager(models.Manager): """Extended manager for establishment subtype.""" def make(self, name, establishment_type): obj = self.model(name=name, establishment_type=establishment_type) obj.full_clean() obj.save() return obj class EstablishmentSubType(ProjectBaseMixin, TranslatedFieldsMixin): """Establishment type model.""" # INDEX NAME CHOICES WINERY = 'winery' INDEX_NAME_TYPES = ( (WINERY, _('Winery')), ) name = TJSONField(blank=True, null=True, default=None, verbose_name=_('Description'), help_text='{"en-GB":"some text"}') index_name = models.CharField(max_length=50, choices=INDEX_NAME_TYPES, unique=True, db_index=True, verbose_name=_('Index name')) establishment_type = models.ForeignKey(EstablishmentType, on_delete=models.CASCADE, verbose_name=_('Type')) tag_categories = models.ManyToManyField('tag.TagCategory', related_name='establishment_subtypes', verbose_name=_('Tag')) objects = EstablishmentSubTypeManager() class Meta: """Meta class.""" verbose_name = _('Establishment subtype') verbose_name_plural = _('Establishment subtypes') def clean_fields(self, exclude=None): if not self.establishment_type.use_subtypes: raise ValidationError(_('Establishment type is not use subtypes.')) class EstablishmentQuerySet(models.QuerySet): """Extended queryset for Establishment model.""" def with_base_related(self): """Return qs with related objects.""" return self.select_related('address', 'establishment_type').\ prefetch_related('tags') def with_extended_related(self): return self.select_related('establishment_type').\ prefetch_related('establishment_subtypes', 'awards', 'schedule', 'phones').\ prefetch_actual_employees() def with_type_related(self): return self.prefetch_related('establishment_subtypes') def with_es_related(self): """Return qs with related for ES indexing objects.""" return self.select_related('address', 'establishment_type', 'address__city', 'address__city__country').\ prefetch_related('tags', 'schedule') def search(self, value, locale=None): """Search text in JSON fields.""" if locale is not None: filters = [ {f'name__icontains': value}, {f'description__{locale}__icontains': value} ] return self.filter(reduce(lambda x, y: x | y, [models.Q(**i) for i in filters])) else: return self.none() def es_search(self, value, locale=None): """Search text via ElasticSearch.""" from search_indexes.documents import EstablishmentDocument search = EstablishmentDocument.search().filter( elasticsearch_dsl.Q('match', name=value) | elasticsearch_dsl.Q('match', **{f'description.{locale}': value}) ).execute() ids = [result.meta.id for result in search] return self.filter(id__in=ids) def by_country_code(self, code): """Return establishments by country code""" return self.filter(address__city__country__code=code) def published(self): """ Return QuerySet with published establishments. """ return self.filter(is_publish=True) def has_published_reviews(self): """ Return QuerySet establishments with published reviews. """ return self.filter(reviews__status=Review.READY,) def annotate_distance(self, point: Point = None): """ Return QuerySet with annotated field - distance Description: """ return self.annotate(distance=Distance('address__coordinates', point, srid=settings.GEO_DEFAULT_SRID)) def annotate_intermediate_public_mark(self): """ Return QuerySet with annotated field - intermediate_public_mark. Description: If establishments in collection POP and its mark is null, then intermediate_mark is set to 10; """ return self.annotate(intermediate_public_mark=Case( When( collections__collection_type=Collection.POP, public_mark__isnull=True, then=settings.DEFAULT_ESTABLISHMENT_PUBLIC_MARK ), default='public_mark', output_field=models.FloatField())) def annotate_mark_similarity(self, mark): """ Return a QuerySet with annotated field - mark_similarity Description: Similarity mark determined by comparison with compared establishment mark """ return self.annotate(mark_similarity=ExpressionWrapper( mark - F('intermediate_public_mark'), output_field=models.FloatField() )) def similar(self, establishment_slug: str): """ Return QuerySet with objects that similar to Establishment. :param establishment_slug: str Establishment slug """ establishment_qs = self.filter(slug=establishment_slug, public_mark__isnull=False) if establishment_qs.exists(): establishment = establishment_qs.first() subquery_filter_by_distance = Subquery( self.exclude(slug=establishment_slug) .filter(image_url__isnull=False, public_mark__gte=10) .has_published_reviews() .annotate_distance(point=establishment.location) .order_by('distance')[:settings.LIMITING_QUERY_OBJECTS] .values('id') ) return self.filter(id__in=subquery_filter_by_distance) \ .annotate_intermediate_public_mark() \ .annotate_mark_similarity(mark=establishment.public_mark) \ .order_by('mark_similarity') \ .distinct('mark_similarity', 'id') else: return self.none() def last_reviewed(self, point: Point): """ Return QuerySet with last reviewed establishments. :param point: location Point object, needs to ordering """ subquery_filter_by_distance = Subquery( self.filter(image_url__isnull=False, public_mark__gte=10) .has_published_reviews() .annotate_distance(point=point) .order_by('distance')[:settings.LIMITING_QUERY_OBJECTS] .values('id') ) return self.filter(id__in=subquery_filter_by_distance) \ .order_by('-reviews__published_at') def prefetch_actual_employees(self): """Prefetch actual employees.""" return self.prefetch_related( models.Prefetch('establishmentemployee_set', queryset=EstablishmentEmployee.objects.actual().select_related( 'position'), to_attr='actual_establishment_employees')) def annotate_in_favorites(self, user): """Annotate flag in_favorites""" favorite_establishment_ids = [] if user.is_authenticated: favorite_establishment_ids = user.favorite_establishment_ids return self.annotate(in_favorites=Case( When( id__in=favorite_establishment_ids, then=True), default=False, output_field=models.BooleanField(default=False))) def by_distance_from_point(self, center, radius, unit='m'): """ Returns nearest establishments :param center: point from which to find nearby establishments :param radius: the maximum distance within the radius of which to look for establishments :return: all establishments within the specified radius of specified point :param unit: length unit e.g. m, km. Default is 'm'. """ kwargs = {unit: radius} return self.filter(address__coordinates__distance_lte=(center, DistanceMeasure(**kwargs))) def artisans(self): """Return artisans.""" return self.filter(establishment_type__index_name=EstablishmentType.ARTISAN) def producers(self): """Return producers.""" return self.filter(establishment_type__index_name=EstablishmentType.PRODUCER) def restaurants(self): """Return restaurants.""" return self.filter(establishment_type__index_name=EstablishmentType.RESTAURANT) def wineries(self): """Return wineries.""" return self.producers().filter( establishment_subtypes__index_name=EstablishmentSubType.WINERY) def by_type(self, value): """Return QuerySet with type by value.""" return self.filter(establishment_type__index_name=value) def by_subtype(self, value): """Return QuerySet with subtype by value.""" return self.filter(establishment_subtypes__index_name=value) class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): """Establishment model.""" old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None) name = models.CharField(_('name'), max_length=255, default='') transliterated_name = models.CharField(default='', max_length=255, verbose_name=_('Transliterated name')) index_name = models.CharField(_('Index name'), max_length=255, default='') description = TJSONField(blank=True, null=True, default=None, verbose_name=_('description'), help_text='{"en-GB":"some text"}') public_mark = models.PositiveIntegerField(blank=True, null=True, default=None, verbose_name=_('public mark'),) # todo: set default 0 toque_number = models.PositiveIntegerField(blank=True, null=True, default=None, verbose_name=_('toque number'),) establishment_type = models.ForeignKey(EstablishmentType, related_name='establishment', on_delete=models.PROTECT, verbose_name=_('type')) establishment_subtypes = models.ManyToManyField(EstablishmentSubType, blank=True, related_name='subtype_establishment', verbose_name=_('subtype')) address = models.ForeignKey(Address, blank=True, null=True, default=None, on_delete=models.PROTECT, verbose_name=_('address')) price_level = models.PositiveIntegerField(blank=True, null=True, default=None, verbose_name=_('price level')) website = models.URLField(blank=True, null=True, default=None, max_length=255, verbose_name=_('Web site URL')) facebook = models.URLField(blank=True, null=True, default=None, max_length=255, verbose_name=_('Facebook URL')) twitter = models.URLField(blank=True, null=True, default=None, max_length=255, verbose_name=_('Twitter URL')) lafourchette = models.URLField(blank=True, null=True, default=None, max_length=255, verbose_name=_('Lafourchette URL')) guestonline_id = models.PositiveIntegerField(blank=True, verbose_name=_('guestonline id'), null=True, default=None,) lastable_id = models.TextField(blank=True, verbose_name=_('lastable id'), unique=True, null=True, default=None,) booking = models.URLField(blank=True, null=True, default=None, max_length=255, verbose_name=_('Booking URL')) is_publish = models.BooleanField(default=False, verbose_name=_('Publish status')) schedule = models.ManyToManyField(to='timetable.Timetable', verbose_name=_('Establishment schedule'), related_name='schedule') # holidays_from = models.DateTimeField(verbose_name=_('Holidays from'), # help_text=_('Holidays closing date from')) # holidays_to = models.DateTimeField(verbose_name=_('Holidays to'), # help_text=_('Holidays closing date to')) transportation = models.TextField(blank=True, null=True, default=None, verbose_name=_('Transportation')) collections = models.ManyToManyField(to='collection.Collection', related_name='establishments', blank=True, default=None, verbose_name=_('Collections')) preview_image_url = models.URLField(verbose_name=_('Preview image URL path'), max_length=255, blank=True, null=True, default=None) slug = models.SlugField(unique=True, max_length=255, null=True, verbose_name=_('Establishment slug')) tz = TimeZoneField(default=settings.TIME_ZONE) awards = generic.GenericRelation(to='main.Award', related_query_name='establishment') tags = models.ManyToManyField('tag.Tag', related_name='establishments', verbose_name=_('Tag')) reviews = generic.GenericRelation(to='review.Review') comments = generic.GenericRelation(to='comment.Comment') favorites = generic.GenericRelation(to='favorites.Favorites') currency = models.ForeignKey(Currency, blank=True, null=True, default=None, on_delete=models.PROTECT, verbose_name=_('currency')) objects = EstablishmentQuerySet.as_manager() class Meta: """Meta class.""" verbose_name = _('Establishment') verbose_name_plural = _('Establishments') def __str__(self): return f'id:{self.id}-{self.name}' # todo: recalculate toque_number def recalculate_toque_number(self): toque_number = 0 if self.address and self.public_mark: toque_number = RatingStrategy.objects. \ get_toque_number(country=self.address.city.country, public_mark=self.public_mark) self.toque_number = toque_number self.save() def recalculate_price_level(self, low_price=None, high_price=None): if low_price is None or high_price is None: low_price, high_price = self.get_price_level() # todo: calculate price level self.price_level = 3 def get_price_level(self): country = self.address.city.country return country.low_price, country.high_price # todo: make via prefetch # @property # def subtypes(self): # return EstablishmentSubType.objects.filter( # subtype_establishment=self, # establishment_type=self.establishment_type, # establishment_type__use_subtypes=True) def set_establishment_type(self, establishment_type): self.establishment_type = establishment_type self.establishment_subtypes.exclude( establishement_type=establishment_type).delete() def add_establishment_subtype(self, establishment_subtype): if establishment_subtype.establishment_type != self.establishment_type: raise ValidationError('Establishment type of subtype does not match') self.establishment_subtypes.add(establishment_subtype) @property def vintage_year(self): last_review = self.reviews.by_status(Review.READY).last() if last_review: return last_review.vintage @property def best_price_menu(self): return 150 @property def best_price_carte(self): return 200 @property def range_price_menu(self): plates = self.menu_set.filter( models.Q(category={'en-GB': 'formulas'}) ).aggregate( max=models.Max('plate__price', output_field=models.FloatField()), min=models.Min('plate__price', output_field=models.FloatField())) return plates @property def range_price_carte(self): plates = self.menu_set.filter( models.Q(category={'en-GB': 'starter'}) | models.Q(category={'en-GB': 'main_course'}) | models.Q(category={'en-GB': 'dessert'}) ).aggregate( max=models.Max('plate__price', output_field=models.FloatField()), min=models.Min('plate__price', output_field=models.FloatField()), ) return plates @property def works_noon(self): """ Used for indexing working by day """ return [ret.weekday for ret in self.schedule.all() if ret.works_at_noon] @property def works_at_weekday(self): """ Used for indexing by working whole day criteria """ return [ret.weekday for ret in self.schedule.all()] @property def works_evening(self): """ Used for indexing working by day """ return [ret.weekday for ret in self.schedule.all() if ret.works_at_afternoon] @property def works_now(self): """ Is establishment working now """ now_at_est_tz = datetime.now(tz=self.tz) current_week = now_at_est_tz.weekday() schedule_for_today = self.schedule.filter(weekday=current_week).first() if schedule_for_today is None or schedule_for_today.opening_time is None or schedule_for_today.ending_time is None: return False time_at_est_tz = now_at_est_tz.time() return schedule_for_today.ending_time > time_at_est_tz > schedule_for_today.opening_time @property def tags_indexing(self): return [{'id': tag.metadata.id, 'label': tag.metadata.label} for tag in self.tags.all()] @property def last_published_review(self): """Return last published review""" return self.reviews.published()\ .order_by('-published_at').first() @property def location(self): """ Return Point object of establishment location """ return self.address.coordinates @property def the_most_recent_award(self): return Award.objects.filter(Q(establishment=self) | Q(employees__establishments=self)) \ .latest(field_name='vintage_year') @property def country_id(self): """ Return Country id of establishment location """ return self.address.country_id @property def establishment_id(self): """ Return establishment id of establishment location """ return self.id @property def wines(self): """Return list products with type wine""" return self.products.wines() class EstablishmentNoteQuerySet(models.QuerySet): """QuerySet for model EstablishmentNote.""" class EstablishmentNote(ProjectBaseMixin): """Note model for Establishment entity.""" old_id = models.PositiveIntegerField(null=True, blank=True) text = models.TextField(verbose_name=_('text')) establishment = models.ForeignKey(Establishment, on_delete=models.PROTECT, related_name='establishment_notes', verbose_name=_('establishment')) user = models.ForeignKey('account.User', on_delete=models.PROTECT, null=True, related_name='establishment_notes', verbose_name=_('author')) objects = EstablishmentNoteQuerySet.as_manager() class Meta: """Meta class.""" verbose_name_plural = _('product note') verbose_name = _('product notes') class Position(BaseAttributes, TranslatedFieldsMixin): """Position model.""" STR_FIELD_NAME = 'name' name = TJSONField(blank=True, null=True, default=None, verbose_name=_('Description'), help_text='{"en":"some text"}') priority = models.IntegerField(unique=True, null=True, default=None) index_name = models.CharField(max_length=255, db_index=True, unique=True, null=True, verbose_name=_('Index name')) class Meta: """Meta class.""" verbose_name = _('Position') verbose_name_plural = _('Positions') class EstablishmentEmployeeQuerySet(models.QuerySet): """Extended queryset for EstablishmEntemployee model.""" def actual(self): """Actual objects..""" now = timezone.now() return self.filter(models.Q(from_date__lte=now), (models.Q(to_date__gte=now) | models.Q(to_date__isnull=True))) class EstablishmentEmployee(BaseAttributes): """EstablishmentEmployee model.""" establishment = models.ForeignKey(Establishment, on_delete=models.PROTECT, verbose_name=_('Establishment')) employee = models.ForeignKey('establishment.Employee', on_delete=models.PROTECT, verbose_name=_('Employee')) from_date = models.DateTimeField(default=timezone.now, verbose_name=_('From date'), null=True, blank=True) to_date = models.DateTimeField(blank=True, null=True, default=None, verbose_name=_('To date')) position = models.ForeignKey(Position, on_delete=models.PROTECT, verbose_name=_('Position')) # old_id = affiliations_id old_id = models.IntegerField(verbose_name=_('Old id'), null=True, blank=True) objects = EstablishmentEmployeeQuerySet.as_manager() class Employee(BaseAttributes): """Employee model.""" user = models.OneToOneField('account.User', on_delete=models.PROTECT, null=True, blank=True, default=None, verbose_name=_('User')) name = models.CharField(max_length=255, verbose_name=_('Last name')) establishments = models.ManyToManyField(Establishment, related_name='employees', through=EstablishmentEmployee,) awards = generic.GenericRelation(to='main.Award', related_query_name='employees') tags = models.ManyToManyField('tag.Tag', related_name='employees', verbose_name=_('Tags')) # old_id = profile_id old_id = models.IntegerField(verbose_name=_('Old id'), null=True, blank=True) class Meta: """Meta class.""" verbose_name = _('Employee') verbose_name_plural = _('Employees') class EstablishmentScheduleQuerySet(models.QuerySet): """QuerySet for model EstablishmentSchedule""" class ContactPhone(models.Model): """Contact phone model.""" establishment = models.ForeignKey( Establishment, related_name='phones', on_delete=models.CASCADE) phone = PhoneNumberField() class Meta: verbose_name = _('contact phone') verbose_name_plural = _('contact phones') def __str__(self): return f'{self.phone.as_e164}' class ContactEmail(models.Model): """Contact email model.""" establishment = models.ForeignKey( Establishment, related_name='emails', on_delete=models.CASCADE) email = models.EmailField() class Meta: verbose_name = _('contact email') verbose_name_plural = _('contact emails') def __str__(self): return f'{self.email}' # # class Wine(TranslatedFieldsMixin, models.Model): # """Wine model.""" # establishment = models.ForeignKey( # 'establishment.Establishment', verbose_name=_('establishment'), # on_delete=models.CASCADE) # bottles = models.IntegerField(_('bottles')) # price_min = models.DecimalField( # _('price min'), max_digits=14, decimal_places=2) # price_max = models.DecimalField( # _('price max'), max_digits=14, decimal_places=2) # by_glass = models.BooleanField(_('by glass')) # price_glass_min = models.DecimalField( # _('price min'), max_digits=14, decimal_places=2) # price_glass_max = models.DecimalField( # _('price max'), max_digits=14, decimal_places=2) # class Plate(TranslatedFieldsMixin, models.Model): """Plate model.""" STR_FIELD_NAME = 'name' name = TJSONField( blank=True, null=True, default=None, verbose_name=_('name'), help_text='{"en-GB":"some text"}') description = TJSONField( blank=True, null=True, default=None, verbose_name=_('description'), help_text='{"en-GB":"some text"}') price = models.DecimalField( _('price'), max_digits=14, decimal_places=2) is_signature_plate = models.BooleanField(_('is signature plate'), default=False) currency = models.ForeignKey( 'main.Currency', verbose_name=_('currency'), on_delete=models.CASCADE, blank=True, null=True, default=None) currency_code = models.CharField( _('currency code'), max_length=250, blank=True, null=True, default=None) menu = models.ForeignKey( 'establishment.Menu', verbose_name=_('menu'), on_delete=models.CASCADE) @property def establishment_id(self): return self.menu.establishment.id class Meta: verbose_name = _('plate') verbose_name_plural = _('plates') class Menu(TranslatedFieldsMixin, BaseAttributes): """Menu model.""" STR_FIELD_NAME = 'category' category = TJSONField( blank=True, null=True, default=None, verbose_name=_('category'), help_text='{"en-GB":"some text"}') establishment = models.ForeignKey( 'establishment.Establishment', verbose_name=_('establishment'), on_delete=models.CASCADE) class Meta: verbose_name = _('menu') verbose_name_plural = _('menu') class SocialNetwork(models.Model): old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None) establishment = models.ForeignKey( 'Establishment', verbose_name=_('establishment'), related_name='socials', on_delete=models.CASCADE) title = models.CharField(_('title'), max_length=255) url = models.URLField(_('URL'), max_length=255) class Meta: verbose_name = _('social network') verbose_name_plural = _('social networks') def __str__(self): return self.title class RatingStrategyManager(models.Manager): """Extended manager for RatingStrategy.""" def get_toque_number(self, country, public_mark): """Get toque number for country and public_mark.""" qs = self.model.objects.by_country(country) if not qs.exists(): qs = self.model.objects.by_country(None) obj = qs.for_public_mark(public_mark).first() if obj: return obj.toque_number return 0 class RatingStrategyQuerySet(models.QuerySet): """Extended queryset for RatingStrategy.""" def by_country(self, country): """Filter by country.""" return self.filter(country=country) def for_public_mark(self, public_mark): """Filter for value.""" return self.filter(public_mark_min_value__lte=public_mark, public_mark_max_value__gte=public_mark) class RatingStrategy(ProjectBaseMixin): """Rating Strategy model.""" TOQUE_NUMBER_CHOICES = ( (1, _('One')), (2, _('Two')), (3, _('Three')), (4, _('Four')), (5, _('Five')), ) country = models.ForeignKey('location.Country', null=True, blank=True, default=None, on_delete=models.CASCADE, verbose_name=_('Country')) toque_number = models.IntegerField(choices=TOQUE_NUMBER_CHOICES) public_mark_min_value = models.IntegerField() public_mark_max_value = models.IntegerField() objects = RatingStrategyManager.from_queryset(RatingStrategyQuerySet)() class Meta: """Meta class.""" verbose_name = _('Rating strategy') verbose_name_plural = _('Rating strategy') unique_together = ('country', 'toque_number') def __str__(self): return f'{self.country.code if self.country else "Other country"}. ' \ f'"{self.toque_number}": {self.public_mark_min_value}-' \ f'{self.public_mark_max_value}'