added notifications, web provider and email provider

This commit is contained in:
Alexander Karpov 2023-09-24 20:28:21 +03:00
parent 3ef20b5eb9
commit 8583885960
33 changed files with 383 additions and 23 deletions

View File

@ -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:

View File

@ -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"]])

View File

View File

@ -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"]

View File

@ -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

View File

@ -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,
},
),
]

View File

@ -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

View File

@ -0,0 +1 @@
from .send import send_notification # noqa

View File

@ -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

View File

@ -0,0 +1,8 @@
"""
Notifications site provider
meta params:
- user_id: bool, required
- conformation: bool, optional
"""
from .send import send_notification # noqa

View File

@ -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"]

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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"),
]

View File

@ -0,0 +1,8 @@
from django.urls import include, path
app_name = "notifications"
urlpatterns = [
path(
"site/", include("akarpov.notifications.providers.site.urls", namespace="site")
),
]

View File

@ -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
)

View File

@ -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)

View File

@ -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

View File

View File

View File

@ -0,0 +1,6 @@
<h3>Hello, {% if username %}{{ username }}{% endif %}!</h3>
<p>You are seeing this message, because you received notification from <a href="https://akarpov.ru">akarpov.ru</a></p>
<p>{{ body }}</p>
<p>If you don't want to receive notifications via email, or got it by excitement <a href="{# TODO: add unsubscribe #}">press here</a></p>

View File

@ -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"""

View File

@ -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"

View File

@ -63,6 +63,11 @@ http:
servers:
- url: http://django:5000
redirect:
loadBalancer:
servers:
- url: http://redirect:3000
flower:
loadBalancer:
servers:

View File

@ -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"),

View File

@ -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()),
]

View File

@ -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

View File

@ -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
# ------------------------------------------------------------------------------

View File

@ -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: