done last commit before merging - fixed media URLSs S3

This commit is contained in:
2026-06-12 00:56:01 +02:00
parent f4c4a8bfd1
commit 44e77e7744
19 changed files with 1478 additions and 193 deletions

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0003_customuser_banner'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='pending_email',
field=models.EmailField(blank=True, null=True, max_length=254),
),
]

View File

@@ -58,6 +58,7 @@ class CustomUser(SoftDeleteModel, AbstractUser):
email_verified = models.BooleanField(default=False)
email = models.EmailField(unique=True, db_index=True)
pending_email = models.EmailField(null=True, blank=True)
# + fields for email verification flow
email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True)
@@ -116,7 +117,7 @@ class CustomUser(SoftDeleteModel, AbstractUser):
return super().delete(*args, **kwargs)
_NULLABLE_CHAR_FIELDS = ('phone_number', 'city', 'street', 'country', 'postal_code', 'email_verification_token')
_NULLABLE_CHAR_FIELDS = ('phone_number', 'city', 'street', 'country', 'postal_code', 'email_verification_token', 'pending_email')
def save(self, *args, **kwargs):
for field in self._NULLABLE_CHAR_FIELDS:

View File

@@ -217,6 +217,31 @@ class PasswordResetConfirmSerializer(serializers.Serializer):
return value
class ChangeEmailSerializer(serializers.Serializer):
current_password = serializers.CharField(write_only=True)
new_email = serializers.EmailField()
turnstile_token = serializers.CharField(write_only=True, required=False, allow_blank=True)
def validate_new_email(self, value):
value = value.lower()
if User.objects.filter(email__iexact=value).exists():
raise serializers.ValidationError("Tento e-mail je již používán.")
return value
class ChangeUsernameSerializer(serializers.Serializer):
new_username = serializers.CharField(min_length=3, max_length=150)
turnstile_token = serializers.CharField(write_only=True, required=False, allow_blank=True)
def validate_new_username(self, value):
import re
if not re.match(r'^[\w.@+-]+$', value):
raise serializers.ValidationError("Povolena jsou písmena, číslice a znaky @/./+/-/_.")
if User.objects.filter(username__iexact=value).exists():
raise serializers.ValidationError("Toto uživatelské jméno je již obsazeno.")
return value
class ChangePasswordSerializer(serializers.Serializer):
current_password = serializers.CharField(write_only=True)
new_password = serializers.CharField(write_only=True)

View File

@@ -39,6 +39,50 @@ def send_email_verification_task(user_id):
)
@shared_task
def send_email_change_verification_task(user_id, new_email, verify_url):
"""Sends confirmation link to the NEW email address."""
from notifications.tasks import send_email_with_context
try:
user = CustomUser.objects.get(pk=user_id)
except CustomUser.DoesNotExist:
return
send_email_with_context(
recipients=new_email,
subject="Potvrzení změny e-mailu — vontor.cz",
template_path="email/action_confirm.html",
context={
"title": "Potvrďte novou e-mailovou adresu",
"description": f"Obdrželi jsme žádost o změnu e-mailové adresy na účtu <strong>{user.username}</strong>. Klikněte na tlačítko níže pro potvrzení.",
"action_url": verify_url,
"cta_label": "Potvrdit nový e-mail",
"note": "Pokud jste tuto změnu nepožadovali, ignorujte tento e-mail — vaše stávající adresa zůstane aktivní.",
},
)
@shared_task
def send_email_change_notification_task(user_id, old_email, new_email):
"""Notifies the OLD email address that a change was requested."""
from notifications.tasks import send_email_with_context
try:
user = CustomUser.objects.get(pk=user_id)
except CustomUser.DoesNotExist:
return
send_email_with_context(
recipients=old_email,
subject="Žádost o změnu e-mailu — vontor.cz",
template_path="email/action_confirm.html",
context={
"title": "Vaše e-mailová adresa se mění",
"description": f"Na účtu <strong>{user.username}</strong> byla zahájena změna e-mailové adresy na <strong>{new_email}</strong>.",
"action_url": None,
"cta_label": None,
"note": "Pokud jste tuto akci nespustili, okamžitě kontaktujte podporu. Změna vstoupí v platnost až po potvrzení z nové adresy.",
},
)
@shared_task
def send_email_test_task(email):
send_email_with_context(

View File

@@ -16,6 +16,15 @@ urlpatterns = [
# Registration & email endpoints
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
path('verify-email/<uidb64>/<token>/', views.EmailVerificationView.as_view(), name='verify-email'),
path('resend-verification/', views.ResendEmailVerificationView.as_view(), name='resend-email-verification'),
# Account deletion
path('delete/', views.DeleteAccountView.as_view(), name='delete-account'),
# Email & username change
path('change-email/', views.ChangeEmailView.as_view(), name='change-email'),
path('confirm-email-change/<uidb64>/<token>/', views.ConfirmEmailChangeView.as_view(), name='confirm-email-change'),
path('change-username/', views.ChangeUsernameView.as_view(), name='change-username'),
# Password reset endpoints
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),

View File

@@ -6,7 +6,10 @@ from .serializers import *
from .permissions import *
from .models import CustomUser
from .tokens import *
from .tasks import send_password_reset_email_task, send_email_verification_task
from .tasks import (
send_password_reset_email_task, send_email_verification_task,
send_email_change_verification_task, send_email_change_notification_task,
)
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
@@ -431,6 +434,208 @@ class PasswordResetConfirmView(APIView):
user.save()
return Response({"detail": "Heslo bylo úspěšně změněno."})
return Response(serializer.errors, status=400)
@extend_schema(
tags=["account"],
summary="Resend email verification",
description="Resends the verification email to the currently authenticated user. Limited to once per minute.",
responses={
200: OpenApiResponse(description="Verification email sent."),
400: OpenApiResponse(description="Email is already verified."),
429: OpenApiResponse(description="Rate limited — seconds_remaining returned."),
},
)
class ResendEmailVerificationView(APIView):
permission_classes = [IsAuthenticated]
COOLDOWN = 60 # seconds
def post(self, request):
from django.core.cache import cache
import time
user = request.user
if user.email_verified:
return Response({"detail": "E-mail je již ověřen."}, status=status.HTTP_400_BAD_REQUEST)
cache_key = f"resend_verification_{user.id}"
sent_at = cache.get(cache_key)
if sent_at is not None:
remaining = int(self.COOLDOWN - (time.time() - sent_at))
if remaining > 0:
return Response(
{"detail": "Příliš mnoho pokusů.", "seconds_remaining": remaining},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
cache.set(cache_key, time.time(), timeout=self.COOLDOWN)
try:
send_email_verification_task.delay(user.id)
except Exception as e:
logger.error(f"Celery not available, using fallback. Error: {e}")
send_email_verification_task(user.id)
return Response({"detail": "Ověřovací e-mail byl odeslán."})
@extend_schema(
tags=["account"],
summary="Request email address change",
description="Validates current password + Turnstile, stores pending_email, sends confirmation link to the new address and a notification to the old one.",
responses={
200: OpenApiResponse(description="Confirmation email sent to new address."),
400: OpenApiResponse(description="Wrong password, duplicate email, or failed CAPTCHA."),
},
)
class ChangeEmailView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
turnstile_token = request.data.get("turnstile_token", "")
remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", ""))
if not verify_turnstile(turnstile_token, remote_ip):
return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST)
serializer = ChangeEmailSerializer(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)
new_email = serializer.validated_data["new_email"]
old_email = user.email
user.pending_email = new_email
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = user.generate_email_verification_token(save=False)
user.save(update_fields=["pending_email", "email_verification_token", "email_verification_sent_at"])
verify_url = f"{settings.FRONTEND_URL}/account/confirm-email-change/{uid}/{token}/"
try:
send_email_change_verification_task.delay(user.id, new_email, verify_url)
send_email_change_notification_task.delay(user.id, old_email, new_email)
except Exception as e:
logger.error(f"Celery not available, using fallback. Error: {e}")
send_email_change_verification_task(user.id, new_email, verify_url)
send_email_change_notification_task(user.id, old_email, new_email)
return Response({"detail": "Ověřovací e-mail byl odeslán na novou adresu."})
@extend_schema(
tags=["account"],
summary="Confirm email address change via link",
description="Verifies uid + token from the confirmation email, swaps email to pending_email.",
parameters=[
OpenApiParameter(name="uidb64", type=str, location=OpenApiParameter.PATH),
OpenApiParameter(name="token", type=str, location=OpenApiParameter.PATH),
],
responses={
200: OpenApiResponse(description="Email changed successfully."),
400: OpenApiResponse(description="Invalid or expired link."),
},
)
class ConfirmEmailChangeView(APIView):
def get(self, request, uidb64, token):
ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "unknown"))
try:
uid = force_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(pk=uid)
except (User.DoesNotExist, ValueError, TypeError):
return Response({"error": "Neplatný odkaz."}, status=status.HTTP_400_BAD_REQUEST)
if not user.pending_email:
return Response({"error": "Žádná čekající změna e-mailu."}, status=status.HTTP_400_BAD_REQUEST)
if not user.verify_email_token(token):
return Response({"error": "Token je neplatný nebo expirovaný."}, status=status.HTTP_400_BAD_REQUEST)
old_email = user.email
user.email = user.pending_email
user.pending_email = None
user.email_verified = True
user.save(update_fields=["email", "pending_email", "email_verified"])
logger.info("Email changed for user %s: %s%s (IP: %s)", user.pk, old_email, user.email, ip)
return Response({"detail": "E-mail byl úspěšně změněn."})
@extend_schema(
tags=["account"],
summary="Change username",
description="Updates the username. Requires Turnstile. Limited to once per 30 days.",
responses={
200: OpenApiResponse(description="Username changed."),
400: OpenApiResponse(description="Duplicate name, invalid characters, or failed CAPTCHA."),
429: OpenApiResponse(description="Rate limited — days_remaining returned."),
},
)
class ChangeUsernameView(APIView):
permission_classes = [IsAuthenticated]
COOLDOWN_DAYS = 30
def post(self, request):
from django.core.cache import cache
import time
turnstile_token = request.data.get("turnstile_token", "")
remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", ""))
if not verify_turnstile(turnstile_token, remote_ip):
return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST)
serializer = ChangeUsernameSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = request.user
cache_key = f"username_change_{user.id}"
changed_at = cache.get(cache_key)
if changed_at is not None:
remaining_days = int(self.COOLDOWN_DAYS - (time.time() - changed_at) / 86400)
if remaining_days > 0:
return Response(
{"detail": "Příliš brzy.", "days_remaining": remaining_days},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
user.username = serializer.validated_data["new_username"]
user.save(update_fields=["username"])
cache.set(cache_key, time.time(), timeout=self.COOLDOWN_DAYS * 86400)
return Response({"detail": "Uživatelské jméno bylo změněno.", "username": user.username})
@extend_schema(
tags=["account"],
summary="Delete own account",
description="Soft-deletes the authenticated user's account after password confirmation. Clears auth cookies.",
responses={
204: OpenApiResponse(description="Account deleted."),
400: OpenApiResponse(description="Wrong password."),
},
)
class DeleteAccountView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
user = request.user
turnstile_token = request.data.get("turnstile_token", "")
remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", ""))
if not verify_turnstile(turnstile_token, remote_ip):
return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST)
password = request.data.get("current_password", "")
if not password or not user.check_password(password):
return Response({"current_password": "Nesprávné heslo."}, status=status.HTTP_400_BAD_REQUEST)
logger.info("Account deletion requested for user %s (id=%s)", user.username, user.pk)
django_logout(request)
user.delete()
response = Response(status=status.HTTP_204_NO_CONTENT)
response.delete_cookie("access_token", path="/")
response.delete_cookie("refresh_token", path="/")
return response

View File

@@ -110,10 +110,11 @@ def _build_user_template_ctx(user: CustomUser) -> dict:
return {
"id": user.pk,
"email": getattr(user, "email", "") or "",
"username": getattr(user, "username", "") or "",
"first_name": first_name,
"firstname": first_name, # alias for templates using `firstname`
"firstname": first_name,
"last_name": last_name,
"lastname": last_name, # alias for templates using `lastname`
"lastname": last_name,
"full_name": full_name,
"get_full_name": full_name, # compatibility for templates using method-style access
"get_full_name": full_name,
}

View File

@@ -0,0 +1,65 @@
<style>
.ac-wrap {
font-family: Arial, sans-serif;
padding: 20px 0;
color: #333;
}
.ac-title {
font-size: 22px;
font-weight: bold;
margin: 0 0 16px 0;
color: #1a1a2e;
}
.ac-body {
font-size: 15px;
line-height: 1.6;
color: #444;
margin: 0 0 24px 0;
}
.ac-btn {
display: inline-block;
background-color: #2563eb;
color: #ffffff !important;
text-decoration: none;
padding: 12px 28px;
border-radius: 6px;
font-weight: bold;
font-size: 15px;
margin: 0 0 24px 0;
}
.ac-note {
font-size: 13px;
color: #777;
border-top: 1px solid #e5e7eb;
padding-top: 16px;
margin-top: 8px;
line-height: 1.5;
}
.ac-url {
font-family: monospace;
font-size: 11px;
word-break: break-all;
color: #2563eb;
margin-top: 8px;
display: block;
}
</style>
<div class="ac-wrap">
<h1 class="ac-title">{{ title }}</h1>
<p class="ac-body">{{ description|safe }}</p>
{% if action_url and cta_label %}
<a href="{{ action_url }}" class="ac-btn">{{ cta_label }}</a>
<p style="font-size:13px; color:#999; margin: 4px 0 20px 0;">
Pokud tlačítko nefunguje, zkopírujte tento odkaz do prohlížeče:
<span class="ac-url">{{ action_url }}</span>
</p>
{% endif %}
{% if note %}
<p class="ac-note">{{ note }}</p>
{% endif %}
</div>