diff --git a/.gitignore b/.gitignore index 9cb2b74f..78187ac0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ logs/ ./docker-compose.override.yml celerybeat-schedule -local_files \ No newline at end of file +local_files +celerybeat.pid diff --git a/apps/account/migrations/0020_role_site.py b/apps/account/migrations/0020_role_site.py new file mode 100644 index 00000000..8fce5f24 --- /dev/null +++ b/apps/account/migrations/0020_role_site.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.7 on 2019-11-22 08:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0037_sitesettings_old_id'), + ('account', '0019_auto_20191108_0827'), + ] + + operations = [ + migrations.AddField( + model_name='role', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='main.SiteSettings', verbose_name='Site settings'), + ), + ] diff --git a/apps/account/models.py b/apps/account/models.py index 78c3c284..c212ffda 100644 --- a/apps/account/models.py +++ b/apps/account/models.py @@ -46,10 +46,8 @@ class Role(ProjectBaseMixin): null=False, blank=False) country = models.ForeignKey(Country, verbose_name=_('Country'), null=True, blank=True, on_delete=models.SET_NULL) - # is_list = models.BooleanField(verbose_name=_('list'), default=True, null=False) - # is_create = models.BooleanField(verbose_name=_('create'), default=False, null=False) - # is_update = models.BooleanField(verbose_name=_('update'), default=False, null=False) - # is_delete = models.BooleanField(verbose_name=_('delete'), default=False, null=False) + site = models.ForeignKey(SiteSettings, verbose_name=_('Site settings'), + null=True, blank=True, on_delete=models.SET_NULL) class UserManager(BaseUserManager): diff --git a/apps/booking/views.py b/apps/booking/views.py index 73f6f55e..06ef9273 100644 --- a/apps/booking/views.py +++ b/apps/booking/views.py @@ -34,7 +34,7 @@ class CheckWhetherBookingAvailable(generics.GenericAPIView): periods = response['periods'] periods_by_name = {period['period']: period for period in periods if 'period' in period} if not periods_by_name: - raise ValueError('Empty guestonline response') + return None period_template = iter(periods_by_name.values()).__next__().copy() period_template.pop('total_left_seats') @@ -84,8 +84,10 @@ class CheckWhetherBookingAvailable(generics.GenericAPIView): service_response = self._preprocess_guestonline_response(service.response) \ if establishment.guestonline_id is not None \ - else service.response - response.update({'details': service_response} if service and service.response else {}) + else service.response if service else None + response.update({'details': service_response}) + if service_response is None: + response['available'] = False return Response(data=response, status=200) diff --git a/apps/collection/management/commands/fix_collection.py b/apps/collection/management/commands/fix_collection.py new file mode 100644 index 00000000..9162f8f7 --- /dev/null +++ b/apps/collection/management/commands/fix_collection.py @@ -0,0 +1,32 @@ +from django.conf import settings +from django.core.management.base import BaseCommand +from tqdm import tqdm + +from collection.models import Collection + + +class Command(BaseCommand): + help = """Fix existed collections.""" + + def handle(self, *args, **kwarg): + update_collections = [] + collections = Collection.objects.values_list('id', 'collection_type', 'description') + for id, collection_type, description in tqdm(collections): + collection = Collection.objects.get(id=id) + description = collection.description + collection_updated = False + + if isinstance(description, str): + if description.lower().find('pop') != -1: + collection.collection_type = Collection.POP + collection_updated = True + + if not isinstance(description, dict): + collection.description = {settings.FALLBACK_LOCALE: collection.description} + collection_updated = True + + if collection_updated: + update_collections.append(collection) + + Collection.objects.bulk_update(update_collections, ['collection_type', 'description', ]) + self.stdout.write(self.style.WARNING(f'Updated products: {len(update_collections)}')) diff --git a/apps/collection/management/commands/import_collection.py b/apps/collection/management/commands/import_collection.py index 67d1cacf..f8f1702e 100644 --- a/apps/collection/management/commands/import_collection.py +++ b/apps/collection/management/commands/import_collection.py @@ -3,6 +3,7 @@ from establishment.models import Establishment from location.models import Country, Language from transfer.models import Collections from collection.models import Collection +from django.conf import settings from news.models import News @@ -93,9 +94,11 @@ class Command(BaseCommand): country = Country.objects.filter(code=obj['country_code']).first() if country: objects.append( - Collection(name={"en-GB": obj['title']}, collection_type=Collection.ORDINARY, + Collection(name={settings.FALLBACK_LOCALE: obj['title']}, + collection_type=Collection.POP if obj['description'].lower().find('pop') != -1 + else Collection.ORDINARY, country=country, - description=obj['description'], + description={settings.FALLBACK_LOCALE: obj['description']}, slug=obj['slug'], old_id=obj['collection_id'], start=obj['start'], image_url='https://s3.eu-central-1.amazonaws.com/gm-test.com/media/'+obj['attachment_suffix_url'] diff --git a/apps/comment/migrations/0007_comment_site.py b/apps/comment/migrations/0007_comment_site.py new file mode 100644 index 00000000..c19629df --- /dev/null +++ b/apps/comment/migrations/0007_comment_site.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.7 on 2019-11-25 08:10 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0037_sitesettings_old_id'), + ('comment', '0006_comment_is_publish'), + ] + + operations = [ + migrations.AddField( + model_name='comment', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='main.SiteSettings', verbose_name='site'), + ), + ] diff --git a/apps/comment/models.py b/apps/comment/models.py index 421a05fb..fa27d3f9 100644 --- a/apps/comment/models.py +++ b/apps/comment/models.py @@ -35,7 +35,8 @@ class Comment(ProjectBaseMixin): user = models.ForeignKey('account.User', related_name='comments', on_delete=models.CASCADE, verbose_name=_('User')) old_id = models.IntegerField(null=True, blank=True, default=None) is_publish = models.BooleanField(default=False, verbose_name=_('Publish status')) - + site = models.ForeignKey('main.SiteSettings', blank=True, null=True, + on_delete=models.SET_NULL, verbose_name=_('site')) content_type = models.ForeignKey(generic.ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() content_object = generic.GenericForeignKey('content_type', 'object_id') diff --git a/apps/comment/tests.py b/apps/comment/tests.py index ff3bd393..79bed42f 100644 --- a/apps/comment/tests.py +++ b/apps/comment/tests.py @@ -8,15 +8,20 @@ from account.models import Role, User, UserRole from authorization.tests.tests_authorization import get_tokens_for_user from comment.models import Comment from utils.tests.tests_permissions import BasePermissionTests +from main.models import SiteSettings class CommentModeratorPermissionTests(BasePermissionTests): def setUp(self): super().setUp() + self.site_ru, created = SiteSettings.objects.get_or_create( + subdomain='ru' + ) + self.role = Role.objects.create( role=2, - country=self.country_ru + site=self.site_ru ) self.role.save() @@ -33,11 +38,12 @@ class CommentModeratorPermissionTests(BasePermissionTests): self.content_type = ContentType.objects.get(app_label='location', model='country') self.user_test = get_tokens_for_user() + self.comment = Comment.objects.create(text='Test comment', mark=1, user=self.user_test["user"], object_id=self.country_ru.pk, content_type_id=self.content_type.id, - country=self.country_ru + site=self.site_ru ) self.comment.save() self.url = reverse('back:comment:comment-crud', kwargs={"id": self.comment.id}) @@ -50,7 +56,7 @@ class CommentModeratorPermissionTests(BasePermissionTests): "user": self.user_test["user"].id, "object_id": self.country_ru.pk, "content_type": self.content_type.id, - "country_id": self.country_ru.id + "site_id": self.site_ru.id } response = self.client.post(self.url, format='json', data=comment) @@ -61,7 +67,7 @@ class CommentModeratorPermissionTests(BasePermissionTests): "user": self.moderator.id, "object_id": self.country_ru.id, "content_type": self.content_type.id, - "country_id": self.country_ru.id + "site_id": self.site_ru.id } tokens = User.create_jwt_tokens(self.moderator) @@ -83,8 +89,9 @@ class CommentModeratorPermissionTests(BasePermissionTests): "text": "test text moderator", "mark": 1, "user": self.moderator.id, - "object_id": self.comment.country_id, - "content_type": self.content_type.id + "object_id": self.country_ru.id, + "content_type": self.content_type.id, + 'site_id': self.site_ru.id } response = self.client.put(self.url, data=data, format='json') diff --git a/apps/comment/views/back.py b/apps/comment/views/back.py index 3b96cbd2..a46b70cb 100644 --- a/apps/comment/views/back.py +++ b/apps/comment/views/back.py @@ -8,13 +8,13 @@ class CommentLstView(generics.ListCreateAPIView): """Comment list create view.""" serializer_class = serializers.CommentBaseSerializer queryset = models.Comment.objects.all() - permission_classes = [permissions.IsAuthenticatedOrReadOnly| IsCommentModerator|IsCountryAdmin] + # permission_classes = [permissions.IsAuthenticatedOrReadOnly| IsCommentModerator|IsCountryAdmin] class CommentRUDView(generics.RetrieveUpdateDestroyAPIView): """Comment RUD view.""" serializer_class = serializers.CommentBaseSerializer queryset = models.Comment.objects.all() - - permission_classes = [IsCountryAdmin | IsCommentModerator] + permission_classes = [IsCommentModerator] + # permission_classes = [IsCountryAdmin | IsCommentModerator] lookup_field = 'id' diff --git a/apps/establishment/filters.py b/apps/establishment/filters.py index adbcae76..db419989 100644 --- a/apps/establishment/filters.py +++ b/apps/establishment/filters.py @@ -55,3 +55,23 @@ class EstablishmentTypeTagFilter(filters.FilterSet): fields = ( 'type_id', ) + + +class EmployeeBackFilter(filters.FilterSet): + """Employee filter set.""" + + search = filters.CharFilter(method='search_by_name_or_last_name') + + class Meta: + """Meta class.""" + + model = models.Employee + fields = ( + 'search', + ) + + def search_by_name_or_last_name(self, queryset, name, value): + """Search by name or last name.""" + if value not in EMPTY_VALUES: + return queryset.search_by_name_or_last_name(value) + return queryset diff --git a/apps/establishment/management/commands/add_artisan_subtype.py b/apps/establishment/management/commands/add_artisan_subtype.py new file mode 100644 index 00000000..f7283f4f --- /dev/null +++ b/apps/establishment/management/commands/add_artisan_subtype.py @@ -0,0 +1,49 @@ +from django.core.management.base import BaseCommand +from tqdm import tqdm + +from establishment.models import Establishment, EstablishmentSubType, EstablishmentType +from transfer.models import Metadata + + +class Command(BaseCommand): + help = 'Add subtype for establishment artisan' + + def handle(self, *args, **options): + artisans = Establishment.objects.artisans().filter( + old_id__isnull=False, + ).prefetch_related('tags') + + old_tags = Metadata.objects.filter( + establishment__in=list(artisans.values_list('old_id', flat=True)), + key='shop_category', + ) + + tags = [] + for tag in tqdm(old_tags): + tags.append(tag.value) + subtypes = set(tags) + + es_type, _ = EstablishmentType.objects.get_or_create( + index_name='artisan', + defaults={ + 'index_name': 'artisan', + 'name': {'en-GB': 'artisan'}, + } + ) + for artisan in tqdm(artisans): + artisan_tags = artisan.tags.all() + for t in artisan_tags: + if t.value in subtypes: + tag = 'coffee_shop' if t.value == 'coffe_shop' else t.value + subtype, _ = EstablishmentSubType.objects.get_or_create( + index_name=tag, + defaults={ + 'index_name': tag, + 'name': {'en-GB': ' '.join(tag.split('_')).capitalize()}, + 'establishment_type': es_type, + } + ) + artisan.establishment_subtypes.add(subtype) + artisan.save() + + self.stdout.write(self.style.WARNING(f'Artisans subtype updated.')) diff --git a/apps/establishment/migrations/0066_auto_20191122_1144.py b/apps/establishment/migrations/0066_auto_20191122_1144.py new file mode 100644 index 00000000..edff3333 --- /dev/null +++ b/apps/establishment/migrations/0066_auto_20191122_1144.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.7 on 2019-11-22 11:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('establishment', '0065_establishment_purchased_products'), + ] + + operations = [ + migrations.AddField( + model_name='employee', + name='last_name', + field=models.CharField(default=None, max_length=255, null=True, verbose_name='Last Name'), + ), + migrations.AddField( + model_name='establishmentemployee', + name='status', + field=models.CharField(choices=[('I', 'Idle'), ('A', 'Accepted'), ('D', 'Declined')], default='I', max_length=1), + ), + migrations.AlterField( + model_name='employee', + name='name', + field=models.CharField(max_length=255, verbose_name='Name'), + ), + ] diff --git a/apps/establishment/migrations/0067_auto_20191122_1244.py b/apps/establishment/migrations/0067_auto_20191122_1244.py new file mode 100644 index 00000000..8dfdc3a4 --- /dev/null +++ b/apps/establishment/migrations/0067_auto_20191122_1244.py @@ -0,0 +1,39 @@ +# Generated by Django 2.2.7 on 2019-11-22 12:44 + +from django.db import migrations, models +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ('establishment', '0066_auto_20191122_1144'), + ] + + operations = [ + migrations.AddField( + model_name='employee', + name='birth_date', + field=models.DateTimeField(default=None, null=True, verbose_name='Birth date'), + ), + migrations.AddField( + model_name='employee', + name='email', + field=models.EmailField(blank=True, default=None, max_length=254, null=True, verbose_name='Email'), + ), + migrations.AddField( + model_name='employee', + name='phone', + field=phonenumber_field.modelfields.PhoneNumberField(default=None, max_length=128, null=True), + ), + migrations.AddField( + model_name='employee', + name='sex', + field=models.PositiveSmallIntegerField(choices=[(0, 'Male'), (1, 'Female')], default=None, null=True, verbose_name='Sex'), + ), + migrations.AddField( + model_name='employee', + name='toque_number', + field=models.PositiveSmallIntegerField(default=None, null=True, verbose_name='Toque number'), + ), + ] diff --git a/apps/establishment/models.py b/apps/establishment/models.py index 26046953..c5533d52 100644 --- a/apps/establishment/models.py +++ b/apps/establishment/models.py @@ -1,6 +1,7 @@ """Establishment models.""" from datetime import datetime from functools import reduce +from typing import List from operator import or_ import elasticsearch_dsl @@ -25,7 +26,8 @@ from main.models import Award, Currency from review.models import Review from utils.models import (ProjectBaseMixin, TJSONField, URLImageMixin, TranslatedFieldsMixin, BaseAttributes, GalleryModelMixin, - IntermediateGalleryModelMixin, HasTagsMixin) + IntermediateGalleryModelMixin, HasTagsMixin, + FavoritesMixin) # todo: establishment type&subtypes check @@ -117,9 +119,10 @@ class EstablishmentQuerySet(models.QuerySet): 'address__city__country') def with_extended_related(self): - return self.select_related('establishment_type'). \ + return self.with_extended_address_related().select_related('establishment_type'). \ prefetch_related('establishment_subtypes', 'awards', 'schedule', - 'phones'). \ + 'phones', 'gallery', 'menu_set', 'menu_set__plate_set', + 'menu_set__plate_set__currency', 'currency'). \ prefetch_actual_employees() def with_type_related(self): @@ -319,7 +322,8 @@ class EstablishmentQuerySet(models.QuerySet): return self.exclude(address__city__country__in=countries) -class Establishment(GalleryModelMixin, ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin, HasTagsMixin): +class Establishment(GalleryModelMixin, ProjectBaseMixin, URLImageMixin, + TranslatedFieldsMixin, HasTagsMixin, FavoritesMixin): """Establishment model.""" # todo: delete image URL fields after moving on gallery @@ -393,6 +397,7 @@ class Establishment(GalleryModelMixin, ProjectBaseMixin, URLImageMixin, Translat verbose_name=_('Tag')) reviews = generic.GenericRelation(to='review.Review') comments = generic.GenericRelation(to='comment.Comment') + carousels = generic.GenericRelation(to='main.Carousel') favorites = generic.GenericRelation(to='favorites.Favorites') currency = models.ForeignKey(Currency, blank=True, null=True, default=None, on_delete=models.PROTECT, @@ -432,11 +437,12 @@ class Establishment(GalleryModelMixin, ProjectBaseMixin, URLImageMixin, Translat @property def visible_tags(self): - return super().visible_tags\ - .exclude(category__index_name__in=['guide', 'collection', 'purchased_item', - 'business_tag', 'business_tags_de'])\ + return super().visible_tags \ + .exclude(category__index_name__in=['guide', 'collection', 'purchased_item', + 'business_tag', 'business_tags_de']) \ + \ + # todo: recalculate toque_number - # todo: recalculate toque_number def recalculate_toque_number(self): toque_number = 0 if self.address and self.public_mark: @@ -609,7 +615,6 @@ class EstablishmentNote(ProjectBaseMixin): class EstablishmentGallery(IntermediateGalleryModelMixin): - establishment = models.ForeignKey(Establishment, null=True, related_name='establishment_gallery', on_delete=models.CASCADE, @@ -660,6 +665,16 @@ class EstablishmentEmployeeQuerySet(models.QuerySet): class EstablishmentEmployee(BaseAttributes): """EstablishmentEmployee model.""" + IDLE = 'I' + ACCEPTED = 'A' + DECLINED = 'D' + + STATUS_CHOICES = ( + (IDLE, 'Idle'), + (ACCEPTED, 'Accepted'), + (DECLINED, 'Declined'), + ) + establishment = models.ForeignKey(Establishment, on_delete=models.PROTECT, verbose_name=_('Establishment')) employee = models.ForeignKey('establishment.Employee', on_delete=models.PROTECT, @@ -670,19 +685,53 @@ class EstablishmentEmployee(BaseAttributes): verbose_name=_('To date')) position = models.ForeignKey(Position, on_delete=models.PROTECT, verbose_name=_('Position')) + + status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=IDLE) + # old_id = affiliations_id old_id = models.IntegerField(verbose_name=_('Old id'), null=True, blank=True) objects = EstablishmentEmployeeQuerySet.as_manager() +class EmployeeQuerySet(models.QuerySet): + + def _generic_search(self, value, filter_fields_names: List[str]): + """Generic method for searching value in specified fields""" + filters = [ + {f'{field}__icontains': value} + for field in filter_fields_names + ] + return self.filter(reduce(lambda x, y: x | y, [models.Q(**i) for i in filters])) + + def search_by_name_or_last_name(self, value): + """Search by name or last_name.""" + return self._generic_search(value, ['name', 'last_name']) + + 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')) + name = models.CharField(max_length=255, verbose_name=_('Name')) + last_name = models.CharField(max_length=255, verbose_name=_('Last Name'), null=True, default=None) + + # SEX CHOICES + MALE = 0 + FEMALE = 1 + + SEX_CHOICES = ( + (MALE, _('Male')), + (FEMALE, _('Female')) + ) + sex = models.PositiveSmallIntegerField(choices=SEX_CHOICES, verbose_name=_('Sex'), null=True, default=None) + birth_date = models.DateTimeField(editable=True, verbose_name=_('Birth date'), null=True, default=None) + email = models.EmailField(blank=True, null=True, default=None, verbose_name=_('Email')) + phone = PhoneNumberField(null=True, default=None) + toque_number = models.PositiveSmallIntegerField(verbose_name=_('Toque number'), null=True, default=None) + establishments = models.ManyToManyField(Establishment, related_name='employees', through=EstablishmentEmployee, ) awards = generic.GenericRelation(to='main.Award', related_query_name='employees') @@ -691,6 +740,8 @@ class Employee(BaseAttributes): # old_id = profile_id old_id = models.IntegerField(verbose_name=_('Old id'), null=True, blank=True) + objects = EmployeeQuerySet.as_manager() + class Meta: """Meta class.""" diff --git a/apps/establishment/serializers/back.py b/apps/establishment/serializers/back.py index a78bce07..dd16e861 100644 --- a/apps/establishment/serializers/back.py +++ b/apps/establishment/serializers/back.py @@ -2,8 +2,9 @@ from rest_framework import serializers from establishment import models from establishment import serializers as model_serializers -from location.serializers import AddressDetailSerializer +from location.serializers import AddressDetailSerializer, TranslatedField from main.models import Currency +from main.serializers import AwardSerializer from utils.decorators import with_base_attributes from utils.serializers import TimeZoneChoiceField from gallery.models import Image @@ -161,12 +162,54 @@ class ContactEmailBackSerializers(model_serializers.PlateSerializer): class EmployeeBackSerializers(serializers.ModelSerializer): """Employee serializers.""" + awards = AwardSerializer(many=True, read_only=True) + class Meta: model = models.Employee fields = [ 'id', 'user', - 'name' + 'name', + 'last_name', + 'sex', + 'birth_date', + 'email', + 'phone', + 'toque_number', + 'awards', + ] + + +class PositionBackSerializer(serializers.ModelSerializer): + """Position Back serializer.""" + + name_translated = TranslatedField() + + class Meta: + model = models.Position + fields = [ + 'id', + 'name_translated', + 'priority', + 'index_name', + ] + + +class EstablishmentEmployeeBackSerializer(serializers.ModelSerializer): + """Establishment Employee serializer.""" + + employee = EmployeeBackSerializers() + position = PositionBackSerializer() + + class Meta: + model = models.EstablishmentEmployee + fields = [ + 'id', + 'employee', + 'from_date', + 'to_date', + 'position', + 'status', ] diff --git a/apps/establishment/serializers/common.py b/apps/establishment/serializers/common.py index 0c183477..cb102ff1 100644 --- a/apps/establishment/serializers/common.py +++ b/apps/establishment/serializers/common.py @@ -13,7 +13,7 @@ from review.serializers import ReviewShortSerializer from tag.serializers import TagBaseSerializer from timetable.serialziers import ScheduleRUDSerializer from utils import exceptions as utils_exceptions -from utils.serializers import ImageBaseSerializer +from utils.serializers import ImageBaseSerializer, CarouselCreateSerializer from utils.serializers import (ProjectModelSerializer, TranslatedField, FavoritesCreateSerializer) @@ -168,12 +168,51 @@ class EstablishmentEmployeeSerializer(serializers.ModelSerializer): awards = AwardSerializer(source='employee.awards', many=True) priority = serializers.IntegerField(source='position.priority') position_index_name = serializers.CharField(source='position.index_name') + status = serializers.CharField() class Meta: """Meta class.""" model = models.Employee - fields = ('id', 'name', 'position_translated', 'awards', 'priority', 'position_index_name') + fields = ('id', 'name', 'position_translated', 'awards', 'priority', 'position_index_name', 'status') + + +class EstablishmentEmployeeCreateSerializer(serializers.ModelSerializer): + """Serializer for establishment employee relation.""" + + class Meta: + """Meta class.""" + + model = models.EstablishmentEmployee + fields = ('id',) + + def _validate_entity(self, entity_id_param: str, entity_class): + entity_id = self.context.get('request').parser_context.get('kwargs').get(entity_id_param) + entity_qs = entity_class.objects.filter(id=entity_id) + if not entity_qs.exists(): + raise serializers.ValidationError({'detail': _(f'{entity_class.__name__} not found.')}) + return entity_qs.first() + + def validate(self, attrs): + """Override validate method""" + establishment = self._validate_entity("establishment_id", models.Establishment) + employee = self._validate_entity("employee_id", models.Employee) + position = self._validate_entity("position_id", models.Position) + + attrs['establishment'] = establishment + attrs['employee'] = employee + attrs['position'] = position + + return attrs + + def create(self, validated_data, *args, **kwargs): + """Override create method""" + validated_data.update({ + 'employee': validated_data.pop('employee'), + 'establishment': validated_data.pop('establishment'), + 'position': validated_data.pop("position") + }) + return super().create(validated_data) class EstablishmentShortSerializer(serializers.ModelSerializer): @@ -396,6 +435,22 @@ class EstablishmentCommentCreateSerializer(comment_serializers.CommentSerializer return super().create(validated_data) +class EstablishmentCommentRUDSerializer(comment_serializers.CommentSerializer): + """Retrieve/Update/Destroy comment serializer.""" + + class Meta: + """Meta class.""" + model = comment_models.Comment + fields = [ + 'id', + 'created', + 'text', + 'mark', + 'nickname', + 'profile_pic', + ] + + class EstablishmentFavoritesCreateSerializer(FavoritesCreateSerializer): """Serializer to favorite object w/ model Establishment.""" @@ -426,6 +481,27 @@ class EstablishmentFavoritesCreateSerializer(FavoritesCreateSerializer): return super().create(validated_data) +class EstablishmentCarouselCreateSerializer(CarouselCreateSerializer): + """Serializer to carousel object w/ model News.""" + + def validate(self, attrs): + establishment = models.Establishment.objects.filter(pk=self.pk).first() + if not establishment: + raise serializers.ValidationError({'detail': _('Object not found.')}) + + if establishment.carousels.exists(): + raise utils_exceptions.CarouselError() + + attrs['establishment'] = establishment + return attrs + + def create(self, validated_data, *args, **kwargs): + validated_data.update({ + 'content_object': validated_data.pop('establishment') + }) + return super().create(validated_data) + + class CompanyBaseSerializer(serializers.ModelSerializer): """Company base serializer""" phone_list = serializers.SerializerMethodField(source='phones', read_only=True) diff --git a/apps/establishment/tests.py b/apps/establishment/tests.py index 3534608c..bd96b052 100644 --- a/apps/establishment/tests.py +++ b/apps/establishment/tests.py @@ -10,6 +10,8 @@ from translation.models import Language from account.models import Role, UserRole from location.models import Country, Address, City, Region from pytz import timezone as py_tz +from main.models import SiteSettings +from timetable.models import Timetable class BaseTestCase(APITestCase): @@ -278,13 +280,13 @@ class PlateTests(ChildTestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) menu = Menu.objects.create( - category=json.dumps({"en-GB": "Test category"}), + category=json.dumps({"ru-RU": "Test category"}), establishment=self.establishment ) currency = Currency.objects.create(name="Test currency") data = { - 'name': json.dumps({"en-GB": "Test plate"}), + 'name': json.dumps({"ru-RU": "Test plate"}), 'establishment': self.establishment.id, 'price': 10, 'menu': menu.id, @@ -298,7 +300,7 @@ class PlateTests(ChildTestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) update_data = { - 'name': json.dumps({"en-GB": "Test new plate"}) + 'name': json.dumps({"ru-RU": "Test new plate"}) } response = self.client.patch('/api/back/establishments/plates/1/', data=update_data) @@ -314,7 +316,7 @@ class MenuTests(ChildTestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) data = { - 'category': json.dumps({"en-GB": "Test category"}), + 'category': json.dumps({"ru-RU": "Test category"}), 'establishment': self.establishment.id } @@ -325,7 +327,7 @@ class MenuTests(ChildTestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) update_data = { - 'category': json.dumps({"en-GB": "Test new category"}) + 'category': json.dumps({"ru-RU": "Test new category"}) } response = self.client.patch('/api/back/establishments/menus/1/', data=update_data) @@ -336,24 +338,56 @@ class MenuTests(ChildTestCase): class EstablishmentShedulerTests(ChildTestCase): - def test_shedule_CRUD(self): + def setUp(self): + super().setUp() + + self.lang, created = Language.objects.get_or_create( + title='Russia', + locale='ru-RU' + ) + + self.country_ru, created = Country.objects.get_or_create( + name={"en-GB": "Russian"} + ) + + self.site_ru, created = SiteSettings.objects.get_or_create( + subdomain='ru' + ) + + role, created = Role.objects.get_or_create( + role=Role.ESTABLISHMENT_MANAGER, + country_id=self.country_ru.id, + site_id=self.site_ru.id + ) + + user_role, created = UserRole.objects.get_or_create( + user=self.user, + role=role, + establishment_id=self.establishment.id + ) + user_role.save() + + def test_schedule_CRUD(self): data = { 'weekday': 1 } + response = self.client.post(f'/api/back/establishments/{self.establishment.id}/schedule/', data=data) self.assertEqual(response.status_code, status.HTTP_201_CREATED) + schedule = response.data - response = self.client.get(f'/api/back/establishments/{self.establishment.id}/schedule/1/') + response = self.client.get(f'/api/back/establishments/{self.establishment.id}/schedule/{schedule["id"]}/') self.assertEqual(response.status_code, status.HTTP_200_OK) update_data = { 'weekday': 2 } - response = self.client.patch(f'/api/back/establishments/{self.establishment.id}/schedule/1/', data=update_data) + response = self.client.patch(f'/api/back/establishments/{self.establishment.id}/schedule/{schedule["id"]}/', + data=update_data) self.assertEqual(response.status_code, status.HTTP_200_OK) - response = self.client.delete(f'/api/back/establishments/{self.establishment.id}/schedule/1/') + response = self.client.delete(f'/api/back/establishments/{self.establishment.id}/schedule/{schedule["id"]}/') self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -441,3 +475,17 @@ class EstablishmentWebFavoriteTests(ChildTestCase): f'/api/web/establishments/slug/{self.establishment.slug}/favorites/', format='json') self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + +class EstablishmentCarouselTests(ChildTestCase): + + def test_back_carousel_CR(self): + data = { + "object_id": self.establishment.id + } + + response = self.client.post(f'/api/back/establishments/{self.establishment.id}/carousels/', data=data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.delete(f'/api/back/establishments/{self.establishment.id}/carousels/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) diff --git a/apps/establishment/urls/back.py b/apps/establishment/urls/back.py index f06e2187..d9b2fbd7 100644 --- a/apps/establishment/urls/back.py +++ b/apps/establishment/urls/back.py @@ -9,6 +9,8 @@ app_name = 'establishment' urlpatterns = [ path('', views.EstablishmentListCreateView.as_view(), name='list'), path('/', views.EstablishmentRUDView.as_view(), name='detail'), + path('/carousels/', views.EstablishmentCarouselCreateDestroyView.as_view(), + name='create-destroy-carousels'), path('/schedule//', views.EstablishmentScheduleRUDView.as_view(), name='schedule-rud'), path('/schedule/', views.EstablishmentScheduleCreateView.as_view(), @@ -38,10 +40,19 @@ urlpatterns = [ path('phones//', views.PhonesRUDView.as_view(), name='phones-rud'), path('emails/', views.EmailListCreateView.as_view(), name='emails'), path('emails//', views.EmailRUDView.as_view(), name='emails-rud'), + path('/employees/', views.EstablishmentEmployeeListView.as_view(), + name='establishment-employees'), path('employees/', views.EmployeeListCreateView.as_view(), name='employees'), path('employees//', views.EmployeeRUDView.as_view(), name='employees-rud'), + path('/employee//position/', + views.EstablishmentEmployeeCreateView.as_view(), + name='employees-establishment-create'), + path('/employee/', + views.EstablishmentEmployeeDeleteView.as_view(), + name='employees-establishment-delete'), path('types/', views.EstablishmentTypeListCreateView.as_view(), name='type-list'), path('types//', views.EstablishmentTypeRUDView.as_view(), name='type-rud'), path('subtypes/', views.EstablishmentSubtypeListCreateView.as_view(), name='subtype-list'), path('subtypes//', views.EstablishmentSubtypeRUDView.as_view(), name='subtype-rud'), + path('positions/', views.EstablishmentPositionListView.as_view(), name='position-list'), ] diff --git a/apps/establishment/urls/common.py b/apps/establishment/urls/common.py index 49cd3631..e37c38f8 100644 --- a/apps/establishment/urls/common.py +++ b/apps/establishment/urls/common.py @@ -17,5 +17,5 @@ urlpatterns = [ path('slug//comments//', views.EstablishmentCommentRUDView.as_view(), name='rud-comment'), path('slug//favorites/', views.EstablishmentFavoritesCreateDestroyView.as_view(), - name='create-destroy-favorites') + name='create-destroy-favorites'), ] diff --git a/apps/establishment/views/back.py b/apps/establishment/views/back.py index d1897397..d3afbf2e 100644 --- a/apps/establishment/views/back.py +++ b/apps/establishment/views/back.py @@ -1,7 +1,9 @@ """Establishment app views.""" +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 -from rest_framework import generics, permissions +from rest_framework import generics, permissions, status +from utils.permissions import IsCountryAdmin, IsEstablishmentManager from establishment import filters, models, serializers from timetable.serialziers import ScheduleRUDSerializer, ScheduleCreateSerializer from utils.permissions import IsCountryAdmin, IsEstablishmentManager @@ -43,8 +45,8 @@ class EstablishmentScheduleRUDView(generics.RetrieveUpdateDestroyAPIView): """ Returns the object the view is displaying. """ - establishment_pk = self.kwargs.get('pk') - schedule_id = self.kwargs.get('schedule_id') + establishment_pk = self.kwargs['pk'] + schedule_id = self.kwargs['schedule_id'] establishment = get_object_or_404(klass=models.Establishment.objects.all(), pk=establishment_pk) @@ -156,11 +158,23 @@ class EmailRUDView(generics.RetrieveUpdateDestroyAPIView): class EmployeeListCreateView(generics.ListCreateAPIView): """Emplyoee list create view.""" + permission_classes = (permissions.AllowAny, ) + filter_class = filters.EmployeeBackFilter serializer_class = serializers.EmployeeBackSerializers queryset = models.Employee.objects.all() pagination_class = None +class EstablishmentEmployeeListView(generics.ListAPIView): + """Establishment emplyoees list view.""" + permission_classes = (permissions.AllowAny, ) + serializer_class = serializers.EstablishmentEmployeeBackSerializer + + def get_queryset(self): + establishment_id = self.kwargs['establishment_id'] + return models.EstablishmentEmployee.objects.filter(establishment__id=establishment_id) + + class EmployeeRUDView(generics.RetrieveUpdateDestroyAPIView): """Employee RUD view.""" serializer_class = serializers.EmployeeBackSerializers @@ -318,3 +332,36 @@ class EstablishmentNoteRUDView(EstablishmentMixinViews, self.check_object_permissions(self.request, note) return note + + +class EstablishmentEmployeeCreateView(generics.CreateAPIView): + serializer_class = serializers.EstablishmentEmployeeCreateSerializer + queryset = models.EstablishmentEmployee.objects.all() + # TODO send email to all admins and add endpoint for changing status + + +class EstablishmentEmployeeDeleteView(generics.DestroyAPIView): + + def _get_object_to_delete(self, establishment_id, employee_id): + result_qs = models.EstablishmentEmployee\ + .objects\ + .filter(establishment_id=establishment_id, employee_id=employee_id) + if not result_qs.exists(): + raise Http404 + return result_qs.first() + + def delete(self, request, *args, **kwargs): + establishment_id = self.kwargs["establishment_id"] + employee_id = self.kwargs["employee_id"] + object_to_delete = self._get_object_to_delete(establishment_id, employee_id) + object_to_delete.delete() + return HttpResponse(status=status.HTTP_204_NO_CONTENT) + + +class EstablishmentPositionListView(generics.ListAPIView): + """Establishment positions list view.""" + + pagination_class = None + permission_classes = (permissions.AllowAny, ) + queryset = models.Position.objects.all() + serializer_class = serializers.PositionBackSerializer diff --git a/apps/establishment/views/web.py b/apps/establishment/views/web.py index 20e8f81a..bd826e4e 100644 --- a/apps/establishment/views/web.py +++ b/apps/establishment/views/web.py @@ -5,12 +5,11 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics, permissions from comment import models as comment_models -from establishment import filters -from establishment import models, serializers +from comment.serializers import CommentRUDSerializer +from establishment import filters, models, serializers from main import methods from utils.pagination import EstablishmentPortionPagination -from utils.permissions import IsCountryAdmin -from comment.serializers import CommentRUDSerializer +from utils.views import FavoritesCreateDestroyMixinView, CarouselCreateDestroyMixinView class EstablishmentMixinView: @@ -35,7 +34,7 @@ class EstablishmentListView(EstablishmentMixinView, generics.ListAPIView): serializer_class = serializers.EstablishmentListRetrieveSerializer def get_queryset(self): - return super().get_queryset().with_schedule()\ + return super().get_queryset().with_schedule() \ .with_extended_address_related().with_currency_related() @@ -57,12 +56,11 @@ class EstablishmentRecentReviewListView(EstablishmentListView): def get_queryset(self): """Overridden method 'get_queryset'.""" qs = super().get_queryset() - user_ip = methods.get_user_ip(self.request) query_params = self.request.query_params if 'longitude' in query_params and 'latitude' in query_params: longitude, latitude = query_params.get('longitude'), query_params.get('latitude') else: - longitude, latitude = methods.determine_coordinates(user_ip) + longitude, latitude = methods.determine_coordinates(self.request) if not longitude or not latitude: return qs.none() point = Point(x=float(longitude), y=float(latitude), srid=settings.GEO_DEFAULT_SRID) @@ -107,9 +105,9 @@ class EstablishmentCommentListView(generics.ListAPIView): establishment = get_object_or_404(models.Establishment, slug=self.kwargs['slug']) return comment_models.Comment.objects.by_content_type(app_label='establishment', - model='establishment')\ - .by_object_id(object_id=establishment.pk)\ - .order_by('-created') + model='establishment') \ + .by_object_id(object_id=establishment.pk) \ + .order_by('-created') class EstablishmentCommentRUDView(generics.RetrieveUpdateDestroyAPIView): @@ -134,21 +132,18 @@ class EstablishmentCommentRUDView(generics.RetrieveUpdateDestroyAPIView): return comment_obj -class EstablishmentFavoritesCreateDestroyView(generics.CreateAPIView, generics.DestroyAPIView): +class EstablishmentFavoritesCreateDestroyView(FavoritesCreateDestroyMixinView): """View for create/destroy establishment from favorites.""" - serializer_class = serializers.EstablishmentFavoritesCreateSerializer - lookup_field = 'slug' - def get_object(self): - """ - Returns the object the view is displaying. - """ - establishment = get_object_or_404(models.Establishment, - slug=self.kwargs['slug']) - favorites = get_object_or_404(establishment.favorites.filter(user=self.request.user)) - # May raise a permission denied - self.check_object_permissions(self.request, favorites) - return favorites + _model = models.Establishment + serializer_class = serializers.EstablishmentFavoritesCreateSerializer + + +class EstablishmentCarouselCreateDestroyView(CarouselCreateDestroyMixinView): + """View for create/destroy establishment from carousel.""" + + _model = models.Establishment + serializer_class = serializers.EstablishmentCarouselCreateSerializer class EstablishmentNearestRetrieveView(EstablishmentListView, generics.ListAPIView): diff --git a/apps/gallery/serializers.py b/apps/gallery/serializers.py index e817cbd8..36360180 100644 --- a/apps/gallery/serializers.py +++ b/apps/gallery/serializers.py @@ -1,4 +1,9 @@ +from django.conf import settings +from django.core.validators import MinValueValidator, MaxValueValidator from rest_framework import serializers +from sorl.thumbnail.parsers import parse_crop +from sorl.thumbnail.parsers import ThumbnailParseError +from django.utils.translation import gettext_lazy as _ from . import models @@ -29,3 +34,86 @@ class ImageSerializer(serializers.ModelSerializer): extra_kwargs = { 'orientation': {'write_only': True} } + + +class CropImageSerializer(ImageSerializer): + """Serializers for image crops.""" + + width = serializers.IntegerField(write_only=True, required=False) + height = serializers.IntegerField(write_only=True, required=False) + crop = serializers.CharField(write_only=True, + required=False, + default='center') + quality = serializers.IntegerField(write_only=True, required=False, + default=settings.THUMBNAIL_QUALITY, + validators=[ + MinValueValidator(1), + MaxValueValidator(100)]) + cropped_image = serializers.DictField(read_only=True, allow_null=True) + + class Meta(ImageSerializer.Meta): + """Meta class.""" + fields = [ + 'id', + 'url', + 'orientation_display', + 'width', + 'height', + 'crop', + 'quality', + 'cropped_image', + ] + + def validate(self, attrs): + """Overridden validate method.""" + file = self._image.image + crop_width = attrs.get('width') + crop_height = attrs.get('height') + crop = attrs.get('crop') + + if (crop_height and crop_width) and (crop and crop != 'smart'): + xy_image = (file.width, file.width) + xy_window = (crop_width, crop_height) + try: + parse_crop(crop, xy_image, xy_window) + attrs['image'] = file + except ThumbnailParseError: + raise serializers.ValidationError({'margin': _('Unrecognized crop option: %s') % crop}) + return attrs + + def create(self, validated_data): + """Overridden create method.""" + width = validated_data.pop('width', None) + height = validated_data.pop('height', None) + quality = validated_data.pop('quality') + crop = validated_data.pop('crop') + + image = self._image + + if image and width and height: + setattr(image, + 'cropped_image', + image.get_cropped_image( + geometry=f'{width}x{height}', + quality=quality, + crop=crop)) + return image + + @property + def view(self): + return self.context.get('view') + + @property + def lookup_field(self): + lookup_field = 'pk' + + if lookup_field in self.view.kwargs: + return self.view.kwargs.get(lookup_field) + + @property + def _image(self): + """Return image from url_kwargs.""" + qs = models.Image.objects.filter(id=self.lookup_field) + if qs.exists(): + return qs.first() + raise serializers.ValidationError({'detail': _('Image not found.')}) diff --git a/apps/gallery/urls.py b/apps/gallery/urls.py index 8258092c..987685cb 100644 --- a/apps/gallery/urls.py +++ b/apps/gallery/urls.py @@ -6,6 +6,7 @@ from . import views app_name = 'gallery' urlpatterns = [ - path('', views.ImageListCreateView.as_view(), name='list-create-image'), - path('/', views.ImageRetrieveDestroyView.as_view(), name='retrieve-destroy-image'), + path('', views.ImageListCreateView.as_view(), name='list-create'), + path('/', views.ImageRetrieveDestroyView.as_view(), name='retrieve-destroy'), + path('/crop/', views.CropImageCreateView.as_view(), name='create-crop'), ] diff --git a/apps/gallery/views.py b/apps/gallery/views.py index 2b155035..1515707f 100644 --- a/apps/gallery/views.py +++ b/apps/gallery/views.py @@ -28,3 +28,8 @@ class ImageRetrieveDestroyView(ImageBaseView, generics.RetrieveDestroyAPIView): else: on_commit(lambda: tasks.delete_image(image_id=instance.id)) return Response(status=status.HTTP_204_NO_CONTENT) + + +class CropImageCreateView(ImageBaseView, generics.CreateAPIView): + """Create crop image.""" + serializer_class = serializers.CropImageSerializer diff --git a/apps/location/tests.py b/apps/location/tests.py index ec2d1437..b48d7206 100644 --- a/apps/location/tests.py +++ b/apps/location/tests.py @@ -25,12 +25,12 @@ class BaseTestCase(APITestCase): {'access_token': tokens.get('access_token'), 'refresh_token': tokens.get('refresh_token')}) - self.lang = Language.objects.get( + self.lang, created = Language.objects.get_or_create( title='Russia', locale='ru-RU' ) - self.country_ru = Country.objects.get( + self.country_ru, created = Country.objects.get_or_create( name={"en-GB": "Russian"} ) @@ -72,7 +72,7 @@ class CountryTests(BaseTestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) update_data = { - 'name': json.dumps({"en-GB": "Test new country"}) + 'name': json.dumps({"ru-RU": "Test new country"}) } response = self.client.patch(f'/api/back/location/countries/{response_data["id"]}/', data=update_data) diff --git a/apps/main/admin.py b/apps/main/admin.py index 9ec76164..4b7038e7 100644 --- a/apps/main/admin.py +++ b/apps/main/admin.py @@ -3,9 +3,15 @@ from django.contrib import admin from main import models +class SiteSettingsInline(admin.TabularInline): + model = models.SiteFeature + extra = 1 + + @admin.register(models.SiteSettings) class SiteSettingsAdmin(admin.ModelAdmin): """Site settings admin conf.""" + inlines = [SiteSettingsInline,] @admin.register(models.Feature) diff --git a/apps/main/management/commands/add_site_features.py b/apps/main/management/commands/add_site_features.py new file mode 100644 index 00000000..9a4f6e1b --- /dev/null +++ b/apps/main/management/commands/add_site_features.py @@ -0,0 +1,94 @@ +from django.core.management.base import BaseCommand +from django.db import connections +from django.utils.text import slugify +from establishment.management.commands.add_position import namedtuplefetchall +from main.models import SiteSettings, Feature, SiteFeature +from location.models import Country +from tqdm import tqdm + + +class Command(BaseCommand): + help = '''Add site_features for old db to new db. + Run after command add_site_settings!''' + + def site_sql(self): + with connections['legacy'].cursor() as cursor: + cursor.execute(''' + select s.id, s.country_code_2 + from sites as s + ''') + return namedtuplefetchall(cursor) + + def update_site_old_id(self): + for a in tqdm(self.site_sql(), desc='Update old_id site: '): + country = Country.objects.filter(code=a.country_code_2).first() + SiteSettings.objects.filter(country=country, old_id__isnull=True)\ + .update(old_id=a.id) + self.stdout.write(self.style.WARNING(f'Updated old_id site.')) + + def feature_sql(self): + with connections['legacy'].cursor() as cursor: + cursor.execute(''' + select f.id, slug + from features as f + ''') + return namedtuplefetchall(cursor) + + def add_feature(self): + objects = [] + for a in tqdm(self.feature_sql(), desc='Add feature: '): + features = Feature.objects.filter(slug=slugify(a.slug)).update(old_id=a.id) + if features == 0: + objects.append( + Feature(slug=slugify(a.slug), old_id=a.id) + ) + Feature.objects.bulk_create(objects) + self.stdout.write(self.style.WARNING(f'Created feature objects.')) + + def site_features_sql(self): + with connections['legacy'].cursor() as cursor: + cursor.execute(''' + select s.id as old_site_feature, + s.site_id, + case when s.state = 'published' + then True + else False + end as published, + s.feature_id, + c.country_code_2 + from features as f + join site_features s on s.feature_id=f.id + join sites c on c.id = s.site_id + ''') + return namedtuplefetchall(cursor) + + def add_site_features(self): + objects = [] + for a in tqdm(self.site_features_sql(), desc='Add site feature: '): + site = SiteSettings.objects.get(old_id=a.site_id, + subdomain=a.country_code_2) + feature = Feature.objects.get(old_id=a.feature_id) + + site_features = SiteFeature.objects\ + .filter(site_settings=site, + feature=feature + ).update(old_id=a.old_site_feature, published=a.published) + + if site_features == 0: + objects.append( + SiteFeature(site_settings=site, + feature=feature, + published=a.published, + main=False, + old_id=a.old_site_feature + ) + ) + SiteFeature.objects.bulk_create(objects) + self.stdout.write(self.style.WARNING(f'Site feature add objects.')) + + def handle(self, *args, **kwargs): + self.update_site_old_id() + self.add_feature() + self.add_site_features() + + diff --git a/apps/main/management/commands/add_site_settings.py b/apps/main/management/commands/add_site_settings.py new file mode 100644 index 00000000..2414a153 --- /dev/null +++ b/apps/main/management/commands/add_site_settings.py @@ -0,0 +1,56 @@ +from django.core.management.base import BaseCommand +from django.db import connections +from establishment.management.commands.add_position import namedtuplefetchall +from main.models import SiteSettings +from location.models import Country +from tqdm import tqdm + + +class Command(BaseCommand): + help = '''Add add site settings from old db to new db. + Run after country migrate!!!''' + + def site_sql(self): + with connections['legacy'].cursor() as cursor: + cursor.execute(''' + select + distinct + id, + country_code_2 as code, + pinterest_page_url, + twitter_page_url, + facebook_page_url, + contact_email, + config, + released, + instagram_page_url, + ad_config + from sites as s + ''') + return namedtuplefetchall(cursor) + + def add_site_settings(self): + objects=[] + for s in tqdm(self.site_sql(), desc='Add site settings'): + country = Country.objects.filter(code=s.code).first() + sites = SiteSettings.objects.filter(subdomain=s.code) + if not sites.exists(): + objects.append( + SiteSettings( + subdomain=s.code, + country=country, + pinterest_page_url=s.pinterest_page_url, + twitter_page_url=s.twitter_page_url, + facebook_page_url=s.facebook_page_url, + instagram_page_url=s.instagram_page_url, + contact_email=s.contact_email, + config=s.config, + ad_config=s.ad_config, + old_id=s.id + ) + ) + SiteSettings.objects.bulk_create(objects) + self.stdout.write(self.style.WARNING(f'Add or get tag category objects.')) + + def handle(self, *args, **kwargs): + self.add_site_settings() \ No newline at end of file diff --git a/apps/main/methods.py b/apps/main/methods.py index d5f307eb..f19d595a 100644 --- a/apps/main/methods.py +++ b/apps/main/methods.py @@ -28,31 +28,25 @@ def get_user_ip(request): return ip -def determine_country_code(ip_addr): +def determine_country_code(request): """Determine country code.""" - country_code = None - if ip_addr: - try: - geoip = GeoIP2() - country_code = geoip.country_code(ip_addr) - country_code = country_code.lower() - except GeoIP2Exception as ex: - logger.info(f'GEOIP Exception: {ex}. ip: {ip_addr}') - except Exception as ex: - logger.error(f'GEOIP Base exception: {ex}') - return country_code + META = request.META + country_code = META.get('X-GeoIP-Country-Code', + META.get('HTTP_X_GEOIP_COUNTRY_CODE')) + if isinstance(country_code, str): + return country_code.lower() -def determine_coordinates(ip_addr: str) -> Tuple[Optional[float], Optional[float]]: - if ip_addr: - try: - geoip = GeoIP2() - return geoip.coords(ip_addr) - except GeoIP2Exception as ex: - logger.warning(f'GEOIP Exception: {ex}. ip: {ip_addr}') - except Exception as ex: - logger.warning(f'GEOIP Base exception: {ex}') - return None, None +def determine_coordinates(request): + META = request.META + longitude = META.get('X-GeoIP-Longitude', + META.get('HTTP_X_GEOIP_LONGITUDE')) + latitude = META.get('X-GeoIP-Latitude', + META.get('HTTP_X_GEOIP_LATITUDE')) + try: + return float(longitude), float(latitude) + except (TypeError, ValueError): + return None, None def determine_user_site_url(country_code): @@ -76,15 +70,11 @@ def determine_user_site_url(country_code): return site.site_url -def determine_user_city(ip_addr: str) -> Optional[City]: - try: - geoip = GeoIP2() - return geoip.city(ip_addr) - except GeoIP2Exception as ex: - logger.warning(f'GEOIP Exception: {ex}. ip: {ip_addr}') - except Exception as ex: - logger.warning(f'GEOIP Base exception: {ex}') - return None +def determine_user_city(request): + META = request.META + city = META.get('X-GeoIP-City', + META.get('HTTP_X_GEOIP_CITY')) + return city def determine_subdivision( diff --git a/apps/main/migrations/0037_sitesettings_old_id.py b/apps/main/migrations/0037_sitesettings_old_id.py new file mode 100644 index 00000000..e7ef11e2 --- /dev/null +++ b/apps/main/migrations/0037_sitesettings_old_id.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-11-22 07:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0036_auto_20191115_0750'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='old_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/apps/main/migrations/0038_feature_old_id.py b/apps/main/migrations/0038_feature_old_id.py new file mode 100644 index 00000000..a4a05c06 --- /dev/null +++ b/apps/main/migrations/0038_feature_old_id.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-11-26 11:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0037_sitesettings_old_id'), + ] + + operations = [ + migrations.AddField( + model_name='feature', + name='old_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/apps/main/migrations/0039_sitefeature_old_id.py b/apps/main/migrations/0039_sitefeature_old_id.py new file mode 100644 index 00000000..10ed25e4 --- /dev/null +++ b/apps/main/migrations/0039_sitefeature_old_id.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-11-26 12:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0038_feature_old_id'), + ] + + operations = [ + migrations.AddField( + model_name='sitefeature', + name='old_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/apps/main/models.py b/apps/main/models.py index 61a4d447..f9c1225f 100644 --- a/apps/main/models.py +++ b/apps/main/models.py @@ -69,6 +69,8 @@ class SiteSettings(ProjectBaseMixin): verbose_name=_('AD config')) currency = models.ForeignKey(Currency, on_delete=models.PROTECT, null=True, default=None) + old_id = models.IntegerField(blank=True, null=True) + objects = SiteSettingsQuerySet.as_manager() class Meta: @@ -105,6 +107,7 @@ class Feature(ProjectBaseMixin, PlatformMixin): priority = models.IntegerField(unique=True, null=True, default=None) route = models.ForeignKey('PageType', on_delete=models.PROTECT, null=True, default=None) site_settings = models.ManyToManyField(SiteSettings, through='SiteFeature') + old_id = models.IntegerField(null=True, blank=True) class Meta: """Meta class.""" @@ -136,6 +139,7 @@ class SiteFeature(ProjectBaseMixin): published = models.BooleanField(default=False, verbose_name=_('Published')) main = models.BooleanField(default=False, verbose_name=_('Main')) nested = models.ManyToManyField('self', symmetrical=False) + old_id = models.IntegerField(null=True, blank=True) objects = SiteFeatureQuerySet.as_manager() diff --git a/apps/main/serializers.py b/apps/main/serializers.py index 572aff31..410eb6bb 100644 --- a/apps/main/serializers.py +++ b/apps/main/serializers.py @@ -1,4 +1,5 @@ """Main app serializers.""" +from django.contrib.contenttypes.models import ContentType from rest_framework import serializers from location.serializers import CountrySerializer @@ -71,7 +72,7 @@ class SiteSettingsSerializer(serializers.ModelSerializer): """Meta class.""" model = models.SiteSettings - fields = ( + fields = [ 'country_code', 'time_format', 'subdomain', @@ -85,7 +86,17 @@ class SiteSettingsSerializer(serializers.ModelSerializer): 'published_features', 'currency', 'country_name', - ) + ] + + +class SiteSettingsBackOfficeSerializer(SiteSettingsSerializer): + """Site settings serializer for back office.""" + + class Meta(SiteSettingsSerializer.Meta): + """Meta class.""" + fields = SiteSettingsSerializer.Meta.fields + [ + 'id', + ] class SiteSerializer(serializers.ModelSerializer): @@ -94,7 +105,11 @@ class SiteSerializer(serializers.ModelSerializer): class Meta: """Meta class.""" model = models.SiteSettings - fields = ('subdomain', 'site_url', 'country') + fields = [ + 'subdomain', + 'site_url', + 'country' + ] class SiteShortSerializer(serializers.ModelSerializer): @@ -107,6 +122,16 @@ class SiteShortSerializer(serializers.ModelSerializer): ] +class SiteBackOfficeSerializer(SiteSerializer): + """Serializer for back office.""" + + class Meta(SiteSerializer.Meta): + """Meta class.""" + fields = SiteSerializer.Meta.fields + [ + 'id', + ] + + # class SiteFeatureSerializer(serializers.ModelSerializer): # """Site feature serializer.""" # @@ -216,3 +241,11 @@ class PageTypeBaseSerializer(serializers.ModelSerializer): 'id', 'name', ] + + +class ContentTypeBackSerializer(serializers.ModelSerializer): + """Serializer fro model ContentType.""" + + class Meta: + model = ContentType + fields = '__all__' diff --git a/apps/main/tests/tests_back.py b/apps/main/tests/tests_back.py index e09c7b4b..781eafab 100644 --- a/apps/main/tests/tests_back.py +++ b/apps/main/tests/tests_back.py @@ -9,7 +9,7 @@ from location.models import Country from main.models import Award, AwardType -class AwardTestCase(APITestCase): +class BaseTestCase(APITestCase): def setUp(self): self.user = User.objects.create_user( @@ -25,6 +25,12 @@ class AwardTestCase(APITestCase): {'access_token': tokens.get('access_token'), 'refresh_token': tokens.get('refresh_token')}) + +class AwardTestCase(BaseTestCase): + + def setUp(self): + super().setUp() + self.country_ru = Country.objects.create( name={'en-GB': 'Russian'}, code='RU', @@ -71,3 +77,13 @@ class AwardTestCase(APITestCase): response = self.client.delete(f'/api/back/main/awards/{self.award.id}/') self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + +class ContentTypeTestCase(BaseTestCase): + + def setUp(self): + super().setUp() + + def test_content_type_list(self): + response = self.client.get('/api/back/main/content_type/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/apps/main/urls/back.py b/apps/main/urls/back.py index d92bddf8..40011aa2 100644 --- a/apps/main/urls/back.py +++ b/apps/main/urls/back.py @@ -1,11 +1,15 @@ """Back main URLs""" from django.urls import path -from main.views import back as views +from main import views app_name = 'main' urlpatterns = [ path('awards/', views.AwardLstView.as_view(), name='awards-list-create'), path('awards//', views.AwardRUDView.as_view(), name='awards-rud'), + path('content_type/', views.ContentTypeView.as_view(), name='content_type-list'), + path('sites/', views.SiteListBackOfficeView.as_view(), name='site-list'), + path('site-settings//', views.SiteSettingsBackOfficeView.as_view(), + name='site-settings'), ] diff --git a/apps/main/urls/common.py b/apps/main/urls/common.py index 964442f9..6b8f26ce 100644 --- a/apps/main/urls/common.py +++ b/apps/main/urls/common.py @@ -1,6 +1,6 @@ """Main app urls.""" from django.urls import path -from main.views.common import * +from main.views import * app = 'main' @@ -8,5 +8,5 @@ common_urlpatterns = [ path('awards/', AwardView.as_view(), name='awards_list'), path('awards//', AwardRetrieveView.as_view(), name='awards_retrieve'), path('carousel/', CarouselListView.as_view(), name='carousel-list'), - path('determine-location/', DetermineLocation.as_view(), name='determine-location') + path('determine-location/', DetermineLocation.as_view(), name='determine-location'), ] diff --git a/apps/main/urls/web.py b/apps/main/urls/web.py index 2126b0c0..50ac9d1f 100644 --- a/apps/main/urls/web.py +++ b/apps/main/urls/web.py @@ -1,11 +1,12 @@ -from main.urls.common import common_urlpatterns from django.urls import path +from main.urls.common import common_urlpatterns from main.views.web import DetermineSiteView, SiteListView, SiteSettingsView urlpatterns = [ path('determine-site/', DetermineSiteView.as_view(), name='determine-site'), path('sites/', SiteListView.as_view(), name='site-list'), - path('site-settings//', SiteSettingsView.as_view(), name='site-settings'), ] + path('site-settings//', SiteSettingsView.as_view(), name='site-settings'), +] urlpatterns.extend(common_urlpatterns) diff --git a/apps/main/views/__init__.py b/apps/main/views/__init__.py index e69de29b..2c9dae42 100644 --- a/apps/main/views/__init__.py +++ b/apps/main/views/__init__.py @@ -0,0 +1,4 @@ +from .common import * +from .mobile import * +from .web import * +from .back import * diff --git a/apps/main/views/back.py b/apps/main/views/back.py index bbbfad53..de47825b 100644 --- a/apps/main/views/back.py +++ b/apps/main/views/back.py @@ -1,8 +1,11 @@ +from django.contrib.contenttypes.models import ContentType +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import generics, permissions from main import serializers from main.filters import AwardFilter from main.models import Award +from main.views import SiteSettingsView, SiteListView class AwardLstView(generics.ListCreateAPIView): @@ -19,3 +22,28 @@ class AwardRUDView(generics.RetrieveUpdateDestroyAPIView): serializer_class = serializers.BackAwardSerializer permission_classes = (permissions.IsAdminUser,) lookup_field = 'id' + + +class ContentTypeView(generics.ListAPIView): + """ContentType list view""" + queryset = ContentType.objects.all() + serializer_class = serializers.ContentTypeBackSerializer + permission_classes = (permissions.IsAdminUser,) + filter_backends = (DjangoFilterBackend, ) + ordering_fields = '__all__' + lookup_field = 'id' + filterset_fields = ( + 'id', + 'model', + 'app_label', + ) + + +class SiteSettingsBackOfficeView(SiteSettingsView): + """Site settings View.""" + serializer_class = serializers.SiteSettingsBackOfficeSerializer + + +class SiteListBackOfficeView(SiteListView): + """Site settings View.""" + serializer_class = serializers.SiteBackOfficeSerializer diff --git a/apps/main/views/common.py b/apps/main/views/common.py index 15f89510..674d045e 100644 --- a/apps/main/views/common.py +++ b/apps/main/views/common.py @@ -70,7 +70,7 @@ class CarouselListView(generics.ListAPIView): def get_queryset(self): country_code = self.request.country_code - if hasattr(settings, 'CAROUSEL_ITEMS') and country_code in ['www', 'main']: + if hasattr(settings, 'CAROUSEL_ITEMS') and country_code in settings.INTERNATIONAL_COUNTRY_CODES: qs = models.Carousel.objects.filter(id__in=settings.CAROUSEL_ITEMS) return qs qs = models.Carousel.objects.is_parsed().active() @@ -86,9 +86,8 @@ class DetermineLocation(generics.GenericAPIView): serializer_class = EmptySerializer def get(self, request, *args, **kwargs): - user_ip = methods.get_user_ip(request) - longitude, latitude = methods.determine_coordinates(user_ip) - city = methods.determine_user_city(user_ip) + longitude, latitude = methods.determine_coordinates(request) + city = methods.determine_user_city(request) if longitude and latitude and city: return Response(data={'latitude': latitude, 'longitude': longitude, 'city': city}) else: diff --git a/apps/main/views/web.py b/apps/main/views/web.py index e1dc32ef..86c550da 100644 --- a/apps/main/views/web.py +++ b/apps/main/views/web.py @@ -14,8 +14,7 @@ class DetermineSiteView(generics.GenericAPIView): serializer_class = EmptySerializer def get(self, request, *args, **kwargs): - user_ip = methods.get_user_ip(request) - country_code = methods.determine_country_code(user_ip) + country_code = methods.determine_country_code(request) url = methods.determine_user_site_url(country_code) return Response(data={'url': url}) @@ -26,7 +25,7 @@ class SiteSettingsView(generics.RetrieveAPIView): lookup_field = 'subdomain' permission_classes = (permissions.AllowAny,) queryset = models.SiteSettings.objects.all() - serializer_class = serializers.SiteSettingsSerializer + serializer_class = serializers.SiteSettingsBackOfficeSerializer class SiteListView(generics.ListAPIView): diff --git a/apps/news/filters.py b/apps/news/filters.py index 6ade7eeb..e8e35307 100644 --- a/apps/news/filters.py +++ b/apps/news/filters.py @@ -20,6 +20,16 @@ class NewsListFilterSet(filters.FilterSet): tag_value__in = filters.CharFilter(method='in_tags') type = filters.CharFilter(method='by_type') + state = filters.NumberFilter() + + SORT_BY_CREATED_CHOICE = "created" + SORT_BY_START_CHOICE = "start" + SORT_BY_CHOICES = ( + (SORT_BY_CREATED_CHOICE, "created"), + (SORT_BY_START_CHOICE, "start"), + ) + sort_by = filters.ChoiceFilter(method='sort_by_field', choices=SORT_BY_CHOICES) + class Meta: """Meta class""" model = models.News @@ -29,6 +39,8 @@ class NewsListFilterSet(filters.FilterSet): 'tag_group', 'tag_value__exclude', 'tag_value__in', + 'state', + 'sort_by', ) def in_tags(self, queryset, name, value): @@ -58,3 +70,6 @@ class NewsListFilterSet(filters.FilterSet): return queryset.filter(news_type__name=value) else: return queryset + + def sort_by_field(self, queryset, name, value): + return queryset.order_by(f'-{value}') diff --git a/apps/news/migrations/0036_news_site.py b/apps/news/migrations/0036_news_site.py new file mode 100644 index 00000000..6e819384 --- /dev/null +++ b/apps/news/migrations/0036_news_site.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.7 on 2019-11-22 09:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0037_sitesettings_old_id'), + ('news', '0035_news_views_count'), + ] + + operations = [ + migrations.AddField( + model_name='news', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='main.SiteSettings', verbose_name='site settings'), + ), + ] diff --git a/apps/news/migrations/0037_auto_20191129_1320.py b/apps/news/migrations/0037_auto_20191129_1320.py new file mode 100644 index 00000000..91c9a898 --- /dev/null +++ b/apps/news/migrations/0037_auto_20191129_1320.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-11-29 13:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0036_news_site'), + ] + + operations = [ + migrations.AlterField( + model_name='news', + name='start', + field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='Start'), + ), + ] diff --git a/apps/news/models.py b/apps/news/models.py index d0e79c64..30e4206b 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -8,7 +8,8 @@ from rest_framework.reverse import reverse from rating.models import Rating, ViewCount from utils.models import (BaseAttributes, TJSONField, TranslatedFieldsMixin, HasTagsMixin, - ProjectBaseMixin, GalleryModelMixin, IntermediateGalleryModelMixin) + ProjectBaseMixin, GalleryModelMixin, IntermediateGalleryModelMixin, + FavoritesMixin) from utils.querysets import TranslationQuerysetMixin from django.conf import settings @@ -126,7 +127,8 @@ class NewsQuerySet(TranslationQuerysetMixin): ) -class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixin): +class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixin, + FavoritesMixin): """News model.""" STR_FIELD_NAME = 'title' @@ -172,7 +174,8 @@ class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixi description = TJSONField(blank=True, null=True, default=None, verbose_name=_('description'), help_text='{"en-GB":"some text"}') - start = models.DateTimeField(verbose_name=_('Start')) + start = models.DateTimeField(blank=True, null=True, default=None, + verbose_name=_('Start')) end = models.DateTimeField(blank=True, null=True, default=None, verbose_name=_('End')) slug = models.SlugField(unique=True, max_length=255, @@ -194,6 +197,7 @@ class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixi views_count = models.OneToOneField('rating.ViewCount', blank=True, null=True, on_delete=models.SET_NULL) ratings = generic.GenericRelation(Rating) favorites = generic.GenericRelation(to='favorites.Favorites') + carousels = generic.GenericRelation(to='main.Carousel') agenda = models.ForeignKey('news.Agenda', blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_('agenda')) @@ -201,7 +205,8 @@ class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixi banner = models.ForeignKey('news.NewsBanner', blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_('banner')) - + site = models.ForeignKey('main.SiteSettings', blank=True, null=True, + on_delete=models.SET_NULL, verbose_name=_('site settings')) objects = NewsQuerySet.as_manager() class Meta: @@ -217,6 +222,10 @@ class News(GalleryModelMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixi def is_publish(self): return self.state in self.PUBLISHED_STATES + @property + def is_international(self): + return self.INTERNATIONAL_TAG_VALUE in map(lambda tag: tag.value, self.tags.all()) + @property def web_url(self): return reverse('web:news:rud', kwargs={'slug': self.slug}) diff --git a/apps/news/serializers.py b/apps/news/serializers.py index 4eaeaeb4..86673645 100644 --- a/apps/news/serializers.py +++ b/apps/news/serializers.py @@ -5,13 +5,14 @@ from rest_framework.fields import SerializerMethodField from account.serializers.common import UserBaseSerializer from gallery.models import Image +from main.models import SiteSettings from location import models as location_models from location.serializers import CountrySimpleSerializer, AddressBaseSerializer from news import models from tag.serializers import TagBaseSerializer from utils import exceptions as utils_exceptions from utils.serializers import (TranslatedField, ProjectModelSerializer, - FavoritesCreateSerializer, ImageBaseSerializer) + FavoritesCreateSerializer, ImageBaseSerializer, CarouselCreateSerializer) class AgendaSerializer(ProjectModelSerializer): @@ -65,7 +66,7 @@ class NewsBaseSerializer(ProjectModelSerializer): subtitle_translated = TranslatedField() news_type = NewsTypeSerializer(read_only=True) tags = TagBaseSerializer(read_only=True, many=True, source='visible_tags') - in_favorites = serializers.BooleanField(allow_null=True) + in_favorites = serializers.BooleanField(allow_null=True, read_only=True) view_counter = serializers.IntegerField(read_only=True) class Meta: @@ -80,7 +81,6 @@ class NewsBaseSerializer(ProjectModelSerializer): 'news_type', 'tags', 'slug', - 'in_favorites', 'view_counter', ) @@ -162,6 +162,7 @@ class NewsDetailWebSerializer(NewsDetailSerializer): class NewsBackOfficeBaseSerializer(NewsBaseSerializer): """News back office base serializer.""" + is_published = serializers.BooleanField(source='is_publish', read_only=True) class Meta(NewsBaseSerializer.Meta): """Meta class.""" @@ -169,6 +170,7 @@ class NewsBackOfficeBaseSerializer(NewsBaseSerializer): fields = NewsBaseSerializer.Meta.fields + ( 'title', 'subtitle', + 'is_published', ) @@ -182,6 +184,9 @@ class NewsBackOfficeDetailSerializer(NewsBackOfficeBaseSerializer, country_id = serializers.PrimaryKeyRelatedField( source='country', write_only=True, queryset=location_models.Country.objects.all()) + site_id = serializers.PrimaryKeyRelatedField( + source='site', write_only=True, + queryset=SiteSettings.objects.all()) template_display = serializers.CharField(source='get_template_display', read_only=True) @@ -193,8 +198,10 @@ class NewsBackOfficeDetailSerializer(NewsBackOfficeBaseSerializer, 'description', 'news_type_id', 'country_id', + 'site_id', 'template', 'template_display', + 'is_international', ) @@ -267,3 +274,24 @@ class NewsFavoritesCreateSerializer(FavoritesCreateSerializer): 'content_object': validated_data.pop('news') }) return super().create(validated_data) + + +class NewsCarouselCreateSerializer(CarouselCreateSerializer): + """Serializer to carousel object w/ model News.""" + + def validate(self, attrs): + news = models.News.objects.filter(pk=self.pk).first() + if not news: + raise serializers.ValidationError({'detail': _('Object not found.')}) + + if news.carousels.exists(): + raise utils_exceptions.CarouselError() + + attrs['news'] = news + return attrs + + def create(self, validated_data, *args, **kwargs): + validated_data.update({ + 'content_object': validated_data.pop('news') + }) + return super().create(validated_data) diff --git a/apps/news/tests.py b/apps/news/tests.py index 532a6efc..40f47312 100644 --- a/apps/news/tests.py +++ b/apps/news/tests.py @@ -5,6 +5,7 @@ from rest_framework.test import APITestCase from rest_framework import status from datetime import datetime, timedelta +from main.models import SiteSettings from news.models import NewsType, News from account.models import User, Role, UserRole from translation.models import Language @@ -30,18 +31,23 @@ class BaseTestCase(APITestCase): 'refresh_token': tokens.get('refresh_token')}) self.test_news_type = NewsType.objects.create(name="Test news type") - self.lang = Language.objects.get( + + self.lang, created = Language.objects.get_or_create( title='Russia', locale='ru-RU' ) - self.country_ru = Country.objects.get( + self.country_ru, created = Country.objects.get_or_create( name={"en-GB": "Russian"} ) + self.site_ru, created = SiteSettings.objects.get_or_create( + subdomain='ru' + ) + role = Role.objects.create( role=Role.CONTENT_PAGE_MANAGER, - country=self.country_ru + site_id=self.site_ru.id ) role.save() @@ -51,16 +57,18 @@ class BaseTestCase(APITestCase): ) user_role.save() + self.test_news = News.objects.create( created_by=self.user, modified_by=self.user, - title={"en-GB": "Test news"}, + title={"ru-RU": "Test news"}, news_type=self.test_news_type, - description={"en-GB": "Description test news"}, + description={"ru-RU": "Description test news"}, start=datetime.now() + timedelta(hours=-2), end=datetime.now() + timedelta(hours=2), state=News.PUBLISHED, slug='test-news-slug', country=self.country_ru, + site=self.site_ru ) @@ -70,14 +78,15 @@ class NewsTestCase(BaseTestCase): def test_news_post(self): test_news = { - "title": {"en-GB": "Test news POST"}, + "title": {"ru-RU": "Test news POST"}, "news_type_id": self.test_news_type.id, - "description": {"en-GB": "Description test news"}, + "description": {"ru-RU": "Description test news"}, "start": datetime.now() + timedelta(hours=-2), "end": datetime.now() + timedelta(hours=2), "state": News.PUBLISHED, "slug": 'test-news-slug_post', "country_id": self.country_ru.id, + "site_id": self.site_ru.id } url = reverse("back:news:list-create") @@ -107,11 +116,12 @@ class NewsTestCase(BaseTestCase): url = reverse('back:news:retrieve-update-destroy', kwargs={'pk': self.test_news.id}) data = { 'id': self.test_news.id, - 'description': {"en-GB": "Description test news!"}, + 'description': {"ru-RU": "Description test news!"}, 'slug': self.test_news.slug, 'start': self.test_news.start, 'news_type_id': self.test_news.news_type_id, - 'country_id': self.country_ru.id + 'country_id': self.country_ru.id, + "site_id": self.site_ru.id } response = self.client.put(url, data=data, format='json') @@ -128,3 +138,17 @@ class NewsTestCase(BaseTestCase): response = self.client.delete(f'/api/web/news/slug/{self.test_news.slug}/favorites/', format='json') self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + +class NewsCarouselTests(BaseTestCase): + + def test_back_carousel_CR(self): + data = { + "object_id": self.test_news.id + } + + response = self.client.post(f'/api/back/news/{self.test_news.id}/carousels/', data=data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.delete(f'/api/back/news/{self.test_news.id}/carousels/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) diff --git a/apps/news/urls/back.py b/apps/news/urls/back.py index 9cc3d94a..982e7810 100644 --- a/apps/news/urls/back.py +++ b/apps/news/urls/back.py @@ -13,4 +13,5 @@ urlpatterns = [ name='gallery-list'), path('/gallery//', views.NewsBackOfficeGalleryCreateDestroyView.as_view(), name='gallery-create-destroy'), -] \ No newline at end of file + path('/carousels/', views.NewsCarouselCreateDestroyView.as_view(), name='create-destroy-carousels'), +] diff --git a/apps/news/urls/common.py b/apps/news/urls/common.py index b42905eb..f5c809de 100644 --- a/apps/news/urls/common.py +++ b/apps/news/urls/common.py @@ -5,5 +5,6 @@ common_urlpatterns = [ path('', views.NewsListView.as_view(), name='list'), path('types/', views.NewsTypeListView.as_view(), name='type'), path('slug//', views.NewsDetailView.as_view(), name='rud'), - path('slug//favorites/', views.NewsFavoritesCreateDestroyView.as_view(), name='create-destroy-favorites') + path('slug//favorites/', views.NewsFavoritesCreateDestroyView.as_view(), + name='create-destroy-favorites'), ] diff --git a/apps/news/views.py b/apps/news/views.py index 638f208b..a4a5c33a 100644 --- a/apps/news/views.py +++ b/apps/news/views.py @@ -6,7 +6,7 @@ from rest_framework import generics, permissions from news import filters, models, serializers from rating.tasks import add_rating from utils.permissions import IsCountryAdmin, IsContentPageManager -from utils.views import CreateDestroyGalleryViewMixin +from utils.views import CreateDestroyGalleryViewMixin, FavoritesCreateDestroyMixinView, CarouselCreateDestroyMixinView from utils.serializers import ImageBaseSerializer @@ -21,7 +21,7 @@ class NewsMixinView: qs = models.News.objects.published() \ .with_base_related() \ .annotate_in_favorites(self.request.user) \ - .order_by('-is_highlighted', '-created') + .order_by('-is_highlighted', '-start') country_code = self.request.country_code if country_code: @@ -84,6 +84,7 @@ class NewsBackOfficeLCView(NewsBackOfficeMixinView, serializer_class = serializers.NewsBackOfficeBaseSerializer filter_class = filters.NewsListFilterSet create_serializers_class = serializers.NewsBackOfficeDetailSerializer + permission_classes = [IsCountryAdmin | IsContentPageManager] def get_serializer_class(self): @@ -150,18 +151,15 @@ class NewsBackOfficeRUDView(NewsBackOfficeMixinView, return self.retrieve(request, *args, **kwargs) -class NewsFavoritesCreateDestroyView(generics.CreateAPIView, generics.DestroyAPIView): +class NewsFavoritesCreateDestroyView(FavoritesCreateDestroyMixinView): """View for create/destroy news from favorites.""" + _model = models.News serializer_class = serializers.NewsFavoritesCreateSerializer - lookup_field = 'slug' - def get_object(self): - """ - Returns the object the view is displaying. - """ - news = get_object_or_404(models.News, slug=self.kwargs.get('slug')) - favorites = get_object_or_404(news.favorites.filter(user=self.request.user)) - # May raise a permission denied - self.check_object_permissions(self.request, favorites) - return favorites + +class NewsCarouselCreateDestroyView(CarouselCreateDestroyMixinView): + """View for create/destroy news from carousel.""" + + _model = models.News + serializer_class = serializers.NewsCarouselCreateSerializer diff --git a/apps/partner/serializers/back.py b/apps/partner/serializers/back.py new file mode 100644 index 00000000..e9e03fe0 --- /dev/null +++ b/apps/partner/serializers/back.py @@ -0,0 +1,21 @@ +"""Back account serializers""" +from rest_framework import serializers +from partner.models import Partner + + +class BackPartnerSerializer(serializers.ModelSerializer): + class Meta: + model = Partner + fields = ( + 'id', + 'old_id', + 'name', + 'url', + 'image', + 'establishment', + 'establishment_id', + 'type', + 'starting_date', + 'expiry_date', + 'price_per_month', + ) diff --git a/apps/partner/tests.py b/apps/partner/tests.py index 494e7f7e..3233950b 100644 --- a/apps/partner/tests.py +++ b/apps/partner/tests.py @@ -1,16 +1,90 @@ # Create your tests here. -from rest_framework.test import APITestCase +from http.cookies import SimpleCookie + from rest_framework import status +from rest_framework.test import APITestCase +from account.models import User, Role, UserRole +from establishment.models import EstablishmentType, Establishment +from location.models import Country, Region, City, Address from partner.models import Partner +from translation.models import Language -class PartnerTestCase(APITestCase): +class BaseTestCase(APITestCase): def setUp(self): - self.test_url = "www.example.com" - self.test_partner = Partner.objects.create(url=self.test_url) + self.username = 'test_user' + self.password = 'test_user_password' + self.email = 'test_user@mail.com' + self.user = User.objects.create_user( + username=self.username, + email=self.email, + password=self.password, + is_staff=True, + ) + + tokens = User.create_jwt_tokens(self.user) + self.client.cookies = SimpleCookie({ + 'access_token': tokens.get('access_token'), + 'refresh_token': tokens.get('refresh_token'), + }) + + self.establishment_type = EstablishmentType.objects.create(name="Test establishment type") + self.role = Role.objects.create(role=Role.ESTABLISHMENT_MANAGER) + + self.establishment = Establishment.objects.create( + name="Test establishment", + establishment_type_id=self.establishment_type.id, + is_publish=True, + slug="test", + ) + + self.user_role = UserRole.objects.create( + user=self.user, + role=self.role, + establishment=self.establishment, + ) + + self.partner = Partner.objects.create( + url='www.ya.ru', + establishment=self.establishment, + ) + + +class PartnerWebTestCase(BaseTestCase): def test_partner_list(self): response = self.client.get("/api/web/partner/") self.assertEqual(response.status_code, status.HTTP_200_OK) + + +class PartnerBackTestCase(BaseTestCase): + + def test_partner_list(self): + response = self.client.get('/api/back/partner/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_partner_post(self): + test_partner = { + 'url': 'http://google.com', + } + response = self.client.post('/api/back/partner/', data=test_partner, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_partner_detail(self): + response = self.client.get(f'/api/back/partner/{self.partner.id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_partner_detail_put(self): + data = { + 'url': 'http://yandex.com', + 'name': 'Yandex', + } + + response = self.client.put(f'/api/back/partner/{self.partner.id}/', data=data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_partner_delete(self): + response = self.client.delete(f'/api/back/partner/{self.partner.id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) diff --git a/apps/partner/transfer_data.py b/apps/partner/transfer_data.py index 868345a8..acda6c2c 100644 --- a/apps/partner/transfer_data.py +++ b/apps/partner/transfer_data.py @@ -1,11 +1,15 @@ from pprint import pprint from establishment.models import Establishment +from partner.models import Partner from transfer.models import EstablishmentBacklinks from transfer.serializers.partner import PartnerSerializer def transfer_partner(): + """ + Transfer data to Partner model only after transfer Establishment + """ establishments = Establishment.objects.filter(old_id__isnull=False).values_list('old_id', flat=True) queryset = EstablishmentBacklinks.objects.filter( establishment_id__in=list(establishments), @@ -24,6 +28,7 @@ def transfer_partner(): serialized_data = PartnerSerializer(data=list(queryset), many=True) if serialized_data.is_valid(): + Partner.objects.all().delete() # TODO: закоментить, если требуется сохранить старые записи serialized_data.save() else: pprint(f"Partner serializer errors: {serialized_data.errors}") diff --git a/apps/partner/urls/back.py b/apps/partner/urls/back.py new file mode 100644 index 00000000..27de2731 --- /dev/null +++ b/apps/partner/urls/back.py @@ -0,0 +1,11 @@ +"""Back account URLs""" +from django.urls import path + +from partner.views import back as views + +app_name = 'partner' + +urlpatterns = [ + path('', views.PartnerLstView.as_view(), name='partner-list-create'), + path('/', views.PartnerRUDView.as_view(), name='partner-rud'), +] diff --git a/apps/partner/views/back.py b/apps/partner/views/back.py new file mode 100644 index 00000000..1033d0ee --- /dev/null +++ b/apps/partner/views/back.py @@ -0,0 +1,27 @@ +from django_filters.rest_framework import DjangoFilterBackend, filters +from rest_framework import generics, permissions + +from partner.models import Partner +from partner.serializers import back as serializers +from utils.permissions import IsEstablishmentManager + + +class PartnerLstView(generics.ListCreateAPIView): + """Partner list create view.""" + queryset = Partner.objects.all() + serializer_class = serializers.BackPartnerSerializer + pagination_class = None + permission_classes = [permissions.IsAdminUser | IsEstablishmentManager] + filter_backends = (DjangoFilterBackend,) + filterset_fields = ( + 'establishment', + 'type', + ) + + +class PartnerRUDView(generics.RetrieveUpdateDestroyAPIView): + """Partner RUD view.""" + queryset = Partner.objects.all() + serializer_class = serializers.BackPartnerSerializer + permission_classes = [permissions.IsAdminUser | IsEstablishmentManager] + lookup_field = 'id' diff --git a/apps/partner/views/views.py b/apps/partner/views/views.py deleted file mode 100644 index 60f00ef0..00000000 --- a/apps/partner/views/views.py +++ /dev/null @@ -1 +0,0 @@ -# Create your views here. diff --git a/apps/product/models.py b/apps/product/models.py index f499afee..561280fc 100644 --- a/apps/product/models.py +++ b/apps/product/models.py @@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _ from django.core.validators import MaxValueValidator, MinValueValidator from utils.models import (BaseAttributes, ProjectBaseMixin, HasTagsMixin, - TranslatedFieldsMixin, TJSONField, + TranslatedFieldsMixin, TJSONField, FavoritesMixin, GalleryModelMixin, IntermediateGalleryModelMixin) @@ -131,7 +131,8 @@ class ProductQuerySet(models.QuerySet): ) -class Product(GalleryModelMixin, TranslatedFieldsMixin, BaseAttributes, HasTagsMixin): +class Product(GalleryModelMixin, TranslatedFieldsMixin, BaseAttributes, + HasTagsMixin, FavoritesMixin): """Product models.""" EARLIEST_VINTAGE_YEAR = 1700 diff --git a/apps/product/transfer_data.py b/apps/product/transfer_data.py index 8e4a26fa..84a8a524 100644 --- a/apps/product/transfer_data.py +++ b/apps/product/transfer_data.py @@ -2,17 +2,6 @@ from pprint import pprint from transfer import models as transfer_models from transfer.serializers import product as product_serializers -from transfer.serializers.partner import PartnerSerializer - - -def transfer_partner(): - queryset = transfer_models.EstablishmentBacklinks.objects.filter(type="Partner") - - serialized_data = PartnerSerializer(data=list(queryset.values()), many=True) - if serialized_data.is_valid(): - serialized_data.save() - else: - pprint(f"News serializer errors: {serialized_data.errors}") def transfer_wine_color(): @@ -50,8 +39,8 @@ def transfer_wine_bottles_produced(): ) queryset = [vars(query) for query in raw_queryset] serialized_data = product_serializers.WineBottlesProducedSerializer( - data=queryset, - many=True) + data=queryset, + many=True) if serialized_data.is_valid(): serialized_data.save() else: @@ -69,8 +58,8 @@ def transfer_wine_classification_type(): ) queryset = [vars(query) for query in raw_queryset] serialized_data = product_serializers.WineClassificationTypeSerializer( - data=queryset, - many=True) + data=queryset, + many=True) if serialized_data.is_valid(): serialized_data.save() else: @@ -79,10 +68,10 @@ def transfer_wine_classification_type(): def transfer_wine_standard(): queryset = transfer_models.ProductClassification.objects.filter(parent_id__isnull=True) \ - .exclude(type='Classification') + .exclude(type='Classification') serialized_data = product_serializers.ProductStandardSerializer( - data=list(queryset.values()), - many=True) + data=list(queryset.values()), + many=True) if serialized_data.is_valid(): serialized_data.save() else: @@ -92,8 +81,8 @@ def transfer_wine_standard(): def transfer_wine_classifications(): queryset = transfer_models.ProductClassification.objects.filter(type='Classification') serialized_data = product_serializers.ProductClassificationSerializer( - data=list(queryset.values()), - many=True) + data=list(queryset.values()), + many=True) if serialized_data.is_valid(): serialized_data.save() else: @@ -104,8 +93,8 @@ def transfer_product(): errors = [] queryset = transfer_models.Products.objects.all() serialized_data = product_serializers.ProductSerializer( - data=list(queryset.values()), - many=True) + data=list(queryset.values()), + many=True) if serialized_data.is_valid(): serialized_data.save() else: @@ -117,8 +106,8 @@ def transfer_product_note(): errors = [] queryset = transfer_models.ProductNotes.objects.exclude(text='') serialized_data = product_serializers.ProductNoteSerializer( - data=list(queryset.values()), - many=True) + data=list(queryset.values()), + many=True) if serialized_data.is_valid(): serialized_data.save() else: @@ -130,8 +119,8 @@ def transfer_plate(): errors = [] queryset = transfer_models.Merchandise.objects.all() serialized_data = product_serializers.PlateSerializer( - data=list(queryset.values()), - many=True) + data=list(queryset.values()), + many=True) if serialized_data.is_valid(): serialized_data.save() else: @@ -143,8 +132,8 @@ def transfer_plate_image(): errors = [] queryset = transfer_models.Merchandise.objects.all() serialized_data = product_serializers.PlateImageSerializer( - data=list(queryset.values()), - many=True) + data=list(queryset.values()), + many=True) if serialized_data.is_valid(): serialized_data.save() else: @@ -153,7 +142,6 @@ def transfer_plate_image(): data_types = { - "partner": [transfer_partner], "wine_characteristics": [ transfer_wine_sugar_content, transfer_wine_color, @@ -161,12 +149,12 @@ data_types = { transfer_wine_classification_type, transfer_wine_standard, transfer_wine_classifications, - ], + ], "product": [ transfer_product, ], "product_note": [ - transfer_product_note, + transfer_product_note, ], "souvenir": [ transfer_plate, diff --git a/apps/product/views/common.py b/apps/product/views/common.py index 8b857ddb..f984a87b 100644 --- a/apps/product/views/common.py +++ b/apps/product/views/common.py @@ -3,9 +3,9 @@ from rest_framework import generics, permissions from django.shortcuts import get_object_or_404 from product.models import Product from comment.models import Comment -from product import serializers -from product import filters +from product import filters, serializers from comment.serializers import CommentRUDSerializer +from utils.views import FavoritesCreateDestroyMixinView class ProductBaseView(generics.GenericAPIView): @@ -37,22 +37,11 @@ class ProductDetailView(ProductBaseView, generics.RetrieveAPIView): serializer_class = serializers.ProductDetailSerializer -class CreateFavoriteProductView(generics.CreateAPIView, - generics.DestroyAPIView): +class CreateFavoriteProductView(FavoritesCreateDestroyMixinView): """View for create/destroy product in favorites.""" + + _model = Product serializer_class = serializers.ProductFavoritesCreateSerializer - lookup_field = 'slug' - - def get_object(self): - """ - Returns the object the view is displaying. - """ - product = get_object_or_404(Product, slug=self.kwargs['slug']) - favorites = get_object_or_404(product.favorites.filter(user=self.request.user)) - - # May raise a permission denied - self.check_object_permissions(self.request, favorites) - return favorites class ProductCommentCreateView(generics.CreateAPIView): diff --git a/apps/review/management/__init__.py b/apps/review/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/review/management/commands/__init__.py b/apps/review/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/review/management/commands/add_review_priority.py b/apps/review/management/commands/add_review_priority.py new file mode 100644 index 00000000..97525cf9 --- /dev/null +++ b/apps/review/management/commands/add_review_priority.py @@ -0,0 +1,23 @@ +from django.core.management.base import BaseCommand +from tqdm import tqdm + +from review.models import Review +from transfer.models import Reviews + + +class Command(BaseCommand): + help = '''Add review priority from old db to new db.''' + + def handle(self, *args, **kwargs): + reviews = Review.objects.all().values_list('old_id', flat=True) + queryset = Reviews.objects.exclude(product_id__isnull=False).filter( + id__in=list(reviews), + ).values_list('id', 'priority') + + for old_id, priority in tqdm(queryset, desc='Add priority to reviews'): + review = Review.objects.filter(old_id=old_id).first() + if review: + review.priority = priority + review.save() + + self.stdout.write(self.style.WARNING(f'Priority added to review objects.')) diff --git a/apps/review/migrations/0019_review_priority.py b/apps/review/migrations/0019_review_priority.py new file mode 100644 index 00000000..980aa7a2 --- /dev/null +++ b/apps/review/migrations/0019_review_priority.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.7 on 2019-11-28 13:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('review', '0018_auto_20191117_1117'), + ] + + operations = [ + migrations.AddField( + model_name='review', + name='priority', + field=models.PositiveSmallIntegerField(blank=True, default=None, null=True, verbose_name='Priority'), + ), + ] diff --git a/apps/review/models.py b/apps/review/models.py index 8734f4f6..bb344fc5 100644 --- a/apps/review/models.py +++ b/apps/review/models.py @@ -39,7 +39,6 @@ class Review(BaseAttributes, TranslatedFieldsMixin): (TO_REVIEW, _('To review')), (READY, _('Ready')), ) - reviewer = models.ForeignKey( 'account.User', related_name='reviews', @@ -83,6 +82,7 @@ class Review(BaseAttributes, TranslatedFieldsMixin): ) vintage = models.IntegerField(_('Year of review'), validators=[MinValueValidator(1900), MaxValueValidator(2100)]) mark = models.FloatField(verbose_name=_('mark'), blank=True, null=True, default=None) + priority = models.PositiveSmallIntegerField(_('Priority'), blank=True, null=True, default=None) old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None) objects = ReviewQuerySet.as_manager() diff --git a/apps/review/serializers/back.py b/apps/review/serializers/back.py index c72cb205..75df94e2 100644 --- a/apps/review/serializers/back.py +++ b/apps/review/serializers/back.py @@ -1,3 +1,54 @@ """Review app back serializers.""" -from review import models +from django.contrib.contenttypes.models import ContentType from rest_framework import serializers + +from account.models import User +from review.models import Review + + +class _ReviewerSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ( + 'id', + 'username', + 'first_name', + 'last_name', + 'email', + ) + + +class _ContentTypeSerializer(serializers.ModelSerializer): + class Meta: + model = ContentType + fields = ( + 'id', + 'app_label', + 'model', + ) + + +class ReviewBackSerializer(serializers.ModelSerializer): + reviewer_data = _ReviewerSerializer(read_only=True, source='reviewer') + content_type_data = _ContentTypeSerializer(read_only=True, source='content_type') + status_display = serializers.CharField(read_only=True, source='get_status_display') + + class Meta: + model = Review + fields = ( + 'id', + 'reviewer', + 'reviewer_data', + 'text', + 'status', + 'status_display', + 'mark', + 'priority', + # 'child', + 'published_at', + 'vintage', + # 'country', + 'content_type', + 'content_type_data', + 'object_id', + ) diff --git a/apps/review/serializers/common.py b/apps/review/serializers/common.py index da7b624b..e714fff7 100644 --- a/apps/review/serializers/common.py +++ b/apps/review/serializers/common.py @@ -10,6 +10,7 @@ class ReviewBaseSerializer(serializers.ModelSerializer): 'id', 'reviewer', 'text', + 'priority', 'status', 'child', 'published_at', @@ -33,6 +34,7 @@ class ReviewShortSerializer(ReviewBaseSerializer): class InquiriesBaseSerializer(serializers.ModelSerializer): """Serializer for model Inquiries.""" + class Meta: model = Inquiries fields = ( @@ -56,6 +58,7 @@ class InquiriesBaseSerializer(serializers.ModelSerializer): class GridItemsBaseSerializer(serializers.ModelSerializer): """Serializer for model GridItems.""" + class Meta: model = GridItems fields = ( diff --git a/apps/review/views/back.py b/apps/review/views/back.py index 27f9af0d..caf12b62 100644 --- a/apps/review/views/back.py +++ b/apps/review/views/back.py @@ -4,19 +4,34 @@ from review import filters from review import models from review import serializers from utils.permissions import IsReviewerManager, IsRestaurantReviewer +from review.serializers.back import ReviewBackSerializer class ReviewLstView(generics.ListCreateAPIView): - """Comment list create view.""" - serializer_class = serializers.ReviewBaseSerializer + """Review list create view. + + status values: + + TO_INVESTIGATE = 0 + TO_REVIEW = 1 + READY = 2 + """ + serializer_class = ReviewBackSerializer queryset = models.Review.objects.all() permission_classes = [permissions.IsAuthenticatedOrReadOnly, ] filterset_class = filters.ReviewFilter class ReviewRUDView(generics.RetrieveUpdateDestroyAPIView): - """Comment RUD view.""" - serializer_class = serializers.ReviewBaseSerializer + """Review RUD view. + + status values: + + TO_INVESTIGATE = 0 + TO_REVIEW = 1 + READY = 2 + """ + serializer_class = ReviewBackSerializer queryset = models.Review.objects.all() permission_classes = [permissions.IsAdminUser | IsReviewerManager | IsRestaurantReviewer] lookup_field = 'id' diff --git a/apps/search_indexes/documents/__init__.py b/apps/search_indexes/documents/__init__.py index 70d17330..c357f29e 100644 --- a/apps/search_indexes/documents/__init__.py +++ b/apps/search_indexes/documents/__init__.py @@ -1,11 +1,12 @@ from search_indexes.documents.establishment import EstablishmentDocument from search_indexes.documents.news import NewsDocument from search_indexes.documents.product import ProductDocument - +from search_indexes.tasks import es_update # todo: make signal to update documents on related fields __all__ = [ 'EstablishmentDocument', 'NewsDocument', 'ProductDocument', + 'es_update', ] \ No newline at end of file diff --git a/apps/search_indexes/documents/establishment.py b/apps/search_indexes/documents/establishment.py index 8b4e5c3c..e53b93de 100644 --- a/apps/search_indexes/documents/establishment.py +++ b/apps/search_indexes/documents/establishment.py @@ -46,6 +46,7 @@ class EstablishmentDocument(Document): 'id': fields.IntegerField(attr='id'), 'label': fields.ObjectField(attr='label_indexing', properties=OBJECT_FIELD_PROPERTIES), + 'value': fields.KeywordField(), }, multi=True) visible_tags = fields.ObjectField( @@ -53,6 +54,7 @@ class EstablishmentDocument(Document): 'id': fields.IntegerField(attr='id'), 'label': fields.ObjectField(attr='label_indexing', properties=OBJECT_FIELD_PROPERTIES), + 'value': fields.KeywordField(), }, multi=True) products = fields.ObjectField( @@ -69,6 +71,14 @@ class EstablishmentDocument(Document): # 'coordinates': fields.GeoPointField(), 'description': fields.ObjectField(attr='description_indexing', properties=OBJECT_FIELD_PROPERTIES), }), + 'wine_colors': fields.ObjectField( + properties={ + 'id': fields.IntegerField(), + 'label': fields.ObjectField(attr='label_indexing', properties=OBJECT_FIELD_PROPERTIES), + 'value': fields.KeywordField(), + }, + multi=True, + ), 'wine_sub_region': fields.ObjectField(properties={ 'id': fields.IntegerField(), 'name': fields.KeywordField(), @@ -113,6 +123,7 @@ class EstablishmentDocument(Document): ), }, ) + favorites_for_users = fields.ListField(field=fields.IntegerField()) class Django: diff --git a/apps/search_indexes/documents/news.py b/apps/search_indexes/documents/news.py index e39036d3..3c87e680 100644 --- a/apps/search_indexes/documents/news.py +++ b/apps/search_indexes/documents/news.py @@ -41,13 +41,13 @@ class NewsDocument(Document): properties=OBJECT_FIELD_PROPERTIES), }, multi=True) - + favorites_for_users = fields.ListField(field=fields.IntegerField()) + start = fields.DateField(attr='start') class Django: model = models.News fields = ( 'id', - 'start', 'end', 'slug', 'state', @@ -57,7 +57,7 @@ class NewsDocument(Document): related_models = [models.NewsType] def get_queryset(self): - return super().get_queryset().published().with_base_related().sort_by_start() + return super().get_queryset().published().with_base_related() def get_instances_from_related(self, related_instance): """If related_models is set, define how to retrieve the Car instance(s) from the related model. diff --git a/apps/search_indexes/documents/product.py b/apps/search_indexes/documents/product.py index 1a092dac..853f72a2 100644 --- a/apps/search_indexes/documents/product.py +++ b/apps/search_indexes/documents/product.py @@ -148,6 +148,8 @@ class ProductDocument(Document): name = fields.TextField(attr='display_name', analyzer='english') name_ru = fields.TextField(attr='display_name', analyzer='russian') name_fr = fields.TextField(attr='display_name', analyzer='french') + favorites_for_users = fields.ListField(field=fields.IntegerField()) + created = fields.DateField(attr='created') # publishing date (?) class Django: model = models.Product diff --git a/apps/search_indexes/filters.py b/apps/search_indexes/filters.py index ab47ef84..ec45350c 100644 --- a/apps/search_indexes/filters.py +++ b/apps/search_indexes/filters.py @@ -1,7 +1,81 @@ """Search indexes filters.""" from elasticsearch_dsl.query import Q -from django_elasticsearch_dsl_drf.filter_backends import SearchFilterBackend +from django_elasticsearch_dsl_drf.filter_backends import SearchFilterBackend, \ + FacetedSearchFilterBackend, GeoSpatialFilteringFilterBackend from search_indexes.utils import OBJECT_FIELD_PROPERTIES +from six import iteritems + + +class CustomGeoSpatialFilteringFilterBackend(GeoSpatialFilteringFilterBackend): + """Automatically adds centering and sorting within bounding box.""" + + @staticmethod + def calculate_center(a, b): + return (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 + + def filter_queryset(self, request, queryset, view): + ret = super().filter_queryset(request, queryset, view) + bb = request.query_params.get('location__geo_bounding_box') + if bb: + center = self.calculate_center(*map(lambda p: list(map(lambda x: float(x),p.split(','))), bb.split('__'))) + request.GET._mutable = True + request.query_params.update({ + 'ordering': f'location__{center[0]}__{center[1]}__km' + }) + request.GET._mutable = False + return ret + + +class CustomFacetedSearchFilterBackend(FacetedSearchFilterBackend): + + def __init__(self): + self.facets_computed = {} + + def aggregate(self, request, queryset, view): + """Aggregate. + + :param request: + :param queryset: + :param view: + :return: + """ + def makefilter(cur_facet): + def myfilter(x): + return cur_facet['facet']._params['field'] != next(iter(x._params)) + return myfilter + __facets = self.construct_facets(request, view) + setattr(view.paginator, 'facets_computed', {}) + for __field, __facet in iteritems(__facets): + agg = __facet['facet'].get_aggregation() + agg_filter = Q('match_all') + if __facet['global']: + queryset.aggs.bucket( + '_filter_' + __field, + 'global' + ).bucket(__field, agg) + else: + qs = queryset.__copy__() + qs.query = queryset.query._clone() + filterer = makefilter(__facet) + for param_type in ['must', 'must_not', 'should']: + if qs.query._proxied._params.get(param_type): + qs.query._proxied._params[param_type] = list( + filter( + filterer, qs.query._proxied._params[param_type] + ) + ) + sh = qs.query._proxied._params.get('should') + if (not sh or not len(sh)) \ + and qs.query._proxied._params.get('minimum_should_match'): + qs.query._proxied._params.pop('minimum_should_match') + facet_name = '_filter_' + __field + qs.aggs.bucket( + facet_name, + 'filter', + filter=agg_filter + ).bucket(__field, agg) + view.paginator.facets_computed.update({facet_name: qs.execute().aggregations[facet_name]}) + return queryset class CustomSearchFilterBackend(SearchFilterBackend): diff --git a/apps/search_indexes/serializers.py b/apps/search_indexes/serializers.py index cac4e336..b45bd2e3 100644 --- a/apps/search_indexes/serializers.py +++ b/apps/search_indexes/serializers.py @@ -135,6 +135,9 @@ class ProductEstablishmentDocumentSerializer(serializers.Serializer): index_name = serializers.CharField() city = AnotherCityDocumentShortSerializer() + def get_attribute(self, instance): + return instance.establishment if instance and instance.establishment else None + class AddressDocumentSerializer(serializers.Serializer): """Address serializer for ES Document.""" @@ -167,7 +170,25 @@ class ScheduleDocumentSerializer(serializers.Serializer): closed_at = serializers.CharField() -class NewsDocumentSerializer(DocumentSerializer): +class InFavoritesMixin(DocumentSerializer): + """Append in_favorites field.""" + + in_favorites = serializers.SerializerMethodField() + + def get_in_favorites(self, obj): + request = self.context['request'] + user = request.user + if user.is_authenticated: + return user.id in obj.favorites_for_users + return False + + class Meta: + """Meta class.""" + + _abstract = True + + +class NewsDocumentSerializer(InFavoritesMixin, DocumentSerializer): """News document serializer.""" title_translated = serializers.SerializerMethodField(allow_null=True) @@ -188,6 +209,7 @@ class NewsDocumentSerializer(DocumentSerializer): 'preview_image_url', 'news_type', 'tags', + 'start', 'slug', ) @@ -200,7 +222,7 @@ class NewsDocumentSerializer(DocumentSerializer): return get_translated_value(obj.subtitle) -class EstablishmentDocumentSerializer(DocumentSerializer): +class EstablishmentDocumentSerializer(InFavoritesMixin, DocumentSerializer): """Establishment document serializer.""" establishment_type = EstablishmentTypeSerializer() @@ -236,7 +258,7 @@ class EstablishmentDocumentSerializer(DocumentSerializer): ) -class ProductDocumentSerializer(DocumentSerializer): +class ProductDocumentSerializer(InFavoritesMixin, DocumentSerializer): """Product document serializer""" tags = TagsDocumentSerializer(many=True, source='related_tags') @@ -271,4 +293,5 @@ class ProductDocumentSerializer(DocumentSerializer): 'grape_variety', 'establishment_detail', 'average_price', + 'created', ) diff --git a/apps/search_indexes/tasks.py b/apps/search_indexes/tasks.py new file mode 100644 index 00000000..b9fefff7 --- /dev/null +++ b/apps/search_indexes/tasks.py @@ -0,0 +1,50 @@ +"""SearchIndex tasks.""" +import logging +from django.db import models +from celery.schedules import crontab +from celery.task import periodic_task +from django_elasticsearch_dsl.registries import registry +from django_redis import get_redis_connection +from establishment.models import Establishment +from news.models import News +from product.models import Product + + +logger = logging.getLogger(__name__) + + +@periodic_task(run_every=crontab(minute='*/1')) +def update_index(): + """Updates ES index.""" + try: + cn = get_redis_connection('es_queue') + for model in [Establishment, News, Product]: + model_name = model.__name__.lower() + while True: + ids = cn.spop(model_name, 500) + if not ids: + break + qs = model.objects.filter(id__in=ids) + try: + doc = registry.get_documents([model]).pop() + except KeyError: + pass + else: + doc().update(qs) + except Exception as ex: + logger.error(f'Updating index failed: {ex}') + + +def es_update(obj): + """Adds object to set of objects for indexing.""" + try: + cn = get_redis_connection('es_queue') + allowed_models = [Establishment, News, Product] + if isinstance(obj, models.QuerySet) and obj.model in allowed_models: + key = obj.model.__name__.lower() + cn.sadd(key, *obj.values_list('id', flat=True)) + elif isinstance(obj, models.Model) and obj.__class__ in allowed_models: + key = obj.__class__.__name__.lower() + cn.sadd(key, obj.id) + except Exception as ex: + logger.warning(f'Send obj to ES failed: {ex}') diff --git a/apps/search_indexes/urls.py b/apps/search_indexes/urls.py index 70e21369..902ccfeb 100644 --- a/apps/search_indexes/urls.py +++ b/apps/search_indexes/urls.py @@ -6,9 +6,11 @@ from search_indexes import views router = routers.SimpleRouter() # router.register(r'news', views.NewsDocumentViewSet, basename='news') # temporarily disabled router.register(r'establishments', views.EstablishmentDocumentViewSet, basename='establishment') -router.register(r'mobile/establishments', views.EstablishmentDocumentViewSet, basename='establishment-mobile') +router.register(r'mobile/establishments', views.MobileEstablishmentDocumentViewSet, basename='establishment-mobile') router.register(r'news', views.NewsDocumentViewSet, basename='news') +router.register(r'mobile/news', views.MobileNewsDocumentViewSet, basename='news-mobile') router.register(r'products', views.ProductDocumentViewSet, basename='product') +router.register(r'mobile/products', views.MobileProductDocumentViewSet, basename='product-mobile') urlpatterns = router.urls diff --git a/apps/search_indexes/utils.py b/apps/search_indexes/utils.py index d30e7de3..d4c4f68a 100644 --- a/apps/search_indexes/utils.py +++ b/apps/search_indexes/utils.py @@ -2,6 +2,8 @@ from django_elasticsearch_dsl import fields from utils.models import get_current_locale, get_default_locale +FACET_MAX_RESPONSE = 9999999 # Unlimited + ALL_LOCALES_LIST = [ 'hr-HR', 'ro-RO', diff --git a/apps/search_indexes/views.py b/apps/search_indexes/views.py index 783754c7..a5b952d7 100644 --- a/apps/search_indexes/views.py +++ b/apps/search_indexes/views.py @@ -4,13 +4,15 @@ from django_elasticsearch_dsl_drf import constants from django_elasticsearch_dsl_drf.filter_backends import ( FilteringFilterBackend, GeoSpatialFilteringFilterBackend, - DefaultOrderingFilterBackend, + GeoSpatialOrderingFilterBackend, + OrderingFilterBackend, ) +from elasticsearch_dsl import TermsFacet from django_elasticsearch_dsl_drf.viewsets import BaseDocumentViewSet -from search_indexes import serializers, filters +from search_indexes import serializers, filters, utils from search_indexes.documents import EstablishmentDocument, NewsDocument from search_indexes.documents.product import ProductDocument -from utils.pagination import ProjectMobilePagination +from utils.pagination import ESDocumentPagination class NewsDocumentViewSet(BaseDocumentViewSet): @@ -18,15 +20,34 @@ class NewsDocumentViewSet(BaseDocumentViewSet): document = NewsDocument lookup_field = 'slug' - pagination_class = ProjectMobilePagination + pagination_class = ESDocumentPagination permission_classes = (permissions.AllowAny,) serializer_class = serializers.NewsDocumentSerializer filter_backends = [ filters.CustomSearchFilterBackend, FilteringFilterBackend, + filters.CustomFacetedSearchFilterBackend, + OrderingFilterBackend ] + ordering_fields = { + 'start': { + 'field': 'start', + }, + } + + faceted_search_fields = { + 'tag': { + 'field': 'tags.id', + 'enabled': True, + 'facet': TermsFacet, + 'options': { + 'size': utils.FACET_MAX_RESPONSE, + }, + }, + } + search_fields = { 'title': {'fuzziness': 'auto:2,5', 'boost': 3}, @@ -65,12 +86,19 @@ class NewsDocumentViewSet(BaseDocumentViewSet): } +class MobileNewsDocumentViewSet(NewsDocumentViewSet): + + filter_backends = [ + filters.CustomSearchFilterBackend, + FilteringFilterBackend, + ] + + class EstablishmentDocumentViewSet(BaseDocumentViewSet): """Establishment document ViewSet.""" document = EstablishmentDocument - lookup_field = 'slug' - pagination_class = ProjectMobilePagination + pagination_class = ESDocumentPagination permission_classes = (permissions.AllowAny,) serializer_class = serializers.EstablishmentDocumentSerializer @@ -82,10 +110,63 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): filter_backends = [ FilteringFilterBackend, filters.CustomSearchFilterBackend, - GeoSpatialFilteringFilterBackend, - # DefaultOrderingFilterBackend, + filters.CustomGeoSpatialFilteringFilterBackend, + filters.CustomFacetedSearchFilterBackend, + GeoSpatialOrderingFilterBackend, ] + faceted_search_fields = { + 'works_at_weekday': { + 'field': 'works_at_weekday', + 'facet': TermsFacet, + 'enabled': True, + }, + 'toque_number': { + 'field': 'toque_number', + 'enabled': True, + 'facet': TermsFacet, + }, + 'works_noon': { + 'field': 'works_noon', + 'facet': TermsFacet, + 'enabled': True, + }, + 'works_evening': { + 'field': 'works_evening', + 'facet': TermsFacet, + 'enabled': True, + }, + 'works_now': { + 'field': 'works_now', + 'facet': TermsFacet, + 'enabled': True, + }, + 'tag': { + 'field': 'tags.id', + 'facet': TermsFacet, + 'enabled': True, + 'options': { + 'size': utils.FACET_MAX_RESPONSE, + }, + }, + 'wine_colors': { + 'field': 'products.wine_colors.id', + 'facet': TermsFacet, + 'enabled': True, + 'options': { + 'size': utils.FACET_MAX_RESPONSE, + }, + }, + 'wine_region_id': { + 'field': 'products.wine_region.id', + 'facet': TermsFacet, + 'enabled': True, + 'options': { + 'size': utils.FACET_MAX_RESPONSE, + }, + } + } + search_fields = { 'name': {'fuzziness': 'auto:2,5', 'boost': 4}, @@ -124,6 +205,13 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): constants.LOOKUP_QUERY_IN, ] }, + 'wine_colors_id': { + 'field': 'products.wine_colors.id', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + constants.LOOKUP_QUERY_EXCLUDE, + ], + }, 'wine_region_id': { 'field': 'products.wine_region.id', 'lookups': [ @@ -206,20 +294,44 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): } } + geo_spatial_ordering_fields = { + 'location': { + 'field': 'address.coordinates', + }, + } + + +class MobileEstablishmentDocumentViewSet(EstablishmentDocumentViewSet): + + filter_backends = [ + FilteringFilterBackend, + filters.CustomSearchFilterBackend, + GeoSpatialFilteringFilterBackend, + ] + class ProductDocumentViewSet(BaseDocumentViewSet): """Product document ViewSet.""" document = ProductDocument - pagination_class = ProjectMobilePagination + pagination_class = ESDocumentPagination permission_classes = (permissions.AllowAny,) serializer_class = serializers.ProductDocumentSerializer filter_backends = [ FilteringFilterBackend, filters.CustomSearchFilterBackend, + filters.CustomFacetedSearchFilterBackend, + OrderingFilterBackend, + # GeoSpatialOrderingFilterBackend, ] + ordering_fields = { + 'created': { + 'field': 'created', + }, + } + search_fields = { 'name': {'fuzziness': 'auto:2,5', 'boost': 8}, @@ -232,6 +344,25 @@ class ProductDocumentViewSet(BaseDocumentViewSet): 'description': {'fuzziness': 'auto:2,5'}, } + faceted_search_fields = { + 'tag': { + 'field': 'wine_colors.id', + 'enabled': True, + 'facet': TermsFacet, + 'options': { + 'size': utils.FACET_MAX_RESPONSE, + }, + }, + 'wine_region_id': { + 'field': 'wine_region.id', + 'enabled': True, + 'facet': TermsFacet, + 'options': { + 'size': utils.FACET_MAX_RESPONSE, + }, + }, + } + translated_search_fields = ( 'description', ) @@ -289,4 +420,12 @@ class ProductDocumentViewSet(BaseDocumentViewSet): constants.LOOKUP_QUERY_EXCLUDE, ], }, - } \ No newline at end of file + } + + +class MobileProductDocumentViewSet(ProductDocumentViewSet): + + filter_backends = [ + FilteringFilterBackend, + filters.CustomSearchFilterBackend, + ] diff --git a/apps/tag/filters.py b/apps/tag/filters.py index 7bd22ec2..5e2b31a7 100644 --- a/apps/tag/filters.py +++ b/apps/tag/filters.py @@ -43,8 +43,8 @@ class TagCategoryFilterSet(TagsBaseFilterSet): 'product_type', ) def by_product_type(self, queryset, name, value): - # if value == product_models.ProductType.WINE: - # queryset = queryset.filter(index_name='wine-color').filter(tags__products__isnull=False) + if value == product_models.ProductType.WINE: + return queryset.wine_tags_category().filter(tags__products__isnull=False) queryset = queryset.by_product_type(value) return queryset @@ -86,7 +86,7 @@ class TagsFilterSet(TagsBaseFilterSet): if self.NEWS in value: queryset = queryset.for_news().filter(value__in=settings.NEWS_CHOSEN_TAGS).distinct('value') if self.ESTABLISHMENT in value: - queryset = queryset.for_establishments().filter(value__in=settings.ESTABLISHMENT_CHOSEN_TAGS).distinct( + queryset = queryset.for_establishments().filter(category__value_type='list').filter(value__in=settings.ESTABLISHMENT_CHOSEN_TAGS).distinct( 'value') return queryset diff --git a/apps/tag/models.py b/apps/tag/models.py index a93c4a1f..12517815 100644 --- a/apps/tag/models.py +++ b/apps/tag/models.py @@ -111,6 +111,9 @@ class TagCategoryQuerySet(models.QuerySet): """Filter by product type index name.""" return self.filter(tags__products__product_type__index_name=index_name) + def wine_tags_category(self): + return self.filter(index_name='wine-color') + def with_tags(self, switcher=True): """Filter by existing tags.""" return self.exclude(tags__isnull=switcher) diff --git a/apps/timetable/serialziers.py b/apps/timetable/serialziers.py index 533bca70..48c8374d 100644 --- a/apps/timetable/serialziers.py +++ b/apps/timetable/serialziers.py @@ -20,6 +20,8 @@ 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.""" @@ -34,6 +36,7 @@ class ScheduleRUDSerializer(serializers.ModelSerializer): 'dinner_end', 'opening_at', 'closed_at', + 'establishment_id' ] def validate(self, attrs): diff --git a/apps/transfer/models.py b/apps/transfer/models.py index a8190879..37f9217a 100644 --- a/apps/transfer/models.py +++ b/apps/transfer/models.py @@ -369,44 +369,45 @@ class GuideFilters(MigrateMixin): db_table = 'guide_filters' -# -# class GuideSections(MigrateMixin): -# using = 'legacy' -# -# type = models.CharField(max_length=255) -# key_name = models.CharField(max_length=255, blank=True, null=True) -# value_name = models.CharField(max_length=255, blank=True, null=True) -# right = models.IntegerField(blank=True, null=True) -# created_at = models.DateTimeField() -# updated_at = models.DateTimeField() +class GuideSections(MigrateMixin): + using = 'legacy' + + type = models.CharField(max_length=255) + key_name = models.CharField(max_length=255, blank=True, null=True) + value_name = models.CharField(max_length=255, blank=True, null=True) + right = models.IntegerField(blank=True, null=True) + created_at = models.DateTimeField() + updated_at = models.DateTimeField() + + class Meta: + managed = False + db_table = 'guide_elements' -# class GuideElements(MigrateMixin): -# using = 'legacy' -# -# type = models.CharField(max_length=255) -# establishment = models.ForeignKey(Establishments, models.DO_NOTHING, blank=True, null=True) -# review = models.ForeignKey('Reviews', models.DO_NOTHING, blank=True, null=True) -# review_text = models.ForeignKey('ReviewTexts', models.DO_NOTHING, blank=True, null=True) -# wine_region = models.ForeignKey('WineLocations', models.DO_NOTHING, blank=True, null=True) -# wine = models.ForeignKey('Products', models.DO_NOTHING, blank=True, null=True) -# color = models.CharField(max_length=255, blank=True, null=True) -# order_number = models.IntegerField(blank=True, null=True) -# guide_ad = models.ForeignKey(GuideAds, models.DO_NOTHING, blank=True, null=True) -# city = models.ForeignKey(Cities, models.DO_NOTHING, blank=True, null=True) -# section = models.ForeignKey('GuideSections', models.DO_NOTHING, blank=True, null=True) -# guide_id = models.IntegerField(blank=True, null=True) -# parent_id = models.IntegerField(blank=True, null=True) -# lft = models.IntegerField() -# rgt = models.IntegerField() -# depth = models.IntegerField() -# children_count = models.IntegerField() -# created_at = models.DateTimeField() -# updated_at = models.DateTimeField() -# -# class Meta: -# managed = False -# db_table = 'guide_elements' +class GuideElements(MigrateMixin): + using = 'legacy' + + type = models.CharField(max_length=255) + establishment = models.ForeignKey('Establishments', models.DO_NOTHING, blank=True, null=True) + review = models.ForeignKey('Reviews', models.DO_NOTHING, blank=True, null=True) + review_text = models.ForeignKey('ReviewTexts', models.DO_NOTHING, blank=True, null=True) + wine_region = models.ForeignKey('WineLocations', models.DO_NOTHING, blank=True, null=True) + wine = models.ForeignKey('Products', models.DO_NOTHING, blank=True, null=True) + color = models.CharField(max_length=255, blank=True, null=True) + order_number = models.IntegerField(blank=True, null=True) + guide_ad = models.ForeignKey(GuideAds, models.DO_NOTHING, blank=True, null=True) + city = models.ForeignKey(Cities, models.DO_NOTHING, blank=True, null=True) + section = models.ForeignKey('GuideSections', models.DO_NOTHING, blank=True, null=True) + guide_id = models.IntegerField(blank=True, null=True) + parent = models.ForeignKey('self', models.DO_NOTHING, blank=True, null=True) + lft = models.IntegerField() + rgt = models.IntegerField() + depth = models.IntegerField() + children_count = models.IntegerField() + + class Meta: + managed = False + db_table = 'guide_elements' class Establishments(MigrateMixin): diff --git a/apps/transfer/serializers/partner.py b/apps/transfer/serializers/partner.py index 69cf308e..61f56dea 100644 --- a/apps/transfer/serializers/partner.py +++ b/apps/transfer/serializers/partner.py @@ -1,29 +1,53 @@ from rest_framework import serializers + +from establishment.models import Establishment from partner.models import Partner class PartnerSerializer(serializers.Serializer): - pass - # 'id', - # 'establishment_id', - # 'partnership_name', - # 'partnership_icon', - # 'backlink_url', - # 'created_at', - # 'type', - # 'starting_date', - # 'expiry_date', - # 'price_per_month', + id = serializers.IntegerField() + establishment_id = serializers.IntegerField() + partnership_name = serializers.CharField(allow_null=True) + partnership_icon = serializers.CharField(allow_null=True) + backlink_url = serializers.CharField(allow_null=True) + created_at = serializers.DateTimeField(format='%m-%d-%Y %H:%M:%S') + type = serializers.CharField(allow_null=True) + starting_date = serializers.DateField(allow_null=True) + expiry_date = serializers.DateField(allow_null=True) + price_per_month = serializers.DecimalField(max_digits=10, decimal_places=2, allow_null=True) + def validate(self, data): + data.update({ + 'old_id': data.pop('id'), + 'name': data['partnership_name'], + 'url': data.pop('backlink_url'), + 'image': self.get_image(data), + 'establishment': self.get_establishment(data), + 'type': Partner.PARTNER if data['type'] == 'Partner' else Partner.SPONSOR, + 'created': data.pop('created_at'), + }) + data.pop('partnership_icon') + data.pop('partnership_name') + data.pop('establishment_id') + return data - # def validate(self, data): - # data["image"] = partnership_to_image_url.get(data["partnership_name"]).get(data["partnership_icon"]) - # data.pop("partnership_name") - # data.pop("partnership_icon") - # return data - # - # def create(self, validated_data): - # return Partner.objects.create(**validated_data) + @staticmethod + def get_image(data): + return partnership_to_image_url.get(data['partnership_name']).get(data['partnership_icon']) + + @staticmethod + def get_establishment(data): + establishment = Establishment.objects.filter(old_id=data['establishment_id']).first() + if not establishment: + raise ValueError(f"Establishment not found with old_id {data['establishment_id']}: ") + return establishment + + def create(self, validated_data): + obj, _ = Partner.objects.update_or_create( + old_id=validated_data['old_id'], + defaults=validated_data, + ) + return obj partnership_to_image_url = { diff --git a/apps/utils/decorators.py b/apps/utils/decorators.py index c48a26c7..18bed79b 100644 --- a/apps/utils/decorators.py +++ b/apps/utils/decorators.py @@ -1,3 +1,5 @@ +from django.contrib.auth.models import AbstractUser + def with_base_attributes(cls): @@ -8,7 +10,7 @@ def with_base_attributes(cls): if request and hasattr(request, "user"): user = request.user - if user is not None: + if user is not None and isinstance(user, AbstractUser): data.update({'modified_by': user}) if not self.instance: diff --git a/apps/utils/exceptions.py b/apps/utils/exceptions.py index 37786ce7..c82ff023 100644 --- a/apps/utils/exceptions.py +++ b/apps/utils/exceptions.py @@ -135,6 +135,14 @@ class FavoritesError(exceptions.APIException): default_detail = _('Item is already in favorites.') +class CarouselError(exceptions.APIException): + """ + The exception should be thrown when the object is already in carousels. + """ + status_code = status.HTTP_400_BAD_REQUEST + default_detail = _('Item is already in carousels.') + + class PasswordResetRequestExistedError(exceptions.APIException): """ The exception should be thrown when password reset request diff --git a/apps/utils/models.py b/apps/utils/models.py index f86093af..b4b64d9f 100644 --- a/apps/utils/models.py +++ b/apps/utils/models.py @@ -5,16 +5,17 @@ from os.path import exists from django.conf import settings from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.gis.db import models +from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields.jsonb import KeyTextTransform from django.utils import timezone from django.utils.html import mark_safe from django.utils.translation import ugettext_lazy as _, get_language -from configuration.models import TranslationSettings from easy_thumbnails.fields import ThumbnailerImageField from sorl.thumbnail import get_thumbnail from sorl.thumbnail.fields import ImageField as SORLImageField +from configuration.models import TranslationSettings from utils.methods import image_path, svg_image_path from utils.validators import svg_image_validator @@ -35,10 +36,6 @@ class ProjectBaseMixin(models.Model): abstract = True -def valid(value): - print("Run") - - class TJSONField(JSONField): """Overrided JsonField.""" @@ -226,6 +223,18 @@ class SORLImageMixin(models.Model): else: return None + def get_cropped_image(self, geometry: str, quality: int, crop: str) -> dict: + cropped_image = get_thumbnail(self.image, + geometry_string=geometry, + crop=crop, + quality=quality) + return { + 'geometry_string': geometry, + 'crop_url': cropped_image.url, + 'quality': quality, + 'crop': crop + } + image_tag.short_description = _('Image') image_tag.allow_tags = True @@ -435,4 +444,12 @@ class HasTagsMixin(models.Model): abstract = True +class FavoritesMixin: + """Append favorites_for_user property.""" + + @property + def favorites_for_users(self): + return self.favorites.aggregate(arr=ArrayAgg('user_id')).get('arr') + + timezone.datetime.now().date().isoformat() \ No newline at end of file diff --git a/apps/utils/pagination.py b/apps/utils/pagination.py index ac83f4f2..199d55b6 100644 --- a/apps/utils/pagination.py +++ b/apps/utils/pagination.py @@ -3,8 +3,8 @@ from base64 import b64encode from urllib import parse as urlparse from django.conf import settings -from rest_framework.pagination import PageNumberPagination, CursorPagination - +from rest_framework.pagination import CursorPagination, PageNumberPagination +from django_elasticsearch_dsl_drf.pagination import PageNumberPagination as ESPagination class ProjectPageNumberPagination(PageNumberPagination): """Customized pagination class.""" @@ -48,6 +48,40 @@ class ProjectMobilePagination(ProjectPageNumberPagination): return self.page.previous_page_number() +class ESDocumentPagination(ESPagination): + """Pagination class for ES results. (includes facets)""" + page_size_query_param = 'page_size' + + def get_next_link(self): + """Get next link method.""" + if not self.page.has_next(): + return None + return self.page.next_page_number() + + def get_previous_link(self): + """Get previous link method.""" + if not self.page.has_previous(): + return None + return self.page.previous_page_number() + + def get_facets(self, page=None): + """Get facets. + + :param page: + :return: + """ + if page is None: + page = self.page + + if hasattr(self, 'facets_computed'): + ret = {} + for filter_field, bucket_data in self.facets_computed.items(): + ret.update({filter_field: bucket_data.__dict__['_d_']}) + return ret + elif hasattr(page, 'facets') and hasattr(page.facets, '_d_'): + return page.facets._d_ + + class EstablishmentPortionPagination(ProjectMobilePagination): """ Pagination for app establishments with limit page size equal to 12 diff --git a/apps/utils/permissions.py b/apps/utils/permissions.py index 86a4be6f..30055c44 100644 --- a/apps/utils/permissions.py +++ b/apps/utils/permissions.py @@ -100,7 +100,10 @@ class IsStandardUser(IsGuest): if hasattr(obj, 'user'): rules = [ - obj.user == request.user and obj.user.email_confirmed, + obj.user == request.user + and obj.user.email_confirmed + and request.user.is_authenticated, + super().has_object_permission(request, view, obj) ] @@ -117,31 +120,50 @@ class IsContentPageManager(IsStandardUser): rules = [ super().has_permission(request, view) ] - # and request.user.email_confirmed, - if hasattr(request, 'user'): - role = Role.objects.filter(role=Role.CONTENT_PAGE_MANAGER, - country_id=request.country_id) \ - .first() # 'Comments moderator' - rules = [ - UserRole.objects.filter(user=request.user, role=role).exists(), - # and obj.user != request.user, - super().has_permission(request, view) - ] + if hasattr(request, 'user'): + if hasattr(request.data, 'site_id'): + role = Role.objects.filter(role=Role.CONTENT_PAGE_MANAGER, + site_id=request.data.site_id,) \ + .first() + + rules = [ + UserRole.objects.filter(user=request.user, role=role).exists(), + super().has_permission(request, view) + ] + elif hasattr(request.data, 'country_id'): + role = Role.objects.filter(role=Role.CONTENT_PAGE_MANAGER, + country_id=request.data.country_id) \ + .first() + + rules = [ + UserRole.objects.filter(user=request.user, role=role).exists(), + super().has_permission(request, view) + ] + return any(rules) def has_object_permission(self, request, view, obj): # Read permissions are allowed to any request. + if hasattr(obj, 'site_id'): + role = Role.objects.filter(role=Role.CONTENT_PAGE_MANAGER, + site_id=obj.site_id) \ + .first() - role = Role.objects.filter(role=Role.CONTENT_PAGE_MANAGER, - country_id=obj.country_id) \ - .first() # 'Comments moderator' + rules = [ + UserRole.objects.filter(user=request.user, role=role).exists(), + super().has_object_permission(request, view, obj) + ] + elif hasattr(obj, 'country_id'): + role = Role.objects.filter(role=Role.CONTENT_PAGE_MANAGER, + country_id=obj.country_id) \ + .first() + + rules = [ + UserRole.objects.filter(user=request.user, role=role).exists(), + super().has_object_permission(request, view, obj) + ] - rules = [ - UserRole.objects.filter(user=request.user, role=role).exists(), - # and obj.user != request.user, - super().has_object_permission(request, view, obj) - ] return any(rules) @@ -150,36 +172,55 @@ class IsCountryAdmin(IsStandardUser): Object-level permission to only allow owners of an object to edit it. Assumes the model instance has an `owner` attribute. """ - def has_permission(self, request, view): rules = [ super().has_permission(request, view) ] # and request.user.email_confirmed, - if hasattr(request.data, 'user') and hasattr(request.data, 'country_id'): - # Read permissions are allowed to any request. + if hasattr(request.data, 'user'): + if hasattr(request.data, 'site_id'): + # Read permissions are allowed to any request. + + role = Role.objects.filter(role=Role.COUNTRY_ADMIN, + site_id=request.data.site_id) \ + .first() + + rules = [ + UserRole.objects.filter(user=request.user, role=role).exists(), + super().has_permission(request, view) + ] + elif hasattr(request.data, 'country_id'): role = Role.objects.filter(role=Role.COUNTRY_ADMIN, country_id=request.data.country_id) \ - .first() # 'Comments moderator' + .first() rules = [ UserRole.objects.filter(user=request.user, role=role).exists(), super().has_permission(request, view) - ] + ] return any(rules) def has_object_permission(self, request, view, obj): # Read permissions are allowed to any request. - role = Role.objects.filter(role=Role.COUNTRY_ADMIN, - country_id=obj.country_id) \ - .first() # 'Comments moderator' + if hasattr(obj, 'site_id'): + role = Role.objects.filter(role=Role.COUNTRY_ADMIN, + site_id=obj.site_id) \ + .first() + + rules = [ + super().has_object_permission(request, view, obj) + ] + elif hasattr(obj, 'country_id'): + role = Role.objects.filter(role=Role.COUNTRY_ADMIN, + country_id=obj.country_id) \ + .first() + + rules = [ + super().has_object_permission(request, view, obj) + ] - rules = [ - super().has_object_permission(request, view, obj) - ] - # and request.user.email_confirmed, if hasattr(request, 'user') and request.user.is_authenticated: rules = [ UserRole.objects.filter(user=request.user, role=role).exists(), @@ -206,13 +247,12 @@ class IsCommentModerator(IsStandardUser): super().has_permission(request, view) ] - # and request.user.email_confirmed, - if hasattr(request.data, 'user') and hasattr(request.data, 'country_id'): + if any(rules) and hasattr(request.data, 'site_id'): # Read permissions are allowed to any request. role = Role.objects.filter(role=Role.COMMENTS_MODERATOR, - country_id=request.data.country_id) \ - .first() # 'Comments moderator' + site_id=request.data.site_id) \ + .first() rules = [ UserRole.objects.filter(user=request.user, role=role).exists(), @@ -222,16 +262,22 @@ class IsCommentModerator(IsStandardUser): return any(rules) def has_object_permission(self, request, view, obj): - # Read permissions are allowed to any request. - role = Role.objects.filter(role=Role.COMMENTS_MODERATOR, - country_id=obj.country_id) \ - .first() # 'Comments moderator' rules = [ - UserRole.objects.filter(user=request.user, role=role).exists() and - obj.user != request.user, super().has_object_permission(request, view, obj) ] + + if request.user.is_authenticated: + + role = Role.objects.filter(role=Role.COMMENTS_MODERATOR, + site_id=obj.site_id) \ + .first() # 'Comments moderator' + + rules = [ + UserRole.objects.filter(user=request.user, role=role).exists() and + obj.user != request.user, + super().has_object_permission(request, view, obj) + ] return any(rules) @@ -242,30 +288,40 @@ class IsEstablishmentManager(IsStandardUser): super().has_permission(request, view) ] - # and request.user.email_confirmed, - if hasattr(request.data, 'user') and hasattr(request.data, 'establishment_id'): - role = Role.objects.filter(role=Role.ESTABLISHMENT_MANAGER) \ - .first() # 'Comments moderator' + if hasattr(request.data, 'user'): + if hasattr(request.data, 'establishment_id'): + role = Role.objects.filter(role=Role.ESTABLISHMENT_MANAGER) \ + .first() - rules = [ - UserRole.objects.filter(user=request.user, role=role, - establishment_id=request.data.establishment_id - ).exists(), - super().has_permission(request, view) - ] + rules = [ + UserRole.objects.filter(user=request.user, role=role, + establishment_id=request.data.establishment_id + ).exists(), + super().has_permission(request, view) + ] return any(rules) def has_object_permission(self, request, view, obj): - role = Role.objects.filter(role=Role.ESTABLISHMENT_MANAGER) \ - .first() # 'Comments moderator' rules = [ - UserRole.objects.filter(user=request.user, role=role, - establishment_id=obj.establishment_id - ).exists(), - super().has_object_permission(request, view, obj) + # special! + super().has_permission(request, view) + # super().has_object_permission(request, view, obj) ] + role = Role.objects.filter(role=Role.ESTABLISHMENT_MANAGER) \ + .first() + + if hasattr(obj, 'establishment_id'): + rules = [ + UserRole.objects.filter(user=request.user, role=role, + establishment_id=obj.establishment_id + ).exists(), + # special! + super().has_permission(request, view) + # super().has_object_permission(request, view, obj) + ] + return any(rules) @@ -277,13 +333,13 @@ class IsReviewerManager(IsStandardUser): ] # and request.user.email_confirmed, - if hasattr(request.data, 'user') and hasattr(request.data, 'country_id'): + if hasattr(request.data, 'user') and hasattr(request.data, 'site_id'): role = Role.objects.filter(role=Role.REVIEWER_MANGER) \ - .first() # 'Comments moderator' + .first() rules = [ UserRole.objects.filter(user=request.user, role=role, - establishment_id=request.data.country_id + establishment_id=request.data.site_id ).exists(), super().has_permission(request, view) ] diff --git a/apps/utils/serializers.py b/apps/utils/serializers.py index b78c202c..f55b69bc 100644 --- a/apps/utils/serializers.py +++ b/apps/utils/serializers.py @@ -2,10 +2,11 @@ import pytz from django.core import exceptions from rest_framework import serializers -from utils import models -from translation.models import Language + from favorites.models import Favorites -from gallery.models import Image +from main.models import Carousel +from translation.models import Language +from utils import models class EmptySerializer(serializers.Serializer): @@ -80,7 +81,6 @@ class FavoritesCreateSerializer(serializers.ModelSerializer): """Serializer to favorite object.""" class Meta: - """Serializer for model Comment.""" model = Favorites fields = [ 'id', @@ -101,6 +101,24 @@ class FavoritesCreateSerializer(serializers.ModelSerializer): return self.request.parser_context.get('kwargs').get('slug') +class CarouselCreateSerializer(serializers.ModelSerializer): + """Carousel to favorite object.""" + + class Meta: + model = Carousel + fields = [ + 'id', + ] + + @property + def request(self): + return self.context.get('request') + + @property + def pk(self): + return self.request.parser_context.get('kwargs').get('pk') + + class RecursiveFieldSerializer(serializers.Serializer): def to_representation(self, value): serializer = self.parent.parent.__class__(value, context=self.context) diff --git a/apps/utils/tests/tests_permissions.py b/apps/utils/tests/tests_permissions.py index 3bba7b7d..18eeb95b 100644 --- a/apps/utils/tests/tests_permissions.py +++ b/apps/utils/tests/tests_permissions.py @@ -5,15 +5,13 @@ from translation.models import Language class BasePermissionTests(APITestCase): def setUp(self): - self.lang = Language.objects.get( + self.lang, created = Language.objects.get_or_create( title='Russia', locale='ru-RU' ) - self.lang.save() - self.country_ru = Country.objects.get( + self.country_ru, created = Country.objects.get_or_create( name={"en-GB": "Russian"} ) - self.country_ru.save() diff --git a/apps/utils/tests/tests_translated.py b/apps/utils/tests/tests_translated.py index 557c8b5d..6249ebd1 100644 --- a/apps/utils/tests/tests_translated.py +++ b/apps/utils/tests/tests_translated.py @@ -40,8 +40,7 @@ class TranslateFieldTests(BaseTestCase): self.news_type = NewsType.objects.create(name="Test news type") self.news_type.save() - - self.country_ru = Country.objects.get( + self.country_ru, created = Country.objects.get_or_create( name={"en-GB": "Russian"} ) diff --git a/apps/utils/thumbnail_engine.py b/apps/utils/thumbnail_engine.py new file mode 100644 index 00000000..f55d58f8 --- /dev/null +++ b/apps/utils/thumbnail_engine.py @@ -0,0 +1,19 @@ +"""Overridden thumbnail engine.""" +from sorl.thumbnail.engines.pil_engine import Engine as PILEngine + + +class GMEngine(PILEngine): + + def create(self, image, geometry, options): + """ + Processing conductor, returns the thumbnail as an image engine instance + """ + image = self.cropbox(image, geometry, options) + image = self.orientation(image, geometry, options) + image = self.colorspace(image, geometry, options) + image = self.remove_border(image, options) + image = self.crop(image, geometry, options) + image = self.rounded(image, geometry, options) + image = self.blur(image, geometry, options) + image = self.padding(image, geometry, options) + return image diff --git a/apps/utils/views.py b/apps/utils/views.py index a8580f59..478a3cb2 100644 --- a/apps/utils/views.py +++ b/apps/utils/views.py @@ -2,12 +2,13 @@ from collections import namedtuple from django.conf import settings from django.db.transaction import on_commit -from rest_framework import generics -from rest_framework import status +from django.shortcuts import get_object_or_404 +from rest_framework import generics, status from rest_framework.decorators import action from rest_framework.response import Response from gallery.tasks import delete_image +from search_indexes.documents import es_update # JWT @@ -69,22 +70,12 @@ class JWTGenericViewMixin: def _put_cookies_in_response(self, cookies: list, response: Response): """Update COOKIES in response from namedtuple""" for cookie in cookies: - # todo: remove config for develop - from os import environ - configuration = environ.get('SETTINGS_CONFIGURATION', None) - if configuration == 'development' or configuration == 'stage': - response.set_cookie(key=cookie.key, - value=cookie.value, - secure=cookie.secure, - httponly=cookie.http_only, - max_age=cookie.max_age, - domain='.id-east.ru') - else: - response.set_cookie(key=cookie.key, - value=cookie.value, - secure=cookie.secure, - httponly=cookie.http_only, - max_age=cookie.max_age,) + response.set_cookie(key=cookie.key, + value=cookie.value, + secure=cookie.secure, + httponly=cookie.http_only, + max_age=cookie.max_age, + domain=settings.COOKIE_DOMAIN) return response def _get_tokens_from_cookies(self, request, cookies: dict = None): @@ -125,6 +116,62 @@ class CreateDestroyGalleryViewMixin(generics.CreateAPIView, return Response(status=status.HTTP_204_NO_CONTENT) +class BaseCreateDestroyMixinView(generics.CreateAPIView, generics.DestroyAPIView): + """Base Create Destroy mixin.""" + + _model = None + serializer_class = None + lookup_field = 'slug' + + def get_base_object(self): + return get_object_or_404(self._model, slug=self.kwargs['slug']) + + def es_update_base_object(self): + es_update(self.get_base_object()) + + def perform_create(self, serializer): + serializer.save() + self.es_update_base_object() + + def perform_destroy(self, instance): + instance.delete() + self.es_update_base_object() + + +class FavoritesCreateDestroyMixinView(BaseCreateDestroyMixinView): + """Favorites Create Destroy mixin.""" + + def get_object(self): + """ + Returns the object the view is displaying. + """ + obj = self.get_base_object() + favorites = get_object_or_404(obj.favorites.filter(user=self.request.user)) + # May raise a permission denied + self.check_object_permissions(self.request, favorites) + return favorites + + +class CarouselCreateDestroyMixinView(BaseCreateDestroyMixinView): + """Carousel Create Destroy mixin.""" + + lookup_field = 'id' + + def get_base_object(self): + return get_object_or_404(self._model, id=self.kwargs['pk']) + + def get_object(self): + """ + Returns the object the view is displaying. + """ + obj = self.get_base_object() + carousels = get_object_or_404(obj.carousels.all()) + # May raise a permission denied + # TODO: возможно нужны пермишены + # self.check_object_permissions(self.request, carousels) + return carousels + + # BackOffice user`s views & viewsets class BindObjectMixin: """Bind object mixin.""" @@ -149,4 +196,4 @@ class BindObjectMixin: return Response(serializer.data, status=status.HTTP_201_CREATED) elif request.method == 'DELETE': self.perform_unbinding(serializer) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/docker-compose.yml b/docker-compose.yml index 48fea8eb..12217d4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: # Redis redis: - image: redis:2.8.23 + image: redis:latest # Celery worker: diff --git a/load_geiopdb.sh b/load_geiopdb.sh deleted file mode 100755 index 48d16af1..00000000 --- a/load_geiopdb.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -DB_CITY_URL="https://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz" -DB_COUNTRY_URL="https://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.tar.gz" -DIR_PATH="geoip_db" -ARCH_PATH="archive" - -mkdir -p $DIR_PATH -cd $DIR_PATH - -mkdir -p $ARCH_PATH - -find . -not -path "./$ARCH_PATH/*" -type f -name "*.mmdb" -exec mv -t "./$ARCH_PATH/" {} \+ - -filename=$(basename $DB_CITY_URL) -wget -O $filename $DB_CITY_URL -tar xzvf "$filename" - -filename=$(basename $DB_COUNTRY_URL) -wget -O $filename $DB_COUNTRY_URL -tar xzvf "$filename" - -find . -mindepth 1 -type f -name "*.mmdb" -not -path "./$ARCH_PATH/*" -exec mv -t . {} \+ diff --git a/make_data_migration.sh b/make_data_migration.sh new file mode 100755 index 00000000..c92f74e7 --- /dev/null +++ b/make_data_migration.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +./manage.py transfer -a +#./manage.py transfer -d +./manage.py transfer -e +./manage.py transfer --fill_city_gallery +./manage.py transfer -l +./manage.py transfer --product +./manage.py transfer --souvenir +./manage.py transfer --establishment_note +./manage.py transfer --product_note +./manage.py transfer --wine_characteristics +./manage.py transfer --inquiries +./manage.py transfer --assemblage +./manage.py transfer --purchased_plaques \ No newline at end of file diff --git a/project/settings/amazon_s3.py b/project/settings/amazon_s3.py index c793dd77..b602618d 100644 --- a/project/settings/amazon_s3.py +++ b/project/settings/amazon_s3.py @@ -13,9 +13,9 @@ AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} AWS_S3_ADDRESSING_STYLE = 'path' # Static settings -# PUBLIC_STATIC_LOCATION = 'static' -# STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_STATIC_LOCATION}/' -# STATICFILES_STORAGE = 'project.storage_backends.PublicStaticStorage' +PUBLIC_STATIC_LOCATION = 'static-dev' +STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_STATIC_LOCATION}/' +STATICFILES_STORAGE = 'project.storage_backends.PublicStaticStorage' # Public media settings MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/' diff --git a/project/settings/base.py b/project/settings/base.py index 2ae87a0b..6ed2dcab 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -254,6 +254,17 @@ AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', ) + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + }, + 'es_queue': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': 'redis://redis:6379/2' + } +} + # Override default OAuth2 namespace DRFSO2_URL_NAMESPACE = 'auth' SOCIAL_AUTH_URL_NAMESPACE = 'auth' @@ -399,6 +410,13 @@ SORL_THUMBNAIL_ALIASES = { 'establishment_xlarge': {'geometry_string': '640x360', 'crop': 'center'}, 'establishment_detail': {'geometry_string': '2048x1152', 'crop': 'center'}, 'establishment_original': {'geometry_string': '1920x1080', 'crop': 'center'}, + 'city_xsmall': {'geometry_string': '70x70', 'crop': 'center'}, + 'city_small': {'geometry_string': '140x140', 'crop': 'center'}, + 'city_medium': {'geometry_string': '280x280', 'crop': 'center'}, + 'city_large': {'geometry_string': '280x280', 'crop': 'center'}, + 'city_xlarge': {'geometry_string': '560x560', 'crop': 'center'}, + 'city_detail': {'geometry_string': '1120x1120', 'crop': 'center'}, + 'city_original': {'geometry_string': '2048x1536', 'crop': 'center'}, } @@ -487,7 +505,6 @@ LIMITING_QUERY_OBJECTS = QUERY_OUTPUT_OBJECTS * 3 # GEO # A Spatial Reference System Identifier GEO_DEFAULT_SRID = 4326 -GEOIP_PATH = os.path.join(PROJECT_ROOT, 'geoip_db') # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.2/howto/static-files/ @@ -509,8 +526,11 @@ FALLBACK_LOCALE = 'en-GB' # TMP TODO remove it later # Временный хардкод для демонстрации > 15 ноября, потом удалить! -CAROUSEL_ITEMS = [230, 231, 232] +CAROUSEL_ITEMS = [465] ESTABLISHMENT_CHOSEN_TAGS = ['gastronomic', 'en_vogue', 'terrace', 'streetfood', 'business', 'bar_cocktail', 'brunch', 'pop'] NEWS_CHOSEN_TAGS = ['eat', 'drink', 'cook', 'style', 'international', 'event', 'partnership'] INTERNATIONAL_COUNTRY_CODES = ['www', 'main', 'next'] +THUMBNAIL_ENGINE = 'utils.thumbnail_engine.GMEngine' + +COOKIE_DOMAIN = None diff --git a/project/settings/development.py b/project/settings/development.py index 06f1199b..f850aad7 100644 --- a/project/settings/development.py +++ b/project/settings/development.py @@ -18,6 +18,17 @@ SITE_DOMAIN_URI = 'id-east.ru' DOMAIN_URI = 'gm.id-east.ru' +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + }, + 'es_queue': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': 'redis://localhost:6379/2' + } +} + + # ELASTICSEARCH SETTINGS ELASTICSEARCH_DSL = { 'default': { @@ -60,3 +71,5 @@ INSTALLED_APPS.append('transfer.apps.TransferConfig') BROKER_URL = 'redis://localhost:6379/1' CELERY_RESULT_BACKEND = BROKER_URL CELERY_BROKER_URL = BROKER_URL + +COOKIE_DOMAIN = '.id-east.ru' diff --git a/project/settings/local.py b/project/settings/local.py index 959e6149..c8974c40 100644 --- a/project/settings/local.py +++ b/project/settings/local.py @@ -5,18 +5,15 @@ import sys ALLOWED_HOSTS = ['*', ] - SEND_SMS = False SMS_CODE_SHOW = True USE_CELERY = True - SCHEMA_URI = 'http' DEFAULT_SUBDOMAIN = 'www' SITE_DOMAIN_URI = 'testserver.com:8000' DOMAIN_URI = '0.0.0.0:8000' - # CELERY # RabbitMQ # BROKER_URL = 'amqp://rabbitmq:5672' @@ -25,19 +22,15 @@ BROKER_URL = 'redis://redis:6379/1' CELERY_RESULT_BACKEND = BROKER_URL CELERY_BROKER_URL = BROKER_URL - # MEDIA MEDIA_URL = f'{SCHEMA_URI}://{DOMAIN_URI}/{MEDIA_LOCATION}/' MEDIA_ROOT = os.path.join(PUBLIC_ROOT, MEDIA_LOCATION) - # SORL thumbnails THUMBNAIL_DEBUG = True - # ADDED TRANSFER APP -INSTALLED_APPS.append('transfer.apps.TransferConfig') - +# INSTALLED_APPS.append('transfer.apps.TransferConfig') # DATABASES DATABASES.update({ @@ -80,15 +73,14 @@ LOGGING = { 'py.warnings': { 'handlers': ['console'], }, - 'django.db.backends': { - 'handlers': ['console', ], - 'level': 'DEBUG', - 'propagate': False, - }, + # 'django.db.backends': { + # 'handlers': ['console', ], + # 'level': 'DEBUG', + # 'propagate': False, + # }, } } - # ELASTICSEARCH SETTINGS ELASTICSEARCH_DSL = { 'default': { @@ -101,8 +93,9 @@ ELASTICSEARCH_INDEX_NAMES = { 'search_indexes.documents.establishment': 'local_establishment', 'search_indexes.documents.product': 'local_product', } - +ELASTICSEARCH_DSL_AUTOSYNC = False TESTING = sys.argv[1:2] == ['test'] if TESTING: ELASTICSEARCH_INDEX_NAMES = {} +ELASTICSEARCH_DSL_AUTOSYNC = False diff --git a/project/settings/production.py b/project/settings/production.py index 3192acea..7ef2dc62 100644 --- a/project/settings/production.py +++ b/project/settings/production.py @@ -4,6 +4,11 @@ from .amazon_s3 import * import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration + +PUBLIC_STATIC_LOCATION = 'static' +STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_STATIC_LOCATION}/' +STATICFILES_STORAGE = 'project.storage_backends.PublicStaticStorage' + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = False @@ -20,7 +25,6 @@ DEFAULT_SUBDOMAIN = 'www' SITE_DOMAIN_URI = 'gaultmillau.com' DOMAIN_URI = 'next.gaultmillau.com' - # ELASTICSEARCH SETTINGS ELASTICSEARCH_DSL = { 'default': { @@ -28,20 +32,17 @@ ELASTICSEARCH_DSL = { } } - ELASTICSEARCH_INDEX_NAMES = { 'search_indexes.documents.news': 'development_news', # temporarily disabled 'search_indexes.documents.establishment': 'development_establishment', 'search_indexes.documents.product': 'development_product', } - sentry_sdk.init( dsn="https://35d9bb789677410ab84a822831c6314f@sentry.io/1729093", integrations=[DjangoIntegration()] ) - BROKER_URL = 'redis://redis:6379/1' CELERY_RESULT_BACKEND = BROKER_URL CELERY_BROKER_URL = BROKER_URL @@ -51,4 +52,6 @@ GUESTONLINE_SERVICE = 'https://api.guestonline.fr/' GUESTONLINE_TOKEN = '' LASTABLE_SERVICE = '' LASTABLE_TOKEN = '' -LASTABLE_PROXY = '' \ No newline at end of file +LASTABLE_PROXY = '' + +COOKIE_DOMAIN = '.gaultmillau.com' diff --git a/project/settings/stage.py b/project/settings/stage.py index 49a7ae0f..95285034 100644 --- a/project/settings/stage.py +++ b/project/settings/stage.py @@ -13,7 +13,6 @@ DEFAULT_SUBDOMAIN = 'www' SITE_DOMAIN_URI = 'id-east.ru' DOMAIN_URI = 'gm-stage.id-east.ru' - # ELASTICSEARCH SETTINGS ELASTICSEARCH_DSL = { 'default': { @@ -21,8 +20,9 @@ ELASTICSEARCH_DSL = { } } - ELASTICSEARCH_INDEX_NAMES = { # 'search_indexes.documents.news': 'stage_news', #temporarily disabled 'search_indexes.documents.establishment': 'stage_establishment', } + +COOKIE_DOMAIN = '.id-east.ru' diff --git a/project/templates/account/change_email.html b/project/templates/account/change_email.html index efe92766..9f412a8f 100644 --- a/project/templates/account/change_email.html +++ b/project/templates/account/change_email.html @@ -19,7 +19,7 @@
diff --git a/project/templates/account/password_change_email.html b/project/templates/account/password_change_email.html index 77cad83f..d82eb967 100644 --- a/project/templates/account/password_change_email.html +++ b/project/templates/account/password_change_email.html @@ -19,7 +19,7 @@
diff --git a/project/templates/account/password_reset_email.html b/project/templates/account/password_reset_email.html index 4d61147d..c290c3c6 100644 --- a/project/templates/account/password_reset_email.html +++ b/project/templates/account/password_reset_email.html @@ -19,7 +19,7 @@
diff --git a/project/templates/authorization/confirm_email.html b/project/templates/authorization/confirm_email.html index c05c85b0..8b1332d0 100644 --- a/project/templates/authorization/confirm_email.html +++ b/project/templates/authorization/confirm_email.html @@ -19,7 +19,7 @@
diff --git a/project/templates/news/news_email.html b/project/templates/news/news_email.html index c2ae227c..27b8086c 100644 --- a/project/templates/news/news_email.html +++ b/project/templates/news/news_email.html @@ -18,7 +18,7 @@
diff --git a/project/urls/back.py b/project/urls/back.py index fdd3d10a..e7e2b43b 100644 --- a/project/urls/back.py +++ b/project/urls/back.py @@ -16,4 +16,5 @@ urlpatterns = [ path('re_blocks/', include(('advertisement.urls.back', 'advertisement'), namespace='advertisement')), path('main/', include('main.urls.back')), + path('partner/', include('partner.urls.back')), ] diff --git a/requirements/base.txt b/requirements/base.txt index 8ce99c84..94e7ca27 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -2,20 +2,20 @@ Django[bcrypt]==2.2.7 psycopg2-binary==2.8.3 pytz==2019.1 sqlparse==0.3.0 -requests -django-solo -easy-thumbnails -fcm-django -django-easy-select2 -bootstrap-admin +requests==2.22.0 +django-solo==1.1.3 +easy-thumbnails==2.6 +fcm-django==0.3.2 +django-easy-select2==1.5.6 +bootstrap-admin==0.4.3 drf-yasg==1.16.0 -timezonefinder +timezonefinder==4.1.0 PySocks!=1.5.7,>=1.5.6; djangorestframework==3.9.4 -markdown +Markdown==3.1.1 django-filter==2.1.0 -djangorestframework-xml +djangorestframework-xml==1.4.0 geoip2==2.9.0 pycountry==19.8.18 django-phonenumber-field[phonenumbers]==2.1.0 @@ -54,5 +54,6 @@ PyYAML==5.1.2 # temp solution redis==3.2.0 +django_redis==4.10.0 # used byes indexing cache kombu==4.6.6 celery==4.3.0 diff --git a/run_celery_beat.sh b/run_celery_beat.sh index 75de842d..e683ef9e 100755 --- a/run_celery_beat.sh +++ b/run_celery_beat.sh @@ -1,4 +1,4 @@ #!/bin/sh sleep 5 -celery -A project worker -B -l info \ No newline at end of file +celery -A project beat -l info \ No newline at end of file