feat: add FAQ question form with email notifications and validations
All checks were successful
Build and Push Docker Image / build (push) Successful in 43s

- Created `FaqQuestionForm` in `forms.py` with custom field validations
- Added FAQ form to the FAQ section with CSRF protection and error handling
- Integrated email notifications to administrator upon FAQ submission
- Updated styles and scripts for form inputs, validation messages, and messages UI
- Adjusted FAQ section layout in `faq.html` and related CSS for responsiveness
- Implemented tests for FAQ form submission and email notifications
This commit is contained in:
2026-04-19 22:51:56 +02:00
parent 80164b833b
commit 4f4a1a55e5
13 changed files with 379 additions and 32 deletions

32
home/forms.py Normal file
View File

@@ -0,0 +1,32 @@
from django import forms
class FaqQuestionForm(forms.Form):
name = forms.CharField(
label="Имя",
max_length=150,
widget=forms.TextInput(attrs={"placeholder": "Имя", "autocomplete": "name"}),
)
phone = forms.CharField(
label="Номер телефона",
max_length=64,
widget=forms.TextInput(attrs={"placeholder": "Номер телефона", "autocomplete": "tel"}),
)
question = forms.CharField(
label="Ваш вопрос",
max_length=1000,
widget=forms.Textarea(attrs={"placeholder": "Ваш вопрос", "rows": 4, "autocomplete": "off"}),
)
accept = forms.BooleanField(
label="Я согласен с политикой обработки персональных данных",
error_messages={"required": "Необходимо согласие на обработку персональных данных."},
)
def clean_name(self):
return self.cleaned_data["name"].strip()
def clean_phone(self):
return self.cleaned_data["phone"].strip()
def clean_question(self):
return self.cleaned_data["question"].strip()

View File

@@ -1,3 +1,38 @@
from django.test import TestCase from django.core import mail
from django.test import TestCase, override_settings
from django.urls import reverse
# Create your tests here.
@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", ADMINISTRATOR_EMAIL="admin@example.com")
class FaqQuestionTests(TestCase):
def test_faq_question_form_sends_email_to_administrator(self):
response = self.client.post(
reverse("home"),
data={
"name": "Иван Петров",
"phone": "+375291112233",
"question": "Сколько стоит доставка?",
"accept": "on",
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], reverse("home") + "#faq-section")
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["admin@example.com"])
self.assertIn("Иван Петров", mail.outbox[0].body)
self.assertIn("Сколько стоит доставка?", mail.outbox[0].body)
def test_faq_question_form_requires_consent(self):
response = self.client.post(
reverse("home"),
data={
"name": "Иван Петров",
"phone": "+375291112233",
"question": "Сколько стоит доставка?",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), 0)
self.assertContains(response, "Необходимо согласие на обработку персональных данных.")

View File

@@ -1,8 +1,48 @@
from django.shortcuts import render import logging
from django.conf import settings
from django.core.mail import send_mail
from django.shortcuts import redirect, render
from django.urls import reverse
from django.contrib import messages
from home.forms import FaqQuestionForm
logger = logging.getLogger(__name__)
def index(request): def index(request):
return render(request=request, template_name='home/index.html', context={}) form = FaqQuestionForm(request.POST or None)
if request.method == "POST" and form.is_valid():
subject = f"Новый вопрос с сайта {settings.SITE_NAME}"
message = "\n".join(
[
f"Имя: {form.cleaned_data['name']}",
f"Телефон: {form.cleaned_data['phone']}",
f"Вопрос: {form.cleaned_data['question']}",
"",
f"Страница: {request.build_absolute_uri('/')}",
]
)
try:
send_mail(
subject=subject,
message=message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[settings.ADMINISTRATOR_EMAIL],
fail_silently=False,
)
except Exception:
logger.exception("Failed to send FAQ question email")
form.add_error(None, "Не удалось отправить вопрос. Попробуйте позже.")
else:
messages.success(request, "Спасибо. Ваш вопрос отправлен, мы свяжемся с вами в ближайшее время.")
return redirect(f"{reverse('home')}#faq-section")
return render(request=request, template_name='home/index.html', context={"faq_form": form})
def services(request): def services(request):

View File

@@ -183,3 +183,4 @@ 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_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'} 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') DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'no-reply@santehray.local')
ADMINISTRATOR_EMAIL = os.getenv('ADMINISTRATOR_EMAIL', 'info@santehray.by')

View File

@@ -560,6 +560,33 @@
padding-bottom: 90px; padding-bottom: 90px;
} }
.faq-messages {
margin-top: 28px;
display: flex;
flex-direction: column;
gap: 12px;
}
.faq-message {
padding: 14px 18px;
border: 1px solid #63768d;
background: rgba(99, 118, 141, 0.08);
color: #36213e;
font-weight: 500;
font-size: 18px;
line-height: 1.35;
}
.faq-message--success {
border-color: #4f7d4f;
background: rgba(79, 125, 79, 0.1);
}
.faq-message--error {
border-color: #a14b4b;
background: rgba(161, 75, 75, 0.1);
}
.faq-section h2 { .faq-section h2 {
font-weight: 400; font-weight: 400;
font-size: 44px; font-size: 44px;
@@ -696,7 +723,8 @@
margin-top: 25px; margin-top: 25px;
display: inline-block; display: inline-block;
& > input[type=text] { & > input[type=text],
& > textarea {
width: 540px; width: 540px;
height: 43px; height: 43px;
padding: 1px 5px; padding: 1px 5px;
@@ -706,6 +734,12 @@
border: 1px solid #554971; border: 1px solid #554971;
} }
& > textarea {
min-height: 140px;
resize: vertical;
padding-top: 10px;
}
} }
.back-form-accept-checkbox { .back-form-accept-checkbox {
@@ -729,6 +763,25 @@
} }
} }
.back-form .errorlist {
margin: 10px 0 0;
padding: 0;
list-style: none;
color: #a14b4b;
font-size: 16px;
line-height: 1.35;
max-width: 540px;
}
.back-form .errorlist li {
margin: 0;
}
.back-form .errorlist.nonfield {
margin-top: 20px;
text-align: center;
}
.back-form-submit { .back-form-submit {
margin-top: 69px; margin-top: 69px;
background: #63768d; background: #63768d;

View File

@@ -6,7 +6,9 @@
.auth-section { .auth-section {
flex: 1; flex: 1;
padding: 60px 0 100px; height: 100%;
padding-top: 60px;
padding-bottom: 60px;
background: background:
radial-gradient(circle at top right, rgba(184, 243, 255, 0.45) 0, rgba(184, 243, 255, 0) 26%), 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%); linear-gradient(180deg, rgba(99, 118, 141, 0.06) 0%, rgba(99, 118, 141, 0.12) 100%);
@@ -27,14 +29,12 @@
flex: 1 1 480px; flex: 1 1 480px;
min-height: 540px; min-height: 540px;
padding: 56px 48px; padding: 56px 48px;
background: #63768d;
color: #ffffff; color: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
background-repeat: no-repeat;
background-size: cover; background-size: cover;
background-position: center; background: #63768d no-repeat center;
} }
.auth-intro--login { .auth-intro--login {
@@ -180,6 +180,10 @@
margin-top: 0; margin-top: 0;
} }
.register-auth-actions {
margin-top: 25px;
}
.auth-buttons { .auth-buttons {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -75,10 +75,84 @@ body {
overflow-x: hidden; overflow-x: hidden;
} }
.site-toast-stack {
position: fixed;
top: 24px;
right: 24px;
z-index: 1200;
display: flex;
flex-direction: column;
gap: 12px;
width: min(420px, calc(100vw - 32px));
}
.site-toast {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border: 1px solid rgba(54, 33, 62, 0.14);
border-radius: 14px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 18px 40px rgba(54, 33, 62, 0.18);
color: var(--dark);
backdrop-filter: blur(10px);
transform: translateY(0);
opacity: 1;
transition: opacity 0.22s ease, transform 0.22s ease;
}
.site-toast.is-hiding {
opacity: 0;
transform: translateY(-10px);
}
.site-toast--success {
border-color: rgba(79, 125, 79, 0.25);
}
.site-toast--error {
border-color: rgba(161, 75, 75, 0.25);
}
.site-toast--warning {
border-color: rgba(176, 133, 56, 0.25);
}
.site-toast__content {
font-size: 16px;
line-height: 1.45;
font-weight: 500;
}
.site-toast__close {
flex: 0 0 auto;
border: 0;
background: transparent;
color: var(--mid);
font-size: 26px;
line-height: 1;
cursor: pointer;
}
.site-toast__close:hover {
color: var(--dark);
}
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-family: 'BerlinType', serif; font-family: 'BerlinType', serif;
} }
@media (max-width: 640px) {
.site-toast-stack {
top: 12px;
right: 12px;
left: 12px;
width: auto;
}
}
/* Container to maintain consistent width */ /* Container to maintain consistent width */
.container { .container {
max-width: 1160px; max-width: 1160px;

34
static/css/warranty.css Normal file
View File

@@ -0,0 +1,34 @@
.warranty {
padding-bottom: 40px;
& > h1 {
margin: 0;
font-style: normal;
font-weight: 400;
font-size: 50px;
text-align: left;
color: #36213e;
}
& > h2 {
margin-top: 30px;
font-style: normal;
font-weight: 400;
font-size: 30px;
text-align: left;
color: #36213e;
}
& > p {
display: block;
margin-top: 30px;
font-style: normal;
font-weight: 400;
font-size: 21px;
text-align: left;
color: #36213e;
}
}

View File

@@ -20,7 +20,36 @@
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
{% if messages %}
<div class="site-toast-stack" aria-live="polite" aria-atomic="true">
{% for message in messages %}
<div class="site-toast site-toast--{{ message.tags }}" data-toast>
<div class="site-toast__content">{{ message }}</div>
<button class="site-toast__close" type="button" aria-label="Закрыть уведомление" data-toast-close>×</button>
</div>
{% endfor %}
</div>
{% endif %}
{% block content %} {% block content %}
{% endblock %} {% endblock %}
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-toast]').forEach(function (toast) {
const closeButton = toast.querySelector('[data-toast-close]')
const hideToast = function () {
toast.classList.add('is-hiding')
window.setTimeout(function () {
toast.remove()
}, 220)
}
if (closeButton) {
closeButton.addEventListener('click', hideToast)
}
window.setTimeout(hideToast, 4000)
})
})
</script>
</body> </body>
</html> </html>

View File

@@ -1,4 +1,4 @@
<section class="faq-section container"> <section class="faq-section container" id="faq-section">
<h2>ОТВЕТЫ НА ВОПРОСЫ</h2> <h2>ОТВЕТЫ НА ВОПРОСЫ</h2>
<div class="faq-grid"> <div class="faq-grid">
@@ -44,23 +44,30 @@
<div id="back-form" style="display: none;" class="back-form"> <div id="back-form" style="display: none;" class="back-form">
<h2>НЕ НАШЛИ ОТВЕТ НА ВАШ ВОПРОС?</h2> <h2>НЕ НАШЛИ ОТВЕТ НА ВАШ ВОПРОС?</h2>
<form action=""> <form action="{% url 'home' %}" method="post">
<label class="q-label" for="name" style="margin-top: 69px;"> {% csrf_token %}
<input type="text" placeholder="Имя" id="name"> {{ faq_form.non_field_errors }}
</label>
<label class="q-label" for="number"> <label class="q-label" for="{{ faq_form.name.id_for_label }}" style="margin-top: 69px;">
<input type="text" placeholder="Номер телефона" id="number"> {{ faq_form.name }}
</label> </label>
{{ faq_form.name.errors }}
<label class="q-label" for="question"> <label class="q-label" for="{{ faq_form.phone.id_for_label }}">
<input type="text" placeholder="Ваш вопрос" id="question"> {{ faq_form.phone }}
</label> </label>
{{ faq_form.phone.errors }}
<label class="q-label" for="{{ faq_form.question.id_for_label }}">
{{ faq_form.question }}
</label>
{{ faq_form.question.errors }}
<div class="back-form-accept-checkbox"> <div class="back-form-accept-checkbox">
<input type="checkbox" id="accept" name="accept"> {{ faq_form.accept }}
<label for="accept">Я согласен с политикой обработки персональных данных</label> <label for="{{ faq_form.accept.id_for_label }}">{{ faq_form.accept.label }}</label>
</div> </div>
{{ faq_form.accept.errors }}
<button class="back-form-submit" type="submit">ОТПРАВИТЬ ВОПРОС</button> <button class="back-form-submit" type="submit">ОТПРАВИТЬ ВОПРОС</button>
</form> </form>
@@ -78,5 +85,12 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
Fancybox.bind('[data-fancybox]') Fancybox.bind('[data-fancybox]')
{% if faq_form.is_bound and faq_form.errors %}
const openFaqButton = document.querySelector('.faq-cta a[data-fancybox]')
if (openFaqButton) {
openFaqButton.click()
}
{% endif %}
}) })
</script> </script>

View File

@@ -4,13 +4,44 @@
{% block title %}Главная{% endblock %} {% block title %}Главная{% endblock %}
{% block add_css %} {% block add_css %}
<link rel="stylesheet" href="{% static 'css/warranty.css' %}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include "_fragments/header.html" %} <div class="full-page">
<div>
{% include "_fragments/header.html" with active="warranty" breadcrumb=1 %}
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/warranty' text_2='Гарантия' %}
</div>
{# TODO #} <main class="warranty container">
В разработке <h1>ГАРАНТИЯ НА ТОВАР</h1>
{% include "_fragments/footer.html" %} <p>
В каталоге нашего интернет-магазина представлены только качественные товары от лучших производителей. Мы
закупаем только сертифицированный товар и только у официальных импортеров и производителей. Покупая
сантехнику в нашем магазине, Вы приобретаете товары только с гарантией! Все гарантийное и
постгарантийное обслуживание товаров производится специализированными авторизированными сервисными
центрами фирм производителей, имеющих на то соответствующие полномочия. Данные условия гарантируют
отсутствие в изделии дефектов и удовлетворяют законным требованиям Потребителя в течение гарантийного
срока с момента передачи товара Потребителю.
</p>
<h2>Условия гарантии:</h2>
<p>
Гарантия не распространяется на неисправности, вызванные неправильным монтажом, чисткой абразивными или
едкими веществами, неправильными эксплуатацией и ремонтом. Внимательно изучите прилагаемую инструкцию по
эксплуатации.
</p>
<p>
Гарантийные обязательства не распространяются на расходные элементы и материалы (батареи, картриджи,
комплектующие и т.п.), а также на ремонт или замену естественно изнашиваемых в процессе эксплуатации
запчастей.
</p>
<p>
В гарантийном обслуживании может быть отказано, если установка и ремонт изделий производились лицами, не
имеющими соответствующих полномочий и квалификации.
</p>
</main>
{% include "_fragments/footer.html" %}
</div>
{% endblock %} {% endblock %}

View File

@@ -8,7 +8,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="auth-page"> <div class="full-page">
{% include "_fragments/header.html" %} {% include "_fragments/header.html" %}
<main class="auth-section"> <main class="auth-section">

View File

@@ -91,7 +91,7 @@
{% endfor %} {% endfor %}
</div> </div>
<div class="auth-actions"> <div class="register-auth-actions">
<button class="auth-submit" type="submit">Создать аккаунт</button> <button class="auth-submit" type="submit">Создать аккаунт</button>
<p class="auth-switch"> <p class="auth-switch">
Уже есть аккаунт? Уже есть аккаунт?