Refactor email system and add contact form backend

Refactored email sending to use a single HTML template with a base layout, removed plain text email templates, and updated all related backend logic. Introduced a new ContactMe model, serializer, Celery task, and API endpoints for handling contact form submissions, including email notifications. Renamed ShopConfiguration to SiteConfiguration throughout the backend for consistency. Updated frontend to remove unused components, add a new Services section, and adjust navigation and contact form integration.
This commit is contained in:
2025-12-12 01:52:41 +01:00
parent df83288591
commit 564418501c
34 changed files with 433 additions and 327 deletions

View File

@@ -10,34 +10,25 @@ from .models import CustomUser
logger = get_task_logger(__name__)
# TODO: předělat funkci ať funguje s base.html šablonou na emaily !!!
def send_email_with_context(recipients, subject, message=None, template_name=None, html_template_name=None, context=None):
def send_email_with_context(recipients, subject, template_path=None, context=None, message: str | None = None):
"""
General function to send emails with a specific context.
Supports rendering plain text and HTML templates.
Converts `user` in context to a plain dict to avoid template access to the model.
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_name or html_template_name:
# Best effort to resolve both templates if only one provided
if not template_name and html_template_name:
template_name = html_template_name.replace(".html", ".txt")
if not html_template_name and template_name:
html_template_name = template_name.replace(".txt", ".html")
if template_path:
ctx = dict(context or {})
# 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"] = {}
message = render_to_string(template_name, ctx)
html_message = render_to_string(html_template_name, ctx)
# 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(
@@ -48,33 +39,13 @@ def send_email_with_context(recipients, subject, message=None, template_name=Non
fail_silently=False,
html_message=html_message,
)
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
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
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
}
#----------------------------------------------------------------------------------------------------
@@ -93,7 +64,7 @@ def send_email_verification_task(user_id):
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
context = {
"user": _build_user_template_ctx(user),
"user": user,
"action_url": verify_url,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Ověřit email",
@@ -102,8 +73,7 @@ def send_email_verification_task(user_id):
send_email_with_context(
recipients=user.email,
subject="Ověření emailu",
template_name="email/email_verification.txt",
html_template_name="email/email_verification.html",
template_path="email/email_verification.html",
context=context,
)
@@ -119,8 +89,7 @@ def send_email_test_task(email):
send_email_with_context(
recipients=email,
subject="Testovací email",
template_name="email/test.txt",
html_template_name="email/test.html",
template_path="email/test.html",
context=context,
)
@@ -138,7 +107,7 @@ def send_password_reset_email_task(user_id):
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
context = {
"user": _build_user_template_ctx(user),
"user": user,
"action_url": reset_url,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Obnovit heslo",
@@ -147,7 +116,6 @@ def send_password_reset_email_task(user_id):
send_email_with_context(
recipients=user.email,
subject="Obnova hesla",
template_name="email/password_reset.txt",
html_template_name="email/password_reset.html",
template_path="email/password_reset.html",
context=context,
)

View File

@@ -1,46 +1,21 @@
<!doctype html>
<html lang="cs">
<body style="margin:0; padding:0; background-color:#f5f7fb;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<tr>
<td align="center" style="padding:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<tr>
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
Ověření emailu
</td>
</tr>
<tr>
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
{% endwith %}
<p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou emailovou adresu kliknutím na tlačítko níže.</p>
<h1 style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px; margin:0;">Ověření emailu</h1>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
<div style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
{% endwith %}
<p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou emailovou adresu kliknutím na tlačítko níže.</p>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</div>

View File

@@ -1,7 +0,0 @@
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
Děkujeme za registraci. Prosíme, ověřte svou emailovou adresu kliknutím na následující odkaz:
{{ action_url }}
Pokud jste účet nevytvořili vy, tento email ignorujte.

View File

@@ -1,46 +1,21 @@
<!doctype html>
<html lang="cs">
<body style="margin:0; padding:0; background-color:#f5f7fb;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<tr>
<td align="center" style="padding:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<tr>
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
Obnova hesla
</td>
</tr>
<tr>
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
{% endwith %}
<p style="margin:0 0 12px 0;">Obdrželi jste tento email, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento email ignorujte.</p>
<h1 style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px; margin:0;">Obnova hesla</h1>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
<div style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
{% endwith %}
<p style="margin:0 0 12px 0;">Obdrželi jste tento email, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento email ignorujte.</p>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</div>

View File

@@ -1,7 +0,0 @@
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
Obdrželi jste tento email, protože byla požádána obnova hesla k vašemu účtu.
Pokud jste o změnu nepožádali, tento email ignorujte.
Pro nastavení nového hesla použijte tento odkaz:
{{ action_url }}

View File

@@ -1,44 +1,19 @@
<!doctype html>
<html lang="cs">
<body style="margin:0; padding:0; background-color:#f5f7fb;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<tr>
<td align="center" style="padding:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<tr>
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
Testovací email
</td>
</tr>
<tr>
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
<p style="margin:0 0 12px 0;">Dobrý den,</p>
<p style="margin:0 0 16px 0;">Toto je testovací email z aplikace etržnice.</p>
<h1 style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px; margin:0;">Testovací email</h1>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
<div style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
<p style="margin:0 0 12px 0;">Dobrý den,</p>
<p style="margin:0 0 16px 0;">Toto je testovací email z aplikace etržnice.</p>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</div>

View File

@@ -1,6 +0,0 @@
Dobrý den,
Toto je testovací email z aplikace etržnice.
Odkaz na aplikaci:
{{ action_url }}

View File

@@ -1,3 +1,14 @@
from django.db import models
# Create your models here.
class ContactMe(models.Model):
client_email = models.EmailField()
content = models.TextField()
sent_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Email to {self.client_email} sent at {self.sent_at}"

View File

@@ -0,0 +1,9 @@
from rest_framework import serializers
from .models import ContactMe
class ContactMeSerializer(serializers.ModelSerializer):
class Meta:
model = ContactMe
fields = ["id", "client_email", "content", "sent_at"]
read_only_fields = ["id", "sent_at"]

View File

@@ -1,2 +1,17 @@
#udělat zasílaní reklamních emailů uživatelům.
#newletter --> když se vytvoří nový record s reklamou email se uloží pomocí zaškrtnutí tlačítka v záznamu
from account.tasks import send_email_with_context
from configuration.models import SiteConfiguration
from celery import shared_task
@shared_task
def send_contact_me_email_task(client_email, message_content):
context = {
"client_email": client_email,
"message_content": message_content
}
send_email_with_context(
recipients=SiteConfiguration.get_solo().contact_email,
subject="Poptávka z kontaktního formuláře!!!",
template_path="email/contact_me.html",
context=context,
)

View File

@@ -0,0 +1,6 @@
<h2 style="margin:0 0 12px 0; font-family:Arial, Helvetica, sans-serif;">Nová zpráva z kontaktního formuláře</h2>
<div style="border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; font-family:Arial, Helvetica, sans-serif;">
<p><span style="font-weight:600;">Email odesílatele:</span> {{ client_email }}</p>
<p style="font-weight:600;">Zpráva:</p>
<pre style="white-space: pre-wrap; word-wrap: break-word;">{{ message_content }}</pre>
</div>

View File

@@ -0,0 +1,15 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ContactMePublicView, ContactMeAdminViewSet
router = DefaultRouter()
router.register(r"contact-messages", ContactMeAdminViewSet, basename="contactme")
urlpatterns = [
# Public endpoint
path("contact-me/", ContactMePublicView.as_view(), name="contact-me"),
# Admin endpoints
path("", include(router.urls)),
]

View File

@@ -1,3 +1,46 @@
from django.shortcuts import render
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, viewsets
from rest_framework.permissions import AllowAny, IsAdminUser
from rest_framework.authentication import SessionAuthentication
from .models import ContactMe
from .serializer import ContactMeSerializer
from .tasks import send_contact_me_email_task
class ContactMePublicView(APIView):
permission_classes = [AllowAny]
# Avoid CSRF for public endpoint by disabling SessionAuthentication
authentication_classes = []
def post(self, request):
email = request.data.get("email")
message = request.data.get("message")
honeypot = request.data.get("hp") # hidden honeypot field
# If honeypot is filled, pretend success without processing
if honeypot:
return Response({"status": "ok"}, status=status.HTTP_200_OK)
if not email or not message:
return Response({"detail": "Missing email or message."}, status=status.HTTP_400_BAD_REQUEST)
# Save to DB
cm = ContactMe.objects.create(client_email=email, content=message)
# Send email via Celery task
try:
send_contact_me_email_task.delay(email, message)
except Exception:
# Fallback to direct call if Celery is not running in DEV
send_contact_me_email_task(email, message)
return Response({"id": cm.id, "status": "queued"}, status=status.HTTP_201_CREATED)
class ContactMeAdminViewSet(viewsets.ModelViewSet):
queryset = ContactMe.objects.all().order_by("-sent_at")
serializer_class = ContactMeSerializer
permission_classes = [IsAdminUser]
# Create your views here.

View File

@@ -10,7 +10,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from weasyprint import HTML
import os
from configuration.models import ShopConfiguration
from configuration.models import SiteConfiguration
from thirdparty.zasilkovna.models import ZasilkovnaPacket
from thirdparty.stripe.models import StripeModel
@@ -240,7 +240,7 @@ class Carrier(models.Model):
def get_price(self):
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
return ShopConfiguration.get_solo().zasilkovna_shipping_price
return SiteConfiguration.get_solo().zasilkovna_shipping_price
else:
return Decimal('0.0')
@@ -285,10 +285,10 @@ class Carrier(models.Model):
class Payment(models.Model):
class PAYMENT(models.TextChoices):
SHOP = "shop", "cz#Platba v obchodě"
Site = "Site", "cz#Platba v obchodě"
STRIPE = "stripe", "cz#Bankovní převod"
CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka"
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP)
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.Site)
#FIXME: potvrdit že logika platby funguje správně
#veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.)
@@ -360,7 +360,7 @@ class OrderItem(models.Model):
def get_total_price(self, discounts: list[DiscountCode] = None):
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
Logika dle ShopConfiguration:
Logika dle SiteConfiguration:
- multiplying_coupons=True: procentuální slevy se násobí (sekvenčně)
P * (1 - p1) -> výsledné * (1 - p2) ...
jinak se použije pouze nejlepší (nejvyšší procento).
@@ -375,7 +375,7 @@ class OrderItem(models.Model):
return base_price
config = ShopConfiguration.get_solo()
config = SiteConfiguration.get_solo()
#seznám slev
applicable_percent_discounts: list[int] = []

View File

@@ -6,14 +6,13 @@ class ConfigurationConfig(AppConfig):
name = 'configuration'
def ready(self):
"""Ensure the ShopConfiguration singleton exists at startup.
"""Ensure the SiteConfiguration singleton exists at startup.
Wrapped in broad DB error handling so that commands like
makemigrations/migrate don't fail when the table does not yet exist.
"""
try:
from .models import ShopConfiguration # local import to avoid premature app registry access
ShopConfiguration.get_solo() # creates if missing
from .models import SiteConfiguration # local import to avoid premature app registry access
SiteConfiguration.get_solo() # creates if missing
except (OperationalError, ProgrammingError):
# DB not ready (e.g., before initial migrate); ignore silently

View File

@@ -2,7 +2,7 @@ from django.db import models
# Create your models here.
class ShopConfiguration(models.Model):
class SiteConfiguration(models.Model):
name = models.CharField(max_length=100, default="Shop name", unique=True)
logo = models.ImageField(upload_to='shop_logos/', blank=True, null=True)

View File

@@ -1,10 +1,10 @@
from rest_framework import serializers
from .models import ShopConfiguration
from .models import SiteConfiguration
class ShopConfigurationAdminSerializer(serializers.ModelSerializer):
class SiteConfigurationAdminSerializer(serializers.ModelSerializer):
class Meta:
model = ShopConfiguration
model = SiteConfiguration
fields = [
"id",
"name",
@@ -29,9 +29,9 @@ class ShopConfigurationAdminSerializer(serializers.ModelSerializer):
]
class ShopConfigurationPublicSerializer(serializers.ModelSerializer):
class SiteConfigurationPublicSerializer(serializers.ModelSerializer):
class Meta:
model = ShopConfiguration
model = SiteConfiguration
# Expose only non-sensitive fields
fields = [
"id",

View File

@@ -1,8 +1,7 @@
from rest_framework.routers import DefaultRouter
from .views import ShopConfigurationAdminViewSet, ShopConfigurationPublicViewSet
from .views import SiteConfigurationAdminViewSet, SiteConfigurationPublicViewSet
router = DefaultRouter()
router.register(r"admin/shop-configuration", ShopConfigurationAdminViewSet, basename="shop-config-admin")
router.register(r"public/shop-configuration", ShopConfigurationPublicViewSet, basename="shop-config-public")
router.register(r"admin/shop-configuration", SiteConfigurationAdminViewSet, basename="shop-config-admin")
router.register(r"public/shop-configuration", SiteConfigurationPublicViewSet, basename="shop-config-public")
urlpatterns = router.urls

View File

@@ -1,25 +1,25 @@
from rest_framework import viewsets, mixins
from rest_framework.permissions import IsAdminUser, AllowAny
from .models import ShopConfiguration
from .models import SiteConfiguration
from .serializers import (
ShopConfigurationAdminSerializer,
ShopConfigurationPublicSerializer,
SiteConfigurationAdminSerializer,
SiteConfigurationPublicSerializer,
)
class _SingletonQuerysetMixin:
def get_queryset(self):
return ShopConfiguration.objects.filter(pk=1)
return SiteConfiguration.objects.filter(pk=1)
def get_object(self):
return ShopConfiguration.get_solo()
return SiteConfiguration.get_solo()
class ShopConfigurationAdminViewSet(_SingletonQuerysetMixin, viewsets.ModelViewSet):
class SiteConfigurationAdminViewSet(_SingletonQuerysetMixin, viewsets.ModelViewSet):
permission_classes = [IsAdminUser]
serializer_class = ShopConfigurationAdminSerializer
serializer_class = SiteConfigurationAdminSerializer
class ShopConfigurationPublicViewSet(_SingletonQuerysetMixin, viewsets.ReadOnlyModelViewSet):
class SiteConfigurationPublicViewSet(_SingletonQuerysetMixin, viewsets.ReadOnlyModelViewSet):
permission_classes = [AllowAny]
serializer_class = ShopConfigurationPublicSerializer
serializer_class = SiteConfigurationPublicSerializer

View File

@@ -26,7 +26,7 @@ from rest_framework.exceptions import ValidationError
from .client import PacketaAPI
from configuration.models import ShopConfiguration
from configuration.models import SiteConfiguration
packeta_client = PacketaAPI() # single reusable instance
@@ -103,8 +103,8 @@ class ZasilkovnaPacket(models.Model):
cod=order.total_price if cash_on_delivery else 0, # dobírka
value=order.total_price,
currency=ShopConfiguration.get_solo().currency, #CZK
eshop= ShopConfiguration.get_solo().name,
currency=SiteConfiguration.get_solo().currency, #CZK
eSite= SiteConfiguration.get_solo().name,
)
self.packet_id = response['packet_id']
self.barcode = response['barcode']

View File

@@ -5,7 +5,7 @@ from django.template import loader
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse, OpenApiParameter, OpenApiTypes
from backend.configuration.models import ShopConfiguration
from backend.configuration.models import SiteConfiguration
from .models import ZasilkovnaShipment, ZasilkovnaPacket
from .serializers import (
@@ -135,7 +135,7 @@ class ZasilkovnaPacketViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet
widget_html = loader.render_to_string(
"zasilkovna/pickup_point_widget.html",
{
"api_key": ShopConfiguration.get_solo().zasilkovna_widget_api_key,
"api_key": SiteConfiguration.get_solo().zasilkovna_widget_api_key,
}
)

View File

@@ -37,7 +37,7 @@ urlpatterns = [
path('api/account/', include('account.urls')),
path('api/commerce/', include('commerce.urls')),
path('api/configuration/', include('configuration.urls')),
#path('api/advertisments/', include('advertisements.urls')),
path('api/advertisement/', include('advertisement.urls')),
path('api/stripe/', include('thirdparty.stripe.urls')),
path('api/trading212/', include('thirdparty.trading212.urls')),

View File

@@ -7,7 +7,6 @@ import PrivateRoute from "./routes/PrivateRoute";
// Pages
import PortfolioPage from "./pages/portfolio/PortfolioPage";
import HostingSecurityPage from "./pages/hosting/HostingSecurityPage";
import ContactPage from "./pages/contact/ContactPage";
import ScrollToTop from "./components/common/ScrollToTop";
@@ -21,7 +20,6 @@ export default function App() {
<Route path="/" element={<HomeLayout />}>
<Route index element={<Home />} />
<Route path="portfolio" element={<PortfolioPage />} />
<Route path="hosting-security" element={<HostingSecurityPage />} />
<Route path="contact" element={<ContactPage />} />
{/* Utilities */}

View File

@@ -73,6 +73,7 @@ export default function ContactMeForm() {
placeholder="Vaše zpráva"
required
/>
<input type="hidden" name="state" />
<input type="submit"/>
</form>
</div>

View File

@@ -1,8 +0,0 @@
import { useEffect, useState } from "react";
export default function HeroCarousel() {
return (
<>
</>
);
}

View File

@@ -1,21 +0,0 @@
export default function HostingSecuritySection() {
return (
<section id="hosting" className="py-20">
<div className="container mx-auto px-4">
<h2 className="text-3xl md:text-4xl font-bold mb-6 text-rainbow">Hosting & Protection</h2>
<div className="card p-6 md:p-8">
<p className="text-brand-text opacity-80">We host our applications ourselves, which reduces hosting costs as projects scale.</p>
<p className="text-brand-text opacity-80 mt-2">All websites are protected by Cloudflare and optimized for performance.</p>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4 text-center text-sm mt-6">
{['Server', 'Cloudflare', 'Docker', 'SSL', 'Monitoring', 'Scaling'].map(item => (
<div key={item} className="p-3 rounded-lg bg-brandGradient text-white shadow-glow transition-transform duration-200 hover:scale-105 flex flex-col items-center justify-center">
<span className="text-xs tracking-wide">{item}</span>
</div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@@ -113,7 +113,6 @@ export default function Navbar({ user, onLogin, onLogout }: NavbarProps) {
</div>
<a className={styles.linkSimple} href="#contacts"><FaGlobe className={styles.iconSmall}/> Kontakt</a>
<a className={styles.linkSimple} href="/projects"><FaProjectDiagram className={styles.iconSmall}/> Projekty</a>
{/* right: user area */}
{!user ? (
@@ -136,9 +135,9 @@ export default function Navbar({ user, onLogin, onLogout }: NavbarProps) {
</button>
<div className={styles.dropdown} role="menu" aria-label="Uživatelské menu">
<a href="/profile" role="menuitem">Profil</a>
<a href="/billing" role="menuitem">Nastavení</a>
<a href="/billing" role="menuitem">Platby</a>
<a href="/me/profile" role="menuitem">Profil</a>
<a href="/me/settings" role="menuitem">Nastavení</a>
<a href="/me/billing" role="menuitem">Platby</a>
<button className={styles.logoutBtn} onClick={onLogout} role="menuitem">
<FaSignOutAlt className={styles.iconSmall} /> Odhlásit se

View File

@@ -1,5 +1,6 @@
.navbar {
width: 80%;
width: 50%;
width: max-content;
margin: auto;
padding: 0 2em;
background-color: var(--c-boxes);
@@ -11,7 +12,7 @@
position: sticky;
top: 0;
z-index: 50;
gap: 1rem;
gap: 1em;
border-bottom-left-radius: 2em;
border-bottom-right-radius: 2em;
@@ -56,7 +57,7 @@
/* Links container */
.links {
display: flex;
gap: 1.6rem;
gap: 3em;
align-items: center;
justify-content: space-around;
width: -webkit-fill-available;

View File

@@ -0,0 +1,115 @@
import { FaLaptopCode, FaVideo, FaCog } from "react-icons/fa";
import { MdLaunch, MdFolder, MdPhone } from "react-icons/md";
import styles from "./services.module.css";
type ServiceItem = {
title: string;
subtitle: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
demoLink?: string;
portfolioLink?: string;
contactLink?: string;
align: "left" | "right";
};
const services: ServiceItem[] = [
{
title: "Weby",
subtitle: "Od prezentačních až po komplexní",
description:
"Tvorba webových stránek všech velikostí - od jednoduchých prezentačních po složité aplikace s databází, e-shopy a portály.",
icon: FaLaptopCode,
demoLink: "/demo/websites",
portfolioLink: "/portfolio#websites",
contactLink: "/contact?service=web",
align: "left",
},
{
title: "Filmařina",
subtitle: "Natáčení, drony, střih",
description:
"Natáčení na místě, drony, reels/TikTok videa a samozřejmě střih. Kompletní video produkce od námětu po finální video.",
icon: FaVideo,
portfolioLink: "/portfolio#videos",
contactLink: "/contact?service=video",
align: "right",
},
{
title: "Servis Dronů",
subtitle: "Opravy a údržba",
description:
"Profesionální servis a údržba dronů. Diagnostika, opravy, kalibrace a poradenství pro bezpečný provoz.",
icon: FaCog,
contactLink: "/contact?service=drone",
align: "left",
},
];export default function Services() {
return (
<section className={`section ${styles.wrapper}`} aria-labelledby="sluzby-heading">
<div className="container">
<header className={styles.header}>
<h2 id="sluzby-heading" className="text-3xl md:text-4xl font-bold tracking-tight">
Služby, které nabízím
</h2>
<p className="mt-3 text-neutral-300 max-w-2xl">
Od návrhu po produkční nasazení. Stavím udržitelné systémy s důrazem na
výkon, bezpečnost a spolehlivost. Základem jsou jasné procesy, čistý kód a
měřitelné výsledky.
</p>
</header>
<div className={styles.servicesStack}>
{services.map((service, idx) => {
const IconComponent = service.icon;
return (
<article
key={service.title}
className={`${styles.card} ${styles[service.align]} ${styles[`card${(idx % 3) + 1}`]}`}
>
<div className={styles.cardBg} />
<div className={styles.cardInner}>
<div className={styles.cardHeader}>
<div className={styles.iconWrapper}>
<IconComponent className={styles.icon} />
</div>
<h3 className="text-xl md:text-2xl font-semibold">{service.title}</h3>
<span className="text-sm md:text-base opacity-80">{service.subtitle}</span>
</div>
<p className="mt-3 text-sm md:text-base text-neutral-300">{service.description}</p>
<div className={styles.actionButtons}>
{service.demoLink && (
<a href={service.demoLink} className={styles.button}>
<MdLaunch className={styles.buttonIcon} />
Demo
</a>
)}
{service.portfolioLink && (
<a href={service.portfolioLink} className={styles.button}>
<MdFolder className={styles.buttonIcon} />
Portfolio
</a>
)}
{service.contactLink && (
<a href={service.contactLink} className={styles.buttonPrimary}>
<MdPhone className={styles.buttonIcon} />
Kontakt
</a>
)}
</div>
</div>
</article>
);
})}
</div> <footer className={styles.cta}>
<a href="/contact" className="glass px-5 py-3 rounded-lg font-medium">
Pojďme to probrat
</a>
</footer>
</div>
</section>
);
}

View File

@@ -0,0 +1,93 @@
/* Services section styles with clip-path animations */
.wrapper {
position: relative;
padding-top: 4rem;
padding-bottom: 4rem;
}
.header {
display: grid;
gap: 0.75rem;
margin-bottom: 3rem;
text-align: center;
}
.servicesStack {
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 800px;
margin: 0 auto;
}.card {
position: relative;
isolation: isolate;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: saturate(140%) blur(8px);
}
.cardBg {
position: absolute;
inset: 0;
z-index: -1;
background: radial-gradient(1200px 400px at 10% 10%, rgba(99, 102, 241, 0.25), transparent 60%),
radial-gradient(900px 300px at 90% 90%, rgba(16, 185, 129, 0.25), transparent 60%);
clip-path: polygon(0% 0%, 60% 0%, 100% 40%, 100% 100%, 0% 100%);
animation: floatClip 10s ease-in-out infinite;
}
.cardInner {
padding: 1rem;
}
.cardHeader {
display: grid;
gap: 0.25rem;
}
.icon {
font-size: 2rem;
line-height: 1;
margin-bottom: 0.5rem;
}/* Removed tags styles as not needed for simplified version */
/* Variant hues per card for subtle differentiation */
.card1 .cardBg {
background: radial-gradient(1200px 400px at 10% 10%, rgba(99, 102, 241, 0.35), transparent 60%),
radial-gradient(900px 300px at 90% 90%, rgba(56, 189, 248, 0.25), transparent 60%);
}
.card2 .cardBg {
background: radial-gradient(1200px 400px at 10% 10%, rgba(16, 185, 129, 0.35), transparent 60%),
radial-gradient(900px 300px at 90% 90%, rgba(245, 158, 11, 0.25), transparent 60%);
}
.card3 .cardBg {
background: radial-gradient(1200px 400px at 10% 10%, rgba(236, 72, 153, 0.35), transparent 60%),
radial-gradient(900px 300px at 90% 90%, rgba(59, 130, 246, 0.25), transparent 60%);
}
/* Removed card4 as we only have 3 services now */@keyframes floatClip {
0% {
clip-path: polygon(0% 0%, 60% 0%, 100% 40%, 100% 100%, 0% 100%);
transform: translate3d(0, 0, 0);
}
50% {
clip-path: polygon(0% 0%, 55% 0%, 100% 35%, 100% 100%, 0% 100%);
transform: translate3d(0, -6px, 0);
}
100% {
clip-path: polygon(0% 0%, 60% 0%, 100% 40%, 100% 100%, 0% 100%);
transform: translate3d(0, 0, 0);
}
}
.cta {
display: flex;
justify-content: center;
margin-top: 2rem;
}

View File

@@ -1,35 +0,0 @@
const categories: { name: string; items: string[] }[] = [
{ name: 'Experience', items: ['Freelance projects', 'Collaborations', 'Open-source contributions'] },
{ name: 'Backend', items: ['Django', 'Python', 'REST API', 'PostgreSQL', 'Celery', 'Docker'] },
{ name: 'Frontend', items: ['React', 'Tailwind', 'Vite', 'TypeScript', 'ShadCN', 'Bootstrap'] },
{ name: 'DevOps / Hosting', items: ['Nginx', 'Docker Compose', 'SSL', 'Cloudflare', 'Self-hosting'] },
{ name: 'Other Tools', items: ['Git', 'VSCode', 'WebRTC', 'ESP32', 'Automation'] },
];
export default function SkillsSection() {
return (
<section id="skills" className="py-20 bg-gradient-to-b from-brand-bg to-brand-bgLight">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold mb-4 text-rainbow">Skills</h2>
<p className="text-brand-text/80 max-w-2xl mx-auto">Core technologies and tools I use across backend, frontend, infrastructure and hardware.</p>
</div>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{categories.map(cat => (
<div key={cat.name} className="card p-6">
<h3 className="font-semibold mb-4 text-rainbow tracking-wide">{cat.name}</h3>
<ul className="space-y-2 text-sm">
{cat.items.map(i => (
<li key={i} className="flex items-center gap-2 text-brand-text/80">
<span className="inline-block w-2 h-2 rounded-full bg-brand-accent" />
<span className="group-hover:text-brand-text transition-colors">{i}</span>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -1,10 +1,9 @@
import { useEffect } from "react";
import HeroCarousel from "../../components/hero/HeroCarousel";
import PortfolioGrid from "../../components/portfolio/PortfolioGrid";
import TradingGraph from "../../components/trading/TradingGraph";
import DonationShop from "../../components/donate/DonationShop";
import SkillsSection from "../../components/skills/SkillsSection";
import HostingSecuritySection from "../../components/hosting/HostingSecuritySection";
import ContactMeForm from "../../components/Forms/ContactMe/ContactMeForm";
import Services from "../../components/services/Services";
export default function Home() {
// Optional: keep spark effect for fun
@@ -33,7 +32,7 @@ export default function Home() {
return (
<main>
<HeroCarousel />
<Services />
<div className="divider" />
<PortfolioGrid />
<div className="divider" />
@@ -41,9 +40,7 @@ export default function Home() {
<div className="divider" />
<DonationShop />
<div className="divider" />
<SkillsSection />
<div className="divider" />
<HostingSecuritySection />
<ContactMeForm />
</main>
);
}

View File

@@ -1,4 +0,0 @@
import HostingSecuritySection from "../../components/hosting/HostingSecuritySection";
export default function HostingSecurityPage(){
return <HostingSecuritySection />;
}