diff --git a/.env.example b/.env.example index 672ddff..d194d1f 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ SECRET_KEY= +# Django +DEBUG=False +ALLOWED_HOSTS=localhost,127.0.0.1 +CORS_ALLOWED_ORIGINS=http://localhost:8000 + # Database POSTGRES_DB= POSTGRES_USER= @@ -32,3 +37,4 @@ TELEGRAM_TOKEN= # SMS SMS_API_KEY= SMS_SENDER= +SMS_TEST=0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e66ab0a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + SECRET_KEY: ci-secret-key + DEBUG: "False" + ALLOWED_HOSTS: localhost,127.0.0.1 + POSTGRES_DB: test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_HOST: localhost + POSTGRES_PORT: "5432" + CELERY_BROKER_URL: amqp://guest:guest@localhost:5672// + CELERY_RESULT_BACKEND: rpc:// + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + - name: Migrate + run: python manage.py migrate + - name: Lint + run: ruff check . + - name: Format check + run: ruff format --check . + - name: Tests + run: pytest --cov --cov-report=term-missing --cov-fail-under=55 + - name: Coverage badge data + id: coverage + run: | + coverage json -o coverage.json + python - <<'PY' >> "$GITHUB_OUTPUT" + import json + + totals = json.load(open("coverage.json"))["totals"] + percent = totals["percent_covered"] + color = "green" if percent >= 80 else "yellow" if percent >= 55 else "red" + print(f"total={totals['percent_covered_display']}%") + print(f"color={color}") + PY + - name: Update coverage badge + uses: schneegans/dynamic-badges-action@v1.7.0 + continue-on-error: true + with: + auth: ${{ secrets.GIST_TOKEN }} + gistID: 097e2cc0afbb6ee7fdbfb4d2d67bb87d + filename: coverage.json + label: coverage + message: ${{ steps.coverage.outputs.total }} + color: ${{ steps.coverage.outputs.color }} diff --git a/.gitignore b/.gitignore index 63cab80..b3cc0c1 100644 --- a/.gitignore +++ b/.gitignore @@ -118,4 +118,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ \ No newline at end of file +.idea/ +# Python +__pycache__/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d126f82 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Anna Putilovskaya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ea79fec..6d052eb 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,127 @@ -# Сервис уведомлений - -### Задача: - -Создать сервис для отправки уведомлений пользователям. -Пользователь может получать уведомления через: -- Email -- SMS -- Telegram - -Нужно обеспечить надежную доставку - если один способ не сработал, попробовать другой. - ---- -### Технический требования: - -- Язык программирования: - - Python 3.12 -- Фреймворк: - - Django Rest Framework -- ORM: - - Django ORM -- Хранение данных: - - PostgreSQL 17 -- Очереди сообщений: - - Celery + RabbitMQ - ---- -### Запуск проекта: - -- Клонируйте проект: - ```python - git clone https://github.com/annaputilovskaya/NotificationService.git - ``` -- Создайте файл .env в корне проекта по образцу (Файл .env.example) и заполните его данными. - - Команда для Unix-подобных систем для копирования файла: - ```python - cp .env.example .env - ``` -- Убедитесь, что на Вашем устройстве установлен Docker. - - Проверка версии Docker: - ```python - docker --version - ``` - Если докер не установлен, [осуществите его установку по инструкции с официального сайта](https://www.docker.com/) - -- Осуществите сборку образов и запуск контейнеров: - ```python - docker compose up -d --build - ``` - ---- -### Структура базы данных: - -image - ---- -### Функционал и бизнес-логика - -1. Пользователи: - - Регистрация с email и паролем - - Получение токена для авторизация - - ОБновление токена авторизации - -image - - - -2. Получатели - - Создание получателя - - Просмотр получателя - - Просмотр списка получателей - - Изменение получтаеля - - Удаление получателя - -image - - - -3. Уведомления - - Создание уведомления - - Просмотр уведомления - - Просмотр списка уведомлений - -image - - - При создания уведомления создается задача по отправке уведомления. - Происходит выбор стратегии отпраки уведомления в следующем порядке: - - Email - - Telegram - - SMS - - В случае, если первая стратегия не сработала, переходим ко второй, далее к третьей. - Если ни одна страегия отправки для каких-либо получателей не сработала, повторяем попытку отправки для этих получателей через определенный временной интервал. - - --- -Сервис подходит для отправки разовых уведомлений. Если нужны повторяющиеся уведомления, возможна доработка. - -В проекте настроено логирование отправки уведомлений с отправкой логов в консоль и с сохранением в файл. - -Проведено ручное тестирование проекта с помощью Postman. - -Автоматизированное тестирование не проводилось. - -Для более подробной информации об API ознакомьтесь с интерактивной документацией. - -Если проект запущен на localhost, пройдите по [ссылке](http://localhost:8000/swagger/) - - +# Notification Service + +[![CI](https://github.com/annaputilovskaya/NotificationService/actions/workflows/ci.yml/badge.svg)](https://github.com/annaputilovskaya/NotificationService/actions/workflows/ci.yml) +[![coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/annaputilovskaya/097e2cc0afbb6ee7fdbfb4d2d67bb87d/raw/coverage.json)](https://github.com/annaputilovskaya/NotificationService/actions/workflows/ci.yml) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![Python](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/) + +An API for reliable delivery of notifications to users across multiple channels — Email, SMS, and Telegram — with automatic failover between channels. + +[Русская версия](README.ru.md) + +## Features + +- **Users** — email/password sign-up, issuing and refreshing JWT access tokens. + + Users + +- **Recipients** — create, view, update, and delete recipients. + + Recipients + +- **Notifications** — create a notification, view a single notification and a list. + + Notifications + +- **Reliable delivery (failover)** — creating a notification enqueues a Celery task. Strategies are tried in order **Email → Telegram → SMS**: when a channel fails, the next one is used. If no strategy succeeds for some recipients, delivery to those recipients is retried after a delay. +- **Pagination** — list endpoints return a paginated `{"count": ..., "results": [...]}` payload (the `?page=` parameter). +- **Logging** — delivery logs go to the console and to a file. +- The service targets one-off notifications; recurring notifications are a possible extension. + +## Stack + +- Python 3.12 +- Django REST Framework (Django ORM) +- PostgreSQL 17 +- Celery + RabbitMQ +- drf-yasg (Swagger/Redoc) + +## Architecture + +```mermaid +flowchart LR + Client["Client"] --> API["Django REST API"] + API -->|create notification| Task["Celery: send_notification"] + Task --> Email["Email strategy"] + Email -->|fail| Telegram["Telegram strategy"] + Telegram -->|fail| SMS["SMS strategy"] + Email --> SMTP[("SMTP")] + Telegram --> TG[("Telegram API")] + SMS --> SMSAPI[("sms.ru")] + Task --> DB[("PostgreSQL")] +``` + +## Data model + +Database schema + +## Getting started + +### Prerequisites + +- Docker and Docker Compose + +### Configuration + +Copy `.env.example` to `.env` and fill in the values: + +```bash +cp .env.example .env +``` + +| Variable | Description | Example | +|---|---|---| +| `SECRET_KEY` | Django secret key | `django-insecure-...` | +| `DEBUG` | Debug mode | `False` | +| `ALLOWED_HOSTS` | Allowed hosts (comma-separated) | `localhost,127.0.0.1` | +| `CORS_ALLOWED_ORIGINS` | Allowed CORS origins | `http://localhost:8000` | +| `POSTGRES_*` | Database connection settings | `POSTGRES_DB=notifications` | +| `CELERY_BROKER_URL` | Celery broker URL (RabbitMQ) | `amqp://user:pass@rabbitmq:5672//` | +| `EMAIL_*` | Mail server settings | `EMAIL_HOST=smtp.example.com` | +| `TELEGRAM_TOKEN` | Telegram bot token | `123456:ABC` | +| `SMS_API_KEY` / `SMS_SENDER` | sms.ru credentials | `SMS_SENDER=Service` | +| `SMS_TEST` | sms.ru test mode (does not send SMS) | `0` | + +### Install & run + +Clone the repository and start the services: + +```bash +git clone https://github.com/annaputilovskaya/NotificationService.git +cd NotificationService +docker compose up -d --build +``` + +Verify Docker is installed (otherwise follow the [official instructions](https://www.docker.com/)): + +```bash +docker --version +``` + +Interactive API documentation (Swagger): http://localhost:8000/swagger/ + +## Tests + +Tests run against PostgreSQL. Start the database and run: + +```bash +docker compose up -d db +python -m pytest --cov +``` + +External services (SMTP, Telegram, sms.ru) are mocked. The project was originally checked manually via Postman; automated core and smoke tests are now in place. + +## Project structure + +``` +config/ # Django settings, Celery, urls +notifications/ # notifications: model, strategies, Celery task +recipients/ # recipients +users/ # users, JWT +conftest.py # shared pytest fixtures +pyproject.toml # ruff/pytest/coverage config +``` + +## License + +Released under the MIT License — see [LICENSE](LICENSE). diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..b0db662 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,127 @@ +# Сервис уведомлений + +[![CI](https://github.com/annaputilovskaya/NotificationService/actions/workflows/ci.yml/badge.svg)](https://github.com/annaputilovskaya/NotificationService/actions/workflows/ci.yml) +[![coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/annaputilovskaya/097e2cc0afbb6ee7fdbfb4d2d67bb87d/raw/coverage.json)](https://github.com/annaputilovskaya/NotificationService/actions/workflows/ci.yml) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![Python](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/) + +API для надёжной доставки уведомлений пользователям по нескольким каналам — Email, SMS и Telegram — с автоматическим переключением между каналами при сбое. + +[English version](README.md) + +## Возможности + +- **Пользователи** — регистрация по email и паролю, получение и обновление JWT-токена авторизации. + + Пользователи + +- **Получатели** — создание, просмотр, изменение и удаление получателей. + + Получатели + +- **Уведомления** — создание уведомления, просмотр одного уведомления и списка. + + Уведомления + +- **Надёжная доставка (failover)** — при создании уведомления ставится задача Celery. Стратегии перебираются по порядку **Email → Telegram → SMS**: если канал не сработал, используется следующий. Если ни одна стратегия не сработала для части получателей, попытка повторяется для этих получателей через заданный интервал. +- **Пагинация** — эндпоинты списков отдают постраничный ответ `{"count": ..., "results": [...]}` (параметр `?page=`). +- **Логирование** — логи отправки пишутся в консоль и сохраняются в файл. +- Сервис ориентирован на разовые уведомления; поддержка повторяющихся уведомлений — возможная доработка. + +## Стек + +- Python 3.12 +- Django REST Framework (Django ORM) +- PostgreSQL 17 +- Celery + RabbitMQ +- drf-yasg (Swagger/Redoc) + +## Архитектура + +```mermaid +flowchart LR + Client["Клиент"] --> API["Django REST API"] + API -->|создание уведомления| Task["Celery: send_notification"] + Task --> Email["Email strategy"] + Email -->|fail| Telegram["Telegram strategy"] + Telegram -->|fail| SMS["SMS strategy"] + Email --> SMTP[("SMTP")] + Telegram --> TG[("Telegram API")] + SMS --> SMSAPI[("sms.ru")] + Task --> DB[("PostgreSQL")] +``` + +## Модель данных + +Схема базы данных + +## Начало работы + +### Требования + +- Docker и Docker Compose + +### Конфигурация + +Скопируйте `.env.example` в `.env` и заполните значения: + +```bash +cp .env.example .env +``` + +| Переменная | Описание | Пример | +|---|---|---| +| `SECRET_KEY` | Секретный ключ Django | `django-insecure-...` | +| `DEBUG` | Режим отладки | `False` | +| `ALLOWED_HOSTS` | Разрешённые хосты (через запятую) | `localhost,127.0.0.1` | +| `CORS_ALLOWED_ORIGINS` | Разрешённые CORS-origins | `http://localhost:8000` | +| `POSTGRES_*` | Параметры подключения к БД | `POSTGRES_DB=notifications` | +| `CELERY_BROKER_URL` | URL брокера Celery (RabbitMQ) | `amqp://user:pass@rabbitmq:5672//` | +| `EMAIL_*` | Настройки почтового сервера | `EMAIL_HOST=smtp.example.com` | +| `TELEGRAM_TOKEN` | Токен Telegram-бота | `123456:ABC` | +| `SMS_API_KEY` / `SMS_SENDER` | Доступ к sms.ru | `SMS_SENDER=Service` | +| `SMS_TEST` | Тестовый режим sms.ru (не отправляет СМС) | `0` | + +### Установка и запуск + +Склонируйте репозиторий и запустите сервисы: + +```bash +git clone https://github.com/annaputilovskaya/NotificationService.git +cd NotificationService +docker compose up -d --build +``` + +Проверка версии Docker (если не установлен — [инструкция](https://www.docker.com/)): + +```bash +docker --version +``` + +Интерактивная документация API (Swagger): http://localhost:8000/swagger/ + +## Тесты + +Тесты запускаются на PostgreSQL. Поднимите БД и выполните: + +```bash +docker compose up -d db +python -m pytest --cov +``` + +Внешние сервисы (SMTP, Telegram, sms.ru) в тестах замоканы. Ранее проект проверялся вручную через Postman; теперь добавлены автоматизированные тесты ядра и smoke. + +## Структура проекта + +``` +config/ # настройки Django, Celery, urls +notifications/ # уведомления: модель, стратегии, задача Celery +recipients/ # получатели +users/ # пользователи, JWT +conftest.py # общие фикстуры pytest +pyproject.toml # конфиг ruff/pytest/coverage +``` + +## Лицензия + +Проект распространяется под лицензией MIT — см. [LICENSE](LICENSE). diff --git a/config/celery.py b/config/celery.py index ad56252..7abd427 100644 --- a/config/celery.py +++ b/config/celery.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import, unicode_literals - import os from celery import Celery diff --git a/config/settings.py b/config/settings.py index 4b63da4..aca1454 100644 --- a/config/settings.py +++ b/config/settings.py @@ -8,13 +8,19 @@ SECRET_KEY = os.getenv("SECRET_KEY") -DEBUG = True +DEBUG = os.getenv("DEBUG", "False").lower() in ("1", "true", "yes") -ALLOWED_HOSTS = ["*"] +ALLOWED_HOSTS = [ + host.strip() + for host in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") + if host.strip() +] # CORS CORS_ALLOWED_ORIGINS = [ - "http://localhost:8000", # TODO: add all allowed origins + origin.strip() + for origin in os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost:8000").split(",") + if origin.strip() ] @@ -37,6 +43,7 @@ ] MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -44,7 +51,6 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - # "corsheaders.middleware.CorsMiddleware", TODO: include CorsMiddleware ] ROOT_URLCONF = "config.urls" @@ -73,6 +79,8 @@ "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, } # Database @@ -165,10 +173,12 @@ # SMS SMS_API_KEY = os.getenv("SMS_API_KEY") SMS_SENDER = os.getenv("SMS_SENDER") +SMS_TEST = os.getenv("SMS_TEST", "0") == "1" # Logging LOG_FILE_NAME = "notifications.log" LOG_FILE_PATH = os.path.join(BASE_DIR, "logs", LOG_FILE_NAME) +os.makedirs(os.path.dirname(LOG_FILE_PATH), exist_ok=True) LOGGING = { "version": 1, diff --git a/notifications/tests.py b/config/tests/__init__.py similarity index 100% rename from notifications/tests.py rename to config/tests/__init__.py diff --git a/config/tests/test_settings.py b/config/tests/test_settings.py new file mode 100644 index 0000000..f1a855d --- /dev/null +++ b/config/tests/test_settings.py @@ -0,0 +1,27 @@ +"""Проверки env-настроек.""" + +import pytest +from django.conf import settings + + +def test_pagination_configured(): + assert settings.REST_FRAMEWORK["PAGE_SIZE"] == 20 + assert ( + settings.REST_FRAMEWORK["DEFAULT_PAGINATION_CLASS"] + == "rest_framework.pagination.PageNumberPagination" + ) + + +def test_cors_middleware_enabled(): + assert "corsheaders.middleware.CorsMiddleware" in settings.MIDDLEWARE + + +def test_sms_test_default_is_false(): + assert settings.SMS_TEST is False + + +@pytest.mark.django_db +def test_swagger_title(api_client): + response = api_client.get("/swagger.json/") + assert response.status_code == 200 + assert "Notification Service API" in response.content.decode() diff --git a/config/urls.py b/config/urls.py index 4a66b90..2c6a3ad 100644 --- a/config/urls.py +++ b/config/urls.py @@ -6,12 +6,13 @@ schema_view = get_schema_view( openapi.Info( - title="Snippets API", + title="Notification Service API", default_version="v1", - description="Test description", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="contact@snippets.local"), - license=openapi.License(name="BSD License"), + description=( + "API сервиса доставки уведомлений по каналам " + "Email → Telegram → SMS с автоматическим failover." + ), + license=openapi.License(name="MIT License"), ), public=True, permission_classes=[ diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..1f49d97 --- /dev/null +++ b/conftest.py @@ -0,0 +1,38 @@ +"""Общие фикстуры pytest.""" + +import pytest +from rest_framework.test import APIClient + +from recipients.models import Recipient +from users.models import User + + +@pytest.fixture +def api_client() -> APIClient: + """Неаутентифицированный APIClient.""" + return APIClient() + + +@pytest.fixture +def user(db) -> User: + """Обычный пользователь-владелец уведомлений.""" + return User.objects.create_user(email="user@example.com", password="Str0ng-pass-1") + + +@pytest.fixture +def other_user(db) -> User: + """Второй пользователь (для проверки изоляции данных).""" + return User.objects.create_user(email="other@example.com", password="Str0ng-pass-1") + + +@pytest.fixture +def auth_client(api_client: APIClient, user: User) -> APIClient: + """APIClient, аутентифицированный как user.""" + api_client.force_authenticate(user=user) + return api_client + + +@pytest.fixture +def recipient(db) -> Recipient: + """Получатель с email.""" + return Recipient.objects.create(email="recipient@example.com") diff --git a/manage.py b/manage.py index d28672e..aabb818 100644 --- a/manage.py +++ b/manage.py @@ -1,5 +1,6 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py index 2b6373d..ec4de24 100644 --- a/notifications/migrations/0001_initial.py +++ b/notifications/migrations/0001_initial.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/notifications/migrations/0002_notification_owner.py b/notifications/migrations/0002_notification_owner.py new file mode 100644 index 0000000..bddabb8 --- /dev/null +++ b/notifications/migrations/0002_notification_owner.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.8 on 2026-10-18 09:43 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("notifications", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="notification", + name="owner", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to=settings.AUTH_USER_MODEL, + verbose_name="Владелец", + ), + ), + ] diff --git a/notifications/models.py b/notifications/models.py index c5da734..fd54eb9 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.db import models from recipients.models import Recipient @@ -30,6 +31,14 @@ class Notification(models.Model): status = models.CharField( max_length=30, choices=STATUS_CHOICES, default="CREATED", verbose_name="Статус" ) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="notifications", + verbose_name="Владелец", + null=True, + blank=True, + ) recipients = models.ManyToManyField( Recipient, verbose_name="Получатели", **NULLABLE ) @@ -42,11 +51,10 @@ class Meta: verbose_name = "Уведомление" verbose_name_plural = "Уведомления" - def __str__(self): - """ - Возвращает строковое представление объекта (тему уведомления). + def __str__(self) -> str: + """Возвращает тему уведомления или техническое имя. Returns: - str: Тема уведомления. + str: Тема уведомления либо строка вида ``Notification #``. """ - return self.topic + return self.topic or f"Notification #{self.pk}" diff --git a/notifications/serializers.py b/notifications/serializers.py index 223386d..1be33a1 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -1,20 +1,12 @@ -from rest_framework.serializers import ModelSerializer +from rest_framework import serializers from notifications.models import Notification -class NotificationSerializer(ModelSerializer): - """ - Сериализатор для модели уведомления Notification. - - Используется для преобразования экземпляров модели Notification в формат JSON - и обратно, а также для валидации данных. - """ +class NotificationSerializer(serializers.ModelSerializer): + """Сериализатор уведомления. Владелец назначается сервером.""" class Meta: - """ - Метаданные сериализатора NotificationSerializer. - """ - model = Notification fields = "__all__" + read_only_fields = ("owner", "status") diff --git a/notifications/strategies.py b/notifications/strategies.py index b7bab5c..0cc7df8 100644 --- a/notifications/strategies.py +++ b/notifications/strategies.py @@ -119,7 +119,6 @@ def send( for recipient in recipient_list: tg_chat_id = recipient.tg_chat_id if tg_chat_id: - payload = { "chat_id": tg_chat_id, "text": message, @@ -139,9 +138,6 @@ def send( else: # Сервер Telegram вернул ошибку, но статус HTTP был 200 OK - print( - f"Telegram message {subject} delivery failed to {recipient.pk}: {data.get('description')}" - ) logger.error( f"Telegram message {subject} delivery failed to {recipient.pk}: {data.get('description')}" ) @@ -201,7 +197,7 @@ def send( "msg": message.encode("utf-8"), "from": settings.SMS_SENDER, "json": 1, - "test": 1, # TODO: Удалить на продакшене, используется для тестирования сервиса !!! + "test": 1 if settings.SMS_TEST else 0, } try: response = requests.post( diff --git a/notifications/tasks.py b/notifications/tasks.py index 5755002..b342a98 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -1,7 +1,8 @@ import logging from celery import shared_task -from django.db import transaction +from celery.exceptions import MaxRetriesExceededError +from django.db import OperationalError, transaction from recipients.models import Recipient @@ -20,6 +21,34 @@ MAX_RETRIES: int = 5 +def _deliver(notification: Notification, recipient_ids: list[int]) -> list[int]: + """Прогоняет получателей через failover-цепочку стратегий. + + Args: + notification: уведомление, задающее тему и текст сообщения. + recipient_ids: идентификаторы получателей текущей попытки. + + Returns: + list[int]: id получателей, которым доставить не удалось. + """ + unnotified_ids = recipient_ids[:] + strategies = get_strategies_map() + for strategy_name in STRATEGY_ORDER: + recipients = Recipient.objects.filter(id__in=unnotified_ids) + if not recipients.exists(): + break + strategy = strategies[strategy_name] + logger.info(f"Strategy {strategy_name}.") + unnotified_ids = strategy.send( + subject=notification.topic, + message=notification.text, + recipient_list=recipients, + ) + if not unnotified_ids: + break + return unnotified_ids + + @shared_task( bind=True, delivery_mode=2, @@ -27,79 +56,53 @@ ignore_result=False, max_retries=MAX_RETRIES, ) -def send_notification( - self, - notification_id: int, - recipient_ids: list[int], -): - """ - Задача Celery для отправки уведомлений с использованием стратегий. - - Пытается отправить уведомление через список стратегий (email, telegram, sms) - последовательно до тех пор, пока все получатели не будут уведомлены - или не закончатся доступные стратегии. При неудаче может повторить попытку. +def send_notification(self, notification_id: int, recipient_ids: list[int]) -> None: + """Отправляет уведомление получателям с failover и retry. Args: - self (Task): Экземпляр задачи Celery (при использовании bind=True). - notification_id (int): PK уведомления для отправки. - recipient_ids (list[int]): Список PK получателей, которым нужно отправить уведомление. + notification_id: идентификатор уведомления. + recipient_ids: идентификаторы получателей текущей попытки. Raises: - self.retry: Если не удалось уведомить всех получателей или произошла ошибка БД/сети. - Notification.DoesNotExist: Если уведомление с данным ID не найдено. + MaxRetriesExceededError: если ретраи исчерпаны (терминальное состояние). """ - unnotified_recipient_ids = recipient_ids[:] + transient_db_error = False + try: - # Получаем уведомление из БД with transaction.atomic(): notification = Notification.objects.get(id=notification_id) - # Меняем статус уведомления при взятии в работу if notification.status == "CREATED": notification.status = "IN_PROGRESS" notification.save() - - # Перебираем стратегии - strategy_id = 0 - while strategy_id < len(STRATEGY_ORDER): - strategy = get_strategies_map()[STRATEGY_ORDER[strategy_id]] - logger.info(f"Strategy {STRATEGY_ORDER[strategy_id]}.") - - # Получаем объекты Recipient для текущего списка пользователей - recipients = Recipient.objects.filter(id__in=unnotified_recipient_ids) - if not recipients.exists(): - break - - unnotified_recipient_ids = strategy.send( - subject=notification.topic, - message=notification.text, - recipient_list=recipients, + except Notification.DoesNotExist: + logger.error(f"Notification {notification_id} not found, stopping task.") + return + except OperationalError as e: + logger.error(f"Notification {notification_id}: transient database error: {e}.") + transient_db_error = True + + if not transient_db_error: + try: + unnotified_recipient_ids = _deliver(notification, unnotified_recipient_ids) + except Exception as e: + logger.error( + f"Notification {notification_id}: unexpected delivery error: {e}." ) - if not unnotified_recipient_ids: - break - - strategy_id += 1 - - # Переводим успешно отправленное уведомление в статус завершенного if not unnotified_recipient_ids: notification.status = "COMPLETED" notification.save() logger.warning(f"Notification {notification.topic} completed successfully.") - # Повторяем попытку после задержки - else: - raise self.retry( - countdown=RETRY_DELAY, - args=[notification_id, unnotified_recipient_ids], - exc=Exception( - f"Failed delivery to {len(unnotified_recipient_ids)} recipients across all channels." - ), - ) - except Notification.DoesNotExist: - logger.error(f"Notification {notification_id} not found, stopping task.") - except Exception as e: + return + + try: raise self.retry( - exc=e, countdown=RETRY_DELAY, args=[notification_id, unnotified_recipient_ids], ) + except MaxRetriesExceededError: + logger.error( + f"Notification {notification_id}: max retries exceeded, giving up." + ) + raise diff --git a/recipients/tests.py b/notifications/tests/__init__.py similarity index 100% rename from recipients/tests.py rename to notifications/tests/__init__.py diff --git a/notifications/tests/test_models.py b/notifications/tests/test_models.py new file mode 100644 index 0000000..dea0027 --- /dev/null +++ b/notifications/tests/test_models.py @@ -0,0 +1,17 @@ +"""Тесты модели Notification.""" + +import pytest + +from notifications.models import Notification + + +@pytest.mark.django_db +def test_str_without_topic(): + notification = Notification.objects.create(owner=None) + assert str(notification) == f"Notification #{notification.pk}" + + +@pytest.mark.django_db +def test_str_with_topic(user): + notification = Notification.objects.create(topic="Hello", owner=user) + assert str(notification) == "Hello" diff --git a/notifications/tests/test_smoke.py b/notifications/tests/test_smoke.py new file mode 100644 index 0000000..d04f80b --- /dev/null +++ b/notifications/tests/test_smoke.py @@ -0,0 +1,8 @@ +"""Smoke-тесты доступности сервисных страниц.""" + +import pytest + + +@pytest.mark.django_db +def test_swagger_reachable(api_client): + assert api_client.get("/swagger/").status_code == 200 diff --git a/notifications/tests/test_strategies.py b/notifications/tests/test_strategies.py new file mode 100644 index 0000000..1b596c1 --- /dev/null +++ b/notifications/tests/test_strategies.py @@ -0,0 +1,115 @@ +"""Тесты стратегий отправки (внешние сервисы замоканы).""" + +from unittest.mock import patch + +import pytest + +from notifications.strategies import ( + EmailNotification, + SMSNotification, + TelegramNotification, + get_strategies_map, +) +from recipients.models import Recipient + + +@pytest.fixture +def recipients(db): + return [ + Recipient.objects.create( + email="a@example.com", tg_chat_id="111", phone="+79000000001" + ), + Recipient.objects.create( + email="b@example.com", tg_chat_id="222", phone="+79000000002" + ), + ] + + +class TestEmailNotification: + @patch("notifications.strategies.check_email_availability") + @patch("notifications.strategies.EmailMessage") + def test_success_returns_empty_list(self, mock_message, mock_check, recipients): + mock_message.return_value.send.return_value = 1 + + result = EmailNotification().send("s", "m", recipients) + + assert result == [] + assert mock_check.call_count == len(recipients) + + @patch("notifications.strategies.check_email_availability") + @patch("notifications.strategies.EmailMessage") + def test_send_returning_zero_counts_as_failure( + self, mock_message, mock_check, recipients + ): + mock_message.return_value.send.return_value = 0 + + result = EmailNotification().send("s", "m", recipients) + + assert result == [r.pk for r in recipients] + + @patch( + "notifications.strategies.check_email_availability", + side_effect=Exception("bad"), + ) + def test_exception_counts_as_failure(self, mock_check, recipients): + result = EmailNotification().send("s", "m", recipients) + + assert result == [r.pk for r in recipients] + + +class TestTelegramNotification: + @patch("notifications.strategies.requests.post") + def test_success(self, mock_post, recipients): + mock_post.return_value.json.return_value = {"ok": True} + + result = TelegramNotification().send("s", "m", recipients) + + assert result == [] + + @patch("notifications.strategies.requests.post") + def test_api_ok_false_is_failure(self, mock_post, recipients): + mock_post.return_value.json.return_value = {"ok": False, "description": "e"} + + result = TelegramNotification().send("s", "m", recipients) + + assert result == [r.pk for r in recipients] + + def test_missing_chat_id_is_failure(self, db): + recipient = Recipient.objects.create(email="nochat@example.com") + + result = TelegramNotification().send("s", "m", [recipient]) + + assert result == [recipient.pk] + + +class TestSMSNotification: + @patch("notifications.strategies.requests.post") + def test_success(self, mock_post, recipients): + mock_post.return_value.json.return_value = {"status": "OK"} + + result = SMSNotification().send("s", "m", recipients) + + assert result == [] + + @patch("notifications.strategies.requests.post") + def test_failure(self, mock_post, recipients): + mock_post.return_value.json.return_value = { + "status": "ERROR", + "sms": {"status_text": "bad"}, + } + + result = SMSNotification().send("s", "m", recipients) + + assert result == [r.pk for r in recipients] + + def test_missing_phone_is_failure(self, db): + recipient = Recipient.objects.create(email="nophone@example.com") + + result = SMSNotification().send("s", "m", [recipient]) + + assert result == [recipient.pk] + + +def test_strategies_map_has_three_channels(): + strategies = get_strategies_map() + assert set(strategies) == {"email", "telegram", "sms"} diff --git a/notifications/tests/test_tasks.py b/notifications/tests/test_tasks.py new file mode 100644 index 0000000..6884eb6 --- /dev/null +++ b/notifications/tests/test_tasks.py @@ -0,0 +1,125 @@ +"""Тесты задачи send_notification: failover и retry.""" + +from unittest.mock import MagicMock, patch + +import pytest +from celery.exceptions import MaxRetriesExceededError, Retry + +from notifications.models import Notification +from notifications.tasks import send_notification + + +@pytest.fixture +def notification(user): + return Notification.objects.create(topic="t", text="x", owner=user) + + +def _strategies(email, telegram, sms): + return {"email": email, "telegram": telegram, "sms": sms} + + +@patch("notifications.tasks.get_strategies_map") +def test_stops_after_first_success(mock_map, notification, recipient): + email, telegram, sms = MagicMock(), MagicMock(), MagicMock() + email.send.return_value = [] + mock_map.return_value = _strategies(email, telegram, sms) + notification.recipients.add(recipient) + + send_notification(notification.pk, [recipient.pk]) + + notification.refresh_from_db() + assert notification.status == "COMPLETED" + email.send.assert_called_once() + telegram.send.assert_not_called() + sms.send.assert_not_called() + + +@patch("notifications.tasks.get_strategies_map") +def test_failover_to_next_strategy(mock_map, notification, recipient): + email, telegram, sms = MagicMock(), MagicMock(), MagicMock() + email.send.return_value = [recipient.pk] + telegram.send.return_value = [] + mock_map.return_value = _strategies(email, telegram, sms) + notification.recipients.add(recipient) + + send_notification(notification.pk, [recipient.pk]) + + notification.refresh_from_db() + assert notification.status == "COMPLETED" + telegram.send.assert_called_once() + sms.send.assert_not_called() + + +@patch("notifications.tasks.get_strategies_map") +def test_retry_only_for_failed_recipients(mock_map, notification, recipient, db): + from recipients.models import Recipient + + second = Recipient.objects.create(email="second@example.com") + email, telegram, sms = MagicMock(), MagicMock(), MagicMock() + email.send.return_value = [second.pk] + telegram.send.return_value = [] + mock_map.return_value = _strategies(email, telegram, sms) + notification.recipients.add(recipient, second) + + send_notification(notification.pk, [recipient.pk, second.pk]) + + notified_ids = set( + telegram.send.call_args.kwargs["recipient_list"].values_list("id", flat=True) + ) + assert notified_ids == {second.pk} + + +@patch("notifications.tasks.get_strategies_map") +def test_all_failed_raises_retry(mock_map, notification, recipient): + email, telegram, sms = MagicMock(), MagicMock(), MagicMock() + email.send.return_value = [recipient.pk] + telegram.send.return_value = [recipient.pk] + sms.send.return_value = [recipient.pk] + mock_map.return_value = _strategies(email, telegram, sms) + notification.recipients.add(recipient) + + with pytest.raises(Retry): + send_notification.apply(args=(notification.pk, [recipient.pk]), throw=True) + + notification.refresh_from_db() + assert notification.status == "IN_PROGRESS" + + +@patch("notifications.tasks.get_strategies_map") +def test_exhausted_retries_are_reraised(mock_map, notification, recipient, caplog): + email, telegram, sms = MagicMock(), MagicMock(), MagicMock() + email.send.return_value = [recipient.pk] + telegram.send.return_value = [recipient.pk] + sms.send.return_value = [recipient.pk] + mock_map.return_value = _strategies(email, telegram, sms) + notification.recipients.add(recipient) + + with patch.object( + send_notification, "retry", side_effect=MaxRetriesExceededError() + ): + with pytest.raises(MaxRetriesExceededError): + send_notification(notification.pk, [recipient.pk]) + + assert any( + record.levelname == "ERROR" and "max retries exceeded" in record.message + for record in caplog.records + ) + + +def test_missing_notification_is_logged_not_raised(db): + send_notification(999999, [1]) # не должно поднимать исключение + + +@patch("notifications.tasks.Notification.objects.get") +def test_transient_db_error_triggers_retry(mock_get, db, caplog): + from django.db import OperationalError + + mock_get.side_effect = OperationalError("connection lost") + + with pytest.raises(Retry): + send_notification(999999, [1]) + + assert any( + record.levelname == "ERROR" and "database error" in record.message + for record in caplog.records + ) diff --git a/notifications/tests/test_utils.py b/notifications/tests/test_utils.py new file mode 100644 index 0000000..6f5592e --- /dev/null +++ b/notifications/tests/test_utils.py @@ -0,0 +1,56 @@ +"""Тесты проверки доступности email.""" + +import socket +from unittest.mock import MagicMock, patch + +import pytest + +from notifications.utils import check_email_availability + + +@patch("notifications.utils.dns.resolver.resolve") +@patch("notifications.utils.smtplib.SMTP") +def test_valid_email_returns_none(mock_smtp_cls, mock_resolve): + mock_resolve.return_value = [MagicMock(exchange=MagicMock())] + mock_resolve.return_value[0].exchange.to_text.return_value = "mx.example.com" + server = mock_smtp_cls.return_value + server.helo.return_value = (250, b"ok") + server.rcpt.return_value = (250, b"ok") + + result = check_email_availability("user@example.com") + + assert result is None + + +@patch("notifications.utils.dns.resolver.resolve") +def test_invalid_email_raises(mock_resolve): + mock_resolve.side_effect = Exception("no dns") + + # noqa: B017 — контракт функции (plan-frozen) — слепой Exception + with pytest.raises(Exception): # noqa: B017 + check_email_availability("user@example.com") + + +@patch("notifications.utils.dns.resolver.resolve") +@patch("notifications.utils.smtplib.SMTP") +def test_helo_rejection_raises(mock_smtp_cls, mock_resolve): + mock_resolve.return_value = [MagicMock(exchange=MagicMock())] + mock_resolve.return_value[0].exchange.to_text.return_value = "mx.example.com" + server = mock_smtp_cls.return_value + server.helo.return_value = (550, b"rejected") + + # noqa: B017 — контракт функции (plan-frozen) — слепой Exception + with pytest.raises(Exception): # noqa: B017 + check_email_availability("user@example.com") + + +@patch("notifications.utils.dns.resolver.resolve") +def test_does_not_mutate_global_socket_timeout(mock_resolve): + mock_resolve.side_effect = Exception("no dns") + default_before = socket.getdefaulttimeout() + + # noqa: B017 — контракт функции (plan-frozen) — слепой Exception + with pytest.raises(Exception): # noqa: B017 + check_email_availability("user@example.com", timeout=1) + + assert socket.getdefaulttimeout() == default_before diff --git a/notifications/tests/test_views.py b/notifications/tests/test_views.py new file mode 100644 index 0000000..5982c38 --- /dev/null +++ b/notifications/tests/test_views.py @@ -0,0 +1,85 @@ +"""Тесты API уведомлений.""" + +from unittest.mock import patch + +import pytest + +from notifications.models import Notification + + +def test_create_requires_auth(api_client): + response = api_client.post( + "/notifications/create/", {"topic": "t", "text": "x"}, format="json" + ) + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_create_sets_owner_and_dispatches_task(auth_client, user, recipient): + with patch("notifications.views.send_notification.delay") as mock_delay: + response = auth_client.post( + "/notifications/create/", + {"topic": "t", "text": "x", "recipients": [recipient.pk]}, + format="json", + ) + + assert response.status_code == 201 + notification = Notification.objects.get() + assert notification.owner_id == user.pk + + mock_delay.assert_called_once_with(notification.pk, [recipient.pk]) + task_id, recipient_ids = mock_delay.call_args.args + assert isinstance(task_id, int) + assert recipient_ids == [recipient.pk] + assert all(isinstance(item, int) for item in recipient_ids) + + +@pytest.mark.django_db +def test_status_is_read_only_for_client(auth_client, user, recipient): + with patch("notifications.views.send_notification.delay"): + response = auth_client.post( + "/notifications/create/", + { + "topic": "t", + "text": "x", + "status": "COMPLETED", + "recipients": [recipient.pk], + }, + format="json", + ) + + assert response.status_code == 201 + notification = Notification.objects.get() + assert notification.status == "CREATED" + + +@pytest.mark.django_db +def test_list_returns_only_own_notifications(auth_client, user, other_user): + Notification.objects.create(topic="mine", owner=user) + Notification.objects.create(topic="foreign", owner=other_user) + + response = auth_client.get("/notifications/list/") + + assert response.status_code == 200 + body = response.json() + results = body["results"] if isinstance(body, dict) else body + assert [item["topic"] for item in results] == ["mine"] + + +@pytest.mark.django_db +def test_retrieve_own_notification(auth_client, user): + notification = Notification.objects.create(topic="mine", owner=user) + + response = auth_client.get(f"/notifications/read/{notification.pk}/") + + assert response.status_code == 200 + assert response.json()["topic"] == "mine" + + +@pytest.mark.django_db +def test_retrieve_foreign_notification_returns_404(auth_client, other_user): + notification = Notification.objects.create(topic="foreign", owner=other_user) + + response = auth_client.get(f"/notifications/read/{notification.pk}/") + + assert response.status_code == 404 diff --git a/notifications/urls.py b/notifications/urls.py index e5e509c..395c91b 100644 --- a/notifications/urls.py +++ b/notifications/urls.py @@ -1,14 +1,16 @@ from django.urls import path from notifications.apps import NotificationsConfig -from notifications.views import (NotificationCreateAPIView, - NotificationListAPIView, - NotificationRetrieveAPIView) +from notifications.views import ( + NotificationCreateAPIView, + NotificationListAPIView, + NotificationRetrieveAPIView, +) app_name = NotificationsConfig.name urlpatterns = [ path("create/", NotificationCreateAPIView.as_view(), name="create"), path("list/", NotificationListAPIView.as_view(), name="list"), - path("read/", NotificationRetrieveAPIView.as_view(), name="read"), + path("read//", NotificationRetrieveAPIView.as_view(), name="read"), ] diff --git a/notifications/utils.py b/notifications/utils.py index 7584f60..0e67564 100644 --- a/notifications/utils.py +++ b/notifications/utils.py @@ -1,5 +1,4 @@ import smtplib -import socket import dns.resolver @@ -25,8 +24,6 @@ def check_email_availability(email, sender_email=EMAIL_HOST_USER, timeout=10): """ domain = email.split("@")[1] - # Установка общего таймаута для всех операций с сокетами - socket.setdefaulttimeout(timeout) try: # Получаем MX-записи домена @@ -52,7 +49,7 @@ def check_email_availability(email, sender_email=EMAIL_HOST_USER, timeout=10): f"Email is likely invalid/unavailable. Code {code}: {message.decode()}" ) - except smtplib.SMTPConnectError as e: + except smtplib.SMTPConnectError: # Попытка подключения через порт 587 с STARTTLS server = smtplib.SMTP(mx_record, 587, timeout=timeout) server.set_debuglevel(0) @@ -64,17 +61,16 @@ def check_email_availability(email, sender_email=EMAIL_HOST_USER, timeout=10): code, message = server.rcpt(str(email)) if code == 250: return - raise Exception(f"Email check failed using STARTTLS.") - - except Exception as e: - return f"Произошла ошибка при работе с SMTP-сервером: {e}" + raise Exception("Email check failed using STARTTLS.") from None finally: if "server" in locals() and server: server.quit() except dns.resolver.NoMXSError: - raise Exception("Домен не имеет MX-записей.") + raise Exception("Домен не имеет MX-записей.") from None except dns.resolver.NXDOMAIN: - raise Exception("Домен не существует.") + raise Exception("Домен не существует.") from None except Exception as e: - raise Exception(f"Произошла ошибка при разрешении DNS или другая ошибка: {e}") + raise Exception( + f"Произошла ошибка при разрешении DNS или другая ошибка: {e}" + ) from e diff --git a/notifications/views.py b/notifications/views.py index 46cc25a..2c7563d 100644 --- a/notifications/views.py +++ b/notifications/views.py @@ -7,53 +7,33 @@ class NotificationCreateAPIView(CreateAPIView): - """ - Контроллер API для создания нового уведомления. - - Предоставляет конечную точку для создания уведомления. После успешного - создания запускает асинхронную задачу Celery на отправку этого уведомления. - - Attributes: - serializer_class (class): Сериализатор, используемый для валидации и десериализации данных. - permission_classes (tuple): Требуемые классы разрешений (только для аутентифицированных). - """ + """Создание уведомления и постановка задачи на отправку.""" serializer_class = NotificationSerializer permission_classes = (IsAuthenticated,) - def perform_create(self, serializer): - """ - Сохраняет новый объект уведомления и запускает фоновую задачу отправки. - - Args: - serializer (NotificationSerializer): Экземпляр сериализатора с валидными данными. - """ - notification = serializer.save() - + def perform_create(self, serializer) -> None: + """Сохраняет уведомление владельцем-текущим пользователем и ставит задачу.""" + notification = serializer.save(owner=self.request.user) recipient_ids = list(notification.recipients.values_list("id", flat=True)) send_notification.delay(notification.pk, recipient_ids) class NotificationRetrieveAPIView(RetrieveAPIView): - """ - Контроллер API для просмотра деталей конкретного уведомления. - - Предоставляет конечную точку для получения информации по одному уведомлению - по его PK/ID. - """ + """Просмотр уведомления, принадлежащего текущему пользователю.""" serializer_class = NotificationSerializer permission_classes = (IsAuthenticated,) - queryset = Notification.objects.all() + def get_queryset(self): + return Notification.objects.filter(owner=self.request.user) -class NotificationListAPIView(ListAPIView): - """ - Контроллер API для просмотра списка всех уведомлений. - Предоставляет конечную точку для получения списка всех доступных уведомлений. - """ +class NotificationListAPIView(ListAPIView): + """Список уведомлений текущего пользователя.""" serializer_class = NotificationSerializer permission_classes = (IsAuthenticated,) - queryset = Notification.objects.all() + + def get_queryset(self): + return Notification.objects.filter(owner=self.request.user) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f9dd5d7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[tool.ruff] +line-length = 88 +target-version = "py312" +extend-exclude = ["docs"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = ["E501"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings" +python_files = ["test_*.py"] +addopts = "-ra --strict-markers" + +[tool.coverage.run] +source = ["config", "users", "notifications", "recipients"] +omit = [ + "*/migrations/*", + "*/tests/*", + "manage.py", + "config/asgi.py", + "config/wsgi.py", + "*/__init__.py", +] + +[tool.coverage.report] +show_missing = true \ No newline at end of file diff --git a/recipients/migrations/0001_initial.py b/recipients/migrations/0001_initial.py index 8f68f88..745e79b 100644 --- a/recipients/migrations/0001_initial.py +++ b/recipients/migrations/0001_initial.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [] diff --git a/users/tests.py b/recipients/tests/__init__.py similarity index 100% rename from users/tests.py rename to recipients/tests/__init__.py diff --git a/recipients/tests/test_serializers.py b/recipients/tests/test_serializers.py new file mode 100644 index 0000000..0ff4fd7 --- /dev/null +++ b/recipients/tests/test_serializers.py @@ -0,0 +1,16 @@ +"""Тесты сериализатора получателя.""" + +import pytest + +from recipients.models import Recipient +from recipients.serializers import RecipientSerializer + + +@pytest.mark.django_db +def test_email_must_be_unique(): + Recipient.objects.create(email="dup@example.com") + + serializer = RecipientSerializer(data={"email": "dup@example.com"}) + + assert not serializer.is_valid() + assert "email" in serializer.errors diff --git a/recipients/tests/test_views.py b/recipients/tests/test_views.py new file mode 100644 index 0000000..e869272 --- /dev/null +++ b/recipients/tests/test_views.py @@ -0,0 +1,26 @@ +"""Smoke-тесты CRUD получателей.""" + +import pytest + + +def test_requires_auth(api_client): + assert api_client.get("/recipients/").status_code == 401 + + +@pytest.mark.django_db +def test_crud_flow(auth_client): + created = auth_client.post( + "/recipients/", {"email": "crud@example.com"}, format="json" + ) + assert created.status_code == 201 + pk = created.json()["id"] + + assert auth_client.get(f"/recipients/{pk}/").status_code == 200 + + updated = auth_client.patch( + f"/recipients/{pk}/", {"first_name": "John"}, format="json" + ) + assert updated.status_code == 200 + assert updated.json()["first_name"] == "John" + + assert auth_client.delete(f"/recipients/{pk}/").status_code == 204 diff --git a/recipients/views.py b/recipients/views.py index 0506793..ff9a306 100644 --- a/recipients/views.py +++ b/recipients/views.py @@ -20,7 +20,7 @@ ), ) @method_decorator( - name="read", + name="retrieve", decorator=swagger_auto_schema( operation_description="Контроллер просмотра получателя." ), diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..896a63a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +pytest>=8,<9 +pytest-django>=4.8,<5 +pytest-cov>=6,<7 +ruff>=0.6,<1 diff --git a/requirements.txt b/requirements.txt index 20b8329..03c1da0 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/users/management/commands/csu.py b/users/management/commands/csu.py index b04864c..b771e2e 100644 --- a/users/management/commands/csu.py +++ b/users/management/commands/csu.py @@ -9,13 +9,14 @@ class Command(BaseCommand): """ Команда Django management для создания администратора (суперпользователя). - Позволяет создать суперпользователя на основе переменных окружения - DJANGO_SUPERUSER_EMAIL и DJANGO_SUPERUSER_PASSWORD, или используя - значения по умолчанию, если переменные не заданы. + Создаёт суперпользователя только на основе переменных окружения + DJANGO_SUPERUSER_EMAIL и DJANGO_SUPERUSER_PASSWORD; если переменные + не заданы, выводит предупреждение и завершается без создания. """ help = ( - "Создает суперпользователя из переменных окружения или значений по умолчанию." + "Создает суперпользователя из переменных окружения " + "DJANGO_SUPERUSER_EMAIL и DJANGO_SUPERUSER_PASSWORD." ) def handle(self, *args, **kwargs): @@ -31,17 +32,16 @@ def handle(self, *args, **kwargs): email = os.environ.get("DJANGO_SUPERUSER_EMAIL") password = os.environ.get("DJANGO_SUPERUSER_PASSWORD") if not email or not password: - email = "admin@admin.com" - password = "admin-01" + self.stdout.write( + self.style.WARNING( + "DJANGO_SUPERUSER_EMAIL/PASSWORD не заданы — суперпользователь не создан." + ) + ) + return # Прерываем выполнение команды, если пользователь с таким email уже есть if User.objects.filter(email=email).exists(): return # Создаем пользователя - user = User.objects.create(email=email) - user.set_password(password) - user.is_superuser = True - user.is_staff = True - user.is_active = True - user.save() + User.objects.create_superuser(email=email, password=password) diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index fdac314..f02eb21 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/users/models.py b/users/models.py index 68fd0ac..a0151f7 100644 --- a/users/models.py +++ b/users/models.py @@ -1,9 +1,44 @@ -from django.contrib.auth.models import AbstractUser +from typing import Any + +from django.contrib.auth.models import AbstractUser, BaseUserManager from django.db import models NULLABLE = {"blank": True, "null": True} +class UserManager(BaseUserManager): + """Менеджер пользователей с email в качестве логина.""" + + def _create_user( + self, email: str, password: str | None, **extra_fields: Any + ) -> "User": + if not email: + raise ValueError("Email обязателен.") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user( + self, email: str, password: str | None = None, **extra_fields: Any + ) -> "User": + 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: str, password: str, **extra_fields: Any + ) -> "User": + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", 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: + raise ValueError("Superuser must have is_superuser=True.") + return self._create_user(email, password, **extra_fields) + + class User(AbstractUser): """ Модель пользователя. @@ -22,6 +57,8 @@ class User(AbstractUser): USERNAME_FIELD = "email" REQUIRED_FIELDS = [] + objects = UserManager() + class Meta: """ Метаданные модели User. diff --git a/users/serializers.py b/users/serializers.py index bbf6f64..0bfcb35 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,63 +1,57 @@ -from rest_framework.serializers import ModelSerializer +from django.contrib.auth.password_validation import validate_password +from rest_framework import serializers from users.models import User -class UserSerializer(ModelSerializer): - """ - Сериализатор пользователя с ограничением информации (минимальный набор полей). +class UserSerializer(serializers.ModelSerializer): + """Сериализатор регистрации/краткой информации о пользователе.""" - Используется для предоставления базовой информации о пользователе, - включая только ID и email. - """ + password = serializers.CharField(write_only=True, validators=[validate_password]) class Meta: - """ - Метаданные сериализатора UserSerializer. - """ - model = User - fields = ("id", "email") + fields = ("id", "email", "password") + def create(self, validated_data: dict) -> User: + """Создаёт пользователя с хешированным паролем.""" + password = validated_data.pop("password") + user = User(**validated_data) + user.set_password(password) + user.save() + return user -class UserDetailSerializer(ModelSerializer): - """ - Сериализатор профиля пользователя. - Используется для просмотра и обновления полного профиля пользователя, - за исключением полей, управляемых автоматически (статусы, даты). - """ +class UserDetailSerializer(serializers.ModelSerializer): + """Полный профиль пользователя без поля пароля.""" class Meta: - """ - Метаданные сериализатора UserDetailSerializer. - """ - model = User - fields = "__all__" + fields = ( + "id", + "email", + "first_name", + "last_name", + "is_active", + "is_staff", + "is_superuser", + "date_joined", + "last_login", + ) read_only_fields = ( "id", "is_staff", "is_superuser", "is_active", "date_joined", + "last_login", ) -class UserAdminSerializer(ModelSerializer): - """ - Сериализатор пользователя для администратора. - - Предоставляет расширенный набор полей для управления пользователем - администратором, позволяя изменять статусы активности и привилегий, - но не ID, email или дату регистрации. - """ +class UserAdminSerializer(serializers.ModelSerializer): + """Сериализатор управления пользователем администратором.""" class Meta: - """ - Метаданные сериализатора UserAdminSerializer. - """ - model = User fields = ("id", "email", "is_staff", "is_superuser", "is_active", "date_joined") read_only_fields = ("id", "email", "date_joined") diff --git a/users/tests/__init__.py b/users/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/tests/test_commands.py b/users/tests/test_commands.py new file mode 100644 index 0000000..89eaf0f --- /dev/null +++ b/users/tests/test_commands.py @@ -0,0 +1,28 @@ +"""Тесты команды csu.""" + +import pytest +from django.core.management import call_command + +from users.models import User + + +@pytest.mark.django_db +def test_skips_when_env_missing(monkeypatch): + monkeypatch.delenv("DJANGO_SUPERUSER_EMAIL", raising=False) + monkeypatch.delenv("DJANGO_SUPERUSER_PASSWORD", raising=False) + + call_command("csu") + + assert not User.objects.filter(is_superuser=True).exists() + + +@pytest.mark.django_db +def test_creates_superuser_from_env(monkeypatch): + monkeypatch.setenv("DJANGO_SUPERUSER_EMAIL", "boss@example.com") + monkeypatch.setenv("DJANGO_SUPERUSER_PASSWORD", "Str0ng-pass-1") + + call_command("csu") + + user = User.objects.get(email="boss@example.com") + assert user.is_superuser and user.is_staff + assert user.check_password("Str0ng-pass-1") diff --git a/users/tests/test_serializers.py b/users/tests/test_serializers.py new file mode 100644 index 0000000..ff38a6a --- /dev/null +++ b/users/tests/test_serializers.py @@ -0,0 +1,30 @@ +"""Тесты сериализаторов пользователя.""" + +import pytest + +from users.serializers import UserDetailSerializer, UserSerializer + + +@pytest.mark.django_db +def test_create_user_hashes_password(): + serializer = UserSerializer( + data={"email": "new@example.com", "password": "Str0ng-pass-1"} + ) + assert serializer.is_valid(), serializer.errors + + user = serializer.save() + + assert user.password != "Str0ng-pass-1" + assert user.check_password("Str0ng-pass-1") + + +@pytest.mark.django_db +def test_create_user_rejects_weak_password(): + serializer = UserSerializer(data={"email": "weak@example.com", "password": "123"}) + assert not serializer.is_valid() + assert "password" in serializer.errors + + +def test_detail_serializer_excludes_password(user): + data = UserDetailSerializer(user).data + assert "password" not in data diff --git a/users/tests/test_views.py b/users/tests/test_views.py new file mode 100644 index 0000000..71822b3 --- /dev/null +++ b/users/tests/test_views.py @@ -0,0 +1,28 @@ +"""Smoke-тесты аутентификации.""" + +import pytest + + +@pytest.mark.django_db +def test_register_login_refresh(api_client): + register = api_client.post( + "/users/register/", + {"email": "smoke@example.com", "password": "Str0ng-pass-1"}, + format="json", + ) + assert register.status_code == 201 + + login = api_client.post( + "/users/login/", + {"email": "smoke@example.com", "password": "Str0ng-pass-1"}, + format="json", + ) + assert login.status_code == 200 + tokens = login.json() + assert "access" in tokens and "refresh" in tokens + + refresh = api_client.post( + "/users/token-refresh/", {"refresh": tokens["refresh"]}, format="json" + ) + assert refresh.status_code == 200 + assert "access" in refresh.json() diff --git a/users/urls.py b/users/urls.py index 3a72014..0c5b323 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,7 +1,6 @@ from django.urls import path from rest_framework.permissions import AllowAny -from rest_framework_simplejwt.views import (TokenObtainPairView, - TokenRefreshView) +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from users.apps import UsersConfig from users.views import UserCreateAPIView diff --git a/users/views.py b/users/views.py index a61c9a0..e0e09ea 100644 --- a/users/views.py +++ b/users/views.py @@ -1,11 +1,14 @@ from rest_framework.exceptions import PermissionDenied -from rest_framework.generics import (CreateAPIView, ListAPIView, - RetrieveAPIView, UpdateAPIView) +from rest_framework.generics import ( + CreateAPIView, + ListAPIView, + RetrieveAPIView, + UpdateAPIView, +) from rest_framework.permissions import AllowAny, IsAuthenticated from users.models import User -from users.serializers import (UserAdminSerializer, UserDetailSerializer, - UserSerializer) +from users.serializers import UserAdminSerializer, UserDetailSerializer, UserSerializer class UserCreateAPIView(CreateAPIView): @@ -13,7 +16,7 @@ class UserCreateAPIView(CreateAPIView): Контроллер API для регистрации нового пользователя. Предоставляет конечную точку для создания нового пользователя с минимальным - набором полей (email). Устанавливает хешированный пароль после создания объекта. + набором полей (email). Пароль хешируется в сериализаторе. Attributes: serializer_class (class): Используемый сериализатор (UserSerializer). @@ -23,17 +26,6 @@ class UserCreateAPIView(CreateAPIView): serializer_class = UserSerializer permission_classes = (AllowAny,) - def perform_create(self, serializer): - """ - Сохраняет нового пользователя и хеширует его пароль. - - Args: - serializer (UserSerializer): Экземпляр сериализатора с валидными данными. - """ - user = serializer.save() - user.set_password(user.password) - user.save() - class UserListAPIView(ListAPIView): """