From 16577170eb32c49a6e9cccb03b5b865ec91bb6be Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 11:36:15 +0300 Subject: [PATCH 01/25] chore: add pytest/ruff/coverage config, fixtures, and __pycache__ ignore --- .gitignore | 5 ++++- conftest.py | 42 ++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 26 ++++++++++++++++++++++++++ requirements-dev.txt | 4 ++++ requirements.txt | Bin 634 -> 302 bytes 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 conftest.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt 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/conftest.py b/conftest.py new file mode 100644 index 0000000..475c8b6 --- /dev/null +++ b/conftest.py @@ -0,0 +1,42 @@ +"""Общие фикстуры 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..43cc9f1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[tool.ruff] +line-length = 88 +target-version = "py312" + +[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/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 20b832907bfafc597e27ca58a58beca046276365..03c1da0991fb419b4cfd42bdeebc72b671484250 100644 GIT binary patch literal 302 zcmY+A!H$D43`Fnwmsm|G6?^b)|6>)1p|p@>ov;;uUsKwudmPPp^u`@c#4BPAHB{>R z;=&L&Wua02p>!8H>oYk|oDq-k1&5cF&M{C}=9XCZBTXK$f$Hru<e)2e{i7vOYlSnA!v=*sH6qK!zd%e0mAR4d+j8WG(x67VCst=MpjfD^e6HKF0 vUb~nEtJ^WSZK;(3a=o`}9}9)QN2#DO{Vu{RGc8mWYE>c!=j9_O`Hxk1!9rne literal 634 zcmZ`$+irqD6r5+1e#)8x+SrG_`5z4`TD-t^0gXSe&Y6{#CNyLTWaoC~+;3|XIPwHs z@xf<~sZNF$Ot8crIV$W>|JJtp7aBQiTc7^jGv7xO)>+PFaWAAaprk82< TxiGDEV2-9`z1R+{zf|`RDG_8K From 8bdc389fce7a0503ff4f6cc1021e477b71657651 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 11:57:46 +0300 Subject: [PATCH 02/25] fix: correct recipients swagger decorator method name --- recipients/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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="Контроллер просмотра получателя." ), From f7234ab576874a34b6fb40a058055248042651e4 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 11:58:19 +0300 Subject: [PATCH 03/25] fix: env-driven settings, pagination, and real Swagger metadata --- .env.example | 6 ++++++ config/settings.py | 17 +++++++++++++---- config/tests/__init__.py | 0 config/tests/test_settings.py | 27 +++++++++++++++++++++++++++ config/urls.py | 11 ++++++----- 5 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 config/tests/__init__.py create mode 100644 config/tests/test_settings.py 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/config/settings.py b/config/settings.py index 4b63da4..0806c51 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,6 +173,7 @@ # 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" diff --git a/config/tests/__init__.py b/config/tests/__init__.py new file mode 100644 index 0000000..e69de29 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=[ From 5a9160f1d6518f1ca2b5902755e850dd6f7c0a40 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 12:06:02 +0300 Subject: [PATCH 04/25] fix: hash passwords via serializer, add email-based UserManager --- users/models.py | 35 +++++++++++++- users/serializers.py | 66 +++++++++++++-------------- users/{tests.py => tests/__init__.py} | 0 users/tests/test_serializers.py | 30 ++++++++++++ users/views.py | 13 +----- 5 files changed, 96 insertions(+), 48 deletions(-) rename users/{tests.py => tests/__init__.py} (100%) create mode 100644 users/tests/test_serializers.py diff --git a/users/models.py b/users/models.py index 68fd0ac..5b61ee7 100644 --- a/users/models.py +++ b/users/models.py @@ -1,9 +1,40 @@ -from django.contrib.auth.models import AbstractUser +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 + ) -> "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 + ) -> "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) -> "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 +53,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..7df6a57 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,63 +1,59 @@ -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) -> 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.py b/users/tests/__init__.py similarity index 100% rename from users/tests.py rename to users/tests/__init__.py 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/views.py b/users/views.py index a61c9a0..905b189 100644 --- a/users/views.py +++ b/users/views.py @@ -13,7 +13,7 @@ class UserCreateAPIView(CreateAPIView): Контроллер API для регистрации нового пользователя. Предоставляет конечную точку для создания нового пользователя с минимальным - набором полей (email). Устанавливает хешированный пароль после создания объекта. + набором полей (email). Пароль хешируется в сериализаторе. Attributes: serializer_class (class): Используемый сериализатор (UserSerializer). @@ -23,17 +23,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): """ From 9a140b89bbd3381468cf3e4604987278dda5f7d4 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 12:20:21 +0300 Subject: [PATCH 05/25] fix: add type hints to user manager and serializer, apply ruff format --- users/models.py | 10 +++++++--- users/serializers.py | 6 ++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/users/models.py b/users/models.py index 5b61ee7..a0151f7 100644 --- a/users/models.py +++ b/users/models.py @@ -1,3 +1,5 @@ +from typing import Any + from django.contrib.auth.models import AbstractUser, BaseUserManager from django.db import models @@ -8,7 +10,7 @@ class UserManager(BaseUserManager): """Менеджер пользователей с email в качестве логина.""" def _create_user( - self, email: str, password: str | None, **extra_fields + self, email: str, password: str | None, **extra_fields: Any ) -> "User": if not email: raise ValueError("Email обязателен.") @@ -19,13 +21,15 @@ def _create_user( return user def create_user( - self, email: str, password: str | None = None, **extra_fields + 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) -> "User": + 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: diff --git a/users/serializers.py b/users/serializers.py index 7df6a57..0bfcb35 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -7,15 +7,13 @@ class UserSerializer(serializers.ModelSerializer): """Сериализатор регистрации/краткой информации о пользователе.""" - password = serializers.CharField( - write_only=True, validators=[validate_password] - ) + password = serializers.CharField(write_only=True, validators=[validate_password]) class Meta: model = User fields = ("id", "email", "password") - def create(self, validated_data) -> User: + def create(self, validated_data: dict) -> User: """Создаёт пользователя с хешированным паролем.""" password = validated_data.pop("password") user = User(**validated_data) From 2c6065d4bdcc82ba8aa75ce448bcfbd16c1c777a Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 12:24:13 +0300 Subject: [PATCH 06/25] test: add auth smoke tests (register/login/refresh) --- users/tests/test_views.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 users/tests/test_views.py 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() From 43c41e29ea1ac20247da6de803b6557f945a76fc Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 12:33:43 +0300 Subject: [PATCH 07/25] test: add recipient CRUD and validation tests --- recipients/{tests.py => tests/__init__.py} | 0 recipients/tests/test_serializers.py | 16 +++++++++++++ recipients/tests/test_views.py | 26 ++++++++++++++++++++++ 3 files changed, 42 insertions(+) rename recipients/{tests.py => tests/__init__.py} (100%) create mode 100644 recipients/tests/test_serializers.py create mode 100644 recipients/tests/test_views.py diff --git a/recipients/tests.py b/recipients/tests/__init__.py similarity index 100% rename from recipients/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 From 09bca588823885f7d246e25325eafc79b8c5b981 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 12:43:32 +0300 Subject: [PATCH 08/25] fix: add Notification.owner and safe __str__ --- .../migrations/0002_notification_owner.py | 21 +++++++++++++++++++ notifications/models.py | 18 +++++++++++----- notifications/{tests.py => tests/__init__.py} | 0 notifications/tests/test_models.py | 17 +++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 notifications/migrations/0002_notification_owner.py rename notifications/{tests.py => tests/__init__.py} (100%) create mode 100644 notifications/tests/test_models.py diff --git a/notifications/migrations/0002_notification_owner.py b/notifications/migrations/0002_notification_owner.py new file mode 100644 index 0000000..8b3149c --- /dev/null +++ b/notifications/migrations/0002_notification_owner.py @@ -0,0 +1,21 @@ +# 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/tests.py b/notifications/tests/__init__.py similarity index 100% rename from notifications/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" From 9cde813a9fa81c6d2dedeaa1a783e76ca70a36e1 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 12:58:35 +0300 Subject: [PATCH 09/25] fix: scope notifications to owner and repair retrieve route --- notifications/serializers.py | 16 ++------ notifications/tests/test_views.py | 66 +++++++++++++++++++++++++++++++ notifications/urls.py | 10 +++-- notifications/views.py | 44 ++++++--------------- 4 files changed, 88 insertions(+), 48 deletions(-) create mode 100644 notifications/tests/test_views.py diff --git a/notifications/serializers.py b/notifications/serializers.py index 223386d..fafc6f3 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",) diff --git a/notifications/tests/test_views.py b/notifications/tests/test_views.py new file mode 100644 index 0000000..2a366a9 --- /dev/null +++ b/notifications/tests/test_views.py @@ -0,0 +1,66 @@ +"""Тесты 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_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/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) From 58d89587d070f69b55448a18c7422fdbb26dc81a Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 13:06:09 +0300 Subject: [PATCH 10/25] fix: configurable SMS test flag, use logger instead of print --- notifications/strategies.py | 8 +- notifications/tests/test_strategies.py | 115 +++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 notifications/tests/test_strategies.py diff --git a/notifications/strategies.py b/notifications/strategies.py index b7bab5c..e0ee7fd 100644 --- a/notifications/strategies.py +++ b/notifications/strategies.py @@ -2,9 +2,9 @@ from abc import ABC, abstractmethod import requests +from django.conf import settings from django.core.mail import EmailMessage -from config import settings from config.settings import EMAIL_HOST_USER, TELEGRAM_TOKEN, TELEGRAM_URL from notifications.utils import check_email_availability from recipients.models import Recipient @@ -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/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"} From 25580606524616118375f2794d5b8adc61a9ea59 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 13:19:45 +0300 Subject: [PATCH 11/25] fix: restore original settings import in strategies --- notifications/strategies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notifications/strategies.py b/notifications/strategies.py index e0ee7fd..0cc7df8 100644 --- a/notifications/strategies.py +++ b/notifications/strategies.py @@ -2,9 +2,9 @@ from abc import ABC, abstractmethod import requests -from django.conf import settings from django.core.mail import EmailMessage +from config import settings from config.settings import EMAIL_HOST_USER, TELEGRAM_TOKEN, TELEGRAM_URL from notifications.utils import check_email_availability from recipients.models import Recipient From bbc7aa6c87d662ef70b3aee5a824441422709d51 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 14:00:08 +0300 Subject: [PATCH 12/25] fix: symmetric retry handling and single terminal retry in send_notification --- notifications/tasks.py | 114 +++++++++++++++--------------- notifications/tests/test_tasks.py | 110 ++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 57 deletions(-) create mode 100644 notifications/tests/test_tasks.py diff --git a/notifications/tasks.py b/notifications/tasks.py index 5755002..327f946 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -1,6 +1,7 @@ import logging from celery import shared_task +from celery.exceptions import MaxRetriesExceededError from django.db import 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,50 @@ 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[:] + 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, - ) - - 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.") + return + + 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: + notification.status = "COMPLETED" + notification.save() + logger.warning(f"Notification {notification.topic} completed successfully.") + return + + try: raise self.retry( - exc=e, + exc=Exception( + f"Failed delivery to {len(unnotified_recipient_ids)} recipients " + "across all channels." + ), 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/notifications/tests/test_tasks.py b/notifications/tests/test_tasks.py new file mode 100644 index 0000000..edb93de --- /dev/null +++ b/notifications/tests/test_tasks.py @@ -0,0 +1,110 @@ +"""Тесты задачи 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]) # не должно поднимать исключение From 37f7f8fa6e6c142683fb6d8096d726aa89392df9 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 16:28:16 +0300 Subject: [PATCH 13/25] fix: ensure MaxRetriesExceededError surfaces on retry exhaustion --- notifications/tasks.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/notifications/tasks.py b/notifications/tasks.py index 327f946..a6ac42f 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -91,10 +91,6 @@ def send_notification(self, notification_id: int, recipient_ids: list[int]) -> N try: raise self.retry( - exc=Exception( - f"Failed delivery to {len(unnotified_recipient_ids)} recipients " - "across all channels." - ), countdown=RETRY_DELAY, args=[notification_id, unnotified_recipient_ids], ) From 1a4d5a3cfebd6524b17bfc7d887b3372b44dbdf5 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 16:35:38 +0300 Subject: [PATCH 14/25] fix: drop process-wide socket timeout in email check --- notifications/tests/test_utils.py | 41 +++++++++++++++++++++++++++++++ notifications/utils.py | 3 --- 2 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 notifications/tests/test_utils.py diff --git a/notifications/tests/test_utils.py b/notifications/tests/test_utils.py new file mode 100644 index 0000000..2f56753 --- /dev/null +++ b/notifications/tests/test_utils.py @@ -0,0 +1,41 @@ +"""Тесты проверки доступности 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") + + with pytest.raises(Exception): + 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() + + with pytest.raises(Exception): + check_email_availability("user@example.com", timeout=1) + + assert socket.getdefaulttimeout() == default_before diff --git a/notifications/utils.py b/notifications/utils.py index 7584f60..4f40e0a 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-записи домена From 1667ca8bb9dfe46a3a49f572683f9791fa7b3415 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 16:41:00 +0300 Subject: [PATCH 15/25] fix: require env credentials for csu superuser --- users/management/commands/csu.py | 24 ++++++++++++------------ users/tests/test_commands.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 users/tests/test_commands.py 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/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") From 11996bd4ca15fbf236a5d068b82a15b87f5559cd Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 16:49:38 +0300 Subject: [PATCH 16/25] test: add swagger smoke test; apply ruff format and lint --- README.md | 8 ++++---- config/celery.py | 2 -- conftest.py | 10 +++------- manage.py | 1 + notifications/migrations/0001_initial.py | 1 - .../migrations/0002_notification_owner.py | 16 +++++++++++----- notifications/tests/test_smoke.py | 8 ++++++++ notifications/tests/test_utils.py | 6 ++++-- notifications/utils.py | 12 +++++++----- pyproject.toml | 1 + recipients/migrations/0001_initial.py | 1 - users/migrations/0001_initial.py | 1 - users/urls.py | 3 +-- users/views.py | 11 +++++++---- 14 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 notifications/tests/test_smoke.py diff --git a/README.md b/README.md index ea79fec..dd6d768 100644 --- a/README.md +++ b/README.md @@ -28,25 +28,25 @@ ### Запуск проекта: - Клонируйте проект: - ```python + ```bash git clone https://github.com/annaputilovskaya/NotificationService.git ``` - Создайте файл .env в корне проекта по образцу (Файл .env.example) и заполните его данными. Команда для Unix-подобных систем для копирования файла: - ```python + ```bash cp .env.example .env ``` - Убедитесь, что на Вашем устройстве установлен Docker. Проверка версии Docker: - ```python + ```bash docker --version ``` Если докер не установлен, [осуществите его установку по инструкции с официального сайта](https://www.docker.com/) - Осуществите сборку образов и запуск контейнеров: - ```python + ```bash docker compose up -d --build ``` 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/conftest.py b/conftest.py index 475c8b6..1f49d97 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,4 @@ -"""Общие фикстуры pytest.""" +"""Общие фикстуры pytest.""" import pytest from rest_framework.test import APIClient @@ -16,17 +16,13 @@ def api_client() -> APIClient: @pytest.fixture def user(db) -> User: """Обычный пользователь-владелец уведомлений.""" - return User.objects.create_user( - email="user@example.com", password="Str0ng-pass-1" - ) + 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" - ) + return User.objects.create_user(email="other@example.com", password="Str0ng-pass-1") @pytest.fixture 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 index 8b3149c..bddabb8 100644 --- a/notifications/migrations/0002_notification_owner.py +++ b/notifications/migrations/0002_notification_owner.py @@ -6,16 +6,22 @@ class Migration(migrations.Migration): - dependencies = [ - ('notifications', '0001_initial'), + ("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='Владелец'), + 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/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_utils.py b/notifications/tests/test_utils.py index 2f56753..52e6cf1 100644 --- a/notifications/tests/test_utils.py +++ b/notifications/tests/test_utils.py @@ -26,7 +26,8 @@ def test_valid_email_returns_none(mock_smtp_cls, mock_resolve): def test_invalid_email_raises(mock_resolve): mock_resolve.side_effect = Exception("no dns") - with pytest.raises(Exception): + # noqa: B017 — контракт функции (plan-frozen) — слепой Exception + with pytest.raises(Exception): # noqa: B017 check_email_availability("user@example.com") @@ -35,7 +36,8 @@ def test_does_not_mutate_global_socket_timeout(mock_resolve): mock_resolve.side_effect = Exception("no dns") default_before = socket.getdefaulttimeout() - with pytest.raises(Exception): + # 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/utils.py b/notifications/utils.py index 4f40e0a..120bd21 100644 --- a/notifications/utils.py +++ b/notifications/utils.py @@ -49,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) @@ -61,7 +61,7 @@ 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.") + raise Exception("Email check failed using STARTTLS.") from None except Exception as e: return f"Произошла ошибка при работе с SMTP-сервером: {e}" @@ -70,8 +70,10 @@ def check_email_availability(email, sender_email=EMAIL_HOST_USER, timeout=10): 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/pyproject.toml b/pyproject.toml index 43cc9f1..f9dd5d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,7 @@ [tool.ruff] line-length = 88 target-version = "py312" +extend-exclude = ["docs"] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] 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/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/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 905b189..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): From 929d674246edc1bd98e71d1d4abea95a8a63e89a Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 17:27:56 +0300 Subject: [PATCH 17/25] fix: surface SMTP check errors, retry on transient DB errors, make status read-only --- notifications/serializers.py | 2 +- notifications/tasks.py | 31 +++++++++++++++++++------------ notifications/tests/test_tasks.py | 15 +++++++++++++++ notifications/tests/test_utils.py | 13 +++++++++++++ notifications/tests/test_views.py | 19 +++++++++++++++++++ notifications/utils.py | 3 --- 6 files changed, 67 insertions(+), 16 deletions(-) diff --git a/notifications/serializers.py b/notifications/serializers.py index fafc6f3..1be33a1 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -9,4 +9,4 @@ class NotificationSerializer(serializers.ModelSerializer): class Meta: model = Notification fields = "__all__" - read_only_fields = ("owner",) + read_only_fields = ("owner", "status") diff --git a/notifications/tasks.py b/notifications/tasks.py index a6ac42f..b342a98 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -2,7 +2,7 @@ from celery import shared_task from celery.exceptions import MaxRetriesExceededError -from django.db import transaction +from django.db import OperationalError, transaction from recipients.models import Recipient @@ -67,6 +67,7 @@ def send_notification(self, notification_id: int, recipient_ids: list[int]) -> N MaxRetriesExceededError: если ретраи исчерпаны (терминальное состояние). """ unnotified_recipient_ids = recipient_ids[:] + transient_db_error = False try: with transaction.atomic(): @@ -77,17 +78,23 @@ def send_notification(self, notification_id: int, recipient_ids: list[int]) -> N except Notification.DoesNotExist: logger.error(f"Notification {notification_id} not found, stopping task.") return - - 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: - notification.status = "COMPLETED" - notification.save() - logger.warning(f"Notification {notification.topic} completed successfully.") - 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: + notification.status = "COMPLETED" + notification.save() + logger.warning(f"Notification {notification.topic} completed successfully.") + return try: raise self.retry( diff --git a/notifications/tests/test_tasks.py b/notifications/tests/test_tasks.py index edb93de..6884eb6 100644 --- a/notifications/tests/test_tasks.py +++ b/notifications/tests/test_tasks.py @@ -108,3 +108,18 @@ def test_exhausted_retries_are_reraised(mock_map, notification, recipient, caplo 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 index 52e6cf1..6f5592e 100644 --- a/notifications/tests/test_utils.py +++ b/notifications/tests/test_utils.py @@ -31,6 +31,19 @@ def test_invalid_email_raises(mock_resolve): 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") diff --git a/notifications/tests/test_views.py b/notifications/tests/test_views.py index 2a366a9..5982c38 100644 --- a/notifications/tests/test_views.py +++ b/notifications/tests/test_views.py @@ -34,6 +34,25 @@ def test_create_sets_owner_and_dispatches_task(auth_client, user, recipient): 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) diff --git a/notifications/utils.py b/notifications/utils.py index 120bd21..0e67564 100644 --- a/notifications/utils.py +++ b/notifications/utils.py @@ -62,9 +62,6 @@ def check_email_availability(email, sender_email=EMAIL_HOST_USER, timeout=10): if code == 250: return raise Exception("Email check failed using STARTTLS.") from None - - except Exception as e: - return f"Произошла ошибка при работе с SMTP-сервером: {e}" finally: if "server" in locals() and server: server.quit() From f8ab142d663f25b6925ded4df599d02477726823 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 19:18:11 +0300 Subject: [PATCH 18/25] docs: add MIT license --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE 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. From 6fef47334d2871bf54489cc1281715cdf60ec286 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 19:22:18 +0300 Subject: [PATCH 19/25] ci: add ruff + pytest workflow with self-generated coverage badge --- .github/workflows/ci.yml | 75 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e281fd3 --- /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: + filename: coverage.json + label: coverage + message: ${{ steps.coverage.outputs.total }} + color: ${{ steps.coverage.outputs.color }} From 648ff2c459f8c69a66180381462cad4fb87f2ead Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 19:26:00 +0300 Subject: [PATCH 20/25] docs: add Russian README per standard template --- README.ru.md | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 README.ru.md diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..679d26c --- /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///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). From d05d133421582e4c54f6010d9f0c14fdbdd666c7 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 19:29:36 +0300 Subject: [PATCH 21/25] docs: rewrite README (EN) per standard template --- README.md | 238 +++++++++++++++++++++++++++++------------------------- 1 file changed, 127 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index dd6d768..90081eb 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 - ---- -### Запуск проекта: - -- Клонируйте проект: - ```bash - git clone https://github.com/annaputilovskaya/NotificationService.git - ``` -- Создайте файл .env в корне проекта по образцу (Файл .env.example) и заполните его данными. - - Команда для Unix-подобных систем для копирования файла: - ```bash - cp .env.example .env - ``` -- Убедитесь, что на Вашем устройстве установлен Docker. - - Проверка версии Docker: - ```bash - docker --version - ``` - Если докер не установлен, [осуществите его установку по инструкции с официального сайта](https://www.docker.com/) - -- Осуществите сборку образов и запуск контейнеров: - ```bash - 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///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). From 26162aa88f922928765a0f97d2faa8c151afd090 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 21:25:22 +0300 Subject: [PATCH 22/25] chore: set real gist id for coverage badge --- .github/workflows/ci.yml | 2 +- README.md | 2 +- README.ru.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e281fd3..e66ab0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: continue-on-error: true with: auth: ${{ secrets.GIST_TOKEN }} - gistID: + gistID: 097e2cc0afbb6ee7fdbfb4d2d67bb87d filename: coverage.json label: coverage message: ${{ steps.coverage.outputs.total }} diff --git a/README.md b/README.md index 90081eb..5a8b5e5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # 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///raw/coverage.json)](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/) diff --git a/README.ru.md b/README.ru.md index 679d26c..1c86fa8 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,7 +1,7 @@ # Сервис уведомлений [![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///raw/coverage.json)](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/) From b898beccb2db2dda6f6eb3e2083ff1dc9f384347 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 21:35:05 +0300 Subject: [PATCH 23/25] fix: quote mermaid node labels for GitHub rendering --- README.md | 18 +++++++++--------- README.ru.md | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 5a8b5e5..a482725 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,15 @@ An API for reliable delivery of notifications to users across multiple channels ```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)] + 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 diff --git a/README.ru.md b/README.ru.md index 1c86fa8..c415be4 100644 --- a/README.ru.md +++ b/README.ru.md @@ -40,15 +40,15 @@ API для надёжной доставки уведомлений пользо ```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)] + 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")] ``` ## Модель данных From 7a04d83d32899fa524f6a8502ce754b91e2777d8 Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 22:01:36 +0300 Subject: [PATCH 24/25] docs: update notifications screenshot in READMEs --- README.md | 2 +- README.ru.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a482725..6d052eb 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ An API for reliable delivery of notifications to users across multiple channels - **Notifications** — create a notification, view a single notification and a list. - Notifications + 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). diff --git a/README.ru.md b/README.ru.md index c415be4..b0db662 100644 --- a/README.ru.md +++ b/README.ru.md @@ -21,7 +21,7 @@ API для надёжной доставки уведомлений пользо - **Уведомления** — создание уведомления, просмотр одного уведомления и списка. - Уведомления + Уведомления - **Надёжная доставка (failover)** — при создании уведомления ставится задача Celery. Стратегии перебираются по порядку **Email → Telegram → SMS**: если канал не сработал, используется следующий. Если ни одна стратегия не сработала для части получателей, попытка повторяется для этих получателей через заданный интервал. - **Пагинация** — эндпоинты списков отдают постраничный ответ `{"count": ..., "results": [...]}` (параметр `?page=`). From 97b9b88c32fdfd1c01ad0762dc099faad5b06e0e Mon Sep 17 00:00:00 2001 From: Anna_Putilovskaya Date: Sun, 18 Oct 2026 22:17:43 +0300 Subject: [PATCH 25/25] fix: create logs directory on settings import for fresh checkouts --- config/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/config/settings.py b/config/settings.py index 0806c51..aca1454 100644 --- a/config/settings.py +++ b/config/settings.py @@ -178,6 +178,7 @@ # 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,