diff --git a/apps/account/serializers/common.py b/apps/account/serializers/common.py index e8d6ba30..b68aca7d 100644 --- a/apps/account/serializers/common.py +++ b/apps/account/serializers/common.py @@ -77,6 +77,23 @@ class UserSerializer(serializers.ModelSerializer): return instance +class UserBaseSerializer(serializers.ModelSerializer): + """Serializer is used to display brief information about the user.""" + + fullname = serializers.CharField(source='get_full_name', read_only=True) + + class Meta: + """Meta class.""" + + model = models.User + fields = ( + 'fullname', + 'cropped_image_url', + 'image_url', + ) + read_only_fields = fields + + class ChangePasswordSerializer(serializers.ModelSerializer): """Serializer for model User.""" @@ -155,38 +172,6 @@ class ChangeEmailSerializer(serializers.ModelSerializer): return instance -class ConfirmEmailSerializer(serializers.ModelSerializer): - """Confirm user email serializer""" - x = serializers.CharField(default=None) - - class Meta: - """Meta class""" - model = models.User - fields = ( - 'email', - ) - - def validate(self, attrs): - """Override validate method""" - email_confirmed = self.instance.email_confirmed - if email_confirmed: - raise utils_exceptions.EmailConfirmedError() - - return attrs - - def update(self, instance, validated_data): - """ - Override update method - """ - - # Send verification link on user email for change email address - if settings.USE_CELERY: - tasks.confirm_new_email_address.delay(instance.id) - else: - tasks.confirm_new_email_address(instance.id) - return instance - - # Firebase Cloud Messaging serializers class FCMDeviceSerializer(serializers.ModelSerializer): """FCM Device model serializer""" diff --git a/apps/account/urls/common.py b/apps/account/urls/common.py index 34583010..4ea2af66 100644 --- a/apps/account/urls/common.py +++ b/apps/account/urls/common.py @@ -8,5 +8,6 @@ app_name = 'account' urlpatterns = [ path('user/', views.UserRetrieveUpdateView.as_view(), name='user-retrieve-update'), path('change-password/', views.ChangePasswordView.as_view(), name='change-password'), + path('email/confirm/', views.SendConfirmationEmailView.as_view(), name='send-confirm-email'), path('email/confirm///', views.ConfirmEmailView.as_view(), name='confirm-email'), ] diff --git a/apps/account/views/common.py b/apps/account/views/common.py index ab62343f..d29ce2bb 100644 --- a/apps/account/views/common.py +++ b/apps/account/views/common.py @@ -1,4 +1,5 @@ """Common account views""" +from django.conf import settings from django.utils.encoding import force_text from django.utils.http import urlsafe_base64_decode from fcm_django.models import FCMDevice @@ -9,6 +10,7 @@ from rest_framework.response import Response from account import models from account.serializers import common as serializers +from authorization.tasks import send_confirm_email from utils import exceptions as utils_exceptions from utils.models import GMTokenGenerator from utils.views import JWTGenericViewMixin @@ -38,19 +40,26 @@ class ChangePasswordView(generics.GenericAPIView): return Response(status=status.HTTP_200_OK) -class SendConfirmationEmailView(JWTGenericViewMixin): +class SendConfirmationEmailView(generics.GenericAPIView): """Confirm email view.""" - serializer_class = serializers.ConfirmEmailSerializer - queryset = models.User.objects.all() - def patch(self, request, *args, **kwargs): - """Implement PATCH-method""" - # Get user instance - instance = self.request.user + def post(self, request, *args, **kwargs): + """Override create method""" + user = self.request.user + country_code = self.request.country_code - serializer = self.get_serializer(data=request.data, instance=instance) - serializer.is_valid(raise_exception=True) - serializer.save() + if user.email_confirmed: + raise utils_exceptions.EmailConfirmedError() + + # Send verification link on user email for change email address + if settings.USE_CELERY: + send_confirm_email.delay( + user_id=user.id, + country_code=country_code) + else: + send_confirm_email( + user_id=user.id, + country_code=country_code) return Response(status=status.HTTP_200_OK) diff --git a/apps/advertisement/migrations/0002_auto_20190917_1307.py b/apps/advertisement/migrations/0002_auto_20190917_1307.py index 178d0f3a..6604a979 100644 --- a/apps/advertisement/migrations/0002_auto_20190917_1307.py +++ b/apps/advertisement/migrations/0002_auto_20190917_1307.py @@ -4,6 +4,20 @@ from django.db import migrations, models import uuid +def fill_uuid(apps, schemaeditor): + Advertisement = apps.get_model('advertisement', 'Advertisement') + for a in Advertisement.objects.all(): + a.uuid = uuid.uuid4() + a.save() + + +def fill_block_level(apps, schemaeditor): + Advertisement = apps.get_model('advertisement', 'Advertisement') + for a in Advertisement.objects.all(): + a.block_level = '' + a.save() + + class Migration(migrations.Migration): dependencies = [ @@ -23,6 +37,12 @@ class Migration(migrations.Migration): field=models.ManyToManyField(to='translation.Language'), ), migrations.AddField( + model_name='advertisement', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + migrations.RunPython(fill_uuid, migrations.RunPython.noop), + migrations.AlterField( model_name='advertisement', name='uuid', field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), @@ -32,8 +52,14 @@ class Migration(migrations.Migration): name='block_level', ), migrations.AddField( + model_name='advertisement', + name='block_level', + field=models.CharField(blank=True, null=True, max_length=10, verbose_name='Block level') + ), + migrations.RunPython(fill_block_level, migrations.RunPython.noop), + migrations.AlterField( model_name='advertisement', name='block_level', field=models.CharField(max_length=10, verbose_name='Block level') - ) + ), ] diff --git a/apps/authorization/serializers/common.py b/apps/authorization/serializers/common.py index 5d8bb3a8..ed68ba9f 100644 --- a/apps/authorization/serializers/common.py +++ b/apps/authorization/serializers/common.py @@ -4,7 +4,7 @@ from django.contrib.auth import authenticate from django.contrib.auth import password_validation as password_validators from django.db.models import Q from rest_framework import serializers -from rest_framework import validators as rest_validators +from rest_framework.generics import get_object_or_404 from account import models as account_models from authorization import tasks @@ -81,7 +81,6 @@ class LoginByUsernameOrEmailSerializer(SourceSerializerMixin, """Serializer for login user""" # REQUEST username_or_email = serializers.CharField(write_only=True) - password = serializers.CharField(write_only=True) # For cookie properties (Max-Age) remember = serializers.BooleanField(write_only=True) @@ -101,21 +100,24 @@ class LoginByUsernameOrEmailSerializer(SourceSerializerMixin, 'refresh_token', 'access_token', ) + extra_kwargs = { + 'password': {'write_only': True} + } def validate(self, attrs): """Override validate method""" username_or_email = attrs.pop('username_or_email') password = attrs.pop('password') user_qs = account_models.User.objects.filter(Q(username=username_or_email) | - (Q(email=username_or_email))) + Q(email=username_or_email)) if not user_qs.exists(): - raise utils_exceptions.UserNotFoundError() + raise utils_exceptions.WrongAuthCredentials() else: user = user_qs.first() authentication = authenticate(username=user.get_username(), password=password) if not authentication: - raise utils_exceptions.UserNotFoundError() + raise utils_exceptions.WrongAuthCredentials() self.instance = user return attrs @@ -127,10 +129,6 @@ class LoginByUsernameOrEmailSerializer(SourceSerializerMixin, return super().to_representation(instance) -class LogoutSerializer(SourceSerializerMixin): - """Serializer for Logout endpoint.""" - - class RefreshTokenSerializer(SourceSerializerMixin): """Serializer for refresh token view""" refresh_token = serializers.CharField(read_only=True) @@ -169,7 +167,3 @@ class RefreshTokenSerializer(SourceSerializerMixin): class OAuth2Serialzier(SourceSerializerMixin): """Serializer OAuth2 authorization""" token = serializers.CharField(max_length=255) - - -class OAuth2LogoutSerializer(SourceSerializerMixin): - """Serializer for logout""" diff --git a/apps/authorization/tasks.py b/apps/authorization/tasks.py index 9947c2a3..c97fbbae 100644 --- a/apps/authorization/tasks.py +++ b/apps/authorization/tasks.py @@ -4,18 +4,19 @@ from django.utils.translation import gettext_lazy as _ from celery import shared_task from account import models as account_models +from smtplib import SMTPException logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO) logger = logging.getLogger(__name__) @shared_task -def send_confirm_email(user_id, country_code): +def send_confirm_email(user_id: int, country_code: str): """Send verification email to user.""" try: obj = account_models.User.objects.get(id=user_id) obj.send_email(subject=_('Email confirmation'), message=obj.confirm_email_template(country_code)) - except: + except Exception as e: logger.error(f'METHOD_NAME: {send_confirm_email.__name__}\n' - f'DETAIL: Exception occurred for user: {user_id}') + f'DETAIL: user {user_id}, - {e}') diff --git a/apps/authorization/views/common.py b/apps/authorization/views/common.py index bb337dce..0b1a58e0 100644 --- a/apps/authorization/views/common.py +++ b/apps/authorization/views/common.py @@ -27,24 +27,6 @@ from utils.permissions import IsAuthenticatedAndTokenIsValid from utils.views import JWTGenericViewMixin -# Mixins -# JWTAuthView mixin -class JWTAuthViewMixin(JWTGenericViewMixin): - """Mixin for authentication views""" - - def post(self, request, *args, **kwargs): - """Implement POST method""" - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - response = Response(serializer.data, status=status.HTTP_200_OK) - access_token = serializer.data.get('access_token') - refresh_token = serializer.data.get('refresh_token') - return self._put_cookies_in_response( - cookies=self._put_data_in_cookies(access_token=access_token, - refresh_token=refresh_token), - response=response) - - # OAuth2 class BaseOAuth2ViewMixin(generics.GenericAPIView): """BaseMixin for classic auth views""" @@ -112,6 +94,7 @@ class OAuth2SignUpView(OAuth2ViewMixin, JWTGenericViewMixin): # Preparing request data serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + request_data = self.prepare_request_data(serializer.validated_data) source = serializer.validated_data.get('source') request_data.update({ @@ -196,7 +179,7 @@ class ConfirmationEmailView(JWTGenericViewMixin): # Login by username|email + password -class LoginByUsernameOrEmailView(JWTAuthViewMixin): +class LoginByUsernameOrEmailView(JWTGenericViewMixin): """Login by email and password""" permission_classes = (permissions.AllowAny,) serializer_class = serializers.LoginByUsernameOrEmailSerializer @@ -205,10 +188,12 @@ class LoginByUsernameOrEmailView(JWTAuthViewMixin): """Implement POST method""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + response = Response(serializer.data, status=status.HTTP_200_OK) access_token = serializer.data.get('access_token') refresh_token = serializer.data.get('refresh_token') is_permanent = serializer.validated_data.get('remember') + return self._put_cookies_in_response( cookies=self._put_data_in_cookies(access_token=access_token, refresh_token=refresh_token, @@ -243,9 +228,11 @@ class RefreshTokenView(JWTGenericViewMixin): def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + response = Response(serializer.data, status=status.HTTP_201_CREATED) access_token = serializer.data.get('access_token') refresh_token = serializer.data.get('refresh_token') + return self._put_cookies_in_response( cookies=self._put_data_in_cookies(access_token=access_token, refresh_token=refresh_token), diff --git a/apps/collection/serializers/common.py b/apps/collection/serializers/common.py index f7319561..78612a55 100644 --- a/apps/collection/serializers/common.py +++ b/apps/collection/serializers/common.py @@ -4,11 +4,24 @@ from collection import models from location import models as location_models -class CollectionSerializer(serializers.ModelSerializer): - """Collection serializer""" +class CollectionBaseSerializer(serializers.ModelSerializer): + """Collection base serializer""" # RESPONSE description_translated = serializers.CharField(read_only=True, allow_null=True) + class Meta: + model = models.Collection + fields = [ + 'id', + 'name', + 'description_translated', + 'image_url', + 'slug', + ] + + +class CollectionSerializer(CollectionBaseSerializer): + """Collection serializer""" # COMMON block_size = serializers.JSONField() is_publish = serializers.BooleanField() @@ -24,18 +37,13 @@ class CollectionSerializer(serializers.ModelSerializer): class Meta: model = models.Collection - fields = [ - 'id', - 'name', - 'description_translated', + fields = CollectionBaseSerializer.Meta.fields + [ 'start', 'end', - 'image_url', 'is_publish', 'on_top', 'country', 'block_size', - 'slug', ] diff --git a/apps/collection/tests.py b/apps/collection/tests.py index ea13fff9..72b40c37 100644 --- a/apps/collection/tests.py +++ b/apps/collection/tests.py @@ -1,15 +1,15 @@ -import json, pytz +import json +import pytz from datetime import datetime -from rest_framework.test import APITestCase -from account.models import User -from rest_framework import status from http.cookies import SimpleCookie -from collection.models import Collection, Guide -from location.models import Country +from rest_framework import status +from rest_framework.test import APITestCase +from account.models import User +from collection.models import Collection, Guide from establishment.models import Establishment, EstablishmentType -# Create your tests here. +from location.models import Country class BaseTestCase(APITestCase): diff --git a/apps/collection/urls/common.py b/apps/collection/urls/common.py index 7ffa50cf..36801ac5 100644 --- a/apps/collection/urls/common.py +++ b/apps/collection/urls/common.py @@ -7,6 +7,7 @@ app_name = 'collection' urlpatterns = [ path('', views.CollectionHomePageView.as_view(), name='list'), + path('/', views.CollectionDetailView.as_view(), name='detail'), path('/establishments/', views.CollectionEstablishmentListView.as_view(), name='detail'), diff --git a/apps/collection/views/common.py b/apps/collection/views/common.py index 148c5fab..5bf8f70e 100644 --- a/apps/collection/views/common.py +++ b/apps/collection/views/common.py @@ -4,7 +4,7 @@ from rest_framework import permissions from collection import models from utils.pagination import ProjectPageNumberPagination from django.shortcuts import get_object_or_404 -from establishment.serializers import EstablishmentListSerializer +from establishment.serializers import EstablishmentBaseSerializer from collection.serializers import common as serializers @@ -12,7 +12,14 @@ from collection.serializers import common as serializers class CollectionViewMixin(generics.GenericAPIView): """Mixin for Collection view""" model = models.Collection - queryset = models.Collection.objects.all() + permission_classes = (permissions.AllowAny,) + serializer_class = serializers.CollectionSerializer + + def get_queryset(self): + """Override get_queryset method.""" + return models.Collection.objects.published() \ + .by_country_code(code=self.request.country_code) \ + .order_by('-on_top', '-modified') class GuideViewMixin(generics.GenericAPIView): @@ -24,40 +31,29 @@ class GuideViewMixin(generics.GenericAPIView): # Views # Collections class CollectionListView(CollectionViewMixin, generics.ListAPIView): - """List Collection view""" - permission_classes = (permissions.AllowAny,) - serializer_class = serializers.CollectionSerializer + """List Collection view.""" + + +class CollectionHomePageView(CollectionListView): + """Collection list view for home page.""" def get_queryset(self): - """Override get_queryset method""" - queryset = models.Collection.objects.published()\ - .by_country_code(code=self.request.country_code)\ - .order_by('-on_top', '-created') - - return queryset + """Override get_queryset.""" + return super(CollectionHomePageView, self).get_queryset() \ + .filter_all_related_gt(3) -class CollectionHomePageView(CollectionViewMixin, generics.ListAPIView): - """List Collection view""" - permission_classes = (permissions.AllowAny,) - serializer_class = serializers.CollectionSerializer - - def get_queryset(self): - """Override get_queryset method""" - queryset = models.Collection.objects.published()\ - .by_country_code(code=self.request.country_code)\ - .filter_all_related_gt(3)\ - .order_by('-on_top', '-modified') - - return queryset +class CollectionDetailView(CollectionViewMixin, generics.RetrieveAPIView): + """Retrieve detail of Collection instance.""" + lookup_field = 'slug' + serializer_class = serializers.CollectionBaseSerializer class CollectionEstablishmentListView(CollectionListView): """Retrieve list of establishment for collection.""" - permission_classes = (permissions.AllowAny,) - pagination_class = ProjectPageNumberPagination - serializer_class = EstablishmentListSerializer lookup_field = 'slug' + pagination_class = ProjectPageNumberPagination + serializer_class = EstablishmentBaseSerializer def get_queryset(self): """ diff --git a/apps/establishment/models.py b/apps/establishment/models.py index f677737d..5c2a0ff0 100644 --- a/apps/establishment/models.py +++ b/apps/establishment/models.py @@ -8,13 +8,14 @@ from django.contrib.gis.geos import Point from django.contrib.gis.measure import Distance as DistanceMeasure from django.core.exceptions import ValidationError from django.db import models -from django.db.models import When, Case, F, ExpressionWrapper, Subquery +from django.db.models import When, Case, F, ExpressionWrapper, Subquery, Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from elasticsearch_dsl import Q from phonenumber_field.modelfields import PhoneNumberField from collection.models import Collection -from main.models import MetaDataContent +from main.models import Award, MetaDataContent from location.models import Address from review.models import Review from utils.models import (ProjectBaseMixin, TJSONField, URLImageMixin, @@ -98,6 +99,16 @@ class EstablishmentQuerySet(models.QuerySet): else: return self.none() + def es_search(self, value, locale=None): + """Search text via ElasticSearch.""" + from search_indexes.documents import EstablishmentDocument + search = EstablishmentDocument.search().filter( + Q('match', name=value) | + Q('match', **{f'description.{locale}': value}) + ).execute() + ids = [result.meta.id for result in search] + return self.filter(id__in=ids) + def by_country_code(self, code): """Return establishments by country code""" return self.filter(address__city__country__code=code) @@ -281,7 +292,7 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): slug = models.SlugField(unique=True, max_length=50, null=True, verbose_name=_('Establishment slug'), editable=True) - awards = generic.GenericRelation(to='main.Award') + awards = generic.GenericRelation(to='main.Award', related_query_name='establishment') tags = generic.GenericRelation(to='main.MetaDataContent') reviews = generic.GenericRelation(to='review.Review') comments = generic.GenericRelation(to='comment.Comment') @@ -330,6 +341,12 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): raise ValidationError('Establishment type of subtype does not match') self.establishment_subtypes.add(establishment_subtype) + @property + def vintage_year(self): + last_review = self.reviews.by_status(Review.READY).last() + if last_review: + return last_review.vintage + @property def best_price_menu(self): return 150 @@ -356,6 +373,11 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): """ return self.address.coordinates + @property + def the_most_recent_award(self): + return Award.objects.filter(Q(establishment=self) | Q(employees__establishments=self)).latest( + field_name='vintage_year') + class Position(BaseAttributes, TranslatedFieldsMixin): """Position model.""" @@ -409,8 +431,8 @@ class Employee(BaseAttributes): verbose_name=_('User')) name = models.CharField(max_length=255, verbose_name=_('Last name')) establishments = models.ManyToManyField(Establishment, related_name='employees', - through=EstablishmentEmployee) - awards = generic.GenericRelation(to='main.Award') + through=EstablishmentEmployee,) + awards = generic.GenericRelation(to='main.Award', related_query_name='employees') tags = generic.GenericRelation(to='main.MetaDataContent') class Meta: diff --git a/apps/establishment/serializers/back.py b/apps/establishment/serializers/back.py index 5a15aafc..d0c70b2f 100644 --- a/apps/establishment/serializers/back.py +++ b/apps/establishment/serializers/back.py @@ -8,7 +8,6 @@ from establishment.serializers import ( from utils.decorators import with_base_attributes from main.models import Currency -from utils.serializers import TJSONSerializer class EstablishmentListCreateSerializer(EstablishmentBaseSerializer): @@ -89,13 +88,15 @@ class SocialNetworkSerializers(serializers.ModelSerializer): class PlatesSerializers(PlateSerializer): """Social network serializers.""" - name = TJSONSerializer + currency_id = serializers.PrimaryKeyRelatedField( source='currency', queryset=Currency.objects.all(), write_only=True ) class Meta: + """Meta class.""" + model = models.Plate fields = PlateSerializer.Meta.fields + [ 'name', diff --git a/apps/establishment/serializers/common.py b/apps/establishment/serializers/common.py index 69a029e2..f09c8200 100644 --- a/apps/establishment/serializers/common.py +++ b/apps/establishment/serializers/common.py @@ -1,18 +1,17 @@ """Establishment serializers.""" -from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ +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 AddressSimpleSerializer +from location.serializers import AddressBaseSerializer from main.models import MetaDataContent from main.serializers import MetaDataContentSerializer, AwardSerializer, CurrencySerializer from review import models as review_models from timetable.serialziers import ScheduleRUDSerializer from utils import exceptions as utils_exceptions -from utils.serializers import TranslatedField -from utils.serializers import TJSONSerializer +from utils.serializers import TranslatedField, ProjectModelSerializer class ContactPhonesSerializer(serializers.ModelSerializer): @@ -44,9 +43,9 @@ class SocialNetworkRelatedSerializers(serializers.ModelSerializer): ] -class PlateSerializer(serializers.ModelSerializer): +class PlateSerializer(ProjectModelSerializer): - name_translated = serializers.CharField(allow_null=True, read_only=True) + name_translated = TranslatedField() currency = CurrencySerializer(read_only=True) class Meta: @@ -59,9 +58,8 @@ class PlateSerializer(serializers.ModelSerializer): ] -class MenuSerializers(serializers.ModelSerializer): +class MenuSerializers(ProjectModelSerializer): plates = PlateSerializer(read_only=True, many=True, source='plate_set') - category = TJSONSerializer() category_translated = serializers.CharField(read_only=True) class Meta: @@ -75,9 +73,8 @@ class MenuSerializers(serializers.ModelSerializer): ] -class MenuRUDSerializers(serializers.ModelSerializer, ): +class MenuRUDSerializers(ProjectModelSerializer): plates = PlateSerializer(read_only=True, many=True, source='plate_set') - category = TJSONSerializer() class Meta: model = models.Menu @@ -141,13 +138,14 @@ class EstablishmentEmployeeSerializer(serializers.ModelSerializer): fields = ('id', 'name', 'position_translated', 'awards', 'priority') -class EstablishmentBaseSerializer(serializers.ModelSerializer): +class EstablishmentBaseSerializer(ProjectModelSerializer): """Base serializer for Establishment model.""" preview_image = serializers.URLField(source='preview_image_url') slug = serializers.SlugField(allow_blank=False, required=True, max_length=50) - address = AddressSimpleSerializer() + address = AddressBaseSerializer() tags = MetaDataContentSerializer(many=True) + in_favorites = serializers.BooleanField(allow_null=True) class Meta: """Meta class.""" @@ -168,20 +166,7 @@ class EstablishmentBaseSerializer(serializers.ModelSerializer): ] -class EstablishmentListSerializer(EstablishmentBaseSerializer): - """Serializer for Establishment model.""" - - in_favorites = serializers.BooleanField(allow_null=True) - - class Meta(EstablishmentBaseSerializer.Meta): - """Meta class.""" - - fields = EstablishmentBaseSerializer.Meta.fields + [ - 'in_favorites', - ] - - -class EstablishmentDetailSerializer(EstablishmentListSerializer): +class EstablishmentDetailSerializer(EstablishmentBaseSerializer): """Serializer for Establishment model.""" description_translated = TranslatedField() @@ -198,11 +183,12 @@ class EstablishmentDetailSerializer(EstablishmentListSerializer): menu = MenuSerializers(source='menu_set', many=True, read_only=True) best_price_menu = serializers.DecimalField(max_digits=14, decimal_places=2, read_only=True) best_price_carte = serializers.DecimalField(max_digits=14, decimal_places=2, read_only=True) + vintage_year = serializers.ReadOnlyField() - class Meta(EstablishmentListSerializer.Meta): + class Meta(EstablishmentBaseSerializer.Meta): """Meta class.""" - fields = EstablishmentListSerializer.Meta.fields + [ + fields = EstablishmentBaseSerializer.Meta.fields + [ 'description_translated', 'image', 'subtypes', @@ -222,18 +208,9 @@ class EstablishmentDetailSerializer(EstablishmentListSerializer): 'best_price_menu', 'best_price_carte', 'transportation', + 'vintage_year', ] - # def get_in_favorites(self, obj): - # """Get in_favorites status flag""" - # user = self.context.get('request').user - # if user.is_authenticated: - # return obj.id in user.favorites.by_content_type(app_label='establishment', - # model='establishment')\ - # .values_list('object_id', flat=True) - # else: - # return False - class EstablishmentCommentCreateSerializer(comment_serializers.CommentSerializer): """Create comment serializer""" diff --git a/apps/establishment/views/web.py b/apps/establishment/views/web.py index 4ece55c0..8f5d2a26 100644 --- a/apps/establishment/views/web.py +++ b/apps/establishment/views/web.py @@ -28,7 +28,7 @@ class EstablishmentListView(EstablishmentMixinView, generics.ListAPIView): """Resource for getting a list of establishments.""" filter_class = filters.EstablishmentFilter - serializer_class = serializers.EstablishmentListSerializer + serializer_class = serializers.EstablishmentBaseSerializer def get_queryset(self): """Overridden method 'get_queryset'.""" @@ -70,7 +70,8 @@ class EstablishmentRecentReviewListView(EstablishmentListView): class EstablishmentSimilarListView(EstablishmentListView): """Resource for getting a list of establishments.""" - serializer_class = serializers.EstablishmentListSerializer + + serializer_class = serializers.EstablishmentBaseSerializer pagination_class = EstablishmentPortionPagination def get_queryset(self): @@ -96,6 +97,7 @@ class EstablishmentCommentCreateView(generics.CreateAPIView): class EstablishmentCommentListView(generics.ListAPIView): """View for return list of establishment comments.""" + permission_classes = (permissions.AllowAny,) serializer_class = serializers.EstablishmentCommentCreateSerializer @@ -153,11 +155,13 @@ class EstablishmentFavoritesCreateDestroyView(generics.CreateAPIView, generics.D class EstablishmentNearestRetrieveView(EstablishmentListView, generics.ListAPIView): """Resource for getting list of nearest establishments.""" - serializer_class = serializers.EstablishmentListSerializer + + serializer_class = serializers.EstablishmentBaseSerializer filter_class = filters.EstablishmentFilter def get_queryset(self): """Overridden method 'get_queryset'.""" + # todo: latitude and longitude lat = self.request.query_params.get('lat') lon = self.request.query_params.get('lon') radius = self.request.query_params.get('radius') diff --git a/apps/favorites/views.py b/apps/favorites/views.py index a80960a8..5d99ed4b 100644 --- a/apps/favorites/views.py +++ b/apps/favorites/views.py @@ -1,13 +1,13 @@ """Views for app favorites.""" from rest_framework import generics - from establishment.models import Establishment -from establishment.serializers import EstablishmentListSerializer +from establishment.serializers import EstablishmentBaseSerializer from .models import Favorites class FavoritesBaseView(generics.GenericAPIView): """Base view for Favorites.""" + def get_queryset(self): """Override get_queryset method.""" return Favorites.objects.by_user(self.request.user) @@ -15,7 +15,8 @@ class FavoritesBaseView(generics.GenericAPIView): class FavoritesEstablishmentListView(generics.ListAPIView): """List views for favorites""" - serializer_class = EstablishmentListSerializer + + serializer_class = EstablishmentBaseSerializer def get_queryset(self): """Override get_queryset method""" diff --git a/apps/location/models.py b/apps/location/models.py index 7084385f..1cab1815 100644 --- a/apps/location/models.py +++ b/apps/location/models.py @@ -71,6 +71,7 @@ class City(models.Model): class Address(models.Model): + """Address model.""" city = models.ForeignKey(City, verbose_name=_('city'), on_delete=models.CASCADE) street_name_1 = models.CharField( @@ -98,11 +99,11 @@ class Address(models.Model): @property def latitude(self): - return self.coordinates.y + return self.coordinates.y if self.coordinates else float(0) @property def longitude(self): - return self.coordinates.x + return self.coordinates.x if self.coordinates else float(0) @property def location_field_indexing(self): diff --git a/apps/location/serializers/back.py b/apps/location/serializers/back.py index f3b36e64..f25aacf6 100644 --- a/apps/location/serializers/back.py +++ b/apps/location/serializers/back.py @@ -1,11 +1,8 @@ -from django.contrib.gis.geos import Point -from rest_framework import serializers - from location import models from location.serializers import common -class AddressCreateSerializer(common.AddressSerializer): +class AddressCreateSerializer(common.AddressDetailSerializer): """Address create serializer.""" diff --git a/apps/location/serializers/common.py b/apps/location/serializers/common.py index 1dee92ed..87d0df4e 100644 --- a/apps/location/serializers/common.py +++ b/apps/location/serializers/common.py @@ -1,5 +1,6 @@ """Location app common serializers.""" from django.contrib.gis.geos import Point +from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers from location import models from utils.serializers import TranslatedField @@ -83,55 +84,18 @@ class CitySerializer(serializers.ModelSerializer): ] -class AddressSerializer(serializers.ModelSerializer): - """Address serializer.""" - - city_id = serializers.PrimaryKeyRelatedField( - source='city', - queryset=models.City.objects.all()) - city = CitySerializer(read_only=True) - geo_lon = serializers.FloatField(allow_null=True) - geo_lat = serializers.FloatField(allow_null=True) - - class Meta: - model = models.Address - fields = [ - 'id', - 'city_id', - 'city', - 'street_name_1', - 'street_name_2', - 'number', - 'postal_code', - 'geo_lon', - 'geo_lat' - ] - - def validate(self, attrs): - # if geo_lat and geo_lon was sent - geo_lat = attrs.pop('geo_lat') if 'geo_lat' in attrs else None - geo_lon = attrs.pop('geo_lon') if 'geo_lon' in attrs else None - - if geo_lat and geo_lon: - # Point(longitude, latitude) - attrs['coordinates'] = Point(geo_lat, geo_lon) - return attrs - - def to_representation(self, instance): - """Override to_representation method""" - if instance.coordinates and isinstance(instance.coordinates, Point): - # Point(longitude, latitude) - setattr(instance, 'geo_lat', instance.coordinates.x) - setattr(instance, 'geo_lon', instance.coordinates.y) - else: - setattr(instance, 'geo_lat', float(0)) - setattr(instance, 'geo_lon', float(0)) - return super().to_representation(instance) - - -class AddressSimpleSerializer(serializers.ModelSerializer): +class AddressBaseSerializer(serializers.ModelSerializer): """Serializer for address obj in related objects.""" + latitude = serializers.FloatField(allow_null=True) + longitude = serializers.FloatField(allow_null=True) + + # todo: remove this fields (backward compatibility) + geo_lon = serializers.FloatField(source='longitude', allow_null=True, + read_only=True) + geo_lat = serializers.FloatField(source='latitude', allow_null=True, + read_only=True) + class Meta: """Meta class.""" @@ -142,4 +106,45 @@ class AddressSimpleSerializer(serializers.ModelSerializer): 'street_name_2', 'number', 'postal_code', + 'latitude', + 'longitude', + + # todo: remove this fields (backward compatibility) + 'geo_lon', + 'geo_lat', + ) + + def validate_latitude(self, value): + if -90 <= value <= 90: + return value + raise serializers.ValidationError(_('Invalid value')) + + def validate_longitude(self, value): + if -180 <= value <= 180: + return value + raise serializers.ValidationError(_('Invalid value')) + + def validate(self, attrs): + # validate coordinates + latitude = attrs.pop('latitude', None) + longitude = attrs.pop('longitude', None) + if latitude is not None and longitude is not None: + attrs['coordinates'] = Point(longitude, latitude) + return attrs + + +class AddressDetailSerializer(AddressBaseSerializer): + """Address serializer.""" + + city_id = serializers.PrimaryKeyRelatedField( + source='city', write_only=True, + queryset=models.City.objects.all()) + city = CitySerializer(read_only=True) + + class Meta(AddressBaseSerializer.Meta): + """Meta class.""" + + fields = AddressBaseSerializer.Meta.fields + ( + 'city_id', + 'city', ) diff --git a/apps/location/views/back.py b/apps/location/views/back.py index 69ec28bc..ce6589ed 100644 --- a/apps/location/views/back.py +++ b/apps/location/views/back.py @@ -1,6 +1,5 @@ """Location app views.""" from rest_framework import generics -from rest_framework import permissions from location import models, serializers from location.views import common @@ -9,13 +8,13 @@ from location.views import common # Address class AddressListCreateView(common.AddressViewMixin, generics.ListCreateAPIView): """Create view for model Address.""" - serializer_class = serializers.AddressSerializer + serializer_class = serializers.AddressDetailSerializer queryset = models.Address.objects.all() class AddressRUDView(common.AddressViewMixin, generics.RetrieveUpdateDestroyAPIView): """RUD view for model Address.""" - serializer_class = serializers.AddressSerializer + serializer_class = serializers.AddressDetailSerializer queryset = models.Address.objects.all() diff --git a/apps/location/views/common.py b/apps/location/views/common.py index 6bc332ad..792fce91 100644 --- a/apps/location/views/common.py +++ b/apps/location/views/common.py @@ -100,17 +100,17 @@ class CityUpdateView(CityViewMixin, generics.UpdateAPIView): # Address class AddressCreateView(AddressViewMixin, generics.CreateAPIView): """Create view for model Address""" - serializer_class = serializers.AddressSerializer + serializer_class = serializers.AddressDetailSerializer class AddressRetrieveView(AddressViewMixin, generics.RetrieveAPIView): """Retrieve view for model Address""" - serializer_class = serializers.AddressSerializer + serializer_class = serializers.AddressDetailSerializer class AddressListView(AddressViewMixin, generics.ListAPIView): """List view for model Address""" permission_classes = (permissions.AllowAny, ) - serializer_class = serializers.AddressSerializer + serializer_class = serializers.AddressDetailSerializer diff --git a/apps/main/models.py b/apps/main/models.py index 47ac90be..fa6cf7d1 100644 --- a/apps/main/models.py +++ b/apps/main/models.py @@ -318,9 +318,9 @@ class Carousel(models.Model): @property def vintage_year(self): if hasattr(self.content_object, 'reviews'): - review_qs = self.content_object.reviews.by_status(Review.READY) - if review_qs.exists(): - return review_qs.last().vintage + last_review = self.content_object.reviews.by_status(Review.READY).last() + if last_review: + return last_review.vintage @property def toque_number(self): @@ -337,9 +337,21 @@ class Carousel(models.Model): if hasattr(self.content_object, 'image_url'): return self.content_object.image_url + @property + def slug(self): + if hasattr(self.content_object, 'slug'): + return self.content_object.slug + + @property + def the_most_recent_award(self): + if hasattr(self.content_object, 'the_most_recent_award'): + return self.content_object.the_most_recent_award + @property def model_name(self): - return self.content_object.__class__.__name__ + if hasattr(self.content_object, 'establishment_type'): + return self.content_object.establishment_type.name_translated + class Page(models.Model): diff --git a/apps/main/serializers.py b/apps/main/serializers.py index c981f0ee..03ea73e6 100644 --- a/apps/main/serializers.py +++ b/apps/main/serializers.py @@ -3,6 +3,7 @@ from rest_framework import serializers from advertisement.serializers.web import AdvertisementSerializer from location.serializers import CountrySerializer from main import models +from establishment.models import Establishment from utils.serializers import TranslatedField @@ -141,6 +142,7 @@ class CarouselListSerializer(serializers.ModelSerializer): image = serializers.URLField(source='image_url') awards = AwardBaseSerializer(many=True) vintage_year = serializers.IntegerField() + last_award = AwardBaseSerializer(source='the_most_recent_award', allow_null=True) class Meta: """Meta class.""" @@ -154,6 +156,8 @@ class CarouselListSerializer(serializers.ModelSerializer): 'public_mark', 'image', 'vintage_year', + 'last_award', + 'slug', ] diff --git a/apps/news/admin.py b/apps/news/admin.py index 7cbfb049..77ea8388 100644 --- a/apps/news/admin.py +++ b/apps/news/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin -from news import models +from news import models +from .tasks import send_email_with_news @admin.register(models.NewsType) class NewsTypeAdmin(admin.ModelAdmin): @@ -9,6 +10,17 @@ class NewsTypeAdmin(admin.ModelAdmin): list_display_links = ['id', 'name'] +def send_email_action(modeladmin, request, queryset): + news_ids = list(queryset.values_list("id", flat=True)) + + send_email_with_news.delay(news_ids) + + + +send_email_action.short_description = "Send the selected news by email" + + @admin.register(models.News) class NewsAdmin(admin.ModelAdmin): """News admin.""" + actions = [send_email_action] diff --git a/apps/news/migrations/0020_remove_news_author.py b/apps/news/migrations/0020_remove_news_author.py new file mode 100644 index 00000000..95f376c9 --- /dev/null +++ b/apps/news/migrations/0020_remove_news_author.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.4 on 2019-10-02 09:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0019_news_author'), + ] + + operations = [ + migrations.RemoveField( + model_name='news', + name='author', + ), + ] diff --git a/apps/news/models.py b/apps/news/models.py index e6967d00..6e91b912 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -5,6 +5,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from rest_framework.reverse import reverse from utils.models import BaseAttributes, TJSONField, TranslatedFieldsMixin +from rating.models import Rating class NewsType(models.Model): @@ -26,10 +27,24 @@ class NewsType(models.Model): class NewsQuerySet(models.QuerySet): """QuerySet for model News""" + def rating_value(self): + return self.annotate(rating=models.Count('ratings__ip', distinct=True)) + + def with_base_related(self): + """Return qs with related objects.""" + return self.select_related('news_type', 'country').prefetch_related('tags') + + def with_extended_related(self): + """Return qs with related objects.""" + return self.select_related('created_by') + def by_type(self, news_type): """Filter News by type""" return self.filter(news_type__name=news_type) + def by_tags(self, tags): + return self.filter(tags__in=tags) + def by_country_code(self, code): """Filter collection by country code.""" return self.filter(country__code=code) @@ -41,9 +56,16 @@ class NewsQuerySet(models.QuerySet): models.Q(end__isnull=True)), state__in=self.model.PUBLISHED_STATES, start__lte=now) - def with_related(self): - """Return qs with related objects.""" - return self.select_related('news_type', 'country').prefetch_related('tags') + # todo: filter by best score + # todo: filter by country? + def should_read(self, news): + return self.model.objects.exclude(pk=news.pk).published().\ + with_base_related().by_type(news.news_type).distinct().order_by('?') + + def same_theme(self, news): + return self.model.objects.exclude(pk=news.pk).published().\ + with_base_related().by_type(news.news_type).\ + by_tags(news.tags.all()).distinct().order_by('-start') class News(BaseAttributes, TranslatedFieldsMixin): @@ -94,15 +116,8 @@ class News(BaseAttributes, TranslatedFieldsMixin): slug = models.SlugField(unique=True, max_length=50, verbose_name=_('News slug')) playlist = models.IntegerField(_('playlist')) - - # author = models.CharField(max_length=255, blank=True, null=True, - # default=None,verbose_name=_('Author')) - state = models.PositiveSmallIntegerField(default=WAITING, choices=STATE_CHOICES, verbose_name=_('State')) - author = models.CharField(max_length=255, blank=True, null=True, - default=None,verbose_name=_('Author')) - is_highlighted = models.BooleanField(default=False, verbose_name=_('Is highlighted')) # TODO: metadata_keys - описание ключей для динамического построения полей метаданных @@ -120,6 +135,8 @@ class News(BaseAttributes, TranslatedFieldsMixin): verbose_name=_('country')) tags = generic.GenericRelation(to='main.MetaDataContent') + ratings = generic.GenericRelation(Rating) + objects = NewsQuerySet.as_manager() class Meta: @@ -138,3 +155,12 @@ class News(BaseAttributes, TranslatedFieldsMixin): @property def web_url(self): return reverse('web:news:rud', kwargs={'slug': self.slug}) + + @property + def should_read(self): + return self.__class__.objects.should_read(self)[:3] + + @property + def same_theme(self): + return self.__class__.objects.same_theme(self)[:3] + diff --git a/apps/news/serializers.py b/apps/news/serializers.py index a01da501..c473be1d 100644 --- a/apps/news/serializers.py +++ b/apps/news/serializers.py @@ -1,11 +1,12 @@ """News app common serializers.""" from rest_framework import serializers +from account.serializers.common import UserBaseSerializer from location import models as location_models from location.serializers import CountrySimpleSerializer from main.serializers import MetaDataContentSerializer from news import models -from utils.serializers import TranslatedField -from account.serializers.common import UserSerializer +from utils.serializers import TranslatedField, ProjectModelSerializer + class NewsTypeSerializer(serializers.ModelSerializer): """News type serializer.""" @@ -17,7 +18,7 @@ class NewsTypeSerializer(serializers.ModelSerializer): fields = ('id', 'name') -class NewsBaseSerializer(serializers.ModelSerializer): +class NewsBaseSerializer(ProjectModelSerializer): """Base serializer for News model.""" # read only fields @@ -50,7 +51,7 @@ class NewsDetailSerializer(NewsBaseSerializer): description_translated = TranslatedField() country = CountrySimpleSerializer(read_only=True) - author = UserSerializer(source='created_by') + author = UserBaseSerializer(source='created_by', read_only=True) state_display = serializers.CharField(source='get_state_display', read_only=True) @@ -70,6 +71,21 @@ 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) + + class Meta(NewsDetailSerializer.Meta): + """Meta class.""" + + fields = NewsDetailSerializer.Meta.fields + ( + 'same_theme', + 'should_read', + ) + + class NewsBackOfficeBaseSerializer(NewsBaseSerializer): """News back office base serializer.""" diff --git a/apps/news/tasks.py b/apps/news/tasks.py new file mode 100644 index 00000000..99065fc8 --- /dev/null +++ b/apps/news/tasks.py @@ -0,0 +1,28 @@ +from celery import shared_task +from django.core.mail import send_mail +from notification.models import Subscriber +from news import models +from django.template.loader import render_to_string +from django.conf import settings +from smtplib import SMTPException + + +@shared_task +def send_email_with_news(news_ids): + + subscribers = Subscriber.objects.filter(state=Subscriber.USABLE) + sent_news = models.News.objects.filter(id__in=news_ids) + + for s in subscribers: + try: + for n in sent_news: + send_mail("G&M News", render_to_string(settings.NEWS_EMAIL_TEMPLATE, + {"title": n.title.get(s.locale), + "subtitle": n.subtitle.get(s.locale), + "description": n.description.get(s.locale), + "code": s.update_code, + "domain_uri": settings.DOMAIN_URI, + "country_code": s.country_code}), + settings.EMAIL_HOST_USER, [s.send_to], fail_silently=False) + except SMTPException: + continue diff --git a/apps/news/views.py b/apps/news/views.py index 74abe33f..61a57251 100644 --- a/apps/news/views.py +++ b/apps/news/views.py @@ -1,7 +1,7 @@ """News app views.""" from rest_framework import generics, permissions from news import filters, models, serializers - +from rating.tasks import add_rating class NewsMixinView: """News mixin.""" @@ -11,7 +11,7 @@ class NewsMixinView: def get_queryset(self, *args, **kwargs): """Override get_queryset method.""" - qs = models.News.objects.with_related().published()\ + qs = models.News.objects.published().with_base_related()\ .order_by('-is_highlighted', '-created') if self.request.country_code: qs = qs.by_country_code(self.request.country_code) @@ -20,14 +20,19 @@ class NewsMixinView: class NewsListView(NewsMixinView, generics.ListAPIView): """News list view.""" + filter_class = filters.NewsListFilterSet class NewsDetailView(NewsMixinView, generics.RetrieveAPIView): """News detail view.""" - lookup_field = 'slug' - serializer_class = serializers.NewsDetailSerializer + lookup_field = 'slug' + serializer_class = serializers.NewsDetailWebSerializer + + def get_queryset(self): + """Override get_queryset method.""" + return super().get_queryset().with_extended_related() class NewsTypeListView(generics.ListAPIView): """NewsType list view.""" @@ -42,7 +47,7 @@ class NewsBackOfficeMixinView: """News back office mixin view.""" permission_classes = (permissions.IsAuthenticated,) - queryset = models.News.objects.with_related() \ + queryset = models.News.objects.with_base_related() \ .order_by('-is_highlighted', '-created') @@ -54,10 +59,15 @@ class NewsBackOfficeLCView(NewsBackOfficeMixinView, create_serializers_class = serializers.NewsBackOfficeDetailSerializer def get_serializer_class(self): + """Override serializer class.""" if self.request.method == 'POST': return self.create_serializers_class return super().get_serializer_class() + def get_queryset(self): + """Override get_queryset method.""" + return super().get_queryset().with_extended_related() + class NewsBackOfficeRUDView(NewsBackOfficeMixinView, generics.RetrieveUpdateDestroyAPIView): @@ -65,3 +75,7 @@ class NewsBackOfficeRUDView(NewsBackOfficeMixinView, serializer_class = serializers.NewsBackOfficeDetailSerializer + def get(self, request, pk, *args, **kwargs): + add_rating(remote_addr=request.META.get('REMOTE_ADDR'), + pk=pk, model='news', app_label='news') + return self.retrieve(request, *args, **kwargs) diff --git a/apps/notification/urls/common.py b/apps/notification/urls/common.py index df43c805..842aa642 100644 --- a/apps/notification/urls/common.py +++ b/apps/notification/urls/common.py @@ -2,6 +2,7 @@ from django.urls import path from notification.views import common +app_name = "notification" urlpatterns = [ path('subscribe/', common.SubscribeView.as_view(), name='subscribe'), diff --git a/apps/rating/__init__.py b/apps/rating/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/rating/admin.py b/apps/rating/admin.py new file mode 100644 index 00000000..151f406b --- /dev/null +++ b/apps/rating/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from rating import models +from rating import tasks + +@admin.register(models.Rating) +class RatingAdmin(admin.ModelAdmin): + """Rating type admin conf.""" + list_display = ['name', 'ip'] + list_display_links = ['name'] + + diff --git a/apps/rating/apps.py b/apps/rating/apps.py new file mode 100644 index 00000000..6f17a343 --- /dev/null +++ b/apps/rating/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class RatingConfig(AppConfig): + name = 'rating' diff --git a/apps/rating/migrations/0001_initial.py b/apps/rating/migrations/0001_initial.py new file mode 100644 index 00000000..03165670 --- /dev/null +++ b/apps/rating/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.4 on 2019-10-02 11:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='Rating', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('ip', models.GenericIPAddressField(verbose_name='ip')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + options={ + 'verbose_name': 'rating', + }, + ), + ] diff --git a/apps/rating/migrations/0002_auto_20191004_0928.py b/apps/rating/migrations/0002_auto_20191004_0928.py new file mode 100644 index 00000000..a172c6f1 --- /dev/null +++ b/apps/rating/migrations/0002_auto_20191004_0928.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.4 on 2019-10-04 09:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('rating', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='rating', + options={}, + ), + migrations.AlterUniqueTogether( + name='rating', + unique_together={('ip', 'object_id', 'content_type')}, + ), + ] diff --git a/apps/rating/migrations/__init__.py b/apps/rating/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/rating/models.py b/apps/rating/models.py new file mode 100644 index 00000000..e1dcec86 --- /dev/null +++ b/apps/rating/models.py @@ -0,0 +1,22 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class Rating(models.Model): + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + ip = models.GenericIPAddressField(verbose_name=_('ip')) + + class Meta: + unique_together = ('ip', 'object_id', 'content_type') + + @property + def name(self): + # Check if Generic obj has name or title + if hasattr(self.content_object, 'name'): + return self.content_object.name + if hasattr(self.content_object, 'title'): + return self.content_object.title_translated diff --git a/apps/rating/tasks.py b/apps/rating/tasks.py new file mode 100644 index 00000000..5c2653a0 --- /dev/null +++ b/apps/rating/tasks.py @@ -0,0 +1,18 @@ +from celery import shared_task +from rating.models import Rating +from django.contrib.contenttypes.models import ContentType + + +def add_rating(remote_addr, pk, model, app_label): + add.apply_async( + (remote_addr, pk, model, app_label), countdown=60 * 60 + ) + + +@shared_task +def add(remote_addr, pk, model, app_label): + content_type = ContentType.objects.get(app_label=app_label, model=model) + Rating.objects.get_or_create( + ip=remote_addr, object_id=pk, content_type=content_type) + + diff --git a/apps/rating/tests.py b/apps/rating/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/apps/rating/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/rating/views.py b/apps/rating/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/apps/rating/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/search_indexes/documents/establishment.py b/apps/search_indexes/documents/establishment.py index 16321723..2d43154e 100644 --- a/apps/search_indexes/documents/establishment.py +++ b/apps/search_indexes/documents/establishment.py @@ -14,6 +14,7 @@ EstablishmentIndex.settings(number_of_shards=1, number_of_replicas=1) class EstablishmentDocument(Document): """Establishment document.""" + preview_image = fields.KeywordField(attr='preview_image_url') description = fields.ObjectField(attr='description_indexing', properties=OBJECT_FIELD_PROPERTIES) establishment_type = fields.ObjectField( @@ -50,7 +51,9 @@ class EstablishmentDocument(Document): fields={'raw': fields.KeywordField()} ), 'number': fields.IntegerField(), - 'location': fields.GeoPointField(attr='location_field_indexing'), + 'postal_code': fields.KeywordField(), + 'coordinates': fields.GeoPointField(attr='location_field_indexing'), + # todo: remove if not used 'city': fields.ObjectField( properties={ 'id': fields.IntegerField(), @@ -82,9 +85,10 @@ class EstablishmentDocument(Document): fields = ( 'id', 'name', - 'toque_number', + 'name_translated', 'price_level', - 'preview_image_url', + 'toque_number', + 'public_mark', 'slug', ) diff --git a/apps/search_indexes/documents/news.py b/apps/search_indexes/documents/news.py index 45e50c42..6e0974d8 100644 --- a/apps/search_indexes/documents/news.py +++ b/apps/search_indexes/documents/news.py @@ -13,18 +13,26 @@ NewsIndex.settings(number_of_shards=1, number_of_replicas=1) class NewsDocument(Document): """News document.""" - news_type = fields.NestedField(properties={ - 'id': fields.IntegerField(), - 'name': fields.KeywordField() - }) - title = fields.ObjectField(properties=OBJECT_FIELD_PROPERTIES) - subtitle = fields.ObjectField(properties=OBJECT_FIELD_PROPERTIES) - description = fields.ObjectField(properties=OBJECT_FIELD_PROPERTIES) - country = fields.NestedField(properties={ - 'id': fields.IntegerField(), - 'code': fields.KeywordField() - }) + news_type = fields.ObjectField(properties={'id': fields.IntegerField(), + 'name': fields.KeywordField()}) + title = fields.ObjectField(attr='title_indexing', + properties=OBJECT_FIELD_PROPERTIES) + subtitle = fields.ObjectField(attr='subtitle_indexing', + properties=OBJECT_FIELD_PROPERTIES) + description = fields.ObjectField(attr='description_indexing', + properties=OBJECT_FIELD_PROPERTIES) + country = fields.ObjectField(properties={'id': fields.IntegerField(), + 'code': fields.KeywordField()}) web_url = fields.KeywordField(attr='web_url') + tags = fields.ObjectField( + properties={ + 'id': fields.IntegerField(attr='metadata.id'), + 'label': fields.ObjectField(attr='metadata.label_indexing', + properties=OBJECT_FIELD_PROPERTIES), + 'category': fields.ObjectField(attr='metadata.category', + properties={'id': fields.IntegerField()}) + }, + multi=True) class Django: @@ -32,20 +40,19 @@ class NewsDocument(Document): fields = ( 'id', 'playlist', + 'start', + 'end', + 'slug', + 'state', + 'is_highlighted', + 'image_url', + 'preview_image_url', + 'template', ) related_models = [models.NewsType] def get_queryset(self): - return super().get_queryset().published() - - def prepare_title(self, instance): - return instance.title - - def prepare_subtitle(self, instance): - return instance.subtitle - - def prepare_description(self, instance): - return instance.description + 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/serializers.py b/apps/search_indexes/serializers.py index 1d8e3ca3..18a1e240 100644 --- a/apps/search_indexes/serializers.py +++ b/apps/search_indexes/serializers.py @@ -1,16 +1,42 @@ """Search indexes serializers.""" from rest_framework import serializers from django_elasticsearch_dsl_drf.serializers import DocumentSerializer +from news.serializers import NewsTypeSerializer from search_indexes.documents import EstablishmentDocument, NewsDocument from search_indexes.utils import get_translated_value +class TagsDocumentSerializer(serializers.Serializer): + """Tags serializer for ES Document.""" + + id = serializers.IntegerField() + label_translated = serializers.SerializerMethodField() + + def get_label_translated(self, obj): + return get_translated_value(obj.label) + + +class AddressDocumentSerializer(serializers.Serializer): + """Address serializer for ES Document.""" + + id = serializers.IntegerField() + street_name_1 = serializers.CharField() + street_name_2 = serializers.CharField() + number = serializers.IntegerField() + postal_code = serializers.CharField() + latitude = serializers.FloatField(allow_null=True, source='coordinates.lat') + longitude = serializers.FloatField(allow_null=True, source='coordinates.lon') + geo_lon = serializers.FloatField(allow_null=True, source='coordinates.lon') + geo_lat = serializers.FloatField(allow_null=True, source='coordinates.lat') + + class NewsDocumentSerializer(DocumentSerializer): """News document serializer.""" title_translated = serializers.SerializerMethodField(allow_null=True) subtitle_translated = serializers.SerializerMethodField(allow_null=True) - description_translated = serializers.SerializerMethodField(allow_null=True) + news_type = NewsTypeSerializer() + tags = TagsDocumentSerializer(many=True) class Meta: """Meta class.""" @@ -18,13 +44,14 @@ class NewsDocumentSerializer(DocumentSerializer): document = NewsDocument fields = ( 'id', - 'title', - 'subtitle', - 'description', - 'web_url', 'title_translated', 'subtitle_translated', - 'description_translated', + 'is_highlighted', + 'image_url', + 'preview_image_url', + 'news_type', + 'tags', + 'slug', ) @staticmethod @@ -35,18 +62,13 @@ class NewsDocumentSerializer(DocumentSerializer): def get_subtitle_translated(obj): return get_translated_value(obj.subtitle) - @staticmethod - def get_description_translated(obj): - return get_translated_value(obj.description) - -# todo: country_name_translated class EstablishmentDocumentSerializer(DocumentSerializer): """Establishment document serializer.""" - description_translated = serializers.SerializerMethodField(allow_null=True) + address = AddressDocumentSerializer() + tags = TagsDocumentSerializer(many=True) - preview_image = serializers.URLField(source='preview_image_url') class Meta: """Meta class.""" @@ -54,43 +76,40 @@ class EstablishmentDocumentSerializer(DocumentSerializer): fields = ( 'id', 'name', - 'public_mark', - 'toque_number', + 'name_translated', 'price_level', - 'description_translated', - 'tags', - 'address', - 'collections', - 'establishment_type', - 'establishment_subtypes', - 'preview_image', + 'toque_number', + 'public_mark', 'slug', + 'preview_image', + 'address', + 'tags', + # 'collections', + # 'establishment_type', + # 'establishment_subtypes', ) - @staticmethod - def get_description_translated(obj): - return get_translated_value(obj.description) - def to_representation(self, instance): - ret = super().to_representation(instance) - dict_merge = lambda a, b: a.update(b) or a - - ret['tags'] = map(lambda tag: dict_merge(tag, {'label_translated': get_translated_value(tag.pop('label'))}), - ret['tags']) - ret['establishment_subtypes'] = map( - lambda subtype: dict_merge(subtype, {'name_translated': get_translated_value(subtype.pop('name'))}), - ret['establishment_subtypes']) - if ret.get('establishment_type'): - ret['establishment_type']['name_translated'] = get_translated_value(ret['establishment_type'].pop('name')) - if ret.get('address'): - ret['address']['city']['country']['name_translated'] = get_translated_value( - ret['address']['city']['country'].pop('name')) - location = ret['address'].pop('location') - if location: - ret['address']['geo_lon'] = location['lon'] - ret['address']['geo_lat'] = location['lat'] - - ret['type'] = ret.pop('establishment_type') - ret['subtypes'] = ret.pop('establishment_subtypes') - - return ret \ No newline at end of file + # def to_representation(self, instance): + # ret = super().to_representation(instance) + # dict_merge = lambda a, b: a.update(b) or a + # + # ret['tags'] = map(lambda tag: dict_merge(tag, {'label_translated': get_translated_value(tag.pop('label'))}), + # ret['tags']) + # ret['establishment_subtypes'] = map( + # lambda subtype: dict_merge(subtype, {'name_translated': get_translated_value(subtype.pop('name'))}), + # ret['establishment_subtypes']) + # if ret.get('establishment_type'): + # ret['establishment_type']['name_translated'] = get_translated_value(ret['establishment_type'].pop('name')) + # if ret.get('address'): + # ret['address']['city']['country']['name_translated'] = get_translated_value( + # ret['address']['city']['country'].pop('name')) + # location = ret['address'].pop('location') + # if location: + # ret['address']['geo_lon'] = location['lon'] + # ret['address']['geo_lat'] = location['lat'] + # + # ret['type'] = ret.pop('establishment_type') + # ret['subtypes'] = ret.pop('establishment_subtypes') + # + # return ret \ No newline at end of file diff --git a/apps/search_indexes/signals.py b/apps/search_indexes/signals.py index 2c04b6c6..f7520b57 100644 --- a/apps/search_indexes/signals.py +++ b/apps/search_indexes/signals.py @@ -1,5 +1,5 @@ """Search indexes app signals.""" -from django.db.models.signals import post_save, post_delete +from django.db.models.signals import post_save from django.dispatch import receiver from django_elasticsearch_dsl.registries import registry @@ -17,17 +17,65 @@ def update_document(sender, **kwargs): address__city__country=instance) for establishment in establishments: registry.update(establishment) - if model_name == 'city': establishments = Establishment.objects.filter( address__city=instance) for establishment in establishments: registry.update(establishment) - if model_name == 'address': establishments = Establishment.objects.filter( address=instance) for establishment in establishments: registry.update(establishment) -# todo: delete document + if app_label == 'establishment': + if model_name == 'establishmenttype': + establishments = Establishment.objects.filter( + establishment_type=instance) + for establishment in establishments: + registry.update(establishment) + if model_name == 'establishmentsubtype': + establishments = Establishment.objects.filter( + establishment_subtypes=instance) + for establishment in establishments: + registry.update(establishment) + + if app_label == 'main': + if model_name == 'metadata': + establishments = Establishment.objects.filter(tags__metadata=instance) + for establishment in establishments: + registry.update(establishment) + if model_name == 'metadatacontent': + establishments = Establishment.objects.filter(tags=instance) + for establishment in establishments: + registry.update(establishment) + + +@receiver(post_save) +def update_news(sender, **kwargs): + from news.models import News + app_label = sender._meta.app_label + model_name = sender._meta.model_name + instance = kwargs['instance'] + + if app_label == 'location': + if model_name == 'country': + qs = News.objects.filter(country=instance) + for news in qs: + registry.update(news) + + if app_label == 'news': + if model_name == 'newstype': + qs = News.objects.filter(news_type=instance) + for news in qs: + registry.update(news) + + if app_label == 'main': + if model_name == 'metadata': + qs = News.objects.filter(tags__metadata=instance) + for news in qs: + registry.update(news) + if model_name == 'metadatacontent': + qs = News.objects.filter(tags=instance) + for news in qs: + registry.update(news) diff --git a/apps/search_indexes/views.py b/apps/search_indexes/views.py index 72cab583..50c32fc7 100644 --- a/apps/search_indexes/views.py +++ b/apps/search_indexes/views.py @@ -1,12 +1,14 @@ """Search indexes app views.""" from rest_framework import permissions from django_elasticsearch_dsl_drf import constants -from django_elasticsearch_dsl_drf.filter_backends import (FilteringFilterBackend, - GeoSpatialFilteringFilterBackend) +from django_elasticsearch_dsl_drf.filter_backends import ( + FilteringFilterBackend, + GeoSpatialFilteringFilterBackend +) from django_elasticsearch_dsl_drf.viewsets import BaseDocumentViewSet -from django_elasticsearch_dsl_drf.pagination import PageNumberPagination from search_indexes import serializers, filters from search_indexes.documents import EstablishmentDocument, NewsDocument +from utils.pagination import ProjectPageNumberPagination class NewsDocumentViewSet(BaseDocumentViewSet): @@ -14,33 +16,44 @@ class NewsDocumentViewSet(BaseDocumentViewSet): document = NewsDocument lookup_field = 'slug' - pagination_class = PageNumberPagination + pagination_class = ProjectPageNumberPagination permission_classes = (permissions.AllowAny,) serializer_class = serializers.NewsDocumentSerializer ordering = ('id',) filter_backends = [ filters.CustomSearchFilterBackend, + FilteringFilterBackend, ] - search_fields = ( - 'title', - 'subtitle', - 'description', - ) + search_fields = { + 'title': {'fuzziness': 'auto:3,4'}, + 'subtitle': {'fuzziness': 'auto'}, + 'description': {'fuzziness': 'auto'}, + } translated_search_fields = ( 'title', 'subtitle', 'description', ) + filter_fields = { + 'tag': { + 'field': 'tags.id', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + ] + }, + 'slug': 'slug', + } + class EstablishmentDocumentViewSet(BaseDocumentViewSet): """Establishment document ViewSet.""" document = EstablishmentDocument lookup_field = 'slug' - pagination_class = PageNumberPagination + pagination_class = ProjectPageNumberPagination permission_classes = (permissions.AllowAny,) serializer_class = serializers.EstablishmentDocumentSerializer ordering = ('id',) @@ -51,15 +64,20 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): GeoSpatialFilteringFilterBackend, ] - search_fields = ( - 'name', - 'description', - ) + search_fields = { + 'name': {'fuzziness': 'auto:3,4'}, + 'name_translated': {'fuzziness': 'auto:3,4'}, + 'description': {'fuzziness': 'auto'}, + } translated_search_fields = ( 'description', ) filter_fields = { - 'tag': 'tags.id', + 'slug': 'slug', + 'tag': { + 'field': 'tags.id', + 'lookups': [constants.LOOKUP_QUERY_IN] + }, 'toque_number': { 'field': 'toque_number', 'lookups': [ @@ -107,7 +125,7 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): geo_spatial_filter_fields = { 'location': { - 'field': 'address.location', + 'field': 'address.coordinates', 'lookups': [ constants.LOOKUP_FILTER_GEO_BOUNDING_BOX, ] diff --git a/apps/timetable/models.py b/apps/timetable/models.py index e1e7fae7..53670d02 100644 --- a/apps/timetable/models.py +++ b/apps/timetable/models.py @@ -36,3 +36,4 @@ class Timetable(ProjectBaseMixin): """Meta class.""" verbose_name = _('Timetable') verbose_name_plural = _('Timetables') + ordering = ['weekday'] diff --git a/apps/utils/exceptions.py b/apps/utils/exceptions.py index df9c2c27..440f4ed4 100644 --- a/apps/utils/exceptions.py +++ b/apps/utils/exceptions.py @@ -123,7 +123,7 @@ class WrongAuthCredentials(AuthErrorMixin): """ The exception should be raised when credentials is not valid for this user """ - default_detail = _('Wrong authorization credentials') + default_detail = _('Incorrect login or password.') class FavoritesError(exceptions.APIException): diff --git a/apps/utils/serializers.py b/apps/utils/serializers.py index d30a046c..2b2282d1 100644 --- a/apps/utils/serializers.py +++ b/apps/utils/serializers.py @@ -1,7 +1,7 @@ """Utils app serializer.""" -from rest_framework import serializers -from utils.models import PlatformMixin from django.core import exceptions +from rest_framework import serializers +from utils import models from translation.models import Language @@ -11,8 +11,8 @@ class EmptySerializer(serializers.Serializer): class SourceSerializerMixin(serializers.Serializer): """Base authorization serializer mixin""" - source = serializers.ChoiceField(choices=PlatformMixin.SOURCES, - default=PlatformMixin.WEB, + source = serializers.ChoiceField(choices=models.PlatformMixin.SOURCES, + default=models.PlatformMixin.WEB, write_only=True) @@ -25,18 +25,16 @@ class TranslatedField(serializers.CharField): read_only=read_only, **kwargs) +# todo: view validation in more detail def validate_tjson(value): - if not isinstance(value, dict): raise exceptions.ValidationError( 'invalid_json', code='invalid_json', params={'value': value}, ) - lang_count = Language.objects.filter(locale__in=value.keys()).count() - - if lang_count == 0: + if lang_count != len(value.keys()): raise exceptions.ValidationError( 'invalid_translated_keys', code='invalid_translated_keys', @@ -44,5 +42,13 @@ def validate_tjson(value): ) -class TJSONSerializer(serializers.JSONField): +class TJSONField(serializers.JSONField): + """Custom serializer's JSONField for model's TJSONField.""" + validators = [validate_tjson] + + +class ProjectModelSerializer(serializers.ModelSerializer): + """Overrided ModelSerializer.""" + + serializers.ModelSerializer.serializer_field_mapping[models.TJSONField] = TJSONField diff --git a/docker-compose.yml b/docker-compose.yml index 2ccd6de3..7c4e49d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,6 @@ services: - POSTGRES_DB=postgres ports: - "5436:5432" -# networks: -# - db-net volumes: - gm-db:/var/lib/postgresql/data/ elasticsearch: @@ -28,8 +26,7 @@ services: - "ES_JAVA_OPTS=-Xms512m -Xmx512m" - discovery.type=single-node - xpack.security.enabled=false -# networks: -# - app-net + # RabbitMQ rabbitmq: image: rabbitmq:latest @@ -83,19 +80,12 @@ services: - worker - worker_beat - elasticsearch -# networks: -# - app-net -# - db-net volumes: - .:/code - gm-media:/media-data ports: - "8000:8000" -#networks: -# app-net: -# db-net: - volumes: gm-db: name: gm-db diff --git a/project/settings/base.py b/project/settings/base.py index 98bede78..d717579d 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -71,7 +71,9 @@ PROJECT_APPS = [ 'review.apps.ReviewConfig', 'comment.apps.CommentConfig', 'favorites.apps.FavoritesConfig', + 'rating.apps.RatingConfig', 'transfer.apps.TransferConfig' + ] EXTERNAL_APPS = [ @@ -289,9 +291,9 @@ SMS_SENDER = 'GM' # EMAIL EMAIL_USE_TLS = True -EMAIL_HOST = 'smtp.gmail.com' -EMAIL_HOST_USER = 'anatolyfeteleu@gmail.com' -EMAIL_HOST_PASSWORD = 'nggrlnbehzksgmbt' +EMAIL_HOST = 'smtp.yandex.ru' +EMAIL_HOST_USER = 't3st.t3stov.t3stovich@yandex.ru' +EMAIL_HOST_PASSWORD = 'ylhernyutkfbylgk' EMAIL_PORT = 587 # Django Rest Swagger @@ -406,6 +408,7 @@ PASSWORD_RESET_TIMEOUT_DAYS = 1 RESETTING_TOKEN_TEMPLATE = 'account/password_reset_email.html' CHANGE_EMAIL_TEMPLATE = 'account/change_email.html' CONFIRM_EMAIL_TEMPLATE = 'authorization/confirm_email.html' +NEWS_EMAIL_TEMPLATE = "news/news_email.html" # COOKIES diff --git a/project/settings/development.py b/project/settings/development.py index 43a60935..ff80b492 100644 --- a/project/settings/development.py +++ b/project/settings/development.py @@ -24,7 +24,7 @@ ELASTICSEARCH_DSL = { ELASTICSEARCH_INDEX_NAMES = { - # 'search_indexes.documents.news': 'development_news', # temporarily disabled + 'search_indexes.documents.news': 'development_news', # temporarily disabled 'search_indexes.documents.establishment': 'development_establishment', } diff --git a/project/settings/local.py b/project/settings/local.py index 503ad191..a644e9b8 100644 --- a/project/settings/local.py +++ b/project/settings/local.py @@ -1,5 +1,6 @@ """Local settings.""" from .base import * +import sys ALLOWED_HOSTS = ['*', ] @@ -65,5 +66,9 @@ ELASTICSEARCH_DSL = { ELASTICSEARCH_INDEX_NAMES = { # 'search_indexes.documents.news': 'local_news', - 'search_indexes.documents.establishment': 'local_establishment', -} \ No newline at end of file + 'search_indexes.documents.establishment': 'local_establishment', +} + +TESTING = sys.argv[1:2] == ['test'] +if TESTING: + ELASTICSEARCH_INDEX_NAMES = {} diff --git a/project/templates/news/news_email.html b/project/templates/news/news_email.html new file mode 100644 index 00000000..a47af685 --- /dev/null +++ b/project/templates/news/news_email.html @@ -0,0 +1,20 @@ + + + + + {{ title }} + + +

{{ title }}

+ + {% if subtitle %} +

{{ subtitle }}

+ {% endif %} + +

{{ description }}

+ +https://{{ country_code }}.{{ domain_uri }}{% url 'web:notification:unsubscribe' code %} + + + + diff --git a/project/urls/web.py b/project/urls/web.py index 0a81672d..5bf538f3 100644 --- a/project/urls/web.py +++ b/project/urls/web.py @@ -23,7 +23,7 @@ urlpatterns = [ path('collections/', include('collection.urls.web')), path('establishments/', include('establishment.urls.web')), path('news/', include('news.urls.web')), - path('notifications/', include('notification.urls.web')), + path('notifications/', include(('notification.urls.web', "notification"), namespace='notification')), path('partner/', include('partner.urls.web')), path('location/', include('location.urls.web')), path('main/', include('main.urls')),