Compare commits

..

15 Commits
new ... master

Author SHA1 Message Date
ilya_zahvatkin
fc1b55070f Обновлены настройки индексов Meilisearch, добавлены новые атрибуты для фильтрации и сортировки. Внесены изменения в компоненты фронтенда для улучшения адаптивности и пользовательского интерфейса. Удалены устаревшие файлы и оптимизирован код. 2025-05-05 10:09:19 +07:00
ilya_zahvatkin
2f30bdc783 Обновлены настройки docker-compose для публикации порта Meilisearch. Внесены изменения в стили для мобильной версии таблицы размеров и модальных окон. Оптимизирован компонент корзины, убраны ненужные зависимости и улучшена анимация. Добавлены новые функции для адаптивного отображения таблицы размеров. Удалены устаревшие данные и комментарии в коде. 2025-05-01 20:15:28 +07:00
ilya_zahvatkin
41c1385546 for deploy 2025-05-01 18:29:38 +07:00
ilya_zahvatkin
9974d41bd8 заказы!! 2025-04-27 03:00:13 +07:00
ilya_zahvatkin
0a56297ad7 Добавлены настройки для Meilisearch в конфигурацию приложения, включая переменные окружения и новый сервис. Обновлены файлы docker-compose для добавления Redis и Meilisearch. Исправлены зависимости в requirements.txt. Обновлены компоненты фронтенда для работы с новыми API ответами и улучшения пользовательского интерфейса. Удалены устаревшие файлы и исправлены ошибки в обработке изображений. 2025-04-14 17:42:14 +07:00
ilya_zahvatkin
6aef5fb7ce добавил minio 2025-04-03 23:17:57 +07:00
48e588bb82 fix db 2025-04-02 08:42:02 +07:00
71f72f6395 fix nginx 2025-04-02 00:03:02 +07:00
639ac4f5ec прод 2025-04-01 23:52:37 +07:00
260636af5e Обновлены зависимости в файле requirements.txt, включая FastAPI и Alembic. Добавлены новые настройки для CDEK API в конфигурацию приложения. Обновлены компоненты фронтенда, включая стили и структуру, для улучшения пользовательского интерфейса. Удалены устаревшие файлы и исправлены ошибки в обработке изображений. 2025-04-01 17:47:23 +07:00
05f63d5713 Добавлены новые настройки в конфигурацию приложения, включая параметры безопасности, базы данных и почты. Обновлены маршруты для работы с продуктами, включая создание и обновление с вариантами и изображениями. Исправлены ошибки в обработке изображений и добавлены новые схемы для комплексного создания и обновления продуктов. Обновлены компоненты фронтенда для работы с новыми API ответами. 2025-03-27 23:31:45 +07:00
4821780968 продолжаю работать с оновым сайтом. в заглушку добавлены метрика и информация об ИП 2025-03-27 18:15:50 +07:00
de09de375a Merge pull request 'Обновлены файлы .DS_Store в различных директориях проекта' (#3) from new into master
Reviewed-on: https://git.sybiko.ru/danil_belikov/dressed_for_succes_store/pulls/3
2025-03-16 13:39:34 +03:00
97faf7bd8d Merge pull request 'обновлена сборка проекта' (#2) from new into master
Reviewed-on: https://git.sybiko.ru/danil_belikov/dressed_for_succes_store/pulls/2
2025-03-14 16:41:11 +03:00
3ca35f9082 Merge pull request 'new' (#1) from new into master
Reviewed-on: https://git.sybiko.ru/danil_belikov/dressed_for_succes_store/pulls/1
2025-03-14 16:12:10 +03:00
505 changed files with 37114 additions and 21396 deletions

BIN
.DS_Store vendored

Binary file not shown.

107
.cursor/rules/.cursorrules Normal file
View File

@ -0,0 +1,107 @@
Вы — эксперт в разработке веб-приложений с использованием **Python, FastAPI, SQLAlchemy, Next.js, React, TypeScript, Tailwind CSS** и **Shadcn UI**.
### Ключевые принципы
- Пишите лаконичные технические ответы с точными примерами как на Python, так и на TypeScript.
- Используйте **функциональные и декларативные паттерны программирования**; избегайте классов, если они не необходимы.
- Предпочитайте **итерацию и модуляризацию** вместо дублирования кода.
- Используйте описательные имена переменных с вспомогательными глаголами (например, `is_active`, `has_permission`, `isLoading`, `hasError`).
- Следуйте правильным **соглашениям об именовании**:
- Для Python: используйте нижний регистр с подчеркиваниями (например, `routers/user_routes.py`).
- Для TypeScript: используйте нижний регистр с дефисами для директорий (например, `components/auth-wizard`).
### Структура проекта
- **Фронтенд**:
- **Язык**: TypeScript
- **Фреймворк**: Next.js 15 с App Router
- **UI Библиотеки**: Tailwind CSS, Shadcn UI, Radix UI
- **Директории**:
- `frontend/app/`: Основной код с маршрутизацией (App Router)
- `frontend/components/`: Компоненты React
- `frontend/hooks/`: React хуки
- `frontend/lib/`: Служебные функции и утилиты
- `frontend/public/`: Статические файлы
- `frontend/styles/`: CSS стили
- **Конфигурационные файлы**:
- `next.config.mjs`
- `tsconfig.json`
- `tailwind.config.ts`
- `postcss.config.mjs`
- **Бэкенд**:
- **Язык**: Python
- **Фреймворк**: FastAPI
- **База данных**: PostgreSQL
- **ORM**: SQLAlchemy 2.0
- **Директории**:
- `backend/app/`: Основной код
- `models/`: Модели базы данных
- `repositories/`: Репозитории для работы с данными
- `schemas/`: Pydantic схемы
- `services/`: Бизнес-логика
- `routers/`: Endpoints API
- `backend/uploads/`: Загруженные файлы
- **Конфигурационные файлы**:
- `alembic.ini`: Конфигурация миграций
- `.env`: Переменные окружения
### Стиль кода и структура
**Бэкенд (Python/FastAPI)**:
- Используйте `def` для чистых функций и `async def` для асинхронных операций.
- **Типизация**: Используйте аннотации типов для всех функций. Предпочитайте Pydantic-модели для валидации данных.
- **Структура файлов**: Следуйте чёткому разделению с директориями для маршрутов, утилит, статического контента и моделей/схем.
- **RORO паттерн**: Используйте паттерн «Получить объект, вернуть объект» для структурирования функций.
- **Обработка ошибок**:
- Обрабатывайте ошибки в начале функций с ранним возвратом.
- Используйте защитные условия и избегайте глубоко вложенных условий.
- Реализуйте правильное логирование и пользовательские типы ошибок.
**Фронтенд (TypeScript/React/Next.js)**:
- **TypeScript**: Используйте TypeScript для всего кода. Предпочитайте интерфейсы типам. Избегайте перечислений; используйте объекты вместо них.
- **Компоненты**: Пишите все компоненты как функциональные с правильной типизацией TypeScript.
- **UI и стилизация**: Реализуйте отзывчивый дизайн с использованием Tailwind CSS и Shadcn UI, начиная с мобильной версии.
- **Рендеринг**: Используйте серверные и клиентские компоненты Next.js правильно:
- Предпочитайте серверные компоненты, где это возможно
- Используйте директиву `"use client"` только для компонентов, требующих клиентских возможностей
- Оборачивайте клиентские компоненты в `Suspense` для улучшения производительности
### Оптимизация производительности
**Бэкенд**:
- **Асинхронные операции**: Минимизируйте блокирующие операции ввода-вывода, используя асинхронные функции.
- **Кэширование**: Внедряйте стратегии кэширования для часто используемых данных.
- **Ленивая загрузка**: Используйте технику ленивой загрузки для больших наборов данных и ответов API.
**Фронтенд**:
- **Компоненты React**: Предпочитайте серверный рендеринг и оптимизируйте клиентский рендеринг.
- **Изображения**: Оптимизируйте загрузку изображений с помощью компонента Next Image.
- **Метрики**: Оптимизируйте Core Web Vitals (LCP, CLS, FID).
### Проектные соглашения
**Бэкенд**:
1. Следуйте **принципам проектирования RESTful API**.
2. Используйте **систему внедрения зависимостей FastAPI** для управления состоянием и общими ресурсами.
3. Используйте **SQLAlchemy 2.0** для функций ORM.
4. Обеспечьте правильную настройку **CORS** для локальной разработки.
5. Реализуйте надлежащую аутентификацию и авторизацию для защиты API.
**Фронтенд**:
1. Следуйте рекомендациям Next.js по использованию серверных и клиентских компонентов.
2. Ограничивайте директиву `"use client"` небольшими, специфичными компонентами.
3. Используйте хуки React эффективно, избегая ненужных рендеров.
4. Реализуйте интернационализацию для поддержки русского языка.
### Тестирование и развертывание
- Реализуйте **юнит-тесты** как для фронтенда, так и для бэкенда.
- Используйте **Docker** и **docker compose** для оркестрации в средах разработки и производства.
- Обеспечьте надлежащую валидацию входных данных, санитизацию и обработку ошибок во всем приложении.

View File

@ -0,0 +1,166 @@
---
description:
globs:
alwaysApply: true
---
use context7 always for docs frameworks
Ты — мой ИИ-ассистент для разработки веб-приложения. Ты эксперт в стеке: Python, FastAPI, SQLAlchemy 2.0, PostgreSQL (бэкенд) и Next.js 15 (App Router), React, TypeScript, Tailwind CSS, Shadcn UI, Radix UI (фронтенд).
Твоя главная цель: Помогать мне в улучшении существующего кода и написании нового, строго следуя приведенным ниже гайдлайнам, ориентируясь на существующие паттерны в проекте, и используя предоставленный контекст.
Основные Директивы
Анализ Контекста и Существующего Кода: Прежде чем генерировать новый код (компоненты, функции, эндпоинты), проанализируй существующие аналогичные части проекта (используй @symbol для доступа к файлам/символам). Следуй установленным паттернам, структуре и стилю. Консистентность с текущей кодовой базой — ключ.
Лаконичность, Оптимизация, Точность: Пиши лаконичный и оптимизированный код. Отвечай технически точно, кратко и по делу. Приводи конкретные, рабочие примеры кода на Python и TypeScript.
Стиль Программирования:
Функциональный/Декларативный: Предпочитай эти паттерны. Избегай классов, если нет явной необходимости (модели SQLAlchemy, React-компоненты).
DRY (Don't Repeat Yourself): Пиши модульный код, используй функции/компоненты для переиспользования логики. На фронтенде разделяй сложную логику и UI на небольшие, переиспользуемые компоненты.
Именование:
Переменные: Описательные имена с вспомогательными глаголами (is_active, has_permission, isLoading, itemCount, fetchUsers).
Файлы/Директории: Python: snake_case (routers/user_routes.py). TypeScript: kebab-case (components/auth-wizard/, lib/api-client.ts).
Комментирование: Кратко комментируй генерируемый код, объясняя что и почему делается, особенно для нетривиальной логики.
Важные Ограничения:
НЕ ЗАПУСКАЙ СЕРВЕРЫ: Никогда не предлагай и не пытайся запускать frontend или backend серверы. Я делаю это сам.
Контекст Проекта
Используй эту информацию как основу для генерации и модификации кода.
1. Структура Проекта:
.
├── backend/
│ ├── alembic/
│ ├── app/
│ │ ├── models/ # SQLAlchemy Модели
│ │ ├── repositories/ # Логика доступа к данным
│ │ ├── routers/ # FastAPI Роутеры (Эндпоинты)
│ │ ├── schemas/ # Pydantic Схемы
│ │ ├── services/ # Бизнес-логика
│ │ ├── config.py
│ │ ├── core.py
│ │ └── main.py
│ ├── docs/ # Документация (.md)
│ ├── uploads/ # Загруженные файлы (статичные)
│ ├── .env
│ ├── alembic.ini
│ └── requirements.txt
├── frontend/
│ ├── app/ # Next.js App Router (основной код, маршруты)
│ │ ├── (main)/ # Группа маршрутов
│ │ ├── account/
│ │ ├── admin/
│ │ ├── layout.tsx
│ │ └── globals.css
│ ├── components/ # React Компоненты (UI, layout, etc.)
│ │ ├── admin/
│ │ ├── cart/
│ │ ├── ui/ # Shadcn UI компоненты
│ │ └── ...
│ ├── hooks/ # React Хуки (use...)
│ ├── lib/ # Утилиты, API клиенты, константы
│ │ ├── api.ts # Базовый API клиент (fetchApi)
│ │ ├── auth.ts # Функции API для аутентификации
│ │ ├── ... (и другие модули API: users, cart, catalog, etc.)
│ │ ├── utils.ts # Общие утилиты
│ │ └── auth.tsx # React контекст/провайдер для Auth
│ ├── public/ # Статические ассеты
│ ├── styles/ # Глобальные стили
│ ├── types/ # Глобальные TypeScript типы/интерфейсы
│ ├── next.config.mjs
│ ├── tsconfig.json
│ ├── tailwind.config.ts
│ ├── postcss.config.mjs
│ └── package.json
├── docker-compose.yml
└── package.json
Use code with caution.
2. Спецификация Backend API (OpenAPI 3.1.0):
Полная спецификация OpenAPI JSON должна быть доступна Cursor как контекст.
Ключевые эндпоинты: Auth, Users, Addresses, Catalog (Categories, Collections, Products, Variants, Images, Sizes), Cart, Orders, Reviews, Content, Analytics.
Аутентификация: OAuth2 Password Bearer.
3. Документация Frontend API Client (frontend/lib/):
Документация по модулям auth.ts, users.ts и т.д. должна быть доступна Cursor как контекст.
Содержит функции-обертки для вызова API бэкенда.
Стиль Кода и Паттерны
Бэкенд (Python / FastAPI / SQLAlchemy):
Функции: def / async def.
Типизация: Обязательные аннотации типов. Pydantic (schemas/) для API.
Структура: routers, services, repositories, models, schemas.
RORO Паттерн: Принимай/возвращай объекты (схемы/модели).
Обработка Ошибок:
Ранний возврат / guard clauses. Избегай вложенности if.
Используй стандартные HTTPException FastAPI для ошибок API.
В сервисах/репозиториях используй try...except для отлова ожидаемых runtime-ошибок (e.g., NoResultFound от SQLAlchemy). Логируй ошибки.
Зависимости: FastAPI Dependency Injection (Depends()).
ORM: SQLAlchemy 2.0 (async Session).
Фронтенд (TypeScript / React / Next.js):
TypeScript: Строгая типизация. interface для объектов, type для остального. Избегай enum, используй as const или union types. unknown лучше any.
Компоненты React:
Функциональные компоненты с хуками. Строгая типизация props.
Компонентный подход: Активно разбивай UI и логику на меньшие, переиспользуемые компоненты, особенно при работе с Shadcn UI. Создавай компоненты рядом с местом использования или в components/, если они общие.
Next.js App Router:
Приоритет Серверным Компонентам (RSC).
"use client": Только для интерактива/хуков/browser API. Минимизируй их размер.
Suspense: Используй для асинхронной загрузки и улучшения UX.
Стилизация: Tailwind CSS first. Shadcn UI / Radix UI для примитивов. Mobile-first.
API Взаимодействие: Используй функции из frontend/lib/*.ts. Обрабатывай состояния isLoading / error.
Обработка Ошибок: В местах вызова API и в UI используй try...catch. Предоставляй обратную связь пользователю (например, через react-hot-toast или аналоги) и логируй ошибки в консоль или систему мониторинга.
Оптимизация Производительности
Бэкенд: Асинхронность, оптимизация запросов SQLAlchemy, пагинация. Рассмотри кэширование, если потребуется.
Фронтенд: Динамические импорты (next/dynamic), next/image, минимизация клиентского JS, Core Web Vitals.
Проектные Соглашения
API: RESTful.
CORS: Настроен для localhost.
Аутентификация/Авторизация: Реализована (OAuth2).
Тестирование и Развертывание
Пиши код, который легко тестировать (чистые функции, DI), но не генерируй тесты по умолчанию.
Используй Docker и docker-compose.yml.
Обеспечивай валидацию и санитизацию данных.

59
.dockerignore Normal file
View File

@ -0,0 +1,59 @@
# Общие файлы
.git
.github
.gitignore
.DS_Store
README.md
**/test
**/.env
# Файлы и директории для Node.js
**/node_modules
**/npm-debug.log
**/.next
**/dist
**/build
**/.coverage
**/coverage
# Файлы и директории для Python
**/__pycache__
**/*.py[cod]
**/*.so
**/*.egg
**/*.egg-info
**/dist
**/build
**/.pytest_cache
**/.coverage
**/htmlcov
**/.tox
**/.mypy_cache
# Виртуальное окружение Python
**/venv
**/.venv
**/env
**/.env
# Файлы базы данных
**/*.sqlite
**/*.db
# Логи и временные файлы
**/logs
**/*.log
**/tmp
**/*.tmp
# Файлы IDE
**/.idea
**/.vscode
**/*.swp
**/*.swo
# Загружаемые файлы, кроме необходимых для работы
# backend/uploads/*
# !backend/uploads/products
# backend/uploads/products/*
# !backend/uploads/products/.gitkeep

17
.env.production Normal file
View File

@ -0,0 +1,17 @@
# Доменное имя сайта без протокола (http/https)
DOMAIN_NAME=dressedforsuccess.shop
# Протокол (http или https)
PROTOCOL=https
# API URL для браузера (клиентская сторона)
NEXT_PUBLIC_API_URL=${PROTOCOL}://${DOMAIN_NAME}/api
# Base URL для статических файлов
NEXT_PUBLIC_BASE_URL=${PROTOCOL}://${DOMAIN_NAME}
# Режим отладки
NEXT_PUBLIC_DEBUG=false
# Мокирование API
NEXT_PUBLIC_MOCK_API=false

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

28
Dockerfile.backend Normal file
View File

@ -0,0 +1,28 @@
FROM python:3.11-slim
WORKDIR /app
# Установка зависимостей системы
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Установка зависимостей Python
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копирование кода приложения
COPY backend/ .
# Копирование .env.docker в .env для использования в контейнере
COPY backend/.env.docker ./.env
# Создание директории для загрузок если её нет
RUN mkdir -p /app/uploads/products
# Настройка разрешений для директории загрузок
RUN chmod -R 777 /app/uploads
# Открытие порта
EXPOSE 8000
# Запуск приложения с Uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

24
Dockerfile.frontend Normal file
View File

@ -0,0 +1,24 @@
FROM node:20-alpine
WORKDIR /app
# Копирование файлов package.json и package-lock.json
COPY frontend/package*.json ./
# Установка зависимостей с флагом --legacy-peer-deps
RUN npm ci --legacy-peer-deps
# Копирование .env.docker в .env.local для использования в контейнере
COPY frontend/.env.docker ./.env.local
# Копирование исходного кода
COPY frontend/ ./
# Сборка приложения
RUN npm run build
# Открытие порта
EXPOSE 3000
# Запуск приложения
CMD ["sh", "-c", "npm run build && npm start"]

Binary file not shown.

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW 2021.5 -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="101.681mm" height="52.7148mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 9795.77 5078.45"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#2B2B2A;fill-rule:nonzero}
]]>
</style>
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_105553243841888">
<path class="fil0" d="M4908.27 805.19c54.07,19.97 154.75,32.69 273.42,7.79 134.29,-28.17 227.42,-126.87 210.47,-234.42 -15.58,-98.88 -78.5,-144.79 -183.21,-184.87 -5,-103.57 -48.42,-197.68 -117.48,-266.17 -25.62,-25.61 -54.57,-47.33 -86.3,-65.14 -19.36,-12.43 -50.13,-24.51 -52.91,-25.62 30.07,-7.24 62.93,-11.37 101.34,-12.25 160.91,-3.71 233.25,103.02 241.68,154.81l67.38 1.11c-5.58,-28.4 -59.6,-180.41 -318.52,-180.41 -47.89,0 -94.65,8.35 -136.41,22.27 -33.41,-8.91 -66.83,-13.92 -102.48,-13.92 -101.33,0 -403.71,0.56 -403.71,0.56l0 17.25c35.1,0 57.37,8.36 71.83,20.61 26.18,21.17 26.18,55.13 26.18,74.61 0,145.76 0,437.26 0,583.01 0,22.83 0,67.94 -45.67,86.87 -12.8,5.57 -30.08,9.47 -52.34,9.47l0 17.81c0,0 245.13,0 396.09,0 61.7,0 110.63,-13.36 110.63,-13.36zm-333.56 -11.14l0 0 0 -760.64 180.43 0c66.83,0 127.42,11.35 204.92,62.92 94.34,62.78 161.59,156.48 172.05,270.63 16.65,181.55 -83.66,312.64 -175.39,368.06 23.38,10.03 47.31,17.26 62.92,21.17 96.9,-61.25 165.06,-170.19 183.75,-279.54 84.09,39.54 123.05,81.3 129.2,142.55 5,52.9 -21.16,95.78 -63.49,126.95 -57.91,43.44 -148.19,64.63 -225.51,56.8 -162.62,-16.47 -250.22,-113.71 -270.64,-191.55l-70.14 0c12.85,56.55 58.58,124.7 147.55,169.83 0,0 -38.54,12.8 -97.45,12.8 -44.55,0 -178.2,0 -178.2,0z"/>
<path class="fil0" d="M5019.09 410.39c23.38,7.24 44.54,13.92 64.58,20.6 1.12,-21.71 1.69,-51.78 -3.34,-80.74 -187.66,-59.58 -275.07,-87.98 -273.39,-189.32 0.55,-23.94 12.23,-47.33 34.5,-68.49 -13.34,-5.01 -27.84,-9.47 -41.18,-12.25 -42.5,35.61 -54.6,75.9 -54.57,107.47 0.11,118.63 90.75,168.16 273.41,222.73z"/>
</g>
<g id="_105553243834656">
<g>
<path class="fil0" d="M2921.05 1914.15c14.17,-25.91 21.24,-57.16 21.24,-93.77 0,-54.25 -15.78,-95.9 -47.37,-124.97 -31.56,-29.06 -76.18,-43.59 -133.82,-43.59l-123.88 0 0 342.24 138.46 0c32.71,0 61.58,-6.84 86.58,-20.54 25.03,-13.67 44.62,-33.47 58.8,-59.37zm-98.13 10.09l0 0c-14.98,9.62 -32.76,14.44 -53.33,14.44l-60.71 0 0 -231.48 50.76 0c35.47,0 62.75,9.68 81.85,29.03 19.12,19.35 28.68,47.4 28.68,84.16 0,24.29 -4.13,45.26 -12.4,62.91 -8.25,17.66 -19.87,31.3 -34.85,40.94z"/>
<path class="fil0" d="M3167.62 1864.11l84.03 0 79.44 129.96 80.64 0 -92.53 -143.8c23.15,-5.49 41.54,-16.96 55.14,-34.36 13.59,-17.4 20.39,-38.1 20.39,-62.06 0,-32.87 -11.09,-58.09 -33.28,-75.67 -22.19,-17.56 -53.68,-26.34 -94.49,-26.34l-171 0 0 342.24 71.67 0 0 -129.96zm0 -156.67l0 0 91.8 0c21.07,0 36.85,4.12 47.37,12.39 10.53,8.26 15.79,20.57 15.79,36.93 0,16.35 -5.17,29.06 -15.55,38.13 -10.36,9.08 -25.57,13.6 -45.66,13.6l-93.75 0 0 -101.05z"/>
<polygon class="fil0" points="3833.78,1938.68 3626.36,1938.68 3626.36,1848.56 3809.02,1848.56 3809.02,1793.18 3626.36,1793.18 3626.36,1707.2 3823.83,1707.2 3823.83,1651.83 3554.7,1651.83 3554.7,1994.07 3833.78,1994.07 "/>
<path class="fil0" d="M4239.57 1829.39c-4.06,-3.88 -8.85,-7.53 -14.45,-10.94 -5.59,-3.39 -11.96,-6.64 -19.07,-9.72 -7.13,-2.91 -16.61,-5.93 -28.43,-9.1 -11.81,-3.16 -25.91,-6.51 -42.25,-10.07 -28.18,-5.99 -46.57,-10.94 -55.14,-14.83 -9.08,-4.04 -15.92,-8.89 -20.52,-14.58 -4.61,-5.66 -6.93,-12.95 -6.93,-21.85 0,-13.6 5.42,-23.81 16.28,-30.62 10.84,-6.8 27.11,-10.19 48.81,-10.19 20.4,0 36.08,3.72 47,11.17 10.92,7.46 18.17,18.55 21.74,33.28l69.46 -9.47c-6.16,-30.62 -19.87,-52.56 -41.17,-65.84 -21.28,-13.27 -53.14,-19.9 -95.58,-19.9 -44.36,0 -78.17,8.21 -101.4,24.63 -23.25,16.44 -34.86,40.13 -34.86,71.06 0,18.13 3.4,33.27 10.2,45.42 6.8,12.14 16.26,21.85 28.42,29.15 5.5,3.24 11.17,6.19 16.99,8.85 5.83,2.68 13.6,5.42 23.31,8.27 9.72,2.84 23.18,6.27 40.34,10.32 14.9,3.08 27.37,5.87 37.4,8.38 10.04,2.51 17.65,4.82 22.84,6.93 10.52,4.22 18.62,9.48 24.29,15.79 5.66,6.32 8.51,14.34 8.51,24.05 0,15.22 -6.23,26.64 -18.72,34.25 -12.47,7.6 -31.24,11.42 -56.36,11.42 -24.12,0 -42.99,-4.08 -56.59,-12.26 -13.6,-8.17 -22.75,-21.34 -27.44,-39.47l-69.23 11.41c14.25,62.68 64.7,94.01 151.32,94.01 48.1,0 84.59,-8.87 109.44,-26.6 24.84,-17.73 37.26,-43.36 37.26,-76.87 0,-14.26 -2.18,-26.86 -6.54,-37.78 -4.38,-10.92 -10.68,-20.37 -18.95,-28.28z"/>
<path class="fil0" d="M4549.65 1998.92c48.1,0 84.59,-8.87 109.44,-26.6 24.84,-17.73 37.27,-43.36 37.27,-76.87 0,-14.26 -2.19,-26.86 -6.55,-37.78 -4.38,-10.92 -10.68,-20.37 -18.95,-28.28 -4.06,-3.88 -8.85,-7.53 -14.45,-10.94 -5.59,-3.39 -11.96,-6.64 -19.07,-9.72 -7.13,-2.91 -16.61,-5.93 -28.43,-9.1 -11.81,-3.16 -25.91,-6.51 -42.25,-10.07 -28.18,-5.99 -46.57,-10.94 -55.14,-14.83 -9.08,-4.04 -15.92,-8.89 -20.52,-14.58 -4.61,-5.66 -6.93,-12.95 -6.93,-21.85 0,-13.6 5.42,-23.81 16.28,-30.62 10.84,-6.8 27.11,-10.19 48.81,-10.19 20.4,0 36.08,3.72 47,11.17 10.92,7.46 18.17,18.55 21.74,33.28l69.46 -9.47c-6.16,-30.62 -19.87,-52.56 -41.17,-65.84 -21.28,-13.27 -53.14,-19.9 -95.58,-19.9 -44.36,0 -78.17,8.21 -101.4,24.63 -23.25,16.44 -34.86,40.13 -34.86,71.06 0,18.13 3.4,33.27 10.2,45.42 6.8,12.14 16.26,21.85 28.42,29.15 5.5,3.24 11.17,6.19 16.99,8.85 5.83,2.68 13.6,5.42 23.31,8.27 9.72,2.84 23.18,6.27 40.34,10.32 14.9,3.08 27.37,5.87 37.4,8.38 10.04,2.51 17.65,4.82 22.84,6.93 10.52,4.22 18.62,9.48 24.29,15.79 5.66,6.32 8.51,14.34 8.51,24.05 0,15.22 -6.23,26.64 -18.72,34.25 -12.47,7.6 -31.24,11.42 -56.36,11.42 -24.12,0 -42.99,-4.08 -56.59,-12.26 -13.6,-8.17 -22.75,-21.34 -27.44,-39.47l-69.23 11.41c14.25,62.68 64.7,94.01 151.32,94.01z"/>
<polygon class="fil0" points="5117.7,1707.2 5117.7,1651.83 4848.57,1651.83 4848.57,1994.07 5127.66,1994.07 5127.66,1938.68 4920.23,1938.68 4920.23,1848.56 5102.89,1848.56 5102.89,1793.18 4920.23,1793.18 4920.23,1707.2 "/>
<path class="fil0" d="M5279.85 1994.07l138.47 0c32.7,0 61.57,-6.84 86.58,-20.54 25.02,-13.67 44.62,-33.47 58.8,-59.37 14.16,-25.91 21.24,-57.16 21.24,-93.77 0,-54.25 -15.78,-95.9 -47.37,-124.97 -31.57,-29.06 -76.18,-43.59 -133.82,-43.59l-123.89 0 0 342.24zm71.67 -286.87l0 0 50.76 0c35.47,0 62.75,9.68 81.85,29.03 19.12,19.35 28.67,47.4 28.67,84.16 0,24.29 -4.12,45.26 -12.39,62.91 -8.26,17.66 -19.87,31.3 -34.86,40.94 -14.97,9.62 -32.76,14.44 -53.32,14.44l-60.71 0 0 -231.48z"/>
<polygon class="fil0" points="6047.96,1868.49 6223.09,1868.49 6223.09,1813.11 6047.96,1813.11 6047.96,1707.2 6228.67,1707.2 6228.67,1651.83 5976.31,1651.83 5976.31,1994.07 6047.96,1994.07 "/>
<path class="fil0" d="M6446.68 1977.42c25.76,14.34 56.52,21.5 92.3,21.5 34.98,0 65.47,-7.37 91.46,-22.11 25.99,-14.72 46.14,-35.41 60.48,-62.05 14.34,-26.64 21.5,-57.76 21.5,-93.4 0,-36.26 -6.9,-67.45 -20.66,-93.52 -13.75,-26.07 -33.51,-46.11 -59.27,-60.12 -25.74,-14 -56.75,-21 -93.01,-21 -36.12,0 -67.1,6.91 -92.91,20.75 -25.83,13.85 -45.59,33.81 -59.27,59.89 -13.69,26.06 -20.53,57.39 -20.53,93.99 0,36.61 6.87,68.17 20.64,94.74 13.78,26.56 33.54,47 59.27,61.32zm18.47 -243.14l0 0c17.49,-20.81 42.27,-31.2 74.33,-31.2 31.57,0 56.1,10.49 73.58,31.44 17.49,20.97 26.23,49.91 26.23,86.84 0,38.71 -8.69,68.57 -26.11,89.64 -17.41,21.04 -42.14,31.56 -74.21,31.56 -20.57,0 -38.32,-4.89 -53.3,-14.7 -14.98,-9.79 -26.53,-23.76 -34.61,-41.89 -8.1,-18.13 -12.15,-39.67 -12.15,-64.61 0,-37.23 8.74,-66.27 26.23,-87.08z"/>
<path class="fil0" d="M6937.74 1864.11l84.04 0 79.43 129.96 80.64 0 -92.54 -143.8c23.15,-5.49 41.54,-16.96 55.14,-34.36 13.6,-17.4 20.4,-38.1 20.4,-62.06 0,-32.87 -11.1,-58.09 -33.29,-75.67 -22.18,-17.56 -53.68,-26.34 -94.48,-26.34l-171 0 0 342.24 71.66 0 0 -129.96zm0 -156.67l0 0 91.81 0c21.06,0 36.84,4.12 47.37,12.39 10.52,8.26 15.78,20.57 15.78,36.93 0,16.35 -5.17,29.06 -15.55,38.13 -10.36,9.08 -25.57,13.6 -45.66,13.6l-93.75 0 0 -101.05z"/>
</g>
<g>
<path class="fil0" d="M627.35 3128.5c-312.41,-99.91 -458.08,-146.06 -455.14,-313.6 1.93,-109.46 154.21,-225.53 405.68,-225.47 287.79,0.07 385.03,181.46 397.9,255.57l111.56 1.45c-9.62,-46.47 -97.83,-297.67 -524.58,-297.67 -259.34,0 -495.04,135.69 -491.46,309.29 4.01,196.05 149.13,276.7 449.94,366.97 355.51,106.69 500.8,187.23 515.93,345.01 19.96,208.26 -256.33,323.28 -475.22,301.64 -358.92,-35.49 -444.44,-293.58 -445.96,-314.95l-115.99 0c39.03,195.14 288.44,356.1 562.16,356.1 379.65,0 604.74,-178.57 573.21,-410.47 -30.07,-221.31 -214.33,-279.92 -508.03,-373.87z"/>
<path class="fil0" d="M9792.83 3502.37c-30.09,-221.31 -214.33,-279.92 -508.03,-373.87 -312.41,-99.91 -458.08,-146.06 -455.14,-313.6 1.9,-109.46 154.21,-225.53 405.68,-225.47 287.79,0.07 385,181.46 397.9,255.57l111.56 1.45c-9.62,-46.47 -97.83,-297.67 -524.58,-297.67 -259.34,0 -495.04,135.69 -491.46,309.29 4.01,196.05 149.13,276.7 449.95,366.97 355.48,106.69 500.8,187.23 515.92,345.01 19.96,208.26 -256.33,323.28 -475.22,301.64 -358.92,-35.49 -444.44,-293.58 -445.96,-314.95l-116.01 0c39.05,195.14 288.46,356.1 562.18,356.1 379.65,0 604.74,-178.57 573.21,-410.47z"/>
<path class="fil0" d="M5602.13 3400.62c-63.13,271.92 -276.29,471.98 -529.27,471.98 -303.48,0 -549.2,-287.46 -549.2,-641.93 0,-354.48 245.71,-641.94 549.2,-641.94 253.46,0 466.14,200.06 529.27,472.47l132.07 0c-75.26,-294.75 -343.3,-512.77 -661.34,-512.77 -376.82,0 -682.73,305.43 -682.73,682.24 0,377.29 305.91,682.72 682.73,682.72 318.04,0 585.59,-217.54 661.34,-512.77l-132.07 0z"/>
<path class="fil0" d="M7158.78 3127.72l54.39 0 78.2 0c-49.56,-327.76 -332.66,-579.29 -674.5,-579.29 -377.27,0 -682.7,305.43 -682.7,682.24 0,377.29 305.43,682.72 682.7,682.72 258.81,0 483.69,-143.73 599.24,-356.41l-126.28 0c-95.64,188.89 -271.91,315.62 -472.96,315.62 -303.48,0 -549.64,-287.46 -549.64,-641.93 0,-34.96 2.42,-69.44 7.25,-102.95l1084.3 0zm-541.91 -538.99l0 0c261.28,0 480.27,213.66 535.61,499.67l-1071.65 0c55.35,-286.01 274.32,-499.67 536.04,-499.67z"/>
<path class="fil0" d="M8004.3 3128.5c-312.38,-99.91 -458.08,-146.06 -455.14,-313.6 1.93,-109.46 154.21,-225.53 405.7,-225.47 287.79,0.07 385.01,181.46 397.88,255.57l111.56 1.45c-9.62,-46.47 -97.8,-297.67 -524.56,-297.67 -259.33,0 -495.03,135.69 -491.48,309.29 4.03,196.05 149.13,276.7 449.94,366.97 355.51,106.69 500.8,187.23 515.93,345.01 19.96,208.26 -256.3,323.28 -475.22,301.64 -358.9,-35.49 -444.45,-293.58 -445.96,-314.95l-115.99 0c39.03,195.14 288.46,356.1 562.16,356.1 379.64,0 604.74,-178.57 573.23,-410.47 -30.09,-221.31 -214.35,-279.92 -508.05,-373.87z"/>
<path class="fil0" d="M3529.31 2588.72c252.98,0 466.16,200.06 529.28,472.47l131.59 0c-75.26,-294.75 -342.82,-512.77 -660.87,-512.77 -377.29,0 -682.72,305.43 -682.72,682.24 0,377.29 305.43,682.72 682.72,682.72 318.05,0 585.12,-217.54 660.87,-512.77l-131.59 0c-63.12,271.92 -276.77,471.98 -529.28,471.98 -303.48,0 -549.67,-287.46 -549.67,-641.93 0,-354.48 246.18,-641.94 549.67,-641.94z"/>
<path class="fil0" d="M2453.98 2562.09l-0.19 0 0 849.19c0,254.83 -206.58,461.42 -461.41,461.42 -254.86,0 -461.44,-206.59 -461.44,-461.42l0 -849.19 -123.81 0 0 852.58c0,336.18 313.22,498.69 588.65,498.69 205.52,0 381.87,-124.37 458.21,-301.9l0 261.24 123.81 0 0 -1310.94 -123.81 0 0 0.33z"/>
</g>
</g>
<path class="fil0" d="M2863.3 4731.51c41.79,0 74.39,11.02 97.36,33.06 22.96,22.04 35.82,46.84 38.11,74.85l-43.17 0c-5.05,-21.13 -15.15,-38.11 -29.85,-50.51 -14.7,-12.4 -35.37,-18.37 -61.99,-18.37 -32.15,0 -58.32,11.02 -78.53,33.98 -19.75,22.97 -29.86,57.87 -29.86,104.71 0,38.57 9.19,69.8 27.1,94.13 17.91,23.88 45,35.82 80.36,35.82 33.06,0 58.32,-12.4 75.32,-37.65 9.18,-13.32 16.07,-30.77 20.2,-52.81l43.63 0c-3.68,34.9 -16.53,63.83 -38.57,87.25 -26.18,28.47 -61.54,42.71 -105.63,42.71 -38.57,0 -70.72,-11.95 -96.43,-34.9 -34.44,-30.77 -51.44,-78.06 -51.44,-141.9 0,-48.68 12.86,-88.63 38.57,-119.4 27.55,-33.98 66.13,-50.97 114.81,-50.97z"/>
<polygon id="_1" class="fil0" points="3146.94,4740.7 3191.94,4740.7 3191.94,4876.62 3363.23,4876.62 3363.23,4740.7 3408.23,4740.7 3408.23,5069.96 3363.23,5069.96 3363.23,4916.12 3191.94,4916.12 3191.94,5069.96 3146.94,5069.96 "/>
<polygon id="_2" class="fil0" points="3576.6,4740.7 3621.61,4740.7 3621.61,5069.96 3576.6,5069.96 "/>
<polygon id="_3" class="fil0" points="3783.39,4740.7 3827.01,4740.7 3827.01,4900.97 3987.74,4740.7 4049.27,4740.7 3911.96,4873.42 4052.94,5069.96 3995.08,5069.96 3879.82,4904.64 3827.01,4955.15 3827.01,5069.96 3783.39,5069.96 "/>
<path id="_4" class="fil0" d="M4492.31 4868.82c14.23,-10.11 23.87,-18.37 29.38,-24.8 8.73,-10.11 12.86,-21.13 12.86,-33.53 0,-10.11 -3.22,-18.37 -9.64,-25.26 -6.43,-6.89 -14.69,-10.56 -25.71,-10.56 -16.53,0 -28.02,5.51 -34.44,16.53 -3.68,5.51 -5.06,11.94 -5.06,18.82 0,8.73 2.3,17.46 7.35,26.18 5.05,8.28 13.31,19.3 25.26,32.61zm-10.57 173.12c16.53,0 30.77,-3.67 42.71,-11.02 11.95,-7.8 21.59,-16.53 28.02,-25.71l-74.85 -90.92c-21.12,14.24 -34.44,24.8 -40.87,32.15 -10.11,11.47 -15.15,25.25 -15.15,41.33 0,17.45 6.43,30.76 19.29,40.4 12.86,9.19 26.64,13.78 40.87,13.78zm-26.18 -155.21c-14.23,-16.07 -23.42,-29.39 -28.01,-40.41 -4.6,-11.02 -7.35,-21.58 -7.35,-31.69 0,-21.13 7.35,-38.57 21.58,-52.81 14.7,-13.78 33.53,-20.66 57.86,-20.66 22.97,0 40.41,6.44 53.28,19.29 12.85,12.86 19.29,28.48 19.29,46.84 0,21.13 -6.44,39.5 -19.75,55.12 -7.8,9.64 -20.66,20.2 -39.04,32.14l60.16 71.64c4.13,-11.94 6.89,-20.66 8.27,-26.63 1.83,-5.97 3.21,-14.24 5.05,-24.8l38.12 0c-2.3,21.12 -7.35,41.33 -15.15,60.62 -7.81,19.29 -11.48,27.09 -11.48,23.42l58.78 71.17 -52.35 0 -30.77 -37.66c-12.4,13.32 -23.42,22.97 -33.52,28.93 -17.91,11.02 -38.57,16.53 -61.54,16.53 -34.44,0 -59.24,-9.18 -74.85,-28.01 -15.62,-18.37 -23.42,-39.04 -23.42,-62.46 0,-24.8 7.8,-45.46 22.96,-62.46 9.18,-10.1 26.64,-22.96 51.89,-38.11z"/>
<path id="_5" class="fil0" d="M5152.26 4934.95l-50.06 -145.58 -52.8 145.58 102.86 0zm-73.01 -194.25l50.51 0 119.4 329.26 -49.13 0 -33.07 -98.73 -130.41 0 -35.82 98.73 -45.46 0 123.99 -329.26z"/>
<path id="_6" class="fil0" d="M5517.55 4731.51c41.79,0 74.39,11.02 97.36,33.06 22.96,22.04 35.82,46.84 38.11,74.85l-43.17 0c-5.05,-21.13 -15.15,-38.11 -29.85,-50.51 -14.7,-12.4 -35.37,-18.37 -61.99,-18.37 -32.15,0 -58.32,11.02 -78.53,33.98 -19.75,22.97 -29.86,57.87 -29.86,104.71 0,38.57 9.19,69.8 27.1,94.13 17.91,23.88 45,35.82 80.36,35.82 33.06,0 58.32,-12.4 75.32,-37.65 9.18,-13.32 16.07,-30.77 20.2,-52.81l43.63 0c-3.68,34.9 -16.53,63.83 -38.57,87.25 -26.18,28.47 -61.54,42.71 -105.63,42.71 -38.57,0 -70.72,-11.95 -96.43,-34.9 -34.44,-30.77 -51.44,-78.06 -51.44,-141.9 0,-48.68 12.86,-88.63 38.57,-119.4 27.55,-33.98 66.13,-50.97 114.81,-50.97z"/>
<polygon id="_7" class="fil0" points="6039.52,4740.7 6039.52,4779.73 5928.39,4779.73 5928.39,5069.96 5883.39,5069.96 5883.39,4779.73 5772.26,4779.73 5772.26,4740.7 "/>
<polygon id="_8" class="fil0" points="6179.72,4740.7 6224.73,4740.7 6224.73,5069.96 6179.72,5069.96 "/>
<polygon id="_9" class="fil0" points="6412.69,4740.7 6507.28,5021.28 6600.51,4740.7 6650.56,4740.7 6530.7,5069.96 6483.4,5069.96 6363.09,4740.7 "/>
<polygon id="_10" class="fil0" points="6786.25,4740.7 7026.42,4740.7 7026.42,4781.1 6829.87,4781.1 6829.87,4880.76 7011.72,4880.76 7011.72,4918.88 6829.87,4918.88 6829.87,5030.92 7030.09,5030.92 7030.09,5069.96 6786.25,5069.96 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW 2021.5 -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="101.681mm" height="52.664mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 9785.87 5068.43"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#2B2B2A;fill-rule:nonzero}
]]>
</style>
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_105553243821280">
<path class="fil0" d="M4903.31 804.37c54.01,19.95 154.59,32.65 273.14,7.79 134.15,-28.14 227.19,-126.74 210.26,-234.18 -15.56,-98.78 -78.42,-144.64 -183.02,-184.69 -4.99,-103.47 -48.37,-197.48 -117.37,-265.9 -25.59,-25.58 -54.51,-47.28 -86.21,-65.08 -19.34,-12.42 -50.08,-24.48 -52.86,-25.59 30.04,-7.23 62.86,-11.36 101.24,-12.24 160.75,-3.71 233.02,102.92 241.44,154.65l67.31 1.11c-5.57,-28.37 -59.54,-180.23 -318.2,-180.23 -47.84,0 -94.56,8.34 -136.28,22.25 -33.38,-8.9 -66.76,-13.91 -102.37,-13.91 -101.23,0 -403.3,0.56 -403.3,0.56l0 17.24c35.06,0 57.31,8.35 71.76,20.59 26.16,21.14 26.16,55.08 26.16,74.54 0,145.61 0,436.82 0,582.42 0,22.81 0,67.87 -45.63,86.78 -12.79,5.56 -30.05,9.46 -52.29,9.46l0 17.79c0,0 244.88,0 395.69,0 61.64,0 110.52,-13.35 110.52,-13.35zm-333.22 -11.13l0 0 0 -759.87 180.25 0c66.76,0 127.29,11.34 204.71,62.85 94.25,62.72 161.42,156.32 171.88,270.36 16.63,181.37 -83.58,312.32 -175.22,367.69 23.36,10.02 47.26,17.25 62.85,21.14 96.8,-61.19 164.89,-170.02 183.56,-279.25 84.01,39.5 122.93,81.22 129.07,142.41 4.99,52.85 -21.13,95.68 -63.42,126.83 -57.85,43.4 -148.04,64.57 -225.28,56.74 -162.45,-16.46 -249.97,-113.59 -270.37,-191.36l-70.07 0c12.84,56.49 58.52,124.57 147.4,169.66 0,0 -38.51,12.79 -97.35,12.79 -44.5,0 -178.02,0 -178.02,0z"/>
<path class="fil0" d="M5014.02 409.98c23.36,7.23 44.49,13.91 64.51,20.58 1.12,-21.69 1.68,-51.73 -3.34,-80.66 -187.47,-59.52 -274.8,-87.89 -273.11,-189.13 0.55,-23.92 12.22,-47.28 34.46,-68.42 -13.33,-5 -27.81,-9.46 -41.14,-12.24 -42.46,35.57 -54.55,75.83 -54.51,107.37 0.11,118.51 90.66,167.99 273.13,222.51z"/>
</g>
<g id="_105553243768096">
<g>
<path class="fil0" d="M2918.1 1912.22c14.16,-25.88 21.22,-57.1 21.22,-93.67 0,-54.19 -15.76,-95.8 -47.32,-124.84 -31.53,-29.03 -76.11,-43.55 -133.69,-43.55l-123.76 0 0 341.9 138.32 0c32.67,0 61.52,-6.83 86.49,-20.52 25,-13.66 44.58,-33.43 58.74,-59.31zm-98.03 10.08l0 0c-14.97,9.61 -32.73,14.43 -53.28,14.43l-60.65 0 0 -231.25 50.71 0c35.44,0 62.69,9.67 81.77,29 19.1,19.33 28.65,47.35 28.65,84.08 0,24.26 -4.13,45.21 -12.39,62.85 -8.24,17.64 -19.85,31.27 -34.81,40.9z"/>
<path class="fil0" d="M3164.42 1862.22l83.94 0 79.36 129.83 80.55 0 -92.44 -143.66c23.13,-5.49 41.5,-16.94 55.09,-34.33 13.58,-17.38 20.37,-38.06 20.37,-62 0,-32.84 -11.08,-58.03 -33.25,-75.6 -22.16,-17.54 -53.63,-26.31 -94.39,-26.31l-170.83 0 0 341.9 71.59 0 0 -129.83zm0 -156.51l0 0 91.71 0c21.05,0 36.81,4.12 47.32,12.38 10.52,8.25 15.77,20.55 15.77,36.89 0,16.33 -5.17,29.03 -15.53,38.09 -10.35,9.07 -25.54,13.59 -45.62,13.59l-93.65 0 0 -100.95z"/>
<polygon class="fil0" points="3829.91,1936.72 3622.69,1936.72 3622.69,1846.69 3805.17,1846.69 3805.17,1791.37 3622.69,1791.37 3622.69,1705.48 3819.96,1705.48 3819.96,1650.16 3551.11,1650.16 3551.11,1992.05 3829.91,1992.05 "/>
<path class="fil0" d="M4235.28 1827.54c-4.05,-3.88 -8.84,-7.53 -14.44,-10.93 -5.58,-3.39 -11.94,-6.63 -19.05,-9.71 -7.12,-2.91 -16.59,-5.93 -28.4,-9.09 -11.8,-3.16 -25.88,-6.51 -42.21,-10.06 -28.15,-5.99 -46.52,-10.93 -55.09,-14.81 -9.07,-4.03 -15.9,-8.88 -20.5,-14.56 -4.61,-5.66 -6.92,-12.93 -6.92,-21.83 0,-13.59 5.42,-23.79 16.26,-30.59 10.83,-6.79 27.08,-10.18 48.77,-10.18 20.38,0 36.04,3.71 46.96,11.15 10.91,7.45 18.15,18.53 21.72,33.25l69.39 -9.46c-6.15,-30.59 -19.85,-52.51 -41.13,-65.77 -21.26,-13.25 -53.09,-19.88 -95.48,-19.88 -44.32,0 -78.09,8.2 -101.29,24.61 -23.22,16.43 -34.83,40.09 -34.83,70.99 0,18.11 3.4,33.23 10.19,45.38 6.79,12.13 16.25,21.83 28.39,29.12 5.5,3.23 11.15,6.19 16.98,8.84 5.82,2.68 13.59,5.42 23.29,8.26 9.71,2.84 23.16,6.27 40.3,10.31 14.89,3.08 27.34,5.86 37.36,8.37 10.03,2.51 17.63,4.81 22.82,6.92 10.51,4.22 18.6,9.47 24.26,15.77 5.66,6.31 8.5,14.32 8.5,24.02 0,15.21 -6.23,26.61 -18.7,34.21 -12.45,7.59 -31.21,11.4 -56.3,11.4 -24.1,0 -42.94,-4.08 -56.53,-12.25 -13.59,-8.16 -22.72,-21.32 -27.41,-39.43l-69.16 11.39c14.23,62.61 64.64,93.91 151.17,93.91 48.05,0 84.5,-8.86 109.33,-26.57 24.81,-17.71 37.23,-43.32 37.23,-76.79 0,-14.24 -2.18,-26.83 -6.53,-37.75 -4.38,-10.91 -10.67,-20.35 -18.93,-28.26z"/>
<path class="fil0" d="M4545.05 1996.9c48.05,0 84.5,-8.86 109.33,-26.57 24.81,-17.71 37.24,-43.32 37.24,-76.79 0,-14.24 -2.18,-26.83 -6.54,-37.75 -4.38,-10.91 -10.67,-20.35 -18.93,-28.26 -4.05,-3.88 -8.84,-7.53 -14.44,-10.93 -5.58,-3.39 -11.94,-6.63 -19.05,-9.71 -7.12,-2.91 -16.59,-5.93 -28.4,-9.09 -11.8,-3.16 -25.88,-6.51 -42.21,-10.06 -28.15,-5.99 -46.52,-10.93 -55.09,-14.81 -9.07,-4.03 -15.9,-8.88 -20.5,-14.56 -4.61,-5.66 -6.92,-12.93 -6.92,-21.83 0,-13.59 5.42,-23.79 16.26,-30.59 10.83,-6.79 27.08,-10.18 48.77,-10.18 20.38,0 36.04,3.71 46.96,11.15 10.91,7.45 18.15,18.53 21.72,33.25l69.39 -9.46c-6.15,-30.59 -19.85,-52.51 -41.13,-65.77 -21.26,-13.25 -53.09,-19.88 -95.48,-19.88 -44.32,0 -78.09,8.2 -101.29,24.61 -23.22,16.43 -34.83,40.09 -34.83,70.99 0,18.11 3.4,33.23 10.19,45.38 6.79,12.13 16.25,21.83 28.39,29.12 5.5,3.23 11.15,6.19 16.98,8.84 5.82,2.68 13.59,5.42 23.29,8.26 9.71,2.84 23.16,6.27 40.3,10.31 14.89,3.08 27.34,5.86 37.36,8.37 10.03,2.51 17.63,4.81 22.82,6.92 10.51,4.22 18.6,9.47 24.26,15.77 5.66,6.31 8.5,14.32 8.5,24.02 0,15.21 -6.23,26.61 -18.7,34.21 -12.45,7.59 -31.21,11.4 -56.3,11.4 -24.1,0 -42.94,-4.08 -56.53,-12.25 -13.59,-8.16 -22.72,-21.32 -27.41,-39.43l-69.16 11.39c14.23,62.61 64.64,93.91 151.17,93.91z"/>
<polygon class="fil0" points="5112.53,1705.48 5112.53,1650.16 4843.67,1650.16 4843.67,1992.05 5122.48,1992.05 5122.48,1936.72 4915.25,1936.72 4915.25,1846.69 5097.74,1846.69 5097.74,1791.37 4915.25,1791.37 4915.25,1705.48 "/>
<path class="fil0" d="M5274.51 1992.05l138.33 0c32.66,0 61.51,-6.83 86.49,-20.52 24.99,-13.66 44.58,-33.43 58.74,-59.31 14.15,-25.88 21.22,-57.1 21.22,-93.67 0,-54.19 -15.76,-95.8 -47.32,-124.84 -31.54,-29.03 -76.11,-43.55 -133.69,-43.55l-123.77 0 0 341.9zm71.59 -286.58l0 0 50.71 0c35.44,0 62.68,9.67 81.77,29 19.1,19.33 28.64,47.35 28.64,84.08 0,24.26 -4.12,45.21 -12.38,62.85 -8.25,17.64 -19.85,31.27 -34.82,40.9 -14.96,9.61 -32.72,14.43 -53.27,14.43l-60.65 0 0 -231.25z"/>
<polygon class="fil0" points="6041.85,1866.6 6216.8,1866.6 6216.8,1811.27 6041.85,1811.27 6041.85,1705.48 6222.38,1705.48 6222.38,1650.16 5970.26,1650.16 5970.26,1992.05 6041.85,1992.05 "/>
<path class="fil0" d="M6440.16 1975.42c25.73,14.32 56.46,21.48 92.21,21.48 34.95,0 65.41,-7.36 91.37,-22.09 25.97,-14.71 46.09,-35.38 60.42,-61.99 14.32,-26.61 21.48,-57.71 21.48,-93.31 0,-36.23 -6.89,-67.38 -20.64,-93.42 -13.73,-26.04 -33.47,-46.06 -59.21,-60.05 -25.72,-13.98 -56.7,-20.98 -92.92,-20.98 -36.08,0 -67.03,6.9 -92.81,20.73 -25.8,13.84 -45.54,33.77 -59.21,59.83 -13.68,26.03 -20.51,57.33 -20.51,93.89 0,36.57 6.86,68.1 20.61,94.64 13.76,26.53 33.5,46.96 59.21,61.26zm18.45 -242.89l0 0c17.47,-20.79 42.23,-31.17 74.26,-31.17 31.54,0 56.04,10.48 73.51,31.41 17.47,20.95 26.21,49.86 26.21,86.75 0,38.67 -8.68,68.5 -26.08,89.55 -17.39,21.02 -42.1,31.53 -74.13,31.53 -20.55,0 -38.28,-4.89 -53.25,-14.69 -14.97,-9.78 -26.5,-23.73 -34.58,-41.85 -8.09,-18.11 -12.14,-39.63 -12.14,-64.55 0,-37.2 8.73,-66.2 26.21,-86.99z"/>
<path class="fil0" d="M6930.73 1862.22l83.95 0 79.35 129.83 80.56 0 -92.45 -143.66c23.13,-5.49 41.5,-16.94 55.09,-34.33 13.59,-17.38 20.38,-38.06 20.38,-62 0,-32.84 -11.09,-58.03 -33.26,-75.6 -22.15,-17.54 -53.63,-26.31 -94.38,-26.31l-170.83 0 0 341.9 71.58 0 0 -129.83zm0 -156.51l0 0 91.72 0c21.04,0 36.8,4.12 47.32,12.38 10.51,8.25 15.76,20.55 15.76,36.89 0,16.33 -5.17,29.03 -15.53,38.09 -10.35,9.07 -25.54,13.59 -45.62,13.59l-93.65 0 0 -100.95z"/>
</g>
<g>
<path class="fil0" d="M626.71 3125.34c-312.09,-99.81 -457.62,-145.91 -454.68,-313.28 1.92,-109.35 154.05,-225.3 405.27,-225.24 287.5,0.07 384.64,181.28 397.49,255.31l111.45 1.45c-9.61,-46.43 -97.73,-297.37 -524.05,-297.37 -259.08,0 -494.54,135.56 -490.96,308.98 4,195.85 148.98,276.42 449.48,366.6 355.15,106.58 500.3,187.04 515.41,344.66 19.94,208.05 -256.07,322.96 -474.74,301.33 -358.56,-35.46 -443.99,-293.28 -445.51,-314.63l-115.87 0c38.99,194.95 288.15,355.74 561.59,355.74 379.27,0 604.13,-178.39 572.63,-410.05 -30.04,-221.08 -214.12,-279.64 -507.52,-373.49z"/>
<path class="fil0" d="M9782.93 3498.83c-30.06,-221.08 -214.12,-279.64 -507.52,-373.49 -312.09,-99.81 -457.62,-145.91 -454.68,-313.28 1.9,-109.35 154.05,-225.3 405.27,-225.24 287.5,0.07 384.61,181.28 397.49,255.31l111.45 1.45c-9.61,-46.43 -97.73,-297.37 -524.05,-297.37 -259.08,0 -494.54,135.56 -490.96,308.98 4,195.85 148.98,276.42 449.49,366.6 355.12,106.58 500.29,187.04 515.4,344.66 19.94,208.05 -256.07,322.96 -474.74,301.33 -358.56,-35.46 -443.99,-293.28 -445.51,-314.63l-115.89 0c39.01,194.95 288.16,355.74 561.61,355.74 379.27,0 604.13,-178.39 572.63,-410.05z"/>
<path class="fil0" d="M5596.47 3397.18c-63.07,271.65 -276.01,471.5 -528.74,471.5 -303.18,0 -548.64,-287.17 -548.64,-641.28 0,-354.12 245.46,-641.29 548.64,-641.29 253.2,0 465.67,199.85 528.74,471.99l131.94 0c-75.18,-294.45 -342.95,-512.25 -660.67,-512.25 -376.44,0 -682.04,305.12 -682.04,681.55 0,376.91 305.6,682.03 682.04,682.03 317.72,0 585,-217.32 660.67,-512.25l-131.94 0z"/>
<path class="fil0" d="M7151.54 3124.56l54.34 0 78.12 0c-49.51,-327.43 -332.32,-578.71 -673.82,-578.71 -376.89,0 -682.01,305.12 -682.01,681.55 0,376.91 305.12,682.03 682.01,682.03 258.55,0 483.2,-143.58 598.64,-356.05l-126.15 0c-95.55,188.7 -271.63,315.3 -472.49,315.3 -303.18,0 -549.08,-287.17 -549.08,-641.28 0,-34.93 2.42,-69.37 7.25,-102.84l1083.2 0zm-541.36 -538.45l0 0c261.01,0 479.78,213.44 535.07,499.16l-1070.56 0c55.29,-285.72 274.05,-499.16 535.49,-499.16z"/>
<path class="fil0" d="M7996.21 3125.34c-312.06,-99.81 -457.62,-145.91 -454.68,-313.28 1.92,-109.35 154.05,-225.3 405.29,-225.24 287.5,0.07 384.62,181.28 397.47,255.31l111.45 1.45c-9.61,-46.43 -97.7,-297.37 -524.03,-297.37 -259.07,0 -494.53,135.56 -490.98,308.98 4.02,195.85 148.98,276.42 449.48,366.6 355.15,106.58 500.3,187.04 515.41,344.66 19.94,208.05 -256.04,322.96 -474.74,301.33 -358.54,-35.46 -444,-293.28 -445.51,-314.63l-115.87 0c38.99,194.95 288.16,355.74 561.59,355.74 379.26,0 604.13,-178.39 572.65,-410.05 -30.06,-221.08 -214.14,-279.64 -507.54,-373.49z"/>
<path class="fil0" d="M3525.75 2586.11c252.73,0 465.69,199.85 528.75,471.99l131.46 0c-75.18,-294.45 -342.47,-512.25 -660.2,-512.25 -376.91,0 -682.03,305.12 -682.03,681.55 0,376.91 305.12,682.03 682.03,682.03 317.73,0 584.53,-217.32 660.2,-512.25l-131.46 0c-63.06,271.65 -276.49,471.5 -528.75,471.5 -303.18,0 -549.11,-287.17 -549.11,-641.28 0,-354.12 245.93,-641.29 549.11,-641.29z"/>
<path class="fil0" d="M2451.5 2559.5l-0.19 0 0 848.33c0,254.58 -206.37,460.96 -460.95,460.96 -254.61,0 -460.97,-206.38 -460.97,-460.96l0 -848.33 -123.69 0 0 851.72c0,335.84 312.91,498.18 588.05,498.18 205.31,0 381.48,-124.25 457.75,-301.59l0 260.98 123.69 0 0 -1309.62 -123.69 0 0 0.33z"/>
</g>
</g>
<polygon class="fil0" points="2756.34,4729.87 2801.29,4729.87 2801.29,4865.66 2972.41,4865.66 2972.41,4729.87 3017.36,4729.87 3017.36,5058.8 2972.41,5058.8 2972.41,4905.12 2801.29,4905.12 2801.29,5058.8 2756.34,5058.8 "/>
<path id="_1" class="fil0" d="M3317.68 4720.69c57.81,0 100.93,18.82 128.91,55.97 22.02,29.35 32.57,66.52 32.57,111.94 0,49.08 -12.39,89.91 -37.16,122.49 -29.35,38.07 -71.1,57.34 -125.24,57.34 -50.92,0 -90.37,-16.51 -119.27,-50.01 -26.15,-32.57 -39,-73.39 -39,-122.49 0,-44.49 11.01,-82.57 33.03,-114.23 28.44,-40.83 70.65,-61.02 126.15,-61.02zm4.59 307.83c39,0 67.44,-14.22 84.87,-42.2 17.89,-27.99 26.6,-60.55 26.6,-97.26 0,-38.53 -10.09,-69.73 -30.28,-93.12 -20.64,-23.86 -48.17,-35.33 -83.04,-35.33 -34.41,0 -61.93,11.47 -83.49,34.87 -21.56,23.4 -32.12,57.8 -32.12,103.22 0,36.71 9.18,67.44 27.52,92.21 18.35,25.23 48.17,37.62 89.92,37.62z"/>
<path id="_2" class="fil0" d="M3620.6 4729.87l63.77 0 94.5 278 94.05 -278 62.85 0 0 328.92 -42.21 0 0 -194.05c0,-6.88 0,-17.89 0.46,-33.49 0.46,-15.6 0.46,-32.12 0.46,-50.01l-94.05 277.55 -44.04 0 -94.51 -277.55 0 10.1c0,8.26 0,20.18 0.46,36.71 0.46,16.51 0.91,28.9 0.91,36.7l0 194.05 -42.66 0 0 -328.92z"/>
<polygon id="_3" class="fil0" points="4097.01,4729.87 4336.94,4729.87 4336.94,4770.24 4140.59,4770.24 4140.59,4869.79 4322.26,4869.79 4322.26,4907.87 4140.59,4907.87 4140.59,5019.8 4340.61,5019.8 4340.61,5058.8 4097.01,5058.8 "/>
<path id="_4" class="fil0" d="M5345.98 4952.83c0.92,18.35 5.5,33.49 12.85,45.42 15.14,21.56 40.83,32.57 78.45,32.57 16.51,0 32.12,-2.29 45.42,-7.33 26.61,-9.18 39.91,-25.7 39.91,-49.54 0,-17.89 -5.5,-30.74 -16.51,-38.53 -11.47,-7.34 -29.36,-13.76 -53.21,-19.27l-44.96 -10.1c-28.9,-6.42 -49.54,-13.76 -61.47,-21.56 -21.11,-13.76 -31.65,-34.41 -31.65,-61.94 0,-29.35 10.55,-53.67 30.74,-72.48 20.64,-19.27 49.54,-28.44 87.16,-28.44 34.41,0 63.77,8.26 87.63,24.77 24.31,16.51 36.24,43.12 36.24,79.82l-41.75 0c-2.3,-17.44 -6.88,-31.2 -14.22,-40.37 -13.76,-17.43 -36.7,-25.69 -69.27,-25.69 -26.6,0 -45.42,5.5 -56.88,16.51 -11.47,11.01 -16.98,23.85 -16.98,38.53 0,16.05 6.42,27.99 19.73,35.32 9.17,4.59 28.9,10.56 60.09,17.89l45.88 10.56c22.47,5.04 39.45,11.92 51.84,20.64 20.64,15.59 31.2,38.07 31.2,66.97 0,36.7 -13.31,62.85 -39.91,78.45 -26.16,15.6 -56.89,23.4 -92.22,23.4 -40.83,0 -72.94,-10.55 -95.88,-31.19 -23.4,-21.11 -34.87,-49.09 -34.41,-84.41l42.2 0z"/>
<polygon id="_5" class="fil0" points="5711.83,4729.87 5756.32,4729.87 5756.32,5019.8 5923.31,5019.8 5923.31,5058.8 5711.83,5058.8 "/>
<polygon id="_6" class="fil0" points="6060.32,4729.87 6300.25,4729.87 6300.25,4770.24 6103.9,4770.24 6103.9,4869.79 6285.56,4869.79 6285.56,4907.87 6103.9,4907.87 6103.9,5019.8 6303.91,5019.8 6303.91,5058.8 6060.32,5058.8 "/>
<polygon id="_7" class="fil0" points="6455.52,4729.87 6695.45,4729.87 6695.45,4770.24 6499.1,4770.24 6499.1,4869.79 6680.76,4869.79 6680.76,4907.87 6499.1,4907.87 6499.1,5019.8 6699.12,5019.8 6699.12,5058.8 6455.52,5058.8 "/>
<path id="_8" class="fil0" d="M6850.72 4729.87l148.17 0c29.36,0 52.76,8.26 71.11,24.77 17.89,16.51 26.6,39.45 26.6,69.26 0,25.7 -7.8,48.18 -23.85,67.45 -16.06,18.81 -40.84,28.44 -73.86,28.44l-103.22 0 0 139 -44.95 0 0 -328.92zm200.93 94.5c0,-24.31 -9.18,-40.83 -27.07,-49.54 -9.63,-4.59 -23.4,-6.88 -40.36,-6.88l-88.54 0 0 114.23 88.54 0c20.18,0 36.23,-4.58 48.62,-12.84 12.4,-8.72 18.82,-23.4 18.82,-44.96z"/>
<path id="_9" class="fil0" d="M4801.09 4857.86c14.21,-10.1 23.85,-18.35 29.35,-24.77 8.72,-10.1 12.85,-21.11 12.85,-33.49 0,-10.1 -3.21,-18.35 -9.63,-25.23 -6.42,-6.88 -14.68,-10.55 -25.69,-10.55 -16.51,0 -27.99,5.5 -34.41,16.51 -3.68,5.5 -5.05,11.92 -5.05,18.81 0,8.72 2.3,17.44 7.34,26.15 5.04,8.27 13.3,19.28 25.23,32.58zm-10.56 172.94c16.51,0 30.74,-3.67 42.66,-11.01 11.93,-7.8 21.57,-16.51 27.99,-25.69l-74.78 -90.83c-21.1,14.22 -34.41,24.77 -40.83,32.12 -10.1,11.46 -15.14,25.22 -15.14,41.29 0,17.43 6.42,30.73 19.27,40.36 12.85,9.18 26.61,13.76 40.83,13.76zm-26.15 -155.05c-14.21,-16.05 -23.4,-29.36 -27.98,-40.37 -4.59,-11.01 -7.34,-21.56 -7.34,-31.65 0,-21.11 7.34,-38.53 21.56,-52.76 14.69,-13.76 33.49,-20.64 57.8,-20.64 22.94,0 40.37,6.43 53.22,19.27 12.84,12.85 19.27,28.45 19.27,46.79 0,21.11 -6.43,39.46 -19.73,55.06 -7.8,9.63 -20.64,20.18 -39,32.11l60.1 71.56c4.13,-11.92 6.88,-20.64 8.26,-26.6 1.83,-5.97 3.2,-14.22 5.04,-24.77l38.08 0c-2.3,21.1 -7.34,41.29 -15.14,60.55 -7.81,19.27 -11.47,27.06 -11.47,23.4l58.72 71.1 -52.3 0 -30.74 -37.62c-12.39,13.31 -23.4,22.94 -33.48,28.9 -17.89,11.01 -38.53,16.51 -61.48,16.51 -34.41,0 -59.18,-9.17 -74.77,-27.98 -15.6,-18.35 -23.4,-39 -23.4,-62.39 0,-24.77 7.8,-45.42 22.93,-62.39 9.17,-10.09 26.61,-22.93 51.84,-38.07z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW 2021.5 -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100.188mm" height="15.1954mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 36141.04 5481.48"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#2B2B2A;fill-rule:nonzero}
]]>
</style>
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_105553227186208">
<g>
<path class="fil0" d="M4500.43 2322.22c-1244.24,-395.04 -1823.79,-583.31 -1812.61,-1255.24 3.61,-158.76 81.13,-313.84 228.74,-454.13 -88.49,-33.22 -184.59,-62.77 -273.07,-81.2 -281.8,236.03 -362,503.22 -361.82,712.52 0.72,786.61 601.74,1114.99 1812.79,1476.77 155.04,48.01 295.3,92.31 428.15,136.61 7.36,-144 11.15,-343.35 -22.19,-535.33zm2067.5 1513.71l0 0c-103.28,-655.56 -520.43,-959.91 -1214.66,-1225.73 -33.15,-686.69 -321.05,-1310.61 -778.93,-1764.74 -169.83,-169.83 -361.82,-313.8 -572.19,-431.94 -128.42,-82.39 -332.42,-162.44 -350.78,-169.83 199.38,-47.98 417.19,-75.36 671.83,-81.24 1066.9,-24.6 1546.57,683.08 1602.45,1026.36l446.7 7.4c-36.94,-188.3 -395.15,-1196.19 -2111.87,-1196.19 -317.44,0 -627.49,55.41 -904.39,147.68 -221.56,-59.09 -443.09,-92.28 -679.44,-92.28 -671.87,0 -2676.64,3.64 -2676.64,3.64l0 114.46c232.71,0 380.36,55.37 476.28,136.61 173.58,140.29 173.58,365.49 173.58,494.71 0,966.37 0,2899.1 0,3865.47 0,151.36 0,450.41 -302.84,575.95 -84.92,36.9 -199.38,62.73 -347.03,62.73l0 118.14c0,0 1625.25,0 2626.17,0 409.11,0 733.51,-88.6 733.51,-88.6 358.5,132.39 1026.03,216.73 1812.79,51.69 890.32,-186.82 1507.79,-841.16 1395.46,-1554.29zm-815.91 1111.27l0 0c-383.96,287.94 -982.49,428.48 -1495.17,376.53 -1078.19,-109.19 -1658.97,-753.86 -1794.39,-1270l-465.09 0c85.21,374.98 388.47,826.8 978.34,1126.03 0,0 -255.58,84.92 -646.11,84.92 -295.37,0 -1181.47,0 -1181.47,0l0 -5043.15 1196.26 0c443.12,0 844.84,75.21 1358.7,417.19 625.51,416.25 1071.38,1037.47 1140.75,1794.29 110.35,1203.73 -554.74,2072.88 -1162.93,2440.36 155.04,66.45 313.66,114.42 417.15,140.29 642.47,-406.15 1094.39,-1128.41 1218.27,-1853.37 557.55,262.14 815.91,539.04 856.63,945.16 33.15,350.74 -140.25,635 -420.94,841.77z"/>
</g>
<g>
<path class="fil0" d="M12099.09 1010.7c0,205.29 -39.93,381.94 -119.58,530.17 -79.61,148.26 -190.39,262.36 -332.6,342.26 -142.2,80.19 -307.24,120.12 -495.18,120.12l-727.45 0 0 -1945.43 643.4 0c329.31,0 583.63,82.68 762.77,248 179.1,165.07 268.64,400.12 268.64,704.87zm-265.07 0l0 0c0,-241.11 -66.12,-425.16 -198.11,-551.67 -132.28,-126.47 -323.51,-189.85 -573.75,-189.85l-374.19 0 0 1523.01 433.46 0c144.4,0 270.04,-31.96 376.14,-95.34 106.34,-63.67 189.02,-153.74 248,-270.59 58.98,-116.84 88.45,-255.47 88.45,-415.56z"/>
<path class="fil0" d="M13295.57 1195.61l0 807.64 -263.7 0 0 -1945.43 915.4 0c219.07,0 388.26,49.06 507.3,147.14 119.33,98.12 178.85,234.51 178.85,409.5 0,144.37 -42.17,265.9 -126.22,364.27 -84.3,98.66 -200.6,160.67 -348.83,186.57l552.46 837.95 -303.92 0 -505.39 -807.64 -605.96 0zm1072.75 -578.4l0 0c0,-113.27 -38.56,-199.52 -115.18,-259.04 -76.87,-59.23 -187.65,-88.99 -332.05,-88.99l-625.51 0 0 717.82 636.55 0c138.88,0 246.34,-32.21 322.42,-97.25 75.75,-64.75 113.78,-155.69 113.78,-272.53z"/>
<polygon class="fil0" points="17171.86,1788.04 17171.86,2003.26 15639.47,2003.26 15639.47,57.83 17115.37,57.83 17115.37,273.33 15903.2,273.33 15903.2,897.47 17032.44,897.47 17032.44,1109.9 15903.2,1109.9 15903.2,1788.04 "/>
<path class="fil0" d="M19615.25 1466.2c0,179.68 -70.02,318.56 -210.52,416.94 -140.29,98.66 -338.11,147.68 -593.01,147.68 -473.97,0 -748.7,-164.49 -824.2,-494.06l255.43 -51.26c29.51,117.13 92.06,202.8 187.65,257.64 95.92,54.83 225.96,82.14 390.75,82.14 170.3,0 301.75,-29.22 394.32,-87.62 92.6,-58.44 138.63,-144.4 138.63,-257.67 0,-63.38 -14.36,-114.89 -43.29,-154.57 -29.18,-39.68 -69.69,-72.22 -122.32,-98.12 -52.38,-25.61 -114.93,-47.36 -187.65,-64.75 -72.76,-17.64 -153.24,-36.36 -241.69,-56.74 -102.23,-22.87 -187.65,-45.49 -256.26,-67.53 -68.32,-22.04 -124.81,-45.45 -168.9,-69.69 -44.37,-24.53 -82.14,-50.43 -113.27,-77.99 -46.03,-42.46 -81.27,-91.77 -105.8,-147.72 -24.24,-56.2 -36.4,-120.67 -36.4,-193.42 0,-166.44 63.67,-294.86 191.26,-385.23 127.3,-90.11 310.01,-135.31 547.27,-135.31 220.98,0 389.92,33.91 506.72,101.4 116.84,67.78 198.98,182.96 245.8,346.12l-259.58 45.45c-28.35,-103.06 -82.93,-178.02 -162.83,-224.3 -80.19,-46.57 -190.97,-69.73 -332.88,-69.73 -155.4,0 -274.16,25.65 -356.01,77.16 -82.14,51.55 -122.9,128.42 -122.9,230.65 0,59.81 15.69,109.41 47.4,148.51 31.96,39.14 77.7,71.93 137.51,98.66 56.2,26.73 175.24,60.35 357.67,100.86 74.38,17.35 148.23,35.53 220.7,54.54 72.76,18.76 141.37,42.46 205.87,71.1 37.73,15.44 73.84,34.74 108.29,57.32 34.7,22.33 64.75,48.48 90.65,77.99 34.7,38.56 62.26,84.05 82.1,136.68 19.59,52.34 29.51,114.06 29.51,184.88z"/>
<path class="fil0" d="M22066.61 1466.2c0,179.68 -69.98,318.56 -210.52,416.94 -140.25,98.66 -338.11,147.68 -593.01,147.68 -473.97,0 -748.7,-164.49 -824.2,-494.06l255.43 -51.26c29.51,117.13 92.06,202.8 187.65,257.64 95.92,54.83 225.96,82.14 390.75,82.14 170.3,0 301.75,-29.22 394.35,-87.62 92.56,-58.44 138.59,-144.4 138.59,-257.67 0,-63.38 -14.32,-114.89 -43.25,-154.57 -29.22,-39.68 -69.73,-72.22 -122.36,-98.12 -52.34,-25.61 -114.89,-47.36 -187.65,-64.75 -72.76,-17.64 -153.2,-36.36 -241.65,-56.74 -102.23,-22.87 -187.65,-45.49 -256.3,-67.53 -68.32,-22.04 -124.81,-45.45 -168.9,-69.69 -44.37,-24.53 -82.14,-50.43 -113.27,-77.99 -45.99,-42.46 -81.27,-91.77 -105.8,-147.72 -24.24,-56.2 -36.36,-120.67 -36.36,-193.42 0,-166.44 63.63,-294.86 191.22,-385.23 127.3,-90.11 310.01,-135.31 547.27,-135.31 220.98,0 389.92,33.91 506.76,101.4 116.81,67.78 198.94,182.96 245.77,346.12l-259.55 45.45c-28.39,-103.06 -82.97,-178.02 -162.87,-224.3 -80.19,-46.57 -190.97,-69.73 -332.88,-69.73 -155.4,0 -274.16,25.65 -356.01,77.16 -82.1,51.55 -122.9,128.42 -122.9,230.65 0,59.81 15.69,109.41 47.4,148.51 31.96,39.14 77.7,71.93 137.51,98.66 56.2,26.73 175.24,60.35 357.67,100.86 74.38,17.35 148.26,35.53 220.73,54.54 72.72,18.76 141.34,42.46 205.83,71.1 37.73,15.44 73.84,34.74 108.29,57.32 34.7,22.33 64.75,48.48 90.65,77.99 34.74,38.56 62.3,84.05 82.14,136.68 19.55,52.34 29.47,114.06 29.47,184.88z"/>
<polygon class="fil0" points="24526.52,1788.04 24526.52,2003.26 22994.13,2003.26 22994.13,57.83 24470.03,57.83 24470.03,273.33 23257.86,273.33 23257.86,897.47 24387.1,897.47 24387.1,1109.9 23257.86,1109.9 23257.86,1788.04 "/>
<path class="fil0" d="M27120.62 1010.7c0,205.29 -39.97,381.94 -119.58,530.17 -79.65,148.26 -190.43,262.36 -332.6,342.26 -142.2,80.19 -307.27,120.12 -495.18,120.12l-727.49 0 0 -1945.43 643.44 0c329.28,0 583.63,82.68 762.73,248 179.1,165.07 268.67,400.12 268.67,704.87zm-265.1 0l0 0c0,-241.11 -66.12,-425.16 -198.11,-551.67 -132.28,-126.47 -323.51,-189.85 -573.71,-189.85l-374.22 0 0 1523.01 433.46 0c144.4,0 270.04,-31.96 376.14,-95.34 106.38,-63.67 189.06,-153.74 248,-270.59 58.98,-116.84 88.45,-255.47 88.45,-415.56z"/>
<polygon class="fil0" points="29668.16,1214.91 29668.16,2003.26 29404.46,2003.26 29404.46,57.83 30786.39,57.83 30786.39,273.33 29668.16,273.33 29668.16,996.92 30753.31,996.92 30753.31,1214.91 "/>
<path class="fil0" d="M33529.3 1021.74c0,208.03 -39.43,387.43 -118.21,538.43 -78.53,151 -190.14,267.01 -334.83,348.58 -144.37,81.56 -315.5,122.07 -513.61,122.07 -198.69,0 -370.08,-40.22 -514.19,-121.24 -144.15,-81.02 -254.89,-197.03 -332.88,-348.04 -77.7,-151 -116.55,-330.94 -116.55,-539.8 0,-208.03 38.27,-386.06 115.18,-533.78 76.87,-147.68 187.36,-261.21 331.48,-340.32 144.11,-79.07 317.16,-118.75 519.71,-118.75 198.94,0 370.36,39.14 514.48,117.38 144.11,78.28 254.89,191.26 332.6,338.94 77.99,147.68 116.84,326.54 116.84,536.52zm-269.21 0l0 0c0,-242.23 -60.64,-432.37 -181.59,-570.43 -120.99,-138.05 -292.12,-206.92 -513.11,-206.92 -222.64,0 -394.86,68.03 -516.39,204.17 -121.24,136.39 -182.13,327.37 -182.13,573.17 0,162.01 27.52,302.55 82.93,421.59 55.12,119.33 134.73,211.35 238.91,276.39 103.89,64.75 228.7,97.29 373.94,97.29 224.56,0 397.06,-69.19 517.22,-207.78 120.12,-138.59 180.22,-334.54 180.22,-587.49z"/>
<path class="fil0" d="M34725.78 1195.61l0 807.64 -263.73 0 0 -1945.43 915.4 0c219.07,0 388.29,49.06 507.33,147.14 119.29,98.12 178.82,234.51 178.82,409.5 0,144.37 -42.17,265.9 -126.18,364.27 -84.34,98.66 -200.6,160.67 -348.86,186.57l552.5 837.95 -303.95 0 -505.39 -807.64 -605.92 0zm1072.75 -578.4l0 0c0,-113.27 -38.6,-199.52 -115.18,-259.04 -76.91,-59.23 -187.65,-88.99 -332.05,-88.99l-625.51 0 0 717.82 636.51 0c138.88,0 246.34,-32.21 322.42,-97.25 75.75,-64.75 113.81,-155.69 113.81,-272.53z"/>
<path class="fil0" d="M11948.38 4859.43c0,179.68 -69.98,318.56 -210.52,416.94 -140.25,98.62 -338.11,147.68 -593.01,147.68 -473.97,0 -748.7,-164.49 -824.2,-494.06l255.47 -51.26c29.47,117.09 92.02,202.8 187.65,257.64 95.88,54.83 225.93,82.14 390.71,82.14 170.3,0 301.75,-29.22 394.35,-87.66 92.56,-58.4 138.59,-144.37 138.59,-257.64 0,-63.38 -14.32,-114.89 -43.25,-154.57 -29.22,-39.68 -69.73,-72.22 -122.36,-98.12 -52.34,-25.61 -114.89,-47.4 -187.65,-64.75 -72.76,-17.64 -153.2,-36.36 -241.65,-56.78 -102.23,-22.87 -187.65,-45.45 -256.26,-67.49 -68.36,-22.04 -124.85,-45.45 -168.93,-69.73 -44.37,-24.53 -82.1,-50.43 -113.27,-77.95 -45.99,-42.46 -81.27,-91.77 -105.8,-147.72 -24.24,-56.2 -36.36,-120.7 -36.36,-193.42 0,-166.44 63.63,-294.86 191.22,-385.23 127.3,-90.11 310.01,-135.31 547.27,-135.31 220.98,0 389.92,33.87 506.76,101.4 116.84,67.78 198.94,182.96 245.8,346.09l-259.58 45.49c-28.39,-103.06 -82.97,-178.02 -162.87,-224.3 -80.19,-46.57 -190.97,-69.73 -332.88,-69.73 -155.4,0 -274.16,25.65 -356.01,77.16 -82.1,51.51 -122.9,128.42 -122.9,230.65 0,59.77 15.73,109.37 47.4,148.51 31.96,39.14 77.7,71.93 137.51,98.66 56.2,26.73 175.24,60.35 357.67,100.86 74.42,17.35 148.26,35.53 220.73,54.54 72.72,18.76 141.37,42.42 205.83,71.1 37.77,15.44 73.84,34.7 108.29,57.32 34.74,22.33 64.75,48.48 90.65,77.99 34.74,38.56 62.3,84.01 82.14,136.65 19.55,52.38 29.47,114.1 29.47,184.91z"/>
<path class="fil0" d="M13653.27 5424.05c-159.3,0 -298.43,-29.22 -416.94,-88.16 -118.75,-58.98 -210.81,-143.32 -276.39,-252.69 -65.29,-109.66 -97.83,-240.86 -97.83,-393.49l0 -1238.65 263.73 0 0 1216.57c0,177.48 44.91,312.5 135.27,404.53 90.11,92.06 220.48,138.05 390.75,138.05 174.7,0 310.84,-47.65 407.84,-142.99 97.25,-95.09 145.77,-234.51 145.77,-417.48l0 -1198.68 262.32 0 0 1213.83c0,157.35 -33.33,292.66 -100.03,405.9 -66.95,113.27 -161.21,200.35 -283.28,261.5 -121.78,61.18 -265.64,91.77 -431.22,91.77z"/>
<path class="fil0" d="M16355.92 5207.47c272.53,0 477.29,-135.02 614.51,-404.53l215.5 107.75c-80.19,167.52 -192.63,294.83 -337.57,382.2 -144.94,87.62 -313.3,131.16 -504.81,131.16 -197.03,0 -366.22,-40.76 -508.13,-122.61 -141.66,-82.14 -250.49,-198.69 -326.54,-350.24 -75.79,-151.29 -113.81,-330.14 -113.81,-536.23 0,-207.24 37.73,-384.69 113.23,-532.95 75.5,-148.26 184.08,-262.04 325.71,-341.15 141.91,-79.07 311.13,-118.75 508.13,-118.75 207.24,0 380.28,40.51 519.17,121.53 139.17,80.98 241.11,201.14 306.7,360.41l-249.92 82.68c-45.2,-112.98 -117.67,-199.52 -217.41,-259.29 -100.03,-60.1 -218.53,-89.86 -355.75,-89.86 -215.21,0 -382.77,69.19 -502.36,207.78 -119.87,138.59 -179.64,328.48 -179.64,569.6 0,158.15 28.35,297.03 84.84,416.07 56.78,119.33 136.68,211.93 240.32,277.76 103.6,65.58 226.22,98.66 367.84,98.66z"/>
<path class="fil0" d="M18963.55 5207.47c272.53,0 477.25,-135.02 614.47,-404.53l215.5 107.75c-80.19,167.52 -192.63,294.83 -337.57,382.2 -144.94,87.62 -313.3,131.16 -504.81,131.16 -197.03,0 -366.22,-40.76 -508.13,-122.61 -141.62,-82.14 -250.49,-198.69 -326.54,-350.24 -75.79,-151.29 -113.81,-330.14 -113.81,-536.23 0,-207.24 37.77,-384.69 113.27,-532.95 75.5,-148.26 184.05,-262.04 325.71,-341.15 141.91,-79.07 311.1,-118.75 508.13,-118.75 207.2,0 380.25,40.51 519.13,121.53 139.17,80.98 241.11,201.14 306.7,360.41l-249.92 82.68c-45.2,-112.98 -117.67,-199.52 -217.41,-259.29 -100.03,-60.1 -218.53,-89.86 -355.75,-89.86 -215.21,0 -382.74,69.19 -502.32,207.78 -119.87,138.59 -179.68,328.48 -179.68,569.6 0,158.15 28.39,297.03 84.88,416.07 56.74,119.33 136.68,211.93 240.28,277.76 103.6,65.58 226.22,98.66 367.88,98.66z"/>
<polygon class="fil0" points="22230.85,5181.28 22230.85,5396.49 20698.46,5396.49 20698.46,3451.06 22174.36,3451.06 22174.36,3666.56 20962.15,3666.56 20962.15,4290.7 22091.39,4290.7 22091.39,4503.14 20962.15,4503.14 20962.15,5181.28 "/>
<path class="fil0" d="M24674.2 4859.43c0,179.68 -69.98,318.56 -210.52,416.94 -140.25,98.62 -338.11,147.68 -593.01,147.68 -473.93,0 -748.67,-164.49 -824.17,-494.06l255.43 -51.26c29.47,117.09 92.02,202.8 187.65,257.64 95.88,54.83 225.96,82.14 390.75,82.14 170.3,0 301.72,-29.22 394.32,-87.66 92.6,-58.4 138.59,-144.37 138.59,-257.64 0,-63.38 -14.32,-114.89 -43.25,-154.57 -29.22,-39.68 -69.73,-72.22 -122.36,-98.12 -52.34,-25.61 -114.89,-47.4 -187.65,-64.75 -72.72,-17.64 -153.2,-36.36 -241.65,-56.78 -102.23,-22.87 -187.65,-45.45 -256.26,-67.49 -68.36,-22.04 -124.81,-45.45 -168.93,-69.73 -44.37,-24.53 -82.1,-50.43 -113.23,-77.95 -46.03,-42.46 -81.31,-91.77 -105.84,-147.72 -24.24,-56.2 -36.36,-120.7 -36.36,-193.42 0,-166.44 63.67,-294.86 191.22,-385.23 127.34,-90.11 310.01,-135.31 547.27,-135.31 221.02,0 389.92,33.87 506.76,101.4 116.84,67.78 198.94,182.96 245.8,346.09l-259.58 45.49c-28.39,-103.06 -82.93,-178.02 -162.83,-224.3 -80.19,-46.57 -190.97,-69.73 -332.88,-69.73 -155.4,0 -274.19,25.65 -356.04,77.16 -82.1,51.51 -122.9,128.42 -122.9,230.65 0,59.77 15.73,109.37 47.4,148.51 31.96,39.14 77.74,71.93 137.51,98.66 56.2,26.73 175.24,60.35 357.67,100.86 74.42,17.35 148.26,35.53 220.73,54.54 72.76,18.76 141.37,42.42 205.83,71.1 37.77,15.44 73.88,34.7 108.29,57.32 34.74,22.33 64.79,48.48 90.69,77.99 34.7,38.56 62.26,84.01 82.1,136.65 19.55,52.38 29.47,114.1 29.47,184.91z"/>
<path class="fil0" d="M27125.85 4859.43c0,179.68 -69.98,318.56 -210.52,416.94 -140.25,98.62 -338.11,147.68 -593.01,147.68 -473.97,0 -748.67,-164.49 -824.2,-494.06l255.47 -51.26c29.47,117.09 92.02,202.8 187.65,257.64 95.88,54.83 225.96,82.14 390.75,82.14 170.27,0 301.72,-29.22 394.32,-87.66 92.56,-58.4 138.59,-144.37 138.59,-257.64 0,-63.38 -14.32,-114.89 -43.25,-154.57 -29.22,-39.68 -69.73,-72.22 -122.36,-98.12 -52.34,-25.61 -114.89,-47.4 -187.65,-64.75 -72.76,-17.64 -153.2,-36.36 -241.65,-56.78 -102.23,-22.87 -187.65,-45.45 -256.26,-67.49 -68.36,-22.04 -124.85,-45.45 -168.93,-69.73 -44.37,-24.53 -82.1,-50.43 -113.23,-77.95 -46.03,-42.46 -81.31,-91.77 -105.84,-147.72 -24.24,-56.2 -36.36,-120.7 -36.36,-193.42 0,-166.44 63.63,-294.86 191.22,-385.23 127.3,-90.11 310.01,-135.31 547.27,-135.31 220.98,0 389.92,33.87 506.76,101.4 116.84,67.78 198.94,182.96 245.8,346.09l-259.58 45.49c-28.39,-103.06 -82.93,-178.02 -162.87,-224.3 -80.19,-46.57 -190.94,-69.73 -332.85,-69.73 -155.44,0 -274.19,25.65 -356.04,77.16 -82.1,51.51 -122.9,128.42 -122.9,230.65 0,59.77 15.73,109.37 47.4,148.51 31.96,39.14 77.7,71.93 137.51,98.66 56.2,26.73 175.24,60.35 357.67,100.86 74.42,17.35 148.26,35.53 220.73,54.54 72.76,18.76 141.37,42.42 205.83,71.1 37.77,15.44 73.84,34.7 108.29,57.32 34.74,22.33 64.75,48.48 90.65,77.99 34.74,38.56 62.3,84.01 82.14,136.65 19.55,52.38 29.47,114.1 29.47,184.91z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

112
README.md Normal file
View File

@ -0,0 +1,112 @@
# Dressed for Success - Интернет-магазин одежды
## О проекте
Интернет-магазин модной одежды Dressed for Success, созданный с использованием современных технологий:
- **Фронтенд**: Next.js, React, TypeScript, Tailwind CSS
- **Бэкенд**: FastAPI, SQLAlchemy, PostgreSQL
- **Развертывание**: Docker, Docker Compose
## Требования
Для запуска проекта вам потребуются:
- Docker
- Docker Compose
## Запуск проекта
### 1. Клонирование репозитория
```bash
git clone https://github.com/username/dressed_for_success_store.git
cd dressed_for_success_store
```
### 2. Запуск через Docker Compose
```bash
docker-compose up -d
```
Это запустит:
- Бэкенд на порту 8000
- Фронтенд на порту 3000
- PostgreSQL на порту 5432
### 3. Доступ к приложению
- **Фронтенд**: http://localhost:3000
- **API бэкенда**: http://localhost:8000/api
- **Документация API**: http://localhost:8000/docs
## Остановка проекта
```bash
docker-compose down
```
Для удаления томов (данных базы данных и загруженных файлов):
```bash
docker-compose down -v
```
## Разработка
### Структура проекта
```
.
├── backend/ # Бэкенд на FastAPI
│ ├── app/ # Код приложения
│ ├── uploads/ # Загружаемые файлы
│ └── requirements.txt # Зависимости Python
├── frontend/ # Фронтенд на Next.js
│ ├── app/ # Код Next.js приложения
│ ├── components/ # React компоненты
│ ├── lib/ # Библиотеки и утилиты
│ └── public/ # Статические файлы
├── docker-compose.yml # Конфигурация Docker Compose
├── Dockerfile.backend # Dockerfile для бэкенда
└── Dockerfile.frontend # Dockerfile для фронтенда
```
## Переменные окружения
### Фронтенд
Основные переменные окружения для фронтенда (файл `.env.local` или `.env.docker`):
```
NEXT_PUBLIC_API_URL=http://localhost:8000/api
NEXT_PUBLIC_BASE_URL=http://localhost:8000
NEXT_PUBLIC_DEBUG=false
NEXT_PUBLIC_MOCK_API=false
```
### Бэкенд
Основные переменные окружения для бэкенда (файл `.env` или `.env.docker`):
```
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shop_db
SECRET_KEY=supersecretkey
DEBUG=0
UPLOAD_DIRECTORY=/app/uploads
```
## Лицензия
[MIT License](LICENSE)
# Сначала получаем SSL-сертификат
./init-letsencrypt.sh ваш-домен.ru
# Затем запускаем сервисы
docker-compose -f docker-compose.prod.yml up -d

BIN
backend/.DS_Store vendored

Binary file not shown.

13
backend/.env Normal file
View File

@ -0,0 +1,13 @@
# Настройки базы данных
DATABASE_URL=postgresql://gen_user:F%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db
# Настройки безопасности
SECRET_KEY=supersecretkey
DEBUG=1
# Настройки загрузки файлов
UPLOAD_DIRECTORY=uploads
# Настройки Meilisearch
MEILISEARCH_URL=http://localhost:7700
MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM

14
backend/.env.docker Normal file
View File

@ -0,0 +1,14 @@
# Настройки базы данных
# DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shop_db
# Настройки приложения
DEBUG=0
SECRET_KEY=supersecretkey
# UPLOAD_DIRECTORY=/app/uploads
# Настройки CORS
FRONTEND_URL=http://frontend:3000
# Настройки Meilisearch
MEILISEARCH_URL=http://meilisearch:7700
MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM

26
backend/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
# Используем официальный образ с Uvicorn+Gunicorn, оптимизированный для FastAPI
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app ./app
EXPOSE 8000
# Точка входа: стандартный CMD из базового образа запустит Gunicorn+Uvicorn
# FROM python:3.10-slim
# WORKDIR /app
# COPY requirements.txt .
# RUN pip install --no-cache-dir -r requirements.txt
# # Для разработки код монтируется через volumes, а в продакшн-билде можно добавить COPY . /app
# CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] # hot-reload

View File

@ -60,7 +60,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = postgresql://postgres:postgres@localhost:5434/shop_db
sqlalchemy.url = postgresql://gen_user:F%%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db
[post_write_hooks]

Binary file not shown.

View File

@ -0,0 +1,42 @@
"""add_new_order
Revision ID: 7192b0707277
Revises: 9773b0186faa
Create Date: 2025-04-27 01:56:31.170476
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7192b0707277'
down_revision: Union[str, None] = '9773b0186faa'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('orders', sa.Column('user_info_json', sa.JSON(), nullable=True))
op.add_column('orders', sa.Column('delivery_method', sa.String(), nullable=True))
op.add_column('orders', sa.Column('city', sa.String(), nullable=True))
op.add_column('orders', sa.Column('delivery_address', sa.String(), nullable=True))
op.add_column('orders', sa.Column('cdek_info', sa.JSON(), nullable=True))
op.add_column('orders', sa.Column('courier_info', sa.JSON(), nullable=True))
op.add_column('orders', sa.Column('items_json', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('orders', 'items_json')
op.drop_column('orders', 'courier_info')
op.drop_column('orders', 'cdek_info')
op.drop_column('orders', 'delivery_address')
op.drop_column('orders', 'city')
op.drop_column('orders', 'delivery_method')
op.drop_column('orders', 'user_info_json')
# ### end Alembic commands ###

View File

@ -0,0 +1,57 @@
"""Add size table and update product structure
Revision ID: 9773b0186faa
Revises: ef40913679bd
Create Date: 2025-03-16 20:30:39.057254
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9773b0186faa'
down_revision: Union[str, None] = 'ef40913679bd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sizes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('code', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code')
)
op.create_index(op.f('ix_sizes_id'), 'sizes', ['id'], unique=False)
op.add_column('product_variants', sa.Column('size_id', sa.Integer(), nullable=False))
op.create_foreign_key(None, 'product_variants', 'sizes', ['size_id'], ['id'])
op.drop_column('product_variants', 'discount_price')
op.drop_column('product_variants', 'name')
op.drop_column('product_variants', 'price')
op.add_column('products', sa.Column('price', sa.Float(), nullable=False))
op.add_column('products', sa.Column('discount_price', sa.Float(), nullable=True))
op.add_column('products', sa.Column('care_instructions', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('products', 'care_instructions')
op.drop_column('products', 'discount_price')
op.drop_column('products', 'price')
op.add_column('product_variants', sa.Column('price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=False))
op.add_column('product_variants', sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False))
op.add_column('product_variants', sa.Column('discount_price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
op.drop_constraint(None, 'product_variants', type_='foreignkey')
op.drop_column('product_variants', 'size_id')
op.drop_index(op.f('ix_sizes_id'), table_name='sizes')
op.drop_table('sizes')
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""add_new_order_
Revision ID: f89a59b0e814
Revises: 7192b0707277
Create Date: 2025-04-27 02:49:23.818786
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f89a59b0e814'
down_revision: Union[str, None] = '7192b0707277'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('orders', sa.Column('payment_method', sa.Enum('CREDIT_CARD', 'PAYPAL', 'BANK_TRANSFER', 'CASH_ON_DELIVERY', 'SBP', 'CARD', name='paymentmethod'), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('orders', 'payment_method')
# ### end Alembic commands ###

BIN
backend/app/.DS_Store vendored

Binary file not shown.

13
backend/app/.env Normal file
View File

@ -0,0 +1,13 @@
CDEK_LOGIN=q8AQtmLL7kPg6TuDo1eB2uqelJS4tHn2
CDEK_PASSWORD=RmAmgvSgSl1yirlz9QupbzOJVqhCxcP5
# CDEK_BASE_URL=https://api.cdek.ru/v2
CDEK_BASE_URL=https://api.edu.cdek.ru/v2
MINIO_ENDPOINT_URL = http://45.129.128.113:9000
MINIO_ACCESS_KEY = ZIK_DressedForSuccess
MINIO_SECRET_KEY = ZIK_DressedForSuccess_/////ZIK_DressedForSuccess_!
MINIO_BUCKET_NAME = dressedforsuccess
MINIO_USE_SSL = false
MEILISEARCH_KEY = dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
MEILISEARCH_URL = http://localhost:7700

View File

@ -1,25 +1,68 @@
import os
from pydantic_settings import BaseSettings
from dotenv import load_dotenv
from pathlib import Path
# Загружаем переменные окружения из .env файла
load_dotenv()
# Базовые настройки
API_PREFIX = "/api"
DEBUG = False
# Настройки безопасности
SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey") # Для JWT
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней
# Настройки базы данных
# DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
# Настройки почты
MAIL_USERNAME = os.getenv("MAIL_USERNAME", "test@example.com")
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD", "test_password")
MAIL_FROM = os.getenv("MAIL_FROM", "noreply@example.com")
MAIL_PORT = int(os.getenv("MAIL_PORT", "587"))
MAIL_SERVER = os.getenv("MAIL_SERVER", "smtp.example.com")
MAIL_TLS = True
MAIL_SSL = False
# Настройки загрузки файлов (старые, для информации)
# UPLOAD_DIRECTORY = os.getenv("UPLOAD_DIRECTORY", "uploads")
ALLOWED_UPLOAD_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 МБ
# Настройки MinIO/S3
MINIO_ENDPOINT_URL = os.getenv("MINIO_ENDPOINT_URL", "http://localhost:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
MINIO_BUCKET_NAME = os.getenv("MINIO_BUCKET_NAME", "images")
MINIO_USE_SSL = os.getenv("MINIO_USE_SSL", "false").lower() == "true"
# Настройки корзины
CART_EXPIRATION_DAYS = 30 # Срок хранения корзины
# Настройки API
MAX_PAGE_SIZE = 100 # Максимальный размер страницы для пагинации
# Базовый URL фронтенда
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
class Settings(BaseSettings):
# Основные настройки приложения
APP_NAME: str = "Интернет-магазин API"
APP_VERSION: str = "0.1.0"
APP_DESCRIPTION: str = "API для интернет-магазина на FastAPI"
DEBUG: bool = False
# Настройки базы данных
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5434/shop_db")
DATABASE_URL: str = "postgresql://gen_user:F%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db"
# Настройки безопасности
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-for-jwt-please-change-in-production")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30*60*24
SECRET_KEY: str = SECRET_KEY
ALGORITHM: str = ALGORITHM
ACCESS_TOKEN_EXPIRE_MINUTES: int = ACCESS_TOKEN_EXPIRE_MINUTES
# Настройки CORS
CORS_ORIGINS: list = [
"http://localhost",
@ -27,27 +70,44 @@ class Settings(BaseSettings):
"http://localhost:8000",
"http://localhost:8080",
]
# Настройки для загрузки файлов
UPLOAD_DIRECTORY: str = "uploads"
MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024 # 5 MB
ALLOWED_UPLOAD_EXTENSIONS: list = ["jpg", "jpeg", "png", "gif", "webp"]
MAX_UPLOAD_SIZE: int = MAX_UPLOAD_SIZE
ALLOWED_UPLOAD_EXTENSIONS: list = list(ALLOWED_UPLOAD_EXTENSIONS)
# Настройки MinIO/S3
MINIO_ENDPOINT_URL: str = MINIO_ENDPOINT_URL
MINIO_ACCESS_KEY: str = MINIO_ACCESS_KEY
MINIO_SECRET_KEY: str = MINIO_SECRET_KEY
MINIO_BUCKET_NAME: str = MINIO_BUCKET_NAME
MINIO_USE_SSL: bool = MINIO_USE_SSL
# Настройки для платежных систем (пример)
PAYMENT_GATEWAY_API_KEY: str = os.getenv("PAYMENT_GATEWAY_API_KEY", "")
PAYMENT_GATEWAY_SECRET: str = os.getenv("PAYMENT_GATEWAY_SECRET", "")
# Настройки для CDEK API
CDEK_LOGIN: str = os.getenv("CDEK_LOGIN", "cdek-login")
CDEK_PASSWORD: str = os.getenv("CDEK_PASSWORD", "cdek-pass")
CDEK_BASE_URL: str = os.getenv("CDEK_BASE_URL", "https://api.cdek.ru/v2")
# Настройки для отправки email (пример)
SMTP_SERVER: str = os.getenv("SMTP_SERVER", "")
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME: str = os.getenv("SMTP_USERNAME", "")
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "")
EMAIL_FROM: str = os.getenv("EMAIL_FROM", "noreply@example.com")
SMTP_SERVER: str = MAIL_SERVER
SMTP_PORT: int = MAIL_PORT
SMTP_USERNAME: str = MAIL_USERNAME
SMTP_PASSWORD: str = MAIL_PASSWORD
EMAIL_FROM: str = MAIL_FROM
# Настройки Meilisearch
MEILISEARCH_URL: str = os.getenv("MEILISEARCH_URL", "http://0.0.0.0:7700")
MEILISEARCH_KEY: str = os.getenv("MEILISEARCH_KEY", "masterKey")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
# Создаем экземпляр настроек
settings = Settings()
settings = Settings()

View File

@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
from app.config import settings
# Настройка SQLAlchemy
SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL
SQLALCHEMY_DATABASE_URL = "postgresql://gen_user:F%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db"
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

View File

@ -1,23 +1,57 @@
import os
import logging
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.exc import SQLAlchemyError
from contextlib import asynccontextmanager
from app.config import settings
from app.routers import router
from app.core import Base, engine
from app.core import Base, engine, SessionLocal
from app.services import meilisearch_service
from app.scripts.sync_meilisearch import sync_products, sync_categories, sync_collections, sync_sizes
logging.basicConfig(level=logging.INFO)
# Создаем таблицы в базе данных
Base.metadata.create_all(bind=engine)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Инициализируем Meilisearch
logging.info("Инициализация Meilisearch...")
try:
# Инициализируем индексы
meilisearch_service.initialize_indexes()
# Создаем сессию базы данных
db = SessionLocal()
try:
# Синхронизируем данные с Meilisearch
sync_categories(db)
sync_collections(db)
sync_sizes(db)
sync_products(db)
logging.info("Синхронизация с Meilisearch завершена успешно")
except Exception as e:
logging.error(f"Ошибка при синхронизации данных с Meilisearch: {str(e)}")
finally:
db.close()
except Exception as e:
logging.error(f"Ошибка при инициализации Meilisearch: {str(e)}")
yield
# Создаем экземпляр приложения FastAPI
app = FastAPI(
title="Dressed for Success API",
description="API для интернет-магазина одежды",
version="1.0.0"
version="1.0.0",
lifespan=lifespan
)
# Настраиваем CORS
@ -40,14 +74,10 @@ async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
# Подключаем роутеры
app.include_router(router, prefix="/api")
# Создаем директорию для загрузок, если она не существует
uploads_dir = Path(settings.UPLOAD_DIRECTORY)
uploads_dir.mkdir(parents=True, exist_ok=True)
# Монтируем статические файлы
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIRECTORY), name="uploads")
# Корневой маршрут
@app.get("/")
async def root():
return {"message": "Добро пожаловать в API интернет-магазина Dressed for Success"}
return {"message": "Добро пожаловать в API интернет-магазина Dressed for Success"}

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text
from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@ -37,6 +37,20 @@ class Category(Base):
products = relationship("Product", back_populates="category")
class Size(Base):
__tablename__ = "sizes"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False) # S, M, L, XL и т.д.
code = Column(String, nullable=False, unique=True) # Уникальный код размера
description = Column(String, nullable=True) # Дополнительная информация
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Отношения
product_variants = relationship("ProductVariant", back_populates="size")
class Product(Base):
__tablename__ = "products"
@ -44,6 +58,9 @@ class Product(Base):
name = Column(String, nullable=False)
slug = Column(String, unique=True, index=True, nullable=False)
description = Column(Text, nullable=True)
price = Column(Float, nullable=False) # Базовая цена продукта
discount_price = Column(Float, nullable=True) # Цена со скидкой
care_instructions = Column(JSON, nullable=True) # Инструкции по уходу и другие детали
is_active = Column(Boolean, default=True)
collection_id = Column(Integer, ForeignKey("collections.id"))
@ -64,17 +81,16 @@ class ProductVariant(Base):
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
name = Column(String, nullable=False)
sku = Column(String, unique=True, nullable=False) # артикул
price = Column(Float, nullable=False)
discount_price = Column(Float, nullable=True)
stock = Column(Integer, default=0)
size_id = Column(Integer, ForeignKey("sizes.id"), nullable=False)
sku = Column(String, unique=True, nullable=False) # артикул
stock = Column(Integer, default=0) # количество на складе
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Отношения
product = relationship("Product", back_populates="variants")
size = relationship("Size", back_populates="product_variants")
class ProductImage(Base):

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, Enum
from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, Enum, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
@ -20,6 +20,8 @@ class PaymentMethod(str, enum.Enum):
PAYPAL = "paypal"
BANK_TRANSFER = "bank_transfer"
CASH_ON_DELIVERY = "cash_on_delivery"
SBP = "sbp"
CARD = "card"
class CartItem(Base):
@ -44,9 +46,28 @@ class Order(Base):
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
status = Column(Enum(OrderStatus), default=OrderStatus.PENDING)
total_amount = Column(Float, nullable=False)
# JSON-поле с информацией о пользователе (для резервного копирования)
user_info_json = Column(JSON, nullable=True)
# Информация о доставке
delivery_method = Column(String, nullable=True) # cdek, courier
city = Column(String, nullable=True)
delivery_address = Column(String, nullable=True) # Форматированный адрес
cdek_info = Column(JSON, nullable=True) # Информация о доставке CDEK
courier_info = Column(JSON, nullable=True) # Информация о курьерской доставке
# Старые поля (для обратной совместимости)
shipping_address_id = Column(Integer, ForeignKey("user_addresses.id"), nullable=True)
# Информация об оплате
payment_method = Column(Enum(PaymentMethod), nullable=True)
payment_details = Column(Text, nullable=True)
# JSON-поле со списком заказанных товаров (для резервного копирования)
items_json = Column(JSON, nullable=True)
# Дополнительная информация
tracking_number = Column(String, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
@ -70,4 +91,4 @@ class OrderItem(Base):
# Отношения
order = relationship("Order", back_populates="items")
variant = relationship("ProductVariant")
variant = relationship("ProductVariant")

View File

@ -5,13 +5,14 @@ from typing import List, Optional, Dict, Any
import re
from datetime import datetime
from app.models.catalog_models import Category, Product, ProductVariant, ProductImage, Collection
from app.models.catalog_models import Category, Product, ProductVariant, ProductImage, Collection, Size
from app.schemas.catalog_schemas import (
CategoryCreate, CategoryUpdate,
ProductCreate, ProductUpdate,
ProductVariantCreate, ProductVariantUpdate,
ProductImageCreate, ProductImageUpdate,
CollectionCreate, CollectionUpdate
CollectionCreate, CollectionUpdate,
SizeCreate, SizeUpdate
)
@ -27,7 +28,7 @@ def generate_slug(name: str) -> str:
slug = re.sub(r'-+', '-', slug)
# Удаляем дефисы в начале и конце
slug = slug.strip('-')
return slug
@ -41,16 +42,16 @@ def get_collection_by_slug(db: Session, slug: str) -> Optional[Collection]:
def get_collections(
db: Session,
skip: int = 0,
limit: int = 100,
db: Session,
skip: int = 0,
limit: int = 100,
is_active: Optional[bool] = True
) -> List[Collection]:
query = db.query(Collection)
if is_active is not None:
query = query.filter(Collection.is_active == is_active)
return query.offset(skip).limit(limit).all()
@ -58,14 +59,14 @@ def create_collection(db: Session, collection: CollectionCreate) -> Collection:
# Если slug не предоставлен, генерируем его из имени
if not collection.slug:
collection.slug = generate_slug(collection.name)
# Проверяем, что коллекция с таким slug не существует
if get_collection_by_slug(db, collection.slug):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Коллекция с таким slug уже существует"
)
# Создаем новую коллекцию
db_collection = Collection(
name=collection.name,
@ -73,7 +74,7 @@ def create_collection(db: Session, collection: CollectionCreate) -> Collection:
description=collection.description,
is_active=collection.is_active
)
try:
db.add(db_collection)
db.commit()
@ -94,10 +95,10 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd
status_code=status.HTTP_404_NOT_FOUND,
detail="Коллекция не найдена"
)
# Обновляем только предоставленные поля
update_data = collection.dict(exclude_unset=True)
# Если slug изменяется, проверяем его уникальность
if "slug" in update_data and update_data["slug"] != db_collection.slug:
if get_collection_by_slug(db, update_data["slug"]):
@ -105,7 +106,7 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd
status_code=status.HTTP_400_BAD_REQUEST,
detail="Коллекция с таким slug уже существует"
)
# Если имя изменяется и slug не предоставлен, генерируем новый slug
if "name" in update_data and "slug" not in update_data:
update_data["slug"] = generate_slug(update_data["name"])
@ -115,11 +116,11 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd
status_code=status.HTTP_400_BAD_REQUEST,
detail="Коллекция с таким slug уже существует"
)
# Применяем обновления
for key, value in update_data.items():
setattr(db_collection, key, value)
try:
db.commit()
db.refresh(db_collection)
@ -139,14 +140,14 @@ def delete_collection(db: Session, collection_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Коллекция не найдена"
)
# Проверяем, есть ли у коллекции продукты
if db.query(Product).filter(Product.collection_id == collection_id).count() > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя удалить коллекцию, у которой есть продукты"
)
try:
db.delete(db_collection)
db.commit()
@ -169,18 +170,18 @@ def get_category_by_slug(db: Session, slug: str) -> Optional[Category]:
def get_categories(
db: Session,
skip: int = 0,
limit: int = 100,
db: Session,
skip: int = 0,
limit: int = 100,
parent_id: Optional[int] = None
) -> List[Category]:
query = db.query(Category)
if parent_id is not None:
query = query.filter(Category.parent_id == parent_id)
else:
query = query.filter(Category.parent_id == None)
return query.offset(skip).limit(limit).all()
@ -188,21 +189,21 @@ def create_category(db: Session, category: CategoryCreate) -> Category:
# Если slug не предоставлен, генерируем его из имени
if not category.slug:
category.slug = generate_slug(category.name)
# Проверяем, что категория с таким slug не существует
if get_category_by_slug(db, category.slug):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Категория с таким slug уже существует"
)
# Проверяем, что родительская категория существует, если указана
if category.parent_id and not get_category(db, category.parent_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Родительская категория не найдена"
)
# Создаем новую категорию
db_category = Category(
name=category.name,
@ -211,7 +212,7 @@ def create_category(db: Session, category: CategoryCreate) -> Category:
parent_id=category.parent_id,
is_active=category.is_active
)
try:
db.add(db_category)
db.commit()
@ -232,10 +233,10 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) ->
status_code=status.HTTP_404_NOT_FOUND,
detail="Категория не найдена"
)
# Обновляем только предоставленные поля
update_data = category.dict(exclude_unset=True)
# Если slug изменяется, проверяем его уникальность
if "slug" in update_data and update_data["slug"] != db_category.slug:
if get_category_by_slug(db, update_data["slug"]):
@ -243,7 +244,7 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) ->
status_code=status.HTTP_400_BAD_REQUEST,
detail="Категория с таким slug уже существует"
)
# Если имя изменяется и slug не предоставлен, генерируем новый slug
if "name" in update_data and "slug" not in update_data:
update_data["slug"] = generate_slug(update_data["name"])
@ -253,25 +254,25 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) ->
status_code=status.HTTP_400_BAD_REQUEST,
detail="Категория с таким slug уже существует"
)
# Проверяем, что родительская категория существует, если указана
if "parent_id" in update_data and update_data["parent_id"] and not get_category(db, update_data["parent_id"]):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Родительская категория не найдена"
)
# Проверяем, что категория не становится своим собственным родителем
if "parent_id" in update_data and update_data["parent_id"] == category_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Категория не может быть своим собственным родителем"
)
# Применяем обновления
for key, value in update_data.items():
setattr(db_category, key, value)
try:
db.commit()
db.refresh(db_category)
@ -291,21 +292,21 @@ def delete_category(db: Session, category_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Категория не найдена"
)
# Проверяем, есть ли у категории подкатегории
if db.query(Category).filter(Category.parent_id == category_id).count() > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя удалить категорию, у которой есть подкатегории"
)
# Проверяем, есть ли у категории продукты
if db.query(Product).filter(Product.category_id == category_id).count() > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя удалить категорию, у которой есть продукты"
)
try:
db.delete(db_category)
db.commit()
@ -328,50 +329,52 @@ def get_product_by_slug(db: Session, slug: str) -> Optional[Product]:
def get_products(
db: Session,
skip: int = 0,
limit: int = 100,
db: Session,
skip: int = 0,
limit: int = 100,
category_id: Optional[int] = None,
collection_id: Optional[int] = None,
search: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
is_active: Optional[bool] = True,
include_variants: Optional[bool] = False
is_active: Optional[bool] = True
) -> List[Product]:
query = db.query(Product)
# Применяем фильтры
if category_id:
query = query.filter(Product.category_id == category_id)
if collection_id:
query = query.filter(Product.collection_id == collection_id)
if search:
query = query.filter(Product.name.ilike(f"%{search}%"))
if is_active is not None:
query = query.filter(Product.is_active == is_active)
# Фильтрация по цене теперь должна быть через варианты продукта
if min_price is not None or max_price is not None:
query = query.join(ProductVariant)
if min_price is not None:
query = query.filter(ProductVariant.price >= min_price)
if max_price is not None:
query = query.filter(ProductVariant.price <= max_price)
products = query.offset(skip).limit(limit).all()
# Если нужно включить варианты, загружаем их для каждого продукта
if include_variants:
for product in products:
product.variants = db.query(ProductVariant).filter(ProductVariant.product_id == product.id).all()
product.images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all()
# Всегда загружаем варианты и изображения для каждого продукта
for product in products:
# Загружаем варианты с размерами
variants = db.query(ProductVariant).join(Size, ProductVariant.size_id == Size.id).filter(ProductVariant.product_id == product.id).all()
product.variants = variants
# Загружаем изображения
product.images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all()
return products
@ -379,48 +382,50 @@ def create_product(db: Session, product: ProductCreate) -> Product:
# Если slug не предоставлен, генерируем его из имени
if not product.slug:
product.slug = generate_slug(product.name)
# Проверяем, что продукт с таким slug не существует
if get_product_by_slug(db, product.slug):
# Проверяем, что slug уникален
if db.query(Product).filter(Product.slug == product.slug).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Продукт с таким slug уже существует"
detail=f"Продукт с slug '{product.slug}' уже существует"
)
# Проверяем, что категория существует
if not get_category(db, product.category_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Категория не найдена"
detail=f"Категория с ID {product.category_id} не найдена"
)
# Проверяем, что коллекция существует, если указана
# Проверяем, что коллекция существует, если она указана
if product.collection_id and not get_collection(db, product.collection_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Коллекция не найдена"
detail=f"Коллекция с ID {product.collection_id} не найдена"
)
# Создаем новый продукт
db_product = Product(
name=product.name,
slug=product.slug,
description=product.description,
price=product.price,
discount_price=product.discount_price,
care_instructions=product.care_instructions,
is_active=product.is_active,
category_id=product.category_id,
collection_id=product.collection_id
)
try:
db.add(db_product)
db.commit()
db.refresh(db_product)
return db_product
except IntegrityError:
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при создании продукта"
detail=f"Ошибка при создании продукта: {str(e)}"
)
@ -429,57 +434,72 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
if not db_product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Продукт не найден"
detail=f"Продукт с ID {product_id} не найден"
)
# Обновляем только предоставленные поля
update_data = product.dict(exclude_unset=True)
# Если slug изменяется, проверяем его уникальность
if "slug" in update_data and update_data["slug"] != db_product.slug:
if get_product_by_slug(db, update_data["slug"]):
if product.slug is not None and product.slug != db_product.slug:
if db.query(Product).filter(Product.slug == product.slug).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Продукт с таким slug уже существует"
detail=f"Продукт с slug '{product.slug}' уже существует"
)
# Если имя изменяется и slug не предоставлен, генерируем новый slug
if "name" in update_data and "slug" not in update_data:
update_data["slug"] = generate_slug(update_data["name"])
# Проверяем уникальность сгенерированного slug
if get_product_by_slug(db, update_data["slug"]) and get_product_by_slug(db, update_data["slug"]).id != product_id:
# Если имя изменяется и slug не предоставлен, обновляем slug
if product.name is not None and product.name != db_product.name and product.slug is None:
product.slug = generate_slug(product.name)
# Проверяем, что новый slug уникален
if db.query(Product).filter(Product.slug == product.slug).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Продукт с таким slug уже существует"
detail=f"Продукт с slug '{product.slug}' уже существует"
)
# Проверяем, что категория существует, если указана
if "category_id" in update_data and update_data["category_id"] and not get_category(db, update_data["category_id"]):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Категория не найдена"
)
# Проверяем, что коллекция существует, если указана
if "collection_id" in update_data and update_data["collection_id"] and not get_collection(db, update_data["collection_id"]):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Коллекция не найдена"
)
# Применяем обновления
for key, value in update_data.items():
setattr(db_product, key, value)
# Проверяем, что категория существует, если она изменяется
if product.category_id is not None and product.category_id != db_product.category_id:
if not get_category(db, product.category_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Категория с ID {product.category_id} не найдена"
)
# Проверяем, что коллекция существует, если она изменяется
if product.collection_id is not None and product.collection_id != db_product.collection_id:
if product.collection_id and not get_collection(db, product.collection_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Коллекция с ID {product.collection_id} не найдена"
)
# Обновляем поля
if product.name is not None:
db_product.name = product.name
if product.slug is not None:
db_product.slug = product.slug
if product.description is not None:
db_product.description = product.description
if product.price is not None:
db_product.price = product.price
if product.discount_price is not None:
db_product.discount_price = product.discount_price
if product.care_instructions is not None:
db_product.care_instructions = product.care_instructions
if product.is_active is not None:
db_product.is_active = product.is_active
if product.category_id is not None:
db_product.category_id = product.category_id
if product.collection_id is not None:
db_product.collection_id = product.collection_id
try:
db.commit()
db.refresh(db_product)
return db_product
except IntegrityError:
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при обновлении продукта"
detail=f"Ошибка при обновлении продукта: {str(e)}"
)
@ -490,16 +510,36 @@ def delete_product(db: Session, product_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Продукт не найден"
)
# Получаем ID всех вариантов продукта
variant_ids = [variant.id for variant in db_product.variants]
# Проверяем, используются ли варианты в корзинах
from app.models.order_models import CartItem
cart_items_count = db.query(CartItem).filter(CartItem.variant_id.in_(variant_ids)).count()
if cart_items_count > 0:
# Удаляем все элементы корзины, связанные с вариантами продукта
db.query(CartItem).filter(CartItem.variant_id.in_(variant_ids)).delete(synchronize_session=False)
# Проверяем, используются ли варианты в заказах
from app.models.order_models import OrderItem
order_items_count = db.query(OrderItem).filter(OrderItem.variant_id.in_(variant_ids)).count()
if order_items_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Невозможно удалить продукт, так как его варианты используются в {order_items_count} заказах"
)
try:
# Удаляем продукт (варианты и изображения удалятся автоматически благодаря cascade)
db.delete(db_product)
db.commit()
return True
except Exception:
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при удалении продукта"
detail=f"Ошибка при удалении продукта: {str(e)}"
)
@ -509,45 +549,64 @@ def get_product_variant(db: Session, variant_id: int) -> Optional[ProductVariant
def get_product_variants(db: Session, product_id: int) -> List[ProductVariant]:
return db.query(ProductVariant).filter(ProductVariant.product_id == product_id).all()
# Загружаем варианты с размерами
return db.query(ProductVariant).join(Size, ProductVariant.size_id == Size.id).filter(ProductVariant.product_id == product_id).all()
def create_product_variant(db: Session, variant: ProductVariantCreate) -> ProductVariant:
# Проверяем, что продукт существует
if not get_product(db, variant.product_id):
db_product = get_product(db, variant.product_id)
if not db_product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Продукт не найден"
detail=f"Продукт с ID {variant.product_id} не найден"
)
# Проверяем, что размер существует
db_size = get_size(db, variant.size_id)
if not db_size:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Размер с ID {variant.size_id} не найден"
)
# Проверяем, что вариант с таким SKU не существует
if db.query(ProductVariant).filter(ProductVariant.sku == variant.sku).first():
existing_variant = db.query(ProductVariant).filter(ProductVariant.sku == variant.sku).first()
if existing_variant:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Вариант с таким SKU уже существует"
detail=f"Вариант продукта с SKU {variant.sku} уже существует"
)
# Создаем новый вариант продукта
# Проверяем, что вариант с таким размером для этого продукта не существует
existing_size_variant = db.query(ProductVariant).filter(
ProductVariant.product_id == variant.product_id,
ProductVariant.size_id == variant.size_id
).first()
if existing_size_variant:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта"
)
db_variant = ProductVariant(
product_id=variant.product_id,
name=variant.name,
size_id=variant.size_id,
sku=variant.sku,
price=variant.price,
discount_price=variant.discount_price,
stock=variant.stock,
is_active=variant.is_active
)
try:
db.add(db_variant)
db.commit()
db.refresh(db_variant)
return db_variant
except IntegrityError:
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при создании варианта продукта"
detail=f"Ошибка при создании варианта продукта: {str(e)}"
)
@ -556,41 +615,73 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
if not db_variant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Вариант продукта не найден"
detail=f"Вариант продукта с ID {variant_id} не найден"
)
# Обновляем только предоставленные поля
update_data = variant.dict(exclude_unset=True)
# Если product_id изменяется, проверяем, что продукт существует
if "product_id" in update_data and update_data["product_id"] != db_variant.product_id:
if not get_product(db, update_data["product_id"]):
# Проверяем, что продукт существует, если ID продукта изменяется
if variant.product_id is not None and variant.product_id != db_variant.product_id:
db_product = get_product(db, variant.product_id)
if not db_product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Продукт не найден"
detail=f"Продукт с ID {variant.product_id} не найден"
)
# Если SKU изменяется, проверяем его уникальность
if "sku" in update_data and update_data["sku"] != db_variant.sku:
if db.query(ProductVariant).filter(ProductVariant.sku == update_data["sku"]).first():
# Проверяем, что размер существует, если ID размера изменяется
if variant.size_id is not None and variant.size_id != db_variant.size_id:
db_size = get_size(db, variant.size_id)
if not db_size:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Размер с ID {variant.size_id} не найден"
)
# Проверяем, что вариант с таким размером для этого продукта не существует
product_id = variant.product_id if variant.product_id is not None else db_variant.product_id
existing_size_variant = db.query(ProductVariant).filter(
ProductVariant.product_id == product_id,
ProductVariant.size_id == variant.size_id,
ProductVariant.id != variant_id
).first()
if existing_size_variant:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Вариант с таким SKU уже существует"
detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта"
)
# Применяем обновления
for key, value in update_data.items():
setattr(db_variant, key, value)
# Проверяем, что SKU уникален, если он изменяется
if variant.sku is not None and variant.sku != db_variant.sku:
existing_variant = db.query(ProductVariant).filter(
ProductVariant.sku == variant.sku,
ProductVariant.id != variant_id
).first()
if existing_variant:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Вариант продукта с SKU {variant.sku} уже существует"
)
# Обновляем поля
if variant.product_id is not None:
db_variant.product_id = variant.product_id
if variant.size_id is not None:
db_variant.size_id = variant.size_id
if variant.sku is not None:
db_variant.sku = variant.sku
if variant.stock is not None:
db_variant.stock = variant.stock
if variant.is_active is not None:
db_variant.is_active = variant.is_active
try:
db.commit()
db.refresh(db_variant)
return db_variant
except IntegrityError:
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при обновлении варианта продукта"
detail=f"Ошибка при обновлении варианта продукта: {str(e)}"
)
@ -601,7 +692,7 @@ def delete_product_variant(db: Session, variant_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Вариант продукта не найден"
)
try:
db.delete(db_variant)
db.commit()
@ -630,14 +721,14 @@ def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage
status_code=status.HTTP_404_NOT_FOUND,
detail="Продукт не найден"
)
# Если изображение отмечено как основное, сбрасываем флаг у других изображений
if image.is_primary:
db.query(ProductImage).filter(
ProductImage.product_id == image.product_id,
ProductImage.is_primary == True
).update({"is_primary": False})
# Создаем новое изображение продукта
db_image = ProductImage(
product_id=image.product_id,
@ -645,7 +736,7 @@ def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage
alt_text=image.alt_text,
is_primary=image.is_primary
)
try:
db.add(db_image)
db.commit()
@ -659,24 +750,27 @@ def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage
)
def update_product_image(db: Session, image_id: int, is_primary: bool) -> ProductImage:
def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) -> ProductImage:
db_image = get_product_image(db, image_id)
if not db_image:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Изображение продукта не найдено"
)
# Если изображение отмечается как основное, сбрасываем флаг у других изображений
if is_primary and not db_image.is_primary:
if image.is_primary and not db_image.is_primary:
db.query(ProductImage).filter(
ProductImage.product_id == db_image.product_id,
ProductImage.is_primary == True
).update({"is_primary": False})
# Обновляем флаг
db_image.is_primary = is_primary
# Обновляем поля
if image.alt_text is not None:
db_image.alt_text = image.alt_text
if image.is_primary is not None:
db_image.is_primary = image.is_primary
try:
db.commit()
db.refresh(db_image)
@ -696,7 +790,7 @@ def delete_product_image(db: Session, image_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Изображение продукта не найдено"
)
try:
db.delete(db_image)
db.commit()
@ -706,4 +800,110 @@ def delete_product_image(db: Session, image_id: int) -> bool:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при удалении изображения продукта"
)
)
# Функции для работы с размерами
def get_size(db: Session, size_id: int) -> Optional[Size]:
return db.query(Size).filter(Size.id == size_id).first()
def get_size_by_code(db: Session, code: str) -> Optional[Size]:
return db.query(Size).filter(Size.code == code).first()
def get_sizes(db: Session, skip: int = 0, limit: int = 100) -> List[Size]:
return db.query(Size).offset(skip).limit(limit).all()
def create_size(db: Session, size: SizeCreate) -> Size:
# Проверяем, что размер с таким кодом не существует
existing_size = get_size_by_code(db, size.code)
if existing_size:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Размер с кодом {size.code} уже существует"
)
db_size = Size(
name=size.name,
code=size.code,
description=size.description
)
try:
db.add(db_size)
db.commit()
db.refresh(db_size)
return db_size
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ошибка при создании размера: {str(e)}"
)
def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size:
db_size = get_size(db, size_id)
if not db_size:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Размер с ID {size_id} не найден"
)
# Проверяем, что код размера уникален, если он изменяется
if size.code and size.code != db_size.code:
existing_size = get_size_by_code(db, size.code)
if existing_size:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Размер с кодом {size.code} уже существует"
)
# Обновляем поля
if size.name is not None:
db_size.name = size.name
if size.code is not None:
db_size.code = size.code
if size.description is not None:
db_size.description = size.description
try:
db.commit()
db.refresh(db_size)
return db_size
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ошибка при обновлении размера: {str(e)}"
)
def delete_size(db: Session, size_id: int) -> bool:
db_size = get_size(db, size_id)
if not db_size:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Размер с ID {size_id} не найден"
)
# Проверяем, используется ли размер в вариантах продуктов
variants_with_size = db.query(ProductVariant).filter(ProductVariant.size_id == size_id).count()
if variants_with_size > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Невозможно удалить размер, так как он используется в {variants_with_size} вариантах продуктов"
)
try:
db.delete(db_size)
db.commit()
return True
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ошибка при удалении размера: {str(e)}"
)

View File

@ -21,7 +21,7 @@ def generate_slug(title: str) -> str:
slug = re.sub(r'-+', '-', slug)
# Удаляем дефисы в начале и конце
slug = slug.strip('-')
return slug
@ -35,16 +35,16 @@ def get_page_by_slug(db: Session, slug: str) -> Optional[Page]:
def get_pages(
db: Session,
skip: int = 0,
limit: int = 100,
db: Session,
skip: int = 0,
limit: int = 100,
published_only: bool = True
) -> List[Page]:
query = db.query(Page)
if published_only:
query = query.filter(Page.is_published == True)
return query.order_by(Page.title).offset(skip).limit(limit).all()
@ -52,14 +52,14 @@ def create_page(db: Session, page: PageCreate) -> Page:
# Если slug не предоставлен, генерируем его из заголовка
if not page.slug:
page.slug = generate_slug(page.title)
# Проверяем, что страница с таким slug не существует
if get_page_by_slug(db, page.slug):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Страница с таким slug уже существует"
)
# Создаем новую страницу
db_page = Page(
title=page.title,
@ -69,7 +69,7 @@ def create_page(db: Session, page: PageCreate) -> Page:
meta_description=page.meta_description,
is_published=page.is_published
)
try:
db.add(db_page)
db.commit()
@ -90,10 +90,10 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
status_code=status.HTTP_404_NOT_FOUND,
detail="Страница не найдена"
)
# Обновляем только предоставленные поля
update_data = page.dict(exclude_unset=True)
update_data = page.model_dump(exclude_unset=True)
# Если slug изменяется, проверяем его уникальность
if "slug" in update_data and update_data["slug"] != db_page.slug:
if get_page_by_slug(db, update_data["slug"]):
@ -101,7 +101,7 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
status_code=status.HTTP_400_BAD_REQUEST,
detail="Страница с таким slug уже существует"
)
# Если заголовок изменяется и slug не предоставлен, генерируем новый slug
if "title" in update_data and "slug" not in update_data:
update_data["slug"] = generate_slug(update_data["title"])
@ -111,11 +111,11 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
status_code=status.HTTP_400_BAD_REQUEST,
detail="Страница с таким slug уже существует"
)
# Применяем обновления
for key, value in update_data.items():
setattr(db_page, key, value)
try:
db.commit()
db.refresh(db_page)
@ -135,7 +135,7 @@ def delete_page(db: Session, page_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Страница не найдена"
)
try:
db.delete(db_page)
db.commit()
@ -152,7 +152,7 @@ def delete_page(db: Session, page_id: int) -> bool:
def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
# Создаем новую запись аналитики
db_log = AnalyticsLog(
user_id=log.user_id,
user_id=log.user_id, # Может быть None для неавторизованных пользователей
event_type=log.event_type,
page_url=log.page_url,
product_id=log.product_id,
@ -162,7 +162,7 @@ def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
referrer=log.referrer,
additional_data=log.additional_data
)
try:
db.add(db_log)
db.commit()
@ -177,9 +177,9 @@ def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
def get_analytics_logs(
db: Session,
skip: int = 0,
limit: int = 100,
db: Session,
skip: int = 0,
limit: int = 100,
event_type: Optional[str] = None,
user_id: Optional[int] = None,
product_id: Optional[int] = None,
@ -188,31 +188,31 @@ def get_analytics_logs(
end_date: Optional[datetime] = None
) -> List[AnalyticsLog]:
query = db.query(AnalyticsLog)
# Применяем фильтры
if event_type:
query = query.filter(AnalyticsLog.event_type == event_type)
if user_id:
query = query.filter(AnalyticsLog.user_id == user_id)
if product_id:
query = query.filter(AnalyticsLog.product_id == product_id)
if category_id:
query = query.filter(AnalyticsLog.category_id == category_id)
if start_date:
query = query.filter(AnalyticsLog.created_at >= start_date)
if end_date:
query = query.filter(AnalyticsLog.created_at <= end_date)
return query.order_by(AnalyticsLog.created_at.desc()).offset(skip).limit(limit).all()
def get_analytics_report(
db: Session,
db: Session,
period: str = "day",
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
@ -229,44 +229,44 @@ def get_analytics_report(
start_date = datetime.utcnow() - timedelta(days=365)
else:
start_date = datetime.utcnow() - timedelta(days=30) # По умолчанию 30 дней
if not end_date:
end_date = datetime.utcnow()
# Получаем все события за указанный период
logs = db.query(AnalyticsLog).filter(
AnalyticsLog.created_at >= start_date,
AnalyticsLog.created_at <= end_date
).all()
# Подсчитываем статистику
total_visits = len(logs)
unique_visitors = len(set([log.ip_address for log in logs if log.ip_address]))
# Подсчитываем просмотры страниц
page_views = {}
for log in logs:
if log.event_type == "page_view" and log.page_url:
page_views[log.page_url] = page_views.get(log.page_url, 0) + 1
# Подсчитываем просмотры продуктов
product_views = {}
for log in logs:
if log.event_type == "product_view" and log.product_id:
product_id = str(log.product_id)
product_views[product_id] = product_views.get(product_id, 0) + 1
# Подсчитываем добавления в корзину
cart_additions = sum(1 for log in logs if log.event_type == "add_to_cart")
# Подсчитываем заказы и выручку
orders_count = sum(1 for log in logs if log.event_type == "order_created")
# Для расчета выручки и среднего чека нам нужны данные о заказах
# В данном примере мы просто используем заглушки
revenue = 0
average_order_value = 0
# Формируем отчет
report = {
"period": period,
@ -281,5 +281,5 @@ def get_analytics_report(
"revenue": revenue,
"average_order_value": average_order_value
}
return report
return report

View File

@ -1,13 +1,15 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException, status
from typing import List, Optional, Dict, Any
from typing import List, Optional, Dict, Any, Union, Tuple
from datetime import datetime
import json
from app.models.order_models import CartItem, Order, OrderItem, OrderStatus, PaymentMethod
from app.models.catalog_models import Product, ProductImage, ProductVariant
from app.models.catalog_models import Product, ProductImage, ProductVariant, Size
from app.models.user_models import User, UserAddress
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, OrderCreateNew
from app.repositories import user_repo
# Функции для работы с корзиной
@ -34,7 +36,7 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
status_code=status.HTTP_404_NOT_FOUND,
detail="Вариант продукта не найден"
)
# Проверяем, что продукт активен
product = db.query(Product).filter(Product.id == variant.product_id).first()
if not product or not product.is_active:
@ -42,7 +44,7 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
status_code=status.HTTP_400_BAD_REQUEST,
detail="Продукт не активен или не найден"
)
# Проверяем, есть ли уже такой товар в корзине
existing_item = get_cart_item_by_variant(db, user_id, cart_item.variant_id)
if existing_item:
@ -58,14 +60,14 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при обновлении элемента корзины"
)
# Создаем новый элемент корзины
db_cart_item = CartItem(
user_id=user_id,
variant_id=cart_item.variant_id,
quantity=cart_item.quantity
)
try:
db.add(db_cart_item)
db.commit()
@ -84,20 +86,20 @@ def update_cart_item(db: Session, cart_item_id: int, cart_item: CartItemUpdate,
CartItem.id == cart_item_id,
CartItem.user_id == user_id
).first()
if not db_cart_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Элемент корзины не найден или не принадлежит пользователю"
)
# Обновляем только предоставленные поля
update_data = cart_item.dict(exclude_unset=True)
update_data = cart_item.model_dump(exclude_unset=True)
# Применяем обновления
for key, value in update_data.items():
setattr(db_cart_item, key, value)
try:
db.commit()
db.refresh(db_cart_item)
@ -115,13 +117,13 @@ def delete_cart_item(db: Session, cart_item_id: int, user_id: int) -> bool:
CartItem.id == cart_item_id,
CartItem.user_id == user_id
).first()
if not db_cart_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Элемент корзины не найден или не принадлежит пользователю"
)
try:
db.delete(db_cart_item)
db.commit()
@ -152,43 +154,211 @@ def get_order(db: Session, order_id: int) -> Optional[Order]:
return db.query(Order).filter(Order.id == order_id).first()
def create_order_new(db: Session, order: OrderCreateNew, user_id: Optional[int] = None) -> Order:
"""
Создает новый заказ на основе новой структуры данных.
Если пользователь не авторизован (user_id=None), создает нового пользователя.
Args:
db: Сессия базы данных
order: Данные для создания заказа
user_id: ID пользователя (None, если пользователь не авторизован)
Returns:
Созданный заказ
Raises:
HTTPException: Если произошла ошибка при создании заказа
"""
# Подготовим информацию о пользователе для сохранения в JSON
user_info_dict = {
"first_name": order.user_info.first_name,
"last_name": order.user_info.last_name,
"email": order.user_info.email,
"phone": order.user_info.phone
}
# Если пользователь не авторизован, создаем нового пользователя или находим существующего
user_created_message = None
if user_id is None:
try:
user, is_new, message = user_repo.find_or_create_user_from_order(db, user_info_dict)
user_id = user.id
user_created_message = message
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ошибка при создании пользователя: {str(e)}"
)
# Подготовим информацию о товарах для сохранения в JSON
items_json = []
for item in order.items:
# Получаем информацию о варианте и продукте
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
if not variant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Вариант товара с ID {item.variant_id} не найден"
)
product = db.query(Product).filter(Product.id == variant.product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Продукт для варианта с ID {item.variant_id} не найден"
)
# Получаем размер для варианта
size = db.query(Size).filter(Size.id == variant.size_id).first()
size_name = size.name if size else "Unknown"
# Получаем основное изображение продукта
image = db.query(ProductImage).filter(
ProductImage.product_id == product.id,
ProductImage.is_primary == True
).first()
if not image:
# Если нет основного изображения, берем первое доступное
image = db.query(ProductImage).filter(
ProductImage.product_id == product.id
).first()
image_url = image.image_url if image else None
# Добавляем информацию о товаре в JSON
items_json.append({
"product_id": product.id,
"variant_id": variant.id,
"quantity": item.quantity,
"price": item.price,
"product_name": product.name,
"variant_name": size_name,
"product_image": image_url,
"slug": product.slug
})
# Преобразуем строковое значение payment_method в значение перечисления PaymentMethod
payment_method_str = order.payment_method
# Прямое сопоставление с известными значениями
if payment_method_str == "sbp":
payment_method = PaymentMethod.SBP
elif payment_method_str == "card":
payment_method = PaymentMethod.CARD
else:
# Если не нашли соответствия, используем значение по умолчанию
payment_method = PaymentMethod.CARD
# Логируем ошибку
print(f"Неизвестный метод оплаты: {order.payment_method}. Используем значение по умолчанию: {payment_method}")
print(f"Метод оплаты: {payment_method} {type(payment_method)}")
print(f"Тип payment_method_str: {type(payment_method_str)}")
print(f"payment_method_str: {payment_method.value}")
payment_method_str = payment_method.value
print(f"payment_method_str: {payment_method_str} {type(payment_method_str)}")
payment_method = payment_method_str
# Создаем новый заказ
new_order = Order(
user_id=user_id,
status=OrderStatus.PENDING,
# JSON с информацией о пользователе
user_info_json=user_info_dict,
# Информация о доставке
delivery_method=order.delivery.method,
city=order.delivery.address.city,
delivery_address=order.delivery.address.formatted_address or f"{order.delivery.address.city}, {order.delivery.address.street} {order.delivery.address.house}",
# Информация о CDEK
cdek_info=order.delivery.cdek_info.model_dump() if order.delivery.cdek_info else None,
# JSON с информацией о товарах
items_json=items_json,
# Информация об оплате
payment_method=payment_method,
notes=order.comment,
# Общая сумма заказа (будет обновлена после добавления товаров)
total_amount=0
)
db.add(new_order)
db.flush() # Получаем ID заказа
# Добавляем товары в заказ через разводную таблицу
for item in order.items:
# Создаем элемент заказа
order_item = OrderItem(
order_id=new_order.id,
variant_id=item.variant_id,
quantity=item.quantity,
price=item.price
)
db.add(order_item)
new_order.total_amount += item.price * item.quantity
# Проверяем, были ли добавлены элементы заказа
if new_order.total_amount == 0:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Не удалось создать заказ: корзина пуста или товары недоступны"
)
try:
db.commit()
db.refresh(new_order)
# Добавляем информацию о создании пользователя в заказ
if user_created_message:
new_order.user_created_message = user_created_message
return new_order
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ошибка при создании заказа: {str(e)}"
)
def get_user_orders(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> List[Order]:
return db.query(Order).filter(Order.user_id == user_id).order_by(Order.created_at.desc()).offset(skip).limit(limit).all()
def get_all_orders(
db: Session,
skip: int = 0,
limit: int = 100,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[OrderStatus] = None
) -> List[Order]:
query = db.query(Order)
if status:
query = query.filter(Order.status == status)
return query.order_by(Order.created_at.desc()).offset(skip).limit(limit).all()
def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
def create_order(db: Session, order: OrderCreate, user_id: Optional[int] = None) -> Order:
# Проверяем, что адрес доставки существует и принадлежит пользователю, если указан
print(f"Начало создания заказа для пользователя {user_id}")
print(f"Данные заказа: {order}")
if order.shipping_address_id:
address = db.query(UserAddress).filter(
UserAddress.id == order.shipping_address_id,
UserAddress.user_id == user_id
).first()
if not address:
print(f"Адрес доставки {order.shipping_address_id} не найден для пользователя {user_id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Указанный адрес доставки не найден"
)
print(f"Адрес доставки найден: {address}")
# Создаем новый заказ
new_order = Order(
user_id=user_id,
@ -198,25 +368,21 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
notes=order.notes,
total_amount=0 # Будет обновлено после добавления товаров
)
db.add(new_order)
db.flush() # Получаем ID заказа
print(f"Создан заказ с ID: {new_order.id}")
# Получаем элементы корзины пользователя
cart_items = []
# Если указаны конкретные элементы корзины
if order.cart_items:
print(f"Используем указанные элементы корзины: {order.cart_items}")
cart_items = db.query(CartItem).filter(
CartItem.id.in_(order.cart_items),
CartItem.user_id == user_id
).all()
# Если указаны прямые элементы заказа
elif order.items:
print(f"Используем прямые элементы заказа: {order.items}")
# Создаем элементы заказа напрямую
for item_data in order.items:
# Проверяем, что вариант существует
@ -227,118 +393,173 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Вариант товара с ID {item_data.variant_id} не найден"
)
# Создаем элемент заказа
order_item = OrderItem(
order_id=new_order.id,
product_id=variant.product_id,
variant_id=variant.id,
quantity=item_data.quantity,
price=item_data.price
)
db.add(order_item)
new_order.total_amount += order_item.price * order_item.quantity
# Иначе используем все элементы корзины пользователя
else:
print(f"Используем все элементы корзины пользователя")
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
# Если используем элементы корзины
if cart_items:
print(f"Найдено {len(cart_items)} элементов корзины")
# Создаем элементы заказа из элементов корзины
for cart_item in cart_items:
# Получаем вариант товара и его цену
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
if not variant:
# Получаем продукт для варианта
product = db.query(Product).filter(Product.id == variant.product_id).first()
if not product:
db.rollback()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Вариант товара с ID {cart_item.variant_id} не найден"
detail=f"Продукт для варианта с ID {item_data.variant_id} не найден"
)
# Определяем цену (используем скидочную цену, если она есть)
price = variant.discount_price if variant.discount_price else variant.price
# Определяем цену для товара (используем discount_price если есть, иначе price)
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
# Создаем элемент заказа
order_item = OrderItem(
order_id=new_order.id,
variant_id=variant.id,
quantity=item_data.quantity,
price=price
)
db.add(order_item)
new_order.total_amount += price * item_data.quantity
# Иначе используем все элементы корзины пользователя
else:
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
# Если используем элементы корзины
if cart_items:
# Создаем элементы заказа из элементов корзины
for cart_item in cart_items:
# Получаем вариант продукта
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
if not variant:
continue
# Получаем продукт
product = db.query(Product).filter(Product.id == variant.product_id).first()
if not product:
continue
# Определяем цену для товара (используем discount_price если есть, иначе price)
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
# Создаем элемент заказа
order_item = OrderItem(
order_id=new_order.id,
variant_id=cart_item.variant_id,
quantity=cart_item.quantity,
price=price
)
db.add(order_item)
# Обновляем общую сумму заказа
new_order.total_amount += price * cart_item.quantity
# Удаляем элемент из корзины
db.delete(cart_item)
# Очищаем корзину пользователя после создания заказа
if cart_items and not order.cart_items:
# Если используем все элементы корзины, очищаем всю корзину
db.query(CartItem).filter(CartItem.user_id == user_id).delete()
elif order.cart_items:
# Если используем только выбранные элементы корзины, удаляем только их
db.query(CartItem).filter(
CartItem.id.in_([item.id for item in cart_items]),
CartItem.user_id == user_id
).delete(synchronize_session=False)
# Проверяем, были ли добавлены элементы заказа
if new_order.total_amount == 0:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Не удалось создать заказ: корзина пуста или товары недоступны"
)
try:
db.commit()
db.refresh(new_order)
print(f"Заказ успешно создан: {new_order.id}, общая сумма: {new_order.total_amount}")
return new_order
except Exception as e:
db.rollback()
print(f"Ошибка при создании заказа: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ошибка при создании заказа: {str(e)}"
)
def update_order(db: Session, order_id: int, order: OrderUpdate, is_admin: bool = False) -> Order:
db_order = get_order(db, order_id)
if not db_order:
def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin: bool = False) -> Order:
"""
Обновляет информацию о заказе.
Args:
db: Сессия базы данных
order_id: ID заказа
order_update: Данные для обновления
is_admin: Флаг, указывающий, является ли пользователь администратором
Returns:
Обновленный заказ
Raises:
HTTPException: Если заказ не найден или нет прав на обновление
"""
# Получаем заказ
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Заказ не найден"
)
# Обычные пользователи могут только отменить заказ
if not is_admin and order.status and order.status != OrderStatus.CANCELLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для изменения статуса заказа"
)
# Нельзя изменить статус заказа с CANCELLED или REFUNDED
if db_order.status in [OrderStatus.CANCELLED, OrderStatus.REFUNDED] and order.status:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Нельзя изменить статус заказа, который уже {db_order.status}"
)
# Обновляем только предоставленные поля
update_data = order.dict(exclude_unset=True)
# Проверяем, что адрес доставки существует и принадлежит пользователю, если указан
if "shipping_address_id" in update_data and update_data["shipping_address_id"]:
address = db.query(UserAddress).filter(
UserAddress.id == update_data["shipping_address_id"],
UserAddress.user_id == db_order.user_id
).first()
# Проверяем возможность обновления статуса
if order_update.status:
# Если заказ уже отменен или доставлен, нельзя менять его статус
if order.status in [OrderStatus.CANCELLED, OrderStatus.DELIVERED, OrderStatus.REFUNDED]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Невозможно изменить статус заказа из {order.status}"
)
# Если пользователь не админ, он может только отменить заказ
if not is_admin and order_update.status != OrderStatus.CANCELLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Пользователи могут только отменить заказ"
)
# Если пользователь хочет отменить заказ, проверяем возможность отмены
if order_update.status == OrderStatus.CANCELLED:
if order.status not in [OrderStatus.PENDING, OrderStatus.PROCESSING]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя отменить заказ, который уже отправлен или доставлен"
)
# Проверяем другие поля для обновления
if not is_admin:
# Обычные пользователи могут обновлять только статус (отмена)
for field in ["shipping_address_id", "payment_method", "payment_details", "tracking_number"]:
if getattr(order_update, field) is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Пользователи не могут изменять поле {field}"
)
# Обновляем поля заказа
update_data = order_update.model_dump(exclude_unset=True)
# Если указан адрес доставки, проверяем его существование
if "shipping_address_id" in update_data:
address = db.query(UserAddress).filter(UserAddress.id == update_data["shipping_address_id"]).first()
if not address:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Адрес доставки не найден или не принадлежит пользователю"
detail="Указанный адрес доставки не найден"
)
# Применяем обновления
for key, value in update_data.items():
setattr(db_order, key, value)
setattr(order, key, value)
try:
db.commit()
db.refresh(db_order)
return db_order
except IntegrityError:
db.refresh(order)
return order
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при обновлении заказа"
detail=f"Ошибка при обновлении заказа: {str(e)}"
)
@ -349,14 +570,14 @@ def delete_order(db: Session, order_id: int, is_admin: bool = False) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Заказ не найден"
)
# Только администраторы могут удалять заказы
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для удаления заказа"
)
try:
db.delete(db_order)
db.commit()
@ -371,65 +592,82 @@ def delete_order(db: Session, order_id: int, is_admin: bool = False) -> bool:
# Функции для получения детальной информации
def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, Any]]:
cart_items = get_user_cart(db, user_id)
"""
Получает корзину пользователя с детальной информацией о товарах.
"""
# Получаем элементы корзины пользователя
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
result = []
for item in cart_items:
# Получаем информацию о варианте и продукте
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
for cart_item in cart_items:
# Получаем вариант продукта
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
if not variant:
continue
# Получаем продукт
product = db.query(Product).filter(Product.id == variant.product_id).first()
if not product:
continue
# Получаем размер варианта
size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None
size_name = size.name if size else ''
# Получаем основное изображение продукта
image = db.query(ProductImage).filter(
product_image = None
primary_image = db.query(ProductImage).filter(
ProductImage.product_id == product.id,
ProductImage.is_primary == True
).first()
# Если нет основного изображения, берем первое доступное
if not image:
image = db.query(ProductImage).filter(
ProductImage.product_id == product.id
).first()
# Рассчитываем цену
price = variant.discount_price if variant.discount_price else variant.price
# Формируем результат
result.append({
"id": item.id,
"user_id": item.user_id,
"variant_id": item.variant_id,
"slug": product.slug,
"quantity": item.quantity,
"created_at": item.created_at,
"updated_at": item.updated_at,
if primary_image:
product_image = primary_image.image_url
else:
# Если нет основного изображения, берем любое
any_image = db.query(ProductImage).filter(ProductImage.product_id == product.id).first()
if any_image:
product_image = any_image.image_url
# Определяем цену товара (используем discount_price если есть, иначе price)
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
# Формируем элемент корзины с деталями
cart_item_details = {
"id": cart_item.id,
"user_id": cart_item.user_id,
"variant_id": cart_item.variant_id,
"quantity": cart_item.quantity,
"created_at": cart_item.created_at,
"updated_at": cart_item.updated_at,
"product_id": product.id,
"product_name": product.name,
"product_price": price,
"product_image": image.image_url if image else None,
"variant_name": variant.name,
"total_price": price * item.quantity
})
"product_image": product_image,
"slug": product.slug,
"variant_name": size_name,
"total_price": price * cart_item.quantity
}
result.append(cart_item_details)
return result
def get_order_with_details(db: Session, order_id: int) -> Dict[str, Any]:
order = get_order(db, order_id)
def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
"""
Получает заказ по ID с детальной информацией о товарах и адресе доставки.
"""
# Получаем заказ
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Заказ не найден"
)
# Получаем пользователя
return None
# Получаем информацию о пользователе
user = db.query(User).filter(User.id == order.user_id).first()
user_email = user.email if user else None
user_name = f"{user.first_name} {user.last_name}" if user and user.first_name and user.last_name else None
# Получаем адрес доставки
shipping_address = None
if order.shipping_address_id:
@ -442,51 +680,113 @@ def get_order_with_details(db: Session, order_id: int) -> Dict[str, Any]:
"city": address.city,
"state": address.state,
"postal_code": address.postal_code,
"country": address.country
"country": address.country,
"is_default": address.is_default
}
# Получаем элементы заказа с деталями
# Получаем элементы заказа с информацией о продуктах
items = []
for item in order.items:
# Получаем информацию о варианте и продукте
order_items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all()
for item in order_items:
# Получаем вариант продукта
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
if not variant:
continue
product = db.query(Product).filter(Product.id == variant.product_id).first()
if not product:
continue
# Формируем элемент заказа
variant_name = None
size_name = None
if variant:
# Получаем размер варианта
size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None
if size:
size_name = size.name
variant_name = f"{size.name}"
# Получаем информацию о продукте
product = None
product_name = "Удаленный продукт"
product_image = None
if variant:
product = db.query(Product).filter(Product.id == variant.product_id).first()
if product:
product_name = product.name
# Получаем основное изображение продукта
primary_image = db.query(ProductImage).filter(
ProductImage.product_id == product.id,
ProductImage.is_primary == True
).first()
if primary_image:
product_image = primary_image.image_url
else:
# Если нет основного изображения, берем любое
any_image = db.query(ProductImage).filter(
ProductImage.product_id == product.id
).first()
if any_image:
product_image = any_image.image_url
# Добавляем информацию об элементе заказа
items.append({
"id": item.id,
"order_id": item.order_id,
"variant_id": item.variant_id,
"quantity": item.quantity,
"price": item.price,
"created_at": item.created_at,
"product_id": product.id,
"product_name": product.name,
"variant_name": variant.name,
"product_id": variant.product_id if variant else None,
"product_name": product_name,
"product_image": product_image,
"variant_name": variant_name,
"size": size_name,
"total_price": item.price * item.quantity
})
# Получаем информацию о пользователе из JSON
user_info = order.user_info_json or {}
# Формируем результат
result = {
"id": order.id,
"user_id": order.user_id,
"status": order.status,
"total_amount": order.total_amount,
# Информация о пользователе из JSON
"user_info_json": order.user_info_json,
# Извлекаем информацию о пользователе для отображения
"first_name": user_info.get("first_name", ""),
"last_name": user_info.get("last_name", ""),
"email": user_info.get("email", user_email),
"phone": user_info.get("phone", ""),
"user_email": user_email, # Для обратной совместимости
"user_name": user_name, # Для обратной совместимости
# Информация о доставке
"delivery_method": order.delivery_method,
"city": order.city,
"delivery_address": order.delivery_address,
"cdek_info": order.cdek_info,
"courier_info": order.courier_info,
# Информация о товарах из JSON
"items_json": order.items_json,
# Старые поля (для обратной совместимости)
"shipping_address_id": order.shipping_address_id,
"shipping_address": shipping_address,
# Дополнительная информация
"payment_method": order.payment_method,
"payment_details": order.payment_details,
"tracking_number": order.tracking_number,
"notes": order.notes,
"created_at": order.created_at,
"updated_at": order.updated_at,
"user_email": user.email if user else None,
"shipping_address": shipping_address,
"items": items
}
return result

View File

@ -1,7 +1,9 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException, status
from typing import List, Optional
from typing import List, Optional, Dict, Any, Tuple
import secrets
import string
from app.models.user_models import User, UserAddress
from app.schemas.user_schemas import UserCreate, UserUpdate, AddressCreate, AddressUpdate
@ -14,6 +16,11 @@ def get_user(db: Session, user_id: int) -> Optional[User]:
def get_user_by_email(db: Session, email: str) -> Optional[User]:
# print(email)
users = db.query(User).all()
# print(users)
# for user in users:
# print(f"ID: {user.id}, Email: {user.email}, Name: {user.first_name} {user.last_name}")
return db.query(User).filter(User.email == email).first()
@ -28,7 +35,7 @@ def create_user(db: Session, user: UserCreate) -> User:
status_code=status.HTTP_400_BAD_REQUEST,
detail="Пользователь с таким email уже существует"
)
# Создаем нового пользователя
hashed_password = get_password_hash(user.password)
db_user = User(
@ -40,7 +47,7 @@ def create_user(db: Session, user: UserCreate) -> User:
is_active=user.is_active,
is_admin=user.is_admin
)
try:
db.add(db_user)
db.commit()
@ -61,17 +68,17 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
status_code=status.HTTP_404_NOT_FOUND,
detail="Пользователь не найден"
)
# Обновляем только предоставленные поля
update_data = user.dict(exclude_unset=True)
# Если предоставлен новый пароль, хешируем его
if "password" in update_data and update_data["password"]:
update_data["password"] = get_password_hash(update_data.pop("password"))
# Удаляем поле password_confirm, если оно есть
update_data.pop("password_confirm", None)
# Проверяем уникальность email, если он изменяется
if "email" in update_data and update_data["email"] != db_user.email:
if get_user_by_email(db, update_data["email"]):
@ -79,11 +86,11 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
status_code=status.HTTP_400_BAD_REQUEST,
detail="Пользователь с таким email уже существует"
)
# Применяем обновления
for key, value in update_data.items():
setattr(db_user, key, value)
try:
db.commit()
db.refresh(db_user)
@ -103,7 +110,7 @@ def delete_user(db: Session, user_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Пользователь не найден"
)
try:
db.delete(db_user)
db.commit()
@ -125,6 +132,57 @@ def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
return user
def find_or_create_user_from_order(db: Session, user_info: Dict[str, Any]) -> Tuple[User, bool, str]:
"""
Ищет пользователя по email или создает нового на основе данных заказа.
Args:
db: Сессия базы данных
user_info: Информация о пользователе из заказа
Returns:
Кортеж (пользователь, создан_новый, сообщение)
"""
email = user_info.get("email")
if not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email обязателен для создания пользователя"
)
# Ищем пользователя по email
existing_user = get_user_by_email(db, email)
if existing_user:
return existing_user, False, "Пользователь с таким email уже существует"
# Генерируем случайный пароль
password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
hashed_password = get_password_hash(password)
# Создаем нового пользователя
new_user = User(
email=email,
password=hashed_password,
first_name=user_info.get("first_name", ""),
last_name=user_info.get("last_name", ""),
phone=user_info.get("phone", ""),
is_active=True,
is_admin=False
)
try:
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user, True, f"Создан новый пользователь с email {email}"
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при создании пользователя"
)
# Функции для работы с адресами пользователей
def get_address(db: Session, address_id: int) -> Optional[UserAddress]:
return db.query(UserAddress).filter(UserAddress.id == address_id).first()
@ -141,7 +199,7 @@ def create_address(db: Session, address: AddressCreate, user_id: int):
UserAddress.user_id == user_id,
UserAddress.is_default == True
).update({"is_default": False})
db_address = UserAddress(
user_id=user_id,
address_line1=address.address_line1,
@ -152,7 +210,7 @@ def create_address(db: Session, address: AddressCreate, user_id: int):
country=address.country,
is_default=address.is_default
)
try:
db.add(db_address)
db.commit()
@ -173,16 +231,16 @@ def update_address(db: Session, address_id: int, address: AddressUpdate, user_id
UserAddress.id == address_id,
UserAddress.user_id == user_id
).first()
if not db_address:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Адрес не найден или не принадлежит пользователю"
)
# Обновляем только предоставленные поля
update_data = address.dict(exclude_unset=True)
# Если адрес становится дефолтным, сбрасываем дефолтный статус у других адресов пользователя
if "is_default" in update_data and update_data["is_default"]:
db.query(UserAddress).filter(
@ -190,11 +248,11 @@ def update_address(db: Session, address_id: int, address: AddressUpdate, user_id
UserAddress.id != address_id,
UserAddress.is_default == True
).update({"is_default": False})
# Применяем обновления
for key, value in update_data.items():
setattr(db_address, key, value)
try:
db.commit()
db.refresh(db_address)
@ -212,13 +270,13 @@ def delete_address(db: Session, address_id: int, user_id: int) -> bool:
UserAddress.id == address_id,
UserAddress.user_id == user_id
).first()
if not db_address:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Адрес не найден или не принадлежит пользователю"
)
try:
db.delete(db_address)
db.commit()
@ -240,10 +298,10 @@ def update_password(db: Session, user_id: int, new_password: str) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Пользователь не найден"
)
hashed_password = get_password_hash(new_password)
db_user.password = hashed_password
try:
db.commit()
return True
@ -259,14 +317,14 @@ def create_password_reset_token(db: Session, user_id: int) -> str:
"""Создает токен для сброса пароля"""
import secrets
import datetime
# В реальном приложении здесь должна быть модель для токенов сброса пароля
# Для примера просто генерируем случайный токен
token = secrets.token_urlsafe(32)
# В реальном приложении сохраняем токен в базе данных с привязкой к пользователю
# и временем истечения срока действия
return token
@ -274,7 +332,7 @@ def verify_password_reset_token(db: Session, token: str) -> Optional[int]:
"""Проверяет токен сброса пароля и возвращает ID пользователя"""
# В реальном приложении проверяем токен в базе данных
# и его срок действия
# Для примера просто возвращаем фиктивный ID пользователя
# В реальном приложении это должна быть проверка в базе данных
return 1 # Фиктивный ID пользователя
return 1 # Фиктивный ID пользователя

View File

@ -8,6 +8,7 @@ from app.routers.order_router import order_router
from app.routers.review_router import review_router
from app.routers.content_router import content_router
from app.routers.analytics_router import analytics_router
from app.routers.delivery_router import delivery_router
# Создаем основной роутер
router = APIRouter()
@ -20,4 +21,5 @@ router.include_router(cart_router)
router.include_router(order_router)
router.include_router(review_router)
router.include_router(content_router)
router.include_router(analytics_router)
router.include_router(analytics_router)
router.include_router(delivery_router)

View File

@ -16,6 +16,12 @@ async def add_to_cart_endpoint(
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Добавляет товар в корзину пользователя.
- **variant_id**: ID варианта продукта
- **quantity**: Количество товара
"""
return services.add_to_cart(db, current_user.id, cart_item)
@ -26,19 +32,47 @@ async def update_cart_item_endpoint(
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Обновляет количество товара в корзине.
- **cart_item_id**: ID элемента корзины
- **quantity**: Новое количество товара
"""
return services.update_cart_item(db, current_user.id, cart_item_id, cart_item)
@cart_router.delete("/items/{cart_item_id}", response_model=Dict[str, Any])
async def remove_from_cart_endpoint(cart_item_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
async def remove_from_cart_endpoint(
cart_item_id: int,
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Удаляет товар из корзины.
- **cart_item_id**: ID элемента корзины
"""
return services.remove_from_cart(db, current_user.id, cart_item_id)
@cart_router.delete("/clear", response_model=Dict[str, Any])
async def clear_cart_endpoint(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
async def clear_cart_endpoint(
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Очищает всю корзину пользователя."""
return services.clear_cart(db, current_user.id)
@cart_router.get("/", response_model=Dict[str, Any])
async def get_cart_endpoint(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
async def get_cart_endpoint(
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Получает содержимое корзины пользователя.
Возвращает список товаров в корзине с информацией о продуктах,
общую сумму и количество товаров.
"""
return services.get_cart(db, current_user.id)

View File

@ -1,23 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File, Form, Query
from sqlalchemy.orm import Session
from typing import List, Optional, Dict, Any
from fastapi.responses import JSONResponse
import logging
import traceback
from app.core import get_db, get_current_admin_user
from app import services
from app.schemas.catalog_schemas import (
CategoryCreate, CategoryUpdate, Category,
ProductCreate, ProductUpdate, Product,
CategoryCreate, CategoryUpdate, Category, CategoryWithSubcategories,
ProductCreate, ProductUpdate, Product, ProductWithDetails,
ProductVariantCreate, ProductVariantUpdate, ProductVariant,
ProductImageCreate, ProductImageUpdate, ProductImage,
CollectionCreate, CollectionUpdate, Collection
CollectionCreate, CollectionUpdate, Collection,
SizeCreate, SizeUpdate, Size,
ProductCreateComplete, ProductUpdateComplete
)
from app.models.user_models import User as UserModel
from app.models.catalog_models import Category, Size, Collection
from app.repositories.catalog_repo import get_products, get_product_by_slug
# Роутер для каталога
catalog_router = APIRouter(prefix="/catalog", tags=["Каталог"])
# Маршруты для коллекций
#########################
# Маршруты для коллекций #
#########################
@catalog_router.post("/collections", response_model=Dict[str, Any])
async def create_collection_endpoint(collection: CollectionCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
return services.create_collection(db, collection)
@ -34,9 +43,69 @@ async def delete_collection_endpoint(collection_id: int, current_user: UserModel
@catalog_router.get("/collections", response_model=Dict[str, Any])
async def get_collections_endpoint(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
return services.get_collections(db, skip, limit)
async def get_collections_endpoint(
*,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
is_active: Optional[bool] = None,
db: Session = Depends(get_db)
):
# Формируем фильтры для Meilisearch
filters = []
if is_active is not None:
filters.append(f"is_active = {str(is_active).lower()}")
# Используем Meilisearch для поиска коллекций
from app.services import meilisearch_service
# Объединяем фильтры в строку
filter_str = " AND ".join(filters) if filters else None
# Выполняем поиск в Meilisearch
result = meilisearch_service.search_collections(
query=search or "",
filters=filter_str,
limit=limit,
offset=skip
)
# Если поиск в Meilisearch успешен, возвращаем результаты
if result["success"]:
return {
"success": True,
"collections": result["collections"],
"total": result["total"]
}
# Если поиск в Meilisearch не удался, используем старый метод
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
# Получаем коллекции из базы данных
collections_db = db.query(Collection).all()
# Преобразуем коллекции в список словарей
from app.schemas.catalog_schemas import Collection as CollectionSchema
collections_list = [CollectionSchema.model_validate(collection).model_dump() for collection in collections_db]
# Синхронизируем коллекции с Meilisearch для будущих запросов
try:
from app.scripts.sync_meilisearch import sync_collections
sync_collections(db)
except Exception as e:
logging.error(f"Failed to sync collections with Meilisearch: {str(e)}")
return {
"success": True,
"collections": collections_list,
"total": len(collections_list)
}
#########################
# Маршруты для категорий #
#########################
@catalog_router.post("/categories", response_model=Dict[str, Any])
async def create_category_endpoint(category: CategoryCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
@ -53,71 +122,313 @@ async def delete_category_endpoint(category_id: int, current_user: UserModel = D
return services.delete_category(db, category_id)
@catalog_router.get("/categories", response_model=List[Dict[str, Any]])
async def get_categories_tree(db: Session = Depends(get_db)):
return services.get_category_tree(db)
@catalog_router.get("/categories", response_model=Dict[str, Any])
async def get_categories_endpoint(
*,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
parent_id: Optional[int] = None,
is_active: Optional[bool] = None,
db: Session = Depends(get_db)
):
# Формируем фильтры для Meilisearch
filters = []
if parent_id is not None:
filters.append(f"parent_id = {parent_id}")
if is_active is not None:
filters.append(f"is_active = {str(is_active).lower()}")
# Используем Meilisearch для поиска категорий
from app.services import meilisearch_service
# Объединяем фильтры в строку
filter_str = " AND ".join(filters) if filters else None
# Выполняем поиск в Meilisearch
result = meilisearch_service.search_categories(
query=search or "",
filters=filter_str,
limit=limit,
offset=skip
)
# Добавляем логирование для отладки
logging.info(f"Meilisearch search result: {result}")
# Если поиск в Meilisearch успешен, возвращаем результаты
if result["success"] and result["categories"]:
return {
"success": True,
"categories": result["categories"],
"total": result["total"]
}
# Если поиск в Meilisearch не удался, используем старый метод
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
# Получаем категории из базы данных
categories = db.query(Category).all()
# Преобразуем категории в список словарей
from app.schemas.catalog_schemas import Category as CategorySchema
categories_list = [CategorySchema.model_validate(category).model_dump() for category in categories]
# Синхронизируем категории с Meilisearch для будущих запросов
try:
from app.scripts.sync_meilisearch import sync_categories
sync_categories(db)
except Exception as e:
logging.error(f"Failed to sync categories with Meilisearch: {str(e)}")
return {
"success": True,
"categories": categories_list,
"total": len(categories_list)
}
#########################
# Маршруты для продуктов #
#########################
@catalog_router.post("/products", response_model=Dict[str, Any])
async def create_product_endpoint(product: ProductCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
# Используем синхронную версию для создания продукта
return services.create_product(db, product)
@catalog_router.put("/products/{product_id}", response_model=Dict[str, Any])
async def update_product_endpoint(product_id: int, product: ProductUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
# Используем синхронную версию для обновления продукта
return services.update_product(db, product_id, product)
@catalog_router.delete("/products/{product_id}", response_model=Dict[str, Any])
async def delete_product_endpoint(product_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
return services.delete_product(db, product_id)
# Используем синхронную версию для удаления продукта
logging.warning(f"Удаление продукта с ID {product_id} в маршруте")
result = services.delete_product(db, product_id)
# Если удаление не удалось и есть сообщение об ошибке, возвращаем соответствующий статус ошибки
if not result.get("success") and "error" in result:
if "заказах" in result.get("error", ""):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["error"])
elif "не найден" in result.get("error", ""):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=result["error"])
else:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=result["error"])
return result
@catalog_router.get("/products/{product_id}", response_model=Dict[str, Any])
async def get_product_details_endpoint(product_id: int, db: Session = Depends(get_db)):
return services.get_product_details(db, product_id)
async def get_product_details_endpoint(*, product_id: int, db: Session = Depends(get_db)):
# Сначала пробуем найти продукт в Meilisearch
from app.services import meilisearch_service
# Используем поиск по ID
result = meilisearch_service.get_product(product_id)
# Если продукт найден в Meilisearch, возвращаем его
if result["success"]:
return {
"success": True,
"product": result["product"]
}
# Если продукт не найден в Meilisearch, используем старый метод
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
# Проверяем существование продукта
from app.models.catalog_models import Product
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Продукт с ID {product_id} не найден"
)
# Получаем детали продукта
product_details = services.get_product_details(db, product_id)
# Синхронизируем продукт с Meilisearch для будущих запросов
try:
from app.scripts.sync_meilisearch import sync_products
sync_products(db)
except Exception as e:
logging.error(f"Failed to sync products with Meilisearch: {str(e)}")
return {"success": True, "product": product_details}
@catalog_router.get("/products/slug/{slug}", response_model=Dict[str, Any])
async def get_product_by_slug_endpoint(slug: str, db: Session = Depends(get_db)):
async def get_product_by_slug_endpoint(*, slug: str, db: Session = Depends(get_db)):
# Сначала пробуем найти продукт в Meilisearch
from app.services import meilisearch_service
# Используем поиск по slug
result = meilisearch_service.get_product_by_slug(slug)
# Если продукт найден в Meilisearch, возвращаем его
if result["success"]:
return {
"success": True,
"product": result["product"]
}
# Если продукт не найден в Meilisearch, используем старый метод
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
# Используем синхронную версию для получения продукта по slug
product = get_product_by_slug(db, slug)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Продукт не найден"
detail=f"Продукт с slug {slug} не найден"
)
return services.get_product_details(db, product.id)
# Получаем детали продукта
product_details = services.get_product_details(db, product.id)
# Синхронизируем продукт с Meilisearch для будущих запросов
try:
from app.scripts.sync_meilisearch import sync_products
sync_products(db)
except Exception as e:
logging.error(f"Failed to sync products with Meilisearch: {str(e)}")
return {"success": True, "product": product_details}
@catalog_router.post("/products/{product_id}/variants", response_model=Dict[str, Any])
async def add_product_variant_endpoint(product_id: int, variant: ProductVariantCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
variant.product_id = product_id
async def add_product_variant_endpoint(
product_id: int,
variant: ProductVariantCreate,
current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
# Убедимся, что product_id в пути совпадает с product_id в данных
if variant.product_id != product_id:
variant.product_id = product_id
return services.add_product_variant(db, variant)
@catalog_router.put("/variants/{variant_id}", response_model=Dict[str, Any])
async def update_product_variant_endpoint(variant_id: int, variant: ProductVariantUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
async def update_product_variant_endpoint(
variant_id: int,
variant: ProductVariantUpdate,
current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
return services.update_product_variant(db, variant_id, variant)
@catalog_router.delete("/variants/{variant_id}", response_model=Dict[str, Any])
async def delete_product_variant_endpoint(variant_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
async def delete_product_variant_endpoint(
variant_id: int,
current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
return services.delete_product_variant(db, variant_id)
@catalog_router.post("/products/{product_id}/images", response_model=Dict[str, Any])
@catalog_router.post("/products/{product_id}/images", description="Upload a product image")
async def upload_product_image_endpoint(
product_id: int,
file: UploadFile = File(...),
is_primary: bool = Form(False),
current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
product_id: int,
is_primary: bool = Form(default=False),
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_admin_user)
):
return services.upload_product_image(db, product_id, file, is_primary)
"""
Загружает изображение для продукта.
Args:
product_id: ID продукта
is_primary: Является ли изображение основным
file: Загружаемый файл
db: Сессия базы данных
current_user: Текущий пользователь
Returns:
Объект с флагом success и данными изображения:
{
"success": true,
"image": {
"id": int,
"product_id": int,
"image_url": str,
"alt_text": str,
"is_primary": bool,
"created_at": datetime,
"updated_at": datetime
}
}
В случае ошибки:
{
"success": false,
"error": str
}
"""
try:
logging.info(f"Начало обработки загрузки изображения для продукта {product_id}")
logging.info(f"Данные файла: имя={file.filename}, тип={file.content_type}, размер={file.size if hasattr(file, 'size') else 'unknown'}")
logging.info(f"is_primary: {is_primary}")
# Удостоверяемся, что продукт существует
from app.models.catalog_models import Product
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
error_msg = f"Продукт с ID {product_id} не найден"
logging.error(error_msg)
return JSONResponse(
status_code=404,
content={"success": False, "error": error_msg}
)
# Используем сервис для загрузки изображения
logging.info("Вызов сервиса upload_product_image")
result = services.upload_product_image(
db, product_id, file, is_primary, alt_text="")
logging.info(f"Результат загрузки изображения: {result}")
# Возвращаем успешный ответ с данными изображения
return {
"success": True,
"image": result
}
except HTTPException as http_exc:
# Обрабатываем HTTP-исключения
logging.error(f"HTTP ошибка при загрузке изображения: {http_exc.detail}, код: {http_exc.status_code}")
return JSONResponse(
status_code=http_exc.status_code,
content={"success": False, "error": http_exc.detail}
)
except Exception as e:
# Логируем ошибку с полным трейсбеком
error_msg = f"Неожиданная ошибка при загрузке изображения: {str(e)}"
logging.error(error_msg)
logging.error(traceback.format_exc())
# Возвращаем ошибку с кодом 400
return JSONResponse(
status_code=400,
content={"success": False, "error": str(e)}
)
@catalog_router.put("/images/{image_id}", response_model=Dict[str, Any])
async def update_product_image_endpoint(image_id: int, image: ProductImageUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
async def update_product_image_endpoint(
image_id: int,
image: ProductImageUpdate,
current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
return services.update_product_image(db, image_id, image)
@ -126,19 +437,305 @@ async def delete_product_image_endpoint(image_id: int, current_user: UserModel =
return services.delete_product_image(db, image_id)
@catalog_router.get("/products", response_model=List[Product])
@catalog_router.get("/products", response_model=Dict[str, Any])
async def get_products_endpoint(
skip: int = 0,
limit: int = 100,
*,
skip: int = 0,
limit: int = 100,
category_id: Optional[int] = None,
category_ids: Optional[str] = None, # Добавляем параметр category_ids в виде строки с разделителями-запятыми
collection_id: Optional[int] = None,
collection_ids: Optional[str] = None, # Добавляем параметр collection_ids в виде строки с разделителями-запятыми
search: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
is_active: Optional[bool] = True,
include_variants: Optional[bool] = False,
sort_by: Optional[str] = None,
sort_order: Optional[str] = "asc",
size_ids: Optional[str] = None, # Параметр size_ids в виде строки с разделителями-запятыми
sort: Optional[str] = None, # Параметр sort для прямой передачи опции сортировки
db: Session = Depends(get_db)
):
products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active, include_variants)
# Преобразуем объекты SQLAlchemy в схемы Pydantic
return [Product.model_validate(product) for product in products]
# Формируем фильтры для Meilisearch
filters = []
# Обработка фильтрации по категориям
if category_ids:
try:
# Преобразуем строку с ID категорий в список
category_ids_list = [int(cat_id.strip()) for cat_id in category_ids.split(',') if cat_id.strip()]
if category_ids_list:
# Формируем условие для фильтрации по категориям
category_filter = " OR ".join([f"category_id = {cat_id}" for cat_id in category_ids_list])
filters.append(f"({category_filter})")
except ValueError as e:
logging.warning(f"Некорректный формат category_ids: {category_ids}. Ошибка: {str(e)}")
# Для обратной совместимости
elif category_id is not None:
filters.append(f"category_id = {category_id}")
# Обработка фильтрации по коллекциям
if collection_ids:
try:
# Преобразуем строку с ID коллекций в список
collection_ids_list = [int(coll_id.strip()) for coll_id in collection_ids.split(',') if coll_id.strip()]
if collection_ids_list:
# Формируем условие для фильтрации по коллекциям
collection_filter = " OR ".join([f"collection_id = {coll_id}" for coll_id in collection_ids_list])
filters.append(f"({collection_filter})")
except ValueError as e:
logging.warning(f"Некорректный формат collection_ids: {collection_ids}. Ошибка: {str(e)}")
# Для обратной совместимости
elif collection_id is not None:
filters.append(f"collection_id = {collection_id}")
if is_active is not None:
filters.append(f"is_active = {str(is_active).lower()}")
if min_price is not None:
filters.append(f"price >= {min_price}")
if max_price is not None:
filters.append(f"price <= {max_price}")
# Обработка фильтрации по размерам
if size_ids:
try:
# Преобразуем строку с ID размеров в список
size_ids_list = [int(size_id.strip()) for size_id in size_ids.split(',') if size_id.strip()]
if size_ids_list:
# Формируем условие для фильтрации по размерам
size_filter = " OR ".join([f"size_ids = {size_id}" for size_id in size_ids_list])
filters.append(f"({size_filter})")
except ValueError as e:
logging.warning(f"Некорректный формат size_ids: {size_ids}. Ошибка: {str(e)}")
# Если формат некорректный, игнорируем этот фильтр
# Формируем параметры сортировки
sort_param = None
# Если передан прямой параметр sort, используем его
if sort:
# Обрабатываем популярные варианты сортировки
if sort == 'popular':
sort_param = None # Используем сортировку по умолчанию
elif sort == 'price_asc':
sort_param = ['price:asc']
elif sort == 'price_desc':
sort_param = ['price:desc']
elif sort == 'newest':
sort_param = ['created_at:desc']
else:
# Если передан другой вариант, используем его напрямую
sort_param = [sort]
# Если передан sort_by, используем его
elif sort_by:
sort_param = [f"{sort_by}:{sort_order}"]
# Используем Meilisearch для поиска продуктов
from app.services import meilisearch_service
# Объединяем фильтры в строку
filter_str = " AND ".join(filters) if filters else None
# Выполняем поиск в Meilisearch
result = meilisearch_service.search_products(
query=search or "",
filters=filter_str,
sort=sort_param,
limit=limit,
offset=skip
)
# Если поиск в Meilisearch успешен, возвращаем результаты
if result["success"]:
return {
"success": True,
"products": result["products"],
"total": result["total"],
"skip": skip,
"limit": limit
}
# Если поиск в Meilisearch не удался, используем старый метод
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active)
products_list = [services.format_product(product) for product in products]
# Синхронизируем продукты с Meilisearch для будущих запросов
try:
from app.scripts.sync_meilisearch import sync_products
sync_products(db)
except Exception as e:
logging.error(f"Failed to sync products with Meilisearch: {str(e)}")
return {
"success": True,
"products": products_list,
"total": len(products_list),
"skip": skip,
"limit": limit
}
# Маршруты для размеров
@catalog_router.get("/sizes", response_model=Dict[str, Any])
async def get_sizes(*,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Получить список всех размеров"""
# Используем Meilisearch для поиска размеров
from app.services import meilisearch_service
# Выполняем поиск в Meilisearch
result = meilisearch_service.search_sizes(
query=search or "",
filters=None,
limit=limit,
offset=skip
)
# Если поиск в Meilisearch успешен, возвращаем результаты
if result["success"]:
return {
"success": True,
"sizes": result["sizes"],
"total": result["total"]
}
# Если поиск в Meilisearch не удался, используем старый метод
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
# Получаем размеры из базы данных
from app.models.catalog_models import Size
sizes_db = db.query(Size).all()
# Преобразуем размеры в список словарей
from app.schemas.catalog_schemas import Size as SizeSchema
sizes_list = [SizeSchema.model_validate(size).model_dump() for size in sizes_db]
# Синхронизируем размеры с Meilisearch для будущих запросов
try:
from app.scripts.sync_meilisearch import sync_sizes
sync_sizes(db)
except Exception as e:
logging.error(f"Failed to sync sizes with Meilisearch: {str(e)}")
return {
"success": True,
"sizes": sizes_list,
"total": len(sizes_list)
}
@catalog_router.get("/sizes/{size_id}", response_model=Dict[str, Any])
async def get_size(
size_id: int,
db: Session = Depends(get_db)
):
"""Получить размер по ID"""
# Пробуем получить размер из Meilisearch
from app.services import meilisearch_service
result = meilisearch_service.get_size(size_id)
if result["success"]:
return {
"success": True,
"size": result["size"]
}
# Если размер не найден в Meilisearch, получаем его из базы данных
size = services.get_size(db, size_id)
# Преобразуем объект SQLAlchemy в словарь
from app.schemas.catalog_schemas import Size as SizeSchema
size_dict = SizeSchema.model_validate(size).model_dump()
return {
"success": True,
"size": size_dict
}
@catalog_router.post("/sizes", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED)
def create_size(
size: SizeCreate,
db: Session = Depends(get_db)
):
"""Создать новый размер"""
new_size = services.create_size(db, size)
# Преобразуем объект SQLAlchemy в словарь
from app.schemas.catalog_schemas import Size as SizeSchema
size_dict = SizeSchema.model_validate(new_size).model_dump()
return {
"success": True,
"size": size_dict
}
@catalog_router.put("/sizes/{size_id}", response_model=Dict[str, Any])
def update_size(
size_id: int,
size: SizeUpdate,
db: Session = Depends(get_db)
):
"""Обновить размер"""
updated_size = services.update_size(db, size_id, size)
# Преобразуем объект SQLAlchemy в словарь
from app.schemas.catalog_schemas import Size as SizeSchema
size_dict = SizeSchema.model_validate(updated_size).model_dump()
return {
"success": True,
"size": size_dict
}
@catalog_router.delete("/sizes/{size_id}", response_model=Dict[str, Any])
def delete_size(
size_id: int,
db: Session = Depends(get_db)
):
"""Удалить размер"""
# Удаляем размер из базы данных
success = services.delete_size(db, size_id)
# Если удаление прошло успешно, удаляем размер из Meilisearch
if success:
from app.services import meilisearch_service
meilisearch_service.delete_size(size_id)
return {"success": success}
# Маршруты для комплексного создания и обновления продуктов
@catalog_router.post("/products/complete", response_model=Dict[str, Any])
async def create_product_complete_endpoint(
product: ProductCreateComplete,
current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Создание продукта вместе с его вариантами и изображениями в одном запросе.
"""
return services.create_product_complete(db, product)
@catalog_router.put("/products/{product_id}/complete", response_model=Dict[str, Any])
async def update_product_complete_endpoint(
product_id: int,
product: ProductUpdateComplete,
current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Обновление продукта вместе с его вариантами и изображениями в одном запросе.
"""
result = services.update_product_complete(db, product_id, product)
return result

View File

@ -0,0 +1,46 @@
from fastapi import APIRouter, Request, Depends
import logging
from app.services import get_cdek_service
from app.services.delivery_service import CDEKService
# Настройка логирования
logger = logging.getLogger(__name__)
# Создаем роутер для доставки
delivery_router = APIRouter(
prefix="/delivery",
tags=["delivery"],
responses={404: {"description": "Not found"}},
)
@delivery_router.post("/cdek")
async def cdek_service_endpoint(request: Request, cdek_service: CDEKService = Depends(get_cdek_service)):
"""
Эндпоинт для обработки запросов виджета CDEK.
Объединяет query-параметры и тело запроса, затем передает их в сервис CDEKService.
- **action**: Тип действия (offices, calculate)
- Для action=offices: Параметры для получения списка офисов
- Для action=calculate: Параметры для расчета стоимости доставки
"""
# Получаем query-параметры из URL
query_params = dict(request.query_params)
logger.info("CDEK эндпоинт: Получены query-параметры: %s", query_params)
try:
# Пытаемся получить тело запроса как JSON
body = await request.json()
logger.info("CDEK эндпоинт: Получено тело запроса: %s", body)
except Exception as e:
logger.warning("CDEK эндпоинт: Не удалось получить JSON тело запроса: %s", str(e))
body = {}
# Объединяем данные (приоритет у тела запроса)
request_data = {**query_params, **body}
logger.info("CDEK эндпоинт: Объединенные данные запроса: %s", request_data)
# Асинхронно обрабатываем запрос
logger.debug("CDEK эндпоинт: Передача запроса в сервис CDEKService")
response = await cdek_service.process(request_data)
logger.info("CDEK эндпоинт: Получен ответ от сервиса, статус: %s", response.status_code)
return response

View File

@ -1,45 +1,133 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Body
from sqlalchemy.orm import Session
from typing import List, Optional, Dict, Any
from typing import List, Optional, Dict, Any, Union
from app.core import get_db, get_current_active_user
from app import services
from app.schemas.order_schemas import OrderCreate, OrderUpdate, Order
from app.schemas.order_schemas import OrderCreate, OrderUpdate, Order, OrderCreateNew
from app.models.user_models import User as UserModel
from app.repositories.order_repo import get_all_orders, get_user_orders
from app.models.order_models import OrderStatus
# Роутер для заказов
order_router = APIRouter(prefix="/orders", tags=["Заказы"])
@order_router.post("/", response_model=Dict[str, Any])
async def create_order_endpoint(order: OrderCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
return services.create_order(db, current_user.id, order)
async def create_order_endpoint(
order: OrderCreate,
# current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Создает новый заказ (старый формат).
- **shipping_address_id**: ID адреса доставки
- **payment_method**: Способ оплаты
- **notes**: Примечания к заказу (опционально)
- **cart_items**: Список ID элементов корзины (опционально)
- **items**: Прямые элементы заказа (опционально)
"""
return services.create_order(db, None, order)
@order_router.post("/new", response_model=Dict[str, Any])
async def create_order_new_endpoint(
order: OrderCreateNew,
db: Session = Depends(get_db),
# current_user: Optional[UserModel] = Depends(get_current_active_user)
):
"""
Создает новый заказ с новой структурой данных.
Если пользователь не авторизован, создает нового пользователя на основе данных заказа.
- **user_info**: Информация о пользователе (имя, фамилия, email, телефон)
- **delivery**: Информация о доставке (метод, адрес, информация о CDEK)
- **items**: Товары в заказе
- **payment_method**: Способ оплаты
- **comment**: Комментарий к заказу (опционально)
"""
# Если пользователь авторизован, используем его ID, иначе передаем None
user_id = None # current_user.id if current_user else None
# Проверяем наличие email в данных заказа
if not order.user_info.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email обязателен для оформления заказа"
)
# Создаем заказ (если пользователь не авторизован, будет создан новый пользователь)
return services.create_order_new(db, user_id, order)
@order_router.get("/{order_id}", response_model=Dict[str, Any])
async def get_order_endpoint(order_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
async def get_order_endpoint(
order_id: int,
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Получает информацию о заказе по ID.
- **order_id**: ID заказа
"""
return services.get_order(db, current_user.id, order_id, current_user.is_admin)
@order_router.put("/{order_id}", response_model=Dict[str, Any])
async def update_order_endpoint(order_id: int, order: OrderUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
async def update_order_endpoint(
order_id: int,
order: OrderUpdate,
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Обновляет информацию о заказе.
- **order_id**: ID заказа
- **status**: Новый статус заказа (опционально, только для админов)
- **shipping_address_id**: ID нового адреса доставки (опционально, только для админов)
- **payment_method**: Новый способ оплаты (опционально, только для админов)
- **payment_details**: Детали оплаты (опционально, только для админов)
- **tracking_number**: Номер отслеживания (опционально, только для админов)
- **notes**: Примечания к заказу (опционально)
"""
return services.update_order(db, current_user.id, order_id, order, current_user.is_admin)
@order_router.post("/{order_id}/cancel", response_model=Dict[str, Any])
async def cancel_order_endpoint(order_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
async def cancel_order_endpoint(
order_id: int,
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Отменяет заказ.
- **order_id**: ID заказа
"""
return services.cancel_order(db, current_user.id, order_id)
@order_router.get("/", response_model=List[Order])
async def get_orders(
skip: int = 0,
limit: int = 100,
status: Optional[str] = None,
current_user: UserModel = Depends(get_current_active_user),
@order_router.get("/", response_model=List[Dict[str, Any]])
async def get_orders_endpoint(
skip: int = 0,
limit: int = 100,
status: Optional[OrderStatus] = None,
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
):
"""
Получает список заказов пользователя.
- **skip**: Количество пропускаемых записей
- **limit**: Максимальное количество записей
- **status**: Фильтр по статусу заказа (опционально)
"""
# Преобразуем заказы в словари с детальной информацией
if current_user.is_admin:
return get_all_orders(db, skip, limit, status)
orders = services.order_repo.get_all_orders(db, skip, limit, status)
else:
return get_user_orders(db, current_user.id, skip, limit)
orders = services.order_repo.get_user_orders(db, current_user.id, skip, limit)
# Получаем полную информацию о каждом заказе
return [services.order_repo.get_order_with_details(db, order.id) for order in orders]

View File

@ -1,5 +1,5 @@
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Union
from typing import Optional, List, Union, Dict, Any
from datetime import datetime
@ -62,14 +62,37 @@ class Category(CategoryBase):
from_attributes = True
# Схемы для размеров
class SizeBase(BaseModel):
name: str
code: str
description: Optional[str] = None
class SizeCreate(SizeBase):
pass
class SizeUpdate(SizeBase):
name: Optional[str] = None
code: Optional[str] = None
description: Optional[str] = None
class Size(SizeBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Схемы для вариантов продуктов
class ProductVariantBase(BaseModel):
product_id: int
name: str
size_id: int
sku: str
price: float
discount_price: Optional[float] = None
stock: int = 0
is_active: bool = True
@ -78,12 +101,10 @@ class ProductVariantCreate(ProductVariantBase):
pass
class ProductVariantUpdate(ProductVariantBase):
class ProductVariantUpdate(BaseModel):
product_id: Optional[int] = None
name: Optional[str] = None
size_id: Optional[int] = None
sku: Optional[str] = None
price: Optional[float] = None
discount_price: Optional[float] = None
stock: Optional[int] = None
is_active: Optional[bool] = None
@ -92,6 +113,7 @@ class ProductVariant(ProductVariantBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
size: Optional[Size] = None
class Config:
from_attributes = True
@ -106,7 +128,8 @@ class ProductImageBase(BaseModel):
class ProductImageCreate(ProductImageBase):
pass
id: Optional[int] = None # Опциональное поле id для обеспечения совместимости с фронтендом
created_at: Optional[datetime] = None # Опциональное поле created_at
class ProductImageUpdate(BaseModel):
@ -123,12 +146,14 @@ class ProductImage(ProductImageBase):
from_attributes = True
# Схемы для продуктов
class ProductBase(BaseModel):
name: str
slug: Optional[str] = None
description: Optional[str] = None
price: float
discount_price: Optional[float] = None
care_instructions: Optional[Dict[str, Any]] = None
is_active: bool = True
category_id: int
collection_id: Optional[int] = None
@ -138,10 +163,13 @@ class ProductCreate(ProductBase):
pass
class ProductUpdate(ProductBase):
class ProductUpdate(BaseModel):
name: Optional[str] = None
slug: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
discount_price: Optional[float] = None
care_instructions: Optional[Dict[str, Any]] = None
is_active: Optional[bool] = None
category_id: Optional[int] = None
collection_id: Optional[int] = None
@ -156,6 +184,8 @@ class Product(ProductBase):
class Config:
from_attributes = True
populate_by_name = True
orm_mode = True
# Расширенные схемы для отображения
@ -178,4 +208,49 @@ class ProductWithDetails(Product):
# Рекурсивное обновление для CategoryWithChildren
CategoryWithSubcategories.update_forward_refs()
CategoryWithSubcategories.update_forward_refs()
# Схемы для продуктов с вложенными объектами
class ProductVariantCreateNested(BaseModel):
size_id: int
sku: str
stock: int = 0
is_active: bool = True
class ProductImageCreateNested(BaseModel):
image_url: str
alt_text: Optional[str] = None
is_primary: bool = False
class ProductCreateComplete(ProductBase):
variants: Optional[List[ProductVariantCreateNested]] = []
images: Optional[List[ProductImageCreateNested]] = []
# Остальные поля наследуются из ProductBase
class ProductVariantUpdateNested(BaseModel):
id: Optional[int] = None # Если id присутствует, обновляем существующий вариант
size_id: Optional[int] = None
sku: Optional[str] = None
stock: Optional[int] = None
is_active: Optional[bool] = None
class ProductImageUpdateNested(BaseModel):
id: Optional[int] = None # Если id присутствует, обновляем существующее изображение
image_url: Optional[str] = None
alt_text: Optional[str] = None
is_primary: Optional[bool] = None
class ProductUpdateComplete(BaseModel):
name: Optional[str] = None
slug: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
discount_price: Optional[float] = None
care_instructions: Optional[Dict[str, Any]] = None
is_active: Optional[bool] = None
category_id: Optional[int] = None
collection_id: Optional[int] = None
variants: Optional[List[ProductVariantUpdateNested]] = None
images: Optional[List[ProductImageUpdateNested]] = None
variants_to_remove: Optional[List[int]] = None # ID вариантов для удаления
images_to_remove: Optional[List[int]] = None # ID изображений для удаления

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
@ -62,7 +62,107 @@ class OrderItemWithProduct(OrderItem):
variant_name: Optional[str] = None
# Схемы для заказов
# Схемы для информации о пользователе
class UserInfoBase(BaseModel):
first_name: str
last_name: Optional[str] = ""
email: str
phone: str
# Схемы для адреса доставки
class AddressBase(BaseModel):
city: str
street: Optional[str] = ""
house: Optional[str] = ""
apartment: Optional[str] = ""
postal_code: Optional[str] = ""
formatted_address: Optional[str] = ""
# Схемы для информации о доставке CDEK
class CDEKPvzInfo(BaseModel):
city_code: Optional[int] = None
city: Optional[str] = None
type: Optional[str] = None
postal_code: Optional[str] = None
country_code: Optional[str] = None
region: Optional[str] = None
have_cashless: Optional[bool] = None
have_cash: Optional[bool] = None
allowed_cod: Optional[bool] = None
is_dressing_room: Optional[bool] = None
code: Optional[str] = None
name: Optional[str] = None
address: Optional[str] = None
work_time: Optional[str] = None
location: Optional[List[float]] = None
weight_min: Optional[int] = None
weight_max: Optional[int] = None
dimensions: Optional[Any] = None
class CDEKTariffInfo(BaseModel):
tariff_code: Optional[int] = None
tariff_name: Optional[str] = None
tariff_description: Optional[str] = None
delivery_mode: Optional[int] = None
delivery_sum: Optional[int] = None
period_min: Optional[int] = None
period_max: Optional[int] = None
calendar_min: Optional[int] = None
calendar_max: Optional[int] = None
delivery_date_range: Optional[Dict[str, str]] = None
class CDEKInfo(BaseModel):
pvz: Optional[CDEKPvzInfo] = None
tariff: Optional[CDEKTariffInfo] = None
delivery_type: Optional[str] = None
# Схемы для информации о доставке курьером
class CourierInfo(BaseModel):
geo_lat: Optional[str] = None
geo_lon: Optional[str] = None
fias_id: Optional[str] = None
kladr_id: Optional[str] = None
# Схемы для информации о доставке
class DeliveryInfo(BaseModel):
method: str # cdek или courier
address: AddressBase
cdek_info: Optional[CDEKInfo] = None
# Схемы для элементов заказа в новом формате
class OrderItemNew(BaseModel):
product_id: int
variant_id: int
quantity: int
price: float
# Схемы для заказов в новом формате
class OrderCreateNew(BaseModel):
user_info: UserInfoBase
delivery: DeliveryInfo
items: List[OrderItemNew]
payment_method: str
comment: Optional[str] = ""
@field_validator('payment_method')
def validate_payment_method(cls, v):
# Проверяем, что значение payment_method соответствует одному из допустимых значений
valid_methods = ["sbp", "card"]
if v.lower() not in valid_methods:
# Если не соответствует, преобразуем к значению по умолчанию
return "card"
return v.lower() # Возвращаем значение в нижнем регистре
# Старые схемы для заказов (для обратной совместимости)
class OrderBase(BaseModel):
shipping_address_id: Optional[int] = None
payment_method: Optional[PaymentMethod] = None
@ -82,12 +182,35 @@ class OrderUpdate(BaseModel):
tracking_number: Optional[str] = None
notes: Optional[str] = None
# Новые поля
delivery_method: Optional[str] = None
city: Optional[str] = None
delivery_address: Optional[str] = None
cdek_info: Optional[Dict[str, Any]] = None
courier_info: Optional[Dict[str, Any]] = None
user_info_json: Optional[Dict[str, Any]] = None
class Order(OrderBase):
id: int
user_id: int
status: OrderStatus
total_amount: float
# Информация о пользователе (из JSON)
user_info_json: Optional[Dict[str, Any]] = None
# Информация о доставке
delivery_method: Optional[str] = None
city: Optional[str] = None
delivery_address: Optional[str] = None
cdek_info: Optional[Dict[str, Any]] = None
courier_info: Optional[Dict[str, Any]] = None
# Информация о товарах (из JSON)
items_json: Optional[List[Dict[str, Any]]] = None
# Дополнительная информация
payment_details: Optional[str] = None
tracking_number: Optional[str] = None
created_at: datetime
@ -119,7 +242,25 @@ class OrderWithDetails(BaseModel):
user_id: int
status: OrderStatus
total_amount: float
# Информация о пользователе (из JSON)
user_info_json: Optional[Dict[str, Any]] = None
# Информация о доставке
delivery_method: Optional[str] = None
city: Optional[str] = None
delivery_address: Optional[str] = None
cdek_info: Optional[Dict[str, Any]] = None
courier_info: Optional[Dict[str, Any]] = None
# Информация о товарах (из JSON)
items_json: Optional[List[Dict[str, Any]]] = None
# Старые поля (для обратной совместимости)
shipping_address_id: Optional[int] = None
shipping_address: Optional[Dict[str, Any]] = None
# Дополнительная информация
payment_method: Optional[PaymentMethod] = None
payment_details: Optional[str] = None
tracking_number: Optional[str] = None
@ -127,5 +268,4 @@ class OrderWithDetails(BaseModel):
created_at: datetime
updated_at: Optional[datetime] = None
user_email: Optional[str] = None
shipping_address: Optional[Dict[str, Any]] = None
items: List[Dict[str, Any]] = []
items: List[Dict[str, Any]] = []

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,254 @@
import sys
import os
import logging
from pathlib import Path
# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
sys.path.append(str(Path(__file__).parent.parent.parent))
from sqlalchemy.orm import Session
from app.core import SessionLocal, engine
from app.models.catalog_models import Product, Category, Collection, Size, ProductVariant, ProductImage
from app.services import meilisearch_service
from app.schemas.catalog_schemas import Product as ProductSchema, Category as CategorySchema
from app.schemas.catalog_schemas import Collection as CollectionSchema, Size as SizeSchema
# Настройка логгера
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def format_product_for_meilisearch(product, variants, images):
"""
Форматирует продукт для индексации в Meilisearch.
"""
# Преобразуем объект SQLAlchemy в словарь
product_dict = {
"id": product.id,
"name": product.name,
"slug": product.slug,
"description": product.description,
"price": product.price,
"discount_price": product.discount_price,
"care_instructions": product.care_instructions,
"is_active": product.is_active,
"category_id": product.category_id,
"collection_id": product.collection_id,
"created_at": product.created_at.isoformat() if product.created_at else None,
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
"variants": [],
"images": [],
"size_ids": [] # Добавляем массив для хранения ID размеров
}
# Добавляем варианты продукта
for variant in variants:
variant_dict = {
"id": variant.id,
"size_id": variant.size_id,
"sku": variant.sku,
"stock": variant.stock,
"is_active": variant.is_active
}
# Добавляем информацию о размере, если она доступна
if variant.size:
variant_dict["size"] = {
"id": variant.size.id,
"name": variant.size.name,
"code": variant.size.code
}
# Добавляем ID размера в массив size_ids для фильтрации
if variant.size_id not in product_dict["size_ids"]:
product_dict["size_ids"].append(variant.size_id)
product_dict["variants"].append(variant_dict)
# Добавляем изображения продукта
for image in images:
image_dict = {
"id": image.id,
"image_url": image.image_url,
"alt_text": image.alt_text,
"is_primary": image.is_primary
}
product_dict["images"].append(image_dict)
# Добавляем основное изображение в корень продукта для удобства
if image.is_primary:
product_dict["primary_image"] = image.image_url
return product_dict
def format_category_for_meilisearch(category):
"""
Форматирует категорию для индексации в Meilisearch.
"""
return {
"id": category.id,
"name": category.name,
"slug": category.slug,
"description": category.description,
"parent_id": category.parent_id,
"is_active": category.is_active,
"created_at": category.created_at.isoformat() if category.created_at else None,
"updated_at": category.updated_at.isoformat() if category.updated_at else None
}
def format_collection_for_meilisearch(collection):
"""
Форматирует коллекцию для индексации в Meilisearch.
"""
return {
"id": collection.id,
"name": collection.name,
"slug": collection.slug,
"description": collection.description,
"is_active": collection.is_active,
"created_at": collection.created_at.isoformat() if collection.created_at else None,
"updated_at": collection.updated_at.isoformat() if collection.updated_at else None
}
def format_size_for_meilisearch(size):
"""
Форматирует размер для индексации в Meilisearch.
"""
return {
"id": size.id,
"name": size.name,
"code": size.code,
"description": size.description,
"created_at": size.created_at.isoformat() if size.created_at else None,
"updated_at": size.updated_at.isoformat() if size.updated_at else None
}
def sync_products(db: Session):
"""
Синхронизирует все продукты с Meilisearch.
"""
logger.info("Syncing products...")
# Получаем все продукты из базы данных
products = db.query(Product).all()
# Форматируем продукты для Meilisearch
products_data = []
for product in products:
variants = db.query(ProductVariant).filter(ProductVariant.product_id == product.id).all()
images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all()
product_data = format_product_for_meilisearch(product, variants, images)
products_data.append(product_data)
# Синхронизируем продукты с Meilisearch
success = meilisearch_service.sync_all_products(products_data)
if success:
logger.info(f"Successfully synced {len(products_data)} products")
else:
logger.error("Failed to sync products")
return success
def sync_categories(db: Session):
"""
Синхронизирует все категории с Meilisearch.
"""
logger.info("Syncing categories...")
# Получаем все категории из базы данных
categories = db.query(Category).all()
# Форматируем категории для Meilisearch
categories_data = [format_category_for_meilisearch(category) for category in categories]
# Синхронизируем категории с Meilisearch
success = meilisearch_service.sync_all_categories(categories_data)
if success:
logger.info(f"Successfully synced {len(categories_data)} categories")
else:
logger.error("Failed to sync categories")
return success
def sync_collections(db: Session):
"""
Синхронизирует все коллекции с Meilisearch.
"""
logger.info("Syncing collections...")
# Получаем все коллекции из базы данных
collections = db.query(Collection).all()
# Форматируем коллекции для Meilisearch
collections_data = [format_collection_for_meilisearch(collection) for collection in collections]
# Синхронизируем коллекции с Meilisearch
success = meilisearch_service.sync_all_collections(collections_data)
if success:
logger.info(f"Successfully synced {len(collections_data)} collections")
else:
logger.error("Failed to sync collections")
return success
def sync_sizes(db: Session):
"""
Синхронизирует все размеры с Meilisearch.
"""
logger.info("Syncing sizes...")
# Получаем все размеры из базы данных
sizes = db.query(Size).all()
# Форматируем размеры для Meilisearch
sizes_data = [format_size_for_meilisearch(size) for size in sizes]
# Синхронизируем размеры с Meilisearch
success = meilisearch_service.sync_all_sizes(sizes_data)
if success:
logger.info(f"Successfully synced {len(sizes_data)} sizes")
else:
logger.error("Failed to sync sizes")
return success
def main():
"""
Основная функция для синхронизации всех данных с Meilisearch.
"""
logger.info("Starting Meilisearch synchronization...")
# Инициализируем индексы в Meilisearch
meilisearch_service.initialize_indexes()
# Создаем сессию базы данных
db = SessionLocal()
try:
# Синхронизируем все данные
sync_categories(db)
sync_collections(db)
sync_sizes(db)
sync_products(db)
logger.info("Meilisearch synchronization completed successfully")
except Exception as e:
logger.error(f"Error during Meilisearch synchronization: {str(e)}")
finally:
db.close()
if __name__ == "__main__":
main()

View File

@ -9,12 +9,14 @@ from app.services.catalog_service import (
create_product, update_product, delete_product, get_product_details,
add_product_variant, update_product_variant, delete_product_variant,
upload_product_image, update_product_image, delete_product_image,
create_collection, update_collection, delete_collection, get_collections
create_collection, update_collection, delete_collection, get_collections,
get_size, get_size_by_code, get_sizes, create_size, update_size, delete_size,
create_product_complete, update_product_complete, format_product
)
from app.services.order_service import (
add_to_cart, update_cart_item, remove_from_cart, clear_cart, get_cart,
create_order, get_order, update_order, cancel_order
create_order, create_order_new, get_order, update_order, cancel_order,
)
from app.services.review_service import (
@ -24,4 +26,10 @@ from app.services.review_service import (
from app.services.content_service import (
create_page, update_page, delete_page, get_page_by_slug,
log_event, get_analytics_report
)
)
from app.services.delivery_service import get_cdek_service
# Импорт репозиториев для прямого доступа
from app.repositories import order_repo

Some files were not shown because too many files have changed in this diff Show More