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

2
.gitignore vendored
View File

@@ -44,3 +44,5 @@ build/
dist/ dist/
*.egg-info/ *.egg-info/
.eggs/ .eggs/
.env*

View File

@@ -10,11 +10,33 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/ https://docs.djangoproject.com/en/5.2/ref/settings/
""" """
import os
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
def load_env_file(path: Path):
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
ENV_FILE = Path(os.getenv("DJANGO_ENV_FILE", BASE_DIR / ".env.test"))
load_env_file(ENV_FILE)
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
@@ -109,7 +131,7 @@ AUTH_PASSWORD_VALIDATORS = [
# https://docs.djangoproject.com/en/5.2/topics/i18n/ # https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'ru-ru' LANGUAGE_CODE = 'ru-ru'
TIME_ZONE = 'UTC' TIME_ZONE = 'Europe/Warsaw'
USE_I18N = True USE_I18N = True
@@ -134,7 +156,11 @@ STORAGES = {
'BACKEND': 'django.core.files.storage.FileSystemStorage', 'BACKEND': 'django.core.files.storage.FileSystemStorage',
}, },
'staticfiles': { 'staticfiles': {
'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage', 'BACKEND': (
'django.contrib.staticfiles.storage.StaticFilesStorage'
if DEBUG else
'whitenoise.storage.CompressedManifestStaticFilesStorage'
),
}, },
} }
@@ -142,3 +168,18 @@ STORAGES = {
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
SITE_NAME = 'SantehRay'
LOGIN_URL = '/users/login/'
LOGIN_REDIRECT_URL = '/users/profile/'
LOGOUT_REDIRECT_URL = '/users/login/'
EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', 'django.core.mail.backends.console.EmailBackend')
EMAIL_HOST = os.getenv('EMAIL_HOST', 'localhost')
EMAIL_PORT = int(os.getenv('EMAIL_PORT', '25'))
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'false').lower() in {'1', 'true', 'yes', 'on'}
EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'false').lower() in {'1', 'true', 'yes', 'on'}
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'no-reply@santehray.local')

View File

@@ -1,36 +1,277 @@
.login-form { .auth-page {
padding: 25px; min-height: 100vh;
border: 1px solid rgba(94, 80, 125, 0.78);
& > h2 {
text-align: center;
font-weight: 400;
font-size: 44px;
color: #36213e;
}
}
.form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 5px;
margin-top: 10px;
} }
.form-label { .auth-section {
font-weight: 400; flex: 1;
font-size: 18px; padding: 60px 0 100px;
line-height: 22px; background:
text-align: left; radial-gradient(circle at top right, rgba(184, 243, 255, 0.45) 0, rgba(184, 243, 255, 0) 26%),
linear-gradient(180deg, rgba(99, 118, 141, 0.06) 0%, rgba(99, 118, 141, 0.12) 100%);
}
.auth-layout {
display: flex;
justify-content: space-between;
align-items: stretch;
gap: 56px;
}
.auth-layout--reverse {
flex-direction: row-reverse;
}
.auth-intro {
flex: 1 1 480px;
min-height: 540px;
padding: 56px 48px;
background: #63768d;
color: #ffffff;
display: flex;
flex-direction: column;
justify-content: center;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.auth-intro--login {
background-image:
linear-gradient(rgba(54, 33, 62, 0.42), rgba(54, 33, 62, 0.42)),
url("../img/kitchen-banner.jpg");
}
.auth-intro--register {
background-image:
linear-gradient(rgba(54, 33, 62, 0.48), rgba(54, 33, 62, 0.48)),
url("../img/about-image.jpg");
}
.auth-intro--reset {
background-image:
linear-gradient(rgba(54, 33, 62, 0.44), rgba(54, 33, 62, 0.44)),
url("../img/stats-image.jpg");
}
.auth-kicker {
margin-bottom: 18px;
font-size: 20px;
line-height: 1.2;
color: #b8f3ff;
}
.auth-title {
max-width: 430px;
margin-bottom: 24px;
font-size: 50px;
line-height: 1.08;
color: #ffffff;
}
.auth-description {
max-width: 450px;
font-size: 18px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.92);
}
.auth-side-link {
align-self: flex-start;
margin-top: 38px;
padding: 12px 16px;
border: 1px solid #ffffff;
font-size: 22px;
line-height: 1.2;
color: #ffffff;
transition: background-color 0.2s ease, color 0.2s ease;
}
.auth-side-link:hover {
background: #ffffff;
color: #63768d;
}
.auth-panel {
flex: 0 1 520px;
padding: 48px 40px;
background: #ffffff;
border: 1px solid rgba(85, 73, 113, 0.22);
box-shadow: 0 20px 40px rgba(54, 33, 62, 0.08);
}
.auth-form {
display: flex;
flex-direction: column;
}
.auth-copy-block {
display: flex;
flex-direction: column;
gap: 18px;
}
.auth-panel-title {
font-size: 38px;
line-height: 1.1;
color: #36213e;
}
.auth-panel-text {
font-size: 18px;
line-height: 1.5;
color: #36213e;
}
.auth-alert {
margin-bottom: 10px;
padding: 14px 16px;
border: 1px solid #9f4b4b;
background: #f4dfdf;
color: #6f2323;
}
.auth-field {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.auth-field:first-child {
margin-top: 0;
}
.auth-field label {
font-size: 18px;
line-height: 1.2;
color: #554971; color: #554971;
} }
.form-input { .auth-field input {
width: 100%;
min-height: 48px;
padding: 12px 14px;
background: #d9d9d9; background: #d9d9d9;
border: 1px solid #554971; border: 1px solid #554971;
font-family: 'Noah', sans-serif;
font-size: 18px;
color: #36213e;
}
padding: 5px; .auth-field input::placeholder {
} color: rgba(54, 33, 62, 0.58);
}
.auth-field input:focus {
outline: none;
border-color: #36213e;
background: #eeeeee;
}
.auth-field-error {
font-size: 15px;
line-height: 1.4;
color: #8c2f2f;
}
.auth-actions {
margin-top: 0;
}
.auth-buttons {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
}
.auth-submit {
padding: 18px 34px;
border: 0;
background: #63768d;
font-size: 24px;
line-height: 1.2;
color: #ffffff;
cursor: pointer;
transition: background-color 0.2s ease;
}
.auth-submit:hover {
background: #554971;
}
.auth-submit-link {
display: inline-block;
padding: 18px 34px;
background: #63768d;
font-size: 24px;
line-height: 1.2;
color: #ffffff;
}
.auth-secondary-link {
display: inline-block;
padding: 10px 0;
font-size: 18px;
line-height: 1.2;
color: #63768d;
text-decoration: underline;
}
.auth-switch {
margin-top: 24px;
font-size: 18px;
line-height: 1.4;
color: #36213e;
}
.auth-switch a {
color: #63768d;
text-decoration: underline;
}
@media (max-width: 900px) {
.auth-section {
padding: 32px 0 72px;
}
.auth-layout,
.auth-layout--reverse {
flex-direction: column;
gap: 24px;
}
.auth-intro,
.auth-panel {
min-height: auto;
padding: 32px 24px;
}
.auth-title {
font-size: 40px;
}
.auth-description {
font-size: 16px;
}
.auth-side-link,
.auth-submit,
.auth-submit-link {
font-size: 20px;
}
.auth-secondary-link,
.auth-panel-text,
.auth-field label,
.auth-field input,
.auth-switch {
font-size: 16px;
}
.auth-panel-title {
font-size: 30px;
}
}

View File

@@ -36,7 +36,7 @@
<path d="M0 2.5A.5.5 0 0 1 .5 2H2a.5.5 0 0 1 .485.379L2.89 4H14.5a.5.5 0 0 1 .485.621l-1.5 6A.5.5 0 0 1 13 11H4a.5.5 0 0 1-.485-.379L1.61 3H.5a.5.5 0 0 1-.5-.5M3.14 5l1.25 5h8.22l1.25-5zM5 13a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-2 1a2 2 0 1 1 4 0 2 2 0 0 1-4 0m9-1a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-2 1a2 2 0 1 1 4 0 2 2 0 0 1-4 0"></path> <path d="M0 2.5A.5.5 0 0 1 .5 2H2a.5.5 0 0 1 .485.379L2.89 4H14.5a.5.5 0 0 1 .485.621l-1.5 6A.5.5 0 0 1 13 11H4a.5.5 0 0 1-.485-.379L1.61 3H.5a.5.5 0 0 1-.5-.5M3.14 5l1.25 5h8.22l1.25-5zM5 13a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-2 1a2 2 0 1 1 4 0 2 2 0 0 1-4 0m9-1a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-2 1a2 2 0 1 1 4 0 2 2 0 0 1-4 0"></path>
</svg> </svg>
</a> </a>
<a href="/users/login/"> <a href="{% if request.user.is_authenticated %}{% url 'users:profile' %}{% else %}{% url 'users:login' %}{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-person" viewBox="0 0 16 16"> class="bi bi-person" viewBox="0 0 16 16">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"></path> <path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"></path>
@@ -71,4 +71,4 @@
</a> </a>
</nav> </nav>
</div> </div>
</header> </header>

View File

@@ -0,0 +1,36 @@
{% extends '_layouts/base.html' %}
{% load static %}
{% block title %}Ссылка недействительна{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static 'css/login.css' %}">
{% endblock %}
{% block content %}
<div class="auth-page">
{% include "_fragments/header.html" %}
<main class="auth-section">
<div class="container auth-layout">
<section class="auth-intro auth-intro--reset">
<p class="auth-kicker">Ошибка подтверждения</p>
<h1 class="auth-title">Ссылка недействительна</h1>
<p class="auth-description">
Ссылка подтверждения истекла или уже была использована. Попробуйте зарегистрироваться снова.
</p>
</section>
<section class="auth-panel">
<div class="auth-copy-block">
<h2 class="auth-panel-title">Что можно сделать</h2>
<p class="auth-panel-text">Если аккаунт уже активирован, просто войдите. Если нет, создайте новый аккаунт и подтвердите почту повторно.</p>
<div class="auth-buttons">
<a class="auth-submit-link" href="{% url 'users:login' %}">Войти</a>
<a class="auth-secondary-link" href="{% url 'users:register' %}">Регистрация</a>
</div>
</div>
</section>
</div>
</main>
{% include "_fragments/footer.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends '_layouts/base.html' %}
{% load static %}
{% block title %}Почта подтверждена{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static 'css/login.css' %}">
{% endblock %}
{% block content %}
<div class="auth-page">
{% include "_fragments/header.html" %}
<main class="auth-section">
<div class="container auth-layout">
<section class="auth-intro auth-intro--login">
<p class="auth-kicker">Аккаунт активирован</p>
<h1 class="auth-title">Почта подтверждена</h1>
<p class="auth-description">
Аккаунт успешно активирован. Теперь вы можете пользоваться личным кабинетом и оформлять заказы.
</p>
</section>
<section class="auth-panel">
<div class="auth-copy-block">
<h2 class="auth-panel-title">Готово</h2>
<p class="auth-panel-text">Вы уже авторизованы в системе. Можете перейти в личный кабинет или вернуться к покупкам.</p>
<div class="auth-buttons">
<a class="auth-submit-link" href="{% url 'users:profile' %}">Личный кабинет</a>
<a class="auth-secondary-link" href="/catalog/">В каталог</a>
</div>
</div>
</section>
</div>
</main>
{% include "_fragments/footer.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
<html lang="ru">
<body style="margin:0;padding:24px;font-family:Arial,sans-serif;background:#f5f7fa;color:#36213e;">
<div style="max-width:640px;margin:0 auto;background:#ffffff;border:1px solid #d6dde6;padding:32px;">
<h1 style="margin:0 0 16px;font-size:28px;color:#36213e;">Подтверждение регистрации</h1>
<p style="margin:0 0 16px;font-size:16px;line-height:1.5;">
Здравствуйте{% if user.get_full_name %}, {{ user.get_full_name }}{% endif %}!
</p>
<p style="margin:0 0 24px;font-size:16px;line-height:1.5;">
Спасибо за регистрацию в {{ site_name }}. Подтвердите почту, чтобы активировать аккаунт.
</p>
<p style="margin:0 0 24px;">
<a href="{{ activation_url }}" style="display:inline-block;padding:14px 22px;background:#63768d;color:#ffffff;text-decoration:none;">Подтвердить почту</a>
</p>
<p style="margin:0;font-size:14px;line-height:1.5;color:#554971;">
Если кнопка не работает, скопируйте ссылку в браузер:<br>
<a href="{{ activation_url }}">{{ activation_url }}</a>
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,8 @@
Здравствуйте{% if user.get_full_name %}, {{ user.get_full_name }}{% endif %}!
Спасибо за регистрацию в {{ site_name }}.
Чтобы активировать аккаунт, перейдите по ссылке:
{{ activation_url }}
Если вы не создавали аккаунт, просто проигнорируйте это письмо.

View File

@@ -0,0 +1 @@
Подтверждение регистрации в {{ site_name }}

View File

@@ -0,0 +1,19 @@
<html lang="ru">
<body style="margin:0;padding:24px;font-family:Arial,sans-serif;background:#f5f7fa;color:#36213e;">
<div style="max-width:640px;margin:0 auto;background:#ffffff;border:1px solid #d6dde6;padding:32px;">
<h1 style="margin:0 0 16px;font-size:28px;color:#36213e;">Восстановление пароля</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.5;">
Вы запросили смену пароля для аккаунта в {{ site_name|default:"SantehRay" }}.
</p>
<p style="margin:0 0 24px;">
<a href="{{ protocol }}://{{ domain }}{% url 'users:password_reset_confirm' uidb64=uid token=token %}" style="display:inline-block;padding:14px 22px;background:#63768d;color:#ffffff;text-decoration:none;">Сменить пароль</a>
</p>
<p style="margin:0;font-size:14px;line-height:1.5;color:#554971;">
Если кнопка не работает, используйте ссылку:<br>
<a href="{{ protocol }}://{{ domain }}{% url 'users:password_reset_confirm' uidb64=uid token=token %}">
{{ protocol }}://{{ domain }}{% url 'users:password_reset_confirm' uidb64=uid token=token %}
</a>
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,8 @@
Здравствуйте!
Вы запросили восстановление пароля в {{ site_name|default:"SantehRay" }}.
Перейдите по ссылке, чтобы задать новый пароль:
{{ protocol }}://{{ domain }}{% url 'users:password_reset_confirm' uidb64=uid token=token %}
Если вы не отправляли этот запрос, просто проигнорируйте это письмо.

View File

@@ -0,0 +1 @@
Восстановление пароля в {{ site_name|default:"SantehRay" }}

View File

@@ -8,32 +8,76 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="full-page"> <div class="auth-page">
{% include "_fragments/header.html" %} {% include "_fragments/header.html" %}
<div class="center-wrapper"> <main class="auth-section">
<form class="login-form" action=""> <div class="container auth-layout">
<h2>ВХОД</h2> <section class="auth-intro auth-intro--login">
<div class="form-group"> <p class="auth-kicker">Личный кабинет</p>
<label class="form-label" for="login">Логин</label> <h1 id="login-title" class="auth-title">Вход в аккаунт</h1>
<input class="form-input" type="text" id="login"> <p class="auth-description">
</div> Войдите, чтобы управлять заказами, сохранять избранные товары и быстрее оформлять покупки.
<div class="form-group"> </p>
<label class="form-label" for="password">Пароль</label> <a class="auth-side-link" href="/catalog/">Перейти в каталог</a>
<input class="form-input" type="password" id="password"> </section>
</div>
<div class="form-group"> <section class="auth-panel" aria-labelledby="login-title">
<div> <form class="auth-form" action="" method="post">
<input class="form-input" type="checkbox" id="persist"> {% csrf_token %}
<label class="form-label" for="persist">Оставаться в системе</label> {% if form.non_field_errors %}
</div> <div class="auth-alert auth-alert--error">
</div> {% for error in form.non_field_errors %}
<div class="form-group"> <p>{{ error }}</p>
<button type="submit">Войти</button> {% endfor %}
</div> </div>
</form> {% endif %}
</div>
<div class="auth-field">
<label for="{{ form.email.id_for_label }}">Электронная почта</label>
<input
id="{{ form.email.id_for_label }}"
name="{{ form.email.html_name }}"
type="email"
value="{{ form.email.value|default:'' }}"
placeholder="example@mail.ru"
autocomplete="email"
>
{% for error in form.email.errors %}
<p class="auth-field-error">{{ error }}</p>
{% endfor %}
</div>
<div class="auth-field">
<label for="{{ form.password.id_for_label }}">Пароль</label>
<input
id="{{ form.password.id_for_label }}"
name="{{ form.password.html_name }}"
type="password"
placeholder="Введите пароль"
autocomplete="current-password"
>
{% for error in form.password.errors %}
<p class="auth-field-error">{{ error }}</p>
{% endfor %}
<a class="auth-secondary-link" href="{% url 'users:password_reset' %}">Сбросить пароль</a>
</div>
<div class="auth-actions">
<div class="auth-buttons">
<button class="auth-submit" type="submit">Войти</button>
</div>
<p class="auth-switch">
Нет аккаунта?
<a href="{% url 'users:register' %}">Зарегистрироваться</a>
</p>
</div>
</form>
</section>
</div>
</main>
{% include "_fragments/footer.html" %} {% include "_fragments/footer.html" %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,65 @@
{% extends '_layouts/base.html' %}
{% load static %}
{% block title %}Сброс пароля{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static 'css/login.css' %}">
{% endblock %}
{% block content %}
<div class="auth-page">
{% include "_fragments/header.html" %}
<main class="auth-section">
<div class="container auth-layout">
<section class="auth-intro auth-intro--reset">
<p class="auth-kicker">Восстановление доступа</p>
<h1 id="password-reset-title" class="auth-title">Сброс пароля</h1>
<p class="auth-description">
Укажите электронную почту, и мы отправим инструкции для восстановления доступа к вашему аккаунту.
</p>
<a class="auth-side-link" href="/users/login/">Вернуться ко входу</a>
</section>
<section class="auth-panel" aria-labelledby="password-reset-title">
<form class="auth-form" action="" method="post">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="auth-alert auth-alert--error">
{% for error in form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="auth-field">
<label for="{{ form.email.id_for_label }}">Электронная почта</label>
<input
id="{{ form.email.id_for_label }}"
name="{{ form.email.html_name }}"
type="email"
value="{{ form.email.value|default:'' }}"
placeholder="example@mail.ru"
autocomplete="email"
>
{% for error in form.email.errors %}
<p class="auth-field-error">{{ error }}</p>
{% endfor %}
</div>
<div class="auth-actions">
<button class="auth-submit" type="submit">Отправить письмо</button>
<p class="auth-switch">
Вспомнили пароль?
<a href="{% url 'users:login' %}">Вернуться ко входу</a>
</p>
</div>
</form>
</section>
</div>
</main>
{% include "_fragments/footer.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends '_layouts/base.html' %}
{% load static %}
{% block title %}Пароль обновлён{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static 'css/login.css' %}">
{% endblock %}
{% block content %}
<div class="auth-page">
{% include "_fragments/header.html" %}
<main class="auth-section">
<div class="container auth-layout">
<section class="auth-intro auth-intro--login">
<p class="auth-kicker">Готово</p>
<h1 class="auth-title">Пароль обновлён</h1>
<p class="auth-description">
Новый пароль сохранён. Теперь можно войти в аккаунт с обновлёнными данными.
</p>
</section>
<section class="auth-panel">
<div class="auth-copy-block">
<h2 class="auth-panel-title">Вход в аккаунт</h2>
<p class="auth-panel-text">Используйте новый пароль на странице входа.</p>
<a class="auth-submit-link" href="{% url 'users:login' %}">Перейти ко входу</a>
</div>
</section>
</div>
</main>
{% include "_fragments/footer.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends '_layouts/base.html' %}
{% load static %}
{% block title %}Новый пароль{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static 'css/login.css' %}">
{% endblock %}
{% block content %}
<div class="auth-page">
{% include "_fragments/header.html" %}
<main class="auth-section">
<div class="container auth-layout">
<section class="auth-intro auth-intro--reset">
<p class="auth-kicker">Обновление пароля</p>
<h1 class="auth-title">Придумайте новый пароль</h1>
<p class="auth-description">
Используйте надёжный пароль, который вы ещё не применяли для этого аккаунта.
</p>
</section>
<section class="auth-panel">
{% if validlink %}
<form class="auth-form" method="post">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="auth-alert auth-alert--error">
{% for error in form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="auth-field">
<label for="{{ form.new_password1.id_for_label }}">Новый пароль</label>
<input
id="{{ form.new_password1.id_for_label }}"
name="{{ form.new_password1.html_name }}"
type="password"
placeholder="Введите новый пароль"
autocomplete="new-password"
>
{% for error in form.new_password1.errors %}
<p class="auth-field-error">{{ error }}</p>
{% endfor %}
</div>
<div class="auth-field">
<label for="{{ form.new_password2.id_for_label }}">Подтверждение пароля</label>
<input
id="{{ form.new_password2.id_for_label }}"
name="{{ form.new_password2.html_name }}"
type="password"
placeholder="Повторите пароль"
autocomplete="new-password"
>
{% for error in form.new_password2.errors %}
<p class="auth-field-error">{{ error }}</p>
{% endfor %}
</div>
<div class="auth-actions">
<button class="auth-submit" type="submit">Сохранить пароль</button>
</div>
</form>
{% else %}
<div class="auth-copy-block">
<h2 class="auth-panel-title">Ссылка недействительна</h2>
<p class="auth-panel-text">Возможно, ссылка уже использована или срок её действия истёк. Запросите сброс пароля повторно.</p>
<a class="auth-secondary-link" href="{% url 'users:password_reset' %}">Запросить новое письмо</a>
</div>
{% endif %}
</section>
</div>
</main>
{% include "_fragments/footer.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends '_layouts/base.html' %}
{% load static %}
{% block title %}Письмо отправлено{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static 'css/login.css' %}">
{% endblock %}
{% block content %}
<div class="auth-page">
{% include "_fragments/header.html" %}
<main class="auth-section">
<div class="container auth-layout">
<section class="auth-intro auth-intro--reset">
<p class="auth-kicker">Проверьте почту</p>
<h1 class="auth-title">Письмо отправлено</h1>
<p class="auth-description">
Если аккаунт с таким адресом существует, мы отправили письмо со ссылкой для смены пароля.
</p>
</section>
<section class="auth-panel">
<div class="auth-copy-block">
<h2 class="auth-panel-title">Что дальше</h2>
<p class="auth-panel-text">Откройте письмо и перейдите по ссылке. Если письма нет, проверьте папку «Спам».</p>
<a class="auth-secondary-link" href="{% url 'users:login' %}">Вернуться ко входу</a>
</div>
</section>
</div>
</main>
{% include "_fragments/footer.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,40 @@
{% extends '_layouts/base.html' %}
{% load static %}
{% block title %}Профиль{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static 'css/login.css' %}">
{% endblock %}
{% block content %}
<div class="auth-page">
{% include "_fragments/header.html" %}
<main class="auth-section">
<div class="container auth-layout">
<section class="auth-intro auth-intro--login">
<p class="auth-kicker">Личный кабинет</p>
<h1 class="auth-title">Здравствуйте, {{ request.user.first_name|default:request.user.username }}</h1>
<p class="auth-description">
Ваш аккаунт активен и подтверждён. Здесь можно продолжить работу с заказами и персональными данными.
</p>
<a class="auth-side-link" href="/catalog/">Перейти в каталог</a>
</section>
<section class="auth-panel">
<div class="auth-copy-block">
<h2 class="auth-panel-title">Данные аккаунта</h2>
<p class="auth-panel-text"><strong>Email:</strong> {{ request.user.email }}</p>
<p class="auth-panel-text"><strong>Имя:</strong> {{ request.user.get_full_name|default:"Не указано" }}</p>
<form method="post" action="{% url 'users:logout' %}">
{% csrf_token %}
<div class="auth-actions">
<button class="auth-submit" type="submit">Выйти из аккаунта</button>
</div>
</form>
</div>
</section>
</div>
</main>
{% include "_fragments/footer.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,108 @@
{% extends '_layouts/base.html' %}
{% load static %}
{% block title %}Регистрация{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static 'css/login.css' %}">
{% endblock %}
{% block content %}
<div class="auth-page">
{% include "_fragments/header.html" %}
<main class="auth-section">
<div class="container auth-layout auth-layout--reverse">
<section class="auth-intro auth-intro--register">
<p class="auth-kicker">Новый аккаунт</p>
<h1 id="register-title" class="auth-title">Регистрация</h1>
<p class="auth-description">
Создайте аккаунт, чтобы получать персональные предложения, отслеживать покупки и сохранять нужные позиции.
</p>
<a class="auth-side-link" href="/services/">Посмотреть услуги</a>
</section>
<section class="auth-panel" aria-labelledby="register-title">
<form class="auth-form" action="" method="post">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="auth-alert auth-alert--error">
{% for error in form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="auth-field">
<label for="{{ form.full_name.id_for_label }}">Имя и фамилия</label>
<input
id="{{ form.full_name.id_for_label }}"
name="{{ form.full_name.html_name }}"
type="text"
value="{{ form.full_name.value|default:'' }}"
placeholder="Введите ваше имя"
autocomplete="name"
>
{% for error in form.full_name.errors %}
<p class="auth-field-error">{{ error }}</p>
{% endfor %}
</div>
<div class="auth-field">
<label for="{{ form.email.id_for_label }}">Электронная почта</label>
<input
id="{{ form.email.id_for_label }}"
name="{{ form.email.html_name }}"
type="email"
value="{{ form.email.value|default:'' }}"
placeholder="example@mail.ru"
autocomplete="email"
>
{% for error in form.email.errors %}
<p class="auth-field-error">{{ error }}</p>
{% endfor %}
</div>
<div class="auth-field">
<label for="{{ form.password1.id_for_label }}">Пароль</label>
<input
id="{{ form.password1.id_for_label }}"
name="{{ form.password1.html_name }}"
type="password"
placeholder="Придумайте пароль"
autocomplete="new-password"
>
{% for error in form.password1.errors %}
<p class="auth-field-error">{{ error }}</p>
{% endfor %}
</div>
<div class="auth-field">
<label for="{{ form.password2.id_for_label }}">Подтверждение пароля</label>
<input
id="{{ form.password2.id_for_label }}"
name="{{ form.password2.html_name }}"
type="password"
placeholder="Повторите пароль"
autocomplete="new-password"
>
{% for error in form.password2.errors %}
<p class="auth-field-error">{{ error }}</p>
{% endfor %}
</div>
<div class="auth-actions">
<button class="auth-submit" type="submit">Создать аккаунт</button>
<p class="auth-switch">
Уже есть аккаунт?
<a href="{% url 'users:login' %}">Войти</a>
</p>
</div>
</form>
</section>
</div>
</main>
{% include "_fragments/footer.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends '_layouts/base.html' %}
{% load static %}
{% block title %}Подтвердите почту{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static 'css/login.css' %}">
{% endblock %}
{% block content %}
<div class="auth-page">
{% include "_fragments/header.html" %}
<main class="auth-section">
<div class="container auth-layout">
<section class="auth-intro auth-intro--register">
<p class="auth-kicker">Почти готово</p>
<h1 class="auth-title">Подтвердите почту</h1>
<p class="auth-description">
Мы отправили письмо на адрес {{ email }}. Перейдите по ссылке из письма, чтобы активировать аккаунт.
</p>
</section>
<section class="auth-panel">
<div class="auth-copy-block">
<h2 class="auth-panel-title">Что дальше</h2>
<p class="auth-panel-text">Проверьте входящие, а также папки «Спам» и «Промоакции», если письмо не видно сразу.</p>
<p class="auth-switch">После подтверждения вы автоматически сможете войти в личный кабинет.</p>
<a class="auth-secondary-link" href="{% url 'users:login' %}">Вернуться на страницу входа</a>
</div>
</section>
</div>
</main>
{% include "_fragments/footer.html" %}
</div>
{% endblock %}

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 from users import views
app_name = "users"
urlpatterns = [ urlpatterns = [
path('login/', views.login, name='login'), path('login/', views.login, name='login'),
path('register/', views.register, name='register'), 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') 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. # Create your views here.
@require_http_methods(["GET", "POST"])
def login(request): 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): 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): 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"