diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b84bfd5 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Скопируйте этот файл в .env и заполните значения. +# .env в репозиторий не коммитится. + +DJANGO_SECRET_KEY=change-me-to-a-long-random-string +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS= + +# База данных: sqlite (локально, по умолчанию) или mysql (прод, по ТЗ) +DB_ENGINE=sqlite + +# Параметры для DB_ENGINE=mysql +DB_NAME=rosmol_shop +DB_USER=root +DB_PASSWORD= +DB_HOST=127.0.0.1 +DB_PORT=3306 diff --git a/.gitignore b/.gitignore index bf3f450..781526d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,15 @@ env/ # Environment / secrets .env .env.* +!.env.example *.local +# Django +db.sqlite3 +db.sqlite3-journal +/media/ +/staticfiles/ + # Logs / temp *.log *.tmp diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/__init__.py b/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py new file mode 100644 index 0000000..35b457f --- /dev/null +++ b/apps/accounts/admin.py @@ -0,0 +1,14 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from .models import User + + +@admin.register(User) +class CustomUserAdmin(UserAdmin): + list_display = ('username', 'full_name', 'role', 'phone', 'region', 'is_staff') + list_filter = ('role', 'is_staff', 'is_active') + search_fields = ('username', 'full_name', 'phone', 'email') + fieldsets = UserAdmin.fieldsets + ( + ('Профиль', {'fields': ('role', 'full_name', 'phone', 'birth_date', 'region')}), + ) \ No newline at end of file diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py new file mode 100644 index 0000000..35886d4 --- /dev/null +++ b/apps/accounts/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.accounts' + diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..37b0dac --- /dev/null +++ b/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 5.1.15 on 2026-09-23 13:44 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('role', models.CharField(choices=[('buyer', 'Покупатель'), ('seller', 'Продавец'), ('moderator', 'Модератор'), ('admin', 'Администратор'), ('partner', 'Партнёр (Росмолодёжь)')], default='buyer', max_length=20, verbose_name='Роль')), + ('full_name', models.CharField(blank=True, max_length=255, verbose_name='ФИО')), + ('phone', models.CharField(blank=True, max_length=32, verbose_name='Телефон')), + ('birth_date', models.DateField(blank=True, null=True, verbose_name='Дата рождения')), + ('region', models.CharField(blank=True, max_length=120, verbose_name='Регион')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'Пользователь', + 'verbose_name_plural': 'Пользователи', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/apps/accounts/migrations/__init__.py b/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/models.py b/apps/accounts/models.py new file mode 100644 index 0000000..430c853 --- /dev/null +++ b/apps/accounts/models.py @@ -0,0 +1,49 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + + +class Role(models.TextChoices): + """Роли пользователей (ТЗ §2).""" + + BUYER = 'buyer', 'Покупатель' + SELLER = 'seller', 'Продавец' + MODERATOR = 'moderator', 'Модератор' + ADMIN = 'admin', 'Администратор' + PARTNER = 'partner', 'Партнёр (Росмолодёжь)' + + +class User(AbstractUser): + """Пользователь площадки (ТЗ §4: User).""" + + role = models.CharField( + 'Роль', max_length=20, choices=Role.choices, default=Role.BUYER, + ) + full_name = models.CharField('ФИО', max_length=255, blank=True) + phone = models.CharField('Телефон', max_length=32, blank=True) + birth_date = models.DateField('Дата рождения', null=True, blank=True) + region = models.CharField('Регион', max_length=120, blank=True) + + class Meta: + verbose_name = 'Пользователь' + verbose_name_plural = 'Пользователи' + + def __str__(self): + return self.full_name or self.username + + @property + def is_seller(self): + return self.role == Role.SELLER + + @property + def age(self): + """Возвращает возраст в годах или None, если дата не указана.""" + from datetime import date + + if not self.birth_date: + return None + today = date.today() + return ( + today.year + - self.birth_date.year + - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day)) + ) diff --git a/apps/accounts/tests.py b/apps/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/accounts/views.py b/apps/accounts/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/accounts/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/catalog/__init__.py b/apps/catalog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/catalog/admin.py b/apps/catalog/admin.py new file mode 100644 index 0000000..b4b5d2b --- /dev/null +++ b/apps/catalog/admin.py @@ -0,0 +1,52 @@ +from django.contrib import admin + +from .models import Brand, Category, Product, ProductImage, Service, TimeSlot + + +class ProductImageInline(admin.TabularInline): + model = ProductImage + extra = 1 + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'type', 'parent', 'sort_order') + list_filter = ('type',) + prepopulated_fields = {'slug': ('name',)} + search_fields = ('name',) + + +@admin.register(Brand) +class BrandAdmin(admin.ModelAdmin): + list_display = ('name', 'user', 'legal_status', 'verification_status', 'rating') + list_filter = ('verification_status', 'legal_status') + search_fields = ('name', 'inn', 'city') + prepopulated_fields = {'slug': ('name',)} + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ('name', 'brand', 'category', 'price', 'stock', 'moderation_status', 'is_active') + list_filter = ('moderation_status', 'is_active', 'category') + search_fields = ('name', 'brand__name') + prepopulated_fields = {'slug': ('name',)} + inlines = [ProductImageInline] + + +@admin.register(ProductImage) +class ProductImageAdmin(admin.ModelAdmin): + list_display = ('product', 'sort_order') + + +@admin.register(Service) +class ServiceAdmin(admin.ModelAdmin): + list_display = ('name', 'brand', 'format', 'duration_minutes', 'price', 'moderation_status') + list_filter = ('format', 'moderation_status') + search_fields = ('name', 'brand__name') + prepopulated_fields = {'slug': ('name',)} + + +@admin.register(TimeSlot) +class TimeSlotAdmin(admin.ModelAdmin): + list_display = ('service', 'start', 'end', 'is_booked') + list_filter = ('is_booked',) \ No newline at end of file diff --git a/apps/catalog/apps.py b/apps/catalog/apps.py new file mode 100644 index 0000000..84a65f7 --- /dev/null +++ b/apps/catalog/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CatalogConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.catalog' + diff --git a/apps/catalog/migrations/0001_initial.py b/apps/catalog/migrations/0001_initial.py new file mode 100644 index 0000000..da80264 --- /dev/null +++ b/apps/catalog/migrations/0001_initial.py @@ -0,0 +1,133 @@ +# Generated by Django 5.1.15 on 2026-09-23 13:44 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Brand', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название бренда')), + ('slug', models.SlugField(blank=True, max_length=220, unique=True, verbose_name='Slug')), + ('logo', models.ImageField(blank=True, null=True, upload_to='brands/logos/', verbose_name='Логотип')), + ('cover', models.ImageField(blank=True, null=True, upload_to='brands/covers/', verbose_name='Обложка')), + ('story', models.TextField(blank=True, max_length=3000, verbose_name='История бренда')), + ('legal_status', models.CharField(choices=[('self_employed', 'Самозанятый'), ('ip', 'ИП'), ('legal', 'Юрлицо')], default='self_employed', max_length=20, verbose_name='Правовой статус')), + ('inn', models.CharField(blank=True, max_length=12, verbose_name='ИНН')), + ('region', models.CharField(blank=True, max_length=120, verbose_name='Регион')), + ('city', models.CharField(blank=True, max_length=120, verbose_name='Город')), + ('socials', models.JSONField(blank=True, default=dict, verbose_name='Соцсети')), + ('verification_status', models.CharField(choices=[('pending', 'На проверке'), ('approved', 'Одобрен'), ('revision', 'На доработке'), ('rejected', 'Отклонён')], default='pending', max_length=20, verbose_name='Статус верификации')), + ('badges', models.JSONField(blank=True, default=list, verbose_name='Знаки')), + ('rating', models.DecimalField(decimal_places=2, default=0, max_digits=3, verbose_name='Рейтинг')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='brand', to=settings.AUTH_USER_MODEL, verbose_name='Владелец')), + ], + options={ + 'verbose_name': 'Бренд', + 'verbose_name_plural': 'Бренды', + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название')), + ('slug', models.SlugField(blank=True, max_length=220, unique=True, verbose_name='Slug')), + ('type', models.CharField(choices=[('product', 'Товар'), ('service', 'Услуга')], default='product', max_length=10, verbose_name='Тип')), + ('sort_order', models.PositiveIntegerField(default=0, verbose_name='Порядок сортировки')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='catalog.category', verbose_name='Родитель')), + ], + options={ + 'verbose_name': 'Категория', + 'verbose_name_plural': 'Категории', + 'ordering': ('sort_order', 'name'), + }, + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Название')), + ('slug', models.SlugField(blank=True, max_length=280, unique=True, verbose_name='Slug')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Цена')), + ('attributes', models.JSONField(blank=True, default=dict, verbose_name='Характеристики')), + ('stock', models.PositiveIntegerField(default=0, verbose_name='Остаток')), + ('lead_time_days', models.PositiveIntegerField(default=0, verbose_name='Срок изготовления, дней')), + ('moderation_status', models.CharField(choices=[('draft', 'Черновик'), ('pending', 'На модерации'), ('published', 'Опубликовано'), ('rejected', 'Отклонено')], default='draft', max_length=20, verbose_name='Статус модерации')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('brand', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='catalog.brand', verbose_name='Бренд')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='catalog.category', verbose_name='Категория')), + ], + options={ + 'verbose_name': 'Товар', + 'verbose_name_plural': 'Товары', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='ProductImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='products/', verbose_name='Изображение')), + ('sort_order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='catalog.product', verbose_name='Товар')), + ], + options={ + 'verbose_name': 'Изображение товара', + 'verbose_name_plural': 'Изображения товаров', + 'ordering': ('sort_order', 'id'), + }, + ), + migrations.CreateModel( + name='Service', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Название')), + ('slug', models.SlugField(blank=True, max_length=280, unique=True, verbose_name='Slug')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('format', models.CharField(choices=[('online', 'Онлайн'), ('offline', 'Офлайн')], default='offline', max_length=10, verbose_name='Формат')), + ('duration_minutes', models.PositiveIntegerField(default=60, verbose_name='Длительность, мин')), + ('price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Цена')), + ('moderation_status', models.CharField(choices=[('draft', 'Черновик'), ('pending', 'На модерации'), ('published', 'Опубликовано'), ('rejected', 'Отклонено')], default='draft', max_length=20, verbose_name='Статус модерации')), + ('is_active', models.BooleanField(default=True, verbose_name='Активна')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('brand', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='catalog.brand', verbose_name='Бренд')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='services', to='catalog.category', verbose_name='Категория')), + ], + options={ + 'verbose_name': 'Услуга', + 'verbose_name_plural': 'Услуги', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='TimeSlot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start', models.DateTimeField(verbose_name='Начало')), + ('end', models.DateTimeField(verbose_name='Окончание')), + ('is_booked', models.BooleanField(default=False, verbose_name='Занят')), + ('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='catalog.service', verbose_name='Услуга')), + ], + options={ + 'verbose_name': 'Слот записи', + 'verbose_name_plural': 'Слоты записи', + 'ordering': ('start',), + }, + ), + ] diff --git a/apps/catalog/migrations/__init__.py b/apps/catalog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/catalog/models.py b/apps/catalog/models.py new file mode 100644 index 0000000..851fd22 --- /dev/null +++ b/apps/catalog/models.py @@ -0,0 +1,232 @@ +from django.conf import settings +from django.db import models +from django.utils.text import slugify + + +class CategoryType(models.TextChoices): + PRODUCT = 'product', 'Товар' + SERVICE = 'service', 'Услуга' + + +class ModerationStatus(models.TextChoices): + DRAFT = 'draft', 'Черновик' + PENDING = 'pending', 'На модерации' + PUBLISHED = 'published', 'Опубликовано' + REJECTED = 'rejected', 'Отклонено' + + +class VerificationStatus(models.TextChoices): + PENDING = 'pending', 'На проверке' + APPROVED = 'approved', 'Одобрен' + REVISION = 'revision', 'На доработке' + REJECTED = 'rejected', 'Отклонён' + + +class LegalStatus(models.TextChoices): + SELF_EMPLOYED = 'self_employed', 'Самозанятый' + IP = 'ip', 'ИП' + LEGAL_ENTITY = 'legal', 'Юрлицо' + + +class Category(models.Model): + """Двухуровневый справочник категорий (ТЗ §3.2).""" + + parent = models.ForeignKey( + 'self', null=True, blank=True, on_delete=models.CASCADE, + related_name='children', verbose_name='Родитель', + ) + name = models.CharField('Название', max_length=200) + slug = models.SlugField('Slug', max_length=220, unique=True, blank=True) + type = models.CharField( + 'Тип', max_length=10, choices=CategoryType.choices, + default=CategoryType.PRODUCT, + ) + sort_order = models.PositiveIntegerField('Порядок сортировки', default=0) + + class Meta: + verbose_name = 'Категория' + verbose_name_plural = 'Категории' + ordering = ('sort_order', 'name') + + def __str__(self): + return f'{self.parent.name} / {self.name}' if self.parent else self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name, allow_unicode=True) + super().save(*args, **kwargs) + + +class Brand(models.Model): + """Профиль продавца — «Бренд» (ТЗ §4: Brand / SellerProfile).""" + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name='brand', verbose_name='Владелец', + ) + name = models.CharField('Название бренда', max_length=200) + slug = models.SlugField('Slug', max_length=220, unique=True, blank=True) + logo = models.ImageField('Логотип', upload_to='brands/logos/', blank=True, null=True) + cover = models.ImageField('Обложка', upload_to='brands/covers/', blank=True, null=True) + story = models.TextField('История бренда', max_length=3000, blank=True) + + legal_status = models.CharField( + 'Правовой статус', max_length=20, + choices=LegalStatus.choices, default=LegalStatus.SELF_EMPLOYED, + ) + inn = models.CharField('ИНН', max_length=12, blank=True) + region = models.CharField('Регион', max_length=120, blank=True) + city = models.CharField('Город', max_length=120, blank=True) + socials = models.JSONField('Соцсети', default=dict, blank=True) + + verification_status = models.CharField( + 'Статус верификации', max_length=20, + choices=VerificationStatus.choices, default=VerificationStatus.PENDING, + ) + badges = models.JSONField('Знаки', default=list, blank=True) + rating = models.DecimalField('Рейтинг', max_digits=3, decimal_places=2, default=0) + + created_at = models.DateTimeField('Дата создания', auto_now_add=True) + + class Meta: + verbose_name = 'Бренд' + verbose_name_plural = 'Бренды' + ordering = ('name',) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name, allow_unicode=True) + super().save(*args, **kwargs) + + +class Product(models.Model): + """Карточка товара (ТЗ §4: Product).""" + + brand = models.ForeignKey( + Brand, on_delete=models.CASCADE, related_name='products', + verbose_name='Бренд', + ) + category = models.ForeignKey( + Category, on_delete=models.PROTECT, related_name='products', + verbose_name='Категория', + ) + name = models.CharField('Название', max_length=255) + slug = models.SlugField('Slug', max_length=280, unique=True, blank=True) + description = models.TextField('Описание', blank=True) + price = models.DecimalField('Цена', max_digits=12, decimal_places=2) + attributes = models.JSONField('Характеристики', default=dict, blank=True) + stock = models.PositiveIntegerField('Остаток', default=0) + lead_time_days = models.PositiveIntegerField('Срок изготовления, дней', default=0) + + moderation_status = models.CharField( + 'Статус модерации', max_length=20, + choices=ModerationStatus.choices, default=ModerationStatus.DRAFT, + ) + is_active = models.BooleanField('Активен', default=True) + created_at = models.DateTimeField('Дата создания', auto_now_add=True) + + class Meta: + verbose_name = 'Товар' + verbose_name_plural = 'Товары' + ordering = ('-created_at',) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name, allow_unicode=True) + super().save(*args, **kwargs) + + @property + def is_available(self): + return self.is_active and self.stock > 0 + + +class ProductImage(models.Model): + """Изображение товара (галерея до 10 фото, ТЗ §3.2).""" + + product = models.ForeignKey( + Product, on_delete=models.CASCADE, related_name='images', + verbose_name='Товар', + ) + image = models.ImageField('Изображение', upload_to='products/') + sort_order = models.PositiveIntegerField('Порядок', default=0) + + class Meta: + verbose_name = 'Изображение товара' + verbose_name_plural = 'Изображения товаров' + ordering = ('sort_order', 'id') + + def __str__(self): + return f'Фото #{self.pk} для {self.product}' + + +class ServiceFormat(models.TextChoices): + ONLINE = 'online', 'Онлайн' + OFFLINE = 'offline', 'Офлайн' + + +class Service(models.Model): + """Карточка услуги (ТЗ §4: Service).""" + + brand = models.ForeignKey( + Brand, on_delete=models.CASCADE, related_name='services', + verbose_name='Бренд', + ) + category = models.ForeignKey( + Category, on_delete=models.PROTECT, related_name='services', + verbose_name='Категория', + ) + name = models.CharField('Название', max_length=255) + slug = models.SlugField('Slug', max_length=280, unique=True, blank=True) + description = models.TextField('Описание', blank=True) + format = models.CharField( + 'Формат', max_length=10, choices=ServiceFormat.choices, + default=ServiceFormat.OFFLINE, + ) + duration_minutes = models.PositiveIntegerField('Длительность, мин', default=60) + price = models.DecimalField('Цена', max_digits=12, decimal_places=2) + + moderation_status = models.CharField( + 'Статус модерации', max_length=20, + choices=ModerationStatus.choices, default=ModerationStatus.DRAFT, + ) + is_active = models.BooleanField('Активна', default=True) + created_at = models.DateTimeField('Дата создания', auto_now_add=True) + + class Meta: + verbose_name = 'Услуга' + verbose_name_plural = 'Услуги' + ordering = ('-created_at',) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name, allow_unicode=True) + super().save(*args, **kwargs) + + +class TimeSlot(models.Model): + """Слот записи на услугу (ТЗ §3.4).""" + + service = models.ForeignKey( + Service, on_delete=models.CASCADE, related_name='slots', + verbose_name='Услуга', + ) + start = models.DateTimeField('Начало') + end = models.DateTimeField('Окончание') + is_booked = models.BooleanField('Занят', default=False) + + class Meta: + verbose_name = 'Слот записи' + verbose_name_plural = 'Слоты записи' + ordering = ('start',) + + def __str__(self): + return f'{self.service} — {self.start:%d.%m.%Y %H:%M}' diff --git a/apps/catalog/tests.py b/apps/catalog/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/catalog/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/catalog/urls.py b/apps/catalog/urls.py new file mode 100644 index 0000000..708c794 --- /dev/null +++ b/apps/catalog/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +app_name = 'catalog' + +urlpatterns = [ + path('', views.home, name='home'), + path('catalog/', views.catalog, name='catalog'), + path('product//', views.product_detail, name='product_detail'), + path('brand//', views.brand_detail, name='brand_detail'), +] \ No newline at end of file diff --git a/apps/catalog/views.py b/apps/catalog/views.py new file mode 100644 index 0000000..0b7db79 --- /dev/null +++ b/apps/catalog/views.py @@ -0,0 +1,58 @@ +from django.shortcuts import get_object_or_404, render + +from apps.catalog.models import Brand, Category, ModerationStatus, Product +from apps.content.models import Banner, Collection + +PUBLISHED = ModerationStatus.PUBLISHED + + +def home(request): + banners = Banner.objects.filter(is_active=True) + collections = Collection.objects.filter(is_published=True) + products = Product.objects.filter( + moderation_status=PUBLISHED, is_active=True, + )[:8] + return render(request, 'catalog/home.html', { + 'banners': banners, + 'collections': collections, + 'products': products, + }) + + +def catalog(request): + qs = Product.objects.filter(moderation_status=PUBLISHED, is_active=True) + + current_category = None + cat_slug = request.GET.get('category') + if cat_slug: + current_category = get_object_or_404(Category, slug=cat_slug) + qs = qs.filter(category=current_category) + + q = request.GET.get('q', '').strip() + if q: + qs = qs.filter(name__icontains=q) + + categories = Category.objects.filter(parent__isnull=True) + + return render(request, 'catalog/catalog.html', { + 'products': qs, + 'categories': categories, + 'current_category': current_category, + 'q': q, + }) + + +def product_detail(request, slug): + product = get_object_or_404( + Product, slug=slug, moderation_status=PUBLISHED, is_active=True, + ) + return render(request, 'catalog/product_detail.html', {'product': product}) + + +def brand_detail(request, slug): + brand = get_object_or_404(Brand, slug=slug, verification_status='approved') + products = brand.products.filter(moderation_status=PUBLISHED, is_active=True) + return render(request, 'catalog/brand_detail.html', { + 'brand': brand, + 'products': products, + }) \ No newline at end of file diff --git a/apps/content/__init__.py b/apps/content/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/content/admin.py b/apps/content/admin.py new file mode 100644 index 0000000..c54fc6f --- /dev/null +++ b/apps/content/admin.py @@ -0,0 +1,31 @@ +from django.contrib import admin + +from .models import Article, Banner, Collection, NewsEvent + + +@admin.register(Collection) +class CollectionAdmin(admin.ModelAdmin): + list_display = ('name', 'is_published', 'published_at') + list_filter = ('is_published',) + filter_horizontal = ('products',) + prepopulated_fields = {'slug': ('name',)} + + +@admin.register(Article) +class ArticleAdmin(admin.ModelAdmin): + list_display = ('title', 'brand', 'published_at') + search_fields = ('title',) + prepopulated_fields = {'slug': ('title',)} + + +@admin.register(NewsEvent) +class NewsEventAdmin(admin.ModelAdmin): + list_display = ('title', 'event_date', 'is_published', 'published_at') + list_filter = ('is_published',) + prepopulated_fields = {'slug': ('title',)} + + +@admin.register(Banner) +class BannerAdmin(admin.ModelAdmin): + list_display = ('title', 'url', 'sort_order', 'is_active') + list_filter = ('is_active',) \ No newline at end of file diff --git a/apps/content/apps.py b/apps/content/apps.py new file mode 100644 index 0000000..d074028 --- /dev/null +++ b/apps/content/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ContentConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.content' + diff --git a/apps/content/migrations/0001_initial.py b/apps/content/migrations/0001_initial.py new file mode 100644 index 0000000..229b1fd --- /dev/null +++ b/apps/content/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# Generated by Django 5.1.15 on 2026-09-23 13:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('catalog', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Banner', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(blank=True, max_length=200, verbose_name='Заголовок')), + ('image', models.ImageField(upload_to='banners/', verbose_name='Изображение')), + ('url', models.URLField(blank=True, verbose_name='Ссылка')), + ('sort_order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ], + options={ + 'verbose_name': 'Баннер', + 'verbose_name_plural': 'Баннеры', + 'ordering': ('sort_order',), + }, + ), + migrations.CreateModel( + name='NewsEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Заголовок')), + ('slug', models.SlugField(blank=True, max_length=280, unique=True, verbose_name='Slug')), + ('body', models.TextField(verbose_name='Текст')), + ('image', models.ImageField(blank=True, null=True, upload_to='news/', verbose_name='Изображение')), + ('event_date', models.DateTimeField(blank=True, null=True, verbose_name='Дата события')), + ('is_published', models.BooleanField(default=False, verbose_name='Опубликовано')), + ('published_at', models.DateTimeField(blank=True, null=True, verbose_name='Опубликовано')), + ], + options={ + 'verbose_name': 'Новость / событие', + 'verbose_name_plural': 'Новости и события', + 'ordering': ('-published_at',), + }, + ), + migrations.CreateModel( + name='Article', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Заголовок')), + ('slug', models.SlugField(blank=True, max_length=280, unique=True, verbose_name='Slug')), + ('cover', models.ImageField(blank=True, null=True, upload_to='articles/', verbose_name='Обложка')), + ('body', models.TextField(verbose_name='Текст')), + ('published_at', models.DateTimeField(blank=True, null=True, verbose_name='Опубликована')), + ('brand', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='articles', to='catalog.brand', verbose_name='Бренд')), + ], + options={ + 'verbose_name': 'История бренда', + 'verbose_name_plural': 'Истории брендов', + 'ordering': ('-published_at',), + }, + ), + migrations.CreateModel( + name='Collection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название')), + ('slug', models.SlugField(blank=True, max_length=220, unique=True, verbose_name='Slug')), + ('banner', models.ImageField(blank=True, null=True, upload_to='collections/', verbose_name='Баннер')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('is_published', models.BooleanField(default=False, verbose_name='Опубликована')), + ('published_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата публикации')), + ('products', models.ManyToManyField(blank=True, related_name='collections', to='catalog.product', verbose_name='Позиции')), + ], + options={ + 'verbose_name': 'Подборка', + 'verbose_name_plural': 'Подборки', + 'ordering': ('name',), + }, + ), + ] diff --git a/apps/content/migrations/__init__.py b/apps/content/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/content/models.py b/apps/content/models.py new file mode 100644 index 0000000..b5fdf5f --- /dev/null +++ b/apps/content/models.py @@ -0,0 +1,101 @@ +from django.db import models +from django.utils.text import slugify + +from apps.catalog.models import Brand, Product + + +class Collection(models.Model): + """Подборка позиций (ТЗ §3.7, §4: Collection).""" + + name = models.CharField('Название', max_length=200) + slug = models.SlugField('Slug', max_length=220, unique=True, blank=True) + banner = models.ImageField('Баннер', upload_to='collections/', blank=True, null=True) + description = models.TextField('Описание', blank=True) + products = models.ManyToManyField( + Product, related_name='collections', blank=True, verbose_name='Позиции', + ) + is_published = models.BooleanField('Опубликована', default=False) + published_at = models.DateTimeField('Дата публикации', null=True, blank=True) + + class Meta: + verbose_name = 'Подборка' + verbose_name_plural = 'Подборки' + ordering = ('name',) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name, allow_unicode=True) + super().save(*args, **kwargs) + + +class Article(models.Model): + """Раздел «Истории брендов» (ТЗ §3.7).""" + + brand = models.ForeignKey( + Brand, null=True, blank=True, on_delete=models.SET_NULL, + related_name='articles', verbose_name='Бренд', + ) + title = models.CharField('Заголовок', max_length=255) + slug = models.SlugField('Slug', max_length=280, unique=True, blank=True) + cover = models.ImageField('Обложка', upload_to='articles/', blank=True, null=True) + body = models.TextField('Текст') + published_at = models.DateTimeField('Опубликована', null=True, blank=True) + + class Meta: + verbose_name = 'История бренда' + verbose_name_plural = 'Истории брендов' + ordering = ('-published_at',) + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title, allow_unicode=True) + super().save(*args, **kwargs) + + +class NewsEvent(models.Model): + """Новости проекта и анонсы офлайн-ярмарок (ТЗ §3.7).""" + + title = models.CharField('Заголовок', max_length=255) + slug = models.SlugField('Slug', max_length=280, unique=True, blank=True) + body = models.TextField('Текст') + image = models.ImageField('Изображение', upload_to='news/', blank=True, null=True) + event_date = models.DateTimeField('Дата события', null=True, blank=True) + is_published = models.BooleanField('Опубликовано', default=False) + published_at = models.DateTimeField('Опубликовано', null=True, blank=True) + + class Meta: + verbose_name = 'Новость / событие' + verbose_name_plural = 'Новости и события' + ordering = ('-published_at',) + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title, allow_unicode=True) + super().save(*args, **kwargs) + + +class Banner(models.Model): + """Баннерная зона на главной (ТЗ §3.7).""" + + title = models.CharField('Заголовок', max_length=200, blank=True) + image = models.ImageField('Изображение', upload_to='banners/') + url = models.URLField('Ссылка', blank=True) + sort_order = models.PositiveIntegerField('Порядок', default=0) + is_active = models.BooleanField('Активен', default=True) + + class Meta: + verbose_name = 'Баннер' + verbose_name_plural = 'Баннеры' + ordering = ('sort_order',) + + def __str__(self): + return self.title or f'Баннер #{self.pk}' \ No newline at end of file diff --git a/apps/content/tests.py b/apps/content/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/content/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/content/views.py b/apps/content/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/content/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/moderation/__init__.py b/apps/moderation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/moderation/admin.py b/apps/moderation/admin.py new file mode 100644 index 0000000..d68941a --- /dev/null +++ b/apps/moderation/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from .models import AuditLog, ModerationRequest + + +@admin.register(ModerationRequest) +class ModerationRequestAdmin(admin.ModelAdmin): + list_display = ('content_type', 'object_id', 'decision', 'moderator', 'created_at', 'decided_at') + list_filter = ('decision', 'content_type') + search_fields = ('comment',) + + +@admin.register(AuditLog) +class AuditLogAdmin(admin.ModelAdmin): + list_display = ('created_at', 'user', 'action', 'object_repr') + list_filter = ('action',) + search_fields = ('action', 'object_repr') + readonly_fields = ('created_at',) \ No newline at end of file diff --git a/apps/moderation/apps.py b/apps/moderation/apps.py new file mode 100644 index 0000000..997af2f --- /dev/null +++ b/apps/moderation/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ModerationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.moderation' + diff --git a/apps/moderation/migrations/0001_initial.py b/apps/moderation/migrations/0001_initial.py new file mode 100644 index 0000000..c17df71 --- /dev/null +++ b/apps/moderation/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 5.1.15 on 2026-09-23 13:44 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AuditLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(max_length=255, verbose_name='Действие')), + ('object_repr', models.CharField(blank=True, max_length=255, verbose_name='Объект')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Время')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Запись журнала', + 'verbose_name_plural': 'Журнал действий', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='ModerationRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField(verbose_name='ID сущности')), + ('decision', models.CharField(choices=[('pending', 'На рассмотрении'), ('approved', 'Одобрено'), ('revision', 'На доработке'), ('rejected', 'Отклонено')], default='pending', max_length=20, verbose_name='Решение')), + ('comment', models.TextField(blank=True, verbose_name='Комментарий модератора')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создана')), + ('decided_at', models.DateTimeField(blank=True, null=True, verbose_name='Решена')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Тип сущности')), + ('moderator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moderated_requests', to=settings.AUTH_USER_MODEL, verbose_name='Модератор')), + ], + options={ + 'verbose_name': 'Заявка на модерацию', + 'verbose_name_plural': 'Заявки на модерацию', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/apps/moderation/migrations/__init__.py b/apps/moderation/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/moderation/models.py b/apps/moderation/models.py new file mode 100644 index 0000000..96a72f2 --- /dev/null +++ b/apps/moderation/models.py @@ -0,0 +1,63 @@ +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + + +class Decision(models.TextChoices): + PENDING = 'pending', 'На рассмотрении' + APPROVED = 'approved', 'Одобрено' + REVISION = 'revision', 'На доработке' + REJECTED = 'rejected', 'Отклонено' + + +class ModerationRequest(models.Model): + """Заявка на модерацию сущности (бренд/карточка/отзыв) (ТЗ §3.8).""" + + content_type = models.ForeignKey( + ContentType, on_delete=models.CASCADE, verbose_name='Тип сущности', + ) + object_id = models.PositiveIntegerField('ID сущности') + entity = GenericForeignKey('content_type', 'object_id') + + decision = models.CharField( + 'Решение', max_length=20, choices=Decision.choices, + default=Decision.PENDING, + ) + comment = models.TextField('Комментарий модератора', blank=True) + moderator = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name='moderated_requests', + verbose_name='Модератор', + ) + created_at = models.DateTimeField('Создана', auto_now_add=True) + decided_at = models.DateTimeField('Решена', null=True, blank=True) + + class Meta: + verbose_name = 'Заявка на модерацию' + verbose_name_plural = 'Заявки на модерацию' + ordering = ('-created_at',) + + def __str__(self): + return f'Модерация {self.content_type} #{self.object_id} — {self.decision}' + + +class AuditLog(models.Model): + """Журнал действий (ТЗ §3.8, §6).""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name='audit_logs', + verbose_name='Пользователь', + ) + action = models.CharField('Действие', max_length=255) + object_repr = models.CharField('Объект', max_length=255, blank=True) + created_at = models.DateTimeField('Время', auto_now_add=True) + + class Meta: + verbose_name = 'Запись журнала' + verbose_name_plural = 'Журнал действий' + ordering = ('-created_at',) + + def __str__(self): + return f'{self.created_at:%Y-%m-%d %H:%M} {self.user} — {self.action}' \ No newline at end of file diff --git a/apps/moderation/tests.py b/apps/moderation/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/moderation/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/moderation/views.py b/apps/moderation/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/moderation/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/orders/__init__.py b/apps/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/orders/admin.py b/apps/orders/admin.py new file mode 100644 index 0000000..0475792 --- /dev/null +++ b/apps/orders/admin.py @@ -0,0 +1,32 @@ +from django.contrib import admin + +from .models import Favorite, Order, OrderItem, PickupPoint + + +class OrderItemInline(admin.TabularInline): + model = OrderItem + extra = 0 + + +@admin.register(PickupPoint) +class PickupPointAdmin(admin.ModelAdmin): + list_display = ('name', 'address', 'partner', 'is_active') + list_filter = ('is_active',) + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ('id', 'buyer', 'status', 'delivery_method', 'payment_method', 'total', 'created_at') + list_filter = ('status', 'delivery_method', 'payment_method') + search_fields = ('buyer__username', 'buyer__full_name') + inlines = [OrderItemInline] + + +@admin.register(OrderItem) +class OrderItemAdmin(admin.ModelAdmin): + list_display = ('order', 'name', 'brand', 'price', 'quantity') + + +@admin.register(Favorite) +class FavoriteAdmin(admin.ModelAdmin): + list_display = ('user', 'product', 'service', 'created_at') \ No newline at end of file diff --git a/apps/orders/apps.py b/apps/orders/apps.py new file mode 100644 index 0000000..7e20da6 --- /dev/null +++ b/apps/orders/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class OrdersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.orders' + diff --git a/apps/orders/migrations/0001_initial.py b/apps/orders/migrations/0001_initial.py new file mode 100644 index 0000000..39265ab --- /dev/null +++ b/apps/orders/migrations/0001_initial.py @@ -0,0 +1,88 @@ +# Generated by Django 5.1.15 on 2026-09-23 13:44 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('catalog', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PickupPoint', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название')), + ('address', models.CharField(max_length=300, verbose_name='Адрес')), + ('working_hours', models.CharField(blank=True, max_length=200, verbose_name='Часы работы')), + ('partner', models.CharField(blank=True, max_length=200, verbose_name='Партнёр')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ], + options={ + 'verbose_name': 'Пункт выдачи', + 'verbose_name_plural': 'Пункты выдачи', + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('new', 'Новый'), ('confirmed', 'Подтверждён'), ('paid', 'Оплачен'), ('assembled', 'Собран'), ('shipped', 'Передан в доставку'), ('done', 'Выполнен'), ('cancelled', 'Отменён'), ('refunded', 'Возврат')], default='new', max_length=20, verbose_name='Статус')), + ('delivery_method', models.CharField(choices=[('pickup', 'Самовывоз'), ('courier', 'Доставка по договорённости'), ('cdek', 'СДЭК')], default='pickup', max_length=20, verbose_name='Способ получения')), + ('address', models.CharField(blank=True, max_length=300, verbose_name='Адрес доставки')), + ('payment_method', models.CharField(choices=[('by_agreement', 'По договорённости (напрямую продавцу)'), ('online', 'Онлайн через агрегатор')], default='by_agreement', max_length=20, verbose_name='Способ оплаты')), + ('customer_comment', models.TextField(blank=True, verbose_name='Комментарий покупателя')), + ('total', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Сумма')), + ('commission', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Комиссия площадки')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')), + ('buyer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to=settings.AUTH_USER_MODEL, verbose_name='Покупатель')), + ('pickup_point', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.pickuppoint', verbose_name='Пункт выдачи')), + ], + options={ + 'verbose_name': 'Заказ', + 'verbose_name_plural': 'Заказы', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='OrderItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Название позиции')), + ('price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Цена за единицу')), + ('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')), + ('brand', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='catalog.brand', verbose_name='Бренд')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.order', verbose_name='Заказ')), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='catalog.product', verbose_name='Товар')), + ('service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='catalog.service', verbose_name='Услуга')), + ], + options={ + 'verbose_name': 'Позиция заказа', + 'verbose_name_plural': 'Позиции заказа', + }, + ), + migrations.CreateModel( + name='Favorite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Добавлено')), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='catalog.product', verbose_name='Товар')), + ('service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='catalog.service', verbose_name='Услуга')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Избранное', + 'verbose_name_plural': 'Избранное', + 'constraints': [models.UniqueConstraint(condition=models.Q(('product__isnull', False)), fields=('user', 'product'), name='uniq_favorite_user_product'), models.UniqueConstraint(condition=models.Q(('service__isnull', False)), fields=('user', 'service'), name='uniq_favorite_user_service')], + }, + ), + ] diff --git a/apps/orders/migrations/__init__.py b/apps/orders/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/orders/models.py b/apps/orders/models.py new file mode 100644 index 0000000..99e53cb --- /dev/null +++ b/apps/orders/models.py @@ -0,0 +1,163 @@ +from django.conf import settings +from django.db import models + +from apps.catalog.models import Brand, Product, Service + + +class OrderStatus(models.TextChoices): + NEW = 'new', 'Новый' + CONFIRMED = 'confirmed', 'Подтверждён' + PAID = 'paid', 'Оплачен' # этап 2 + ASSEMBLED = 'assembled', 'Собран' + SHIPPED = 'shipped', 'Передан в доставку' # этап 2 + DONE = 'done', 'Выполнен' + CANCELLED = 'cancelled', 'Отменён' + REFUNDED = 'refunded', 'Возврат' # этап 2 + + +class DeliveryMethod(models.TextChoices): + PICKUP = 'pickup', 'Самовывоз' + COURIER = 'courier', 'Доставка по договорённости' # этап 1 + CDEK = 'cdek', 'СДЭК' # этап 2 + + +class PaymentMethod(models.TextChoices): + BY_AGREEMENT = 'by_agreement', 'По договорённости (напрямую продавцу)' + ONLINE = 'online', 'Онлайн через агрегатор' # этап 2 + + +class PickupPoint(models.Model): + """Пункт выдачи (ТЗ §4: PickupPoint).""" + + name = models.CharField('Название', max_length=200) + address = models.CharField('Адрес', max_length=300) + working_hours = models.CharField('Часы работы', max_length=200, blank=True) + partner = models.CharField('Партнёр', max_length=200, blank=True) + is_active = models.BooleanField('Активен', default=True) + + class Meta: + verbose_name = 'Пункт выдачи' + verbose_name_plural = 'Пункты выдачи' + ordering = ('name',) + + def __str__(self): + return f'{self.name} ({self.address})' + + +class Order(models.Model): + """Заказ / заявка (ТЗ §4: Order).""" + + buyer = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, + related_name='orders', verbose_name='Покупатель', + ) + status = models.CharField( + 'Статус', max_length=20, choices=OrderStatus.choices, + default=OrderStatus.NEW, + ) + delivery_method = models.CharField( + 'Способ получения', max_length=20, + choices=DeliveryMethod.choices, default=DeliveryMethod.PICKUP, + ) + address = models.CharField('Адрес доставки', max_length=300, blank=True) + pickup_point = models.ForeignKey( + PickupPoint, null=True, blank=True, on_delete=models.SET_NULL, + related_name='orders', verbose_name='Пункт выдачи', + ) + payment_method = models.CharField( + 'Способ оплаты', max_length=20, + choices=PaymentMethod.choices, default=PaymentMethod.BY_AGREEMENT, + ) + customer_comment = models.TextField('Комментарий покупателя', blank=True) + + total = models.DecimalField('Сумма', max_digits=12, decimal_places=2, default=0) + commission = models.DecimalField( + 'Комиссия площадки', max_digits=12, decimal_places=2, default=0, + ) + + created_at = models.DateTimeField('Создан', auto_now_add=True) + updated_at = models.DateTimeField('Обновлён', auto_now=True) + + class Meta: + verbose_name = 'Заказ' + verbose_name_plural = 'Заказы' + ordering = ('-created_at',) + + def __str__(self): + return f'Заказ №{self.pk}' + + def recalculate_total(self): + self.total = sum((item.line_total for item in self.items.all()), start=0) + self.save(update_fields=['total']) + + +class OrderItem(models.Model): + """Позиция заказа. Может относиться к товару или к услуге (ТЗ §4).""" + + order = models.ForeignKey( + Order, on_delete=models.CASCADE, related_name='items', + verbose_name='Заказ', + ) + brand = models.ForeignKey( + Brand, on_delete=models.PROTECT, related_name='order_items', + verbose_name='Бренд', + ) + product = models.ForeignKey( + Product, null=True, blank=True, on_delete=models.SET_NULL, + related_name='order_items', verbose_name='Товар', + ) + service = models.ForeignKey( + Service, null=True, blank=True, on_delete=models.SET_NULL, + related_name='order_items', verbose_name='Услуга', + ) + name = models.CharField('Название позиции', max_length=255) + price = models.DecimalField('Цена за единицу', max_digits=12, decimal_places=2) + quantity = models.PositiveIntegerField('Количество', default=1) + + class Meta: + verbose_name = 'Позиция заказа' + verbose_name_plural = 'Позиции заказа' + + def __str__(self): + return f'{self.name} × {self.quantity}' + + @property + def line_total(self): + return self.price * self.quantity + + +class Favorite(models.Model): + """Избранное покупателя (ТЗ §3.2, §3.3).""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name='favorites', verbose_name='Пользователь', + ) + product = models.ForeignKey( + Product, null=True, blank=True, on_delete=models.CASCADE, + related_name='favorited_by', verbose_name='Товар', + ) + service = models.ForeignKey( + Service, null=True, blank=True, on_delete=models.CASCADE, + related_name='favorited_by', verbose_name='Услуга', + ) + created_at = models.DateTimeField('Добавлено', auto_now_add=True) + + class Meta: + verbose_name = 'Избранное' + verbose_name_plural = 'Избранное' + constraints = [ + models.UniqueConstraint( + fields=('user', 'product'), + condition=models.Q(product__isnull=False), + name='uniq_favorite_user_product', + ), + models.UniqueConstraint( + fields=('user', 'service'), + condition=models.Q(service__isnull=False), + name='uniq_favorite_user_service', + ), + ] + + def __str__(self): + return f'{self.user} → {self.product or self.service}' diff --git a/apps/orders/tests.py b/apps/orders/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/orders/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/orders/views.py b/apps/orders/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/orders/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/reviews/__init__.py b/apps/reviews/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reviews/admin.py b/apps/reviews/admin.py new file mode 100644 index 0000000..49159d8 --- /dev/null +++ b/apps/reviews/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import Review + + +@admin.register(Review) +class ReviewAdmin(admin.ModelAdmin): + list_display = ('id', 'author', 'product', 'service', 'rating', 'status', 'created_at') + list_filter = ('status', 'rating') + search_fields = ('author__username', 'text') \ No newline at end of file diff --git a/apps/reviews/apps.py b/apps/reviews/apps.py new file mode 100644 index 0000000..a5399b0 --- /dev/null +++ b/apps/reviews/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ReviewsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.reviews' + diff --git a/apps/reviews/migrations/0001_initial.py b/apps/reviews/migrations/0001_initial.py new file mode 100644 index 0000000..540ed3f --- /dev/null +++ b/apps/reviews/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 5.1.15 on 2026-09-23 13:44 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('catalog', '0001_initial'), + ('orders', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rating', models.PositiveSmallIntegerField(default=5, verbose_name='Оценка')), + ('text', models.TextField(blank=True, verbose_name='Текст отзыва')), + ('images', models.JSONField(blank=True, default=list, verbose_name='Фото')), + ('brand_reply', models.TextField(blank=True, verbose_name='Ответ бренда')), + ('status', models.CharField(choices=[('pending', 'На модерации'), ('published', 'Опубликован'), ('rejected', 'Отклонён')], default='pending', max_length=20, verbose_name='Статус')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL, verbose_name='Автор')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='orders.order', verbose_name='Заказ')), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='catalog.product', verbose_name='Товар')), + ('service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='catalog.service', verbose_name='Услуга')), + ], + options={ + 'verbose_name': 'Отзыв', + 'verbose_name_plural': 'Отзывы', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/apps/reviews/migrations/__init__.py b/apps/reviews/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reviews/models.py b/apps/reviews/models.py new file mode 100644 index 0000000..bd46fe8 --- /dev/null +++ b/apps/reviews/models.py @@ -0,0 +1,50 @@ +from django.conf import settings +from django.db import models + +from apps.catalog.models import Product, Service +from apps.orders.models import Order + + +class ReviewStatus(models.TextChoices): + PENDING = 'pending', 'На модерации' + PUBLISHED = 'published', 'Опубликован' + REJECTED = 'rejected', 'Отклонён' + + +class Review(models.Model): + """Отзыв на товар/услугу после подтверждённого заказа (ТЗ §3.5).""" + + order = models.ForeignKey( + Order, on_delete=models.CASCADE, related_name='reviews', + verbose_name='Заказ', + ) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name='reviews', verbose_name='Автор', + ) + product = models.ForeignKey( + Product, null=True, blank=True, on_delete=models.CASCADE, + related_name='reviews', verbose_name='Товар', + ) + service = models.ForeignKey( + Service, null=True, blank=True, on_delete=models.CASCADE, + related_name='reviews', verbose_name='Услуга', + ) + rating = models.PositiveSmallIntegerField('Оценка', default=5) + text = models.TextField('Текст отзыва', blank=True) + images = models.JSONField('Фото', default=list, blank=True) + brand_reply = models.TextField('Ответ бренда', blank=True) + + status = models.CharField( + 'Статус', max_length=20, choices=ReviewStatus.choices, + default=ReviewStatus.PENDING, + ) + created_at = models.DateTimeField('Создан', auto_now_add=True) + + class Meta: + verbose_name = 'Отзыв' + verbose_name_plural = 'Отзывы' + ordering = ('-created_at',) + + def __str__(self): + return f'Отзыв #{self.pk} ({self.rating}/5)' \ No newline at end of file diff --git a/apps/reviews/tests.py b/apps/reviews/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/reviews/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/reviews/views.py b/apps/reviews/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/reviews/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..400e9e3 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..0ecd323 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,146 @@ +""" +Настройки Django для проекта «Создано молодыми». + +База данных: MySQL в продакшене (по ТЗ), SQLite — для локальной разработки. +Управляется переменной окружения DB_ENGINE (mysql | sqlite). +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent.parent + +# Загружаем переменные окружения из .env (файл не коммитится). +load_dotenv(BASE_DIR / '.env') + +# --- Безопасность --------------------------------------------------------- + +SECRET_KEY = os.getenv( + 'DJANGO_SECRET_KEY', + 'django-insecure-dev-only-change-me-before-production', +) + +DEBUG = os.getenv('DJANGO_DEBUG', 'True').lower() in ('1', 'true', 'yes', 'on') + +ALLOWED_HOSTS = [ + h.strip() + for h in os.getenv('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') + if h.strip() +] + +CSRF_TRUSTED_ORIGINS = [ + o.strip() + for o in os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', '').split(',') + if o.strip() +] + +# --- Приложения ----------------------------------------------------------- + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + # Локальные приложения + 'apps.accounts', + 'apps.catalog', + 'apps.orders', + 'apps.reviews', + 'apps.moderation', + 'apps.content', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' +ASGI_APPLICATION = 'config.asgi.application' + +# --- База данных ---------------------------------------------------------- +# MySQL — целевая СУБД (ТЗ §6). Локально без установленного MySQL +# используется SQLite, чтобы проект запускался «из коробки». + +if os.getenv('DB_ENGINE', 'sqlite').lower() == 'mysql': + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': os.getenv('DB_NAME', 'rosmol_shop'), + 'USER': os.getenv('DB_USER', 'root'), + 'PASSWORD': os.getenv('DB_PASSWORD', ''), + 'HOST': os.getenv('DB_HOST', '127.0.0.1'), + 'PORT': os.getenv('DB_PORT', '3306'), + 'OPTIONS': { + 'charset': 'utf8mb4', + 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", + }, + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } + } + +# --- Аутентификация ------------------------------------------------------- + +AUTH_USER_MODEL = 'accounts.User' + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +LOGIN_URL = 'accounts:login' +LOGIN_REDIRECT_URL = 'catalog:home' +LOGOUT_REDIRECT_URL = 'catalog:home' + +# --- Интернационализация -------------------------------------------------- + +LANGUAGE_CODE = 'ru-ru' +TIME_ZONE = 'Europe/Moscow' +USE_I18N = True +USE_TZ = True + +# --- Файлы ---------------------------------------------------------------- + +STATIC_URL = 'static/' +STATICFILES_DIRS = [BASE_DIR / 'static'] +STATIC_ROOT = BASE_DIR / 'staticfiles' + +MEDIA_URL = 'media/' +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..9752efc --- /dev/null +++ b/config/urls.py @@ -0,0 +1,12 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('apps.catalog.urls')), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..e232e56 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1ba797a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Django>=5.0,<5.2 +PyMySQL>=1.1 +python-dotenv>=1.0 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..7f5f286 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,141 @@ +/* «Создано молодыми» — базовые стили (mobile-first, адаптив 360–1440px) */ + +:root { + --color-bg: #ffffff; + --color-text: #1a1a1a; + --color-muted: #6b7280; + --color-primary: #e11d48; + --color-primary-dark: #be123c; + --color-border: #e5e7eb; + --radius: 12px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; + color: var(--color-text); + background: var(--color-bg); + line-height: 1.5; +} + +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 16px; +} + +/* Шапка */ +.site-header { + border-bottom: 1px solid var(--color-border); + background: #fff; + position: sticky; + top: 0; + z-index: 10; +} +.header-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 12px; + padding-bottom: 12px; +} +.logo { + font-weight: 700; + font-size: 18px; + color: var(--color-primary); + text-decoration: none; +} +.nav { display: flex; align-items: center; gap: 16px; } +.nav a { color: var(--color-text); text-decoration: none; } +.nav a:hover { color: var(--color-primary); } +.nav-user { color: var(--color-muted); font-size: 14px; } + +/* Кнопки */ +.btn { + display: inline-block; + padding: 10px 18px; + border: none; + border-radius: var(--radius); + background: var(--color-primary); + color: #fff; + font-size: 15px; + cursor: pointer; + text-decoration: none; +} +.btn:hover { background: var(--color-primary-dark); } + +/* Hero */ +.hero { + padding: 48px 0 32px; + text-align: center; +} +.hero h1 { font-size: 32px; margin: 0 0 8px; } +.hero p { color: var(--color-muted); margin: 0 0 20px; } + +/* Баннеры */ +.banners { display: grid; gap: 12px; margin: 16px 0; } +.banner { + display: block; + padding: 24px; + border-radius: var(--radius); + background: linear-gradient(135deg, #fecdd3, #fda4af); + color: #881337; + text-decoration: none; + font-weight: 600; +} + +/* Сетка карточек */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; + margin: 16px 0 32px; +} +.card { + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow); +} +.card a { color: inherit; text-decoration: none; } +.card h3 { margin: 0 0 8px; font-size: 16px; } +.price { font-weight: 700; color: var(--color-primary); margin: 0 0 4px; } +.brand { color: var(--color-muted); font-size: 14px; margin: 0; } + +/* Фильтры */ +.filters { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 16px 0; +} +.filters input, +.filters select { + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-size: 15px; +} + +/* Хлебные крошки */ +.breadcrumbs { margin: 16px 0; color: var(--color-muted); font-size: 14px; } +.breadcrumbs a { color: var(--color-muted); } + +/* Подвал */ +.site-footer { + border-top: 1px solid var(--color-border); + padding: 20px 0; + color: var(--color-muted); + font-size: 14px; + margin-top: 32px; +} + +@media (min-width: 768px) { + .hero h1 { font-size: 40px; } + .banners { grid-template-columns: repeat(3, 1fr); } +} \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..8ed38d9 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,15 @@ +// «Создано молодыми» — базовый JS (корзина-заглушка на этапе 1). + +document.addEventListener('DOMContentLoaded', () => { + const buttons = document.querySelectorAll('[data-add-to-cart]'); + + buttons.forEach((btn) => { + btn.addEventListener('click', () => { + const id = btn.dataset.addToCart; + // Этап 1: заявка без онлайн-оплаты. Здесь — заглушка. + btn.textContent = 'Добавлено ✓'; + btn.disabled = true; + console.log(`[cart] добавлен товар #${id}`); + }); + }); +}); \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..947b1f3 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,36 @@ +{% load static %} + + + + + + {% block title %}Создано молодыми{% endblock %} + + + + + +
+ {% block content %}{% endblock %} +
+ +
+
© 2026 «Создано молодыми»
+
+ + + + \ No newline at end of file diff --git a/templates/catalog/_product_card.html b/templates/catalog/_product_card.html new file mode 100644 index 0000000..0555370 --- /dev/null +++ b/templates/catalog/_product_card.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/templates/catalog/brand_detail.html b/templates/catalog/brand_detail.html new file mode 100644 index 0000000..4a914e4 --- /dev/null +++ b/templates/catalog/brand_detail.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% block title %}{{ brand.name }}{% endblock %} +{% block content %} +

{{ brand.name }}

+

{{ brand.region }}{% if brand.city %} · {{ brand.city }}{% endif %}

+

{{ brand.story }}

+ +

Товары

+
+ {% for p in products %} + {% include 'catalog/_product_card.html' %} + {% empty %} +

У бренда пока нет товаров.

+ {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/catalog/catalog.html b/templates/catalog/catalog.html new file mode 100644 index 0000000..cff58e1 --- /dev/null +++ b/templates/catalog/catalog.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block title %}Каталог{% endblock %} +{% block content %} +

Каталог

+ +
+ + + +
+ +
+ {% for p in products %} + {% include 'catalog/_product_card.html' %} + {% empty %} +

Ничего не найдено.

+ {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/catalog/home.html b/templates/catalog/home.html new file mode 100644 index 0000000..6a244aa --- /dev/null +++ b/templates/catalog/home.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} +{% block title %}Создано молодыми — площадка молодых предпринимателей{% endblock %} +{% block content %} +
+

Покупайте у своих

+

Товары и услуги молодых предпринимателей вашего региона.

+ Перейти в каталог +
+ + {% if banners %} +
+ {% for b in banners %} + + {% endfor %} +
+ {% endif %} + +
+

Новинки

+
+ {% for p in products %} + {% include 'catalog/_product_card.html' %} + {% empty %} +

Пока нет товаров.

+ {% endfor %} +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/catalog/product_detail.html b/templates/catalog/product_detail.html new file mode 100644 index 0000000..e819af6 --- /dev/null +++ b/templates/catalog/product_detail.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} +{% block title %}{{ product.name }}{% endblock %} +{% block content %} + + +
+

{{ product.name }}

+

{{ product.price }} ₽

+

Бренд: + {{ product.brand.name }} +

+

{{ product.description }}

+

Остаток: {{ product.stock }}

+ +
+{% endblock %} \ No newline at end of file