feat: implement user authentication features (registration, activation, and password reset)
All checks were successful
Build and Push Docker Image / build (push) Successful in 46s
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:
88
users/forms.py
Normal file
88
users/forms.py
Normal 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
33
users/services.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user