diff --git a/akarpov/common/channels.py b/akarpov/common/channels.py index e5af385..971fa9d 100644 --- a/akarpov/common/channels.py +++ b/akarpov/common/channels.py @@ -1,20 +1,46 @@ +from importlib import import_module + from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer, JsonWebsocketConsumer +from django.conf import settings +from django.contrib.sessions.models import Session from akarpov.common.jwt import read_jwt +from akarpov.users.models import User + +engine = import_module(settings.SESSION_ENGINE) +sessionstore = engine.SessionStore @database_sync_to_async def get_user(headers): # WARNING headers type is bytes - if b"authorization" not in headers or not headers[b"authorization"]: - return False + if (b"authorization" not in headers or not headers[b"authorization"]) and ( + b"cookie" not in headers or not headers[b"cookie"] + ): + return None + if b"authorization" in headers: + jwt = headers[b"authorization"].decode() + data = read_jwt(jwt) + if not data: + return None + payload = data + elif b"cookie" in headers: + cookies = dict([x.split("=") for x in headers[b"cookie"].decode().split("; ")]) + if "sessionid" not in cookies: + return None + try: + session = sessionstore(cookies["sessionid"]) + user_id = session["_auth_user_id"] + except (Session.DoesNotExist, User.DoesNotExist, KeyError): + return None - jwt = headers[b"authorization"].decode() - payload = read_jwt(jwt) + payload = {"id": user_id} + else: + payload = {} if not payload or "id" not in payload: - return False + return None return payload["id"] @@ -27,7 +53,7 @@ def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): - scope["user"] = await get_user(dict(scope["headers"])) + scope["user_id"] = await get_user(dict(scope["headers"])) try: return await self.app(scope, receive, send) except ValueError: diff --git a/akarpov/common/jwt.py b/akarpov/common/jwt.py index ca3468e..70378ad 100644 --- a/akarpov/common/jwt.py +++ b/akarpov/common/jwt.py @@ -3,7 +3,7 @@ import jwt import pytz from django.conf import settings -from jwt import ExpiredSignatureError, InvalidSignatureError +from jwt import DecodeError, ExpiredSignatureError, InvalidSignatureError TIMEZONE = pytz.timezone("Europe/Moscow") @@ -24,7 +24,10 @@ def sign_jwt(data: dict, t_life: None | int = None) -> str: def read_jwt(token: str) -> dict | bool: """reads jwt, validates it and return payload if correct""" - header_data = jwt.get_unverified_header(token) + try: + header_data = jwt.get_unverified_header(token) + except DecodeError: + return False secret = settings.SECRET_KEY try: payload = jwt.decode(token, key=secret, algorithms=[header_data["alg"]]) diff --git a/akarpov/notifications/__init__.py b/akarpov/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/akarpov/notifications/admin.py b/akarpov/notifications/admin.py new file mode 100644 index 0000000..1b9f909 --- /dev/null +++ b/akarpov/notifications/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from akarpov.notifications.models import Notification + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_filter = ["provider"] diff --git a/akarpov/notifications/apps.py b/akarpov/notifications/apps.py new file mode 100644 index 0000000..1872f08 --- /dev/null +++ b/akarpov/notifications/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + name = "akarpov.notifications" + verbose_name = "Notifications" + + def ready(self): + try: + import akarpov.notifications.signals # noqa F401 + except ImportError: + pass diff --git a/akarpov/notifications/migrations/0001_initial.py b/akarpov/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..50f6dce --- /dev/null +++ b/akarpov/notifications/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.5 on 2023-09-20 10:52 + +from django.db import migrations, models +import django_extensions.db.fields + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ("title", models.CharField(max_length=255)), + ("body", models.TextField(blank=True, max_length=5000, null=True)), + ( + "provider", + models.CharField( + choices=[ + ("akarpov.notifications.providers.site", "site"), + ("akarpov.notifications.providers.email", "email"), + ] + ), + ), + ("meta", models.JSONField(null=True)), + ("delivered", models.BooleanField(default=False)), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + ), + ] diff --git a/akarpov/notifications/migrations/__init__.py b/akarpov/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/akarpov/notifications/models.py b/akarpov/notifications/models.py new file mode 100644 index 0000000..f80ce49 --- /dev/null +++ b/akarpov/notifications/models.py @@ -0,0 +1,17 @@ +from django.db import models +from django_extensions.db.models import TimeStampedModel + + +class Notification(TimeStampedModel): + class NotificationProviders(models.TextChoices): + site = "akarpov.notifications.providers.site", "site" + email = "akarpov.notifications.providers.email", "email" + + title = models.CharField(max_length=255) + body = models.TextField(max_length=5000, null=True, blank=True) + provider = models.CharField(choices=NotificationProviders.choices) + meta = models.JSONField(null=True) + delivered = models.BooleanField(default=False) + + def __str__(self): + return self.title diff --git a/akarpov/notifications/providers/__init__.py b/akarpov/notifications/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/akarpov/notifications/providers/email/__init__.py b/akarpov/notifications/providers/email/__init__.py new file mode 100644 index 0000000..a749c1c --- /dev/null +++ b/akarpov/notifications/providers/email/__init__.py @@ -0,0 +1 @@ +from .send import send_notification # noqa diff --git a/akarpov/notifications/providers/email/send.py b/akarpov/notifications/providers/email/send.py new file mode 100644 index 0000000..5e74f25 --- /dev/null +++ b/akarpov/notifications/providers/email/send.py @@ -0,0 +1,34 @@ +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string + +from akarpov.notifications.models import Notification +from akarpov.users.models import User + + +def send_notification(notification: Notification) -> bool: + if not notification.meta or all( + ["email" not in notification.meta, "user_id" not in notification.meta] + ): + raise KeyError( + f"can't send notification {notification.id}, email/user_id is not found" + ) + if "email" in notification.meta: + email = notification.meta["email"] + username = "" + else: + user = User.objects.get(id=notification.meta["user_id"]) + email = user.email + username = user.username + message = render_to_string( + "email/notification.html", {"username": username, "body": notification.body} + ) + send_mail( + notification.title, + notification.body, + settings.EMAIL_FROM, + [email], + fail_silently=False, + html_message=message, + ) + return True diff --git a/akarpov/notifications/providers/site/__init__.py b/akarpov/notifications/providers/site/__init__.py new file mode 100644 index 0000000..9aab311 --- /dev/null +++ b/akarpov/notifications/providers/site/__init__.py @@ -0,0 +1,8 @@ +""" +Notifications site provider +meta params: + - user_id: bool, required + - conformation: bool, optional +""" + +from .send import send_notification # noqa diff --git a/akarpov/notifications/providers/site/api/__init__.py b/akarpov/notifications/providers/site/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/akarpov/notifications/providers/site/api/serializers.py b/akarpov/notifications/providers/site/api/serializers.py new file mode 100644 index 0000000..a781c4b --- /dev/null +++ b/akarpov/notifications/providers/site/api/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from akarpov.notifications.models import Notification + + +class SiteNotificationSerializer(serializers.ModelSerializer): + class Meta: + model = Notification + fields = ["title", "body", "created", "delivered"] diff --git a/akarpov/notifications/providers/site/api/views.py b/akarpov/notifications/providers/site/api/views.py new file mode 100644 index 0000000..bded5bf --- /dev/null +++ b/akarpov/notifications/providers/site/api/views.py @@ -0,0 +1,19 @@ +from rest_framework import generics, permissions + +from akarpov.common.api import StandardResultsSetPagination +from akarpov.notifications.models import Notification +from akarpov.notifications.providers.site.api.serializers import ( + SiteNotificationSerializer, +) + + +class ListNotificationsAPIView(generics.ListAPIView): + permission_classes = [permissions.IsAuthenticated] + serializer_class = SiteNotificationSerializer + pagination_class = StandardResultsSetPagination + + def get_queryset(self): + return Notification.objects.filter(meta__user_id=self.request.user.id) + + +# TODO: add read notification url here diff --git a/akarpov/notifications/providers/site/consumers.py b/akarpov/notifications/providers/site/consumers.py new file mode 100644 index 0000000..64dc16a --- /dev/null +++ b/akarpov/notifications/providers/site/consumers.py @@ -0,0 +1,28 @@ +from akarpov.common.channels import BaseConsumer + + +class NotificationsConsumer(BaseConsumer): + def __init__(self, *args, **kwargs): + super().__init__(args, kwargs) + self.room_group_name = None + + async def connect(self): + self.room_group_name = f"notifications_{self.scope['user_id']}" + + await self.accept() + await self.channel_layer.group_add(self.room_group_name, self.channel_name) + if not self.scope["user_id"]: + await self.send_error("Authorization is required") + await self.disconnect(close_code=None) + return + + async def disconnect(self, close_code): + await self.channel_layer.group_discard(self.room_group_name, self.channel_name) + await self.close() + + async def receive_json(self, content: dict, **kwargs): + return content + + async def notification(self, event): + data = event["data"] + await self.send_json(data) diff --git a/akarpov/notifications/providers/site/send.py b/akarpov/notifications/providers/site/send.py new file mode 100644 index 0000000..1a7b34d --- /dev/null +++ b/akarpov/notifications/providers/site/send.py @@ -0,0 +1,30 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +from akarpov.notifications.models import Notification +from akarpov.notifications.providers.site.api.serializers import ( + SiteNotificationSerializer, +) + + +def send_notification(notification: Notification) -> bool: + if ( + not notification.meta + or "user_id" not in notification.meta + or not notification.meta["user_id"] + ): + raise KeyError( + f"can't send notification {notification.id}, user_id is not found" + ) + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + f"notifications_{notification.meta['user_id']}", + { + "type": "notification", + "data": SiteNotificationSerializer().to_representation(notification), + }, + ) + if "conformation" in notification.meta and notification.meta["conformation"]: + # no view conformation required, only pop up on site + return False + return True diff --git a/akarpov/notifications/providers/site/urls.py b/akarpov/notifications/providers/site/urls.py new file mode 100644 index 0000000..917d3bc --- /dev/null +++ b/akarpov/notifications/providers/site/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from akarpov.notifications.providers.site.api.views import ListNotificationsAPIView + +app_name = "notifications:site" +urlpatterns = [ + path("", ListNotificationsAPIView.as_view(), name="list"), +] diff --git a/akarpov/notifications/providers/urls.py b/akarpov/notifications/providers/urls.py new file mode 100644 index 0000000..8dbe2da --- /dev/null +++ b/akarpov/notifications/providers/urls.py @@ -0,0 +1,8 @@ +from django.urls import include, path + +app_name = "notifications" +urlpatterns = [ + path( + "site/", include("akarpov.notifications.providers.site.urls", namespace="site") + ), +] diff --git a/akarpov/notifications/services.py b/akarpov/notifications/services.py new file mode 100644 index 0000000..e5aa6f4 --- /dev/null +++ b/akarpov/notifications/services.py @@ -0,0 +1,7 @@ +from akarpov.notifications.tasks import run_create_send_notification + + +def send_notification(title: str, body: str, provider: str, **kwargs): + run_create_send_notification.apply_async( + kwargs={"title": title, "body": body, "provider": provider} | kwargs + ) diff --git a/akarpov/notifications/signals.py b/akarpov/notifications/signals.py new file mode 100644 index 0000000..83e7cb8 --- /dev/null +++ b/akarpov/notifications/signals.py @@ -0,0 +1,11 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from akarpov.notifications.models import Notification +from akarpov.notifications.tasks import run_send_notification + + +@receiver(post_save, sender=Notification) +def notification_create(sender, instance: Notification, created, **kwargs): + if created: + run_send_notification.apply_async(kwargs={"pk": instance.pk}, countdown=2) diff --git a/akarpov/notifications/tasks.py b/akarpov/notifications/tasks.py new file mode 100644 index 0000000..2a3a0d5 --- /dev/null +++ b/akarpov/notifications/tasks.py @@ -0,0 +1,32 @@ +from importlib import import_module + +from celery import shared_task + +from akarpov.notifications.models import Notification + +providers = {x[1]: x[0] for x in Notification.NotificationProviders.choices} + + +@shared_task +def run_send_notification(pk): + instance = Notification.objects.get(pk=pk) + provider = import_module(instance.provider) + instance.delivered = provider.send_notification(instance) + instance.save() + + +@shared_task +def run_create_send_notification(title: str, body: str, provider: str, **kwargs): + if provider != "*" and provider not in providers: + raise ValueError(f"no such provider: {provider}") + if provider == "*": + for provider in providers: + Notification.objects.create( + title=title, body=body, provider=providers[provider], meta=kwargs + ) + else: + Notification.objects.create( + title=title, body=body, provider=providers[provider], meta=kwargs + ) + + return diff --git a/akarpov/notifications/tests.py b/akarpov/notifications/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/akarpov/notifications/views.py b/akarpov/notifications/views.py new file mode 100644 index 0000000..e69de29 diff --git a/akarpov/templates/email/notification.html b/akarpov/templates/email/notification.html new file mode 100644 index 0000000..f127d84 --- /dev/null +++ b/akarpov/templates/email/notification.html @@ -0,0 +1,6 @@ +

Hello, {% if username %}{{ username }}{% endif %}!

+

You are seeing this message, because you received notification from akarpov.ru

+ +

{{ body }}

+ +

If you don't want to receive notifications via email, or got it by excitement press here

diff --git a/akarpov/users/api/views.py b/akarpov/users/api/views.py index 870e556..b3aacaa 100644 --- a/akarpov/users/api/views.py +++ b/akarpov/users/api/views.py @@ -1,8 +1,10 @@ +from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema from rest_framework import generics, permissions, status, views from rest_framework.response import Response from akarpov.common.api import SmallResultsSetPagination +from akarpov.common.jwt import sign_jwt from akarpov.users.api.serializers import ( UserEmailVerification, UserFullPublicInfoSerializer, @@ -27,6 +29,14 @@ def post(self, request, *args, **kwargs): return self.create(request, *args, **kwargs) +class GenerateUserJWTTokenAPIView(generics.GenericAPIView): + permission_classes = [permissions.IsAuthenticated] + + @extend_schema(responses={200: OpenApiTypes.STR}) + def get(self, request, *args, **kwargs): + return Response(data=sign_jwt(data={"id": self.request.user.id})) + + class UserEmailValidationAPIViewSet(views.APIView): """Receives token from email and activates user""" diff --git a/compose/production/django/Dockerfile b/compose/production/django/Dockerfile index c88dbaf..237d469 100644 --- a/compose/production/django/Dockerfile +++ b/compose/production/django/Dockerfile @@ -26,6 +26,7 @@ ARG APP_HOME=/app ENV PYTHONUNBUFFERED 1 ENV PYTHONDONTWRITEBYTECODE 1 ENV BUILD_ENV ${BUILD_ENVIRONMENT} +ENV POETRY_VERSION 1.4.2 WORKDIR ${APP_HOME} @@ -34,14 +35,12 @@ RUN addgroup --system django \ # Install required system dependencies -RUN apt-get update && apt-get install --no-install-recommends -y \ - # psycopg2 dependencies - libpq-dev \ - # Translations dependencies - gettext \ - # cleaning up unused files - && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ - && rm -rf /var/lib/apt/lists/* +RUN apt-get update && \ + apt-get install -y build-essential libpq-dev gettext libmagic-dev libjpeg-dev zlib1g-dev && \ + # Dependencies for file preview generation + apt-get install -y webp libimage-exiftool-perl libmagickwand-dev ffmpeg libgdal-dev && \ + apt-get purge -y --auto-remove -o APT:AutoRemove:RecommendsImportant=false && \ + rm -rf /var/lib/apt/lists/* RUN pip install "poetry==$POETRY_VERSION" diff --git a/compose/production/traefik/traefik.yml b/compose/production/traefik/traefik.yml index 1005d93..7b13c4e 100644 --- a/compose/production/traefik/traefik.yml +++ b/compose/production/traefik/traefik.yml @@ -63,6 +63,11 @@ http: servers: - url: http://django:5000 + redirect: + loadBalancer: + servers: + - url: http://redirect:3000 + flower: loadBalancer: servers: diff --git a/config/api_router.py b/config/api_router.py index 433a4bf..94b4eb1 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -1,7 +1,7 @@ from django.urls import include, path from rest_framework.authtoken.views import obtain_auth_token -from akarpov.users.api.views import UserRegisterAPIViewSet +from akarpov.users.api.views import GenerateUserJWTTokenAPIView, UserRegisterAPIViewSet app_name = "api" @@ -16,6 +16,7 @@ name="user_register_api", ), path("token/", obtain_auth_token), + path("jwt/", GenerateUserJWTTokenAPIView.as_view()), ] ), ), @@ -23,6 +24,10 @@ "users/", include("akarpov.users.api.urls", namespace="users"), ), + path( + "notifications/", + include("akarpov.notifications.providers.urls", namespace="notifications"), + ), path( "blog/", include("akarpov.blog.api.urls", namespace="blog"), diff --git a/config/routing.py b/config/routing.py index 4cfa959..3781a14 100644 --- a/config/routing.py +++ b/config/routing.py @@ -1,7 +1,9 @@ from django.urls import re_path from akarpov.music.consumers import RadioConsumer +from akarpov.notifications.providers.site.consumers import NotificationsConsumer websocket_urlpatterns = [ re_path(r"ws/radio/", RadioConsumer.as_asgi()), + re_path(r"ws/notifications/", NotificationsConsumer.as_asgi()), ] diff --git a/config/settings/base.py b/config/settings/base.py index 15abfe0..69452e4 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -155,10 +155,11 @@ "akarpov.files", "akarpov.music", "akarpov.gallery", + "akarpov.tools.qr", "akarpov.pipeliner", + "akarpov.notifications", "akarpov.test_platform", "akarpov.tools.shortener", - "akarpov.tools.qr", "akarpov.tools.promocodes", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps diff --git a/config/settings/local.py b/config/settings/local.py index b42f819..c94478f 100644 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -10,6 +10,7 @@ "DJANGO_SECRET_KEY", default="Lm4alUqtub6qQT4MnV4NmtXQP02RCBtmGj1bJhyDho07Bkjk9WFZxGtwpnLNQGJQ", ) +TOKEN_EXP = 24 * 60 * 60 # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts ALLOWED_HOSTS = ["*"] CSRF_TRUSTED_ORIGINS = ["http://127.0.0.1", "https://*.akarpov.ru"] @@ -20,6 +21,7 @@ EMAIL_HOST = env("EMAIL_HOST", default="mailhog") # https://docs.djangoproject.com/en/dev/ref/settings/#email-port EMAIL_PORT = env("EMAIL_PORT", default="1025") +EMAIL_FROM = env("EMAIL_FROM", default="noreply@akarpov.ru") # WhiteNoise # ------------------------------------------------------------------------------ diff --git a/production.yml b/production.yml index ab637b3..dd184df 100644 --- a/production.yml +++ b/production.yml @@ -24,7 +24,6 @@ services: - ./.envs/.production/.postgres command: /start - redirect: build: context: . @@ -35,16 +34,30 @@ services: depends_on: - postgres - redis - - mailhog volumes: - - .:/app:z + - type: bind + source: /var/www/media/ + target: /app/akarpov/media/ env_file: - ./.envs/.production/.django - ./.envs/.production/.postgres - ports: - - "3000:3000" command: /start-redirect + traefik: + build: + context: . + dockerfile: ./compose/production/traefik/Dockerfile + image: akarpov_production_traefik + depends_on: + - django + - redirect + volumes: + - production_traefik:/etc/traefik/acme + ports: + - "0.0.0.0:80:80" + - "0.0.0.0:443:443" + - "0.0.0.0:3000:3000" + - "0.0.0.0:5555:5555" postgres: build: