diff --git a/.gitignore b/.gitignore index 2dabe48f..681e23bd 100644 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,11 @@ logs/ /datadir/ /_files/ /geoip_db/ +/venv # dev ./docker-compose.override.yml +docker-compose.override.yml celerybeat-schedule local_files celerybeat.pid diff --git a/apps/establishment/migrations/0099_auto_20200207_1136.py b/apps/establishment/migrations/0099_auto_20200207_1136.py new file mode 100644 index 00000000..4fac47e8 --- /dev/null +++ b/apps/establishment/migrations/0099_auto_20200207_1136.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.7 on 2020-02-07 11:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('establishment', '0098_auto_20200204_1205'), + ] + + operations = [ + migrations.AddField( + model_name='establishment', + name='last_update_by_gm', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='establishment', + name='last_update_by_manager', + field=models.DateTimeField(null=True), + ), + ] diff --git a/apps/establishment/models.py b/apps/establishment/models.py index 60e3e0b5..611830fb 100644 --- a/apps/establishment/models.py +++ b/apps/establishment/models.py @@ -33,7 +33,7 @@ from utils.models import ( BaseAttributes, FavoritesMixin, FileMixin, GalleryMixin, HasTagsMixin, IntermediateGalleryModelMixin, ProjectBaseMixin, TJSONField, TranslatedFieldsMixin, TypeDefaultImageMixin, URLImageMixin, default_menu_bool_array, PhoneModelMixin, - AwardsModelMixin) + AwardsModelMixin, CarouselMixin, UpdateByMixin) # todo: establishment type&subtypes check @@ -137,6 +137,14 @@ class EstablishmentQuerySet(models.QuerySet): """Return qs with related reviews.""" return self.prefetch_related('reviews') + def with_reviews_sorted(self): + return self.prefetch_related( + Prefetch( + 'reviews', + queryset=Review.objects.published().order_by('-published_at'), + ) + ) + def with_currency_related(self): """Return qs with related """ return self.prefetch_related('currency') @@ -547,8 +555,15 @@ class EstablishmentQuerySet(models.QuerySet): return self.prefetch_related('menu_set', 'menu_set__plates', 'back_office_wine') -class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin, - TranslatedFieldsMixin, HasTagsMixin, FavoritesMixin, AwardsModelMixin): +class Establishment(GalleryMixin, + ProjectBaseMixin, + URLImageMixin, + TranslatedFieldsMixin, + HasTagsMixin, + FavoritesMixin, + AwardsModelMixin, + CarouselMixin, + UpdateByMixin): """Establishment model.""" ABANDONED = 0 @@ -565,12 +580,12 @@ class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin, (ABANDONED, _('Abandoned')), (CLOSED, _('Closed')), (PUBLISHED, _('Published')), - (UNPICKED, _('Unpicked')), + # (UNPICKED, _('Unpicked')), (WAITING, _('Waiting')), - (HIDDEN, _('Hidden')), - (DELETED, _('Deleted')), + # (HIDDEN, _('Hidden')), + # (DELETED, _('Deleted')), (OUT_OF_SELECTION, _('Out of selection')), - (UNPUBLISHED, _('Unpublished')), + # (UNPUBLISHED, _('Unpublished')), ) old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None) @@ -717,9 +732,13 @@ class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin, raise ValidationError('Establishment type of subtype does not match') self.establishment_subtypes.add(establishment_subtype) + @property + def last_review(self): + return self.reviews.by_status(Review.READY).last() + @property def vintage_year(self): - last_review = self.reviews.by_status(Review.READY).last() + last_review = self.last_review if last_review: return last_review.vintage diff --git a/apps/establishment/serializers/back.py b/apps/establishment/serializers/back.py index a24123b0..2cca450c 100644 --- a/apps/establishment/serializers/back.py +++ b/apps/establishment/serializers/back.py @@ -1,3 +1,4 @@ +from datetime import datetime from functools import lru_cache from django.contrib.contenttypes.models import ContentType @@ -9,17 +10,20 @@ from rest_framework import serializers from slugify import slugify from account import models as account_models +from account.models import Role from account.serializers.common import UserShortSerializer from collection.models import Guide from establishment import models, serializers as model_serializers from establishment.models import ContactEmail, ContactPhone, EstablishmentEmployee from establishment.serializers.common import ContactPhonesSerializer +from review.serializers.common import ReviewBaseSerializer from gallery.models import Image from location.serializers import AddressDetailSerializer, TranslatedField, AddressBaseSerializer, \ AddressEstablishmentSerializer from main import models as main_models from main.models import Currency from main.serializers import AwardSerializer +from review.serializers import ReviewBaseSerializer, User from tag.serializers import TagBaseSerializer from utils.decorators import with_base_attributes from utils.methods import string_random @@ -92,12 +96,13 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria ) subtypes = model_serializers.EstablishmentSubTypeBaseSerializer(source='establishment_subtypes', read_only=True, many=True) - + reviews = ReviewBaseSerializer(allow_null=True, read_only=True, many=True) restaurant_category = TagBaseSerializer(read_only=True, many=True, allow_null=True) restaurant_cuisine = TagBaseSerializer(read_only=True, many=True, allow_null=True) artisan_category = TagBaseSerializer(read_only=True, many=True, allow_null=True) distillery_type = TagBaseSerializer(read_only=True, many=True, allow_null=True) food_producer = TagBaseSerializer(read_only=True, many=True, allow_null=True) + vintage_year = serializers.IntegerField(read_only=True, allow_null=True) class Meta(model_serializers.EstablishmentBaseSerializer.Meta): fields = [ @@ -137,6 +142,8 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria 'artisan_category', 'distillery_type', 'food_producer', + 'reviews', + 'vintage_year', ] def to_representation(self, instance): @@ -167,13 +174,9 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria validated_data['slug'] = slug if 'address' in validated_data: - address_fields = validated_data.pop('address') - address_instance = get_object_or_404(models.Address, id=address_fields['id'] or None) - address_id = getattr(address_instance, 'id') - - models.Address.objects.filter(id=address_id).update(**address_fields) - - validated_data['address_id'] = address_id + address = models.Address(**validated_data.pop('address')) + address.save() + validated_data['address_id'] = address.id instance = super().create(validated_data) @@ -215,6 +218,7 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer): subtypes = model_serializers.EstablishmentSubTypeBaseSerializer(source='establishment_subtypes', read_only=True, many=True) type = model_serializers.EstablishmentTypeBaseSerializer(source='establishment_type', read_only=True) + phones = serializers.ListField( source='contact_phones', allow_null=True, @@ -223,8 +227,11 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer): required=False, write_only=True, ) + contact_phones = ContactPhonesSerializer(source='phones', read_only=True, many=True) + last_review = ReviewBaseSerializer(read_only=True) + class Meta(model_serializers.EstablishmentBaseSerializer.Meta): fields = [ 'id', @@ -253,6 +260,10 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer): 'tags', 'status', 'status_display', + 'last_review', + 'must_of_the_week', + 'last_update_by_gm', + 'last_update_by_manager', ] def to_representation(self, instance): @@ -260,7 +271,7 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer): data['phones'] = data.pop('contact_phones', None) return data - def update(self, instance, validated_data): + def update(self, instance: models.Establishment, validated_data): phones_list = [] if 'contact_phones' in validated_data: phones_list = validated_data.pop('contact_phones') @@ -269,9 +280,30 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer): if 'contact_emails' in validated_data: emails_list = validated_data.pop('contact_emails') + request = self.context.get('request') + if request and hasattr(request, 'user'): + user = request.user + + if isinstance(user, User): + is_by_manager = user.userrole_set.filter( + pk=user.pk, + role__in=( + Role.ESTABLISHMENT_MANAGER, + Role.ESTABLISHMENT_ADMINISTRATOR, + Role.COUNTRY_ADMIN + ) + ).exists() + + if is_by_manager: + instance.last_update_by_manager = datetime.now() + else: + ''' by gm. ''' + instance.last_update_by_gm = datetime.now() + instance = super().update(instance, validated_data) phones_handler(phones_list, instance) emails_handler(emails_list, instance) + return instance @@ -359,6 +391,17 @@ class PositionBackSerializer(serializers.ModelSerializer): ] +class AdminEmployeeBackSerializers(serializers.ModelSerializer): + + class Meta: + model = models.Employee + fields = [ + 'id', + 'name', + 'last_name', + ] + + # TODO: test decorator @with_base_attributes class EmployeeBackSerializers(PhoneMixinSerializer, serializers.ModelSerializer): @@ -972,6 +1015,7 @@ class CardAndWinesSerializer(serializers.ModelSerializer): class TeamMemberSerializer(serializers.ModelSerializer): """Serializer for team establishment BO section""" + class Meta: model = account_models.User fields = ( diff --git a/apps/establishment/urls/back.py b/apps/establishment/urls/back.py index 64bf736d..8882ba91 100644 --- a/apps/establishment/urls/back.py +++ b/apps/establishment/urls/back.py @@ -60,6 +60,7 @@ urlpatterns = [ path('/employees/', views.EstablishmentEmployeeListView.as_view(), name='establishment-employees'), path('employees/', views.EmployeeListCreateView.as_view(), name='employees'), + path('employees/for_admin/', views.AdminEmployeeListView.as_view(), name='employees-list-for-admin'), path('employees/search/', views.EmployeesListSearchViews.as_view(), name='employees-search'), path('employees//', views.EmployeeRUDView.as_view(), name='employees-rud'), path('employees//', views.RemoveAwardView.as_view(), name='employees-award-delete'), diff --git a/apps/establishment/views/back.py b/apps/establishment/views/back.py index 01926bd8..46a2f7a2 100644 --- a/apps/establishment/views/back.py +++ b/apps/establishment/views/back.py @@ -65,7 +65,8 @@ class EstablishmentListCreateView(EstablishmentMixinViews, generics.ListCreateAP .with_certain_tag_category_related('cuisine', 'restaurant_cuisine') \ .with_certain_tag_category_related('shop_category', 'artisan_category') \ .with_certain_tag_category_related('distillery_type', 'distillery_type') \ - .with_certain_tag_category_related('producer_type', 'food_producer') + .with_certain_tag_category_related('producer_type', 'food_producer') \ + .with_reviews_sorted() class EmployeeEstablishmentPositionsView(generics.ListAPIView): @@ -209,7 +210,7 @@ class EstablishmentRUDView(EstablishmentMixinViews, generics.RetrieveUpdateDestr ) def get_queryset(self): - """Overridden get_queryset method.""" + """An overridden get_queryset method.""" qs = super(EstablishmentRUDView, self).get_queryset() return qs.prefetch_related( 'establishmentemployee_set', @@ -270,9 +271,36 @@ class EstablishmentScheduleRUDView(EstablishmentMixinViews, generics.RetrieveUpd class EstablishmentScheduleCreateView(generics.CreateAPIView): """ - Establishment schedule Create view - - Implement creating Establishment shedule. + ## Create establishment schedule + ### *POST* + #### Description + Create schedule for establishment by establishment `slug`. + ##### Request + Required: + * weekday (`enum`) + ``` + 0 (Monday), + 1 (Tuesday), + 2 (Wednesday), + 3 (Thursday), + 4 (Friday), + 5 (Saturday), + 6 (Sunday) + ``` + Non-required: + * lunch_start (str) - time in a format (`ISO-8601`, e.g. - `hh:mm:ss`) + * lunch_end (str) - time in a format (`ISO-8601`, e.g. - `hh:mm:ss`) + * dinner_start (str) - time in a format (`ISO-8601`, e.g. - `hh:mm:ss`) + * dinner_end (str) - time in a format (`ISO-8601`, e.g. - `hh:mm:ss`) + * opening_at (str) - time in a format (`ISO-8601`, e.g. - `hh:mm:ss`) + * closed_at (str) - time in a format (`ISO-8601`, e.g. - `hh:mm:ss`) + ##### Response + ``` + { + "id": 1, + ... + } + ``` """ lookup_field = 'slug' serializer_class = ScheduleCreateSerializer @@ -292,8 +320,8 @@ class CardAndWinesListView(generics.RetrieveAPIView): queryset = models.Establishment.objects.with_base_related() def get_object(self): - establishment = models.Establishment.objects.prefetch_plates()\ - .filter(pk=self.kwargs['establishment_id'])\ + establishment = models.Establishment.objects.prefetch_plates() \ + .filter(pk=self.kwargs['establishment_id']) \ .first() if establishment is None: raise Http404 @@ -809,7 +837,7 @@ class EmployeesListSearchViews(generics.ListAPIView): serializer_class = serializers.EmployeeBackSerializers queryset = ( models.Employee.objects.with_back_office_related() - .select_related('photo') + .select_related('photo') ) permission_classes = get_permission_classes( IsEstablishmentManager, @@ -931,6 +959,43 @@ class EmployeeRUDView(generics.RetrieveUpdateDestroyAPIView): ) +class AdminEmployeeListView(generics.ListAPIView): + """ + ## Employee list view, where request user is ESTABLISHMENT_ADMINISTRATOR. + ### *GET* + #### Description + Return paginated list of employees. + + #### Response + ``` + { + "id": 1324, + "name": "Alex", + "last_name": "Wolf", + { + ``` + + """ + serializer_class = serializers.AdminEmployeeBackSerializers + permission_classes = get_permission_classes(IsEstablishmentAdministrator, ) + pagination_class = None + + def get_queryset(self): + user = self.request.user + if user.is_anonymous: + return None + est_ids = models.Establishment.objects.filter( + userrole__user=user, + userrole__role__role=Role.ESTABLISHMENT_ADMINISTRATOR, + ).values_list('id', flat=True) + + qs = models.Employee.objects.filter(establishments__in=est_ids).distinct().with_back_office_related() + + if self.request.country_code: + qs = qs.filter(establishments__address__city__country__code=self.request.country_code) + return qs + + class RemoveAwardView(generics.DestroyAPIView): """ ## Remove award view. @@ -1060,7 +1125,38 @@ class EstablishmentSubtypeRUDView(generics.RetrieveUpdateDestroyAPIView): class EstablishmentGalleryCreateDestroyView(EstablishmentMixinViews, CreateDestroyGalleryViewMixin): - """Resource for a create|destroy gallery for establishment for back-office users.""" + """ + ## Establishment gallery image Create/Destroy view + ### *POST* + #### Description + Attaching existing **image** by `image identifier` to **establishment** by `establishment slug` + in request kwargs. + ##### Request + ``` + No body + ``` + ##### Response + E.g.: + ``` + No content + ``` + + ### *DELETE* + #### Description + Delete existing **gallery image** from **establishment** gallery, by `image identifier` + and `establishment slug` in request kwargs. + + **Note**: + > Image wouldn't be deleted after all. + ##### Request + ``` + No body + ``` + ##### Response + ``` + No content + ``` + """ lookup_field = 'slug' serializer_class = serializers.EstablishmentBackOfficeGallerySerializer permission_classes = get_permission_classes() @@ -1083,7 +1179,28 @@ class EstablishmentGalleryCreateDestroyView(EstablishmentMixinViews, class EstablishmentGalleryListView(EstablishmentMixinViews, generics.ListAPIView): - """Resource for returning gallery for establishment for back-office users.""" + """ + ## Establishment gallery image list view + ### *GET* + #### Description + Returning paginated list of establishment images by `establishment slug`, + with cropped images. + ##### Response + E.g.: + ``` + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 11, + ... + } + ] + } + ``` + """ lookup_field = 'slug' serializer_class = serializers.ImageBaseSerializer permission_classes = get_permission_classes() @@ -1093,7 +1210,7 @@ class EstablishmentGalleryListView(EstablishmentMixinViews, qs = super(EstablishmentGalleryListView, self).get_queryset() establishment = get_object_or_404(qs, slug=self.kwargs.get('slug')) - # May raise a permission denied + # May raises a permission denied self.check_object_permissions(self.request, establishment) return establishment @@ -1330,10 +1447,10 @@ class EstablishmentGuideCreateDestroyView(generics.GenericAPIView): lookup_url_kwarg = getattr(self, 'establishment_lookup_url_kwarg', None) assert lookup_url_kwarg in self.kwargs, ( - 'Expected view %s to be called with a URL keyword argument ' - 'named "%s". Fix your URL conf, or set the `.lookup_field` ' - 'attribute on the view correctly.' % - (self.__class__.__name__, lookup_url_kwarg) + 'Expected view %s to be called with a URL keyword argument ' + 'named "%s". Fix your URL conf, or set the `.lookup_field` ' + 'attribute on the view correctly.' % + (self.__class__.__name__, lookup_url_kwarg) ) filters = {'klass': queryset, lookup_url_kwarg: self.kwargs.get(lookup_url_kwarg)} @@ -1351,10 +1468,10 @@ class EstablishmentGuideCreateDestroyView(generics.GenericAPIView): lookup_url_kwarg = getattr(self, 'guide_lookup_url_kwarg', None) assert lookup_url_kwarg in self.kwargs, ( - 'Expected view %s to be called with a URL keyword argument ' - 'named "%s". Fix your URL conf, or set the `.lookup_field` ' - 'attribute on the view correctly.' % - (self.__class__.__name__, lookup_url_kwarg) + 'Expected view %s to be called with a URL keyword argument ' + 'named "%s". Fix your URL conf, or set the `.lookup_field` ' + 'attribute on the view correctly.' % + (self.__class__.__name__, lookup_url_kwarg) ) obj = get_object_or_404(klass=queryset, id=self.kwargs.get(lookup_url_kwarg)) @@ -1535,4 +1652,4 @@ class EstablishmentAwardCreateAndBind(generics.CreateAPIView, generics.DestroyAP def delete(self, request, *args, **kwargs): establishment = get_object_or_404(models.Establishment, id=kwargs['establishment_id']) establishment.remove_award(kwargs['award_id']) - return self._award_list_for_establishment(kwargs['establishment_id'], status.HTTP_200_OK) \ No newline at end of file + return self._award_list_for_establishment(kwargs['establishment_id'], status.HTTP_200_OK) diff --git a/apps/gallery/migrations/0010_auto_20200206_1944.py b/apps/gallery/migrations/0010_auto_20200206_1944.py new file mode 100644 index 00000000..218aa2c6 --- /dev/null +++ b/apps/gallery/migrations/0010_auto_20200206_1944.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2020-02-06 19:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0009_auto_20200206_1749'), + ] + + operations = [ + migrations.AlterField( + model_name='image', + name='is_public', + field=models.BooleanField(default=True, verbose_name='Is media source public'), + ), + ] diff --git a/apps/gallery/migrations/0011_image_cropbox.py b/apps/gallery/migrations/0011_image_cropbox.py new file mode 100644 index 00000000..26f8d569 --- /dev/null +++ b/apps/gallery/migrations/0011_image_cropbox.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.7 on 2020-02-08 19:00 + +import django.core.validators +from django.db import migrations, models +import re + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0010_auto_20200206_1944'), + ] + + operations = [ + migrations.AddField( + model_name='image', + name='cropbox', + field=models.CharField(default=None, max_length=500, null=True, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:,\\d+)*\\Z'), code='invalid', message='Enter only digits separated by commas.')], verbose_name='x1,y1,x2,y2 crop settings'), + ), + ] diff --git a/apps/gallery/models.py b/apps/gallery/models.py index cc51af11..6a19b859 100644 --- a/apps/gallery/models.py +++ b/apps/gallery/models.py @@ -1,5 +1,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from django.core import validators +from sorl.thumbnail import get_thumbnail from botocore.exceptions import ClientError from django.conf import settings from project.storage_backends import PublicMediaStorage @@ -43,6 +45,8 @@ class Image(BaseAttributes, SORLImageMixin, PlatformMixin): default=None) link = models.URLField(blank=True, null=True, default=None, verbose_name=_('mp4 or youtube video link')) order = models.PositiveIntegerField(default=0, verbose_name=_('Sorting order')) + cropbox = models.CharField(max_length=500, validators=[validators.validate_comma_separated_integer_list], null=True, + default=None, verbose_name=_('x1,y1,x2,y2 crop settings')) objects = ImageQuerySet.as_manager() class Meta: @@ -55,6 +59,16 @@ class Image(BaseAttributes, SORLImageMixin, PlatformMixin): """String representation""" return f'{self.id}' + @property + def image_by_cropbox(self): + """Returns cropped image if cropbox is set""" + if self.cropbox and self.image: + x1, y1, x2, y2 = map(int, self.cropbox.split(',')) + return get_thumbnail(self.image, + geometry_string=f'{round(x2 - x1)}x{round(y2 - y1)}', + cropbox=self.cropbox, + quality=100) + def set_pubic(self, is_public=True): if not settings.AWS_STORAGE_BUCKET_NAME: """Backend doesn't use aws s3""" @@ -69,6 +83,12 @@ class Image(BaseAttributes, SORLImageMixin, PlatformMixin): else: file_object.Acl().put(ACL='authenticated-read') + @property + def is_main(self) -> bool: + establishment_gallery_list = list(self.establishment_gallery.all()) + if establishment_gallery_list and len(establishment_gallery_list): + return establishment_gallery_list[0].is_main + @property def type(self) -> str: if self.image: diff --git a/apps/gallery/serializers.py b/apps/gallery/serializers.py index 0e50b231..2667d7bc 100644 --- a/apps/gallery/serializers.py +++ b/apps/gallery/serializers.py @@ -6,7 +6,7 @@ from sorl.thumbnail.parsers import parse_crop, ThumbnailParseError from django.utils.translation import gettext_lazy as _ from django.conf import settings from django.shortcuts import get_object_or_404 -from establishment.models import Establishment +from establishment.models import Establishment, EstablishmentGallery from account.serializers.common import UserBaseSerializer from . import models @@ -53,6 +53,8 @@ class EstablishmentGallerySerializer(serializers.ModelSerializer): type = serializers.ChoiceField(read_only=True, choices=models.Image.MEDIA_TYPES) created_by = UserBaseSerializer(read_only=True, allow_null=True) image_size_in_KB = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=20) + is_main = serializers.BooleanField() + cropped_image = serializers.ImageField(source='image_by_cropbox', allow_null=True, read_only=True) class Meta: model = models.Image @@ -65,11 +67,16 @@ class EstablishmentGallerySerializer(serializers.ModelSerializer): 'preview', 'is_public', 'title', + 'is_main', 'created_by', + 'created', 'image_size_in_KB', + 'cropbox', + 'cropped_image', ) extra_kwargs = { 'created': {'read_only': True}, + 'created_by': {'read_only': True}, } def validate(self, attrs): @@ -78,24 +85,43 @@ class EstablishmentGallerySerializer(serializers.ModelSerializer): if image and image.size >= settings.FILE_UPLOAD_MAX_MEMORY_SIZE: raise serializers.ValidationError({'detail': _('File size too large: %s bytes') % image.size}) - + if attrs.get('cropbox'): + if len(attrs['cropbox'].split(',')) != 4: + raise serializers.ValidationError({'detail': _('Cropbox contains 4 integer values separated by comma.')}) return attrs def create(self, validated_data): + is_main = validated_data.pop('is_main') establishment = get_object_or_404(klass=Establishment, pk=self.context['view'].kwargs['establishment_id']) instance = super().create(validated_data) instance.created_by = self.context['request'].user instance.establishment_set.add(establishment) instance.save() + if is_main: + EstablishmentGallery.objects.filter( + establishment=establishment + ).update(is_main=False) # reset all before setting True on some instance + EstablishmentGallery.objects.filter( + image=instance + ).update(is_main=is_main) return instance def update(self, instance: models.Image, validated_data): if instance.is_public != validated_data.get('is_public'): instance.set_pubic(validated_data.get('is_public', True)) + if 'is_main' in validated_data: + is_main = validated_data.pop('is_main') + if is_main: + establishment = instance.establishment_gallery.all()[0].establishment + EstablishmentGallery.objects.filter( + establishment=establishment + ).update(is_main=False) # reset all before setting True on some instance + EstablishmentGallery.objects.filter( + image=instance + ).update(is_main=is_main) return super().update(instance, validated_data) - class CropImageSerializer(ImageSerializer): """Serializers for image crops.""" diff --git a/apps/gallery/views.py b/apps/gallery/views.py index 7dc99248..da0cac21 100644 --- a/apps/gallery/views.py +++ b/apps/gallery/views.py @@ -1,6 +1,7 @@ from django.conf import settings from django.db.transaction import on_commit from rest_framework import generics, status +from rest_framework.permissions import AllowAny from rest_framework.response import Response from utils.methods import get_permission_classes @@ -8,6 +9,7 @@ from utils.permissions import IsContentPageManager, IsCountryAdmin, IsEstablishm IsProducerFoodInspector, IsEstablishmentAdministrator from . import tasks, models, serializers + class ImageBaseView(generics.GenericAPIView): """Base Image view.""" model = models.Image @@ -19,24 +21,83 @@ class ImageBaseView(generics.GenericAPIView): class ImageListCreateView(ImageBaseView, generics.ListCreateAPIView): - """List/Create Image view.""" + """ + ## List/Create view + ### *GET* + #### Description + Get paginated list of images, with ordering by field `modified` (descending) + #### Response + E.g.: + ``` + { + "count": 40595, + "next": 2, + "previous": null, + "results": [ + { + "id": 47336, + ... + } + ] + } + ``` + ### *POST* + #### Description + Upload an image on a server. + ##### Request + Required: + * file (`file`) - download file + Available: + * orientation (`enum`) - default: `null` + ``` + 0 (Horizontal) + 1 (Vertical) + ``` + * title (`str`) - title of image file (default - `''`) + * is_public (`bool`) - flag that responds for availability + for displaying (default - `True`) + * preview (`file`) - download preview file (default - `null`) + * link (`str`) - mp4 or youtube video link (default - `null`) + * order (`int`) - order number (default - `0`) + ##### Response + E.g.: + ``` + { + "id": 47336, + ... + } + + ``` + """ class MediaForEstablishmentView(ImageBaseView, generics.ListCreateAPIView): """View for creating and retrieving certain establishment media.""" pagination_class = None - permission_classes = (IsCountryAdmin, IsEstablishmentAdministrator, IsEstablishmentManager, IsProducerFoodInspector) + # permission_classes = (IsCountryAdmin, IsEstablishmentAdministrator, IsEstablishmentManager, IsProducerFoodInspector) + permission_classes = (AllowAny, ) serializer_class = serializers.EstablishmentGallerySerializer def get_queryset(self): return super().get_queryset().filter(establishment__pk=self.kwargs['establishment_id'])\ - .order_by('-order').prefetch_related('created_by') + .order_by('-establishment_gallery__is_main', '-order').prefetch_related('created_by', + 'establishment_gallery') -class MediaUpdateView(ImageBaseView, generics.UpdateAPIView): +class MediaUpdateView(ImageBaseView, generics.UpdateAPIView, generics.DestroyAPIView): """View for updating media data""" serializer_class = serializers.EstablishmentGallerySerializer - permission_classes = () + # permission_classes = (IsCountryAdmin, IsEstablishmentAdministrator, IsEstablishmentManager, IsProducerFoodInspector) + permission_classes = (AllowAny, ) + + def delete(self, request, *args, **kwargs): + """Override destroy view""" + instance = self.get_object() + if settings.USE_CELERY: + on_commit(lambda: tasks.delete_image.delay(image_id=instance.id)) + else: + on_commit(lambda: tasks.delete_image(image_id=instance.id)) + return Response(status=status.HTTP_204_NO_CONTENT) class ImageRetrieveDestroyView(ImageBaseView, generics.RetrieveDestroyAPIView): diff --git a/apps/location/serializers/common.py b/apps/location/serializers/common.py index b4ea9c5f..9221c3b6 100644 --- a/apps/location/serializers/common.py +++ b/apps/location/serializers/common.py @@ -223,11 +223,18 @@ class AddressBaseSerializer(serializers.ModelSerializer): class AddressEstablishmentSerializer(AddressBaseSerializer): """Address serializer.""" - id = serializers.IntegerField(required=True) - street_name_1 = serializers.CharField(required=False, default='') - street_name_2 = serializers.CharField(required=False, default='') + id = serializers.IntegerField(required=False) + street_name_1 = serializers.CharField(required=False, allow_blank=True, default='') + street_name_2 = serializers.CharField(required=False, allow_blank=True, default='') number = serializers.IntegerField(required=False, default=0) postal_code = serializers.CharField(required=False, default='') + city_id = serializers.PrimaryKeyRelatedField( + source='city', + queryset=models.City.objects.all(), + write_only=True, + required=True, + ) + city = CityBaseSerializer(read_only=True) class Meta(AddressBaseSerializer.Meta): """Meta class.""" @@ -238,6 +245,8 @@ class AddressEstablishmentSerializer(AddressBaseSerializer): 'street_name_2', 'number', 'postal_code', + 'city_id', + 'city', ) diff --git a/apps/main/filters.py b/apps/main/filters.py index 72636d9e..065cfdd0 100644 --- a/apps/main/filters.py +++ b/apps/main/filters.py @@ -38,3 +38,23 @@ class AwardFilter(filters.FilterSet): if value not in EMPTY_VALUES: return queryset.by_employee_id(value, content_type='establishmentemployee') return queryset + + +class AwardTypeFilterSet(filters.FilterSet): + """Award type FilterSet.""" + + id = filters.NumberFilter(help_text='Filter by AwardType identifier.') + name = filters.CharFilter(method='by_name', help_text='Filter by AwardType name.') + + class Meta: + """Meta class.""" + model = models.AwardType + fields = [ + 'id', + 'name', + ] + + def by_name(self, queryset, name, value): + if value not in EMPTY_VALUES: + return queryset.by_name(value) + return queryset \ No newline at end of file diff --git a/apps/main/models.py b/apps/main/models.py index 639621a3..343b75d4 100644 --- a/apps/main/models.py +++ b/apps/main/models.py @@ -223,6 +223,10 @@ class AwardTypeQuerySet(models.QuerySet): """Filter QuerySet by country code.""" return self.filter(country__code=country_code) + def by_name(self, name: str): + """Filter by name field.""" + return self.filter(name__icontains=name) + class AwardType(models.Model): """AwardType model.""" diff --git a/apps/main/serializers/back.py b/apps/main/serializers/back.py index 58221fc0..1bf8b3bc 100644 --- a/apps/main/serializers/back.py +++ b/apps/main/serializers/back.py @@ -3,6 +3,7 @@ from rest_framework import serializers from account.models import User from account.serializers import BackUserSerializer from main import models +from main.serializers import CarouselListSerializer class PanelSerializer(serializers.ModelSerializer): @@ -27,3 +28,15 @@ class PanelSerializer(serializers.ModelSerializer): 'user', 'user_id' ] + + +class BackCarouselListSerializer(CarouselListSerializer): + """Serializer for retrieving list of carousel items.""" + + class Meta: + """Meta class.""" + + model = models.Carousel + fields = CarouselListSerializer.Meta.fields + [ + 'active', + ] diff --git a/apps/main/serializers/common.py b/apps/main/serializers/common.py index 4f7ad68f..fd3e32fe 100644 --- a/apps/main/serializers/common.py +++ b/apps/main/serializers/common.py @@ -1,11 +1,14 @@ """Main app serializers.""" +from typing import Union + from django.contrib.contenttypes.models import ContentType from rest_framework import serializers +from establishment.models import Employee from location.serializers import CountrySerializer from main import models -from establishment.models import Employee from tag.serializers import TagBackOfficeSerializer +from utils.exceptions import EmployeeNotFoundError from utils.serializers import ProjectModelSerializer, RecursiveFieldSerializer, TranslatedField @@ -207,6 +210,7 @@ class AwardBaseSerializer(serializers.ModelSerializer): """Award base serializer.""" title_translated = serializers.CharField(read_only=True, allow_null=True) + title = serializers.CharField(write_only=True, help_text='Title text') class Meta: model = models.Award @@ -215,45 +219,63 @@ class AwardBaseSerializer(serializers.ModelSerializer): 'title_translated', 'vintage_year', 'image_url', + 'title', ] + @property + def request(self): + """Return a request object""" + return self.context.get('request') + + @property + def context_kwargs(self) -> Union[dict, None]: + """Return a request kwargs.""" + if hasattr(self.request, 'parser_context'): + return self.request.parser_context.get('kwargs') + + def validate_title(self, value) -> dict: + """Construct title str to JSON that contains locale from request.""" + return {self.request.locale: value} + class AwardSerializer(AwardBaseSerializer): """Award serializer.""" - award_type = AwardTypeBaseSerializer(read_only=True) - class Meta: - model = models.Award + class Meta(AwardBaseSerializer.Meta): fields = AwardBaseSerializer.Meta.fields + ['award_type', ] class BackAwardSerializer(AwardBaseSerializer): """Award serializer.""" + award_type_display = AwardTypeBaseSerializer(read_only=True, + source='award_type') + award_type = serializers.PrimaryKeyRelatedField( + queryset=models.AwardType.objects.all(), + write_only=True, + required=True, + ) - award_type = AwardTypeBaseSerializer(read_only=True) - - class Meta: - model = models.Award + class Meta(AwardBaseSerializer.Meta): fields = AwardBaseSerializer.Meta.fields + [ 'award_type', + 'award_type_display', 'state', 'content_type', 'object_id', ] + def to_representation(self, instance): + data = super(BackAwardSerializer, self).to_representation(instance) + data['award_type'] = data.pop('award_type_display', None) + return data -class BackAwardEmployeeCreateSerializer(serializers.ModelSerializer): + +class BackAwardEmployeeCreateSerializer(AwardBaseSerializer): """Award, The Creator.""" - award_type = serializers.PrimaryKeyRelatedField(required=True, queryset=models.AwardType.objects.all()) - title = serializers.CharField(write_only=True) - def get_title(self, obj): - pass - - class Meta: - model = models.Award + class Meta(AwardBaseSerializer.Meta): fields = ( 'id', 'award_type', @@ -262,9 +284,15 @@ class BackAwardEmployeeCreateSerializer(serializers.ModelSerializer): ) def validate(self, attrs): - attrs['object_id'] = self.context.get('request').parser_context.get('kwargs')['employee_id'] + """An overridden validate method.""" + employee_id = self.context_kwargs.get('employee_id') + employee_qs = Employee.objects.filter(id=employee_id) + + if not employee_qs.exists(): + raise EmployeeNotFoundError() + + attrs['object_id'] = employee_id attrs['content_type'] = ContentType.objects.get_for_model(Employee) - attrs['title'] = {self.context.get('request').locale: attrs['title']} return attrs diff --git a/apps/main/urls/back.py b/apps/main/urls/back.py index 8b8f5102..b5f7b2f7 100644 --- a/apps/main/urls/back.py +++ b/apps/main/urls/back.py @@ -8,7 +8,8 @@ app_name = 'main' urlpatterns = [ path('awards/', views.AwardLstView.as_view(), name='awards-list-create'), path('awards//', views.AwardRUDView.as_view(), name='awards-rud'), - path('awards/create-and-bind//', views.AwardCreateAndBind.as_view(), name='award-employee-create'), + path('awards/create-and-bind//', views.AwardCreateAndBind.as_view(), + name='award-employee-create'), path('award-types/', views.AwardTypesListView.as_view(), name='awards-types-list'), path('content_type/', views.ContentTypeView.as_view(), name='content_type-list'), path('sites/', views.SiteListBackOfficeView.as_view(), name='site-list-create'), @@ -28,7 +29,6 @@ urlpatterns = [ path('panels//', views.PanelsRUDView.as_view(), name='panels-rud'), path('panels//execute/', views.PanelsExecuteView.as_view(), name='panels-execute'), path('panels//csv/', views.PanelsExportCSVView.as_view(), name='panels-csv'), - path('panels//xls/', views.PanelsExecuteXLSView.as_view(), name='panels-xls') + path('panels//xls/', views.PanelsExecuteXLSView.as_view(), name='panels-xls'), + path('carousel/', views.BackCarouselListView.as_view(), name='carousel-list'), ] - - diff --git a/apps/main/views/back.py b/apps/main/views/back.py index 5584cf00..5c73da5d 100644 --- a/apps/main/views/back.py +++ b/apps/main/views/back.py @@ -9,15 +9,61 @@ from establishment.models import Employee from establishment.serializers.back import EmployeeBackSerializers from main import serializers from main import tasks -from main.filters import AwardFilter -from main.models import Award, Footer, PageType, Panel, SiteFeature, Feature, AwardType -from main.serializers.back import PanelSerializer +from main.filters import AwardFilter, AwardTypeFilterSet +from main.models import Award, Footer, PageType, Panel, SiteFeature, Feature, AwardType, Carousel +from main.serializers.back import PanelSerializer, BackCarouselListSerializer from main.views import SiteSettingsView, SiteListView from utils.methods import get_permission_classes class AwardLstView(generics.ListCreateAPIView): - """Award list create view.""" + """ + ## List of awards + ### *GET* + #### Description + Return paginated list of awards. + Available filters: + * establishment_id (`int`) - Filter by establishment identifier + * product_id (`int`) - Filter by product identifier + * employee_id (`int`) - Filter by employee identifier + * state (`enum`) - `0 (Waiting)`, `1 (Published)` + * award_type (`str`) - Filter by award type identifier + * vintage_year (`str`) - Filter by a vintage year + ##### Response + E.g.: + ``` + { + "count": 58, + "next": 2, + "previous": null, + "results": [ + { + "id": 1, + ... + } + ] + } + ``` + + ### *POST* + #### Description + Create a record in Award table. + ##### Request + Required: + * content_type (`int`) - identifier of content type entity + * object_id (`int`) - identifier of content object + * award_type (`int`) - identifier of award type + * title (`str`) - title of an award + Non required: + * vintage_year (str) - vintage year in a format - `yyyy` + ##### Response + ``` + { + "id": 1, + ... + } + ``` + """ queryset = Award.objects.all().with_base_related() serializer_class = serializers.BackAwardSerializer permission_classes = get_permission_classes() @@ -25,7 +71,35 @@ class AwardLstView(generics.ListCreateAPIView): class AwardCreateAndBind(generics.CreateAPIView): - """Award create and bind to employee by id""" + """ + ## Creating an Award for an Employee. + ### *POST* + #### Description + Creating an Award for an Employee and return in response + serialized Employee object. + ##### Response + E.g. + ``` + { + "id": 1, + ... + } + ``` + ##### Request + Required: + * award_type (`int`) - identifier of award type + * title (`str`) - title of an award + Non required: + * vintage_year (str) - vintage year in a format - `yyyy` + ##### Response + E.g. + ``` + { + "id": 1, + ... + } + ``` + """ queryset = Award.objects.all().with_base_related() serializer_class = serializers.BackAwardEmployeeCreateSerializer permission_classes = get_permission_classes() @@ -41,7 +115,52 @@ class AwardCreateAndBind(generics.CreateAPIView): class AwardRUDView(generics.RetrieveUpdateDestroyAPIView): - """Award RUD view.""" + """ + ## Retrieve/Update/Destroy Award view + ### *GET* + #### Description + Retrieving serialized object of an Award by an identifier + #### Response + E.g. + ``` + { + "id": 1, + ... + } + ``` + + ### *PATCH* + #### Description + Partially update Award object by identifier + ##### Request + Available: + * content_type (`int`) - identifier of content type entity + * object_id (`int`) - identifier of content object + * award_type (`int`) - identifier of award type + * title (`str`) - title of an award + * vintage_year (str) - vintage year in a format - `yyyy` + ##### Response + E.g. + ``` + { + "id": 1, + ... + } + ``` + + ### *DELETE* + #### Description + Delete an Award instance by award identifier + ##### Request + ``` + No request data + ``` + ##### Response + E.g. + ``` + No content + ``` + """ queryset = Award.objects.all().with_base_related() serializer_class = serializers.BackAwardSerializer permission_classes = get_permission_classes() @@ -49,20 +168,32 @@ class AwardRUDView(generics.RetrieveUpdateDestroyAPIView): class AwardTypesListView(generics.ListAPIView): - """AwardType List view.""" + """ + ## List of Award types view. + ### *GET* + #### Description + Return non paginated list of Award types filtered by request country code. + Available filters: + * id (`int`) - award type identifier + * name (`str`) - award type name + ##### Response + ``` + [ + { + "id": 1, + ... + } + ] + ``` + """ pagination_class = None serializer_class = serializers.AwardTypeBaseSerializer permission_classes = get_permission_classes() - filter_backends = (DjangoFilterBackend,) - ordering_fields = '__all__' lookup_field = 'id' - filterset_fields = ( - 'id', - 'name', - ) + filter_class = AwardTypeFilterSet def get_queryset(self): - """Overridden get_queryset method.""" + """An overridden get_queryset method.""" if hasattr(self, 'request') and hasattr(self.request, 'country_code'): return AwardType.objects.by_country_code(self.request.country_code) return AwardType.objects.none() @@ -200,3 +331,40 @@ class PanelsExecuteXLSView(PanelsExecuteView): {"success": _('The file will be sent to your email.')}, status=status.HTTP_200_OK ) + + +class BackCarouselListView(generics.ListAPIView): + """ + ## List of carousel. + ### *GET* + #### Description + Return list of carousel items. + ##### Response + E.g.: + ``` + { + "id": 1, + "model_name": "model_name", + "name": "name", + ... + "awards": [ + { + "id": 1, + ... + } + ] + } + ``` + """ + + queryset = Carousel.objects.all() + serializer_class = BackCarouselListSerializer + permission_classes = get_permission_classes() + pagination_class = None + + def get_queryset(self): + country_code = self.request.country_code + qs = Carousel.objects.all() + if country_code: + qs = qs.by_country_code(country_code) + return qs diff --git a/apps/news/models.py b/apps/news/models.py index e840f9c5..8924474a 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -22,8 +22,10 @@ from utils.models import ( BaseAttributes, FavoritesMixin, GalleryMixin, HasTagsMixin, IntermediateGalleryModelMixin, ProjectBaseMixin, TJSONField, TranslatedFieldsMixin, TypeDefaultImageMixin, -) + CarouselMixin) from utils.querysets import TranslationQuerysetMixin +from location.models import Country +from utils.parsers import NewsSlug class Agenda(ProjectBaseMixin, TranslatedFieldsMixin): @@ -257,8 +259,12 @@ class NewsQuerySet(TranslationQuerysetMixin): return self.filter(site__country__code=country_code) if not user.is_superuser else self -class News(GalleryMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixin, - FavoritesMixin): +class News(GalleryMixin, + BaseAttributes, + TranslatedFieldsMixin, + HasTagsMixin, + FavoritesMixin, + CarouselMixin): """News model.""" STR_FIELD_NAME = 'title' @@ -358,24 +364,41 @@ class News(GalleryMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixin, return f'news: {next(iter(self.slugs.values()))}' def create_duplicate(self, new_country, view_count_model): + country_codes = list(Country.objects.all().values_list('code', flat=True)) + + # Get all existed slugs + all_slugs = {slug_value + for slug_dict in News.objects.all().values_list('slugs', flat=True) + for slug_value in slug_dict.values()} + + new_slugs = {} + for locale, raw_slug in self.slugs.items(): + slug = NewsSlug.parse(raw_slug, country_codes) + + # all slugs LIKE% slug + similar_slugs = sorted(x for x in all_slugs if NewsSlug.parse(x, country_codes).value == slug.value) + + if len(similar_slugs) == 0: + # It is impossible because at least current instance has slug + raise ValueError('Duplicating unsaved object') + else: + # The last slug in similar_slugs is slug with largest count + last_slug = NewsSlug.parse(similar_slugs[-1], country_codes) + + new_slug = NewsSlug(slug.value, new_country.code, last_slug.count) + if last_slug.country_code is not None: + new_slug.count += 1 + + new_slugs[locale] = str(new_slug) + self.pk = None self.state = self.UNPUBLISHED - self.slugs = {locale: f'{slug}-{new_country.code}' for locale, slug in self.slugs.items()} + self.slugs = new_slugs self.country = new_country self.views_count = view_count_model 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""" diff --git a/apps/news/views.py b/apps/news/views.py index b0c11e08..5071d79e 100644 --- a/apps/news/views.py +++ b/apps/news/views.py @@ -176,7 +176,38 @@ class NewsBackOfficeLCView(NewsBackOfficeMixinView, class NewsBackOfficeGalleryCreateDestroyView(NewsBackOfficeMixinView, CreateDestroyGalleryViewMixin): - """Resource for a create gallery for news for back-office users.""" + """ + ## News gallery image Create/Destroy view + ### *POST* + #### Description + Attaching existing **image** by `image identifier` to **news** by `news identifier` + in request kwargs. + ##### Request + ``` + No body + ``` + ##### Response + E.g.: + ``` + No content + ``` + + ### *DELETE* + #### Description + Delete existing **gallery image** from **news** gallery, by `image identifier` + and `news identifier` in request kwargs. + + **Note**: + > Image wouldn't be deleted after all. + ##### Request + ``` + No body + ``` + ##### Response + ``` + No content + ``` + """ serializer_class = serializers.NewsBackOfficeGallerySerializer def create(self, request, *args, **kwargs): @@ -203,7 +234,28 @@ class NewsBackOfficeGalleryCreateDestroyView(NewsBackOfficeMixinView, class NewsBackOfficeGalleryListView(NewsBackOfficeMixinView, generics.ListAPIView): - """Resource for returning gallery for news for back-office users.""" + """ + ## News gallery image list view + ### *GET* + #### Description + Returning paginated list of news images by `news identifier`, + with cropped images. + ##### Response + E.g.: + ``` + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 11, + ... + } + ] + } + ``` + """ serializer_class = ImageBaseSerializer def get_object(self): diff --git a/apps/product/views/back.py b/apps/product/views/back.py index def0a13a..217cc255 100644 --- a/apps/product/views/back.py +++ b/apps/product/views/back.py @@ -46,7 +46,38 @@ class ProductSubTypeBackOfficeMixinView: class ProductBackOfficeGalleryCreateDestroyView(ProductBackOfficeMixinView, CreateDestroyGalleryViewMixin): - """Resource for a create gallery for product for back-office users.""" + """ + ## Product gallery image Create/Destroy view + ### *POST* + #### Description + Attaching existing **image** by `image identifier` to **product** by `product identifier` + in request kwargs. + ##### Request + ``` + No body + ``` + ##### Response + E.g.: + ``` + No content + ``` + + ### *DELETE* + #### Description + Delete existing **gallery image** from **product** gallery, by `image identifier` + and `product identifier` in request kwargs. + + **Note**: + > Image wouldn't be deleted after all. + ##### Request + ``` + No body + ``` + ##### Response + ``` + No content + ``` + """ serializer_class = serializers.ProductBackOfficeGallerySerializer def get_object(self): @@ -66,7 +97,28 @@ class ProductBackOfficeGalleryCreateDestroyView(ProductBackOfficeMixinView, class ProductBackOfficeGalleryListView(ProductBackOfficeMixinView, generics.ListAPIView): - """Resource for returning gallery for product for back-office users.""" + """ + ## Product gallery image list view + ### *GET* + #### Description + Returning paginated list of product images by `product identifier`, + with cropped images. + ##### Response + E.g.: + ``` + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 11, + ... + } + ] + } + ``` + """ serializer_class = ImageBaseSerializer def get_object(self): diff --git a/apps/review/models.py b/apps/review/models.py index f17629ba..34f2c4b7 100644 --- a/apps/review/models.py +++ b/apps/review/models.py @@ -97,6 +97,10 @@ class Review(BaseAttributes, TranslatedFieldsMixin): objects = ReviewQuerySet.as_manager() + @property + def status_display(self): + return self.REVIEW_STATUSES[self.status][1] + class Meta: """Meta class.""" verbose_name = _('Review') diff --git a/apps/review/serializers/common.py b/apps/review/serializers/common.py index e714fff7..46a9741d 100644 --- a/apps/review/serializers/common.py +++ b/apps/review/serializers/common.py @@ -4,14 +4,19 @@ from review.models import Review, Inquiries, GridItems class ReviewBaseSerializer(serializers.ModelSerializer): + text_translated = serializers.CharField(read_only=True) + status_display = serializers.CharField(read_only=True) + class Meta: model = Review fields = ( 'id', 'reviewer', 'text', + 'text_translated', 'priority', 'status', + 'status_display', 'child', 'published_at', 'vintage', diff --git a/apps/tag/filters.py b/apps/tag/filters.py index a9f675c6..a531e730 100644 --- a/apps/tag/filters.py +++ b/apps/tag/filters.py @@ -55,7 +55,7 @@ class TagCategoryFilterSet(TagsBaseFilterSet): if value == EstablishmentType.ARTISAN: qs = models.TagCategory.objects.with_base_related().filter(index_name='shop_category') else: - qs = queryset.by_establishment_type(value) + qs = queryset.by_establishment_type(value).exclude(index_name__in=['guide', 'collection']) return qs diff --git a/apps/timetable/serialziers.py b/apps/timetable/serialziers.py index a3183543..a23de288 100644 --- a/apps/timetable/serialziers.py +++ b/apps/timetable/serialziers.py @@ -1,7 +1,5 @@ """Serializer for app timetable""" -import datetime - from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -23,8 +21,6 @@ class ScheduleRUDSerializer(serializers.ModelSerializer): dinner_end = serializers.TimeField(required=False) opening_at = serializers.TimeField(required=False) closed_at = serializers.TimeField(required=False) - # For permission!! - establishment_id = serializers.ReadOnlyField(source='establishment.id') class Meta: """Meta class.""" @@ -40,7 +36,6 @@ class ScheduleRUDSerializer(serializers.ModelSerializer): 'dinner_end', 'opening_at', 'closed_at', - 'establishment_id' ] def validate(self, attrs): diff --git a/apps/utils/exceptions.py b/apps/utils/exceptions.py index 03507ab0..3c88736b 100644 --- a/apps/utils/exceptions.py +++ b/apps/utils/exceptions.py @@ -32,6 +32,11 @@ class UserNotFoundError(AuthErrorMixin, ProjectBaseException): default_detail = _('User not found') +class EmployeeNotFoundError(ProjectBaseException): + """The exception should be thrown when the employee cannot get""" + default_detail = _('Employee not found') + + class EmailSendingError(exceptions.APIException): """The exception should be thrown when unable to send an email""" status_code = status.HTTP_400_BAD_REQUEST diff --git a/apps/utils/models.py b/apps/utils/models.py index 2f2a39d1..adbfbbeb 100644 --- a/apps/utils/models.py +++ b/apps/utils/models.py @@ -4,6 +4,7 @@ from os.path import exists from django.conf import settings from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.contrib.contenttypes.models import ContentType from django.contrib.gis.db import models from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import JSONField @@ -517,6 +518,7 @@ def default_menu_bool_array(): class PhoneModelMixin: """Mixin for PhoneNumberField.""" + @cached_property def country_calling_code(self): """Return phone code from PhonеNumberField.""" @@ -537,3 +539,32 @@ class AwardsModelMixin: if hasattr(self, 'awards'): self.awards.remove(award) + + +class CarouselMixin: + @property + def must_of_the_week(self) -> bool: + """Detects whether current item in carousel""" + from main.models import Carousel + + if hasattr(self, 'pk') and (hasattr(self, 'country') or hasattr(self, 'country_id')): + kwargs = { + 'content_type': ContentType.objects.get_for_model(self), + 'object_id': self.pk, + 'country': getattr(self, 'country', getattr(self, 'country_id', None)), + } + + return Carousel.objects.filter(**kwargs).exists() + + return False + + +class UpdateByMixin(models.Model): + """Modify by mixin""" + last_update_by_manager = models.DateTimeField(null=True) + + last_update_by_gm = models.DateTimeField(null=True) + + class Meta: + """Meta class.""" + abstract = True diff --git a/apps/utils/parsers.py b/apps/utils/parsers.py new file mode 100644 index 00000000..dc84a60e --- /dev/null +++ b/apps/utils/parsers.py @@ -0,0 +1,49 @@ + + +class NewsSlug: + def __init__(self, value=None, country_code=None, count=0): + self.value = value + self.country_code = country_code + self.count = count + + @classmethod + def parse(cls, raw_slug, country_codes): + slug, *rest = raw_slug.split('-') + instance = NewsSlug() + + if len(rest) >= 1 and rest[-1] in country_codes: + # It is like 'slug-en' + + instance.value = '-'.join([slug, *rest[:-1]]) + instance.country_code = rest[-1] + elif len(rest) >= 2 and rest[-1].isdigit() and rest[-2] in country_codes: + # It is like 'slug-en-1' + + instance.value = '-'.join([slug, *rest[:-2]]) + instance.country_code = rest[-2] + instance.count = int(rest[-1]) + else: + # It is like 'slug' + + instance.value = '-'.join([slug, *rest]) + + return instance + + def __lt__(self, other): + return self.value < other.value + + def __str__(self): + if self.value is None: + raise ValueError('No value for slug') + + slug_parts = [self.value] + if self.country_code is not None: + slug_parts.append(self.country_code) + + if self.count != 0: + slug_parts.append(str(self.count)) + + return '-'.join(slug_parts) + + def __repr__(self): + return f'<{self.__class__.__name__} {self.value}, {self.country_code}, {self.count}>' diff --git a/apps/utils/serializers.py b/apps/utils/serializers.py index 2922d4cf..d609f6ec 100644 --- a/apps/utils/serializers.py +++ b/apps/utils/serializers.py @@ -108,6 +108,7 @@ class CarouselCreateSerializer(serializers.ModelSerializer): model = Carousel fields = [ 'id', + 'active', ] @property diff --git a/project/settings/development.py b/project/settings/development.py index 9539daee..25736d95 100644 --- a/project/settings/development.py +++ b/project/settings/development.py @@ -1,5 +1,4 @@ """Development settings.""" -from .amazon_s3 import * from .base import * ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126', '0.0.0.0'] @@ -15,7 +14,6 @@ DEFAULT_SUBDOMAIN = 'www' SITE_DOMAIN_URI = 'id-east.ru' DOMAIN_URI = 'gm.id-east.ru' - CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', @@ -26,7 +24,6 @@ CACHES = { } } - # ELASTICSEARCH SETTINGS ELASTICSEARCH_DSL = { 'default': { @@ -35,7 +32,6 @@ ELASTICSEARCH_DSL = { } } - ELASTICSEARCH_INDEX_NAMES = { 'search_indexes.documents.news': 'development_news', 'search_indexes.documents.establishment': 'development_establishment', @@ -45,7 +41,6 @@ ELASTICSEARCH_INDEX_NAMES = { # ELASTICSEARCH_DSL_AUTOSYNC = False - # DATABASE DATABASES.update({ 'legacy': { @@ -75,7 +70,6 @@ EMAIL_HOST_USER = 'anatolyfeteleu@gmail.com' EMAIL_HOST_PASSWORD = 'nggrlnbehzksgmbt' EMAIL_PORT = 587 - MIDDLEWARE.append('utils.middleware.log_db_queries_per_API_request') LOGGING = { @@ -107,3 +101,7 @@ LOGGING = { }, } } + +EMAIL_TECHNICAL_SUPPORT = 'n.malinova@octopod.ru' + +from .amazon_s3 import *