feat: регистрация продавца (FR-01) + палитра B (зелёный/янтарь)
This commit is contained in:
1 parent
43ac518fc2
commit
c681c724a5
12 files changed
+335
-8
No files matched your search
@@ -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='Согласие на обработку ПДн'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -22,6 +22,9 @@ class User(AbstractUser):
|
|||||||
phone = models.CharField('Телефон', max_length=32, blank=True)
|
phone = models.CharField('Телефон', max_length=32, blank=True)
|
||||||
birth_date = models.DateField('Дата рождения', null=True, blank=True)
|
birth_date = models.DateField('Дата рождения', null=True, blank=True)
|
||||||
region = models.CharField('Регион', max_length=120, blank=True)
|
region = models.CharField('Регион', max_length=120, blank=True)
|
||||||
|
pd_consent = models.BooleanField(
|
||||||
|
'Согласие на обработку ПДн', default=False,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Пользователь'
|
verbose_name = 'Пользователь'
|
||||||
|
|||||||
@@ -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
@@ -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='Выписка/справка'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -83,6 +83,14 @@ class Brand(models.Model):
|
|||||||
'Статус верификации', max_length=20,
|
'Статус верификации', max_length=20,
|
||||||
choices=VerificationStatus.choices, default=VerificationStatus.PENDING,
|
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)
|
badges = models.JSONField('Знаки', default=list, blank=True)
|
||||||
rating = models.DecimalField('Рейтинг', max_digits=3, decimal_places=2, default=0)
|
rating = models.DecimalField('Рейтинг', max_digits=3, decimal_places=2, default=0)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.urls import include, path
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('', include('apps.catalog.urls')),
|
path('', include('apps.catalog.urls')),
|
||||||
|
path('accounts/', include('apps.accounts.urls')),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
+48
-6
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--color-bg: #ffffff;
|
--color-bg: #ffffff;
|
||||||
--color-text: #1a1a1a;
|
--color-text: #111827;
|
||||||
--color-muted: #6b7280;
|
--color-muted: #6b7280;
|
||||||
--color-primary: #e11d48;
|
--color-primary: #0e9f6e;
|
||||||
--color-primary-dark: #be123c;
|
--color-primary-dark: #0b7a55;
|
||||||
|
--color-accent: #f59e0b;
|
||||||
|
--color-success: #16a34a;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-danger: #dc2626;
|
||||||
--color-border: #e5e7eb;
|
--color-border: #e5e7eb;
|
||||||
--radius: 12px;
|
--radius: 12px;
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
@@ -83,8 +87,8 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: linear-gradient(135deg, #fecdd3, #fda4af);
|
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
|
||||||
color: #881337;
|
color: #065f46;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -138,4 +142,42 @@ body {
|
|||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.hero h1 { font-size: 40px; }
|
.hero h1 { font-size: 40px; }
|
||||||
.banners { grid-template-columns: repeat(3, 1fr); }
|
.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); }
|
||||||
@@ -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 %}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<a class="logo" href="{% url 'catalog:home' %}">Создано молодыми</a>
|
<a class="logo" href="{% url 'catalog:home' %}">Создано молодыми</a>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="{% url 'catalog:catalog' %}">Каталог</a>
|
<a href="{% url 'catalog:catalog' %}">Каталог</a>
|
||||||
|
<a href="{% url 'accounts:seller_register' %}">Стать продавцом</a>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<span class="nav-user">{{ user.full_name|default:user.username }}</span>
|
<span class="nav-user">{{ user.full_name|default:user.username }}</span>
|
||||||
<a href="{% url 'admin:index' %}">Админка</a>
|
<a href="{% url 'admin:index' %}">Админка</a>
|
||||||
|
|||||||
Reference in new issue
Block a user