feat: регистрация продавца (FR-01) + палитра B (зелёный/янтарь)

This commit is contained in:
apuc committed 2026-09-23 18:28:45 +03:00
1 parent 43ac518fc2
commit c681c724a5
12 files changed
+335 -8

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
from datetime import date
from django import forms
from django.contrib.auth.forms import UserCreationForm
from apps.accounts.models import User
from apps.catalog.models import LegalStatus
MIN_AGE = 14
MAX_AGE = 35
class SellerStep1Form(UserCreationForm):
"""Шаг 1 — учётная запись и личные данные (ФИО, дата рождения, регион)."""
full_name = forms.CharField(label='ФИО', max_length=255)
birth_date = forms.DateField(
label='Дата рождения',
widget=forms.DateInput(attrs={'type': 'date'}),
)
region = forms.CharField(label='Регион', max_length=120)
class Meta:
model = User
fields = ('username', 'email', 'full_name', 'birth_date', 'region')
def clean_birth_date(self):
birth_date = self.cleaned_data['birth_date']
today = date.today()
age = (
today.year
- birth_date.year
- ((today.month, today.day) < (birth_date.month, birth_date.day))
)
if age < MIN_AGE:
raise forms.ValidationError(
f'Минимальный возраст участника — {MIN_AGE} лет.',
)
if age > MAX_AGE:
raise forms.ValidationError(
f'Максимальный возраст участника — {MAX_AGE} лет.',
)
return birth_date
class SellerStep2Form(forms.Form):
"""Шаг 2 — данные бренда (название, статус, ИНН, город, соцсети, деятельность)."""
brand_name = forms.CharField(label='Название бренда', max_length=200)
legal_status = forms.ChoiceField(
label='Правовой статус', choices=LegalStatus.choices,
)
inn = forms.CharField(label='ИНН', max_length=12, required=False)
city = forms.CharField(label='Город', max_length=120, required=False)
socials = forms.CharField(
label='Ссылки на соцсети (по одной в строке)',
widget=forms.Textarea(attrs={'rows': 3}), required=False,
)
story = forms.CharField(
label='Описание деятельности',
widget=forms.Textarea(attrs={'rows': 4}), max_length=3000, required=False,
)
def clean_inn(self):
inn = self.cleaned_data.get('inn', '').strip()
if inn and not inn.isdigit():
raise forms.ValidationError('ИНН должен содержать только цифры.')
return inn
class SellerStep3Form(forms.Form):
"""Шаг 3 — подтверждения и согласие на обработку ПДн."""
production_photo = forms.ImageField(
label='Фото производства', required=False,
)
statement_doc = forms.FileField(
label='Выписка/справка (PDF, JPG, PNG)', required=False,
)
pd_consent = forms.BooleanField(
label='Согласен(на) на обработку персональных данных',
required=True,
)
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-09-23 15:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='pd_consent',
field=models.BooleanField(default=False, verbose_name='Согласие на обработку ПДн'),
),
]
+3
View File
@@ -22,6 +22,9 @@ class User(AbstractUser):
phone = models.CharField('Телефон', max_length=32, blank=True)
birth_date = models.DateField('Дата рождения', null=True, blank=True)
region = models.CharField('Регион', max_length=120, blank=True)
pd_consent = models.BooleanField(
'Согласие на обработку ПДн', default=False,
)
class Meta:
verbose_name = 'Пользователь'
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = 'accounts'
urlpatterns = [
path('register/seller/', views.seller_register, name='seller_register'),
path('register/seller/done/', views.seller_register_done, name='seller_register_done'),
]
+113 -2
View File
@@ -1,3 +1,114 @@
from django.shortcuts import render
from datetime import date
# Create your views here.
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import redirect, render
from django.urls import reverse
from apps.accounts.forms import SellerStep1Form, SellerStep2Form, SellerStep3Form
from apps.accounts.models import Role, User
from apps.catalog.models import Brand, VerificationStatus
from apps.moderation.models import AuditLog, ModerationRequest
SESSION_KEY = 'seller_registration'
def _get_session_data(request):
return request.session.get(SESSION_KEY, {})
def seller_register(request):
"""Многошаговая регистрация продавца (FR-01)."""
step = int(request.GET.get('step', 1))
data = _get_session_data(request)
if step == 1:
form = SellerStep1Form(request.POST or None)
if request.method == 'POST' and form.is_valid():
cleaned = form.cleaned_data
# date нельзя сериализовать в сессию (JSON) — храним ISO-строку.
cleaned['birth_date'] = cleaned['birth_date'].isoformat()
data['step1'] = cleaned
request.session[SESSION_KEY] = data
return redirect(reverse('accounts:seller_register') + '?step=2')
return render(request, 'accounts/seller_register.html', {
'form': form, 'step': 1, 'total_steps': 3,
})
if step == 2:
if 'step1' not in data:
return redirect(reverse('accounts:seller_register'))
form = SellerStep2Form(request.POST or None)
if request.method == 'POST' and form.is_valid():
data['step2'] = form.cleaned_data
request.session[SESSION_KEY] = data
return redirect(reverse('accounts:seller_register') + '?step=3')
return render(request, 'accounts/seller_register.html', {
'form': form, 'step': 2, 'total_steps': 3,
})
if step == 3:
if 'step1' not in data or 'step2' not in data:
return redirect(reverse('accounts:seller_register'))
form = SellerStep3Form(request.POST or None, request.FILES or None)
if request.method == 'POST' and form.is_valid():
_create_seller(request, data, form.cleaned_data)
request.session.pop(SESSION_KEY, None)
return redirect(reverse('accounts:seller_register_done'))
return render(request, 'accounts/seller_register.html', {
'form': form, 'step': 3, 'total_steps': 3,
})
return redirect(reverse('accounts:seller_register'))
def _create_seller(request, data, step3):
s1 = data['step1']
s2 = data['step2']
user = User.objects.create_user(
username=s1['username'],
email=s1.get('email', ''),
password=s1['password1'],
role=Role.SELLER,
full_name=s1['full_name'],
birth_date=date.fromisoformat(s1['birth_date']),
region=s1['region'],
pd_consent=step3['pd_consent'],
)
socials = [
line.strip()
for line in s2.get('socials', '').splitlines()
if line.strip()
]
brand = Brand.objects.create(
user=user,
name=s2['brand_name'],
legal_status=s2['legal_status'],
inn=s2.get('inn', ''),
city=s2.get('city', ''),
region=s1['region'],
socials=socials,
story=s2.get('story', ''),
production_photo=step3.get('production_photo'),
statement_doc=step3.get('statement_doc'),
verification_status=VerificationStatus.PENDING,
)
ModerationRequest.objects.create(
content_type=ContentType.objects.get_for_model(Brand),
object_id=brand.pk,
decision='pending',
)
AuditLog.objects.create(
user=user,
action='seller_registered',
object_repr=f'Бренд «{brand.name}»',
)
return user, brand
def seller_register_done(request):
return render(request, 'accounts/seller_register_done.html')
@@ -0,0 +1,23 @@
# Generated by Django 5.1.15 on 2026-09-23 15:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('catalog', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='brand',
name='production_photo',
field=models.ImageField(blank=True, null=True, upload_to='brands/verification/', verbose_name='Фото производства'),
),
migrations.AddField(
model_name='brand',
name='statement_doc',
field=models.FileField(blank=True, null=True, upload_to='brands/verification/', verbose_name='Выписка/справка'),
),
]
+8
View File
@@ -83,6 +83,14 @@ class Brand(models.Model):
'Статус верификации', max_length=20,
choices=VerificationStatus.choices, default=VerificationStatus.PENDING,
)
production_photo = models.ImageField(
'Фото производства', upload_to='brands/verification/',
blank=True, null=True,
)
statement_doc = models.FileField(
'Выписка/справка', upload_to='brands/verification/',
blank=True, null=True,
)
badges = models.JSONField('Знаки', default=list, blank=True)
rating = models.DecimalField('Рейтинг', max_digits=3, decimal_places=2, default=0)
+1
View File
@@ -6,6 +6,7 @@ from django.urls import include, path
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('apps.catalog.urls')),
path('accounts/', include('apps.accounts.urls')),
]
if settings.DEBUG:
+48 -6
View File
@@ -2,10 +2,14 @@
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-text: #111827;
--color-muted: #6b7280;
--color-primary: #e11d48;
--color-primary-dark: #be123c;
--color-primary: #0e9f6e;
--color-primary-dark: #0b7a55;
--color-accent: #f59e0b;
--color-success: #16a34a;
--color-warning: #f59e0b;
--color-danger: #dc2626;
--color-border: #e5e7eb;
--radius: 12px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
@@ -83,8 +87,8 @@ body {
display: block;
padding: 24px;
border-radius: var(--radius);
background: linear-gradient(135deg, #fecdd3, #fda4af);
color: #881337;
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
color: #065f46;
text-decoration: none;
font-weight: 600;
}
@@ -138,4 +142,42 @@ body {
@media (min-width: 768px) {
.hero h1 { font-size: 40px; }
.banners { grid-template-columns: repeat(3, 1fr); }
}
}
/* Форма регистрации продавца */
.step-indicator {
color: var(--color-muted);
font-size: 14px;
margin: 8px 0 16px;
}
.reg-form {
max-width: 560px;
display: grid;
gap: 14px;
margin: 16px 0 32px;
}
.reg-form label { font-weight: 600; font-size: 14px; }
.reg-form input,
.reg-form select,
.reg-form textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 15px;
font-family: inherit;
}
.reg-form .helptext { color: var(--color-muted); font-size: 13px; }
.reg-form .errorlist { color: var(--color-danger); font-size: 13px; margin: 4px 0 0; padding-left: 18px; }
.reg-actions {
display: flex;
gap: 12px;
align-items: center;
margin-top: 8px;
}
.btn-ghost {
background: transparent;
color: var(--color-primary);
border: 1px solid var(--color-primary);
}
.btn-ghost:hover { background: #f0fdf4; color: var(--color-primary-dark); }
+19
View File
@@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% block title %}Регистрация продавца{% endblock %}
{% block content %}
<h1>Регистрация продавца</h1>
<p class="step-indicator">Шаг {{ step }} из {{ total_steps }}</p>
<form method="post" enctype="multipart/form-data" class="reg-form">
{% csrf_token %}
{{ form.as_p }}
<div class="reg-actions">
{% if step > 1 %}
<a class="btn btn-ghost" href="?step={{ step|add:"-1" }}">Назад</a>
{% endif %}
<button class="btn" type="submit">
{% if step == total_steps %}Отправить на модерацию{% else %}Далее{% endif %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends 'base.html' %}
{% block title %}Заявка отправлена{% endblock %}
{% block content %}
<h1>Заявка отправлена</h1>
<p>Ваша заявка на участие принята и находится на проверке модератором.
Статус можно отслеживать в личном кабинете.</p>
<a class="btn" href="{% url 'catalog:home' %}">На главную</a>
{% endblock %}
+1
View File
@@ -13,6 +13,7 @@
<a class="logo" href="{% url 'catalog:home' %}">Создано&nbsp;молодыми</a>
<nav class="nav">
<a href="{% url 'catalog:catalog' %}">Каталог</a>
<a href="{% url 'accounts:seller_register' %}">Стать продавцом</a>
{% if user.is_authenticated %}
<span class="nav-user">{{ user.full_name|default:user.username }}</span>
<a href="{% url 'admin:index' %}">Админка</a>