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

@@ -1,121 +1,80 @@
from celery import shared_task
from celery.utils.log import get_task_logger
from django.core.mail import send_mail
from django.conf import settings
from django.utils.http import urlsafe_base64_encode
from django.utils.encoding import force_bytes
from django.template.loader import render_to_string
from .tokens import *
from .models import CustomUser
# Re-exported so existing imports from account.tasks still work
from notifications.tasks import send_email_with_context # noqa: F401
logger = get_task_logger(__name__)
def send_email_with_context(recipients, subject, template_path=None, context=None, message: str | None = None):
"""
Send emails rendering a single HTML template.
- `template_name` is a simple base name without extension, e.g. "email/test".
- Renders only HTML (".html"), no ".txt" support.
- Converts `user` in context to a plain dict to avoid passing models to templates.
"""
if isinstance(recipients, str):
recipients = [recipients]
html_message = None
if template_path:
ctx = dict(context or {})
# Render base layout and include the provided template as the main content.
# The included template receives the same context as the base.
html_message = render_to_string(
"email/components/base.html",
{"content_template": template_path, **ctx},
)
try:
send_mail(
subject=subject,
message=message or "",
from_email=None,
recipient_list=recipients,
fail_silently=False,
html_message=html_message,
)
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend' and message:
logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
return True
except Exception as e:
logger.error(f"E-mail se neodeslal: {e}")
return False
#----------------------------------------------------------------------------------------------------
# This function sends an email to the user for email verification after registration.
@shared_task
def send_email_verification_task(user_id):
from notifications.models import Notification
try:
user = CustomUser.objects.get(pk=user_id)
except CustomUser.DoesNotExist:
logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
return 0
logger.info("send_email_verification_task: invalid user_id %s", user_id)
return
uid = urlsafe_base64_encode(force_bytes(user.pk))
# {changed} generate and store a per-user token
token = user.generate_email_verification_token()
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
context = {
"user": user,
"action_url": verify_url,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Ověřit email",
}
send_email_with_context(
recipients=user.email,
subject="Ověření emailu",
Notification.notify(
user=user,
title="Ověření emailu",
text="Prosím ověřte svou e-mailovou adresu kliknutím na odkaz v e-mailu.",
action_url=verify_url,
template_path="email/email_verification.html",
context=context,
email_context={
"action_url": verify_url,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Ověřit email",
},
)
@shared_task
def send_email_test_task(email):
context = {
"action_url": settings.FRONTEND_URL,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Otevřít aplikaci",
}
send_email_with_context(
recipients=email,
subject="Testovací email",
template_path="email/test.html",
context=context,
context={
"action_url": settings.FRONTEND_URL,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Otevřít aplikaci",
},
)
@shared_task
def send_password_reset_email_task(user_id):
from notifications.models import Notification
try:
user = CustomUser.objects.get(pk=user_id)
except CustomUser.DoesNotExist:
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
return 0
logger.info("send_password_reset_email_task: invalid user_id %s", user_id)
return
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = password_reset_token.make_token(user)
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
context = {
"user": user,
"action_url": reset_url,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Obnovit heslo",
}
send_email_with_context(
recipients=user.email,
subject="Obnova hesla",
Notification.notify(
user=user,
title="Obnova hesla",
text="Byl vyžádán reset vašeho hesla. Klikněte na odkaz pro nastavení nového hesla.",
action_url=reset_url,
template_path="email/password_reset.html",
context=context,
)
email_context={
"action_url": reset_url,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Obnovit heslo",
},
)

View File

@@ -237,7 +237,9 @@ class UserView(viewsets.ModelViewSet):
def get_serializer_class(self):
user = getattr(self.request, 'user', None)
is_admin = user and (getattr(user, 'role', None) == 'admin' or getattr(user, 'is_superuser', False))
if self.action in ['retrieve', 'list'] and not is_admin:
return PublicUserSerializer
return CustomUserSerializer
@@ -277,11 +279,15 @@ class ChangePasswordView(APIView):
def post(self, request):
serializer = ChangePasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = request.user
if not user.check_password(serializer.validated_data['current_password']):
return Response({"current_password": "Nesprávné heslo."}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(serializer.validated_data['new_password'])
user.save()
return Response({"detail": "Heslo bylo úspěšně změněno."})

View File

@@ -1,47 +1,35 @@
from venv import create
from account.tasks import send_email_with_context
from notifications.tasks import send_email_with_context
from configuration.models import SiteConfiguration
from celery import shared_task
from celery.schedules import crontab
from commerce.models import Product
import datetime
@shared_task
def send_contact_me_email_task(client_email, message_content):
context = {
"client_email": client_email,
"message_content": message_content
}
config_email = SiteConfiguration.get_solo().contact_email
recipient = config_email if config_email else "brunovontor@gmail.com"
send_email_with_context(
recipients=recipient,
subject="Poptávka z kontaktního formuláře!!!",
template_path="email/contact_me.html",
context=context,
context={
"client_email": client_email,
"message_content": message_content,
},
)
@shared_task
def send_newly_added_items_to_store_email_task_last_week():
last_week_date = datetime.datetime.now() - datetime.timedelta(days=7)
"""
__lte -> Less than or equal
__gte -> Greater than or equal
__lt -> Less than
__gt -> Greater than
"""
products_of_week = Product.objects.filter(
include_in_week_summary_email=True,
created_at__gte=last_week_date
created_at__gte=last_week_date,
)
config = SiteConfiguration.get_solo()
send_email_with_context(
recipients=config.contact_email,
subject="Nový produkt přidán do obchodu",
@@ -49,6 +37,5 @@ def send_newly_added_items_to_store_email_task_last_week():
context={
"products_of_week": products_of_week,
"site_currency": config.currency,
}
},
)

View File

@@ -11,7 +11,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator, validat
try:
from weasyprint import HTML
except ImportError:
except (ImportError, OSError):
HTML = None
import os

View File

@@ -1,182 +1,157 @@
from account.tasks import send_email_with_context
from celery import shared_task
from django.apps import apps
from django.utils import timezone
from notifications.models import Notification
from notifications.tasks import send_email_with_context
def _notify_order(order, title, text, template_path, action_url=None):
"""Send in-app notification + email for logged-in users, email-only for guests."""
if order.user:
Notification.notify(
user=order.user,
title=title,
text=text,
notification_type=Notification.Type.ORDER,
action_url=action_url or f"/objednavky/{order.id}/",
template_path=template_path,
email_context={"order": order},
)
else:
send_email_with_context(
recipients=order.email,
subject=title,
template_path=template_path,
context={"order": order},
)
# -- CLEANUP TASKS --
# Delete expired/cancelled orders (older than 24 hours)
@shared_task
def delete_expired_orders():
Order = apps.get_model('commerce', 'Order')
expired_orders = Order.objects.filter(status=Order.OrderStatus.CANCELLED, created_at__lt=timezone.now() - timezone.timedelta(hours=24))
expired_orders = Order.objects.filter(
status=Order.OrderStatus.CANCELLED,
created_at__lt=timezone.now() - timezone.timedelta(hours=24),
)
count = expired_orders.count()
expired_orders.delete()
return count
# -- NOTIFICATIONS CARRIER --
# -- CARRIER NOTIFICATIONS --
# Zásilkovna
@shared_task
def notify_zasilkovna_sended(order = None, **kwargs):
def notify_zasilkovna_sended(order=None, **kwargs):
if not order:
raise ValueError("Order must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_zasilkovna_sended:", kwargs)
send_email_with_context(
recipients=order.email,
subject="Your order has been shipped",
raise ValueError("Order must be provided.")
_notify_order(
order,
title="Vaše objednávka byla odeslána",
text=f"Objednávka #{order.id} byla předána přepravci Zásilkovna.",
template_path="email/shipping/zasilkovna/zasilkovna_sended.html",
context={
"order": order,
})
)
# Shop
@shared_task
def notify_Ready_to_pickup(order = None, **kwargs):
def notify_Ready_to_pickup(order=None, **kwargs):
if not order:
raise ValueError("Order must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_Ready_to_pickup:", kwargs)
send_email_with_context(
recipients=order.email,
subject="Your order is ready for pickup",
raise ValueError("Order must be provided.")
_notify_order(
order,
title="Vaše objednávka je připravena k vyzvednutí",
text=f"Objednávka #{order.id} čeká na vyzvednutí na prodejně.",
template_path="email/shipping/ready_to_pickup/ready_to_pickup.html",
context={
"order": order,
})
)
# -- NOTIFICATIONS ORDER --
# -- ORDER NOTIFICATIONS --
@shared_task
def notify_order_successfuly_created(order = None, **kwargs):
def notify_order_successfuly_created(order=None, **kwargs):
if not order:
raise ValueError("Order must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_order_successfuly_created:", kwargs)
send_email_with_context(
recipients=order.email,
subject="Your order has been successfully created",
raise ValueError("Order must be provided.")
_notify_order(
order,
title="Objednávka byla úspěšně vytvořena",
text=f"Vaše objednávka #{order.id} byla přijata a čeká na zpracování.",
template_path="email/order_created.html",
context={
"order": order,
})
)
@shared_task
def notify_order_payed(order = None, **kwargs):
def notify_order_payed(order=None, **kwargs):
if not order:
raise ValueError("Order must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_order_payed:", kwargs)
send_email_with_context(
recipients=order.email,
subject="Your order has been paid",
raise ValueError("Order must be provided.")
_notify_order(
order,
title="Platba objednávky přijata",
text=f"Platba za objednávku #{order.id} byla úspěšně přijata.",
template_path="email/order_paid.html",
context={
"order": order,
})
)
@shared_task
def notify_about_missing_payment(order = None, **kwargs):
def notify_about_missing_payment(order=None, **kwargs):
if not order:
raise ValueError("Order must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_about_missing_payment:", kwargs)
send_email_with_context(
recipients=order.email,
subject="Payment missing for your order",
raise ValueError("Order must be provided.")
_notify_order(
order,
title="Nezaplacená objednávka",
text=f"Objednávka #{order.id} dosud nebyla zaplacena. Dokončete platbu co nejdříve.",
template_path="email/order_missing_payment.html",
context={
"order": order,
})
)
# -- NOTIFICATIONS REFUND --
# -- REFUND NOTIFICATIONS --
@shared_task
def notify_refund_items_arrived(order = None, **kwargs):
def notify_refund_items_arrived(order=None, **kwargs):
if not order:
raise ValueError("Order must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_refund_items_arrived:", kwargs)
send_email_with_context(
recipients=order.email,
subject="Your refund items have arrived",
raise ValueError("Order must be provided.")
_notify_order(
order,
title="Vrácené zboží přijato",
text=f"Vrácené zboží k objednávce #{order.id} bylo přijato. Reklamace bude zpracována.",
template_path="email/order_refund_items_arrived.html",
context={
"order": order,
})
)
# Refund accepted, returning money
@shared_task
def notify_refund_accepted(order = None, **kwargs):
def notify_refund_accepted(order=None, **kwargs):
if not order:
raise ValueError("Order must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_refund_accepted:", kwargs)
send_email_with_context(
recipients=order.email,
subject="Your refund has been accepted",
raise ValueError("Order must be provided.")
_notify_order(
order,
title="Vrácení peněz schváleno",
text=f"Vaše reklamace k objednávce #{order.id} byla schválena. Peníze budou vráceny.",
template_path="email/order_refund_accepted.html",
context={
"order": order,
})
)
# -- NOTIFICATIONS ORDER STATUS --
# -- ORDER STATUS NOTIFICATIONS --
@shared_task
def notify_order_cancelled(order = None, **kwargs):
def notify_order_cancelled(order=None, **kwargs):
if not order:
raise ValueError("Order must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_order_cancelled:", kwargs)
send_email_with_context(
recipients=order.email,
subject="Your order has been cancelled",
raise ValueError("Order must be provided.")
_notify_order(
order,
title="Objednávka zrušena",
text=f"Objednávka #{order.id} byla zrušena.",
template_path="email/order_cancelled.html",
context={
"order": order,
})
)
@shared_task
def notify_order_completed(order = None, **kwargs):
def notify_order_completed(order=None, **kwargs):
if not order:
raise ValueError("Order must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_order_completed:", kwargs)
send_email_with_context(
recipients=order.email,
subject="Your order has been completed",
raise ValueError("Order must be provided.")
_notify_order(
order,
title="Objednávka dokončena",
text=f"Vaše objednávka #{order.id} byla úspěšně dokončena. Děkujeme za nákup!",
template_path="email/order_completed.html",
context={
"order": order,
})
)

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

View File

@@ -0,0 +1 @@
# editorjs.io - základ

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

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

View File

@@ -0,0 +1,16 @@
from django.db import models
# Create your models here.
class Blog(models.Model):
title = models.CharField(max_length=255)
content = models.JsonField()
authors = models.ManyToManyField('Author', related_name='blogs')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title

View File

View File

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

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -0,0 +1,6 @@
# Notify requests for chats
# Notify @mentions in chats
# Add requests before filling chat (maybe add chat type request and allowed and archived for advanced filtering) + actions

View File

@@ -1,68 +0,0 @@
chat app diagram
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT (browser) │
└────────────┬────────────────────────────┬───────────────────────┘
│ WebSocket │ HTTP REST
│ ws/chat/<id>/ │ /api/social/
▼ ▼
┌────────────────────────┐ ┌────────────────────────────────────┐
│ ChatConsumer │ │ REST Views │
│ (consumers.py) │ │ (views.py) │
│ │ │ │
│ connect() │ │ ChatViewSet │
│ ├─ auth check │ │ ├─ list / retrieve (GET) │
│ └─ membership check ──┼───┼──► IsChatMember │
│ │ │ ├─ create (POST) │
│ receive() │ │ ├─ update/partial (PATCH/PUT) │
│ ├─ new_chat_message │ │ │ └─ CanManageChat │
│ │ (text only) │ │ ├─ destroy (DELETE) │
│ │ └─► _create_message │ │ └─ CanManageChat │
│ │ (DB INSERT) │ │ └─ add/remove member & moderator │
│ │ │ │ │
│ ├─ new_reply_message │ │ MessageViewSet │
│ │ (text only) │ │ ├─ list / retrieve (GET) │
│ │ └─► _create_message │ │ └─ IsAuthenticated │
│ │ (DB INSERT) │ │ ├─ send (POST) │
│ │ │ │ │ ├─ IsAuthenticated │
│ └─ reaction │ │ │ ├─ DB INSERT Message │
│ └─► _toggle_reaction │ │ ├─ DB INSERT MessageFile(s) │
│ (DB UPDATE/ │ │ │ └─► group_send(chat.msg) ─────┼──► WebSocket push
│ DELETE) │ │ ├─ update/partial (PATCH/PUT) │
│ │ │ │ ├─ IsMessageSenderOnly │
│ typing / stop_typing │ │ │ ├─ message.edit_content() │
│ └─► group_send only │ │ │ └─► group_send(edit.msg) ─────┼──► WebSocket push
│ (no DB write) │ │ └─ destroy (DELETE) │
│ │ │ ├─ CanDeleteMessage │
│ │ │ └─► group_send(delete.msg) ───┼──► WebSocket push
└────────────┬───────────┘ └──────────────┬─────────────────────┘
│ │
│ both use channel_layer │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Channel Layer (Redis) │
│ group: "chat_{id}" │
└─────────────────────────────────────────────────────────────────┘
▼ group_send dispatches to all connected consumers
┌─────────────────────────────────────────────────────────────────┐
│ ChatConsumer event handlers (push to each connected client) │
│ chat_message · reply_chat_message · edit_message │
│ delete_message · message_reaction · typing_status · stop_typing│
└─────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────┐ ┌──────────────────────────┐
│ Database writes summary │ │ When to use which path │
│ │ │ │
│ CREATE Message ← consumer │ │ WS ← text-only msg │
│ (text only) │ │ HTTP← msg with files │
│ CREATE Message ← view /send │ │ HTTP← edit message │
│ (any) │ │ HTTP← delete message │
│ CREATE MessageFile ← view/send │ │ WS ← reaction │
│ UPDATE Message ← view │ │ WS ← typing indicator │
│ DELETE Message ← view (soft) │ └──────────────────────────┘
│ CREATE MessageReaction ← cons │
│ UPDATE MessageReaction ← cons │
│ DELETE MessageReaction ← cons │
│ CREATE MessageHistory ← model │
│ (auto on edit_content) │
└──────────────────────────────────┘

View File

@@ -0,0 +1,3 @@
# Notify on mod ADD
# notify for Apeal

View File

@@ -0,0 +1,11 @@
# Votes in posts as widget
# Location tag
# PFP from hub on posts outside of hub
# Collab/multiple authors on posts
# NSFW tag, spoiler tag
# Notify on likes 1 --> 10 --> 100 -> 500 -> 1000 etc..

View File

@@ -18,9 +18,10 @@ django_asgi_app = get_asgi_application()
from channels.routing import ProtocolTypeRouter, URLRouter
from social.chat.routing import websocket_urlpatterns as social_ws
from thirdparty.downloader.routing import websocket_urlpatterns as downloader_ws
from notifications.routing import websocket_urlpatterns as notifications_ws
from vontor_cz.middleware import JWTAuthMiddleware
websocket_urlpatterns = downloader_ws + social_ws
websocket_urlpatterns = downloader_ws + social_ws + notifications_ws
application = ProtocolTypeRouter({
"http": django_asgi_app,

View File

@@ -348,6 +348,7 @@ MY_CREATED_APPS = [
'social.posts',
'advertisement',
'notifications',
'thirdparty.downloader',
'thirdparty.stripe', # register Stripe app so its models are recognized

View File

@@ -43,6 +43,7 @@ urlpatterns = [
path('api/configuration/', include('configuration.urls')),
path('api/advertisement/', include('advertisement.urls')),
path('api/notifications/', include('notifications.urls')),
path('api/social/hubs/', include('social.hubs.urls')),
path('api/social/posts/', include('social.posts.urls')),
path('api/social/', include('social.chat.urls')),