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..ee133fa3 --- /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, throttling +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/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 5cd8b5b0..8a8fa6aa 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', @@ -300,7 +301,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 = { @@ -385,3 +385,6 @@ FILE_UPLOAD_PERMISSIONS = 0o644 SOLO_CACHE_TIMEOUT = 300 + +# REDIRECT URL +SITE_REDIRECT_URL_UNSUBSCRIBE = '/unsubscribe/' 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 baf361b4..1a9554d5 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')),