diff --git a/apps/account/models.py b/apps/account/models.py index 13943878..3eb07722 100644 --- a/apps/account/models.py +++ b/apps/account/models.py @@ -243,6 +243,20 @@ class User(AbstractUser): template_name=settings.CHANGE_EMAIL_TEMPLATE, context=context) + @property + def favorite_establishment_ids(self): + """Return establishment IDs that in favorites for current user.""" + return self.favorites.by_content_type(app_label='establishment', + model='establishment')\ + .values_list('object_id', flat=True) + + @property + def favorite_recipe_ids(self): + """Return recipe IDs that in favorites for current user.""" + return self.favorites.by_content_type(app_label='recipe', + model='recipe')\ + .values_list('object_id', flat=True) + class UserRole(ProjectBaseMixin): """UserRole model.""" diff --git a/apps/comment/serializers/back.py b/apps/comment/serializers/back.py index d0cd47c8..325086c0 100644 --- a/apps/comment/serializers/back.py +++ b/apps/comment/serializers/back.py @@ -6,4 +6,4 @@ from rest_framework import serializers class CommentBaseSerializer(serializers.ModelSerializer): class Meta: model = models.Comment - fields = ('id', 'text', 'mark', 'user') \ No newline at end of file + fields = ('id', 'text', 'mark', 'user', 'object_id', 'content_type') \ No newline at end of file diff --git a/apps/comment/tests.py b/apps/comment/tests.py index bd22214a..ff3bd393 100644 --- a/apps/comment/tests.py +++ b/apps/comment/tests.py @@ -30,18 +30,48 @@ class CommentModeratorPermissionTests(BasePermissionTests): ) self.userRole.save() - content_type = ContentType.objects.get(app_label='location', model='country') + 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=content_type.id, + object_id=self.country_ru.pk, + content_type_id=self.content_type.id, country=self.country_ru ) self.comment.save() self.url = reverse('back:comment:comment-crud', kwargs={"id": self.comment.id}) + def test_post(self): + self.url = reverse('back:comment:comment-list-create') + + comment = { + "text": "Test comment POST", + "user": self.user_test["user"].id, + "object_id": self.country_ru.pk, + "content_type": self.content_type.id, + "country_id": self.country_ru.id + } + + response = self.client.post(self.url, format='json', data=comment) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + comment = { + "text": "Test comment POST moder", + "user": self.moderator.id, + "object_id": self.country_ru.id, + "content_type": self.content_type.id, + "country_id": self.country_ru.id + } + + tokens = User.create_jwt_tokens(self.moderator) + self.client.cookies = SimpleCookie( + {'access_token': tokens.get('access_token'), + 'refresh_token': tokens.get('access_token')}) + + response = self.client.post(self.url, format='json', data=comment) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + def test_put_moderator(self): tokens = User.create_jwt_tokens(self.moderator) self.client.cookies = SimpleCookie( @@ -52,7 +82,9 @@ class CommentModeratorPermissionTests(BasePermissionTests): "id": self.comment.id, "text": "test text moderator", "mark": 1, - "user": self.moderator.id + "user": self.moderator.id, + "object_id": self.comment.country_id, + "content_type": self.content_type.id } response = self.client.put(self.url, data=data, format='json') @@ -60,7 +92,7 @@ class CommentModeratorPermissionTests(BasePermissionTests): def test_get(self): response = self.client.get(self.url, format='json') - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_put_other_user(self): other_user = User.objects.create_user(username='test', @@ -99,9 +131,10 @@ class CommentModeratorPermissionTests(BasePermissionTests): "id": self.comment.id, "text": "test text moderator", "mark": 1, - "user": super_user.id + "user": super_user.id, + "object_id": self.country_ru.id, + "content_type": self.content_type.id, } - response = self.client.put(self.url, data=data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/apps/comment/views/back.py b/apps/comment/views/back.py index 2895fdbe..3b96cbd2 100644 --- a/apps/comment/views/back.py +++ b/apps/comment/views/back.py @@ -8,12 +8,13 @@ class CommentLstView(generics.ListCreateAPIView): """Comment list create view.""" serializer_class = serializers.CommentBaseSerializer queryset = models.Comment.objects.all() - permission_classes = [permissions.IsAuthenticatedOrReadOnly,] + 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 = [IsCountryAdmin | IsCommentModerator] lookup_field = 'id' diff --git a/apps/establishment/admin.py b/apps/establishment/admin.py index 8acadc02..20731a39 100644 --- a/apps/establishment/admin.py +++ b/apps/establishment/admin.py @@ -7,6 +7,7 @@ from comment.models import Comment from utils.admin import BaseModelAdminMixin from establishment import models from main.models import Award +from product.models import Product from review import models as review_models @@ -47,15 +48,18 @@ class CommentInline(GenericTabularInline): extra = 0 +class ProductInline(admin.TabularInline): + model = Product + extra = 0 + + @admin.register(models.Establishment) class EstablishmentAdmin(BaseModelAdminMixin, admin.ModelAdmin): """Establishment admin.""" list_display = ['id', '__str__', 'image_tag', ] inlines = [ AwardInline, ContactPhoneInline, ContactEmailInline, - ReviewInline, CommentInline] - raw_id_fields = ('address',) - fields = ['old_id', 'name'] + ReviewInline, CommentInline, ProductInline] @admin.register(models.Position) diff --git a/apps/establishment/migrations/0044_position_index_name.py b/apps/establishment/migrations/0044_position_index_name.py new file mode 100644 index 00000000..0bf423d1 --- /dev/null +++ b/apps/establishment/migrations/0044_position_index_name.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-24 14:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('establishment', '0043_establishment_currency'), + ] + + operations = [ + migrations.AddField( + model_name='position', + name='index_name', + field=models.CharField(db_index=True, max_length=255, null=True, unique=True, verbose_name='Index name'), + ), + ] diff --git a/apps/establishment/models.py b/apps/establishment/models.py index 7aea9dc5..981f9bb3 100644 --- a/apps/establishment/models.py +++ b/apps/establishment/models.py @@ -121,6 +121,11 @@ class EstablishmentQuerySet(models.QuerySet): def with_type_related(self): return self.prefetch_related('establishment_subtypes') + def with_es_related(self): + """Return qs with related for ES indexing objects.""" + return self.select_related('address', 'establishment_type', 'address__city', 'address__city__country').\ + prefetch_related('tags', 'schedule') + def search(self, value, locale=None): """Search text in JSON fields.""" if locale is not None: @@ -244,14 +249,12 @@ class EstablishmentQuerySet(models.QuerySet): def annotate_in_favorites(self, user): """Annotate flag in_favorites""" - favorite_establishments = [] + favorite_establishment_ids = [] if user.is_authenticated: - favorite_establishments = user.favorites.by_content_type(app_label='establishment', - model='establishment') \ - .values_list('object_id', flat=True) + favorite_establishment_ids = user.favorite_establishment_ids return self.annotate(in_favorites=Case( When( - id__in=favorite_establishments, + id__in=favorite_establishment_ids, then=True), default=False, output_field=models.BooleanField(default=False))) @@ -483,6 +486,11 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): """ return self.id + @property + def wines(self): + """Return list products with type wine""" + return self.products.wines() + class Position(BaseAttributes, TranslatedFieldsMixin): """Position model.""" @@ -494,6 +502,9 @@ class Position(BaseAttributes, TranslatedFieldsMixin): priority = models.IntegerField(unique=True, null=True, default=None) + index_name = models.CharField(max_length=255, db_index=True, unique=True, + null=True, verbose_name=_('Index name')) + class Meta: """Meta class.""" diff --git a/apps/establishment/serializers/common.py b/apps/establishment/serializers/common.py index 2846c5c8..3f42c15b 100644 --- a/apps/establishment/serializers/common.py +++ b/apps/establishment/serializers/common.py @@ -5,15 +5,14 @@ from rest_framework import serializers from comment import models as comment_models from comment.serializers import common as comment_serializers from establishment import models -from favorites.models import Favorites from location.serializers import AddressBaseSerializer from main.serializers import AwardSerializer, CurrencySerializer from review import models as review_models from tag.serializers import TagBaseSerializer from timetable.serialziers import ScheduleRUDSerializer from utils import exceptions as utils_exceptions -from utils.serializers import ProjectModelSerializer -from utils.serializers import TranslatedField +from utils.serializers import (ProjectModelSerializer, TranslatedField, + FavoritesCreateSerializer) class ContactPhonesSerializer(serializers.ModelSerializer): @@ -118,6 +117,18 @@ class EstablishmentTypeBaseSerializer(serializers.ModelSerializer): 'use_subtypes': {'write_only': True}, } +class EstablishmentTypeGeoSerializer(EstablishmentTypeBaseSerializer): + """Serializer for EstablishmentType model w/ index_name.""" + + class Meta(EstablishmentTypeBaseSerializer.Meta): + fields = EstablishmentTypeBaseSerializer.Meta.fields + [ + 'index_name' + ] + extra_kwargs = { + **EstablishmentTypeBaseSerializer.Meta.extra_kwargs, + 'index_name': {'read_only': True}, + } + class EstablishmentSubTypeBaseSerializer(serializers.ModelSerializer): """Serializer for EstablishmentSubType models.""" @@ -147,12 +158,13 @@ class EstablishmentEmployeeSerializer(serializers.ModelSerializer): position_translated = serializers.CharField(source='position.name_translated') awards = AwardSerializer(source='employee.awards', many=True) priority = serializers.IntegerField(source='position.priority') + position_index_name = serializers.CharField(source='position.index_name') class Meta: """Meta class.""" model = models.Employee - fields = ('id', 'name', 'position_translated', 'awards', 'priority') + fields = ('id', 'name', 'position_translated', 'awards', 'priority', 'position_index_name') class EstablishmentBaseSerializer(ProjectModelSerializer): @@ -184,6 +196,19 @@ class EstablishmentBaseSerializer(ProjectModelSerializer): ] +class EstablishmentGeoSerializer(EstablishmentBaseSerializer): + """Serializer for Geo view.""" + + type = EstablishmentTypeGeoSerializer(source='establishment_type', read_only=True) + + class Meta(EstablishmentBaseSerializer.Meta): + """Meta class.""" + + fields = EstablishmentBaseSerializer.Meta.fields + [ + 'type' + ] + + class EstablishmentDetailSerializer(EstablishmentBaseSerializer): """Serializer for Establishment model.""" @@ -280,26 +305,13 @@ class EstablishmentCommentRUDSerializer(comment_serializers.CommentSerializer): ] -class EstablishmentFavoritesCreateSerializer(serializers.ModelSerializer): - """Create comment serializer""" - - class Meta: - """Serializer for model Comment""" - model = Favorites - fields = [ - 'id', - 'created', - ] - - def get_user(self): - """Get user from request""" - return self.context.get('request').user +class EstablishmentFavoritesCreateSerializer(FavoritesCreateSerializer): + """Serializer to favorite object w/ model Establishment.""" def validate(self, attrs): - """Override validate method""" + """Overridden validate method""" # Check establishment object - establishment_slug = self.context.get('request').parser_context.get('kwargs').get('slug') - establishment_qs = models.Establishment.objects.filter(slug=establishment_slug) + establishment_qs = models.Establishment.objects.filter(slug=self.slug) # Check establishment obj by slug from lookup_kwarg if not establishment_qs.exists(): @@ -308,18 +320,16 @@ class EstablishmentFavoritesCreateSerializer(serializers.ModelSerializer): establishment = establishment_qs.first() # Check existence in favorites - if self.get_user().favorites.by_content_type(app_label='establishment', - model='establishment')\ - .by_object_id(object_id=establishment.id).exists(): + if establishment.favorites.filter(user=self.user).exists(): raise utils_exceptions.FavoritesError() attrs['establishment'] = establishment return attrs def create(self, validated_data, *args, **kwargs): - """Override create method""" + """Overridden create method""" validated_data.update({ - 'user': self.get_user(), + 'user': self.user, 'content_object': validated_data.pop('establishment') }) return super().create(validated_data) diff --git a/apps/establishment/tasks.py b/apps/establishment/tasks.py index cf23a7e6..fdc10933 100644 --- a/apps/establishment/tasks.py +++ b/apps/establishment/tasks.py @@ -1,10 +1,15 @@ """Establishment app tasks.""" import logging + from celery import shared_task +from celery.schedules import crontab +from celery.task import periodic_task +from django.core import management +from django_elasticsearch_dsl.management.commands import search_index + from establishment import models from location.models import Country - logger = logging.getLogger(__name__) @@ -12,10 +17,15 @@ logger = logging.getLogger(__name__) def recalculate_price_levels_by_country(country_id): try: country = Country.objects.get(pk=country_id) - except Country.DoesNotExist as ex: + except Country.DoesNotExist as _: logger.error(f'ESTABLISHMENT. Country does not exist. ID {country_id}') else: qs = models.Establishment.objects.filter(address__city__country=country) for establishment in qs: establishment.recalculate_price_level(low_price=country.low_price, high_price=country.high_price) + +@periodic_task(run_every=crontab(minute=59)) +def rebuild_establishment_indices(): + management.call_command(search_index.Command(), action='rebuild', models=[models.Establishment.__name__], + force=True) diff --git a/apps/establishment/urls/common.py b/apps/establishment/urls/common.py index 8d9453c1..49cd3631 100644 --- a/apps/establishment/urls/common.py +++ b/apps/establishment/urls/common.py @@ -9,7 +9,6 @@ urlpatterns = [ path('', views.EstablishmentListView.as_view(), name='list'), path('recent-reviews/', views.EstablishmentRecentReviewListView.as_view(), name='recent-reviews'), - # path('wineries/', views.WineriesListView.as_view(), name='wineries-list'), path('slug//', views.EstablishmentRetrieveView.as_view(), name='detail'), path('slug//similar/', views.EstablishmentSimilarListView.as_view(), name='similar'), path('slug//comments/', views.EstablishmentCommentListView.as_view(), name='list-comments'), @@ -18,5 +17,5 @@ urlpatterns = [ path('slug//comments//', views.EstablishmentCommentRUDView.as_view(), name='rud-comment'), path('slug//favorites/', views.EstablishmentFavoritesCreateDestroyView.as_view(), - name='add-to-favorites') + name='create-destroy-favorites') ] diff --git a/apps/establishment/views/web.py b/apps/establishment/views/web.py index 0699d9d0..05391827 100644 --- a/apps/establishment/views/web.py +++ b/apps/establishment/views/web.py @@ -138,21 +138,18 @@ class EstablishmentFavoritesCreateDestroyView(generics.CreateAPIView, generics.D """ Returns the object the view is displaying. """ - establishment_obj = get_object_or_404(models.Establishment, - slug=self.kwargs['slug']) - obj = get_object_or_404( - self.request.user.favorites.by_content_type(app_label='establishment', - model='establishment') - .by_object_id(object_id=establishment_obj.pk)) + 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, obj) - return obj + self.check_object_permissions(self.request, favorites) + return favorites class EstablishmentNearestRetrieveView(EstablishmentListView, generics.ListAPIView): """Resource for getting list of nearest establishments.""" - serializer_class = serializers.EstablishmentBaseSerializer + serializer_class = serializers.EstablishmentGeoSerializer filter_class = filters.EstablishmentFilter def get_queryset(self): @@ -170,14 +167,3 @@ class EstablishmentNearestRetrieveView(EstablishmentListView, generics.ListAPIVi return qs.by_distance_from_point(**{k: v for k, v in filter_kwargs.items() if v is not None}) return qs - - -# Wineries -# todo: find out about difference between subtypes data -# class WineriesListView(EstablishmentListView): -# """Return list establishments with type Wineries""" -# -# def get_queryset(self): -# """Overridden get_queryset method.""" -# qs = super(WineriesListView, self).get_queryset() -# return qs.with_type_related().wineries() diff --git a/apps/favorites/urls.py b/apps/favorites/urls.py index bd0c1d16..ad4c6e9d 100644 --- a/apps/favorites/urls.py +++ b/apps/favorites/urls.py @@ -8,4 +8,6 @@ app_name = 'favorites' urlpatterns = [ path('establishments/', views.FavoritesEstablishmentListView.as_view(), name='establishment-list'), + path('products/', views.FavoritesProductListView.as_view(), + name='product-list'), ] diff --git a/apps/favorites/views.py b/apps/favorites/views.py index 5d99ed4b..d2973142 100644 --- a/apps/favorites/views.py +++ b/apps/favorites/views.py @@ -1,7 +1,11 @@ """Views for app favorites.""" from rest_framework import generics from establishment.models import Establishment +from establishment.filters import EstablishmentFilter from establishment.serializers import EstablishmentBaseSerializer +from product.models import Product +from product.serializers import ProductBaseSerializer +from product.filters import ProductFilterSet from .models import Favorites @@ -14,11 +18,24 @@ class FavoritesBaseView(generics.GenericAPIView): class FavoritesEstablishmentListView(generics.ListAPIView): - """List views for favorites""" + """List views for establishments in favorites.""" serializer_class = EstablishmentBaseSerializer + filter_class = EstablishmentFilter def get_queryset(self): """Override get_queryset method""" return Establishment.objects.filter(favorites__user=self.request.user)\ .order_by('-favorites') + + +class FavoritesProductListView(generics.ListAPIView): + """List views for products in favorites.""" + + serializer_class = ProductBaseSerializer + filter_class = ProductFilterSet + + def get_queryset(self): + """Override get_queryset method""" + return Product.objects.filter(favorites__user=self.request.user)\ + .order_by('-favorites') diff --git a/apps/location/admin.py b/apps/location/admin.py index a7610a65..a52fa14e 100644 --- a/apps/location/admin.py +++ b/apps/location/admin.py @@ -19,6 +19,22 @@ class CityAdmin(admin.ModelAdmin): """City admin.""" +class WineAppellationInline(admin.TabularInline): + model = models.WineAppellation + extra = 0 + + +@admin.register(models.WineRegion) +class WineRegionAdmin(admin.ModelAdmin): + """WineRegion admin.""" + inlines = [WineAppellationInline, ] + + +@admin.register(models.WineAppellation) +class WineAppellationAdmin(admin.ModelAdmin): + """WineAppellation admin.""" + + @admin.register(models.Address) class AddressAdmin(admin.OSMGeoAdmin): """Address admin.""" diff --git a/apps/location/migrations/0013_wineappellation_wineregion.py b/apps/location/migrations/0013_wineappellation_wineregion.py new file mode 100644 index 00000000..dee84e55 --- /dev/null +++ b/apps/location/migrations/0013_wineappellation_wineregion.py @@ -0,0 +1,41 @@ +# Generated by Django 2.2.4 on 2019-10-28 07:27 + +from django.db import migrations, models +import django.db.models.deletion +import utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('location', '0012_data_migrate'), + ] + + operations = [ + migrations.CreateModel( + name='WineRegion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', utils.models.TJSONField(help_text='{"en-GB":"some text"}', verbose_name='Name')), + ('country', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='location.Country', verbose_name='country')), + ], + options={ + 'verbose_name': 'wine region', + 'verbose_name_plural': 'wine regions', + }, + bases=(utils.models.TranslatedFieldsMixin, models.Model), + ), + migrations.CreateModel( + name='WineAppellation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', utils.models.TJSONField(help_text='{"en-GB":"some text"}', verbose_name='Name')), + ('wine_region', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='appellations', to='location.WineRegion', verbose_name='wine region')), + ], + options={ + 'verbose_name': 'wine appellation', + 'verbose_name_plural': 'wine appellations', + }, + bases=(utils.models.TranslatedFieldsMixin, models.Model), + ), + ] diff --git a/apps/location/models.py b/apps/location/models.py index 5b440560..fc81ec37 100644 --- a/apps/location/models.py +++ b/apps/location/models.py @@ -134,6 +134,49 @@ class Address(models.Model): return self.city.country_id +class WineRegionQuerySet(models.QuerySet): + """Wine region queryset.""" + + +class WineRegion(TranslatedFieldsMixin, models.Model): + """Wine region model.""" + STR_FIELD_NAME = 'name' + + name = TJSONField(verbose_name=_('Name'), + help_text='{"en-GB":"some text"}') + country = models.ForeignKey(Country, on_delete=models.PROTECT, + verbose_name=_('country')) + + objects = WineRegionQuerySet.as_manager() + + class Meta: + """Meta class.""" + verbose_name_plural = _('wine regions') + verbose_name = _('wine region') + + +class WineAppellationQuerySet(models.QuerySet): + """Wine appellation queryset.""" + + +class WineAppellation(TranslatedFieldsMixin, models.Model): + """Wine appellation model.""" + STR_FIELD_NAME = 'name' + + name = TJSONField(verbose_name=_('Name'), + help_text='{"en-GB":"some text"}') + wine_region = models.ForeignKey(WineRegion, on_delete=models.PROTECT, + related_name='appellations', + verbose_name=_('wine region')) + + objects = WineAppellationQuerySet.as_manager() + + class Meta: + """Meta class.""" + verbose_name_plural = _('wine appellations') + verbose_name = _('wine appellation') + + # todo: Make recalculate price levels @receiver(post_save, sender=Country) def run_recalculate_price_levels(sender, instance, **kwargs): diff --git a/apps/location/serializers/back.py b/apps/location/serializers/back.py index f25aacf6..c178f7fd 100644 --- a/apps/location/serializers/back.py +++ b/apps/location/serializers/back.py @@ -16,4 +16,5 @@ class CountryBackSerializer(common.CountrySerializer): 'code', 'svg_image', 'name', + 'country_id' ] diff --git a/apps/location/serializers/common.py b/apps/location/serializers/common.py index 87d0df4e..2a70c3b8 100644 --- a/apps/location/serializers/common.py +++ b/apps/location/serializers/common.py @@ -148,3 +148,31 @@ class AddressDetailSerializer(AddressBaseSerializer): 'city_id', 'city', ) + + +class WineAppellationBaseSerializer(serializers.ModelSerializer): + """Wine appellations.""" + name_translated = TranslatedField() + + class Meta: + """Meta class.""" + model = models.WineAppellation + fields = [ + 'id', + 'name_translated', + ] + + +class WineRegionBaseSerializer(serializers.ModelSerializer): + """Wine region serializer.""" + name_translated = TranslatedField() + country = CountrySerializer() + + class Meta: + """Meta class.""" + model = models.WineRegion + fields = [ + 'id', + 'name_translated', + 'country', + ] diff --git a/apps/location/tests.py b/apps/location/tests.py index 4e192831..3eaefd85 100644 --- a/apps/location/tests.py +++ b/apps/location/tests.py @@ -20,6 +20,7 @@ class BaseTestCase(APITestCase): username=self.username, email=self.email, password=self.password) tokens = User.create_jwt_tokens(self.user) + self.client.cookies = SimpleCookie( {'access_token': tokens.get('access_token'), 'refresh_token': tokens.get('refresh_token')}) diff --git a/apps/location/views/back.py b/apps/location/views/back.py index cb8246a4..bb64ff72 100644 --- a/apps/location/views/back.py +++ b/apps/location/views/back.py @@ -4,44 +4,48 @@ from rest_framework import generics from location import models, serializers from location.views import common from utils.permissions import IsCountryAdmin - +from rest_framework.permissions import IsAuthenticatedOrReadOnly # Address + + class AddressListCreateView(common.AddressViewMixin, generics.ListCreateAPIView): """Create view for model Address.""" serializer_class = serializers.AddressDetailSerializer queryset = models.Address.objects.all() - permission_classes = [IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] class AddressRUDView(common.AddressViewMixin, generics.RetrieveUpdateDestroyAPIView): """RUD view for model Address.""" serializer_class = serializers.AddressDetailSerializer queryset = models.Address.objects.all() - permission_classes = [IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] # City class CityListCreateView(common.CityViewMixin, generics.ListCreateAPIView): """Create view for model City.""" serializer_class = serializers.CitySerializer - permission_classes = [IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] + class CityRUDView(common.CityViewMixin, generics.RetrieveUpdateDestroyAPIView): """RUD view for model City.""" serializer_class = serializers.CitySerializer - permission_classes = [IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] # Region class RegionListCreateView(common.RegionViewMixin, generics.ListCreateAPIView): """Create view for model Region""" serializer_class = serializers.RegionSerializer - permission_classes = [IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] + class RegionRUDView(common.RegionViewMixin, generics.RetrieveUpdateDestroyAPIView): """Retrieve view for model Region""" serializer_class = serializers.RegionSerializer - permission_classes = [IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] # Country @@ -50,10 +54,11 @@ class CountryListCreateView(generics.ListCreateAPIView): queryset = models.Country.objects.all() serializer_class = serializers.CountryBackSerializer pagination_class = None - permission_classes = [IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] + class CountryRUDView(generics.RetrieveUpdateDestroyAPIView): """RUD view for model Country.""" serializer_class = serializers.CountryBackSerializer - permission_classes = [IsCountryAdmin] + permission_classes = [IsAuthenticatedOrReadOnly|IsCountryAdmin] queryset = models.Country.objects.all() \ No newline at end of file diff --git a/apps/news/migrations/0029_auto_20191025_1241.py b/apps/news/migrations/0029_auto_20191025_1241.py new file mode 100644 index 00000000..8e5bcba4 --- /dev/null +++ b/apps/news/migrations/0029_auto_20191025_1241.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-25 12:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0003_auto_20191003_1228'), + ('news', '0028_auto_20191024_1649'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='newsgallery', + unique_together={('news', 'image'), ('news', 'is_main')}, + ), + ] diff --git a/apps/news/models.py b/apps/news/models.py index 6508215d..ca0f2887 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -236,4 +236,4 @@ class NewsGallery(models.Model): """NewsGallery meta class.""" verbose_name = _('news gallery') verbose_name_plural = _('news galleries') - unique_together = ('news', 'is_main') + unique_together = (('news', 'is_main'), ('news', 'image')) diff --git a/apps/news/serializers.py b/apps/news/serializers.py index 2b4e98b6..1389d20e 100644 --- a/apps/news/serializers.py +++ b/apps/news/serializers.py @@ -97,6 +97,7 @@ class CropImageSerializer(serializers.Serializer): class NewsImageSerializer(serializers.ModelSerializer): """Serializer for returning crop images of news image.""" + orientation_display = serializers.CharField(source='get_orientation_display', read_only=True) original_url = serializers.URLField(source='image.url') @@ -149,6 +150,17 @@ class NewsBaseSerializer(ProjectModelSerializer): ) +class NewsSimilarListSerializer(NewsBaseSerializer): + """List serializer for News model.""" + preview_image_url = serializers.URLField() + + class Meta(NewsBaseSerializer.Meta): + """Meta class.""" + fields = NewsBaseSerializer.Meta.fields + ( + 'preview_image_url', + ) + + class NewsListSerializer(NewsBaseSerializer): """List serializer for News model.""" @@ -191,8 +203,8 @@ class NewsDetailSerializer(NewsBaseSerializer): class NewsDetailWebSerializer(NewsDetailSerializer): """News detail serializer for web users..""" - same_theme = NewsBaseSerializer(many=True, read_only=True) - should_read = NewsBaseSerializer(many=True, read_only=True) + same_theme = NewsSimilarListSerializer(many=True, read_only=True) + should_read = NewsSimilarListSerializer(many=True, read_only=True) agenda = AgendaSerializer() banner = NewsBannerSerializer() @@ -265,7 +277,6 @@ class NewsBackOfficeGallerySerializer(serializers.ModelSerializer): """Override validate method.""" news_pk = self.get_request_kwargs().get('pk') image_id = self.get_request_kwargs().get('image_id') - is_main = attrs.get('is_main') news_qs = models.News.objects.filter(pk=news_pk) image_qs = Image.objects.filter(id=image_id) @@ -278,12 +289,6 @@ class NewsBackOfficeGallerySerializer(serializers.ModelSerializer): news = news_qs.first() image = image_qs.first() - if news.news_gallery.filter(image=image).exists(): - raise serializers.ValidationError({'detail': _('Image is already added')}) - - if is_main and news.news_gallery.main_image().exists(): - raise serializers.ValidationError({'detail': _('Main image is already added')}) - attrs['news'] = news attrs['image'] = image diff --git a/apps/news/tests.py b/apps/news/tests.py index 115763e5..77dbca8e 100644 --- a/apps/news/tests.py +++ b/apps/news/tests.py @@ -66,6 +66,22 @@ class NewsTestCase(BaseTestCase): def setUp(self): super().setUp() + def test_news_post(self): + test_news ={ + "title": {"en-GB": "Test news POST"}, + "news_type_id": self.test_news_type.id, + "description": {"en-GB": "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, + } + + url = reverse("back:news:list-create") + response = self.client.post(url, data=test_news, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + def test_web_news(self): response = self.client.get(reverse('web:news:list')) self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/apps/news/views.py b/apps/news/views.py index 9cd5d969..9cbe6b53 100644 --- a/apps/news/views.py +++ b/apps/news/views.py @@ -119,7 +119,7 @@ class NewsBackOfficeGalleryCreateDestroyView(NewsBackOfficeMixinView, return gallery def create(self, request, *args, **kwargs): - """Override create method""" + """Overridden create method""" super().create(request, *args, **kwargs) return Response(status=status.HTTP_201_CREATED) diff --git a/apps/product/admin.py b/apps/product/admin.py new file mode 100644 index 00000000..b3dbc0cd --- /dev/null +++ b/apps/product/admin.py @@ -0,0 +1,18 @@ +"""Product admin conf.""" +from django.contrib import admin +from .models import Product, ProductType, ProductSubType + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + """Admin page for model Product.""" + + +@admin.register(ProductType) +class ProductTypeAdmin(admin.ModelAdmin): + """Admin page for model ProductType.""" + + +@admin.register(ProductSubType) +class ProductSubTypeAdmin(admin.ModelAdmin): + """Admin page for model ProductSubType.""" diff --git a/apps/product/filters.py b/apps/product/filters.py new file mode 100644 index 00000000..a30147eb --- /dev/null +++ b/apps/product/filters.py @@ -0,0 +1,34 @@ +"""Filters for app Product.""" +from django.core.validators import EMPTY_VALUES +from django_filters import rest_framework as filters + +from product import models + + +class ProductFilterSet(filters.FilterSet): + """Product filter set.""" + + establishment_id = filters.NumberFilter() + product_type = filters.ChoiceFilter(method='by_product_type', + choices=models.ProductType.INDEX_NAME_TYPES) + product_subtype = filters.ChoiceFilter(method='by_product_subtype', + choices=models.ProductSubType.INDEX_NAME_TYPES) + + class Meta: + """Meta class.""" + model = models.Product + fields = [ + 'establishment_id', + 'product_type', + 'product_subtype', + ] + + def by_product_type(self, queryset, name, value): + if value not in EMPTY_VALUES: + return queryset.by_product_type(value) + return queryset + + def by_product_subtype(self, queryset, name, value): + if value not in EMPTY_VALUES: + return queryset.by_product_subtype(value) + return queryset diff --git a/apps/product/migrations/0001_initial.py b/apps/product/migrations/0001_initial.py new file mode 100644 index 00000000..a6abbbb8 --- /dev/null +++ b/apps/product/migrations/0001_initial.py @@ -0,0 +1,94 @@ +# Generated by Django 2.2.4 on 2019-10-28 07:27 + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import utils.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('location', '0013_wineappellation_wineregion'), + ('establishment', '0044_position_index_name'), + ] + + operations = [ + migrations.CreateModel( + name='ProductType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Date created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('name', utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='Name')), + ('index_name', models.CharField(choices=[('food', 'Food'), ('wine', 'Wine'), ('liquor', 'Liquor')], db_index=True, max_length=50, unique=True, verbose_name='Index name')), + ('use_subtypes', models.BooleanField(default=True, verbose_name='Use subtypes')), + ], + options={ + 'verbose_name': 'Product type', + 'verbose_name_plural': 'Product types', + }, + bases=(utils.models.TranslatedFieldsMixin, models.Model), + ), + migrations.CreateModel( + name='ProductSubType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Date created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('name', utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='Name')), + ('index_name', models.CharField(choices=[('rum', 'Rum'), ('other', 'Other')], db_index=True, max_length=50, unique=True, verbose_name='Index name')), + ('product_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subtypes', to='product.ProductType', verbose_name='Product type')), + ], + options={ + 'verbose_name': 'Product subtype', + 'verbose_name_plural': 'Product subtypes', + }, + bases=(utils.models.TranslatedFieldsMixin, models.Model), + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Date created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('category', models.PositiveIntegerField(choices=[(0, 'Common'), (1, 'Online')], default=0)), + ('name', utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='Name')), + ('description', utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='Description')), + ('characteristics', django.contrib.postgres.fields.jsonb.JSONField(verbose_name='Characteristics')), + ('available', models.BooleanField(default=True, verbose_name='Available')), + ('public_mark', models.PositiveIntegerField(blank=True, default=None, null=True, verbose_name='public mark')), + ('country', models.ManyToManyField(to='location.Country', verbose_name='Country')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='product_records_created', to=settings.AUTH_USER_MODEL, verbose_name='created by')), + ('establishment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='establishment.Establishment', verbose_name='establishment')), + ('modified_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='product_records_modified', to=settings.AUTH_USER_MODEL, verbose_name='modified by')), + ('product_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='product.ProductType', verbose_name='Type')), + ('subtypes', models.ManyToManyField(blank=True, related_name='products', to='product.ProductSubType', verbose_name='Subtypes')), + ('wine_appellation', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='location.WineAppellation', verbose_name='wine appellation')), + ('wine_region', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='wines', to='location.WineRegion', verbose_name='wine region')), + ], + options={ + 'verbose_name': 'Product', + 'verbose_name_plural': 'Products', + }, + bases=(utils.models.TranslatedFieldsMixin, models.Model), + ), + migrations.CreateModel( + name='OnlineProduct', + fields=[ + ], + options={ + 'verbose_name': 'Online product', + 'verbose_name_plural': 'Online products', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('product.product',), + ), + ] diff --git a/apps/product/migrations/0002_product_slug.py b/apps/product/migrations/0002_product_slug.py new file mode 100644 index 00000000..a4605464 --- /dev/null +++ b/apps/product/migrations/0002_product_slug.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-29 14:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='slug', + field=models.SlugField(max_length=255, null=True, unique=True, verbose_name='Establishment slug'), + ), + ] diff --git a/apps/product/models.py b/apps/product/models.py index 41f0c7c6..d4011fa0 100644 --- a/apps/product/models.py +++ b/apps/product/models.py @@ -1,5 +1,7 @@ """Product app models.""" from django.db import models +from django.contrib.contenttypes import fields as generic +from django.core.exceptions import ValidationError from django.contrib.postgres.fields import JSONField from django.utils.translation import gettext_lazy as _ from utils.models import (BaseAttributes, ProjectBaseMixin, @@ -9,9 +11,23 @@ from utils.models import (BaseAttributes, ProjectBaseMixin, class ProductType(TranslatedFieldsMixin, ProjectBaseMixin): """ProductType model.""" + STR_FIELD_NAME = 'name' + + # INDEX NAME CHOICES + FOOD = 'food' + WINE = 'wine' + LIQUOR = 'liquor' + + INDEX_NAME_TYPES = ( + (FOOD, _('Food')), + (WINE, _('Wine')), + (LIQUOR, _('Liquor')), + ) + name = TJSONField(blank=True, null=True, default=None, verbose_name=_('Name'), help_text='{"en-GB":"some text"}') - index_name = models.CharField(max_length=50, unique=True, db_index=True, + index_name = models.CharField(max_length=50, choices=INDEX_NAME_TYPES, + unique=True, db_index=True, verbose_name=_('Index name')) use_subtypes = models.BooleanField(_('Use subtypes'), default=True) @@ -25,19 +41,35 @@ class ProductType(TranslatedFieldsMixin, ProjectBaseMixin): class ProductSubType(TranslatedFieldsMixin, ProjectBaseMixin): """ProductSubtype model.""" + STR_FIELD_NAME = 'name' + + # INDEX NAME CHOICES + RUM = 'rum' + OTHER = 'other' + + INDEX_NAME_TYPES = ( + (RUM, _('Rum')), + (OTHER, _('Other')), + ) + product_type = models.ForeignKey(ProductType, on_delete=models.CASCADE, related_name='subtypes', verbose_name=_('Product type')) name = TJSONField(blank=True, null=True, default=None, verbose_name=_('Name'), help_text='{"en-GB":"some text"}') - index_name = models.CharField(max_length=50, unique=True, db_index=True, + index_name = models.CharField(max_length=50, choices=INDEX_NAME_TYPES, + unique=True, db_index=True, verbose_name=_('Index name')) class Meta: """Meta class.""" - verbose_name = _('Product type') - verbose_name_plural = _('Product types') + verbose_name = _('Product subtype') + verbose_name_plural = _('Product subtypes') + + def clean_fields(self, exclude=None): + if not self.product_type.use_subtypes: + raise ValidationError(_('Product type is not use subtypes.')) class ProductManager(models.Manager): @@ -47,16 +79,33 @@ class ProductManager(models.Manager): class ProductQuerySet(models.QuerySet): """Product queryset.""" + def with_base_related(self): + return self.select_related('product_type', 'establishment') \ + .prefetch_related('product_type__subtypes', 'country') + def common(self): return self.filter(category=self.model.COMMON) def online(self): return self.filter(category=self.model.ONLINE) + def wines(self): + return self.filter(type__index_name=ProductType.WINE) + + def by_product_type(self, product_type: str): + """Filter by type.""" + return self.filter(product_type__index_name=product_type) + + def by_product_subtype(self, product_subtype: str): + """Filter by subtype.""" + return self.filter(subtypes__index_name=product_subtype) + class Product(TranslatedFieldsMixin, BaseAttributes): """Product models.""" + STR_FIELD_NAME = 'name' + COMMON = 0 ONLINE = 1 @@ -72,13 +121,30 @@ class Product(TranslatedFieldsMixin, BaseAttributes): description = TJSONField(_('Description'), null=True, blank=True, default=None, help_text='{"en-GB":"some text"}') characteristics = JSONField(_('Characteristics')) - country = models.ForeignKey('location.Country', on_delete=models.PROTECT, - verbose_name=_('Country')) + country = models.ManyToManyField('location.Country', + verbose_name=_('Country')) available = models.BooleanField(_('Available'), default=True) - type = models.ForeignKey(ProductType, on_delete=models.PROTECT, - related_name='products', verbose_name=_('Type')) - subtypes = models.ManyToManyField(ProductSubType, related_name='products', + product_type = models.ForeignKey(ProductType, on_delete=models.PROTECT, + related_name='products', verbose_name=_('Type')) + subtypes = models.ManyToManyField(ProductSubType, blank=True, + related_name='products', verbose_name=_('Subtypes')) + establishment = models.ForeignKey('establishment.Establishment', + on_delete=models.PROTECT, + related_name='products', + verbose_name=_('establishment')) + public_mark = models.PositiveIntegerField(blank=True, null=True, default=None, + verbose_name=_('public mark'),) + wine_region = models.ForeignKey('location.WineRegion', on_delete=models.PROTECT, + related_name='wines', + blank=True, null=True, + verbose_name=_('wine region')) + wine_appellation = models.ForeignKey('location.WineAppellation', on_delete=models.PROTECT, + blank=True, null=True, + verbose_name=_('wine appellation')) + slug = models.SlugField(unique=True, max_length=255, null=True, + verbose_name=_('Establishment slug')) + favorites = generic.GenericRelation(to='favorites.Favorites') objects = ProductManager.from_queryset(ProductQuerySet)() @@ -88,12 +154,22 @@ class Product(TranslatedFieldsMixin, BaseAttributes): verbose_name = _('Product') verbose_name_plural = _('Products') + def clean_fields(self, exclude=None): + super().clean_fields(exclude=exclude) + if self.product_type.index_name == ProductType.WINE and not self.wine_region: + raise ValidationError(_('wine_region field must be specified.')) + if not self.product_type.index_name == ProductType.WINE and self.wine_region: + raise ValidationError(_('wine_region field must not be specified.')) + if (self.wine_region and self.wine_appellation) and \ + self.wine_appellation not in self.wine_region.appellations.all(): + raise ValidationError(_('Wine appellation not exists in wine region.')) + class OnlineProductManager(ProductManager): """Extended manger for OnlineProduct model.""" def get_queryset(self): - """Overrided get_queryset method.""" + """Overridden get_queryset method.""" return super().get_queryset().online() diff --git a/apps/product/serializers/__init__.py b/apps/product/serializers/__init__.py index e69de29b..c564831e 100644 --- a/apps/product/serializers/__init__.py +++ b/apps/product/serializers/__init__.py @@ -0,0 +1,3 @@ +from .common import * +from .web import * +from .mobile import * diff --git a/apps/product/serializers/common.py b/apps/product/serializers/common.py index 236cd38b..7ebfaa28 100644 --- a/apps/product/serializers/common.py +++ b/apps/product/serializers/common.py @@ -1 +1,96 @@ +"""Product app serializers.""" from rest_framework import serializers +from utils.serializers import TranslatedField, FavoritesCreateSerializer +from product.models import Product, ProductSubType, ProductType +from utils import exceptions as utils_exceptions +from django.utils.translation import gettext_lazy as _ +from location.serializers import (WineRegionBaseSerializer, WineAppellationBaseSerializer, + CountrySimpleSerializer) + + +class ProductSubTypeBaseSerializer(serializers.ModelSerializer): + """ProductSubType base serializer""" + name_translated = TranslatedField() + index_name_display = serializers.CharField(source='get_index_name_display') + + class Meta: + model = ProductSubType + fields = [ + 'id', + 'name_translated', + 'index_name_display', + ] + + +class ProductTypeBaseSerializer(serializers.ModelSerializer): + """ProductType base serializer""" + name_translated = TranslatedField() + index_name_display = serializers.CharField(source='get_index_name_display') + + class Meta: + model = ProductType + fields = [ + 'id', + 'name_translated', + 'index_name_display', + ] + + +class ProductBaseSerializer(serializers.ModelSerializer): + """Product base serializer.""" + name_translated = TranslatedField() + description_translated = TranslatedField() + category_display = serializers.CharField(source='get_category_display') + product_type = ProductTypeBaseSerializer() + subtypes = ProductSubTypeBaseSerializer(many=True) + wine_region = WineRegionBaseSerializer(allow_null=True) + wine_appellation = WineAppellationBaseSerializer(allow_null=True) + available_countries = CountrySimpleSerializer(source='country', many=True) + + class Meta: + """Meta class.""" + model = Product + fields = [ + 'id', + 'slug', + 'name_translated', + 'category_display', + 'description_translated', + 'available', + 'product_type', + 'subtypes', + 'public_mark', + 'wine_region', + 'wine_appellation', + 'available_countries', + ] + + +class ProductFavoritesCreateSerializer(FavoritesCreateSerializer): + """Serializer to create favorite object w/ model Product.""" + + def validate(self, attrs): + """Overridden validate method""" + # Check establishment object + product_qs = Product.objects.filter(slug=self.slug) + + # Check establishment obj by slug from lookup_kwarg + if not product_qs.exists(): + raise serializers.ValidationError({'detail': _('Object not found.')}) + else: + product = product_qs.first() + + # Check existence in favorites + if product.favorites.filter(user=self.user).exists(): + raise utils_exceptions.FavoritesError() + + attrs['product'] = product + return attrs + + def create(self, validated_data, *args, **kwargs): + """Overridden create method""" + validated_data.update({ + 'user': self.user, + 'content_object': validated_data.pop('product') + }) + return super().create(validated_data) \ No newline at end of file diff --git a/apps/product/urls/common.py b/apps/product/urls/common.py index e69de29b..d0dbb8a9 100644 --- a/apps/product/urls/common.py +++ b/apps/product/urls/common.py @@ -0,0 +1,12 @@ +"""Product url patterns.""" +from django.urls import path + +from product import views + +app_name = 'product' + +urlpatterns = [ + path('', views.ProductListView.as_view(), name='list'), + path('slug//favorites/', views.CreateFavoriteProductView.as_view(), + name='create-destroy-favorites') +] diff --git a/apps/product/urls/web.py b/apps/product/urls/web.py index e69de29b..116c7b0b 100644 --- a/apps/product/urls/web.py +++ b/apps/product/urls/web.py @@ -0,0 +1,7 @@ +"""Product web url patterns.""" +from product.urls.common import urlpatterns as common_urlpatterns + +urlpatterns = [ +] + +urlpatterns.extend(common_urlpatterns) diff --git a/apps/product/views/__init__.py b/apps/product/views/__init__.py index e69de29b..6f4a8001 100644 --- a/apps/product/views/__init__.py +++ b/apps/product/views/__init__.py @@ -0,0 +1,4 @@ +from .back import * +from .common import * +from .mobile import * +from .web import * diff --git a/apps/product/views/common.py b/apps/product/views/common.py index e69de29b..63b9677f 100644 --- a/apps/product/views/common.py +++ b/apps/product/views/common.py @@ -0,0 +1,39 @@ +"""Product app views.""" +from rest_framework import generics, permissions +from django.shortcuts import get_object_or_404 +from product.models import Product +from product import serializers +from product import filters + + +class ProductBaseView(generics.GenericAPIView): + """Product base view""" + + def get_queryset(self): + """Override get_queryset method.""" + return Product.objects.with_base_related() + + +class ProductListView(ProductBaseView, generics.ListAPIView): + """List view for model Product.""" + permission_classes = (permissions.AllowAny, ) + serializer_class = serializers.ProductBaseSerializer + filter_class = filters.ProductFilterSet + + +class CreateFavoriteProductView(generics.CreateAPIView, + generics.DestroyAPIView): + """View for create/destroy product in favorites.""" + 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 diff --git a/apps/recipe/models.py b/apps/recipe/models.py index 6df7adc2..349fed7b 100644 --- a/apps/recipe/models.py +++ b/apps/recipe/models.py @@ -15,14 +15,12 @@ class RecipeQuerySet(models.QuerySet): def annotate_in_favorites(self, user): """Annotate flag in_favorites""" - favorite_establishments = [] + favorite_recipe_ids = [] if user.is_authenticated: - favorite_establishments = user.favorites.by_content_type(app_label='recipe', - model='recipe') \ - .values_list('object_id', flat=True) + favorite_recipe_ids = user.favorite_recipe_ids return self.annotate(in_favorites=models.Case( models.When( - id__in=favorite_establishments, + id__in=favorite_recipe_ids, then=True), default=False, output_field=models.BooleanField(default=False))) diff --git a/apps/search_indexes/documents/establishment.py b/apps/search_indexes/documents/establishment.py index 5d858321..7eac2d6c 100644 --- a/apps/search_indexes/documents/establishment.py +++ b/apps/search_indexes/documents/establishment.py @@ -22,14 +22,13 @@ class EstablishmentDocument(Document): 'id': fields.IntegerField(), 'name': fields.ObjectField(attr='name_indexing', properties=OBJECT_FIELD_PROPERTIES), + 'index_name': fields.KeywordField(attr='index_name'), }) establishment_subtypes = fields.ObjectField( properties={ 'id': fields.IntegerField(), - 'name': fields.ObjectField(attr='name_indexing', - properties={ - 'id': fields.IntegerField(), - }), + 'name': fields.ObjectField(attr='name_indexing'), + 'index_name': fields.KeywordField(attr='index_name'), }, multi=True) works_evening = fields.ListField(fields.IntegerField( @@ -82,15 +81,8 @@ class EstablishmentDocument(Document): ), } ), - } + }, ) - # todo: need to fix - # collections = fields.ObjectField( - # properties={ - # 'id': fields.IntegerField(attr='collection.id'), - # 'collection_type': fields.IntegerField(attr='collection.collection_type'), - # }, - # multi=True) class Django: @@ -99,6 +91,7 @@ class EstablishmentDocument(Document): 'id', 'name', 'name_translated', + 'is_publish', 'price_level', 'toque_number', 'public_mark', @@ -106,4 +99,4 @@ class EstablishmentDocument(Document): ) def get_queryset(self): - return super().get_queryset().published() + return super().get_queryset().with_es_related() diff --git a/apps/search_indexes/serializers.py b/apps/search_indexes/serializers.py index d4ab2dbf..b9df01b7 100644 --- a/apps/search_indexes/serializers.py +++ b/apps/search_indexes/serializers.py @@ -1,5 +1,6 @@ """Search indexes serializers.""" from rest_framework import serializers +from elasticsearch_dsl import AttrDict from django_elasticsearch_dsl_drf.serializers import DocumentSerializer from news.serializers import NewsTypeSerializer from search_indexes.documents import EstablishmentDocument, NewsDocument @@ -13,6 +14,8 @@ class TagsDocumentSerializer(serializers.Serializer): label_translated = serializers.SerializerMethodField() def get_label_translated(self, obj): + if isinstance(obj, dict): + return get_translated_value(obj.get('label')) return get_translated_value(obj.label) @@ -29,6 +32,13 @@ class AddressDocumentSerializer(serializers.Serializer): geo_lon = serializers.FloatField(allow_null=True, source='coordinates.lon') geo_lat = serializers.FloatField(allow_null=True, source='coordinates.lat') + # todo: refator + def to_representation(self, instance): + if instance != AttrDict(d={}) or \ + (isinstance(instance, dict) and len(instance) != 0): + return super().to_representation(instance) + return None + class ScheduleDocumentSerializer(serializers.Serializer): """Schedule serializer for ES Document""" @@ -75,7 +85,7 @@ class NewsDocumentSerializer(DocumentSerializer): class EstablishmentDocumentSerializer(DocumentSerializer): """Establishment document serializer.""" - address = AddressDocumentSerializer() + address = AddressDocumentSerializer(allow_null=True) tags = TagsDocumentSerializer(many=True) schedule = ScheduleDocumentSerializer(many=True, allow_null=True) diff --git a/apps/search_indexes/views.py b/apps/search_indexes/views.py index 25205bac..72ce2efa 100644 --- a/apps/search_indexes/views.py +++ b/apps/search_indexes/views.py @@ -3,12 +3,13 @@ from rest_framework import permissions from django_elasticsearch_dsl_drf import constants from django_elasticsearch_dsl_drf.filter_backends import ( FilteringFilterBackend, - GeoSpatialFilteringFilterBackend + GeoSpatialFilteringFilterBackend, + DefaultOrderingFilterBackend, ) from django_elasticsearch_dsl_drf.viewsets import BaseDocumentViewSet from search_indexes import serializers, filters from search_indexes.documents import EstablishmentDocument, NewsDocument -from utils.pagination import ProjectPageNumberPagination +from utils.pagination import ProjectMobilePagination class NewsDocumentViewSet(BaseDocumentViewSet): @@ -16,7 +17,7 @@ class NewsDocumentViewSet(BaseDocumentViewSet): document = NewsDocument lookup_field = 'slug' - pagination_class = ProjectPageNumberPagination + pagination_class = ProjectMobilePagination permission_classes = (permissions.AllowAny,) serializer_class = serializers.NewsDocumentSerializer ordering = ('id',) @@ -53,15 +54,23 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): document = EstablishmentDocument lookup_field = 'slug' - pagination_class = ProjectPageNumberPagination + pagination_class = ProjectMobilePagination permission_classes = (permissions.AllowAny,) serializer_class = serializers.EstablishmentDocumentSerializer - ordering = ('id',) + + def get_queryset(self): + qs = super(EstablishmentDocumentViewSet, self).get_queryset() + qs = qs.filter('match', is_publish=True) + if self.request.country_code: + qs = qs.filter('term', + **{'address.city.country.code': self.request.country_code}) + return qs filter_backends = [ FilteringFilterBackend, filters.CustomSearchFilterBackend, GeoSpatialFilteringFilterBackend, + DefaultOrderingFilterBackend, ] search_fields = { @@ -72,6 +81,7 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): translated_search_fields = ( 'description', ) + ordering = 'id' filter_fields = { 'slug': 'slug', 'tag': { @@ -124,6 +134,15 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): 'establishment_subtypes': { 'field': 'establishment_subtypes.id' }, + 'type': { + 'field': 'establishment_type.index_name' + }, + 'subtype': { + 'field': 'establishment_subtypes.index_name', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + ], + }, 'works_noon': { 'field': 'works_noon', 'lookups': [ diff --git a/apps/timetable/serialziers.py b/apps/timetable/serialziers.py index babe33c1..37725e1d 100644 --- a/apps/timetable/serialziers.py +++ b/apps/timetable/serialziers.py @@ -27,6 +27,7 @@ class ScheduleRUDSerializer(serializers.ModelSerializer): fields = [ 'id', 'weekday_display', + 'weekday', 'lunch_start', 'lunch_end', 'dinner_start', diff --git a/apps/utils/models.py b/apps/utils/models.py index ae6e15b0..0c94d23f 100644 --- a/apps/utils/models.py +++ b/apps/utils/models.py @@ -44,18 +44,19 @@ class TJSONField(JSONField): def to_locale(language): """Turn a language name (en-us) into a locale name (en_US).""" - language, _, country = language.lower().partition('-') - if not country: - return language - # A language with > 2 characters after the dash only has its first - # character after the dash capitalized; e.g. sr-latn becomes sr-Latn. - # A language with 2 characters after the dash has both characters - # capitalized; e.g. en-us becomes en-US. - country, _, tail = country.partition('-') - country = country.title() if len(country) > 2 else country.upper() - if tail: - country += '-' + tail - return language + '-' + country + if language: + language, _, country = language.lower().partition('-') + if not country: + return language + # A language with > 2 characters after the dash only has its first + # character after the dash capitalized; e.g. sr-latn becomes sr-Latn. + # A language with 2 characters after the dash has both characters + # capitalized; e.g. en-us becomes en-US. + country, _, tail = country.partition('-') + country = country.title() if len(country) > 2 else country.upper() + if tail: + country += '-' + tail + return language + '-' + country def translate_field(self, field_name): diff --git a/apps/utils/permissions.py b/apps/utils/permissions.py index 45d978a0..86a4be6f 100644 --- a/apps/utils/permissions.py +++ b/apps/utils/permissions.py @@ -20,8 +20,8 @@ class IsAuthenticatedAndTokenIsValid(permissions.BasePermission): access_token = request.COOKIES.get('access_token') if user.is_authenticated and access_token: access_token = AccessToken(access_token) - valid_tokens = user.access_tokens.valid()\ - .by_jti(jti=access_token.payload.get('jti')) + valid_tokens = user.access_tokens.valid() \ + .by_jti(jti=access_token.payload.get('jti')) return valid_tokens.exists() else: return False @@ -31,13 +31,14 @@ class IsRefreshTokenValid(permissions.BasePermission): """ Check if user has a valid refresh token and authenticated """ + def has_permission(self, request, view): """Check permissions by refresh token and default REST permission IsAuthenticated""" refresh_token = request.COOKIES.get('refresh_token') if refresh_token: refresh_token = GMRefreshToken(refresh_token) - refresh_token_qs = JWTRefreshToken.objects.valid()\ - .by_jti(jti=refresh_token.payload.get('jti')) + refresh_token_qs = JWTRefreshToken.objects.valid() \ + .by_jti(jti=refresh_token.payload.get('jti')) return refresh_token_qs.exists() else: return False @@ -55,11 +56,15 @@ class IsGuest(permissions.IsAuthenticatedOrReadOnly): """ Object-level permission to only allow owners of an object to edit it. """ + def has_permission(self, request, view): - return request.user.is_authenticated + rules = [ + request.user.is_superuser, + request.method in permissions.SAFE_METHODS + ] + return any(rules) def has_object_permission(self, request, view, obj): - rules = [ request.user.is_superuser, request.method in permissions.SAFE_METHODS @@ -72,6 +77,21 @@ class IsStandardUser(IsGuest): 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, 'user'): + rules = [ + request.user.is_authenticated, + super().has_permission(request, view) + ] + + return any(rules) + def has_object_permission(self, request, view, obj): # Read permissions are allowed to any request rules = [ @@ -92,11 +112,29 @@ class IsContentPageManager(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, '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) + ] + return any(rules) + def has_object_permission(self, request, view, obj): # Read permissions are allowed to any request. role = Role.objects.filter(role=Role.CONTENT_PAGE_MANAGER, - country_id=obj.country_id)\ + country_id=obj.country_id) \ .first() # 'Comments moderator' rules = [ @@ -112,6 +150,26 @@ 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. + + role = Role.objects.filter(role=Role.COUNTRY_ADMIN, + country_id=request.data.country_id) \ + .first() # 'Comments moderator' + + 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, @@ -119,9 +177,20 @@ class IsCountryAdmin(IsStandardUser): .first() # 'Comments moderator' rules = [ - UserRole.objects.filter(user=request.user, role=role).exists(), - super().has_object_permission(request, view, obj), + 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(), + super().has_object_permission(request, view, obj), + ] + + if hasattr(request.data, 'user'): + rules = [ + UserRole.objects.filter(user=request.data.user, role=role).exists(), + super().has_object_permission(request, view, obj), + ] return any(rules) @@ -131,10 +200,31 @@ class IsCommentModerator(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. + + role = Role.objects.filter(role=Role.COMMENTS_MODERATOR, + country_id=request.data.country_id) \ + .first() # 'Comments moderator' + + 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.COMMENTS_MODERATOR, - country_id=obj.country_id)\ + country_id=obj.country_id) \ .first() # 'Comments moderator' rules = [ @@ -147,10 +237,28 @@ class IsCommentModerator(IsStandardUser): class IsEstablishmentManager(IsStandardUser): - def has_object_permission(self, request, view, obj): - role = Role.objects.filter(role=Role.ESTABLISHMENT_MANAGER)\ + 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, 'establishment_id'): + role = Role.objects.filter(role=Role.ESTABLISHMENT_MANAGER) \ .first() # 'Comments moderator' + 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 @@ -163,11 +271,28 @@ class IsEstablishmentManager(IsStandardUser): class IsReviewerManager(IsStandardUser): - def has_object_permission(self, request, view, obj): + 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'): + role = Role.objects.filter(role=Role.REVIEWER_MANGER) \ + .first() # 'Comments moderator' + + rules = [ + UserRole.objects.filter(user=request.user, role=role, + establishment_id=request.data.country_id + ).exists(), + super().has_permission(request, view) + ] + return any(rules) + + def has_object_permission(self, request, view, obj): role = Role.objects.filter(role=Role.REVIEWER_MANGER, - country_id=obj.country_id)\ - .first() + country_id=obj.country_id) \ + .first() rules = [ UserRole.objects.filter(user=request.user, role=role).exists(), @@ -179,8 +304,25 @@ class IsReviewerManager(IsStandardUser): class IsRestaurantReviewer(IsStandardUser): - def has_object_permission(self, request, view, obj): + 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, 'object_id'): + role = Role.objects.filter(role=Role.RESTAURANT_REVIEWER) \ + .first() # 'Comments moderator' + + rules = [ + UserRole.objects.filter(user=request.user, role=role, + establishment_id=request.data.object_id + ).exists(), + super().has_permission(request, view) + ] + return any(rules) + + def has_object_permission(self, request, view, obj): content_type = ContentType.objects.get(app_lable='establishment', model='establishment') diff --git a/apps/utils/serializers.py b/apps/utils/serializers.py index eeff1043..bd7e8b8e 100644 --- a/apps/utils/serializers.py +++ b/apps/utils/serializers.py @@ -4,6 +4,7 @@ from django.core import exceptions from rest_framework import serializers from utils import models from translation.models import Language +from favorites.models import Favorites class EmptySerializer(serializers.Serializer): @@ -72,3 +73,28 @@ class ProjectModelSerializer(serializers.ModelSerializer): """Overrided ModelSerializer.""" serializers.ModelSerializer.serializer_field_mapping[models.TJSONField] = TJSONField + + +class FavoritesCreateSerializer(serializers.ModelSerializer): + """Serializer to favorite object.""" + + class Meta: + """Serializer for model Comment.""" + model = Favorites + fields = [ + 'id', + 'created', + ] + + @property + def request(self): + return self.context.get('request') + + @property + def user(self): + """Get user from request""" + return self.request.user + + @property + def slug(self): + return self.request.parser_context.get('kwargs').get('slug') diff --git a/apps/utils/tests/tests_permissions.py b/apps/utils/tests/tests_permissions.py index edc1a5d7..3bba7b7d 100644 --- a/apps/utils/tests/tests_permissions.py +++ b/apps/utils/tests/tests_permissions.py @@ -9,10 +9,11 @@ class BasePermissionTests(APITestCase): title='Russia', locale='ru-RU' ) + self.lang.save() self.country_ru = Country.objects.get( 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 4569ecf2..557c8b5d 100644 --- a/apps/utils/tests/tests_translated.py +++ b/apps/utils/tests/tests_translated.py @@ -9,6 +9,7 @@ from account.models import User from news.models import News, NewsType from establishment.models import Establishment, EstablishmentType, Employee +from location.models import Country class BaseTestCase(APITestCase): @@ -39,7 +40,13 @@ class TranslateFieldTests(BaseTestCase): self.news_type = NewsType.objects.create(name="Test news type") self.news_type.save() + + self.country_ru = Country.objects.get( + name={"en-GB": "Russian"} + ) + self.news_item = News.objects.create( + id=8, created_by=self.user, modified_by=self.user, title={ @@ -52,6 +59,7 @@ class TranslateFieldTests(BaseTestCase): news_type=self.news_type, slug='test', state=News.PUBLISHED, + country=self.country_ru, ) self.news_item.save() diff --git a/fabfile.py b/fabfile.py index 9ad7f871..e8a52f85 100644 --- a/fabfile.py +++ b/fabfile.py @@ -53,12 +53,14 @@ def collectstatic(): def deploy(branch=None): - fetch() - install_requirements() - migrate() - collectstatic() - touch() - kill_celery() + role = env.roles[0] + if env.roledefs[role]['branch'] != 'develop': + fetch() + install_requirements() + migrate() + collectstatic() + touch() + kill_celery() def rev(): diff --git a/project/settings/base.py b/project/settings/base.py index 3d3ec8ca..3a23122a 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -76,7 +76,7 @@ PROJECT_APPS = [ 'rating.apps.RatingConfig', 'transfer.apps.TransferConfig', 'tag.apps.TagConfig' - + 'product.apps.ProductConfig', ] EXTERNAL_APPS = [ @@ -100,7 +100,7 @@ EXTERNAL_APPS = [ 'timezone_field', 'storages', 'sorl.thumbnail', - 'timezonefinder' + 'timezonefinder', ] @@ -373,7 +373,7 @@ THUMBNAIL_DEFAULT_OPTIONS = { THUMBNAIL_QUALITY = 85 THUMBNAIL_DEBUG = False SORL_THUMBNAIL_ALIASES = { - 'news_preview': {'geometry_string': '100x100', 'crop': 'center'}, + 'news_preview': {'geometry_string': '300x260', 'crop': 'center'}, 'news_promo_horizontal_web': {'geometry_string': '1900x600', 'crop': 'center'}, 'news_promo_horizontal_mobile': {'geometry_string': '375x260', 'crop': 'center'}, 'news_tile_horizontal_web': {'geometry_string': '300x275', 'crop': 'center'}, diff --git a/project/urls/web.py b/project/urls/web.py index 77a06961..86f7eac2 100644 --- a/project/urls/web.py +++ b/project/urls/web.py @@ -35,4 +35,5 @@ urlpatterns = [ path('comments/', include('comment.urls.web')), path('favorites/', include('favorites.urls')), path('timetables/', include('timetable.urls.web')), + path('products/', include('product.urls.web')), ]