feat: implement user authentication features (registration, activation, and password reset)
All checks were successful
Build and Push Docker Image / build (push) Successful in 46s

- Added email templates for activation and password reset (`activation_email.html`, `password_reset_email.html`, etc.)
- Created forms for registration (`RegisterForm`) and login (`LoginForm`) with custom validation
- Developed new pages for account activation (`activation_success.html`, `activation_invalid.html`), password reset workflow, and profile
- Configured email backend and environment file handling in settings
- Updated header template to conditionally display profile or login links based on authentication status
- Enhanced CSS styles for authentication-related pages and forms
This commit is contained in:
2026-04-15 18:27:23 +02:00
parent 921cea9044
commit 80164b833b
25 changed files with 1198 additions and 59 deletions

88
users/forms.py Normal file
View File

@@ -0,0 +1,88 @@
from django import forms
from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm, UserCreationForm
from django.core.exceptions import ValidationError
User = get_user_model()
class LoginForm(forms.Form):
email = forms.EmailField(label="Электронная почта")
password = forms.CharField(label="Пароль", strip=False, widget=forms.PasswordInput)
error_messages = {
"invalid_login": "Неверная электронная почта или пароль.",
"inactive": "Аккаунт ещё не подтверждён. Проверьте письмо на почте.",
}
def __init__(self, request=None, *args, **kwargs):
self.request = request
self.user_cache = None
super().__init__(*args, **kwargs)
def clean_email(self):
return self.cleaned_data["email"].strip().lower()
def clean(self):
cleaned_data = super().clean()
email = cleaned_data.get("email")
password = cleaned_data.get("password")
if not email or not password:
return cleaned_data
self.user_cache = authenticate(self.request, username=email, password=password)
if self.user_cache is not None:
return cleaned_data
user = User.objects.filter(username=email).first()
if user and not user.is_active and user.check_password(password):
raise ValidationError(self.error_messages["inactive"])
raise ValidationError(self.error_messages["invalid_login"])
def get_user(self):
return self.user_cache
class RegisterForm(UserCreationForm):
full_name = forms.CharField(label="Имя и фамилия", max_length=150)
email = forms.EmailField(label="Электронная почта")
class Meta(UserCreationForm.Meta):
model = User
fields = ("full_name", "email", "password1", "password2")
def clean_email(self):
email = self.cleaned_data["email"].strip().lower()
if User.objects.filter(email__iexact=email).exists():
raise ValidationError("Пользователь с такой электронной почтой уже существует.")
return email
def save(self, commit=True):
user = super().save(commit=False)
email = self.cleaned_data["email"]
full_name = self.cleaned_data["full_name"].strip()
parts = full_name.split(maxsplit=1)
user.username = email
user.email = email
user.first_name = parts[0] if parts else ""
user.last_name = parts[1] if len(parts) > 1 else ""
user.is_active = False
if commit:
user.save()
return user
class SantehRayPasswordResetForm(PasswordResetForm):
email = forms.EmailField(label="Электронная почта")
def clean_email(self):
return self.cleaned_data["email"].strip().lower()
class SantehRaySetPasswordForm(SetPasswordForm):
pass

33
users/services.py Normal file
View File

@@ -0,0 +1,33 @@
from django.conf import settings
from django.contrib.auth.tokens import default_token_generator
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
def send_activation_email(request, user):
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = default_token_generator.make_token(user)
activation_path = reverse("users:activate", kwargs={"uidb64": uid, "token": token})
activation_url = request.build_absolute_uri(activation_path)
context = {
"user": user,
"activation_url": activation_url,
"site_name": getattr(settings, "SITE_NAME", "SantehRay"),
}
subject = render_to_string("users/emails/activation_subject.txt", context).strip()
text_body = render_to_string("users/emails/activation_email.txt", context)
html_body = render_to_string("users/emails/activation_email.html", context)
email = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[user.email],
)
email.attach_alternative(html_body, "text/html")
email.send()

View File

@@ -1,3 +1,81 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.contrib.auth.tokens import default_token_generator
from django.core import mail
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
# Create your tests here.
User = get_user_model()
@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
class UserAuthFlowTests(TestCase):
def test_registration_creates_inactive_user_and_sends_activation_email(self):
response = self.client.post(
reverse("users:register"),
data={
"full_name": "Иван Петров",
"email": "ivan@example.com",
"password1": "StrongPassword123",
"password2": "StrongPassword123",
},
)
self.assertEqual(response.status_code, 200)
user = User.objects.get(email="ivan@example.com")
self.assertFalse(user.is_active)
self.assertEqual(user.username, "ivan@example.com")
self.assertEqual(len(mail.outbox), 1)
self.assertIn("/users/activate/", mail.outbox[0].body)
def test_activation_link_activates_user(self):
user = User.objects.create_user(
username="anna@example.com",
email="anna@example.com",
password="StrongPassword123",
is_active=False,
)
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = default_token_generator.make_token(user)
response = self.client.get(reverse("users:activate", kwargs={"uidb64": uid, "token": token}))
self.assertEqual(response.status_code, 200)
user.refresh_from_db()
self.assertTrue(user.is_active)
self.assertEqual(int(self.client.session["_auth_user_id"]), user.pk)
def test_login_with_email_works_for_active_user(self):
user = User.objects.create_user(
username="oleg@example.com",
email="oleg@example.com",
password="StrongPassword123",
is_active=True,
)
response = self.client.post(
reverse("users:login"),
data={"email": "oleg@example.com", "password": "StrongPassword123"},
)
self.assertRedirects(response, reverse("users:profile"))
self.assertEqual(int(self.client.session["_auth_user_id"]), user.pk)
def test_password_reset_sends_email_for_existing_user(self):
User.objects.create_user(
username="maria@example.com",
email="maria@example.com",
password="StrongPassword123",
is_active=True,
)
response = self.client.post(
reverse("users:password_reset"),
data={"email": "maria@example.com"},
)
self.assertRedirects(response, reverse("users:password_reset_done"))
self.assertEqual(len(mail.outbox), 1)
self.assertIn("/users/reset/", mail.outbox[0].body)

View File

@@ -2,8 +2,16 @@ from django.urls import path
from users import views
app_name = "users"
urlpatterns = [
path('login/', views.login, name='login'),
path('register/', views.register, name='register'),
path('logout/', views.logout, name='logout'),
path('activate/<uidb64>/<token>/', views.activate, name='activate'),
path('password-reset/', views.SantehRayPasswordResetView.as_view(), name='password_reset'),
path('password-reset/done/', views.SantehRayPasswordResetDoneView.as_view(), name='password_reset_done'),
path('reset/<uidb64>/<token>/', views.SantehRayPasswordResetConfirmView.as_view(), name='password_reset_confirm'),
path('reset/done/', views.SantehRayPasswordResetCompleteView.as_view(), name='password_reset_complete'),
path('profile/', views.profile, name='profile')
]

View File

@@ -1,14 +1,98 @@
from django.shortcuts import render
from django.contrib.auth import get_user_model, login as auth_login, logout as auth_logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.tokens import default_token_generator
from django.contrib.auth.views import (
PasswordResetCompleteView,
PasswordResetConfirmView,
PasswordResetDoneView,
PasswordResetView,
)
from django.shortcuts import redirect, render
from django.urls import reverse_lazy
from django.utils.encoding import force_str
from django.utils.http import urlsafe_base64_decode
from django.views.decorators.http import require_http_methods
from users.forms import LoginForm, RegisterForm, SantehRayPasswordResetForm, SantehRaySetPasswordForm
from users.services import send_activation_email
User = get_user_model()
# Create your views here.
@require_http_methods(["GET", "POST"])
def login(request):
return render(request, 'users/login.html', context={})
if request.user.is_authenticated:
return redirect("users:profile")
form = LoginForm(request=request, data=request.POST or None)
if request.method == "POST" and form.is_valid():
auth_login(request, form.get_user())
return redirect("users:profile")
return render(request, "users/login.html", context={"form": form})
@require_http_methods(["GET", "POST"])
def register(request):
return render(request, 'users/register.html', context={})
if request.user.is_authenticated:
return redirect("users:profile")
form = RegisterForm(request.POST or None)
if request.method == "POST" and form.is_valid():
user = form.save()
send_activation_email(request, user)
return render(request, "users/register_done.html", context={"email": user.email})
return render(request, "users/register.html", context={"form": form})
@require_http_methods(["POST"])
def logout(request):
auth_logout(request)
return redirect("users:login")
def activate(request, uidb64, token):
try:
uid = force_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(pk=uid)
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
user = None
if user and default_token_generator.check_token(user, token):
user.is_active = True
user.save(update_fields=["is_active"])
auth_login(request, user)
return render(request, "users/activation_success.html", context={"user": user})
return render(request, "users/activation_invalid.html", status=400)
@login_required
def profile(request):
return render(request, 'users/profile.html', context={})
return render(request, "users/profile.html", context={})
class SantehRayPasswordResetView(PasswordResetView):
form_class = SantehRayPasswordResetForm
template_name = "users/password_reset.html"
email_template_name = "users/emails/password_reset_email.txt"
html_email_template_name = "users/emails/password_reset_email.html"
subject_template_name = "users/emails/password_reset_subject.txt"
success_url = reverse_lazy("users:password_reset_done")
class SantehRayPasswordResetDoneView(PasswordResetDoneView):
template_name = "users/password_reset_done.html"
class SantehRayPasswordResetConfirmView(PasswordResetConfirmView):
template_name = "users/password_reset_confirm.html"
success_url = reverse_lazy("users:password_reset_complete")
form_class = SantehRaySetPasswordForm
class SantehRayPasswordResetCompleteView(PasswordResetCompleteView):
template_name = "users/password_reset_complete.html"