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
- ```
-
----
-### Структура базы данных:
-
-
-
----
-### Функционал и бизнес-логика
-
-1. Пользователи:
- - Регистрация с email и паролем
- - Получение токена для авторизация
- - ОБновление токена авторизации
-
-
-
-
-
-2. Получатели
- - Создание получателя
- - Просмотр получателя
- - Просмотр списка получателей
- - Изменение получтаеля
- - Удаление получателя
-
-
-
-
-
-3. Уведомления
- - Создание уведомления
- - Просмотр уведомления
- - Просмотр списка уведомлений
-
-
-
-
- При создания уведомления создается задача по отправке уведомления.
- Происходит выбор стратегии отпраки уведомления в следующем порядке:
- - Email
- - Telegram
- - SMS
-
- В случае, если первая стратегия не сработала, переходим ко второй, далее к третьей.
- Если ни одна страегия отправки для каких-либо получателей не сработала, повторяем попытку отправки для этих получателей через определенный временной интервал.
-
- ---
-Сервис подходит для отправки разовых уведомлений. Если нужны повторяющиеся уведомления, возможна доработка.
-
-В проекте настроено логирование отправки уведомлений с отправкой логов в консоль и с сохранением в файл.
-
-Проведено ручное тестирование проекта с помощью Postman.
-
-Автоматизированное тестирование не проводилось.
-
-Для более подробной информации об API ознакомьтесь с интерактивной документацией.
-
-Если проект запущен на localhost, пройдите по [ссылке](http://localhost:8000/swagger/)
-
-
+# Notification Service
+
+[](https://github.com/annaputilovskaya/NotificationService/actions/workflows/ci.yml)
+[](https://github.com/annaputilovskaya/NotificationService/actions/workflows/ci.yml)
+[](./LICENSE)
+[](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.
+
+
+
+- **Recipients** — create, view, update, and delete recipients.
+
+
+
+- **Notifications** — create a notification, view a single notification and a list.
+
+
+
+- **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
+
+
+
+## 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 @@
+# Сервис уведомлений
+
+[](https://github.com/annaputilovskaya/NotificationService/actions/workflows/ci.yml)
+[](https://github.com/annaputilovskaya/NotificationService/actions/workflows/ci.yml)
+[](./LICENSE)
+[](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):
"""