diff --git a/.github/workflows/test_and_coverage.yml b/.github/workflows/test_and_coverage.yml new file mode 100644 index 000000000..0a546af0a --- /dev/null +++ b/.github/workflows/test_and_coverage.yml @@ -0,0 +1,75 @@ +name: 'coverage' +on: + push: + branches: + - master + - main + pull_request: + branches: + - master + - main +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - name: Get Cover + uses: orgoro/coverage@v3.2 + with: + coverageFile: python-final-diplom/reference/netology_pd_diplom/.coverage.xml + token: ${{ secrets.GITHUB_TOKEN }} + +# name: 'Тестирование и покрытие кода' + +# on: +# push: +# branches: +# - master +# - main +# pull_request: + +# jobs: +# build: +# runs-on: ubuntu-latest + +# steps: +# - name: Checkout repository +# uses: actions/checkout@v3 + +# - name: Setup Python 3.10 +# uses: actions/setup-python@v3 +# with: +# python-version: '3.10' + +# - name: Install dependencies +# run: | +# python -m pip install --upgrade pip +# pip install -r requirements.txt +# pip install pytest pytest-cov coverage + +# - name: Show project directory structure +# run: | +# echo "Current directory: $(pwd)" +# ls -la +# ls -la reference + +# - name: Run tests with coverage +# run: | +# export DJANGO_SETTINGS_MODULE=reference.netology_pd_diplom.netology_pd_diplom.settings +# cd reference +# pytest --cov=netology_pd_diplom --cov-report=xml:coverage.xml tests/ +# env: +# PYTHONPATH: ${{ github.workspace }}/reference +# DJANGO_SETTINGS_MODULE: reference.netology_pd_diplom.netology_pd_diplom.settings + +# - name: Upload coverage to Codecov +# uses: codecov/codecov-action@v3 +# with: +# token: ${{ secrets.CODECOV_TOKEN }} +# files: coverage.xml +# flags: unittests +# fail_ci_if_error: true + + + + + diff --git a/.gitignore b/.gitignore index e1abce054..89ff7cd0f 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,6 @@ venv.bak/ # mypy .mypy_cache/ + +/Users/apple/Documents/Full-stack-Python-developer/my_diplom/redis-7.2.7 +reference/netology_pd_diplom/dump.rdb diff --git a/README.md b/README.md index 6f5ef53e8..b81d1cfdb 100644 --- a/README.md +++ b/README.md @@ -230,3 +230,46 @@ _Важно: не нарушайте дедлайн сдачи, возника * выполненная базовая часть проекта, * наличие собственных комментариев к коду, * использование сторонних библиотек и фреймворков. + +## Дополнительное задание +> Отказ от django signals, с дальнейшей интеграцией в проект фреймворка Celery для по-настоящему асинхронных задач +Рекомендую начать с использования любой из нижеследующих инструкций. +- https://realpython.com/asynchronous-tasks-with-django-and-celery/ + +или +- https://stackabuse.com/asynchronous-tasks-in-django-with-redis-and-celery/ + +> Вижу вы покрыли код тестами, предлагаю добавить красивую кнопку с текущим покрытием у себя в Гитхабе +https://github.com/marketplace/actions/python-coverage + +> Опробовать автогенерацию документации Open API в рамках пакета DRF-Spectacular. После внедрения обязательно внимательно просмотрите страницу Swagger. Тут сразу можно будет оценить для себя разницу с Postman и огромный потенциал для плодотворной работы в команде с несколькими разработчиками. P.S. Используйте чаще docstring к классам и функциям +https://drf-spectacular.readthedocs.io/en/latest/readme.html + +> Попробовать простой DRF тротлинг, и можно проверить его работу добавление отдельного TestCase +https://www.django-rest-framework.org/api-guide/throttling/ + +> Добавить возможность авторизации с 1-2 социальных сетей, тут можно ознакомится с такой крутой библиотекой +https://github.com/python-social-auth/social-app-django + +> Наверняка пользовались админкой в ходе создания проекта, предлагаю сделать ей мощный тюнинг через эти библиотеки на выбор: +https://github.com/otto-torino/django-baton +https://django-jet-reboot.readthedocs.io/en/latest/ + +> После добавления Celery можно добавить загрузку аватаров пользователей и картинок товаров с последующей асинхронной обработкой их в фоновом режиме, например создание миниатюр различного размера для быстрой загрузки. Для этого есть множество библиотек на выбор: +https://easy-thumbnails.readthedocs.io/en/latest/ +https://django-versatileimagefield.readthedocs.io/en/latest/ +https://django-imagekit.readthedocs.io/en/latest/ + +> Очень часто в коммерческих проектах для перехвата ошибок, которые возникают у пользователей внедряют Sentry или Rollbar. Он позволяет быть в курсе насколько стабильно работает проект и к тому же хранит полные traceback ошибок, так чтобы можно было их исследовать. Будет большой плюс в резюме от наличия знаний по этой технологии. Попробуйте внедрить ее на базовом уровне и создать APIView, который вызывает исключение, что увидеть его в консоли Sentry или RollBar +https://blog.sentry.io/monitoring-performance-and-errors-in-a-django-application-with-sentry/ +https://docs.sentry.io/platforms/python/integrations/django/ +RollBar +https://docs.rollbar.com/docs/django +https://docs.rollbar.com/docs/celery + +> внедрить кэширование запросов к БД с использованием Redis, посмотреть насколько уменьшится время отклика системы. Вот эти библиотеки на выбор могут помочь с кэшированием запросов к БД: +https://github.com/noripyt/django-cachalot +https://github.com/Suor/django-cacheops + +> и последнее только по желанию, часто в проектах бывают задачи поиска причин медленной работы запроса. Например, из-за неправильного ORM запроса к БД. Рекомендую ознакомиться с пакетом https://github.com/jazzband/django-silk +проанализировать запросы в Postman и посмотреть какие из них вызывают каскад вторичных запросов, которые перегружают СУБД. Лично у меня вызывает удовлетворение, если ранее запрос выполнялся 400-500 мс, а сейчас за 3-4 мс, после того как я выявил проблему через Silk и оптимизировал запрос. Возможно, и Вам понравится) diff --git a/reference/netology_pd_diplom/.coveragrc b/reference/netology_pd_diplom/.coveragrc new file mode 100644 index 000000000..a197a65da --- /dev/null +++ b/reference/netology_pd_diplom/.coveragrc @@ -0,0 +1,8 @@ +[run] +branch = True # Включаем измерение ветвления (условия if/else) +omit = tests/* # Исключаем папку с тестами из анализа покрытия + +[report] +show_missing = True # Показывать непокрытые участки кода +skip_covered = False # Отображать покрытый код +precision = 2 # Точность отчета — два знака после запятой \ No newline at end of file diff --git a/reference/netology_pd_diplom/.dockerignore b/reference/netology_pd_diplom/.dockerignore new file mode 100644 index 000000000..2ca324a44 --- /dev/null +++ b/reference/netology_pd_diplom/.dockerignore @@ -0,0 +1,2 @@ +.env +enviroments.sh \ No newline at end of file diff --git a/reference/netology_pd_diplom/.gitignore b/reference/netology_pd_diplom/.gitignore index ed02b07e1..a89e175cf 100644 --- a/reference/netology_pd_diplom/.gitignore +++ b/reference/netology_pd_diplom/.gitignore @@ -104,3 +104,5 @@ venv.bak/ # mypy .mypy_cache/ + +enviroments.sh \ No newline at end of file diff --git a/reference/netology_pd_diplom/Dockerfile b/reference/netology_pd_diplom/Dockerfile new file mode 100644 index 000000000..ddd495b8a --- /dev/null +++ b/reference/netology_pd_diplom/Dockerfile @@ -0,0 +1,23 @@ +# Устанавливаем базовый образ Python и Alpine Linux +FROM python:3.10-alpine + +# Установка компилятора gcc для Alpine Linux +RUN apk add --no-cache gcc musl-dev + +# Устанавливаем рабочую директорию в контейнере +WORKDIR /app + +# Копируем файл requirements.txt локального проекта в WORKDIR контейнера +COPY ./requirements.txt ./requirements.txt + +# Устанавливаем зависимости +RUN pip install -r requirements.txt + +# Указываем порт, который будет слушать сервер +EXPOSE 8000 + +# Копируем файлы локального проекта в WORKDIR контейнера +COPY . . + +# Команда на запуск сервера +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/reference/netology_pd_diplom/README.md b/reference/netology_pd_diplom/README.md index 44f17da71..fdad3bdaa 100644 --- a/reference/netology_pd_diplom/README.md +++ b/reference/netology_pd_diplom/README.md @@ -1,9 +1,151 @@ # Пример API-сервиса для магазина -[Документация по запросам в PostMan](https://documenter.getpostman.com/view/5037826/SVfJUrSc) +[Исходная документация по запросам в PostMan](https://documenter.getpostman.com/view/5037826/SVfJUrSc) +## Разворачиваем проект +более подробно: +https://realpython.com/asynchronous-tasks-with-django-and-celery/ +https://stackabuse.com/asynchronous-tasks-in-django-with-redis-and-celery/ +### Установка Redis на Windows + +- [Загрузите zip-файл Redis](https://github.com/microsoftarchive/redis/releases/tag/win-3.2.100) и распакуйте его в какую-нибудь директорию. +- Найдите файл с именем `redis-server.exe` и дважды щелкните по нему, чтобы запустить сервер в командном окне. +- Аналогично найдите другой файл с именем `redis-cli.exe` и дважды щелкните по нему, чтобы открыть программу в отдельном командном окне. +- В командном окне, запустив клиент CLI, проверьте, может ли клиент взаимодействовать с сервером, выполнив команду, и если все пройдет хорошо , должен быть возвращен pingответ `PONG` + +### Установка Redis на Mac OSX / Linux + +- [Загрузите файл tarball](https://redis.io/download) Redis и распакуйте его в какой-нибудь каталог. +- Запустите `makefile`, чтобы собрать программу +```bash +make install +``` +- Откройте окно терминала и выполните +```bash +redis-server +``` +- В другом окне терминала запустите клиент CLI +```bash +redis-cli +``` +проверьте, может ли клиент взаимодействовать с сервером, выполнив команду `ping`, и если все пройдет хорошо , должен быть возвращен ответ `PONG` +```bash +127.0.0.1:6379> ping +``` + +### Установка Redis с помощью docer-compose +Создайте `.yml` файл следующего содержания +``` +version: '3.1' + +services: + + redis: + image: redis + ports: + - "6379:6379" +``` + +Запустить сборку +``` +docker-compose up -d +``` + +1. Установка виртуального окружения Python и зависимостей requirements.txt, в том числе и `Celery` + +2. Откройте три отдельных окна терминала перейдите в папку в которой находится `manage.py` и запустите почередно: +```bash +(venv) $ python manage.py runserver +$ redis-server # — единственный из трех, который вы можете запустить за пределами своей виртуальной среды +(venv) $ python -m celery -A netology_pd_diplom worker +``` + +## Разворачиваем проект с помощью Docker и Docker-compose + +1. Клонировать приоект + +2. Перейти в рабочую дерриктороию + +3. Загрузить все переменные сред +```bash +export DEBUG=True +export SECRET_KEY="your_secret_key_here" # Отправил почтой +export ALLOWED_HOSTS=localhost,127.0.0.1 # Или * - доступ с любого адреса +export DB_ENGINE=django.db.backends.sqlite3 +export DB_NAME=db.sqlite3 +export EMAIL_HOST_PASSWORD="your_email_password" # Пароль привязанный к почтовому сервису (Настройки для mail.ru, справка https://help.mail.ru/mail/security/protection/external/) Отправил почтой +``` + +для PostgreSQl +```bash +export DEBUG=True +export SECRET_KEY="your_secret_key_here" +export ALLOWED_HOSTS=localhost,127.0.0.1 +export DB_ENGINE=django.db.backends.postgresql +export DB_NAME="your_postgres_db_name" +export POSTGRES_USER="your_postgres_db_user" +export POSTGRES_PASSWORD="your_postgres_db_pass" +export EMAIL_HOST_PASSWORD="your_email_host_password" +``` + +Кроме того, если видишь сообщение Compose can now delegate builds to bake for better performance, +можешь включить использование нового инструмента Bake для сборки образов, установив переменную окружения: +```bash +export COMPOSE_BAKE=true +``` + +5. Запустить сборку (--build для пересборки) +```bash +docker-compose up -d --build +``` + +--- +## Дополнительное задание +> Отказ от django signals, с дальнейшей интеграцией в проект фреймворка Celery для по-настоящему асинхронных задач +Рекомендую начать с использования любой из нижеследующих инструкций. +- https://realpython.com/asynchronous-tasks-with-django-and-celery/ + +или +- https://stackabuse.com/asynchronous-tasks-in-django-with-redis-and-celery/ + +> Вижу вы покрыли код тестами, предлагаю добавить красивую кнопку с текущим покрытием у себя в Гитхабе +https://github.com/marketplace/actions/python-coverage + +> Опробовать автогенерацию документации Open API в рамках пакета DRF-Spectacular. После внедрения обязательно внимательно просмотрите страницу Swagger. Тут сразу можно будет оценить для себя разницу с Postman и огромный потенциал для плодотворной работы в команде с несколькими разработчиками. P.S. Используйте чаще docstring к классам и функциям +https://drf-spectacular.readthedocs.io/en/latest/readme.html + +> Попробовать простой DRF тротлинг, и можно проверить его работу добавление отдельного TestCase +https://www.django-rest-framework.org/api-guide/throttling/ + +> Добавить возможность авторизации с 1-2 социальных сетей, тут можно ознакомится с такой крутой библиотекой +https://github.com/python-social-auth/social-app-django + +> Наверняка пользовались админкой в ходе создания проекта, предлагаю сделать ей мощный тюнинг через эти библиотеки на выбор: +https://github.com/otto-torino/django-baton +https://django-jet-reboot.readthedocs.io/en/latest/ + +> После добавления Celery можно добавить загрузку аватаров пользователей и картинок товаров с последующей асинхронной обработкой их в фоновом режиме, например создание миниатюр различного размера для быстрой загрузки. Для этого есть множество библиотек на выбор: +https://easy-thumbnails.readthedocs.io/en/latest/ +https://django-versatileimagefield.readthedocs.io/en/latest/ +https://django-imagekit.readthedocs.io/en/latest/ + +> Очень часто в коммерческих проектах для перехвата ошибок, которые возникают у пользователей внедряют Sentry или Rollbar. Он позволяет быть в курсе насколько стабильно работает проект и к тому же хранит полные traceback ошибок, так чтобы можно было их исследовать. Будет большой плюс в резюме от наличия знаний по этой технологии. Попробуйте внедрить ее на базовом уровне и создать APIView, который вызывает исключение, что увидеть его в консоли Sentry или RollBar +https://blog.sentry.io/monitoring-performance-and-errors-in-a-django-application-with-sentry/ +https://docs.sentry.io/platforms/python/integrations/django/ +RollBar +https://docs.rollbar.com/docs/django +https://docs.rollbar.com/docs/celery + +> внедрить кэширование запросов к БД с использованием Redis, посмотреть насколько уменьшится время отклика системы. Вот эти библиотеки на выбор могут помочь с кэшированием запросов к БД: +https://github.com/noripyt/django-cachalot +https://github.com/Suor/django-cacheops + +> и последнее только по желанию, часто в проектах бывают задачи поиска причин медленной работы запроса. Например, из-за неправильного ORM запроса к БД. Рекомендую ознакомиться с пакетом https://github.com/jazzband/django-silk +проанализировать запросы в Postman и посмотреть какие из них вызывают каскад вторичных запросов, которые перегружают СУБД. Лично у меня вызывает удовлетворение, если ранее запрос выполнялся 400-500 мс, а сейчас за 3-4 мс, после того как я выявил проблему через Silk и оптимизировал запрос. Возможно, и Вам понравится) + +--- ## **Получить исходный код** diff --git a/reference/netology_pd_diplom/UserFlow.md b/reference/netology_pd_diplom/UserFlow.md new file mode 100644 index 000000000..a48f5ae1c --- /dev/null +++ b/reference/netology_pd_diplom/UserFlow.md @@ -0,0 +1,888 @@ +# Юзер флоу с ссылками на эндпоинты: +### *Ниже представлен уточненный юзер флоу с привязкой к эндпоинтам. Данный юзер флоу охватывает полное взаимодействие пользователя с интернет-магазином, включая регистрацию, авторизацию, управление контактами, работу с заказами и управление магазинами, а также события, которые инициируют отправку уведомлений по электронной почте, улучшая пользовательский опыт и обеспечивая ясность в коммуникации.* +--- + +[Документация в Postman](https://www.postman.com/martian-meadow-988006/workspace/my-diplom/collection/24194477-dd78304f-62a3-4f96-b0f3-aacd6094d18e?action=share&creator=24194477) + +--- + +### 1. **Регистрация пользователя** +- Эндпоинт: **POST `/user/register`** + - Новый пользователь регистрируется, предоставляя уникальный email, пароль и дополнительные данные о компании. Пользователь может зарегистрироваться как покупатель buyer (по умолчанию) или магазин shop (в административной панеле сервиса). + +```bash +curl --location --request POST 'http://localhost:8000/api/v1/user/register' \ +--form 'first_name="Константин"' \ +--form 'last_name="Фещук"' \ +--form 'email="dilmah949dilma@gmail.com"' \ +--form 'password="qwer1234A"' \ +--form 'company=""' \ +--form 'position=""' +``` +```bash +{ + "Status": true +} +``` +- **Событие:** При создании нового пользователя отправляется e-mail с токеном подтверждения для активации учетной записи. + +- Подтверждение email: + - Эндпоинт: **POST `/user/register/confirm`** + - Пользователь подтверждает свою почту, передавая в теле запроса токен подтверждения, чтобы активировать учетную запись (`is_active`). При этом токен, в целях безопасности, удаляется из базы данных. + +```bash +curl --location --request POST 'http://localhost:8000/api/v1/user/register/confirm' \ +--form 'email="dilmah949dilma@gmail.com"' \ +--form 'token="c335efb7aef8e3d3f0df5951450"' +``` +```bash +{ + "Status": true +} +``` + +--- + +### 2. **Авторизация** +- Эндпоинт: **POST `/user/login`** + - Пользователь вводит логин (email) и пароль для входа в систему. После успешного входа система проверяет активность учетной записи (`is_active`) и формирует токен для аутентификации. Этот токен далее передается в заголовке `Authorization` во всех последующих запросах, чтобы идентифицировать пользователя. + +```bash +curl --location --request POST 'http://localhost:8000/api/v1/user/login' \ +--form 'email="dilmah949dilma@gmail.com"' \ +--form 'password="qwer1234A"' +``` +```bash +{ + "Status": true, + "Token": "e77fbc201870a229252b67a35ed1600e6a60bbd5" +} +``` +--- + +### 3. **Просмотр и редактирование данных пользователя** +- Эндпоинт: **GET, POST `/user/details`** + - Зарегистрированный пользователь получает информацию о своем профиле и может редактировать ее методом POST. + +**Просмотр данных пользователя о своем профиле** +Ключ `contacts` содержит список контактных данных пользователя и заполняется после добавления пользователем своего контакта (см. пункт 4) +```bash +curl --location --request GET 'http://localhost:8000/api/v1/user/details' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' +``` +```bash +{ + "id": 24, + "first_name": "Костя", + "last_name": "Фещук", + "email": "dilmah949dilma@gmail.com", + "company": "", + "position": "", + "contacts": [ + { + "id": 3, + "city": "Калининград", + "street": "Бахчисарайска", + "house": "26", + "structure": "4", + "building": "", + "apartment": "", + "phone": "89210088233" + } + ] +} +``` + +**Редактирование данных пользователя о своем профиле методом POST** + +```bash +curl --location --request POST 'http://localhost:8000/api/v1/user/details' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' \ +--form 'first_name="Константин Николаевич"' \ +--form 'last_name="Фещук"' \ +--form 'email="dilmah949dilma@gmail.com"' \ +--form 'password="qwer1234A"' \ +--form 'company="mycompany"' \ +--form 'position="myposition"' +``` +```bash +{ + "Status": true +} +``` +--- + +### 4. **Управление контактами пользователя** +- Эндпоинт: **POST/GET/PUT/DELETE `/user/contact`** + - Пользователь может добавлять, изменять или удалять контактную информацию (например, адрес и телефон), используемую для выполнения заказов. + +**Добавление контактной информации** +```bash +curl --location --request POST 'http://localhost:8000/api/v1/user/contact' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' \ +--form 'city="Калининград"' \ +--form 'street="Бахчисарайская"' \ +--form 'house="26"' \ +--form 'structure="4"' \ +--form 'building="123"' \ +--form 'apartment="123"' \ +--form 'phone="+49564563242"' +``` +```bash +{ + "Status": true +} +``` + +**Просмотр контактной информации** +```bash +curl --location --request GET 'http://localhost:8000/api/v1/user/contact' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' +``` +```bash +[ + { + "id": 1, + "city": "Калининград", + "street": "Бахчисарайская", + "house": "26", + "structure": "4", + "building": "123", + "apartment": "123", + "phone": "+49564563242" + } +] +``` + +**Удаление контактной информации** +```bash +curl --location --request DELETE 'http://localhost:8000/api/v1/user/contact' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' \ +--form 'items="1,2"' +``` +```bash +{ + "Status": true, + "Удалено объектов": 2 +} +``` + +**Редактирование контактной информации** +```bash +curl --location --request PUT 'http://localhost:8000/api/v1/user/contact' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' \ +--form 'city="Калининград"' \ +--form 'street="Бахчисарайска"' \ +--form 'house="26"' \ +--form 'structure="4"' \ +--form 'building=""' \ +--form 'apartment=""' \ +--form 'id="3"' \ +--form 'phone="89210088233"' +``` +```bash +{ + "Status": true +} +``` +--- + +### 5. **Работа покупателей** + +#### 5.1. **Просмотр магазинов** +- Эндпоинт: **GET `/shops`** + - Покупатель получает список активных магазинов, готовых к приему заказов (`Shop.state = True`). +```bash +curl --location --request GET 'http://localhost:8000/api/v1/shops' +``` +```bash +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "name": "Связной", + "state": true + } + ] +} +``` +#### 5.2. **Просмотр категорий товаров** +- Эндпоинт: **GET `/categories`** + - Пользователь видит список категорий товаров, доступных для покупки. +```bash +curl --location --request GET 'http://localhost:8000/api/v1/categories' +``` +```bash +{ + "count": 4, + "next": null, + "previous": null, + "results": [ + { + "id": 5, + "name": "Телевизоры" + }, + { + "id": 224, + "name": "Смартфоны" + }, + { + "id": 15, + "name": "Аксессуары" + }, + { + "id": 1, + "name": "Flash-накопители" + } + ] +} +``` +#### 5.3. **Просмотр товаров магазина** +- Эндпоинт: **GET `/products?shop_id=1&category_id=224`** + - Покупатель видит доступные товары из каталога магазина, включая их характеристики и наличие. +```bash +curl --location --request GET 'http://localhost:8000/api/v1/products?shop_id=1&category_id=224' +``` +
+Нажмите, чтобы развернуть + +```bash +[ + { + "id": 15, + "model": "apple/iphone/xs-max", + "product": { + "name": "Смартфон Apple iPhone XS Max 512GB (золотистый)", + "category": "Смартфоны" + }, + "shop": 1, + "quantity": 14, + "price": 110000, + "price_rrc": 116990, + "product_parameters": [ + { + "parameter": "Диагональ (дюйм)", + "value": "6.5" + }, + { + "parameter": "Разрешение (пикс)", + "value": "2688x1242" + }, + { + "parameter": "Встроенная память (Гб)", + "value": "512" + }, + { + "parameter": "Цвет", + "value": "золотистый" + } + ] + }, + { + "id": 16, + "model": "apple/iphone/xr", + "product": { + "name": "Смартфон Apple iPhone XR 256GB (красный)", + "category": "Смартфоны" + }, + "shop": 1, + "quantity": 9, + "price": 65000, + "price_rrc": 69990, + "product_parameters": [ + { + "parameter": "Диагональ (дюйм)", + "value": "6.1" + }, + { + "parameter": "Разрешение (пикс)", + "value": "1792x828" + }, + { + "parameter": "Встроенная память (Гб)", + "value": "256" + }, + { + "parameter": "Цвет", + "value": "красный" + } + ] + }, + { + "id": 17, + "model": "apple/iphone/xr", + "product": { + "name": "Смартфон Apple iPhone XR 256GB (черный)", + "category": "Смартфоны" + }, + "shop": 1, + "quantity": 5, + "price": 65000, + "price_rrc": 69990, + "product_parameters": [ + { + "parameter": "Диагональ (дюйм)", + "value": "6.1" + }, + { + "parameter": "Разрешение (пикс)", + "value": "1792x828" + }, + { + "parameter": "Встроенная память (Гб)", + "value": "256" + }, + { + "parameter": "Цвет", + "value": "черный" + } + ] + }, + { + "id": 18, + "model": "apple/iphone/xr", + "product": { + "name": "Смартфон Apple iPhone XR 128GB (синий)", + "category": "Смартфоны" + }, + "shop": 1, + "quantity": 7, + "price": 60000, + "price_rrc": 64990, + "product_parameters": [ + { + "parameter": "Диагональ (дюйм)", + "value": "6.1" + }, + { + "parameter": "Разрешение (пикс)", + "value": "1792x828" + }, + { + "parameter": "Встроенная память (Гб)", + "value": "256" + }, + { + "parameter": "Цвет", + "value": "синий" + } + ] + }, + { + "id": 23, + "model": "xiaomi/mi-10t-pro", + "product": { + "name": "Smartphone Xiaomi Mi 10T Pro 256GB (cosmic black)", + "category": "Смартфоны" + }, + "shop": 1, + "quantity": 6, + "price": 70000, + "price_rrc": 74990, + "product_parameters": [ + { + "parameter": "Screen Size (inches)", + "value": "6.67" + }, + { + "parameter": "Resolution (pixels)", + "value": "2400x1080" + }, + { + "parameter": "Internal Memory (GB)", + "value": "256" + }, + { + "parameter": "Color", + "value": "cosmic black" + } + ] + } +] +``` +
+ +#### 5.4. **Добавление товаров в корзину. Просмотр и изменение ее содержимого** +- Эндпоинт: **POST/GET/PUT/DELETE `/basket`** + - Авторизиролванный как пркупатель пользователь может добавлять товары и их количество в корзину, редактировать их количество или удалять товары. На этом этапе создается заказ `Order` с полем `state="basket"` (статус - корзина). + Также покупатель может просмотреть товары находящиеся в корзине. + +**Добавление товара в корзину** +```bash +curl --location --request POST 'http://localhost:8000/api/v1/basket' \ +--header 'Content-Type: application/x-www-form-urlencoded ' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' \ +--form 'items="[ + { + \"product_info\": 25, + \"quantity\": 13 + }, + { + \"product_info\": 26, + \"quantity\": 12 + } + ]"' +``` + +```bash +{ + "Status": true, + "Создано объектов": 2 +} +``` + + +**Просмотр корзины** +```bash +curl --location --request GET 'http://localhost:8000/api/v1/basket' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' +``` + +
+Нажмите, чтобы развернуть + +```bash +[ + { + "id": 1, + "ordered_items": [ + { + "id": 3, + "product_info": { + "id": 25, + "model": "lg/oled-cx", + "product": { + "name": "LG OLED CX 55\" 4K UHD Smart TV", + "category": "Телевизоры" + }, + "shop": 1, + "quantity": 7, + "price": 1800, + "price_rrc": 1999, + "product_parameters": [ + { + "parameter": "Screen Size (inches)", + "value": "55" + }, + { + "parameter": "Resolution (pixels)", + "value": "3840x2160" + }, + { + "parameter": "Smart TV", + "value": "True" + } + ] + }, + "quantity": 13 + }, + { + "id": 4, + "product_info": { + "id": 26, + "model": "sony/bravia-x900h", + "product": { + "name": "Sony Bravia X900H 75\" 4K UHD Smart TV", + "category": "Телевизоры" + }, + "shop": 1, + "quantity": 3, + "price": 3000, + "price_rrc": 3499, + "product_parameters": [ + { + "parameter": "Screen Size (inches)", + "value": "75" + }, + { + "parameter": "Resolution (pixels)", + "value": "3840x2160" + }, + { + "parameter": "Smart TV", + "value": "True" + } + ] + }, + "quantity": 12 + } + ], + "state": "basket", + "dt": "2025-02-28T21:05:14.051909Z", + "total_sum": 59400, + "contact": null # Заполнить при добавлении контакта + } +] +``` +
+ +**Удаление выбранных товаров из корзины** +```bash +curl --location --request DELETE 'http://localhost:8000/api/v1/basket' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' \ +--form 'items="1,2"' +``` + +```bash +{ + "Status": true, + "Удалено объектов": 2 +} +``` + +**Редактирование количество товаров в корзине** +```bash +curl --location --request PUT 'http://localhost:8000/api/v1/basket' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' \ +--form 'items="[ + { + \"id\": 5, + \"quantity\": 3 + }, + { + \"id\": 6, + \"quantity\": 1 + } + ]"' +``` + +```bash +{ + "Status": true, + "Обновлено объектов": 2 +} +``` + +#### 5.5. **Оформление заказа** +- Эндпоинт: **POST `/order`** + - Покупатель отправляет форму с контактными данными (`id` - id заказа и `contact` - id контактной информации). Заказ меняет статус на `state="new"` и становится доступен для обработки магазином. + +- Событие: При изменении состояния заказа отправляется e-mail магазину с уведомлением об изменении статуса заказа с сообщением ЭТО NEW!!! >>> "Статус заказа изменен на: Новый" <<< ЭТО NEW!!!. + +```bash +curl --location --request POST 'http://localhost:8000/api/v1/order' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' \ +--form 'id="1"' \ +--form 'contact="3"' +``` + +```bash +{ + "Status": true +} +``` +--- + +### 6. **Работа с заказами** + +#### 6.1. **Просмотр своих заказов (покупатель)** +- Эндпоинт: **GET `/order`** + - Покупатель видит список своих заказов, их статусы (`new`, `confirmed`, `sent`, `delivered`, `canceled`) и детали. + +```bash +curl --location --request GET 'http://localhost:8000/api/v1/order' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Token e77fbc201870a229252b67a35ed1600e6a60bbd5' +``` + +
+Нажмите, чтобы развернуть + +```bash +[ + { + "id": 1, + "ordered_items": [ + { + "id": 5, + "product_info": { + "id": 25, + "model": "lg/oled-cx", + "product": { + "name": "LG OLED CX 55\" 4K UHD Smart TV", + "category": "Телевизоры" + }, + "shop": 1, + "quantity": 7, + "price": 1800, + "price_rrc": 1999, + "product_parameters": [ + { + "parameter": "Screen Size (inches)", + "value": "55" + }, + { + "parameter": "Resolution (pixels)", + "value": "3840x2160" + }, + { + "parameter": "Smart TV", + "value": "True" + } + ] + }, + "quantity": 3 + }, + { + "id": 6, + "product_info": { + "id": 26, + "model": "sony/bravia-x900h", + "product": { + "name": "Sony Bravia X900H 75\" 4K UHD Smart TV", + "category": "Телевизоры" + }, + "shop": 1, + "quantity": 3, + "price": 3000, + "price_rrc": 3499, + "product_parameters": [ + { + "parameter": "Screen Size (inches)", + "value": "75" + }, + { + "parameter": "Resolution (pixels)", + "value": "3840x2160" + }, + { + "parameter": "Smart TV", + "value": "True" + } + ] + }, + "quantity": 1 + } + ], + "state": "new", + "dt": "2025-02-28T21:05:14.051909Z", + "total_sum": 8400, + "contact": { + "id": 3, + "city": "Калининград", + "street": "Бахчисарайска", + "house": "26", + "structure": "4", + "building": "", + "apartment": "", + "phone": "89210088233" + } + } +] +``` +
+ +#### 6.2. **Получение заказов (магазин)** +- Эндпоинт: **GET `/partner/orders`** + - Магазин получает список новых заказов, поступивших от покупателей, для обработки. + +```bash +curl --location --request GET 'http://localhost:8000/api/v1/partner/orders' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Token 53a4742c02627553d1dbee0268815f72d1c40b5b' +``` + +
+Нажмите, чтобы развернуть + +```bash +[ + { + "id": 1, + "ordered_items": [ + { + "id": 5, + "product_info": { + "id": 25, + "model": "lg/oled-cx", + "product": { + "name": "LG OLED CX 55\" 4K UHD Smart TV", + "category": "Телевизоры" + }, + "shop": 1, + "quantity": 7, + "price": 1800, + "price_rrc": 1999, + "product_parameters": [ + { + "parameter": "Screen Size (inches)", + "value": "55" + }, + { + "parameter": "Resolution (pixels)", + "value": "3840x2160" + }, + { + "parameter": "Smart TV", + "value": "True" + } + ] + }, + "quantity": 3 + }, + { + "id": 6, + "product_info": { + "id": 26, + "model": "sony/bravia-x900h", + "product": { + "name": "Sony Bravia X900H 75\" 4K UHD Smart TV", + "category": "Телевизоры" + }, + "shop": 1, + "quantity": 3, + "price": 3000, + "price_rrc": 3499, + "product_parameters": [ + { + "parameter": "Screen Size (inches)", + "value": "75" + }, + { + "parameter": "Resolution (pixels)", + "value": "3840x2160" + }, + { + "parameter": "Smart TV", + "value": "True" + } + ] + }, + "quantity": 1 + } + ], + "state": "new", + "dt": "2025-02-28T21:05:14.051909Z", + "total_sum": 8400, + "contact": { + "id": 3, + "city": "Калининград", + "street": "Бахчисарайска", + "house": "26", + "structure": "4", + "building": "", + "apartment": "", + "phone": "89210088233" + } + } +] +``` +
+ +#### 6.3. **Обновление состояния заказа** (NEW!) +- Эндпоинт: **POST `/partner/orders`** + - Магазин меняет статус заказа на значения `STATE_CHOICES` соответствующие `"confirmed"`, `"assembled"`, `"sent"`, `"delivered"` или `"canceled"` в соответствии с этапами его обработки. + +```bash +curl --location --request POST 'http://localhost:8000/api/v1/partner/orders' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Token 53a4742c02627553d1dbee0268815f72d1c40b5b' \ +--form 'id="1"' \ # номер заказа +--form 'state="confirmed"' # статус заказа из STATE_CHOICES +``` + +```bash +{ + "Status": true, + "Message": "Статус заказа успешно изменен на Подтвержден" +} +``` + +- Событие: При изменении состояния заказа отправляется e-mail пользователю с уведомлением об измененном статусе заказа. +Статус заказа обязательно выбирается из `STATE_CHOICES` и из запроса интегрируется в тело письма (<<< ЭТО NEW!) + +--- + +### 7. **Управление магазином (для владельцев магазинов)** + +#### 7.1. **Обновление товаров и данных магазина** +- Эндпоинт: **POST `/partner/update`** + - Магазин добавляет или обновляет информацию о своих товарах, ценах и наличии. + +```bash +curl --location --request POST 'http://localhost:8000/api/v1/partner/update' \ +--header 'Authorization: Token 53a4742c02627553d1dbee0268815f72d1c40b5b' \ +--form 'url="https://raw.githubusercontent.com/netology-code/python-final-diplom/master/data/shop1.yaml"' # это адрес прайса поставщика товаров +``` + +```bash +{ + "Status": true +} +``` + +#### 7.2. **Изменение статуса магазина** +- Эндпоинт: **GET, POST `/partner/state`** + - Владелец магазина может открывать или закрывать свой магазин, чтобы управлять возможностью принимать новые заказы (`state=True/False`). + +**Просмотреть статус магазина** + +```bash +curl --location --request GET 'http://localhost:8000/api/v1/partner/state' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Token 53a4742c02627553d1dbee0268815f72d1c40b5b' +``` + +```bash +{ + "id": 1, + "name": "Связной", + "state": true +} +``` +**Изменить (открыть/закрыть) статус магазина** + +```bash +curl --location --request POST 'http://localhost:8000/api/v1/partner/state' \ +--header 'Authorization: Token 53a4742c02627553d1dbee0268815f72d1c40b5b' \ +--form 'state="off"' # Или state="on" +``` + +```bash +{ + "Status": true +} +``` + +--- + +### 8. **Сброс пароля** +- Эндпоинты: + - **POST `/user/password_reset`** — для запроса сброса пароля. + +```bash +curl --location --request POST 'http://localhost:8000/api/v1/user/password_reset' \ +--form 'email="dilmah949dilma@gmail.com"' +``` + +```bash +{ + "status": "OK" +} +``` + + - **Событие:** При создании токена сброса пароля отправляется e-mail пользователю с токеном для сброса пароля. + + - **POST `/user/password_reset/confirm`** — для подтверждения сброса и установки нового пароля. + +```bash +curl --location --request POST 'http://localhost:8000/api/v1/user/password_reset/confirm' \ +--form 'email="dilmah949dilma@gmail.com"' \ +--form 'password="qwer1234Anew"' \ +--form 'token="adeaf600a9098d0e730e1025576d70a14ac3c6a"' +``` + +```bash +{ + "status": "OK" +} +``` + diff --git a/reference/netology_pd_diplom/backend/apps.py b/reference/netology_pd_diplom/backend/apps.py index 90cbb2cb4..e0101c21c 100644 --- a/reference/netology_pd_diplom/backend/apps.py +++ b/reference/netology_pd_diplom/backend/apps.py @@ -1,11 +1,13 @@ from django.apps import AppConfig - class BackendConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'backend' def ready(self): - """ - импортируем сигналы - """ + # Импортируем обработчики сигналов здесь, чтобы избежать циклических импортов + from backend.signals import ( + password_reset_token_created, + new_user_registered_signal, + new_order_signal + ) \ No newline at end of file diff --git a/reference/netology_pd_diplom/backend/db.sqlite3.png b/reference/netology_pd_diplom/backend/db.sqlite3.png new file mode 100644 index 000000000..371ac48ba Binary files /dev/null and b/reference/netology_pd_diplom/backend/db.sqlite3.png differ diff --git a/reference/netology_pd_diplom/backend/models.py b/reference/netology_pd_diplom/backend/models.py index d3138bfb9..f515b2793 100644 --- a/reference/netology_pd_diplom/backend/models.py +++ b/reference/netology_pd_diplom/backend/models.py @@ -2,18 +2,19 @@ from django.contrib.auth.models import AbstractUser from django.contrib.auth.validators import UnicodeUsernameValidator from django.db import models -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _ # функция интернационализации (i18n) +# gettext_lazy — "ленивая" версия функции перевода (вычисляется только при рендеринге, а не при загрузке модели). from django_rest_passwordreset.tokens import get_token_generator -STATE_CHOICES = ( - ('basket', 'Статус корзины'), - ('new', 'Новый'), - ('confirmed', 'Подтвержден'), - ('assembled', 'Собран'), - ('sent', 'Отправлен'), - ('delivered', 'Доставлен'), - ('canceled', 'Отменен'), -) +# STATE_CHOICES = ( +# ('basket', 'Статус корзины'), +# ('new', 'Новый'), +# ('confirmed', 'Подтвержден'), +# ('assembled', 'Собран'), +# ('sent', 'Отправлен'), +# ('delivered', 'Доставлен'), +# ('canceled', 'Отменен'), +# ) Перемещено в Order USER_TYPE_CHOICES = ( ('shop', 'Магазин'), @@ -28,31 +29,65 @@ class UserManager(BaseUserManager): """ Миксин для управления пользователями + Менеджер полностью переопределяет логику работы с username, используя email как основной идентификатор """ use_in_migrations = True def _create_user(self, email, password, **extra_fields): """ - Create and save a user with the given username, email, and password. + Создайте и сохраните пользователя с указанным именем, адресом электронной почты и паролем. + Args: + email (str): Обязательное поле. Электронная почта пользователя + password (str): Пароль (может быть None для незарегистрированных пользователей) + **extra_fields: Дополнительные атрибуты пользователя + Returns: + User: созданный пользователь + Raises: + ValueError: если email не указан """ + # Валидация обязательного поля if not email: raise ValueError('The given email must be set') + # Нормализация email (приведение к нижнему регистру, обрезка пробелов) email = self.normalize_email(email) + # Создание объекта пользователя (использует связанную модель User) user = self.model(email=email, **extra_fields) + # Безопасное хеширование пароля перед сохранением в БД user.set_password(password) + # Сохранение в БД с указанием используемой базы данных user.save(using=self._db) return user + def create_user(self, email, password=None, **extra_fields): + """ + Создает обычного пользователя с дефолтными правами. + + Особенности: + - is_staff: False (нет доступа в админку) + - is_superuser: False (нет прав суперпользователя) + """ extra_fields.setdefault('is_staff', False) extra_fields.setdefault('is_superuser', False) return self._create_user(email, password, **extra_fields) def create_superuser(self, email, password, **extra_fields): + """ + Создает суперпользователя с расширенными правами. + + Автоматически устанавливает: + - is_staff: True (доступ в админку) + - is_superuser: True (полные права) + - is_active: True (активация без подтверждения email) + + Raises: + ValueError: если права не соответствуют суперпользователю + """ + # Установка прав суперпользователя extra_fields.setdefault('is_staff', True) extra_fields.setdefault('is_superuser', True) extra_fields.setdefault('is_active', True) - + # Валидация установленных прав if extra_fields.get('is_staff') is not True: raise ValueError('Superuser must have is_staff=True.') if extra_fields.get('is_superuser') is not True: @@ -64,14 +99,39 @@ def create_superuser(self, email, password, **extra_fields): class User(AbstractUser): """ Стандартная модель пользователей + Заменяет стандартный username на email для аутентификации (кастомизация). """ + # Убираем обязательные поля по умолчанию (username не используется для входа) REQUIRED_FIELDS = [] - objects = UserManager() + + # Менеджер объектов с расширенной логикой создания пользователей, так как + # используем email как уникальный идентификатор вместо username + objects = UserManager() # теперь при работе с БД через object можно использовать методы из + # кастомного менеджера UserManager() - create_user() и create_superuser(), например + # user = User.objects.create_user() или user = User.objects.create_superuser() + + # используем email как уникальный идентификатор вместо username USERNAME_FIELD = 'email' + + # Основное поле для аутентификации (уникальное, обязательно для заполнения) email = models.EmailField(_('email address'), unique=True) - company = models.CharField(verbose_name='Компания', max_length=40, blank=True) - position = models.CharField(verbose_name='Должность', max_length=40, blank=True) + # Дополнительные бизнес-поля (необязательные) + company = models.CharField( + verbose_name=_('Компания'), + max_length=40, + blank=True, # разрешаем пустое значение + help_text=_("Название компании пользователя (для магазинов)"), + ) + position = models.CharField( + verbose_name=_('Должность'), + max_length=40, + blank=True, + help_text=_("Должность в компании (для сотрудников магазинов)"), + ) + # Валидатор для username (оставлен для совместимости) username_validator = UnicodeUsernameValidator() + # Поле username оставлено для совместимости с материнским AbstractUser, + # но не используется для аутентификации username = models.CharField( _('username'), max_length=150, @@ -81,53 +141,74 @@ class User(AbstractUser): 'unique': _("A user with that username already exists."), }, ) + # Статус активации (по умолчанию неактивен до подтверждения email) is_active = models.BooleanField( _('active'), - default=False, + default=False, # активируется после подтверждения email help_text=_( 'Designates whether this user should be treated as active. ' - 'Unselect this instead of deleting accounts.' - ), + 'Unselect this instead of deleting accounts.'), + ) + # Тип пользователя для разграничения ролей в системе + type = models.CharField( + verbose_name=_('Тип пользователя'), + choices=USER_TYPE_CHOICES, # ('shop', 'Магазин') или ('buyer', 'Покупатель') + max_length=5, + default='buyer', + help_text=_("Тип учетной записи (магазин или покупатель)"), ) - type = models.CharField(verbose_name='Тип пользователя', choices=USER_TYPE_CHOICES, max_length=5, default='buyer') def __str__(self): - return f'{self.first_name} {self.last_name}' + """Строковое представление с использованием имени и фамилии""" + return f'{self.first_name} {self.last_name}' if self.first_name else self.email class Meta: - verbose_name = 'Пользователь' - verbose_name_plural = "Список пользователей" - ordering = ('email',) + verbose_name = _('Пользователь') + verbose_name_plural = _("Список пользователей") + ordering = ('email',) # сортировка по email по умолчанию + # Дополнительная защита от дубликатов + constraints = [ + models.UniqueConstraint( + fields=['email'], + name='unique_email' + ) + ] class Shop(models.Model): - objects = models.manager.Manager() - name = models.CharField(max_length=50, verbose_name='Название') - url = models.URLField(verbose_name='Ссылка', null=True, blank=True) - user = models.OneToOneField(User, verbose_name='Пользователь', + + objects = models.manager.Manager() # Можно создать кастомный менеджер, который позволит при взаимодействии с базой данных + # используя objects применять свои кастомные методы, но здесь используется стандартный менеджер,так как если + # провалиться внутрь него мы увидим заглушку pass, ну или какие-то собственные методы определены с помощью .from_queryset + # в субклассе QuerySet + # см. https://docs.djangoproject.com/en/4.1/topics/db/managers/#managers + # см. https://docs.google.com/document/d/1_zO0NaMzGqY6ohgqYxi875pghBdFho9RvKxB5fhbjSc/edit?usp=sharing + name = models.CharField(max_length=50, verbose_name=_('Название')) + url = models.URLField(verbose_name=_('Ссылка'), null=True, blank=True) + user = models.OneToOneField(User, verbose_name=_('Пользователь'), blank=True, null=True, on_delete=models.CASCADE) - state = models.BooleanField(verbose_name='статус получения заказов', default=True) + state = models.BooleanField(verbose_name=_('статус получения заказов'), default=True) # filename class Meta: - verbose_name = 'Магазин' - verbose_name_plural = "Список магазинов" - ordering = ('-name',) + verbose_name = _('Магазин') + verbose_name_plural = _('Список магазинов') + ordering = ('-name',) # сортировка по названию по убыванию def __str__(self): return self.name class Category(models.Model): - objects = models.manager.Manager() - name = models.CharField(max_length=40, verbose_name='Название') - shops = models.ManyToManyField(Shop, verbose_name='Магазины', related_name='categories', blank=True) + objects = models.manager.Manager() + name = models.CharField(max_length=40, verbose_name=_('Название')) + shops = models.ManyToManyField(Shop, verbose_name=_('Магазины'), related_name='categories', blank=True) class Meta: - verbose_name = 'Категория' - verbose_name_plural = "Список категорий" + verbose_name = _('Категория') + verbose_name_plural = _('Список категорий') ordering = ('-name',) def __str__(self): @@ -136,13 +217,13 @@ def __str__(self): class Product(models.Model): objects = models.manager.Manager() - name = models.CharField(max_length=80, verbose_name='Название') - category = models.ForeignKey(Category, verbose_name='Категория', related_name='products', blank=True, + name = models.CharField(max_length=80, verbose_name=_('Название')) + category = models.ForeignKey(Category, verbose_name=_('Категория'), related_name='products', blank=True, on_delete=models.CASCADE) class Meta: - verbose_name = 'Продукт' - verbose_name_plural = "Список продуктов" + verbose_name = _('Продукт') + verbose_name_plural = _('Список продуктов') ordering = ('-name',) def __str__(self): @@ -151,31 +232,31 @@ def __str__(self): class ProductInfo(models.Model): objects = models.manager.Manager() - model = models.CharField(max_length=80, verbose_name='Модель', blank=True) - external_id = models.PositiveIntegerField(verbose_name='Внешний ИД') - product = models.ForeignKey(Product, verbose_name='Продукт', related_name='product_infos', blank=True, + model = models.CharField(max_length=80, verbose_name=_('Модель'), blank=True) + external_id = models.PositiveIntegerField(verbose_name=_('Внешний ИД')) + product = models.ForeignKey(Product, verbose_name=_('Продукт'), related_name='product_infos', blank=True, on_delete=models.CASCADE) - shop = models.ForeignKey(Shop, verbose_name='Магазин', related_name='product_infos', blank=True, + shop = models.ForeignKey(Shop, verbose_name=_('Магазин'), related_name='product_infos', blank=True, on_delete=models.CASCADE) - quantity = models.PositiveIntegerField(verbose_name='Количество') - price = models.PositiveIntegerField(verbose_name='Цена') - price_rrc = models.PositiveIntegerField(verbose_name='Рекомендуемая розничная цена') + quantity = models.PositiveIntegerField(verbose_name=_('Количество')) + price = models.PositiveIntegerField(verbose_name=_('Цена')) + price_rrc = models.PositiveIntegerField(verbose_name=_('Рекомендуемая розничная цена')) class Meta: - verbose_name = 'Информация о продукте' - verbose_name_plural = "Информационный список о продуктах" + verbose_name = _('Информация о продукте') + verbose_name_plural = _("Информационный список о продуктах") constraints = [ models.UniqueConstraint(fields=['product', 'shop', 'external_id'], name='unique_product_info'), - ] + ] # дополнительная защита от дубликатов class Parameter(models.Model): objects = models.manager.Manager() - name = models.CharField(max_length=40, verbose_name='Название') + name = models.CharField(max_length=40, verbose_name=_('Название')) class Meta: - verbose_name = 'Имя параметра' - verbose_name_plural = "Список имен параметров" + verbose_name = _('Имя параметра') + verbose_name_plural = _('Список имен параметров') ordering = ('-name',) def __str__(self): @@ -184,59 +265,75 @@ def __str__(self): class ProductParameter(models.Model): objects = models.manager.Manager() - product_info = models.ForeignKey(ProductInfo, verbose_name='Информация о продукте', + product_info = models.ForeignKey(ProductInfo, verbose_name=_('Информация о продукте'), related_name='product_parameters', blank=True, on_delete=models.CASCADE) - parameter = models.ForeignKey(Parameter, verbose_name='Параметр', related_name='product_parameters', blank=True, + parameter = models.ForeignKey(Parameter, verbose_name=_('Параметр'), related_name='product_parameters', blank=True, on_delete=models.CASCADE) - value = models.CharField(verbose_name='Значение', max_length=100) + value = models.CharField(verbose_name=_('Значение'), max_length=100) class Meta: - verbose_name = 'Параметр' - verbose_name_plural = "Список параметров" + verbose_name = _('Параметр') + verbose_name_plural = _('Список параметров') constraints = [ models.UniqueConstraint(fields=['product_info', 'parameter'], name='unique_product_parameter'), ] - + class Contact(models.Model): objects = models.manager.Manager() - user = models.ForeignKey(User, verbose_name='Пользователь', + user = models.ForeignKey(User, verbose_name=_('Пользователь'), related_name='contacts', blank=True, on_delete=models.CASCADE) - city = models.CharField(max_length=50, verbose_name='Город') - street = models.CharField(max_length=100, verbose_name='Улица') - house = models.CharField(max_length=15, verbose_name='Дом', blank=True) - structure = models.CharField(max_length=15, verbose_name='Корпус', blank=True) - building = models.CharField(max_length=15, verbose_name='Строение', blank=True) - apartment = models.CharField(max_length=15, verbose_name='Квартира', blank=True) - phone = models.CharField(max_length=20, verbose_name='Телефон') + city = models.CharField(max_length=50, verbose_name=_('Город')) + street = models.CharField(max_length=100, verbose_name=_('Улица')) + house = models.CharField(max_length=15, verbose_name=_('Дом'), blank=True) + structure = models.CharField(max_length=15, verbose_name=_('Корпус'), blank=True) + building = models.CharField(max_length=15, verbose_name=_('Строение'), blank=True) + apartment = models.CharField(max_length=15, verbose_name=_('Квартира'), blank=True) + phone = models.CharField(max_length=20, verbose_name=_('Телефон')) class Meta: - verbose_name = 'Контакты пользователя' - verbose_name_plural = "Список контактов пользователя" + verbose_name = _('Контакты пользователя') + verbose_name_plural = _("Список контактов пользователя") def __str__(self): return f'{self.city} {self.street} {self.house}' class Order(models.Model): + # Все обращения к статусам теперь происходят через модель Order + # Соответствующим принципам Django (хранение CHOICES внутри модели) + STATE_CHOICES = ( + ('basket', 'Статус корзины'), + ('new', 'Новый'), + ('confirmed', 'Подтвержден'), + ('assembled', 'Собран'), + ('sent', 'Отправлен'), + ('delivered', 'Доставлен'), + ('canceled', 'Отменен'), + ) + objects = models.manager.Manager() - user = models.ForeignKey(User, verbose_name='Пользователь', + user = models.ForeignKey(User, verbose_name=_('Пользователь'), related_name='orders', blank=True, on_delete=models.CASCADE) dt = models.DateTimeField(auto_now_add=True) - state = models.CharField(verbose_name='Статус', choices=STATE_CHOICES, max_length=15) - contact = models.ForeignKey(Contact, verbose_name='Контакт', + state = models.CharField(verbose_name=_('Статус'), choices=STATE_CHOICES, max_length=15) + contact = models.ForeignKey(Contact, verbose_name=_('Контакт'), blank=True, null=True, on_delete=models.CASCADE) class Meta: - verbose_name = 'Заказ' - verbose_name_plural = "Список заказ" + verbose_name = _('Заказ') + verbose_name_plural = _("Список заказ") ordering = ('-dt',) + # NEW!!! Используется встроенный метод Django для полей с CHOICES (выборами) вместо ручного создания словаря + def get_state_display(self): + return dict(self.STATE_CHOICES).get(self.state, self.state) + def __str__(self): return str(self.dt) @@ -245,29 +342,29 @@ def __str__(self): # return self.ordered_items.aggregate(total=Sum("quantity"))["total"] -class OrderItem(models.Model): +class OrderItem(models.Model): # связывает заказ с конкретными товарами (`ProductInfo`) и количеством objects = models.manager.Manager() - order = models.ForeignKey(Order, verbose_name='Заказ', related_name='ordered_items', blank=True, + order = models.ForeignKey(Order, verbose_name=_('Заказ'), related_name='ordered_items', blank=True, on_delete=models.CASCADE) - product_info = models.ForeignKey(ProductInfo, verbose_name='Информация о продукте', related_name='ordered_items', + product_info = models.ForeignKey(ProductInfo, verbose_name=_('Информация о продукте'), related_name='ordered_items', blank=True, on_delete=models.CASCADE) - quantity = models.PositiveIntegerField(verbose_name='Количество') + quantity = models.PositiveIntegerField(verbose_name=_('Количество')) class Meta: - verbose_name = 'Заказанная позиция' - verbose_name_plural = "Список заказанных позиций" + verbose_name = _('Заказанная позиция') + verbose_name_plural = _("Список заказанных позиций") constraints = [ models.UniqueConstraint(fields=['order_id', 'product_info'], name='unique_order_item'), - ] + ] class ConfirmEmailToken(models.Model): objects = models.manager.Manager() class Meta: - verbose_name = 'Токен подтверждения Email' - verbose_name_plural = 'Токены подтверждения Email' + verbose_name = _('Токен подтверждения Email') + verbose_name_plural = _('Токены подтверждения Email') @staticmethod def generate_key(): @@ -278,15 +375,15 @@ def generate_key(): User, related_name='confirm_email_tokens', on_delete=models.CASCADE, - verbose_name=_("The User which is associated to this password reset token") + verbose_name=_('The User which is associated to this password reset token') ) created_at = models.DateTimeField( auto_now_add=True, - verbose_name=_("When was this token generated") + verbose_name=_('When was this token generated') ) - # Key field, though it is not the primary key of the model + # Ключевое поле, хотя оно и не является первичным ключом модели key = models.CharField( _("Key"), max_length=64, @@ -300,4 +397,4 @@ def save(self, *args, **kwargs): return super(ConfirmEmailToken, self).save(*args, **kwargs) def __str__(self): - return "Password reset token for user {user}".format(user=self.user) + return _("Password reset token for user {user}").format(user=self.user) diff --git a/reference/netology_pd_diplom/backend/serializers.py b/reference/netology_pd_diplom/backend/serializers.py index 48ec2d54b..cc5dbb538 100644 --- a/reference/netology_pd_diplom/backend/serializers.py +++ b/reference/netology_pd_diplom/backend/serializers.py @@ -8,21 +8,20 @@ class ContactSerializer(serializers.ModelSerializer): class Meta: model = Contact fields = ('id', 'city', 'street', 'house', 'structure', 'building', 'apartment', 'user', 'phone') - read_only_fields = ('id',) + read_only_fields = ('id',) # поле будет включено в вывод но не будет изменяться или создаваться пользователем extra_kwargs = { - 'user': {'write_only': True} + 'user': {'write_only': True} # поле user только для записи не будет возвращаться при сериализации } - class UserSerializer(serializers.ModelSerializer): - contacts = ContactSerializer(read_only=True, many=True) + # Вложенный сериализатор используется для связи "один ко многим" (many=True) между пользователем и его контактами + contacts = ContactSerializer(read_only=True, many=True) # contacts доступны только для чтения и через этот сериализатор не изменяются class Meta: model = User fields = ('id', 'first_name', 'last_name', 'email', 'company', 'position', 'contacts') read_only_fields = ('id',) - class CategorySerializer(serializers.ModelSerializer): class Meta: model = Category @@ -74,7 +73,7 @@ class Meta: class OrderItemCreateSerializer(OrderItemSerializer): - product_info = ProductInfoSerializer(read_only=True) + product_info = ProductInfoSerializer(read_only=True) class OrderSerializer(serializers.ModelSerializer): diff --git a/reference/netology_pd_diplom/backend/signals.py b/reference/netology_pd_diplom/backend/signals.py index 0a2c0552e..5964f3177 100644 --- a/reference/netology_pd_diplom/backend/signals.py +++ b/reference/netology_pd_diplom/backend/signals.py @@ -6,77 +6,127 @@ from django.dispatch import receiver, Signal from django_rest_passwordreset.signals import reset_password_token_created -from backend.models import ConfirmEmailToken, User +from backend.models import ConfirmEmailToken, User, Order -new_user_registered = Signal() +# Сигналы для отслеживания событий, таких как создание нового +# пользователя и сброс пароля. Каждый раз, когда происходит событие, соответствующий +# обработчик выполняет действия, например, отправляет электронное письмо. -new_order = Signal() +new_user_registered = Signal() # сигнал для регистрации нового пользователя +new_order = Signal() # сигнал для обновления статуса заказа @receiver(reset_password_token_created) -def password_reset_token_created(sender, instance, reset_password_token, **kwargs): +def password_reset_token_created(sender: Type[User], instance: User, reset_password_token: ConfirmEmailToken, **kwargs) -> None: """ - Отправляем письмо с токеном для сброса пароля - When a token is created, an e-mail needs to be sent to the user - :param sender: View Class that sent the signal - :param instance: View Instance that sent the signal - :param reset_password_token: Token Model Object - :param kwargs: - :return: + Отправляет электронное письмо пользователю, когда происходит сброс пароля. + + Args: + sender: отправитель - модель User + instance: пользователь, для которого происходит сброс пароля + reset_password_token: токен для сброса пароля + **kwargs: дополнительные параметры """ - # send an e-mail to the user - + # send an e-mail to the user msg = EmailMultiAlternatives( # title: - f"Password Reset Token for {reset_password_token.user}", + f"Токен сброса пароля для {reset_password_token.user}", # message: reset_password_token.key, # from: settings.EMAIL_HOST_USER, # to: [reset_password_token.user.email] - ) - msg.send() + ) # отправляем письмо с токеном для сброса пароля + msg.send() # отправляем письмо @receiver(post_save, sender=User) -def new_user_registered_signal(sender: Type[User], instance: User, created: bool, **kwargs): +def new_user_registered_signal(sender: Type[User], instance: User, created: bool, **kwargs) -> None: """ - отправляем письмо с подтрердждением почты + Отправляет письмо с токеном для подтверждения электронного адреса, + когда пользователь регистрируется. + Args: + sender (Type[User]): отправитель - модель User + instance (User): новосозданный пользователь + created (bool): флаг, что пользователь был создан + **kwargs: дополнительные параметры """ - if created and not instance.is_active: - # send an e-mail to the user - token, _ = ConfirmEmailToken.objects.get_or_create(user_id=instance.pk) + # if created and not instance.is_active: # если пользователь создан и не активен + # # создаем токен для подтверждения электронного адреса + # token, _ = ConfirmEmailToken.objects.get_or_create(user_id=instance.pk) # возвращает кортеж, содержащий два элемента: сам объект и булево значение, + # # указывающее, был ли он создан или уже существовал (заглушка _). + # msg = EmailMultiAlternatives( + # # title: + # f"Токен подтверждения электронного адреса для {instance.email}", + # # message: + # token.key, + # # from: + # settings.EMAIL_HOST_USER, + # # to: + # [instance.email] + # ) + # msg.send() + pass # NEW! Закоментировано так как ЭТО СДЕЛАНО НА АСИНХРОНКЕ в tasks.py - msg = EmailMultiAlternatives( - # title: - f"Password Reset Token for {instance.email}", - # message: - token.key, - # from: - settings.EMAIL_HOST_USER, - # to: - [instance.email] - ) - msg.send() +# @receiver(new_order) +# def new_order_signal(user_id: int, **kwargs) -> None: +# """ +# Отправляет электронное письмо пользователю, когда происходит обновление статуса заказа. + +# Args: +# user_id (int): id пользователя, которому будет отправлено письмо +# **kwargs: дополнительные параметры +# """ +# # send an e-mail to the user +# user = User.objects.get(id=user_id) + +# msg = EmailMultiAlternatives( +# # title: +# f"Обновление статуса заказа", +# # message: +# 'Заказ сформирован', +# # from: +# settings.EMAIL_HOST_USER, +# # to: +# [user.email] +# ) +# msg.send() + + +######################### NEW NEW NEW ######################## @receiver(new_order) -def new_order_signal(user_id, **kwargs): +def new_order_signal(user_id: int, order: Order, **kwargs) -> None: """ - отправяем письмо при изменении статуса заказа + Отправляет электронное письмо пользователю при изменении статуса заказа. + Динамически цепляет статус заказа из запроса клиента и интегрирует его в тело письма + + Args: + user_id (int): ID пользователя + order (Order): Объект заказа """ - # send an e-mail to the user - user = User.objects.get(id=user_id) + try: + user = User.objects.get(id=user_id) + + # Получаем человекочитаемое название статуса + state_display = order.get_state_display() + + msg = EmailMultiAlternatives( + # Заголовок + f"Обновление статуса заказа №{order.id}", + # Сообщение с текущим статусом + f"Статус заказа изменен на: {state_display}", + # Отправитель + settings.EMAIL_HOST_USER, + # Получатель + [user.email] + ) + msg.send() + except User.DoesNotExist: + print(f"User with id {user_id} not found") + except Exception as e: + print(f"Error sending email: {str(e)}") - msg = EmailMultiAlternatives( - # title: - f"Обновление статуса заказа", - # message: - 'Заказ сформирован', - # from: - settings.EMAIL_HOST_USER, - # to: - [user.email] - ) - msg.send() +######################### NEW NEW NEW ######################## \ No newline at end of file diff --git a/reference/netology_pd_diplom/backend/tasks.py b/reference/netology_pd_diplom/backend/tasks.py new file mode 100644 index 000000000..99964667d --- /dev/null +++ b/reference/netology_pd_diplom/backend/tasks.py @@ -0,0 +1,71 @@ +from celery import shared_task +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from backend.models import ConfirmEmailToken, User, Order + + +@shared_task +def send_password_reset_email(reset_password_token_key: str, user_email: str): + """ + Асинхронная задача для отправки email с токеном сброса пароля. + + Args: + reset_password_token_key (str): Токен для сброса пароля. + user_email (str): Email пользователя. + """ + msg = EmailMultiAlternatives( + # Заголовок + f"Восстановление пароля", + # Тело сообщения + reset_password_token_key, + # Отправитель + settings.EMAIL_HOST_USER, + # Получатель + [user_email], + ) + msg.send() + + +@shared_task +def send_new_user_confirmation_email(token: str, email: str): + """ + Асинхронная задача для отправки email с подтверждением регистрации. + + Args: + token (str): Токен для подтверждения email. + email (str): Email пользователя. + """ + msg = EmailMultiAlternatives( + # Заголовок + "Подтверждение регистрации ", + # Тело сообщения + f"Ваш токен для подтверждения email: {token}", + # Отправитель + settings.EMAIL_HOST_USER, + # Получатель + [email], + ) + msg.send() + + +@shared_task +def send_order_status_update_email(user_email: str, order_id: int, state_display: str): + """ + Асинхронная задача для отправки уведомления о смене статуса заказа. + + Args: + user_email (str): Email пользователя. + order_id (int): ID заказа. + state_display (str): Человеко-читаемый статус заказа. + """ + msg = EmailMultiAlternatives( + # Заголовок + f"Обновление статуса заказа №{order_id}", + # Сообщение с текущим статусом + f"Статус вашего заказа изменен на: {state_display}", + # Отправитель + settings.EMAIL_HOST_USER, + # Получатель + [user_email], + ) + msg.send() diff --git a/reference/netology_pd_diplom/backend/tests.py b/reference/netology_pd_diplom/backend/tests.py deleted file mode 100644 index 7ce503c2d..000000000 --- a/reference/netology_pd_diplom/backend/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/reference/netology_pd_diplom/backend/urls.py b/reference/netology_pd_diplom/backend/urls.py index 51ab7e918..c94333148 100644 --- a/reference/netology_pd_diplom/backend/urls.py +++ b/reference/netology_pd_diplom/backend/urls.py @@ -15,8 +15,10 @@ path('user/details', AccountDetails.as_view(), name='user-details'), path('user/contact', ContactView.as_view(), name='user-contact'), path('user/login', LoginAccount.as_view(), name='user-login'), - path('user/password_reset', reset_password_request_token, name='password-reset'), - path('user/password_reset/confirm', reset_password_confirm, name='password-reset-confirm'), + path('user/password_reset', reset_password_request_token, name='password-reset'), # Этот путь определяет URL, по которому пользователи могут запрашивать сброс пароля, + # на указанный пользователем маил высылается письмо с токеном сброса пароля + path('user/password_reset/confirm', reset_password_confirm, name='password-reset-confirm'), # Этот путь определяет URL, по которому пользователи могут подтверждать сброс пароля. + # путем отправки токена сброса пароля path('categories', CategoryView.as_view(), name='categories'), path('shops', ShopView.as_view(), name='shops'), path('products', ProductInfoView.as_view(), name='shops'), diff --git a/reference/netology_pd_diplom/backend/views.py b/reference/netology_pd_diplom/backend/views.py index c9618c83b..7dd774751 100644 --- a/reference/netology_pd_diplom/backend/views.py +++ b/reference/netology_pd_diplom/backend/views.py @@ -1,4 +1,5 @@ from distutils.util import strtobool +from typing import Any from rest_framework.request import Request from django.contrib.auth import authenticate from django.contrib.auth.password_validation import validate_password @@ -12,8 +13,9 @@ from rest_framework.generics import ListAPIView from rest_framework.response import Response from rest_framework.views import APIView -from ujson import loads as load_json +from ujson import loads as load_json # более быстрая альтернатива стандартной библиотеки json from yaml import load as load_yaml, Loader +from rest_framework import status # статусы ошибок from backend.models import Shop, Category, Product, ProductInfo, Parameter, ProductParameter, Order, OrderItem, \ Contact, ConfirmEmailToken @@ -22,50 +24,98 @@ from backend.signals import new_user_registered, new_order +# class RegisterAccount(APIView): +# """ +# Для регистрации покупателей +# """ + +# # Регистрация методом POST +# def post(self, request: Request, *args: Any, **kwargs: Any) -> JsonResponse: # NEW Добавлена аннотация типов +# """ +# Process a POST request and create a new user. + +# Args: +# - request (Request): The Django request object. + +# Returns: +# - JsonResponse: The response indicating the status of the operation and any errors. +# """ +# # проверяем, находятся ли все ключи в request.data +# if {'first_name', 'last_name', 'email', 'password', 'company', 'position'}.issubset(request.data): + +# # проверяем пароль на сложность +# # sad = 'asd' +# try: +# validate_password(request.data['password']) # Проверка сложности пароля +# except ValidationError as password_error: # Проверка ошибок пароля NEW +# error_array = [] +# for item in password_error: +# error_array.append(str(item)) # добавление строки (NEW) ошибок пароля в массив error_array +# return JsonResponse({'Status': False, 'Errors': {'password': error_array}}, status=400) # Отправка клиенту информации об ошибке ввода пароля +# else: +# # проверяем данные пользователя на валидность +# user_serializer = UserSerializer(data=request.data) +# if user_serializer.is_valid(): +# user = user_serializer.save() # сохраняем данные пользователя в БД (если e-mail уникальный, +# # что проверяется в models.py в классе UserManager в момент записи в БД +# # Так как пароли должны храниться в безопасном виде. +# # Метод set_password() автоматически хеширует пароль из запроса +# # с использованием алгоритма, установленного в настройках Django (по умолчанию это PBKDF2). +# user.set_password(request.data['password']) +# user.save() # сохраняем пользователя и хешированный пароль +# return JsonResponse({'Status': True}) +# else: +# return JsonResponse({'Status': False, 'Errors': user_serializer.errors}, status=400) + +# return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}, status=400) + + +### NEW Async Celery ### NEW Async Celery ### NEW Async Celery ### NEW Async Celery ### + +from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_201_CREATED +from backend.tasks import send_new_user_confirmation_email # Импортируем задачу для отправки письма + class RegisterAccount(APIView): """ - Для регистрации покупателей + Для регистрации пользователей """ - - # Регистрация методом POST - - def post(self, request, *args, **kwargs): + + def post(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """ + Обрабатываем POST-запросы и создаем новых пользователей. + + :param request: Объект запроса от клиента. + :return: JSON-ответ с результатом операции и возможными ошибками. """ - Process a POST request and create a new user. + required_fields = ['first_name', 'last_name', 'email', 'password', 'company', 'position'] + missing_fields = [field for field in required_fields if field not in request.data] + + if missing_fields: + return Response({"Status": False, "Errors": {"missing_fields": missing_fields}}, + status=HTTP_400_BAD_REQUEST) + + try: + validate_password(request.data['password']) + except ValidationError as password_errors: + return Response({"Status": False, "Errors": {"password": list(password_errors)}}, + status=HTTP_400_BAD_REQUEST) + + serializer = UserSerializer(data=request.data) + if serializer.is_valid(): + user = serializer.save() + user.set_password(request.data['password']) + user.save() + + # Создаем токен подтверждения почтового адреса и отправляем письмо + confirm_token = ConfirmEmailToken.objects.create(user=user).key + send_new_user_confirmation_email.delay(confirm_token, user.email) + + return Response({"Status": True}, status=HTTP_201_CREATED) + else: + return Response({"Status": False, "Errors": serializer.errors}, status=HTTP_400_BAD_REQUEST) - Args: - request (Request): The Django request object. +### NEW Async Celery ### NEW Async Celery ### NEW Async Celery ### NEW Async Celery ### - Returns: - JsonResponse: The response indicating the status of the operation and any errors. - """ - # проверяем обязательные аргументы - if {'first_name', 'last_name', 'email', 'password', 'company', 'position'}.issubset(request.data): - - # проверяем пароль на сложность - sad = 'asd' - try: - validate_password(request.data['password']) - except Exception as password_error: - error_array = [] - # noinspection PyTypeChecker - for item in password_error: - error_array.append(item) - return JsonResponse({'Status': False, 'Errors': {'password': error_array}}) - else: - # проверяем данные для уникальности имени пользователя - - user_serializer = UserSerializer(data=request.data) - if user_serializer.is_valid(): - # сохраняем пользователя - user = user_serializer.save() - user.set_password(request.data['password']) - user.save() - return JsonResponse({'Status': True}) - else: - return JsonResponse({'Status': False, 'Errors': user_serializer.errors}) - - return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}) class ConfirmAccount(APIView): @@ -76,33 +126,33 @@ class ConfirmAccount(APIView): # Регистрация методом POST def post(self, request, *args, **kwargs): """ - Подтверждает почтовый адрес пользователя. + Подтверждает почтовый адрес пользователя. - Args: - - request (Request): The Django request object. - - Returns: - - JsonResponse: The response indicating the status of the operation and any errors. - """ - # проверяем обязательные аргументы - if {'email', 'token'}.issubset(request.data): + Args: + - request (Request): The Django request object. + Returns: + - JsonResponse: The response indicating the status of the operation and any errors. + """ + # проверяем, находятся ли все ключи в request.data + if {'email', 'token'}.issubset(request.data): + # Устанавливаем соответствие между парой email-token переданной POST парой email-token, хранящейся в БД token = ConfirmEmailToken.objects.filter(user__email=request.data['email'], key=request.data['token']).first() if token: - token.user.is_active = True - token.user.save() - token.delete() + token.user.is_active = True # если токен есть, активируем нового пользователя + token.user.save() # и сохраняем изменеия в БД + token.delete() # токен удаляем в целях повышения защиты return JsonResponse({'Status': True}) else: - return JsonResponse({'Status': False, 'Errors': 'Неправильно указан токен или email'}) + return JsonResponse({'Status': False, 'Errors': 'Неправильно указан токен или email'}, status=400) - return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}) + return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}, status=400) class AccountDetails(APIView): """ - A class for managing user account details. + Класс для управления данными учетной записи пользователя. Methods: - get: Retrieve the details of the authenticated user. @@ -115,53 +165,50 @@ class AccountDetails(APIView): # получить данные def get(self, request: Request, *args, **kwargs): """ - Retrieve the details of the authenticated user. + Retrieve the details of the authenticated user. - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - Response: The response containing the details of the authenticated user. + Returns: + - Response: The response containing the details of the authenticated user. """ - if not request.user.is_authenticated: + if not request.user.is_authenticated: # если пользователь не аутентифицирован return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) - serializer = UserSerializer(request.user) + serializer = UserSerializer(request.user) # сериализуем пользователя return Response(serializer.data) # Редактирование методом POST def post(self, request, *args, **kwargs): """ - Update the account details of the authenticated user. + Update the account details of the authenticated user. - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - JsonResponse: The response indicating the status of the operation and any errors. - """ - if not request.user.is_authenticated: + Returns: + - JsonResponse: The response indicating the status of the operation and any errors. + """ + if not request.user.is_authenticated: # если пользователь не аутентифицирован return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) # проверяем обязательные аргументы - if 'password' in request.data: - errors = {} + # errors = {} # проверяем пароль на сложность try: validate_password(request.data['password']) except Exception as password_error: - error_array = [] - # noinspection PyTypeChecker - for item in password_error: - error_array.append(item) + error_array = [] # создаем список для хранения ошибок + for item in password_error: # добавление строки ошибок пароля в массив error_array + error_array.append(item) # добавление строки ошибок пароля в массив error_array return JsonResponse({'Status': False, 'Errors': {'password': error_array}}) else: - request.user.set_password(request.data['password']) - - # проверяем остальные данные - user_serializer = UserSerializer(request.user, data=request.data, partial=True) - if user_serializer.is_valid(): - user_serializer.save() + request.user.set_password(request.data['password']) # записываем новый пароль в БД + user_serializer = UserSerializer(request.user, data=request.data, partial=True) # сериализуем пользователя (data - обновляемые данные + # partial=True - не все поля необходимо передавать, и если некоторые поля отсутствуют, это не вызовет ошибку валидации) + if user_serializer.is_valid(): # если данные валидны + user_serializer.save() # сохраняем изменения в БД return JsonResponse({'Status': True}) else: return JsonResponse({'Status': False, 'Errors': user_serializer.errors}) @@ -175,80 +222,97 @@ class LoginAccount(APIView): # Авторизация методом POST def post(self, request, *args, **kwargs): """ - Authenticate a user. + Создание токена аутентификации для активированных пользователей. - Args: - request (Request): The Django request object. + Args: + request (Request): The Django request object. - Returns: - JsonResponse: The response indicating the status of the operation and any errors. - """ - if {'email', 'password'}.issubset(request.data): - user = authenticate(request, username=request.data['email'], password=request.data['password']) + Returns: + JsonResponse: The response indicating the status of the operation and any errors. + """ + if {'email', 'password'}.issubset(request.data): # проверяем, находятся ли все ключи в request.data + user = authenticate(request, username=request.data['email'], password=request.data['password']) # сверяем учетные данные пользователя из запроса с теми, что хранятся в БД + # и возвращает объект пользователя, если аутентификация по имени и паролю прошла успешно - if user is not None: - if user.is_active: - token, _ = Token.objects.get_or_create(user=user) + if user is not None: # если пользователь найден + if user.is_active: # если пользователь активирован (заодно в админке выбрать этот пользователь buyer или shop) + token, _ = Token.objects.get_or_create(user=user) # создаем токен (этот токен будет использоваться для аутентификации пользователя в будущих запросах) - return JsonResponse({'Status': True, 'Token': token.key}) + return JsonResponse({'Status': True, 'Token': token.key}, status=200) # возвращаем токен клиенту - return JsonResponse({'Status': False, 'Errors': 'Не удалось авторизовать'}) + return JsonResponse({'Status': False, 'Errors': 'Не удалось авторизовать'}, status=400) # если пользователь не найден - return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}) + return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}, status=400) # если не указаны все необходимые аргументы -class CategoryView(ListAPIView): +class CategoryView(ListAPIView): """ - Класс для просмотра категорий + Класс для просмотра списка категорий товаров """ - queryset = Category.objects.all() - serializer_class = CategorySerializer + queryset = Category.objects.all() # выбираем все категории товаров + serializer_class = CategorySerializer # сериализуем их class ShopView(ListAPIView): """ Класс для просмотра списка магазинов """ - queryset = Shop.objects.filter(state=True) + queryset = Shop.objects.filter(state=True) # фильтруем магазины по статусу готовности к получению заказов, т.е. магазин открыт(закрыт) serializer_class = ShopSerializer class ProductInfoView(APIView): """ - A class for searching products. + Класс для поиска продуктов. - Methods: - - get: Retrieve the product information based on the specified filters. + Methods: + - get: Retrieve the product information based on the specified filters. - Attributes: - - None - """ + Attributes: + - None + """ def get(self, request: Request, *args, **kwargs): """ - Retrieve the product information based on the specified filters. + Клас для поиска продуктов по фильтрам. - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - Response: The response containing the product information. - """ - query = Q(shop__state=True) - shop_id = request.query_params.get('shop_id') - category_id = request.query_params.get('category_id') + Returns: + - Response: The response containing the product information. + """ + # Объект Q используется для создания сложных запросов с помощью логических операторов + query = Q(shop__state=True) # фильтруем магазины по статусу готовности к получению заказов + shop_id = request.query_params.get('shop_id') # получаем id магазина из параметров запроса + category_id = request.query_params.get('category_id') # получаем id категории из параметров запроса - if shop_id: - query = query & Q(shop_id=shop_id) + if shop_id: # если id магазина указан + query = query & Q(shop_id=shop_id) # фильтруем продукты по id магазина - if category_id: - query = query & Q(product__category_id=category_id) + if category_id: # если id категории указан + query = query & Q(product__category_id=category_id) # фильтруем продукты по id категории - # фильтруем и отбрасываем дуликаты + # фильтруем объекты ProductInfo по сформированному запросу query и отбрасываем дубликаты queryset = ProductInfo.objects.filter( - query).select_related( - 'shop', 'product__category').prefetch_related( - 'product_parameters__parameter').distinct() + query).select_related( # информация о связанных магазинах (shop) и категориях продуктов (product__category) + # будет загружена джоином с основным объектом ProductInfo (т.е. в одном запросе к БД что эффективно) + 'shop', 'product__category').prefetch_related( # В отличие от select_related, метод выполняет отдельные запросы для получения связанных объектов, + # а затем соединяет их в Python с объектом ProductInfo. + # Здесь он извлекает параметры каждого продукта и их значения и соединяет с информацией о продукте. + 'product_parameters__parameter').distinct() # удаления дублирующихся записей из результата выборки + + # ПОМЕТКА! prefetch_related - выполняет отдельные запросы для основной модели и для связанных объектов. Затем результаты объединяются в Python. + # ПОМЕТКА! select_related - использует SQL JOIN для выполнения запроса и получения связанных объектов одновременно с основным объектом. + + # ЗАМЕТКА + # Жадная загрузка (чтобы уменьшить количество обращений к базе данных) + # всех связей из поля 'shop' (это id магазинов модели Shop) и из поля 'product' - + # это id продуктов соответствующей категории (поле 'category' модели Product, + # содержит id категорий, связанной модели Category). + + # поле 'product_parameters' содержит id, связывающие значения параметров продуктов, + # с ProductInfo, а поле 'parameter' содержит id названий этих параметров из модели Parametr. serializer = ProductInfoSerializer(queryset, many=True) @@ -272,58 +336,72 @@ class BasketView(APIView): # получить корзину def get(self, request, *args, **kwargs): """ - Retrieve the items in the user's basket. + Retrieve the items in the user's basket. - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - Response: The response containing the items in the user's basket. - """ - if not request.user.is_authenticated: + Returns: + - Response: The response containing the items in the user's basket. + """ + if not request.user.is_authenticated: # проверяется, аутентифицирован ли пользователь return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) - basket = Order.objects.filter( - user_id=request.user.id, state='basket').prefetch_related( - 'ordered_items__product_info__product__category', - 'ordered_items__product_info__product_parameters__parameter').annotate( + + # Извлекаем заказы из корзины и рассчитываем итоговую стоимость заказов в корзине + basket = Order.objects.filter( # Из модели Order извлекаются заказы пользователя с заданным user_id, у которых состояние равно "basket" (корзина) + user_id=request.user.id, state='basket').prefetch_related( # заранее извлекаем связанные данные, + # что минимизирует количество запросов к базе данных и улучшает производительность + 'ordered_items__product_info__product__category', # у id покупателя заказ, количество заказанных товаров их характеристики, названия и категории. + 'ordered_items__product_info__product_parameters__parameter').annotate( # параметры товаров и их значения. total_sum=Sum(F('ordered_items__quantity') * F('ordered_items__product_info__price'))).distinct() - - serializer = OrderSerializer(basket, many=True) + # далее рассчитываем итоговую стоимость заказа total_sum - умножаем количество заказанных товаров на их цену и суммируем общую цену + serializer = OrderSerializer(basket, many=True) # сериализуем все заказы в корзине в формат JSON return Response(serializer.data) # редактировать корзину def post(self, request, *args, **kwargs): """ - Add an items to the user's basket. + Добавить товары в корзину пользователя (покупателя). - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - JsonResponse: The response indicating the status of the operation and any errors. - """ - if not request.user.is_authenticated: + Returns: + - JsonResponse: The response indicating the status of the operation and any errors. + """ + if not request.user.is_authenticated: # проверяется, аутентифицирован ли пользователь return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) - items_sting = request.data.get('items') - if items_sting: + items_sting = request.data.get('items') # из байт-строки в формате JSON получаем данные о товарах из запроса + if items_sting: # если данные о товарах указаны в запросе try: - items_dict = load_json(items_sting) + # + items_dict = load_json(items_sting) # преобразуем данные о товарах из строки JSON в словарь python except ValueError: return JsonResponse({'Status': False, 'Errors': 'Неверный формат запроса'}) - else: - basket, _ = Order.objects.get_or_create(user_id=request.user.id, state='basket') - objects_created = 0 - for order_item in items_dict: - order_item.update({'order': basket.id}) - serializer = OrderItemSerializer(data=order_item) - if serializer.is_valid(): + else: # если JSON-строка при преобразовании в словарь python была валидна + # добавляем товары в корзину: в моделе Order создаем новый заказ для пользователя с id + # и присваеваем ему статус "корзина" + basket, _ = Order.objects.get_or_create(user_id=request.user.id, state='basket') # создаем корзину, как экземпляр модели Order + + # ЗАМЕТКА + # Если заказ с указанными параметрами существует, basket будет ссылаться на этот существующий объект. + # Если заказа нет, будет создан новый Order, и basket будет указывать на этот новый объект. + # _: Параметр, который принимает значение True, если объект был создан, и False, если объект был найден. + # Этот параметр не используется в дальнейшем коде, так как он обозначен знаком подчеркивания (зачастую + # это означает, что значение не нужно). + + objects_created = 0 # счетчик добавленных в корзину товаров + for order_item in items_dict: # для каждого товара из словаря items_dict ... + order_item.update({'order': basket.id}) # ... добавляем id заказа (в словарь order_item добавляем/обновляем ключ order со значением basket.id) + serializer = OrderItemSerializer(data=order_item) # создаем экземпляр сериализатора OrderItemSerializer с данными из order_item + if serializer.is_valid(): # если данные валидны try: - serializer.save() + serializer.save() # сохраняем данные в модели OrderItem except IntegrityError as error: return JsonResponse({'Status': False, 'Errors': str(error)}) else: - objects_created += 1 + objects_created += 1 # увеличиваем счетчик else: @@ -335,60 +413,76 @@ def post(self, request, *args, **kwargs): # удалить товары из корзины def delete(self, request, *args, **kwargs): """ - Remove items from the user's basket. + Удалить товары из корзины пользователя (покупателя). - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - JsonResponse: The response indicating the status of the operation and any errors. - """ - if not request.user.is_authenticated: + Returns: + - JsonResponse: The response indicating the status of the operation and any errors. + """ + if not request.user.is_authenticated: # если пользователь не аутентифицирован выкидываем 403 return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) - items_sting = request.data.get('items') - if items_sting: - items_list = items_sting.split(',') - basket, _ = Order.objects.get_or_create(user_id=request.user.id, state='basket') - query = Q() - objects_deleted = False - for order_item_id in items_list: - if order_item_id.isdigit(): - query = query | Q(order_id=basket.id, id=order_item_id) - objects_deleted = True - - if objects_deleted: - deleted_count = OrderItem.objects.filter(query).delete()[0] + items_sting = request.data.get('items') # из байт-строки в формате JSON получаем данные о товарах из запроса + if items_sting: # если данные о товарах указаны в запросе + items_list = items_sting.split(',') # сплитуем строку по разделителю "," получая на выходе список id товаров для удаления + # Получаем корзину пользователя + basket, _ = Order.objects.get_or_create(user_id=request.user.id, state='basket') + query = Q() # создаем пустое условие фильтрации. + objects_deleted = False # Флаг для отслеживания, были ли удалены товары. + for order_item_id in items_list: # для каждого id товара в списке + if order_item_id.isdigit(): # Проверяем, является ли идентификатор числом. + # Добавляем условие для удаления в запрос Q ... + query = query | Q(order_id=basket.id, id=order_item_id) # которое используется для фильтрации модели + # OrderItem по идентификатору заказа и идентификатору товара ниже. + # СПРАВКА + # Если в переменной items_list находятся значения [3, 5], и id корзины basket.id = 1, + # запрос query будет последовательно строиться так: + # После первого элемента: Q(order_id=1, id=3) + # После второго элемента: Q(order_id=1, id=3) | Q(order_id=1, id=5) + # Таким образом, в результате вы получите один объект Q, + # который соответствует всем указанным идентификаторам в корзине. + + objects_deleted = True # Устанавливаем флаг, что у нас есть объекты для удаления. + + if objects_deleted: # Если есть объекты для удаления, выполняем удаление. + deleted_count = OrderItem.objects.filter(query).delete()[0] # Удаляем объекты, соответствующие условию в query из OrderItem. + # СПРАВКА + # Метод delete() возвращает кортеж (количество удаленных записей, словарь, где ключами являются модели, а значениями — количество удаленных записей для каждой модели) + # мы используем [0], чтобы извлечь только первый элемент (количество удаленных объектов) из возвращаемого кортежа + # для формирования ответа пользователю + return JsonResponse({'Status': True, 'Удалено объектов': deleted_count}) return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}) # добавить позиции в корзину def put(self, request, *args, **kwargs): """ - Update the items in the user's basket. + Обновление товаров в корзине пользователя (покупателя). - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - JsonResponse: The response indicating the status of the operation and any errors. - """ - if not request.user.is_authenticated: + Returns: + - JsonResponse: The response indicating the status of the operation and any errors. + """ + if not request.user.is_authenticated: # если пользователь не аутентифицирован выкидываем 403 return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) - items_sting = request.data.get('items') - if items_sting: + items_sting = request.data.get('items') # из байт-строки в формате JSON получаем данные о товарах из запроса + if items_sting: # если данные о товарах указаны в запросе try: - items_dict = load_json(items_sting) + items_dict = load_json(items_sting) # преобразуем в словарь except ValueError: return JsonResponse({'Status': False, 'Errors': 'Неверный формат запроса'}) else: - basket, _ = Order.objects.get_or_create(user_id=request.user.id, state='basket') - objects_updated = 0 - for order_item in items_dict: - if type(order_item['id']) == int and type(order_item['quantity']) == int: + basket, _ = Order.objects.get_or_create(user_id=request.user.id, state='basket') # Получаем заказ пользователя + objects_updated = 0 # счетчик обновленных объектов + for order_item in items_dict: # для каждого товара в запросе + if type(order_item['id']) == int and type(order_item['quantity']) == int: # проверяем, что идентификатор и количество являются целыми числами objects_updated += OrderItem.objects.filter(order_id=basket.id, id=order_item['id']).update( - quantity=order_item['quantity']) + quantity=order_item['quantity']) # обновляем количество товара return JsonResponse({'Status': True, 'Обновлено объектов': objects_updated}) return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}) @@ -407,40 +501,50 @@ class PartnerUpdate(APIView): def post(self, request, *args, **kwargs): """ - Update the partner price list information. - - Args: - - request (Request): The Django request object. - - Returns: - - JsonResponse: The response indicating the status of the operation and any errors. - """ - if not request.user.is_authenticated: - return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) + Обновите информацию о прайс-листе партнёров. - if request.user.type != 'shop': - return JsonResponse({'Status': False, 'Error': 'Только для магазинов'}, status=403) + Args: + - request (Request): The Django request object. - url = request.data.get('url') - if url: - validate_url = URLValidator() + Returns: + - JsonResponse: The response indicating the status of the operation and any errors. + """ + if not request.user.is_authenticated: # если пользователь не аутентифицирован + return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) # возвращаем ошибку 403 и сообщение о необходимости аутентификации + + if request.user.type != 'shop': # если пользователь не магазин + return JsonResponse({'Status': False, 'Error': 'Только для магазинов'}, status=403) # возвращаем ошибку 403 и сообщение о необходимости быть магазином (назначается в админ панеле) + # Загружаем товары из .yml который размещен по адресу с url + url = request.data.get('url') # получаем url из запроса пользователя + if url: # если url указан + validate_url = URLValidator() # создаем валидатор для url try: - validate_url(url) + validate_url(url) # проверяем валидность url except ValidationError as e: - return JsonResponse({'Status': False, 'Error': str(e)}) + return JsonResponse({'Status': False, 'Error': str(e)}, status=400) # возвращаем ошибку 400 и сообщение об ошибке валидации url else: - stream = get(url).content - - data = load_yaml(stream, Loader=Loader) - - shop, _ = Shop.objects.get_or_create(name=data['shop'], user_id=request.user.id) - for category in data['categories']: - category_object, _ = Category.objects.get_or_create(id=category['id'], name=category['name']) - category_object.shops.add(shop.id) - category_object.save() - ProductInfo.objects.filter(shop_id=shop.id).delete() - for item in data['goods']: - product, _ = Product.objects.get_or_create(name=item['name'], category_id=item['category']) + stream = get(url).content # получаем контент из yaml-файла размещенного по некоторому url + + data = load_yaml(stream, Loader=Loader) # декодируем контент и преобразуем в словарь + + shop, _ = Shop.objects.get_or_create(name=data['shop'], user_id=request.user.id) # получаем или создаем магазин с названием из data['shop'] и авторизированным пользователем request.user.id + + # ЗАМЕТКА: + # request: + # Это объект HttpRequest, который передается в представление в Django. Он содержит всю информацию о текущем запросе, включая данные о пользователе, + # пришедшие из HTTP-заголовков, параметры URL, данные и тд + # request.user: + # Это атрибут объекта request, который возвращает объект пользователя (класса User) для текущего запроса (если пользователь аутенифицирован is_authenticated = True). + # request.user.id: + # Это доступ к атрибуту id объекта пользователя. Он возвращает уникальный идентификатор пользователя в базе данных. + + for category in data['categories']: # проходим по категориям в data['categories'] + category_object, _ = Category.objects.get_or_create(id=category['id'], name=category['name']) # получаем или создаем категорию с id и name из category + category_object.shops.add(shop.id) # добавляем магазин в категорию + category_object.save() # сохраняем изменения в категории + ProductInfo.objects.filter(shop_id=shop.id).delete() # удаляем из прайса все загруженные в магазин shop.id товары + for item in data['goods']: # проходим по товарам в data['goods'] + product, _ = Product.objects.get_or_create(name=item['name'], category_id=item['category']) # получаем или создаем продукт с названием item['name'] и категорией item['category'] product_info = ProductInfo.objects.create(product_id=product.id, external_id=item['id'], @@ -448,69 +552,70 @@ def post(self, request, *args, **kwargs): price=item['price'], price_rrc=item['price_rrc'], quantity=item['quantity'], - shop_id=shop.id) - for name, value in item['parameters'].items(): - parameter_object, _ = Parameter.objects.get_or_create(name=name) + shop_id=shop.id) # создаем информацию о продукте + for name, value in item['parameters'].items(): # проходим по ключам и значениям в item['parameters'] + parameter_object, _ = Parameter.objects.get_or_create(name=name) # получаем или создаем параметр товара с названием name ProductParameter.objects.create(product_info_id=product_info.id, parameter_id=parameter_object.id, - value=value) + value=value) # создаем значение параметра товара - return JsonResponse({'Status': True}) + return JsonResponse({'Status': True}, status=200) # возвращаем сообщение об успешном обновлении прайса - return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}) + return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}, status=400) # возвращаем ошибку 400 и сообщение о необходимости указать все необходимые аргументы class PartnerState(APIView): """ - A class for managing partner state. + Клас открытия/закрытия магазина. - Methods: - - get: Retrieve the state of the partner. + Methods: + - get: Retrieve the state of the partner. - Attributes: - - None - """ + Attributes: + - None + """ # получить текущий статус def get(self, request, *args, **kwargs): """ - Retrieve the state of the partner. + Получить статус магазина. - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - Response: The response containing the state of the partner. - """ + Returns: + - Response: The response containing the state of the partner. + """ if not request.user.is_authenticated: return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) if request.user.type != 'shop': return JsonResponse({'Status': False, 'Error': 'Только для магазинов'}, status=403) - shop = request.user.shop - serializer = ShopSerializer(shop) - return Response(serializer.data) + shop = request.user.shop # получаем магазин + serializer = ShopSerializer(shop) # сериализуем данные + return Response(serializer.data) # возвращаем статус магазина # изменить текущий статус def post(self, request, *args, **kwargs): """ - Update the state of a partner. + Обновить статус магазина. - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - JsonResponse: The response indicating the status of the operation and any errors. - """ - if not request.user.is_authenticated: + Returns: + - JsonResponse: The response indicating the status of the operation and any errors. + """ + if not request.user.is_authenticated: # если пользователь не аутентифицирован return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) - if request.user.type != 'shop': + if request.user.type != 'shop': # Если пользователь не магазин return JsonResponse({'Status': False, 'Error': 'Только для магазинов'}, status=403) - state = request.data.get('state') + state = request.data.get('state') # получаем статус if state: try: - Shop.objects.filter(user_id=request.user.id).update(state=strtobool(state)) + # Обновляем статус магазина отфильтрованного по ID пользователя + Shop.objects.filter(user_id=request.user.id).update(state=strtobool(state)) # преобразует строку в логическое значение True или False return JsonResponse({'Status': True}) except ValueError as error: return JsonResponse({'Status': False, 'Errors': str(error)}) @@ -520,7 +625,7 @@ def post(self, request, *args, **kwargs): class PartnerOrders(APIView): """ - Класс для получения заказов поставщиками + Класс для получения заказов поставщиками и изменения статуса готовности товара к выдачи Methods: - get: Retrieve the orders associated with the authenticated partner. @@ -530,33 +635,124 @@ class PartnerOrders(APIView): def get(self, request, *args, **kwargs): """ - Retrieve the orders associated with the authenticated partner. + Извлечь заказы, поступившие магазину от покупателя. - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - Response: The response containing the orders associated with the partner. - """ - if not request.user.is_authenticated: + Returns: + - Response: The response containing the orders associated with the partner. + """ + if not request.user.is_authenticated: # если пользователь не аутентифицирован return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) - if request.user.type != 'shop': + if request.user.type != 'shop': # если пользователь не магазин return JsonResponse({'Status': False, 'Error': 'Только для магазинов'}, status=403) - order = Order.objects.filter( + order = Order.objects.filter( # Фильтруем заказы по ID пользователя (владельца магазина), + # исключая заказы со статусом "в корзине" ordered_items__product_info__shop__user_id=request.user.id).exclude(state='basket').prefetch_related( - 'ordered_items__product_info__product__category', + # Загружаем информацию о заказе, включая количество заказанных товаров и их цену и добавляем контакты для доставки + 'ordered_items__product_info__product__category', 'ordered_items__product_info__product_parameters__parameter').select_related('contact').annotate( + # Вычисляем общую сумму заказа total_sum=Sum(F('ordered_items__quantity') * F('ordered_items__product_info__price'))).distinct() + + # ПОМЕТКА! prefetch_related - выполняет отдельные запросы для основной модели и для связанных объектов. Затем результаты объединяются в Python. + # ПОМЕТКА! select_related - использует SQL JOIN для выполнения запроса и получения связанных объектов одновременно с основным объектом. serializer = OrderSerializer(order, many=True) return Response(serializer.data) + ######################## NEW NEW NEW ######################## + + def post(self, request, *args, **kwargs): + """ + Изменение статуса заказа пользователем(магазином). + + Args: + request (Request): The Django request object. + + Returns: + JsonResponse: The response containing the status of the operation and any errors. + + Raises: + HTTPError: If the request is invalid or the user is not authenticated or is not a shop. + """ + if not request.user.is_authenticated: + return JsonResponse( + {'Status': False, 'Error': 'Log in required'}, + status=status.HTTP_403_FORBIDDEN + ) + + if request.user.type != 'shop': + return JsonResponse( + {'Status': False, 'Error': 'Только для магазинов'}, + status=status.HTTP_403_FORBIDDEN + ) + + if {'id', 'state'}.issubset(request.data): + order_id = request.data['id'] + new_state = request.data['state'] + + # Проверка, что ID заказа является числом + if not str(order_id).isdigit(): + return JsonResponse( + {'Status': False, 'Error': 'ID должен быть числом'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Поиск заказа, связанного с магазином пользователя + order = Order.objects.filter( + id=order_id, # по id заказа + ordered_items__product_info__shop__user_id=request.user.id # по пользователю(магазину) + ).first() # получаем первый найденный заказ соответствующий условиям (жадность) + + if not order: + return JsonResponse( + {'Status': False, 'Error': 'Заказ с указанным ID не найден'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Проверка текущего статуса + if order.state == new_state: + return JsonResponse( + {'Status': False, 'Error': 'Статус заказа уже установлен'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Проверка допустимости нового статуса + valid_states = dict(Order.STATE_CHOICES).keys() + if new_state not in valid_states: + return JsonResponse( + {'Status': False, 'Error': f'Некорректный статус заказа. Укажите значения из {dict(Order.STATE_CHOICES)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Обновление статуса заказа + order.state = new_state + order.save() + new_order.send(sender=self.__class__, user_id=request.user.id, order=order) + return JsonResponse({'Status': True, 'Message': f'Статус заказа успешно изменен на {dict(Order.STATE_CHOICES)[order.state]}'}, status=status.HTTP_200_OK) + except Exception as e: + return JsonResponse( + {'Status': False, 'Error': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + return JsonResponse( + {'Status': False, 'Error': 'Не указаны id и state'}, + status=status.HTTP_400_BAD_REQUEST + ) + + ######################### NEW NEW NEW ######################## + + class ContactView(APIView): """ - A class for managing contact information. + Класс для управления контактной информацией. Methods: - get: Retrieve the contact information of the authenticated user. @@ -571,42 +767,42 @@ class ContactView(APIView): # получить мои контакты def get(self, request, *args, **kwargs): """ - Retrieve the contact information of the authenticated user. + Retrieve the contact information of the authenticated user. - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - Response: The response containing the contact information. - """ - if not request.user.is_authenticated: + Returns: + - Response: The response containing the contact information. + """ + if not request.user.is_authenticated: # если пользователь не аутентифицирован return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) contact = Contact.objects.filter( - user_id=request.user.id) - serializer = ContactSerializer(contact, many=True) + user_id=request.user.id) # выбираем все контакты, связанные с аутентифицированным пользователем + serializer = ContactSerializer(contact, many=True) # сериализуем контакты (возвращает QuerySet с несколькими записями) return Response(serializer.data) # добавить новый контакт def post(self, request, *args, **kwargs): """ - Create a new contact for the authenticated user. + Create a new contact for the authenticated user. - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - JsonResponse: The response indicating the status of the operation and any errors. - """ - if not request.user.is_authenticated: + Returns: + - JsonResponse: The response indicating the status of the operation and any errors. + """ + if not request.user.is_authenticated: # если пользователь не аутентифицирован return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) - if {'city', 'street', 'phone'}.issubset(request.data): - request.data._mutable = True - request.data.update({'user': request.user.id}) + if {'city', 'street', 'phone'}.issubset(request.data): # если есть все необходимые аргументы + request.data._mutable = True # изменяем мутабельность чтобы ... + request.data.update({'user': request.user.id}) # добавить в request.data новый ключ user, который содержит идентификатор аутентифицированного пользователя serializer = ContactSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() + if serializer.is_valid(): # если данные валидны + serializer.save() # сохраняем контактные данные в базу данных return JsonResponse({'Status': True}) else: return JsonResponse({'Status': False, 'Errors': serializer.errors}) @@ -616,54 +812,55 @@ def post(self, request, *args, **kwargs): # удалить контакт def delete(self, request, *args, **kwargs): """ - Delete the contact of the authenticated user. + Delete the contact of the authenticated user. - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - JsonResponse: The response indicating the status of the operation and any errors. - """ - if not request.user.is_authenticated: + Returns: + - JsonResponse: The response indicating the status of the operation and any errors. + """ + if not request.user.is_authenticated: # если пользователь не аутентифицирован return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) - items_sting = request.data.get('items') - if items_sting: - items_list = items_sting.split(',') - query = Q() - objects_deleted = False - for contact_id in items_list: - if contact_id.isdigit(): - query = query | Q(user_id=request.user.id, id=contact_id) - objects_deleted = True - - if objects_deleted: - deleted_count = Contact.objects.filter(query).delete()[0] + items_sting = request.data.get('items') # получаем список id контактов + if items_sting: # если список id контактов указан + items_list = items_sting.split(',') # сплитуем строку по разделителю "," получая на выходе список id контактов для удаления + query = Q() # создаем пустое условие фильтрации + objects_deleted = False # флаг удаленных объектов + for contact_id in items_list: # перебираем список id контактов + if contact_id.isdigit(): # если id контакта является числом + # Добавляем условие для удаления в запрос Q ... + query = query | Q(user_id=request.user.id, id=contact_id) # которое используется для фильтрации модели Contact + # по идентификатору пользователя и идентификатору контакта ниже. + objects_deleted = True # устанавливаем флаг удаленных объектов + if objects_deleted: # если True + deleted_count = Contact.objects.filter(query).delete()[0] # удаляем объекты по запросу query return JsonResponse({'Status': True, 'Удалено объектов': deleted_count}) return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}) # редактировать контакт def put(self, request, *args, **kwargs): - if not request.user.is_authenticated: - """ - Update the contact information of the authenticated user. + """ + Update the contact information of the authenticated user. - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - JsonResponse: The response indicating the status of the operation and any errors. - """ + Returns: + - JsonResponse: The response indicating the status of the operation and any errors. + """ + if not request.user.is_authenticated: return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) - if 'id' in request.data: - if request.data['id'].isdigit(): - contact = Contact.objects.filter(id=request.data['id'], user_id=request.user.id).first() - print(contact) - if contact: - serializer = ContactSerializer(contact, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() + if 'id' in request.data: # если id контакта указан + if request.data['id'].isdigit(): # если id контакта является числом + contact = Contact.objects.filter(id=request.data['id'], user_id=request.user.id).first() # фильтруем контакты по заданному id + if contact: # если контакт найден + serializer = ContactSerializer(contact, data=request.data, partial=True) # обновляем контакт (data - данные для обновления, + # partial=True - не все поля необходимо передавать, и если некоторые поля отсутствуют, это не вызовет ошибку валидации) + if serializer.is_valid(): # если объект сериализаторы содержит валидные данные + serializer.save() # записываем данные в БД return JsonResponse({'Status': True}) else: return JsonResponse({'Status': False, 'Errors': serializer.errors}) @@ -673,7 +870,9 @@ def put(self, request, *args, **kwargs): class OrderView(APIView): """ - Класс для получения и размешения заказов пользователями + Класс для получения и размешения заказов пользователями (покупателями) + и отправки уведомлений пользователям о статусе заказа. + Methods: - get: Retrieve the details of a specific order. - post: Create a new order. @@ -687,52 +886,95 @@ class OrderView(APIView): # получить мои заказы def get(self, request, *args, **kwargs): """ - Retrieve the details of user orders. + Получить данные о заказах пользователя(покупателя). - Args: - - request (Request): The Django request object. + Args: + - request (Request): The Django request object. - Returns: - - Response: The response containing the details of the order. - """ - if not request.user.is_authenticated: + Returns: + - Response: The response containing the details of the order. + """ + if not request.user.is_authenticated: # return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) + # фильтруем заказы по id пользователя(покупателя) + # исключая из результата фильтрации заказы со статусом "в корзине"... order = Order.objects.filter( - user_id=request.user.id).exclude(state='basket').prefetch_related( - 'ordered_items__product_info__product__category', - 'ordered_items__product_info__product_parameters__parameter').select_related('contact').annotate( - total_sum=Sum(F('ordered_items__quantity') * F('ordered_items__product_info__price'))).distinct() + user_id=request.user.id).exclude(state='basket').prefetch_related( # добавляем связанную предварительную выборку: + 'ordered_items__product_info__product__category', # заказанные_товары__информация_о_продукте__категория_продукта, + # заказанные_товары__информация_о_продукте__параметры_продукта_параметр + 'ordered_items__product_info__product_parameters__parameter').select_related('contact').annotate( # выбираем связанные контакты и добавляем аннотацию + total_sum=Sum(F('ordered_items__quantity') * F('ordered_items__product_info__price'))).distinct() # рассчитываем общую стоимость заказа, + # перемножая количество и цену товара и удаляем дубликаты из результата выборки + # ПОМЕТКА! prefetch_related - выполняет отдельные запросы для основной модели и для связанных объектов. Затем результаты объединяются в Python. + # ПОМЕТКА! select_related - использует SQL JOIN для выполнения запроса и получения связанных объектов одновременно с основным объектом. serializer = OrderSerializer(order, many=True) return Response(serializer.data) - # разместить заказ из корзины + ######################### NEW NEW NEW ######################## + + # разместить новый заказ из корзины def post(self, request, *args, **kwargs): """ - Put an order and send a notification. - - Args: - - request (Request): The Django request object. - - Returns: - - JsonResponse: The response indicating the status of the operation and any errors. - """ - if not request.user.is_authenticated: - return JsonResponse({'Status': False, 'Error': 'Log in required'}, status=403) - - if {'id', 'contact'}.issubset(request.data): + Разместить новый заказ из корзины и отправить уведомление магазину. + Args: + - request (Request): The Django request object. + Returns: + - JsonResponse: The response indicating the status of the operation and any errors. + """ + + if not request.user.is_authenticated: # проверяем токен аутентификации в запросе + return JsonResponse( + {'Status': False, 'Error': 'Log in required'}, + status=status.HTTP_403_FORBIDDEN + ) + + if {'id', 'contact'}.issubset(request.data): # проверка наличия в запросе id заказа и контактных данных покупателя if request.data['id'].isdigit(): try: - is_updated = Order.objects.filter( - user_id=request.user.id, id=request.data['id']).update( - contact_id=request.data['contact'], - state='new') + # Получаем объект заказа + order = Order.objects.get( + user_id=request.user.id, # по id покупателя + id=request.data['id'] # и по id заказа переданному в запросе + ) + + # ЗАМЕТКА! + # если вы уверены в единственности объекта (например, для `id` или уникальных ключей, как здесь), + # использовать `.get()` не только быстрее, но и легче интерпретировать + # https://docs.google.com/document/d/1_zO0NaMzGqY6ohgqYxi875pghBdFho9RvKxB5fhbjSc/edit?usp=sharing + + # Обновляем поля объекта заказа + order.contact_id = request.data['contact'] + order.state = 'new' # устанавливаем статус заказа с "в корзине" на "новый" + order.save() # сохраняем изменения в БД + + # Отправляем сигнал с order (уведомление магазину от заказчика) + new_order.send( + sender=self.__class__, # отправляем от текущего класса + user_id=request.user.id, # по id покупателя + order=order # объект заказа + ) + return JsonResponse({'Status': True}, status=status.HTTP_200_OK) + + except Order.DoesNotExist: + return JsonResponse( + {'Status': False, 'Errors': 'Заказ не найден'}, + status=status.HTTP_404_NOT_FOUND + ) except IntegrityError as error: print(error) - return JsonResponse({'Status': False, 'Errors': 'Неправильно указаны аргументы'}) - else: - if is_updated: - new_order.send(sender=self.__class__, user_id=request.user.id) - return JsonResponse({'Status': True}) - - return JsonResponse({'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}) + return JsonResponse( + {'Status': False, 'Errors': 'Неправильно указаны аргументы'}, + status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + return JsonResponse( + {'Status': False, 'Errors': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + return JsonResponse( + {'Status': False, 'Errors': 'Не указаны все необходимые аргументы'}, + status=status.HTTP_400_BAD_REQUEST + ) + ######################### NEW NEW NEW ######################## \ No newline at end of file diff --git a/reference/netology_pd_diplom/docker-compose.yml b/reference/netology_pd_diplom/docker-compose.yml new file mode 100644 index 000000000..462040662 --- /dev/null +++ b/reference/netology_pd_diplom/docker-compose.yml @@ -0,0 +1,87 @@ +services: + + app: + build: + context: . + environment: + DEBUG: ${DEBUG} + SECRET_KEY: ${SECRET_KEY} + ALLOWED_HOSTS: ${ALLOWED_HOSTS} + DB_ENGINE: ${DB_ENGINE} + DB_NAME: ${DB_NAME} + EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD} + entrypoint: python manage.py runserver 0.0.0.0:8000 + volumes: + - ./:/app + ports: + - "8000:8000" # Добавляем проброс порта + depends_on: + celery: + condition: service_started + broker: + condition: service_healthy + # db: + # condition: service_healthy + networks: + - backend-network + + broker: + image: redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 3 + networks: + - backend-network + + celery: + build: + context: . + entrypoint: python -m celery -A netology_pd_diplom worker + volumes: + - ./:/app + depends_on: + # db: + # condition: service_healthy + broker: + condition: service_healthy + networks: + - backend-network + +networks: + backend-network: + driver: bridge + + + # broker: + # image: rabbitmq:4-management + # ports: + # - "5672:5672" + # - "15672:15672" + # environment: + # RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} + # RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} + # healthcheck: + # test: ["CMD", "rabbitmqctl", "status"] + # interval: 5s + # timeout: 5s + # retries: 3 + # networks: + # - backend-network + + # db: + # image: postgres:15.3 + # ports: + # - "5432:5432" + # environment: + # POSTGRES_USER: ${POSTGRES_USER} + # POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + # POSTGRES_DB: ${POSTGRES_DB} + + # healthcheck: + # test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + # networks: + # - backend-network diff --git a/reference/netology_pd_diplom/netology_pd_diplom/__init__.py b/reference/netology_pd_diplom/netology_pd_diplom/__init__.py index e69de29bb..478a0d658 100644 --- a/reference/netology_pd_diplom/netology_pd_diplom/__init__.py +++ b/reference/netology_pd_diplom/netology_pd_diplom/__init__.py @@ -0,0 +1,4 @@ +# Этот импорт гарантирует, что приложение Celery будет загружено при запуске Django +from .celery import app as celery_app + +__all__ = ['celery_app'] diff --git a/reference/netology_pd_diplom/netology_pd_diplom/asgi.py b/reference/netology_pd_diplom/netology_pd_diplom/asgi.py new file mode 100644 index 000000000..b3f700070 --- /dev/null +++ b/reference/netology_pd_diplom/netology_pd_diplom/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for django_celery_example project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'netology_pd_diplom.settings') + +application = get_asgi_application() \ No newline at end of file diff --git a/reference/netology_pd_diplom/netology_pd_diplom/celery.py b/reference/netology_pd_diplom/netology_pd_diplom/celery.py new file mode 100644 index 000000000..dc44fa033 --- /dev/null +++ b/reference/netology_pd_diplom/netology_pd_diplom/celery.py @@ -0,0 +1,27 @@ +# по материалам +# https://realpython.com/asynchronous-tasks-with-django-and-celery/ +# https://stackabuse.com/asynchronous-tasks-in-django-with-redis-and-celery/ + +import os +from celery import Celery +from celery.result import AsyncResult + +# Устанавливаем модуль настроек Django по умолчанию +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'netology_pd_diplom.settings') + +# Создаем экземпляр Celery +app = Celery('netology_pd_diplom') + +# Загружаем настройки из Django +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Автоматическое обнаружение задач (tasks.py в каждом приложении) +app.autodiscover_tasks() + +def get_result(task_id: str) -> AsyncResult: + """ + Функция для получения результата асинхронной задачи + :param task_id: Идентификатор задачи + :return: Объект AsyncResult + """ + return AsyncResult(task_id, app=app) \ No newline at end of file diff --git a/reference/netology_pd_diplom/netology_pd_diplom/settings.py b/reference/netology_pd_diplom/netology_pd_diplom/settings.py index 6660e9906..f13d34c04 100644 --- a/reference/netology_pd_diplom/netology_pd_diplom/settings.py +++ b/reference/netology_pd_diplom/netology_pd_diplom/settings.py @@ -3,14 +3,32 @@ Generated by 'django-admin startproject' using Django 5.0. -For more information on this file, see -https://docs.djangoproject.com/en/2.2/topics/settings/ +Более подробную информацию об этом файле см. на странице +https://docs.djangoproject.com/en/5.1/topics/settings/ -For the full list of settings and their values, see -https://docs.djangoproject.com/en/2.2/ref/settings/ +Полный список настроек и их значений см. на странице +https://docs.djangoproject.com/en/5.1/ref/settings/ """ import os +from dotenv import load_dotenv # pip install python-dotenv + +load_dotenv('.env') + +# Обновление сертификата SSL (для почтового клиента) +import certifi + +# На macOS: Запустите скрипт установки сертификатов Python: +# bash +# /Applications/Python\ 3.10/Install\ Certificates.command +# (Замените 3.10 на вашу версию Python, если отличается.) + +# Установите пакет certifi: +# pip install certifi + +# В settings.py добавьте: +# Путь к доверенным обновленным сертификатам +os.environ['SSL_CERT_FILE'] = certifi.where() # Указываем Python использовать сертификаты из certifi # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -19,12 +37,14 @@ # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = '=hs6$#5om031nujz4staql9mbuste=!dc^6)4opsjq!vvjxzj@' +SECRET_KEY = os.getenv('SECRET_KEY') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.getenv('DEBUG') -ALLOWED_HOSTS = ['*'] +ALLOWED_HOSTS = [ + os.getenv('ALLOWED_HOSTS'), +] # Application definition @@ -38,7 +58,7 @@ 'rest_framework', 'rest_framework.authtoken', 'django_rest_passwordreset', - 'backend', + 'backend.apps.BackendConfig', ] MIDDLEWARE = [ @@ -76,15 +96,19 @@ # https://docs.djangoproject.com/en/2.2/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + 'ENGINE': os.getenv('DB_ENGINE'), + 'NAME': os.path.join(f"{BASE_DIR}", f"{os.getenv('DB_NAME')}") } - - } +# DATABASES = { +# 'default': { +# 'ENGINE': 'django.db.backends.sqlite3', +# 'NAME': BASE_DIR / 'db.sqlite3', +# } +# } + # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators @@ -106,7 +130,9 @@ # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = 'ru' # Язык по умолчанию + +USE_I18N = True # Включение интернационализации TIME_ZONE = 'UTC' @@ -125,14 +151,43 @@ EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # EMAIL_USE_TLS = True +# EMAIL_HOST = 'smtp.mail.ru' +# EMAIL_HOST_USER = 'netology.diplom@mail.ru' +# EMAIL_HOST_PASSWORD = 'CLdm7yW4U9nivz9mbexu' +# EMAIL_PORT = '465' +# EMAIL_USE_SSL = True +# SERVER_EMAIL = EMAIL_HOST_USER + +# # Настройки для mail.ru, справка https://help.mail.ru/mail/security/protection/external/ +EMAIL_HOST = 'smtp.mail.ru' # SMTP-сервер +EMAIL_HOST_USER = 'festchuk@mail.ru' +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') # Пароль приложения +EMAIL_PORT = '465' # Для SSL (или 587 для TLS) +EMAIL_USE_SSL = True # Для порта 465 (если используете 587 (более безопасно) → EMAIL_USE_TLS = True) +SERVER_EMAIL = EMAIL_HOST_USER # Для отправки системных писем + + +# # Проверка доступа к SMTP +# # python manage.py shell + +# from django.core.mail import send_mail +# from django.conf import settings + +# # Проверка параметров +# print("EMAIL_HOST:", settings.EMAIL_HOST) +# print("EMAIL_PORT:", settings.EMAIL_PORT) +# print("EMAIL_HOST_USER:", settings.EMAIL_HOST_USER) + +# # Тест отправки +# send_mail( +# 'Тест аутентификации', +# 'Проверка доступа к SMTP.', +# settings.EMAIL_HOST_USER, +# ['dilmah949dilma@gmail.com'], # Список рассылки тестового письма +# fail_silently=False, +# ) -EMAIL_HOST = 'smtp.mail.ru' -EMAIL_HOST_USER = 'netology.diplom@mail.ru' -EMAIL_HOST_PASSWORD = 'CLdm7yW4U9nivz9mbexu' -EMAIL_PORT = '465' -EMAIL_USE_SSL = True -SERVER_EMAIL = EMAIL_HOST_USER REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', @@ -141,14 +196,25 @@ 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.BrowsableAPIRenderer', - - ), + ), # Классы рендереров 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.TokenAuthentication', - ), - + ), # Аутентификация по токену } -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +# Celery +# Необходимо использовать имя контейнера Redis в качестве адреса для подключения, а не localhost. +# Например, если Redis запущен в контейнере с именем broker, то адрес должен выглядеть так: +CELERY_BROKER_URL = 'redis://localhost:6379' # Бекенд задач (Если запускаем через docker-compose 'redis://broker:6379' + # если локально то 'redis://localhost:6379') +CELERY_RESULT_BACKEND = 'redis://localhost:6379' # Бекенд результатов +CELERY_ACCEPT_CONTENT = ['application/json'] # Допустимый формат +CELERY_RESULT_SERIALIZER = 'json' # Сериализатор результатов +CELERY_TASK_SERIALIZER = 'json' # Сериализатор задач +# В логе указано предупреждение о том, что настройка broker_connection_retry устареет в Celery 6.0. +# Чтобы отключить это, в конфиг добавляется: +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True diff --git a/reference/netology_pd_diplom/pytest.ini b/reference/netology_pd_diplom/pytest.ini new file mode 100644 index 000000000..3be1f2837 --- /dev/null +++ b/reference/netology_pd_diplom/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +DJANGO_SETTINGS_MODULE = netology_pd_diplom.settings +python_files = tests.py test_*.py *_tests.py +addopts = --reuse-db \ No newline at end of file diff --git a/reference/netology_pd_diplom/requirements.txt b/reference/netology_pd_diplom/requirements.txt index 9f6ea3cb7..201aaae32 100644 --- a/reference/netology_pd_diplom/requirements.txt +++ b/reference/netology_pd_diplom/requirements.txt @@ -1,7 +1,85 @@ -django~=5.0 -djangorestframework~=3.14.0 -celery~=5.3.0 -requests~=2.31.0 -ujson~=5.9.0 -pyyaml~=6.0.0 -django-rest-passwordreset>=1.3.0 +amqp==5.3.1 +asgiref==3.8.1 +async-timeout==5.0.1 +billiard==4.2.1 +celery==5.3.6 +certifi==2025.1.31 +charset-normalizer==3.4.1 +click==8.1.8 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +Django==5.1.6 +django-extensions==3.2.3 +django-rest-passwordreset==1.5.0 +djangorestframework==3.14.0 +exceptiongroup==1.2.2 +idna==3.10 +iniconfig==2.0.0 +kombu==5.4.2 +model-bakery==1.20.3 +packaging==24.2 +pluggy==1.5.0 +prompt_toolkit==3.0.50 +pytest==8.3.4 +pytest-django==4.10.0 +pytest-mock==3.14.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +pytz==2025.1 +PyYAML==6.0.2 +redis==5.2.1 +requests==2.32.3 +requests-mock==1.12.1 +six==1.17.0 +sqlparse==0.5.3 +tomli==2.2.1 +typing_extensions==4.12.2 +tzdata==2025.1 +ujson==5.9.0 +urllib3==2.3.0 +vine==5.1.0 +wcwidth==0.2.13 +amqp==5.3.1 +asgiref==3.8.1 +async-timeout==5.0.1 +billiard==4.2.1 +celery==5.3.6 +certifi==2025.1.31 +charset-normalizer==3.4.1 +click==8.1.8 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +coverage==7.8.0 +Django==5.1.6 +django-extensions==3.2.3 +django-rest-passwordreset==1.5.0 +djangorestframework==3.14.0 +exceptiongroup==1.2.2 +idna==3.10 +iniconfig==2.0.0 +kombu==5.4.2 +model-bakery==1.20.3 +packaging==24.2 +pluggy==1.5.0 +prompt_toolkit==3.0.50 +pytest==8.3.4 +pytest-django==4.10.0 +pytest-mock==3.14.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +pytz==2025.1 +PyYAML==6.0.2 +redis==5.2.1 +requests==2.32.3 +requests-mock==1.12.1 +six==1.17.0 +sqlparse==0.5.3 +tomli==2.2.1 +typing_extensions==4.12.2 +tzdata==2025.1 +ujson==5.9.0 +urllib3==2.3.0 +vine==5.1.0 +wcwidth==0.2.13 diff --git a/reference/netology_pd_diplom/tests/README.md b/reference/netology_pd_diplom/tests/README.md new file mode 100644 index 000000000..49e80ff92 --- /dev/null +++ b/reference/netology_pd_diplom/tests/README.md @@ -0,0 +1,72 @@ +# Тестирование +#### *Тесты сфокусированны на проверке API, а модели проверяются косвенно (т.е. в текущих тестах проверяется интеграция — например, при добавлении товаров в корзину создаётся OrderItem, но нет юнит-тестов конкретно для модели OrderItem).* +--- +#### Чтобы запустить подготовленные тесты, выполните следующие шаги: +--- + +1. Установите необходимые зависимости +```bash +pip install pytest pytest-django model_bakery requests-mock pytest-mock +``` +2. Настройте окружение +Создайте файл `pytest.ini` в корне проекта: +```ini +[pytest] +DJANGO_SETTINGS_MODULE = your_project.settings +python_files = tests.py test_*.py *_tests.py +addopts = --reuse-db +``` +3. Разместите тесты +Создайте структуру директорий: +``` +your_app/ +├── tests/ +│ ├── __init__.py +│ └── test_api.py # файл с тестами +``` +4. Запуск тестов +Основные команды: +```bash +# Все тесты +pytest your_app/tests/ -v + +# Конкретный тестовый класс +pytest your_app/tests/test_api.py::TestRegisterAccount -v + +# Конкретный тестовый метод +pytest your_app/tests/test_api.py::TestRegisterAccount::test_register_success -v + +# Запись лога +pytest your_app/tests/test_api.py::TestRegisterAccount::test_register_success -v --log-cli-level=INFO +``` +5. Пример вывода при успешном запуске +```bash +============================= test session starts ============================== +collected 9 items + +your_app/tests/test_api.py::TestRegisterAccount::test_register_missing_fields PASSED +your_app/tests/test_api.py::TestRegisterAccount::test_register_invalid_password PASSED +your_app/tests/test_api.py::TestRegisterAccount::test_register_success PASSED +... +============================== 9 passed in 2.15s =============================== +``` +6. Возможные проблемы и решения +Проблема: База данных не создается +Решение: Добавьте флаг при первом запуске: + +```bash +pytest --create-db +```` +Проблема: Ошибки с миграциями +Решение: Выполните миграции перед тестами: +```bash +python manage.py migrate +``` +7. Дополнительные настройки +Для детализации вывода используйте: +```bash +pytest -v # Подробный вывод +pytest --cov # Покрытие кода +pytest -x # Остановка при первой ошибке +pytest --ff # Запуск сначала проваленных тестов +``` \ No newline at end of file diff --git a/reference/netology_pd_diplom/tests/__init__.py b/reference/netology_pd_diplom/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/reference/netology_pd_diplom/tests/log.txt b/reference/netology_pd_diplom/tests/log.txt new file mode 100644 index 000000000..fdf5640e4 --- /dev/null +++ b/reference/netology_pd_diplom/tests/log.txt @@ -0,0 +1,16 @@ +============================= test session starts ============================== +platform darwin -- Python 3.10.7, pytest-8.3.4, pluggy-1.5.0 -- /Users/apple/Documents/Full-stack-Python-developer/my_diplom/venv/bin/python3 +cachedir: .pytest_cache +django: version: 5.1.6, settings: netology_pd_diplom.settings (from ini) +rootdir: /Users/apple/Documents/Full-stack-Python-developer/my_diplom/python-final-diplom/reference/netology_pd_diplom +configfile: pytest.ini +plugins: django-4.10.0, mock-3.14.0, requests-mock-1.12.1 +collecting ... collected 5 items + +tests/test_api.py::TestPartnerUpdate::test_partner_update_permission PASSED [ 20%] +tests/test_api.py::TestPartnerUpdate::test_unauthenticated_user PASSED [ 40%] +tests/test_api.py::TestPartnerUpdate::test_partner_update_invalid_url PASSED [ 60%] +tests/test_api.py::TestPartnerUpdate::test_partner_update_missing_url PASSED [ 80%] +tests/test_api.py::TestPartnerUpdate::test_successful_update PASSED [100%] + +============================== 5 passed in 1.37s =============================== diff --git a/reference/netology_pd_diplom/tests/test_api.py b/reference/netology_pd_diplom/tests/test_api.py new file mode 100644 index 000000000..91af2b832 --- /dev/null +++ b/reference/netology_pd_diplom/tests/test_api.py @@ -0,0 +1,345 @@ +# Тесты сфокусированны на проверке API, а модели проверяются +# косвенно (т.е. в текущих тестах проверяется интеграция — например, +# при добавлении товаров в корзину создаётся OrderItem, +# но нет юнит-тестов конкретно для модели OrderItem). + +import json # для работы с JSON +import pytest # для написания тестов +from django.urls import reverse # для работы с пространством имен +# APIClient - тестовый клиент DRF, который позволяет имитировать +# HTTP-запросы к разработанному API в тестах +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model # для получения модели пользователя +from model_bakery import baker # для создания тестовых данных +from unittest.mock import patch # для работы с моками +from backend.models import Shop, Category, Product, ProductInfo, Order, Contact, ConfirmEmailToken + +User = get_user_model() # получаем модель пользователя + +# Фикстура для клиента API +@pytest.fixture +def client(): + """ + Фикстура для клиента API. + """ + return APIClient() # клиент для тестирования API + +# Общая фикстура для аутентифицированного пользователя +@pytest.fixture +def authenticated_user(client): + """ + Фикстура для аутентифицированного пользователя. + """ + user = baker.make(User) # создаем тестового пользователя + client.force_authenticate(user=user) # Принудительная аутентификация пользователя + return user + +# Тесты для RegisterAccount +@pytest.mark.django_db +class TestRegisterAccount: + """ + Класс для тестирования регистрации пользователя. + """ + def test_register_missing_fields(self, client): + """ + Проверяем, что регистрация пользователя не проходит, если не указаны все обязательные поля. + """ + url = reverse('backend:user-register') # получаем url эндпоинта RegisterAccount из пространства имен + response = client.post(url, {}) # отправляем POST-запрос + assert response.status_code == 400 # проверка статуса + assert 'Errors' in response.json() # проверяем, что в ответе есть ключ 'Errors' + + def test_register_invalid_password(self, client): + """ + Проверяем, что регистрация пользователя не проходит, если пароль невалиден. + """ + data = { + 'first_name': 'Test', + 'last_name': 'User', + 'email': 'test@test.com', + 'password': '123', + 'company': 'TestCo', + 'position': 'Manager' + } # создаем словарь с невалидным паролем + url = reverse('backend:user-register') # получаем url эндпоинта RegisterAccount из пространства имен + response = client.post(url, data) # отправляем POST-запрос + assert response.status_code == 400 # проверка статуса + assert 'password' in response.json()['Errors'] # проверяем, что в ответе есть ключ 'password' внутри ключа 'Errors' + + def test_register_success(self, client): + """ + Проверяем, что регистрация пользователя проходит успешно. + """ + data = { + 'first_name': 'Test', + 'last_name': 'User', + 'email': 'test@test.com', + 'password': 'TestPass123!', + 'company': 'TestCo', + 'position': 'Manager' + } # создаем словарь с валидным паролем + url = reverse('backend:user-register') + response = client.post(url, data) + assert response.status_code == 200 + assert response.json()['Status'] is True # проверяем, что в ответе есть ключ 'Status' со значением True + +# Тесты для ConfirmAccount +@pytest.mark.django_db # декоратор для работы с базой данных +class TestConfirmAccount: + """ + Класс для тестирования подтверждения учетной записи. + """ + def test_confirm_invalid_token(self, client): + """ + Проверяем, что подтверждение учетной записи не проходит, если указан невалидный токен. + """ + user = baker.make(User, email='test@test.com', is_active=False) # создаем тестового пользователя + baker.make(ConfirmEmailToken, user=user) # создаем токен + url = reverse('backend:user-register-confirm') # получаем url эндпоинта ConfirmAccount из пространства имен + data = {'email': 'test@test.com', 'token': 'wrong_token'} # создаем словарь с невалидным токеном + response = client.post(url, data) + assert response.status_code == 400 + assert 'Errors' in response.json() # проверяем, что в ответе есть ключ 'Errors' + + def test_confirm_success(self, client): + """ + Проверяем, что подтверждение учетной записи проходит успешно. + """ + user = baker.make(User, email='test@test.com', is_active=False) # создаем тестового пользователя + token = baker.make(ConfirmEmailToken, user=user) # создаем токен + url = reverse('backend:user-register-confirm') # получаем url эндпоинта ConfirmAccount из пространства имен + data = {'email': 'test@test.com', 'token': token.key} # создаем словарь с валидным токеном + response = client.post(url, data) + user.refresh_from_db() # обновляем данные пользователя из базы данных + assert response.status_code == 200 + assert user.is_active is True # проверяем, что пользователь активирован (поле is_active установлено в True) + +# Тесты для LoginAccount +@pytest.mark.django_db +class TestLoginAccount: + """ + Класс для тестирования входа в систему. + """ + def test_login_invalid_credentials(self, client): + """ + Проверяем, что вход в систему не проходит, если указаны невалидные учетные данные. + """ + user = baker.make(User, email='test@test.com', password='TestPass123!', is_active=True) # создаем тестового пользователя (для авторизации он должен быть активирован) + user.set_password(user.password) # устанавливаем пароль + user.save() # сохраняем его в базе данных + url = reverse('backend:user-login') # получаем url эндпоинта LoginAccount из пространства имен + data = {'email': 'test@test.com', 'password': 'WrongPass'} # создаем словарь с невалидным паролем + response = client.post(url, data) + assert response.status_code == 400 + assert 'Errors' in response.json() # проверяем, что в ответе есть ключ 'Errors' + + def test_login_success(self, client): + """ + Проверяем, что вход в систему проходит успешно. + """ + user = baker.make(User, email='test@test.com', password='TestPass123!', is_active=True) + user.set_password(user.password) + user.save() + url = reverse('backend:user-login') + data = {'email': 'test@test.com', 'password': 'TestPass123!'} # создаем словарь с валидным паролем + response = client.post(url, data) + assert response.status_code == 200 + assert 'Token' in response.json() # проверяем, что в ответе есть ключ 'Token' + +# Тесты для PartnerUpdate +@pytest.mark.django_db +class TestPartnerUpdate: + """ + Класс для тестирования обновления партнера. + """ + def test_partner_update_permission(self, client): + """ + Проверяем, что обновление партнера доступно только для магазинов. + """ + user = baker.make(User, type='buyer') # назначаем пользователю тип 'buyer' - покупатель + client.force_authenticate(user=user) # Принудительная аутентификация пользователя + url = reverse('backend:partner-update') # получаем url эндпоинта PartnerUpdate + response = client.post(url, {}) # отправляем POST-запрос + assert response.status_code == 403 + assert response.json()['Error'] == 'Только для магазинов' # проверяем, что в ответе есть ключ 'Error' со значением 'Только для магазинов' + + def test_unauthenticated_user(self, client): + """ + Проверяем, что обновление партнера доступно только для + зарегистрированных пользователей. + """ + url = reverse('backend:partner-update') + response = client.post(url, {}) + assert response.status_code == 403 + assert response.json()['Error'] == 'Log in required' # проверяем, что в ответе есть ключ 'Error' со значением 'Требуется вход' + + def test_partner_update_invalid_url(self, client): + """ + Проверяем, что обновление партнера доступно только для + зарегистрированных пользователей с правильными учетными данными. + """ + user = baker.make(User, type='shop') # назначаем пользователю тип 'shop' - магазин + client.force_authenticate(user=user) # Принудительная аутентификация пользователя + url = reverse('backend:partner-update') # получаем url эндпоинта PartnerUpdate + data = {'url': 'invalid-url'} # создаем словарь с невалидным url + response = client.post(url, data) # отправляем POST-запрос + assert response.status_code == 400 + assert 'Error' in response.json() # проверяем, что в ответе есть ключ 'Error' + + def test_partner_update_missing_url(self, client): + """ + Проверяем, что обновление партнера доступно только для + зарегистрированных пользователей с полными учетными данными. + """ + user = baker.make(User, type='shop') + client.force_authenticate(user=user) # Принудительная аутентификация пользователя + url = reverse('backend:partner-update') + response = client.post(url, {}) + assert response.status_code == 400 + assert response.json()['Errors'] == 'Не указаны все необходимые аргументы' + + @patch('backend.views.get') # подменяем метод `requests.get` с помощью Mock для изоляции теста от реальных HTTP-запросов + @patch('backend.views.load_yaml') # подменяем функцию `load_yaml`, которая парсит YAML-файлы + def test_successful_update(self, mock_load_yaml, mock_get, client): + """ + Проверяем, что обновление партнера проходит успешно. + mock_load_yaml - мокирование функции `load_yaml`, которая парсит YAML-файлы + mock_get - мокирование метода `requests.get`, чтобы вернуть заранее заданные данные + """ + user = baker.make(User, type='shop') # назначаем пользователю тип 'shop' - магазин + client.force_authenticate(user=user) # принудительная аутентификация пользователя + mock_load_yaml.return_value = { + 'shop': 'Test Shop', + 'categories': [{'id': 1, 'name': 'Category 1'}], + 'goods': [{ + 'id': 1, + 'name': 'Test Product', + 'model': 'Model X', + 'category': 1, + 'price': 100, + 'price_rrc': 150, + 'quantity': 10, + 'parameters': {'color': 'red'} + }] + } # заранее задаем данные + mock_get.return_value.content = b'mock-content' # мокирование данных, которые возвращаются как байтовая строка + url = reverse('backend:partner-update') # получаем url эндпоинта PartnerUpdate + data = {'url': 'http://example.com/test.yml'} # мокированный URL прайс-листа + response = client.post(url, data, format='json') # отправляем POST-запрос + assert response.status_code == 200 + assert response.json()['Status'] is True # проверяем, что в ответе есть ключ 'Status' со значением True + +# Тесты для BasketView +@pytest.mark.django_db +class TestBasketView: + """ + Класс для тестирования BasketView (корзина). + """ + @pytest.fixture + def setup_data(self): + """ + Фикстура для создания необходимых данных для тестирования. + """ + user = baker.make(User) # создаем тестового пользователя + category = baker.make(Category) # создаем тестовую категорию + shop = baker.make(Shop) # создаем тестовый магазин + product = baker.make(Product, category=category) # создаем тестовый товар + product_info = baker.make(ProductInfo, product=product, shop=shop) # создаем тестовую информацию о продукте + return user, product_info # возвращаем созданные данные + + def test_basket_access_unauthorized(self, client): + """ + Проверяем доступ к корзине для неавторизованных пользователей. + """ + url = reverse('backend:basket') + response = client.get(url) + assert response.status_code == 403 + assert response.json()['Error'] == 'Log in required' + + def test_basket_add_items(self, client, setup_data): + """ + Проверяем добавление товаров в корзину для авторизованных пользователей. + """ + user, product_info = setup_data # получаем данные из фикстуры + client.force_authenticate(user=user) # аутентификация пользователя + payload = {"items": json.dumps([{"product_info": product_info.id, "quantity": 2}])} # подготавливаем данные для запроса в формате JSON + response = client.post(reverse('backend:basket'), data=payload, format='json') # отправляем POST-запрос + assert response.status_code == 200 + assert response.json() == {'Status': True, 'Создано объектов': 1} # проверяем, что в ответе есть ключ 'Status' со значением True и ключ 'Создано объектов' со значением 1 + +# Тесты для OrderView +@pytest.mark.django_db +class TestOrderView: + """ + Класс для тестирования OrderView (заказы). + """ + @pytest.fixture + def setup_order(self): + """ + Фикстура для создания необходимых данных для тестирования. + """ + user = baker.make(User) # создаем тестового пользователя + contact = baker.make(Contact, user=user) # создаем тестовый контакт + order = baker.make(Order, user=user, state='basket') # создаем тестовый заказ + return user, contact, order + + def test_order_create_success(self, client, setup_order): + """ + Проверяем успешное создание заказа. + """ + user, contact, order = setup_order # получаем данные из фикстуры + client.force_authenticate(user=user) # аутентификация пользователя + response = client.post( + reverse('backend:order'), + {'id': str(order.id), 'contact': contact.id}, + format='json' + ) # отправляем POST-запрос с данными об заказе и контакте + assert response.status_code == 200 + assert response.json() == {'Status': True} # проверяем, что в ответе есть ключ 'Status' со значением True + + def test_order_create_not_found(self, client): + """ + Проверяем, что заказ отсутствует в базе данных при неправильном ID для поиска. + """ + user = baker.make(User) # создаем тестового пользователя + contact = baker.make(Contact, user=user) # создаем тестовый контакт + client.force_authenticate(user=user) # аутентификация пользователя + response = client.post( + reverse('backend:order'), + {'id': '999', 'contact': contact.id}, + format='json' + ) # отправляем POST-запрос с неправильным ID заказа + assert response.status_code == 404 + assert response.json() == {'Status': False, 'Errors': 'Заказ не найден'} # проверяем, что в ответе есть ключ 'Status' со значением False и ключ 'Errors' со значением 'Заказ не найден' + + def test_order_create_unauthenticated(self, client): + """ + Проверяем, что заказ не создается, если пользователь не аутентифицирован. + """ + response = client.post(reverse('backend:order'), {}) + assert response.status_code == 403 + assert response.json() == {'Status': False, 'Error': 'Log in required'} # проверяем, что в ответе есть ключ 'Status' со значением False и ключ 'Error' со значением 'Log in required' + + def test_order_create_invalid_data(self, client): + """ + Проверяем, что заказ не создается с невалидными данными. + """ + user = baker.make(User) # создаем тестового пользователя + client.force_authenticate(user=user) # аутентификация пользователя + response = client.post( + reverse('backend:order'), + {'id': 'invalid_id', 'contact': 1}, + format='json' + ) # отправляем POST-запрос с невалидными данными + assert response.status_code == 400 + assert 'Errors' in response.json() # проверяем, что в ответе есть ключ 'Errors' + + def test_missing_required_fields(self, client): + """ + Проверяем, что заказ не создается, если не указаны все необходимые аргументы. + """ + user = baker.make(User) # создаем тестового пользователя + client.force_authenticate(user=user) # аутентификация пользователя + response = client.post(reverse('backend:order'), {}, format='json') # отправляем POST-запрос без необходимых аргументов + assert response.status_code == 400 + assert response.json() == {'Status': False, 'Errors': 'Не указаны все необходимые аргументы'} diff --git a/requirements.txt b/requirements.txt index 9f6ea3cb7..201aaae32 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,85 @@ -django~=5.0 -djangorestframework~=3.14.0 -celery~=5.3.0 -requests~=2.31.0 -ujson~=5.9.0 -pyyaml~=6.0.0 -django-rest-passwordreset>=1.3.0 +amqp==5.3.1 +asgiref==3.8.1 +async-timeout==5.0.1 +billiard==4.2.1 +celery==5.3.6 +certifi==2025.1.31 +charset-normalizer==3.4.1 +click==8.1.8 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +Django==5.1.6 +django-extensions==3.2.3 +django-rest-passwordreset==1.5.0 +djangorestframework==3.14.0 +exceptiongroup==1.2.2 +idna==3.10 +iniconfig==2.0.0 +kombu==5.4.2 +model-bakery==1.20.3 +packaging==24.2 +pluggy==1.5.0 +prompt_toolkit==3.0.50 +pytest==8.3.4 +pytest-django==4.10.0 +pytest-mock==3.14.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +pytz==2025.1 +PyYAML==6.0.2 +redis==5.2.1 +requests==2.32.3 +requests-mock==1.12.1 +six==1.17.0 +sqlparse==0.5.3 +tomli==2.2.1 +typing_extensions==4.12.2 +tzdata==2025.1 +ujson==5.9.0 +urllib3==2.3.0 +vine==5.1.0 +wcwidth==0.2.13 +amqp==5.3.1 +asgiref==3.8.1 +async-timeout==5.0.1 +billiard==4.2.1 +celery==5.3.6 +certifi==2025.1.31 +charset-normalizer==3.4.1 +click==8.1.8 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +coverage==7.8.0 +Django==5.1.6 +django-extensions==3.2.3 +django-rest-passwordreset==1.5.0 +djangorestframework==3.14.0 +exceptiongroup==1.2.2 +idna==3.10 +iniconfig==2.0.0 +kombu==5.4.2 +model-bakery==1.20.3 +packaging==24.2 +pluggy==1.5.0 +prompt_toolkit==3.0.50 +pytest==8.3.4 +pytest-django==4.10.0 +pytest-mock==3.14.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +pytz==2025.1 +PyYAML==6.0.2 +redis==5.2.1 +requests==2.32.3 +requests-mock==1.12.1 +six==1.17.0 +sqlparse==0.5.3 +tomli==2.2.1 +typing_extensions==4.12.2 +tzdata==2025.1 +ujson==5.9.0 +urllib3==2.3.0 +vine==5.1.0 +wcwidth==0.2.13