Introduce notifications app and integrate emails
Add a new notifications app (models, serializers, views, admin, tasks, consumers, routing, urls, tests) with Notification.notify for in-app websocket pushes and optional email delivery. Centralize email sending in notifications.tasks.send_email_with_context and re-export it from account.tasks for compatibility. Update account and commerce and advertisement tasks to use Notification.notify/_notify_order and the new email helper; adjust order/notification tasks to consolidate logic. Wire notifications into ASGI routing, settings and URL conf. Misc: handle OSError when importing weasyprint, add tmp/ to .gitignore, add local .claude PowerShell checks, add social.blog skeleton, and remove legacy ews-component test files.
This commit is contained in:
0
backend/notifications/__init__.py
Normal file
0
backend/notifications/__init__.py
Normal file
11
backend/notifications/admin.py
Normal file
11
backend/notifications/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
from .models import Notification
|
||||
|
||||
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "title", "notification_type", "user", "is_read", "bulk", "send_email", "created_at")
|
||||
list_filter = ("notification_type", "is_read", "bulk", "send_email", "created_at")
|
||||
search_fields = ("title", "text", "user__email", "user__username")
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = ("created_at", "read_at", "email_subject", "email_template_path", "send_email", "bulk")
|
||||
5
backend/notifications/apps.py
Normal file
5
backend/notifications/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
name = 'notifications'
|
||||
93
backend/notifications/consumers.py
Normal file
93
backend/notifications/consumers.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import json
|
||||
from channels.db import database_sync_to_async
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
@database_sync_to_async
|
||||
def _mark_notification_read(notification_id, user):
|
||||
from .models import Notification
|
||||
try:
|
||||
n = Notification.objects.get(pk=notification_id, user=user)
|
||||
if not n.is_read:
|
||||
n.is_read = True
|
||||
n.read_at = timezone.now()
|
||||
n.save(update_fields=["is_read", "read_at"])
|
||||
return n.read_at.isoformat()
|
||||
except Notification.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
@database_sync_to_async
|
||||
def _mark_all_notifications_read(user):
|
||||
from .models import Notification
|
||||
now = timezone.now()
|
||||
return Notification.objects.filter(user=user, is_read=False).update(is_read=True, read_at=now)
|
||||
|
||||
|
||||
@database_sync_to_async
|
||||
def _delete_notification(notification_id, user):
|
||||
from .models import Notification
|
||||
try:
|
||||
n = Notification.objects.get(pk=notification_id, user=user)
|
||||
n.delete()
|
||||
return True
|
||||
except Notification.DoesNotExist:
|
||||
return False
|
||||
|
||||
|
||||
class NotificationConsumer(AsyncWebsocketConsumer):
|
||||
|
||||
async def connect(self):
|
||||
user = self.scope["user"]
|
||||
if not user.is_authenticated:
|
||||
await self.close(code=4401)
|
||||
return
|
||||
self.group_name = f"notifications_{user.pk}"
|
||||
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||
await self.accept()
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
if hasattr(self, "group_name"):
|
||||
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||
|
||||
async def receive(self, text_data):
|
||||
data = json.loads(text_data)
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "mark_read":
|
||||
notification_id = data.get("id")
|
||||
read_at = await _mark_notification_read(notification_id, self.scope["user"])
|
||||
if read_at:
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "notification.read",
|
||||
"id": notification_id,
|
||||
"read_at": read_at,
|
||||
}))
|
||||
|
||||
elif msg_type == "mark_all_read":
|
||||
count = await _mark_all_notifications_read(self.scope["user"])
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "notification.read_all",
|
||||
"marked": count,
|
||||
}))
|
||||
|
||||
elif msg_type == "delete":
|
||||
notification_id = data.get("id")
|
||||
deleted = await _delete_notification(notification_id, self.scope["user"])
|
||||
if deleted:
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "notification.deleted",
|
||||
"id": notification_id,
|
||||
}))
|
||||
|
||||
async def notification_new(self, event):
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "notification.new",
|
||||
"id": event["id"],
|
||||
"title": event["title"],
|
||||
"text": event["text"],
|
||||
"notification_type": event["notification_type"],
|
||||
"action_url": event.get("action_url"),
|
||||
"created_at": event["created_at"],
|
||||
}))
|
||||
0
backend/notifications/migrations/__init__.py
Normal file
0
backend/notifications/migrations/__init__.py
Normal file
145
backend/notifications/models.py
Normal file
145
backend/notifications/models.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import logging
|
||||
from django.db import models
|
||||
|
||||
from account.models import CustomUser
|
||||
from vontor_cz.models import SoftDeleteModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Notification(SoftDeleteModel):
|
||||
class Type(models.TextChoices):
|
||||
SYSTEM = "system", "Systém"
|
||||
ORDER = "order", "Objednávka"
|
||||
PAYMENT = "payment", "Platba"
|
||||
SOCIAL = "social", "Sociální"
|
||||
CHAT = "chat", "Chat"
|
||||
ADVERTISEMENT = "advertisement", "Inzerát"
|
||||
|
||||
title = models.CharField(max_length=200, help_text="Předmět oznámení.")
|
||||
text = models.TextField(help_text="Obsah oznámení.")
|
||||
|
||||
notification_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=Type.choices,
|
||||
default=Type.SYSTEM,
|
||||
help_text="Kategorie oznámení — používá se pro ikonky a filtrování na frontendu.",
|
||||
)
|
||||
action_url = models.CharField(
|
||||
max_length=500,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Volitelný odkaz na detail (např. '/objednavky/123/').",
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
user = models.ForeignKey(
|
||||
CustomUser, on_delete=models.CASCADE, null=True, related_name='notifications',
|
||||
help_text="Příjemce oznámení.",
|
||||
)
|
||||
bulk = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True, pokud bylo oznámení vytvořeno hromadně pro více uživatelů.",
|
||||
)
|
||||
|
||||
is_read = models.BooleanField(default=False)
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
send_email = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True, pokud byl zároveň odeslán e-mail.",
|
||||
)
|
||||
email_subject = models.CharField(max_length=255, null=True, blank=True)
|
||||
email_template_path = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@classmethod
|
||||
def notify(cls, user=None, title=None, text=None, *, users=None,
|
||||
notification_type=None, action_url=None,
|
||||
email_subject=None, template_path=None,
|
||||
email_context=None, attachments=None, bulk=False):
|
||||
"""Create an in-app notification and optionally send an email + WebSocket push.
|
||||
|
||||
Modes:
|
||||
• Single — user=<CustomUser> → returns Notification
|
||||
• Bulk — users=<list|qs> → returns list[Notification]
|
||||
|
||||
notification_type controls the frontend icon/colour (default: system).
|
||||
action_url is an optional relative path the bell badge links to.
|
||||
If template_path is set, an email is dispatched (Celery if CELERY_ENABLED, else sync).
|
||||
"""
|
||||
if users is not None:
|
||||
return [
|
||||
cls.notify(
|
||||
user=u, title=title, text=text,
|
||||
notification_type=notification_type, action_url=action_url,
|
||||
email_subject=email_subject, template_path=template_path,
|
||||
email_context=email_context, attachments=attachments,
|
||||
bulk=True,
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
if user is None:
|
||||
raise ValueError("Notification.notify() requires either 'user' or 'users'.")
|
||||
|
||||
notification = cls.objects.create(
|
||||
user=user,
|
||||
title=title,
|
||||
text=text,
|
||||
notification_type=notification_type or cls.Type.SYSTEM,
|
||||
action_url=action_url,
|
||||
bulk=bulk,
|
||||
send_email=bool(template_path),
|
||||
email_subject=email_subject,
|
||||
email_template_path=template_path,
|
||||
)
|
||||
|
||||
# Real-time push via WebSocket
|
||||
try:
|
||||
from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer:
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
f"notifications_{user.pk}",
|
||||
{
|
||||
"type": "notification.new",
|
||||
"id": notification.pk,
|
||||
"title": notification.title,
|
||||
"text": notification.text,
|
||||
"notification_type": notification.notification_type,
|
||||
"action_url": notification.action_url,
|
||||
"created_at": notification.created_at.isoformat(),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[Notification.notify] WebSocket push failed for user %s: %s", user.pk, exc)
|
||||
|
||||
if template_path and getattr(user, 'email', None):
|
||||
try:
|
||||
from django.conf import settings
|
||||
from notifications.tasks import send_notification_email_task
|
||||
ctx = dict(email_context or {})
|
||||
if 'user' not in ctx:
|
||||
ctx['user'] = user
|
||||
kwargs = dict(
|
||||
recipient_email=user.email,
|
||||
subject=email_subject or title,
|
||||
template_path=template_path,
|
||||
context=ctx,
|
||||
attachments=attachments,
|
||||
)
|
||||
if getattr(settings, 'CELERY_ENABLED', False):
|
||||
send_notification_email_task.delay(**kwargs)
|
||||
else:
|
||||
send_notification_email_task(**kwargs)
|
||||
except Exception as exc:
|
||||
logger.warning("[Notification.notify] Email failed for user %s: %s", user.pk, exc)
|
||||
|
||||
return notification
|
||||
6
backend/notifications/routing.py
Normal file
6
backend/notifications/routing.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.urls import re_path
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r"ws/notifications/$", consumers.NotificationConsumer.as_asgi()),
|
||||
]
|
||||
15
backend/notifications/serializers.py
Normal file
15
backend/notifications/serializers.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Notification
|
||||
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = [
|
||||
'id', 'title', 'text', 'notification_type', 'action_url',
|
||||
'is_read', 'read_at', 'created_at', 'bulk', 'send_email',
|
||||
]
|
||||
read_only_fields = [
|
||||
'id', 'title', 'text', 'notification_type', 'action_url',
|
||||
'is_read', 'read_at', 'created_at', 'bulk', 'send_email',
|
||||
]
|
||||
119
backend/notifications/tasks.py
Normal file
119
backend/notifications/tasks.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from datetime import datetime
|
||||
|
||||
from celery import shared_task
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
from django.core.mail import send_mail, EmailMultiAlternatives
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
from account.models import CustomUser
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_notification_email_task(recipient_email, subject, template_path, context=None, attachments=None):
|
||||
send_email_with_context(
|
||||
recipients=recipient_email,
|
||||
subject=subject,
|
||||
template_path=template_path,
|
||||
context=context or {},
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def send_email_with_context(recipients, subject, template_path=None, context=None, message: str | None = None, attachments=None):
|
||||
"""
|
||||
Send email using component-based template system.
|
||||
Uses base.html with header/footer components and includes the specified content template.
|
||||
"""
|
||||
if isinstance(recipients, str):
|
||||
recipients = [recipients]
|
||||
|
||||
if not recipients:
|
||||
logger.warning("No recipients provided for email. Skipping sending email.")
|
||||
return "No recipients provided for email."
|
||||
|
||||
ctx = dict(context or {})
|
||||
|
||||
# Add current_year to context if not present
|
||||
if "current_year" not in ctx:
|
||||
ctx["current_year"] = datetime.now().year
|
||||
|
||||
# Add AppConfig data for footer if not already present
|
||||
if "company_name" not in ctx:
|
||||
try:
|
||||
from configuration.models import AppConfig
|
||||
config = AppConfig.objects.first()
|
||||
if config:
|
||||
ctx["company_name"] = config.company_name
|
||||
ctx["company_address"] = config.company_address
|
||||
ctx["company_ico"] = config.company_ico
|
||||
ctx["company_dic"] = config.company_dic
|
||||
ctx["contact_email"] = config.contact_email
|
||||
ctx["contact_phone"] = config.contact_phone
|
||||
except Exception:
|
||||
pass # Gracefully skip if AppConfig not available
|
||||
|
||||
# Sanitize user if someone passes the model by mistake
|
||||
if "user" in ctx and not isinstance(ctx["user"], dict):
|
||||
try:
|
||||
ctx["user"] = _build_user_template_ctx(ctx["user"])
|
||||
except Exception:
|
||||
ctx["user"] = {}
|
||||
|
||||
# Render HTML using base template with content include
|
||||
html_message = None
|
||||
if template_path:
|
||||
ctx["content_template"] = template_path
|
||||
html_message = render_to_string("email/components/base.html", ctx)
|
||||
|
||||
try:
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=message or "Tento e-mail vyžaduje HTML podporu.",
|
||||
from_email=None,
|
||||
to=recipients if len(recipients) == 1 else [],
|
||||
bcc=recipients if len(recipients) > 1 else [],
|
||||
)
|
||||
|
||||
if html_message:
|
||||
email.attach_alternative(html_message, "text/html")
|
||||
|
||||
if attachments:
|
||||
for filename, content, mimetype in attachments:
|
||||
email.attach(filename, content, mimetype)
|
||||
|
||||
email.send(fail_silently=False)
|
||||
|
||||
logger.info(f"Sent email to {recipients} with subject '{subject}'")
|
||||
|
||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
||||
logger.debug(f"\nEMAIL HTML:\n{html_message}\nKONEC OBSAHU")
|
||||
return "Successfully sent email."
|
||||
except Exception as e:
|
||||
logger.error(f"E-mail se neodeslal: {e}")
|
||||
raise # Re-raise so Celery marks the task as FAILED and can retry
|
||||
|
||||
|
||||
def _build_user_template_ctx(user: CustomUser) -> dict:
|
||||
"""
|
||||
Return a plain dict for templates instead of passing the DB model.
|
||||
Provides aliases to avoid template errors (firstname vs first_name).
|
||||
Adds a backward-compatible key 'get_full_name' for templates using user.get_full_name.
|
||||
"""
|
||||
first_name = getattr(user, "first_name", "") or ""
|
||||
last_name = getattr(user, "last_name", "") or ""
|
||||
full_name = f"{first_name} {last_name}".strip()
|
||||
return {
|
||||
"id": user.pk,
|
||||
"email": getattr(user, "email", "") or "",
|
||||
"first_name": first_name,
|
||||
"firstname": first_name, # alias for templates using `firstname`
|
||||
"last_name": last_name,
|
||||
"lastname": last_name, # alias for templates using `lastname`
|
||||
"full_name": full_name,
|
||||
"get_full_name": full_name, # compatibility for templates using method-style access
|
||||
}
|
||||
3
backend/notifications/tests.py
Normal file
3
backend/notifications/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
10
backend/notifications/urls.py
Normal file
10
backend/notifications/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from . import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'', views.NotificationViewSet, basename='notification')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
51
backend/notifications/views.py
Normal file
51
backend/notifications/views.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from django.utils import timezone
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
from .models import Notification
|
||||
from .serializers import NotificationSerializer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema(tags=["notifications"])
|
||||
class NotificationViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
|
||||
serializer_class = NotificationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return Notification.objects.filter(user=self.request.user)
|
||||
|
||||
@extend_schema(
|
||||
summary="Unread notification count",
|
||||
responses={200: {"type": "object", "properties": {"unread_count": {"type": "integer"}}}},
|
||||
)
|
||||
@action(detail=False, methods=["get"], url_path="unread-count")
|
||||
def unread_count(self, request):
|
||||
count = self.get_queryset().filter(is_read=False).count()
|
||||
return Response({"unread_count": count})
|
||||
|
||||
@extend_schema(summary="Mark a single notification as read", responses={200: NotificationSerializer})
|
||||
@action(detail=True, methods=["post"], url_path="read")
|
||||
def mark_read(self, request, pk=None):
|
||||
notification = self.get_object()
|
||||
if not notification.is_read:
|
||||
notification.is_read = True
|
||||
notification.read_at = timezone.now()
|
||||
notification.save(update_fields=["is_read", "read_at"])
|
||||
return Response(NotificationSerializer(notification).data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Mark all notifications as read",
|
||||
responses={200: {"type": "object", "properties": {"marked": {"type": "integer"}}}},
|
||||
)
|
||||
@action(detail=False, methods=["post"], url_path="read-all")
|
||||
def mark_all_read(self, request):
|
||||
now = timezone.now()
|
||||
updated = self.get_queryset().filter(is_read=False).update(is_read=True, read_at=now)
|
||||
return Response({"marked": updated})
|
||||
Reference in New Issue
Block a user