diff --git a/apps/accounts/forms.py b/apps/accounts/forms.py new file mode 100644 index 0000000..467f106 --- /dev/null +++ b/apps/accounts/forms.py @@ -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, + ) \ No newline at end of file diff --git a/apps/accounts/migrations/0002_user_pd_consent.py b/apps/accounts/migrations/0002_user_pd_consent.py new file mode 100644 index 0000000..456a759 --- /dev/null +++ b/apps/accounts/migrations/0002_user_pd_consent.py @@ -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='Согласие на обработку ПДн'), + ), + ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 430c853..07bbc42 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -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 = 'Пользователь' diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py new file mode 100644 index 0000000..96b661f --- /dev/null +++ b/apps/accounts/urls.py @@ -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'), +] \ No newline at end of file diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 91ea44a..3ea5a2c 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -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') \ No newline at end of file diff --git a/apps/catalog/migrations/0002_brand_production_photo_brand_statement_doc.py b/apps/catalog/migrations/0002_brand_production_photo_brand_statement_doc.py new file mode 100644 index 0000000..88ba118 --- /dev/null +++ b/apps/catalog/migrations/0002_brand_production_photo_brand_statement_doc.py @@ -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='Выписка/справка'), + ), + ] diff --git a/apps/catalog/models.py b/apps/catalog/models.py index 851fd22..2ff2114 100644 --- a/apps/catalog/models.py +++ b/apps/catalog/models.py @@ -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) diff --git a/config/urls.py b/config/urls.py index 9752efc..1c2e218 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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: diff --git a/static/css/style.css b/static/css/style.css index 7f5f286..2195667 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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); } -} \ No newline at end of file +} + +/* Форма регистрации продавца */ +.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); } \ No newline at end of file diff --git a/templates/accounts/seller_register.html b/templates/accounts/seller_register.html new file mode 100644 index 0000000..dbd5191 --- /dev/null +++ b/templates/accounts/seller_register.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} +{% block title %}Регистрация продавца{% endblock %} +{% block content %} +
Шаг {{ step }} из {{ total_steps }}
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/accounts/seller_register_done.html b/templates/accounts/seller_register_done.html new file mode 100644 index 0000000..483ee30 --- /dev/null +++ b/templates/accounts/seller_register_done.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %} +{% block title %}Заявка отправлена{% endblock %} +{% block content %} +Ваша заявка на участие принята и находится на проверке модератором. + Статус можно отслеживать в личном кабинете.
+ На главную +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 947b1f3..e3a3ab4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -13,6 +13,7 @@ Создано молодыми