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:
David Bruno Vontor
2026-06-09 16:18:41 +02:00
parent 46bc131a56
commit 2592a69790
74 changed files with 666 additions and 13194 deletions

View File

View 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")

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
name = 'notifications'

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

View 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

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

View 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',
]

View 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
}

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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)),
]

View 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})