diff --git a/apps/establishment/admin.py b/apps/establishment/admin.py index 638f6564..bb2d7ee3 100644 --- a/apps/establishment/admin.py +++ b/apps/establishment/admin.py @@ -4,6 +4,7 @@ from django.contrib.contenttypes.admin import GenericTabularInline from establishment import models from main.models import Award, MetaDataContent +from review import models as review_models @admin.register(models.EstablishmentType) @@ -26,10 +27,29 @@ class MetaDataContentInline(GenericTabularInline): extra = 0 +class ContactPhoneInline(admin.TabularInline): + """Contact phone inline admin.""" + model = models.ContactPhone + extra = 0 + + +class ContactEmailInline(admin.TabularInline): + """Contact email inline admin.""" + model = models.ContactEmail + extra = 0 + +class ReviewInline(GenericTabularInline): + model = review_models.Review + extra = 0 + + @admin.register(models.Establishment) class EstablishmentAdmin(admin.ModelAdmin): """Establishment admin.""" - inlines = [AwardInline, MetaDataContentInline] + inlines = [ + AwardInline, MetaDataContentInline, + ContactPhoneInline, ContactEmailInline, + ReviewInline] @admin.register(models.EstablishmentSchedule) diff --git a/apps/establishment/filters.py b/apps/establishment/filters.py index dc391bf6..46960d70 100644 --- a/apps/establishment/filters.py +++ b/apps/establishment/filters.py @@ -1,16 +1,28 @@ -from django_filters import FilterSet +"""Establishment app filters.""" +from django.core.validators import EMPTY_VALUES from django_filters import rest_framework as filters - from establishment import models -class EstablishmentFilter(FilterSet): +class EstablishmentFilter(filters.FilterSet): + """Establishment filterset.""" + tag_id = filters.NumberFilter(field_name='tags__metadata__id',) award_id = filters.NumberFilter(field_name='awards__id',) + search = filters.CharFilter(method='search_text') class Meta: + """Meta class.""" + model = models.Establishment fields = ( 'tag_id', - 'award_id' + 'award_id', + 'search', ) + + def search_text(self, queryset, name, value): + """Search text.""" + if value not in EMPTY_VALUES: + return queryset.search(value, locale=self.request.locale) + return queryset diff --git a/apps/establishment/migrations/0005_auto_20190901_0831.py b/apps/establishment/migrations/0005_auto_20190901_0831.py new file mode 100644 index 00000000..ef99bf4d --- /dev/null +++ b/apps/establishment/migrations/0005_auto_20190901_0831.py @@ -0,0 +1,77 @@ +# Generated by Django 2.2.4 on 2019-09-01 08:31 + +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields +import utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('location', '0009_auto_20190901_0831'), + ('establishment', '0004_auto_20190828_1156'), + ] + + operations = [ + migrations.CreateModel( + name='Contact', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='location.Address')), + ], + options={ + 'verbose_name': 'contact', + 'verbose_name_plural': 'contacts', + }, + ), + migrations.AlterField( + model_name='establishment', + name='description', + field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='Description'), + ), + migrations.AlterField( + model_name='establishment', + name='name', + field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='Name'), + ), + migrations.AlterField( + model_name='establishmentsubtype', + name='name', + field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='Description'), + ), + migrations.AlterField( + model_name='establishmenttype', + name='name', + field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='Description'), + ), + migrations.CreateModel( + name='ContactPhone', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('phone', phonenumber_field.modelfields.PhoneNumberField(max_length=128)), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='phones', to='establishment.Contact')), + ], + options={ + 'verbose_name': 'contact phone', + 'verbose_name_plural': 'contact phones', + }, + ), + migrations.CreateModel( + name='ContactEmail', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to='establishment.Contact')), + ], + options={ + 'verbose_name': 'contact email', + 'verbose_name_plural': 'contact emails', + }, + ), + migrations.AddField( + model_name='contact', + name='establishment', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to='establishment.Establishment'), + ), + ] diff --git a/apps/establishment/migrations/0006_merge_20190901_0846.py b/apps/establishment/migrations/0006_merge_20190901_0846.py new file mode 100644 index 00000000..e629f2b9 --- /dev/null +++ b/apps/establishment/migrations/0006_merge_20190901_0846.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-09-01 08:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('establishment', '0005_auto_20190901_0831'), + ('establishment', '0005_establishmentschedule'), + ] + + operations = [ + ] diff --git a/apps/establishment/migrations/0007_auto_20190901_1032.py b/apps/establishment/migrations/0007_auto_20190901_1032.py new file mode 100644 index 00000000..eb6c6a4a --- /dev/null +++ b/apps/establishment/migrations/0007_auto_20190901_1032.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.4 on 2019-09-01 10:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('establishment', '0006_merge_20190901_0846'), + ] + + operations = [ + migrations.RemoveField( + model_name='contactemail', + name='contact', + ), + migrations.RemoveField( + model_name='contactphone', + name='contact', + ), + migrations.DeleteModel( + name='Contact', + ), + migrations.DeleteModel( + name='ContactEmail', + ), + migrations.DeleteModel( + name='ContactPhone', + ), + ] diff --git a/apps/establishment/migrations/0008_contactemail_contactphone.py b/apps/establishment/migrations/0008_contactemail_contactphone.py new file mode 100644 index 00000000..bda629ed --- /dev/null +++ b/apps/establishment/migrations/0008_contactemail_contactphone.py @@ -0,0 +1,39 @@ +# Generated by Django 2.2.4 on 2019-09-01 10:36 + +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ('establishment', '0007_auto_20190901_1032'), + ] + + operations = [ + migrations.CreateModel( + name='ContactPhone', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('phone', phonenumber_field.modelfields.PhoneNumberField(max_length=128)), + ('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='phones', to='establishment.Establishment')), + ], + options={ + 'verbose_name': 'contact phone', + 'verbose_name_plural': 'contact phones', + }, + ), + migrations.CreateModel( + name='ContactEmail', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to='establishment.Establishment')), + ], + options={ + 'verbose_name': 'contact email', + 'verbose_name_plural': 'contact emails', + }, + ), + ] diff --git a/apps/establishment/models.py b/apps/establishment/models.py index 668d0d46..a2cbb11e 100644 --- a/apps/establishment/models.py +++ b/apps/establishment/models.py @@ -3,10 +3,13 @@ from django.contrib.contenttypes import fields as generic from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ +from phonenumber_field.modelfields import PhoneNumberField from location.models import Address -from utils.models import (ProjectBaseMixin, ImageMixin, TJSONField, - TraslatedFieldsMixin, BaseAttributes) +from utils.models import ( + ProjectBaseMixin, ImageMixin, TJSONField, + TraslatedFieldsMixin, BaseAttributes +) # todo: establishment type&subtypes check @@ -14,7 +17,7 @@ class EstablishmentType(ProjectBaseMixin, TraslatedFieldsMixin): """Establishment type model.""" name = TJSONField(blank=True, null=True, default=None, verbose_name=_('Description'), - help_text='{"en":"some text"}') + help_text='{"en-GB":"some text"}') use_subtypes = models.BooleanField(_('Use subtypes'), default=True) class Meta: @@ -38,7 +41,7 @@ class EstablishmentSubType(ProjectBaseMixin, TraslatedFieldsMixin): """Establishment type model.""" name = TJSONField(blank=True, null=True, default=None, verbose_name=_('Description'), - help_text='{"en":"some text"}') + help_text='{"en-GB":"some text"}') establishment_type = models.ForeignKey(EstablishmentType, on_delete=models.CASCADE, verbose_name=_('Type')) @@ -51,23 +54,34 @@ class EstablishmentSubType(ProjectBaseMixin, TraslatedFieldsMixin): verbose_name = _('Establishment subtype') verbose_name_plural = _('Establishment subtypes') - # def __str__(self): - # """__str__ method.""" - # return self.name - def clean_fields(self, exclude=None): if not self.establishment_type.use_subtypes: raise ValidationError(_('Establishment type is not use subtypes.')) +class EstablishmentQuerySet(models.QuerySet): + """Extended queryset for Establishment model.""" + + def search(self, value, locale=None): + """Search text in JSON fields.""" + if locale is not None: + filters = [ + {f'name__{locale}__icontains': value}, + {f'description__{locale}__icontains': value} + ] + return self.filter(reduce(lambda x, y: x | y, [models.Q(**i) for i in filters])) + else: + return self.none() + + class Establishment(ProjectBaseMixin, ImageMixin, TraslatedFieldsMixin): """Establishment model.""" name = TJSONField(blank=True, null=True, default=None, - verbose_name=_('Name'), help_text='{"en":"some text"}') + verbose_name=_('Name'), help_text='{"en-GB":"some text"}') description = TJSONField(blank=True, null=True, default=None, verbose_name=_('Description'), - help_text='{"en":"some text"}') + help_text='{"en-GB":"some text"}') public_mark = models.PositiveIntegerField(blank=True, null=True, default=None, verbose_name=_('Public mark'),) @@ -89,6 +103,9 @@ class Establishment(ProjectBaseMixin, ImageMixin, TraslatedFieldsMixin): verbose_name=_('Price level')) awards = generic.GenericRelation(to='main.Award') tags = generic.GenericRelation(to='main.MetaDataContent') + reviews = generic.GenericRelation(to='review.Review') + + objects = EstablishmentQuerySet.as_manager() class Meta: """Meta class.""" @@ -149,6 +166,97 @@ class EstablishmentSchedule(BaseAttributes): verbose_name_plural = _('Establishment schedules') +class ContactPhone(models.Model): + """Contact phone model.""" + establishment = models.ForeignKey( + Establishment, related_name='phones', on_delete=models.CASCADE) + phone = PhoneNumberField() + + class Meta: + verbose_name = _('contact phone') + verbose_name_plural = _('contact phones') + + def __str__(self): + return f'{self.phone.as_e164}' + + +class ContactEmail(models.Model): + """Contact email model.""" + establishment = models.ForeignKey( + Establishment, related_name='emails', on_delete=models.CASCADE) + email = models.EmailField() + + class Meta: + verbose_name = _('contact email') + verbose_name_plural = _('contact emails') + + def __str__(self): + return f'{self.email}' + +# +# class Wine(TraslatedFieldsMixin, models.Model): +# """Wine model.""" +# establishment = models.ForeignKey( +# 'establishment.Establishment', verbose_name=_('establishment'), +# on_delete=models.CASCADE) +# bottles = models.IntegerField(_('bottles')) +# price_min = models.DecimalField( +# _('price min'), max_digits=14, decimal_places=2) +# price_max = models.DecimalField( +# _('price max'), max_digits=14, decimal_places=2) +# by_glass = models.BooleanField(_('by glass')) +# price_glass_min = models.DecimalField( +# _('price min'), max_digits=14, decimal_places=2) +# price_glass_max = models.DecimalField( +# _('price max'), max_digits=14, decimal_places=2) +# +# +# class Plate(TraslatedFieldsMixin, models.Model): +# """Plate model.""" +# +# STARTER = 0 +# MAIN = 1 +# COURSE = 2 +# DESSERT = 3 +# +# PLATE_TYPE_CHOICES = ( +# (STARTER, _('starter')), +# (MAIN, _('main')), +# (COURSE, _('course')), +# (DESSERT, _('dessert')), +# ) +# name = models.CharField(_('name'), max_length=255) +# plate_type = models.PositiveSmallIntegerField(_('plate_type'), choices=PLATE_TYPE_CHOICES) +# description = TJSONField( +# blank=True, null=True, default=None, verbose_name=_('description'), +# help_text='{"en-GB":"some text"}') +# price = models.DecimalField( +# _('price'), max_digits=14, decimal_places=2) +# is_signature_plate = models.BooleanField(_('is signature plate')) +# currency = models.ForeignKey( +# 'main.Currency', verbose_name=_('currency'), on_delete=models.CASCADE) +# +# menu = models.ManyToManyField(to='Plate', verbose_name=_(''), through='establishment.Menu') +# +# class Meta: +# verbose_name = _('plate') +# verbose_name_plural = _('plates') +# +# def __str__(self): +# return f'plate_id:{self.id}' +# +# +# class Menu(TraslatedFieldsMixin, BaseAttributes): +# """Menu model.""" +# establishment = models.ForeignKey( +# 'establishment.Establishment', verbose_name=_('establishment'), +# on_delete=models.CASCADE) +# plate = models.ForeignKey(Plate, verbose_name=_('menu'), on_delete=models.CASCADE) +# +# class Meta: +# verbose_name = _('menu') +# verbose_name_plural = _('menu') + class CommentQuerySet(models.QuerySet): """QuerySets for Comment model.""" diff --git a/apps/establishment/serializers.py b/apps/establishment/serializers.py index 660f9709..7655e070 100644 --- a/apps/establishment/serializers.py +++ b/apps/establishment/serializers.py @@ -4,9 +4,28 @@ from rest_framework import serializers from establishment import models from location.serializers import AddressSerializer from main.serializers import MetaDataContentSerializer, AwardSerializer +from review import models as review_models from timetable.models import Timetable +class ContactPhonesSerializer(serializers.ModelSerializer): + """Contact phone serializer""" + class Meta: + model = models.ContactPhone + fields = [ + 'phone' + ] + + +class ContactEmailsSerializer(serializers.ModelSerializer): + """Contact email serializer""" + class Meta: + model = models.ContactEmail + fields = [ + 'email' + ] + + class EstablishmentTypeSerializer(serializers.ModelSerializer): """Serializer for EstablishmentType model.""" @@ -47,6 +66,16 @@ class EstablishmentScheduleSerializer(serializers.ModelSerializer): ) +class ReviewSerializer(serializers.ModelSerializer): + """Serializer for model Review.""" + class Meta: + """Meta class.""" + model = review_models.Review + fields = ( + 'text', + ) + + class CommentSerializer(serializers.ModelSerializer): """Comment serializer""" nickname = serializers.CharField(source='author.username') @@ -76,6 +105,10 @@ class EstablishmentSerializer(serializers.ModelSerializer): schedule = EstablishmentScheduleSerializer(source='schedule.schedule', many=True, allow_null=True) + phones = ContactPhonesSerializer(read_only=True, many=True, ) + emails = ContactEmailsSerializer(read_only=True, many=True, ) + reviews = ReviewSerializer(source='reviews.last', + allow_null=True) comments = CommentSerializer(many=True, allow_null=True) @@ -98,5 +131,8 @@ class EstablishmentSerializer(serializers.ModelSerializer): 'tags', 'awards', 'schedule', + 'phones', + 'emails', + 'reviews', 'comments', ) diff --git a/apps/location/migrations/0003_auto_20190821_1407.py b/apps/location/migrations/0003_auto_20190821_1407.py index 548e2a70..c82ec597 100644 --- a/apps/location/migrations/0003_auto_20190821_1407.py +++ b/apps/location/migrations/0003_auto_20190821_1407.py @@ -30,6 +30,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='country', name='name', - field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=None, help_text='{"en":"some text"}', null=True, verbose_name='Text'), + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='Text'), ), ] diff --git a/apps/location/migrations/0009_auto_20190901_0831.py b/apps/location/migrations/0009_auto_20190901_0831.py new file mode 100644 index 00000000..c4e21715 --- /dev/null +++ b/apps/location/migrations/0009_auto_20190901_0831.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.4 on 2019-09-01 08:31 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('location', '0008_auto_20190827_1302'), + ] + + operations = [ + migrations.AlterField( + model_name='country', + name='name', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='Name'), + ), + ] diff --git a/apps/location/models.py b/apps/location/models.py index 56bd89f3..613cc013 100644 --- a/apps/location/models.py +++ b/apps/location/models.py @@ -17,7 +17,7 @@ class Country(SVGImageMixin, ProjectBaseMixin): """Country model.""" name = JSONField(null=True, blank=True, default=None, - verbose_name=_('Name'), help_text='{"en":"some text"}') + verbose_name=_('Name'), help_text='{"en-GB":"some text"}') code = models.CharField(max_length=255, unique=True, verbose_name=_('Code')) low_price = models.IntegerField(default=25, verbose_name=_('Low price')) high_price = models.IntegerField(default=50, verbose_name=_('High price')) diff --git a/apps/main/migrations/0013_auto_20190901_1032.py b/apps/main/migrations/0013_auto_20190901_1032.py new file mode 100644 index 00000000..95c86883 --- /dev/null +++ b/apps/main/migrations/0013_auto_20190901_1032.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.4 on 2019-09-01 10:32 + +from django.db import migrations, models +import utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0012_auto_20190829_1155'), + ] + + operations = [ + migrations.CreateModel( + name='Currency', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='name')), + ], + options={ + 'verbose_name': 'currency', + 'verbose_name_plural': 'currencies', + }, + ), + migrations.AlterField( + model_name='award', + name='title', + field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='title'), + ), + migrations.AlterField( + model_name='metadata', + name='label', + field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='label'), + ), + ] diff --git a/apps/main/models.py b/apps/main/models.py index cb406d98..b58dc402 100644 --- a/apps/main/models.py +++ b/apps/main/models.py @@ -190,7 +190,7 @@ class Award(TraslatedFieldsMixin, models.Model): award_type = models.ForeignKey('main.AwardType', on_delete=models.CASCADE) title = TJSONField( _('title'), null=True, blank=True, - default=None, help_text='{"en":"some text"}') + default=None, help_text='{"en-GB":"some text"}') vintage_year = models.CharField(_('vintage year'), max_length=255, default='') content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) @@ -228,7 +228,7 @@ class MetaData(TraslatedFieldsMixin, models.Model): """MetaData model.""" label = TJSONField( _('label'), null=True, blank=True, - default=None, help_text='{"en":"some text"}') + default=None, help_text='{"en-GB":"some text"}') category = models.ForeignKey( MetaDataCategory, verbose_name=_('category'), on_delete=models.CASCADE) @@ -250,3 +250,15 @@ class MetaDataContent(models.Model): object_id = models.PositiveIntegerField() content_object = generic.GenericForeignKey('content_type', 'object_id') metadata = models.ForeignKey(MetaData, on_delete=models.CASCADE) + + +class Currency(models.Model): + """Currency model.""" + name = models.CharField(_('name'), max_length=50) + + class Meta: + verbose_name = _('currency') + verbose_name_plural = _('currencies') + + def __str__(self): + return f'{self.name}' diff --git a/apps/news/migrations/0009_auto_20190901_1032.py b/apps/news/migrations/0009_auto_20190901_1032.py new file mode 100644 index 00000000..5a083240 --- /dev/null +++ b/apps/news/migrations/0009_auto_20190901_1032.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.4 on 2019-09-01 10:32 + +from django.db import migrations +import utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0008_auto_20190828_1522'), + ] + + operations = [ + migrations.AlterField( + model_name='news', + name='description', + field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='description'), + ), + migrations.AlterField( + model_name='news', + name='subtitle', + field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='subtitle'), + ), + migrations.AlterField( + model_name='news', + name='title', + field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='title'), + ), + ] diff --git a/apps/news/models.py b/apps/news/models.py index e3eff57e..0abc7ab5 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -43,14 +43,14 @@ class News(BaseAttributes, TraslatedFieldsMixin): title = TJSONField( _('title'), null=True, blank=True, - default=None, help_text='{"en":"some text"}') + default=None, help_text='{"en-GB":"some text"}') subtitle = TJSONField( _('subtitle'), null=True, blank=True, - default=None, help_text='{"en":"some text"}' + default=None, help_text='{"en-GB":"some text"}' ) description = TJSONField( _('description'), null=True, blank=True, - default=None, help_text='{"en":"some text"}' + default=None, help_text='{"en-GB":"some text"}' ) start = models.DateTimeField(_('start')) end = models.DateTimeField(_('end')) diff --git a/apps/news/serializers/common.py b/apps/news/serializers/common.py index 904c6f89..b64a7fc9 100644 --- a/apps/news/serializers/common.py +++ b/apps/news/serializers/common.py @@ -1,5 +1,5 @@ +"""News app common serializers.""" from rest_framework import serializers - from gallery import models as gallery_models from location.models import Address from location.serializers import AddressSerializer @@ -23,7 +23,7 @@ class NewsSerializer(serializers.ModelSerializer): title_translated = serializers.CharField(read_only=True, allow_null=True) subtitle_translated = serializers.CharField(read_only=True, allow_null=True) description_translated = serializers.CharField(read_only=True, allow_null=True) - image_url = serializers.ImageField(source='image.image') + image_url = serializers.ImageField(source='image.image', allow_null=True) class Meta: model = models.News diff --git a/apps/news/views/common.py b/apps/news/views/common.py index 82c77068..2678289b 100644 --- a/apps/news/views/common.py +++ b/apps/news/views/common.py @@ -1,50 +1,37 @@ +"""News app common app.""" from rest_framework import generics, permissions - -from news import filters -from news.models import News, NewsType +from news import filters, models from news.serializers import common as serializers -from utils.views import (JWTGenericViewMixin, - JWTListAPIView) +from utils.views import JWTGenericViewMixin, JWTListAPIView -# Mixins -class NewsViewMixin(JWTGenericViewMixin): - """View mixin for News model""" +class NewsMixin: + """News mixin.""" - def get_queryset(self, *args, **kwargs): - """Override get_queryset method""" - return News.objects.annotate_localized_fields(locale=self.request.locale) - - -class NewsListView(NewsViewMixin, JWTListAPIView): - """News list view.""" permission_classes = (permissions.AllowAny, ) serializer_class = serializers.NewsSerializer - filter_class = filters.NewsListFilterSet def get_queryset(self, *args, **kwargs): """Override get_queryset method""" - return News.objects.published()\ - .by_country_code(code=self.request.country_code)\ - .order_by('-is_highlighted', '-created') + return models.News.objects.published() \ + .by_country_code(code=self.request.country_code) \ + .order_by('-is_highlighted', '-created') -# class NewsCreateView(generics.CreateAPIView): -# """News list view.""" -# queryset = News.objects.all() -# permission_classes = (permissions.IsAuthenticated, ) -# serializer_class = serializers.NewsCreateUpdateSerializer +class NewsListView(NewsMixin, JWTListAPIView): + """News list view.""" + + filter_class = filters.NewsListFilterSet -class NewsDetailView(NewsViewMixin, generics.RetrieveAPIView): +class NewsDetailView(NewsMixin, JWTGenericViewMixin, generics.RetrieveAPIView): """News detail view.""" - permission_classes = (permissions.IsAuthenticatedOrReadOnly, ) - serializer_class = serializers.NewsSerializer class NewsTypeListView(generics.ListAPIView): """NewsType list view.""" + serializer_class = serializers.NewsTypeSerializer permission_classes = (permissions.AllowAny, ) pagination_class = None - queryset = NewsType.objects.all() + queryset = models.NewsType.objects.all() diff --git a/apps/notification/__init__.py b/apps/notification/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/notification/admin.py b/apps/notification/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/apps/notification/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/notification/apps.py b/apps/notification/apps.py new file mode 100644 index 00000000..2035ffa3 --- /dev/null +++ b/apps/notification/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class NotificationConfig(AppConfig): + name = 'notification' + verbose_name = _('notification') diff --git a/apps/notification/migrations/0001_initial.py b/apps/notification/migrations/0001_initial.py new file mode 100644 index 00000000..6cdcf6f2 --- /dev/null +++ b/apps/notification/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.4 on 2019-08-30 11:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Subscriber', + 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')), + ('email', models.EmailField(blank=True, default=None, max_length=254, null=True, unique=True, verbose_name='Email')), + ('ip_address', models.GenericIPAddressField(blank=True, default=None, null=True, verbose_name='IP address')), + ('country_code', models.CharField(blank=True, default=None, max_length=10, null=True, verbose_name='Country code')), + ('locale', models.CharField(blank=True, default=None, max_length=10, null=True, verbose_name='Locale identifier')), + ('state', models.PositiveIntegerField(choices=[(0, 'Unusable'), (1, 'Usable')], default=1, verbose_name='State')), + ('update_code', models.CharField(blank=True, db_index=True, default=None, max_length=254, null=True, verbose_name='Token')), + ('user', models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscriber', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Subscriber', + 'verbose_name_plural': 'Subscribers', + }, + ), + ] diff --git a/apps/notification/migrations/__init__.py b/apps/notification/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/notification/models.py b/apps/notification/models.py new file mode 100644 index 00000000..85176d24 --- /dev/null +++ b/apps/notification/models.py @@ -0,0 +1,124 @@ +"""Notification app models.""" +from django.conf import settings +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from account.models import User +from utils.methods import generate_string_code +from utils.models import ProjectBaseMixin + + +# todo: associate user & subscriber after users registration +class SubscriberManager(models.Manager): + """Extended manager for Subscriber model.""" + + def make_subscriber(self, email=None, user=None, ip_address=None, country_code=None, + locale=None, *args, **kwargs): + """Make subscriber and update info.""" + # search existing object + if not user: + user = User.objects.filter(email=email).first() + if user: + obj = self.model.objects.filter(models.Q(user=user) | models.Q( + email=user.email)).first() + else: + obj = self.model.objects.filter(email=email).first() + + # update or create + if obj: + if user: + obj.user = user + obj.email = None + else: + obj.email = email + obj.ip_address = ip_address + obj.country_code = country_code + obj.locale = locale + obj.state = self.model.USABLE + obj.update_code = generate_string_code() + obj.save() + else: + obj = self.model.objects.create(user=user, email=email, ip_address=ip_address, + country_code=country_code, locale=locale) + return obj + + def associate_user(self, user): + """Associate user.""" + obj = self.model.objects.filter(user=user).first() + if obj is None: + obj = self.model.objects.filter(email=user.email_confirmed, user__isnull=True).first() + if obj: + obj.user = user + obj.email = None + obj.save() + return obj + + +class SubscriberQuerySet(models.QuerySet): + """Extended queryset for Subscriber model.""" + + def by_usable(self, switcher=True): + if switcher: + return self.filter(state=self.model.USABLE) + else: + return self.filter(state=self.model.UNUSABLE) + + +class Subscriber(ProjectBaseMixin): + """Subscriber model.""" + + UNUSABLE = 0 + USABLE = 1 + + STATE_CHOICES = ( + (UNUSABLE, _('Unusable')), + (USABLE, _('Usable')), + ) + + user = models.OneToOneField(User, blank=True, null=True, default=None, + on_delete=models.SET_NULL, related_name='subscriber', + verbose_name=_('User')) + email = models.EmailField(blank=True, null=True, default=None, unique=True, + verbose_name=_('Email')) + ip_address = models.GenericIPAddressField(blank=True, null=True, default=None, + verbose_name=_('IP address')) + country_code = models.CharField(max_length=10, blank=True, null=True, default=None, + verbose_name=_('Country code')) + locale = models.CharField(blank=True, null=True, default=None, + max_length=10, verbose_name=_('Locale identifier')) + state = models.PositiveIntegerField(choices=STATE_CHOICES, default=USABLE, + verbose_name=_('State')) + update_code = models.CharField(max_length=254, blank=True, null=True, default=None, + db_index=True, verbose_name=_('Token')) + + objects = SubscriberManager.from_queryset(SubscriberQuerySet)() + + class Meta: + """Meta class.""" + + verbose_name = _('Subscriber') + verbose_name_plural = _('Subscribers') + + def save(self, *args, **kwargs): + """Overrided save method.""" + if self.update_code is None: + self.update_code = generate_string_code() + return super(Subscriber, self).save(*args, **kwargs) + + def unsubscribe(self): + """Unsubscribe user.""" + self.state = self.UNUSABLE + self.save() + + @property + def send_to(self): + """Actual email.""" + return self.user.email if self.user else self.email + + @property + def link_to_unsubscribe(self): + """Link to unsubscribe.""" + schema = settings.SCHEMA_URI + site_domain = settings.SITE_DOMAIN_URI + url = settings.SITE_REDIRECT_URL_UNSUBSCRIBE + query = f'?code={self.update_code}' + return f'{schema}://{site_domain}{url}{query}' diff --git a/apps/notification/serializers/__init__.py b/apps/notification/serializers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/notification/serializers/common.py b/apps/notification/serializers/common.py new file mode 100644 index 00000000..a4d85d1f --- /dev/null +++ b/apps/notification/serializers/common.py @@ -0,0 +1,47 @@ +"""Notification app serializers.""" +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers +from notification import models +from utils.methods import get_user_ip + + +class SubscribeSerializer(serializers.ModelSerializer): + """Subscribe serializer.""" + + email = serializers.EmailField(required=False, source='send_to') + state_display = serializers.CharField(source='get_state_display', read_only=True) + + class Meta: + """Meta class.""" + + model = models.Subscriber + fields = ('email', 'state', 'state_display') + read_only_fields = ('state', 'state_display') + + def validate(self, attrs): + """Validate attrs.""" + request = self.context.get('request') + user = request.user + + # validate email + email = attrs.get('send_to') + if user.is_authenticated: + if email is not None and email != user.email: + raise serializers.ValidationError(_('Does not match user email')) + else: + if email is None: + raise serializers.ValidationError({'email': _('This field is required.')}) + + # append info + attrs['email'] = email + attrs['country_code'] = request.country_code + attrs['locale'] = request.locale + attrs['ip_address'] = get_user_ip(request) + if user.is_authenticated: + attrs['user'] = user + return attrs + + def create(self, validated_data): + """Create obj.""" + obj = models.Subscriber.objects.make_subscriber(**validated_data) + return obj diff --git a/apps/notification/urls/__init__.py b/apps/notification/urls/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/notification/urls/common.py b/apps/notification/urls/common.py new file mode 100644 index 00000000..df43c805 --- /dev/null +++ b/apps/notification/urls/common.py @@ -0,0 +1,12 @@ +"""Notification app common urlconf.""" +from django.urls import path +from notification.views import common + + +urlpatterns = [ + path('subscribe/', common.SubscribeView.as_view(), name='subscribe'), + path('subscribe-info/', common.SubscribeInfoAuthUserView.as_view(), name='check-code-auth'), + path('subscribe-info//', common.SubscribeInfoView.as_view(), name='check-code'), + path('unsubscribe/', common.UnsubscribeAuthUserView.as_view(), name='unsubscribe-auth'), + path('unsubscribe//', common.UnsubscribeView.as_view(), name='unsubscribe'), +] \ No newline at end of file diff --git a/apps/notification/urls/web.py b/apps/notification/urls/web.py new file mode 100644 index 00000000..4edbd858 --- /dev/null +++ b/apps/notification/urls/web.py @@ -0,0 +1,7 @@ +"""Establishment app web urlconf.""" +from notification.urls.common import urlpatterns as common_urlpatterns + + +urlpatterns = [] + +urlpatterns.extend(common_urlpatterns) diff --git a/apps/notification/views/__init__.py b/apps/notification/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/notification/views/common.py b/apps/notification/views/common.py new file mode 100644 index 00000000..ccb3cd69 --- /dev/null +++ b/apps/notification/views/common.py @@ -0,0 +1,78 @@ +"""Notification app common views.""" +from django.shortcuts import get_object_or_404 +from rest_framework import generics, permissions +from rest_framework.response import Response +from notification import models +from notification.serializers import common as serializers + + +class SubscribeView(generics.GenericAPIView): + """Subscribe View.""" + + queryset = models.Subscriber.objects.all() + permission_classes = (permissions.AllowAny, ) + serializer_class = serializers.SubscribeSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(data=serializer.data) + + +class SubscribeInfoView(generics.RetrieveAPIView): + """Subscribe info view.""" + + lookup_field = 'update_code' + lookup_url_kwarg = 'code' + permission_classes = (permissions.AllowAny, ) + queryset = models.Subscriber.objects.all() + serializer_class = serializers.SubscribeSerializer + + +class SubscribeInfoAuthUserView(generics.RetrieveAPIView): + """Subscribe info auth user view.""" + + permission_classes = (permissions.IsAuthenticated, ) + queryset = models.Subscriber.objects.all() + serializer_class = serializers.SubscribeSerializer + + def get_object(self): + user = self.request.user + queryset = self.filter_queryset(self.get_queryset()) + filter_kwargs = {'user': user} + obj = get_object_or_404(queryset, **filter_kwargs) + self.check_object_permissions(self.request, obj) + return obj + + +class UnsubscribeView(generics.GenericAPIView): + """Unsubscribe view.""" + + lookup_field = 'update_code' + lookup_url_kwarg = 'code' + permission_classes = (permissions.AllowAny, ) + queryset = models.Subscriber.objects.all() + serializer_class = serializers.SubscribeSerializer + + def patch(self, request, *args, **kw): + obj = self.get_object() + obj.unsubscribe() + serializer = self.get_serializer(instance=obj) + return Response(data=serializer.data) + + +class UnsubscribeAuthUserView(generics.GenericAPIView): + """Unsubscribe auth user view.""" + + permission_classes = (permissions.IsAuthenticated, ) + queryset = models.Subscriber.objects.all() + serializer_class = serializers.SubscribeSerializer + + def patch(self, request, *args, **kw): + user = request.user + obj = get_object_or_404(models.Subscriber, user=user) + obj.unsubscribe() + serializer = self.get_serializer(instance=obj) + return Response(data=serializer.data) + diff --git a/apps/review/admin.py b/apps/review/admin.py index f3aeca39..cbd0bf94 100644 --- a/apps/review/admin.py +++ b/apps/review/admin.py @@ -1,7 +1,3 @@ -from django.contrib import admin -from review import models - - -@admin.register(models.Review) -class ReviewAdminModel(admin.ModelAdmin): - """Admin model for model Review.""" +# @admin.register(models.Review) +# class ReviewAdminModel(admin.ModelAdmin): +# """Admin model for model Review.""" diff --git a/apps/review/migrations/0001_initial.py b/apps/review/migrations/0001_initial.py new file mode 100644 index 00000000..3deac75e --- /dev/null +++ b/apps/review/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 2.2.4 on 2019-09-01 09:32 + +import django.core.validators +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('translation', '0002_siteinterfacedictionary'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Review', + 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')), + ('object_id', models.PositiveIntegerField()), + ('text', models.TextField(verbose_name='Text')), + ('status', models.PositiveSmallIntegerField(choices=[(0, 'To investigate'), (1, 'To review'), (2, 'Ready')], default=0)), + ('published_at', models.DateTimeField(blank=True, default=None, help_text='Review published datetime', null=True, verbose_name='Publish datetime')), + ('vintage', models.IntegerField(validators=[django.core.validators.MinValueValidator(1900), django.core.validators.MaxValueValidator(2100)], verbose_name='Year of review')), + ('child', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='review.Review', verbose_name='Child review')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='review_records_created', to=settings.AUTH_USER_MODEL, verbose_name='created by')), + ('language', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='translation.Language', verbose_name='Review language')), + ('modified_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='review_records_modified', to=settings.AUTH_USER_MODEL, verbose_name='modified by')), + ('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL, verbose_name='Reviewer')), + ], + options={ + 'verbose_name': 'Review', + 'verbose_name_plural': 'Reviews', + }, + ), + ] diff --git a/apps/translation/migrations/0003_auto_20190901_1032.py b/apps/translation/migrations/0003_auto_20190901_1032.py new file mode 100644 index 00000000..85bf63eb --- /dev/null +++ b/apps/translation/migrations/0003_auto_20190901_1032.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.4 on 2019-09-01 10:32 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('translation', '0002_siteinterfacedictionary'), + ] + + operations = [ + migrations.AlterField( + model_name='siteinterfacedictionary', + name='text', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='Text'), + ), + ] diff --git a/apps/translation/models.py b/apps/translation/models.py index f675b315..42530965 100644 --- a/apps/translation/models.py +++ b/apps/translation/models.py @@ -49,7 +49,7 @@ class SiteInterfaceDictionary(ProjectBaseMixin): verbose_name=_('Page')) keywords = models.CharField(max_length=255, verbose_name='Keywords') text = JSONField(_('Text'), null=True, blank=True, - default=None, help_text='{"en":"some text"}') + default=None, help_text='{"en-GB":"some text"}') objects = SiteInterfaceDictionaryManager() diff --git a/apps/utils/methods.py b/apps/utils/methods.py index 9dcafa97..a34fcc07 100644 --- a/apps/utils/methods.py +++ b/apps/utils/methods.py @@ -1,6 +1,7 @@ """Utils app method.""" import random import re +import string from django.conf import settings from django.http.request import HttpRequest @@ -60,3 +61,20 @@ def svg_image_path(instance, filename): instance._meta.model_name, datetime.now().strftime(settings.REST_DATE_FORMAT), filename) + + +def get_user_ip(request): + """Get user ip.""" + meta_dict = request.META + x_forwarded_for = meta_dict.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = meta_dict.get('REMOTE_ADDR') + return ip + + +def generate_string_code(size=64, + chars=string.ascii_lowercase + string.ascii_uppercase + string.digits): + """Generate string code.""" + return ''.join([random.SystemRandom().choice(chars) for _ in range(size)]) diff --git a/project/settings/base.py b/project/settings/base.py index 503d551f..3d054331 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -61,6 +61,7 @@ PROJECT_APPS = [ 'location.apps.LocationConfig', 'main.apps.MainConfig', 'news.apps.NewsConfig', + 'notification.apps.NotificationConfig', 'partner.apps.PartnerConfig', 'translation.apps.TranslationConfig', 'configuration.apps.ConfigurationConfig', @@ -82,6 +83,7 @@ EXTERNAL_APPS = [ 'django_extensions', 'rest_framework_simplejwt.token_blacklist', 'solo', + 'phonenumber_field', ] @@ -302,7 +304,6 @@ CELERY_ACCEPT_CONTENT = ['application/json'] CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' CELERY_TIMEZONE = TIME_ZONE -USE_CELERY = False # Django FCM (Firebase push notificatoins) FCM_DJANGO_SETTINGS = { @@ -388,5 +389,7 @@ FILE_UPLOAD_PERMISSIONS = 0o644 SOLO_CACHE_TIMEOUT = 300 +# REDIRECT URL +SITE_REDIRECT_URL_UNSUBSCRIBE = '/unsubscribe/' SITE_NAME = 'Gault & Millau' diff --git a/project/settings/development.py b/project/settings/development.py index 798eb6b0..7ecc6aa2 100644 --- a/project/settings/development.py +++ b/project/settings/development.py @@ -5,6 +5,7 @@ ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126'] SEND_SMS = False SMS_CODE_SHOW = True +USE_CELERY = False SCHEMA_URI = 'http' DEFAULT_SUBDOMAIN = 'www' diff --git a/project/settings/local.py b/project/settings/local.py index 93e6948b..19b3b51c 100644 --- a/project/settings/local.py +++ b/project/settings/local.py @@ -12,11 +12,6 @@ DEFAULT_SUBDOMAIN = 'www' SITE_DOMAIN_URI = 'testserver.com:8000' DOMAIN_URI = '0.0.0.0:8000' -# OTHER SETTINGS -API_HOST = '0.0.0.0:8000' -API_HOST_URL = 'http://%s' % API_HOST - - # CELERY BROKER_URL = 'amqp://rabbitmq:5672' CELERY_RESULT_BACKEND = BROKER_URL diff --git a/project/urls/web.py b/project/urls/web.py index ab6a2a76..e3b7b230 100644 --- a/project/urls/web.py +++ b/project/urls/web.py @@ -23,6 +23,7 @@ urlpatterns = [ path('collection/', include('collection.urls.web')), path('establishments/', include('establishment.urls.web')), path('news/', include('news.urls.web')), + path('notifications/', include('notification.urls.web')), path('partner/', include('partner.urls.web')), path('location/', include('location.urls')), path('main/', include('main.urls')), diff --git a/requirements/base.txt b/requirements/base.txt index 20a87346..3d7987c0 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -17,6 +17,7 @@ djangorestframework-xml celery amqp>=2.4.0 geoip2==2.9.0 +django-phonenumber-field[phonenumbers]==2.1.0 # auth socials djangorestframework-oauth