feat: add FAQ question form with email notifications and validations
All checks were successful
Build and Push Docker Image / build (push) Successful in 43s
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:
32
home/forms.py
Normal file
32
home/forms.py
Normal 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()
|
||||
@@ -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, "Необходимо согласие на обработку персональных данных.")
|
||||
|
||||
@@ -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):
|
||||
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):
|
||||
|
||||
@@ -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_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')
|
||||
ADMINISTRATOR_EMAIL = os.getenv('ADMINISTRATOR_EMAIL', 'info@santehray.by')
|
||||
|
||||
@@ -560,6 +560,33 @@
|
||||
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 {
|
||||
font-weight: 400;
|
||||
font-size: 44px;
|
||||
@@ -696,7 +723,8 @@
|
||||
margin-top: 25px;
|
||||
display: inline-block;
|
||||
|
||||
& > input[type=text] {
|
||||
& > input[type=text],
|
||||
& > textarea {
|
||||
width: 540px;
|
||||
height: 43px;
|
||||
padding: 1px 5px;
|
||||
@@ -706,6 +734,12 @@
|
||||
|
||||
border: 1px solid #554971;
|
||||
}
|
||||
|
||||
& > textarea {
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-top: 69px;
|
||||
background: #63768d;
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
.auth-section {
|
||||
flex: 1;
|
||||
padding: 60px 0 100px;
|
||||
height: 100%;
|
||||
padding-top: 60px;
|
||||
padding-bottom: 60px;
|
||||
background:
|
||||
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%);
|
||||
@@ -27,14 +29,12 @@
|
||||
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;
|
||||
background: #63768d no-repeat center;
|
||||
}
|
||||
|
||||
.auth-intro--login {
|
||||
@@ -180,6 +180,10 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.register-auth-actions {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.auth-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -75,10 +75,84 @@ body {
|
||||
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 {
|
||||
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 {
|
||||
max-width: 1160px;
|
||||
|
||||
34
static/css/warranty.css
Normal file
34
static/css/warranty.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,36 @@
|
||||
{% endblock %}
|
||||
</head>
|
||||
<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 %}
|
||||
{% 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>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
<section class="faq-section container">
|
||||
<section class="faq-section container" id="faq-section">
|
||||
<h2>ОТВЕТЫ НА ВОПРОСЫ</h2>
|
||||
<div class="faq-grid">
|
||||
|
||||
@@ -44,23 +44,30 @@
|
||||
|
||||
<div id="back-form" style="display: none;" class="back-form">
|
||||
<h2>НЕ НАШЛИ ОТВЕТ НА ВАШ ВОПРОС?</h2>
|
||||
<form action="">
|
||||
<label class="q-label" for="name" style="margin-top: 69px;">
|
||||
<input type="text" placeholder="Имя" id="name">
|
||||
</label>
|
||||
<form action="{% url 'home' %}" method="post">
|
||||
{% csrf_token %}
|
||||
{{ faq_form.non_field_errors }}
|
||||
|
||||
<label class="q-label" for="number">
|
||||
<input type="text" placeholder="Номер телефона" id="number">
|
||||
<label class="q-label" for="{{ faq_form.name.id_for_label }}" style="margin-top: 69px;">
|
||||
{{ faq_form.name }}
|
||||
</label>
|
||||
{{ faq_form.name.errors }}
|
||||
|
||||
<label class="q-label" for="question">
|
||||
<input type="text" placeholder="Ваш вопрос" id="question">
|
||||
<label class="q-label" for="{{ faq_form.phone.id_for_label }}">
|
||||
{{ faq_form.phone }}
|
||||
</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">
|
||||
<input type="checkbox" id="accept" name="accept">
|
||||
<label for="accept">Я согласен с политикой обработки персональных данных</label>
|
||||
{{ faq_form.accept }}
|
||||
<label for="{{ faq_form.accept.id_for_label }}">{{ faq_form.accept.label }}</label>
|
||||
</div>
|
||||
{{ faq_form.accept.errors }}
|
||||
|
||||
<button class="back-form-submit" type="submit">ОТПРАВИТЬ ВОПРОС</button>
|
||||
</form>
|
||||
@@ -78,5 +85,12 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
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>
|
||||
@@ -4,13 +4,44 @@
|
||||
{% block title %}Главная{% endblock %}
|
||||
|
||||
{% block add_css %}
|
||||
|
||||
<link rel="stylesheet" href="{% static 'css/warranty.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% 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>
|
||||
|
||||
<main class="warranty container">
|
||||
<h1>ГАРАНТИЯ НА ТОВАР</h1>
|
||||
<p>
|
||||
В каталоге нашего интернет-магазина представлены только качественные товары от лучших производителей. Мы
|
||||
закупаем только сертифицированный товар и только у официальных импортеров и производителей. Покупая
|
||||
сантехнику в нашем магазине, Вы приобретаете товары только с гарантией! Все гарантийное и
|
||||
постгарантийное обслуживание товаров производится специализированными авторизированными сервисными
|
||||
центрами фирм производителей, имеющих на то соответствующие полномочия. Данные условия гарантируют
|
||||
отсутствие в изделии дефектов и удовлетворяют законным требованиям Потребителя в течение гарантийного
|
||||
срока с момента передачи товара Потребителю.
|
||||
</p>
|
||||
<h2>Условия гарантии:</h2>
|
||||
<p>
|
||||
Гарантия не распространяется на неисправности, вызванные неправильным монтажом, чисткой абразивными или
|
||||
едкими веществами, неправильными эксплуатацией и ремонтом. Внимательно изучите прилагаемую инструкцию по
|
||||
эксплуатации.
|
||||
</p>
|
||||
<p>
|
||||
Гарантийные обязательства не распространяются на расходные элементы и материалы (батареи, картриджи,
|
||||
комплектующие и т.п.), а также на ремонт или замену естественно изнашиваемых в процессе эксплуатации
|
||||
запчастей.
|
||||
</p>
|
||||
<p>
|
||||
В гарантийном обслуживании может быть отказано, если установка и ремонт изделий производились лицами, не
|
||||
имеющими соответствующих полномочий и квалификации.
|
||||
</p>
|
||||
</main>
|
||||
|
||||
{# TODO #}
|
||||
В разработке
|
||||
{% include "_fragments/footer.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -8,7 +8,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-page">
|
||||
<div class="full-page">
|
||||
{% include "_fragments/header.html" %}
|
||||
|
||||
<main class="auth-section">
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="auth-actions">
|
||||
<div class="register-auth-actions">
|
||||
<button class="auth-submit" type="submit">Создать аккаунт</button>
|
||||
<p class="auth-switch">
|
||||
Уже есть аккаунт?
|
||||
|
||||
Reference in New Issue
Block a user