From 68a4c5028bd93b7223d724e827ee2740462a1660 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sat, 13 Jun 2026 13:11:59 +0300 Subject: [PATCH 01/47] Init notification-service. --- .github/workflows/python-checks.yml | 3 +++ .gitignore | 1 + .pre-commit-config.yaml | 7 +++++++ 3 files changed, 11 insertions(+) diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index 9bf6663..394e308 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -37,6 +37,9 @@ jobs: - name: Run mypy mediaservice run: uv run mypy mediaservice + - name: Run mypy notification-service + run: uv run mypy notification-service + - name: Run mypy packages run: uv run mypy packages diff --git a/.gitignore b/.gitignore index e5e54b2..f3b1dfa 100644 --- a/.gitignore +++ b/.gitignore @@ -222,3 +222,4 @@ __marimo__/ .secrets config.local.yaml mediaservice/.env +notification-service/.env \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 00d57fc..6eeda7f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,6 +30,13 @@ repos: exclude: tests args: ["mediaservice"] + - id: mypy + alias: mypy notification-service + name: Run mypy notification-service + language: system + exclude: tests + args: ["notification-service"] + - id: mypy alias: mypy packages name: Run mypy packages From f62394206190635d95eb1deab9835bd742d10548 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sat, 13 Jun 2026 13:36:41 +0300 Subject: [PATCH 02/47] Create dockerfile to notification-service. --- notification-service/Dockerfile | 15 +++++++++++++++ notification-service/main.py | 0 2 files changed, 15 insertions(+) create mode 100644 notification-service/Dockerfile create mode 100644 notification-service/main.py diff --git a/notification-service/Dockerfile b/notification-service/Dockerfile new file mode 100644 index 0000000..500a766 --- /dev/null +++ b/notification-service/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-bookworm + +WORKDIR /notification-service + +RUN pip install uv + +COPY pyproject.toml uv.lock ./ + +RUN uv sync + +COPY packages ./packages + +COPY notification-service . + +CMD ["uv", "run", "python", "main.py"] \ No newline at end of file diff --git a/notification-service/main.py b/notification-service/main.py new file mode 100644 index 0000000..e69de29 From 0036871b735e99adbff080c85459947d9475cd5d Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sat, 13 Jun 2026 14:28:10 +0300 Subject: [PATCH 03/47] Configure docker-compose using notification service. --- docker-compose.yml | 30 +++++++++++++++++++-- mediaservice/api/main_views.py | 2 +- notification-service/Dockerfile | 2 +- notification-service/api/__init__.py | 0 notification-service/api/api_v1/__init__.py | 0 notification-service/api/main_views.py | 25 +++++++++++++++++ notification-service/lifespan.py | 15 +++++++++++ notification-service/main.py | 11 ++++++++ 8 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 notification-service/api/__init__.py create mode 100644 notification-service/api/api_v1/__init__.py create mode 100644 notification-service/api/main_views.py create mode 100644 notification-service/lifespan.py diff --git a/docker-compose.yml b/docker-compose.yml index 8c7c968..dfa7e7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,10 +31,10 @@ services: condition: service_healthy redis: condition: service_healthy - rabbitmq: - condition: service_healthy mediaservice: condition: service_healthy + notification-service: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] start_period: 15s @@ -192,6 +192,32 @@ services: rabbitmq: condition: service_healthy + notification-service: + build: + context: . + dockerfile: notification-service/Dockerfile + container_name: notification-service + develop: + watch: + - path: ./notification-service + action: sync+restart + target: /notification-service + + - path: ./packages + action: sync+restart + target: /notification-service/packages + ports: + - "8003:8000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + start_period: 3s + interval: 2s + timeout: 2s + retries: 3 + depends_on: + rabbitmq: + condition: service_healthy + volumes: postgres-data: diff --git a/mediaservice/api/main_views.py b/mediaservice/api/main_views.py index 0216f72..b6043e8 100644 --- a/mediaservice/api/main_views.py +++ b/mediaservice/api/main_views.py @@ -21,5 +21,5 @@ def read_root( @router.get("/health") -async def check_health() -> dict[str, str]: +def check_health() -> dict[str, str]: return {"status": "ok"} diff --git a/notification-service/Dockerfile b/notification-service/Dockerfile index 500a766..45815f8 100644 --- a/notification-service/Dockerfile +++ b/notification-service/Dockerfile @@ -12,4 +12,4 @@ COPY packages ./packages COPY notification-service . -CMD ["uv", "run", "python", "main.py"] \ No newline at end of file +CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000","--reload"] \ No newline at end of file diff --git a/notification-service/api/__init__.py b/notification-service/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notification-service/api/api_v1/__init__.py b/notification-service/api/api_v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notification-service/api/main_views.py b/notification-service/api/main_views.py new file mode 100644 index 0000000..b6043e8 --- /dev/null +++ b/notification-service/api/main_views.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Request + +router = APIRouter( + tags=["Main"], +) + + +@router.get("/") +def read_root( + request: Request, + name: str = "Nikolay", +) -> dict[str, str]: + docs_url = request.url.replace( + path="/docs", + query="", + ) + return { + "message": f"Hello {name}!", + "docs": str(docs_url), + } + + +@router.get("/health") +def check_health() -> dict[str, str]: + return {"status": "ok"} diff --git a/notification-service/lifespan.py b/notification-service/lifespan.py new file mode 100644 index 0000000..232d97f --- /dev/null +++ b/notification-service/lifespan.py @@ -0,0 +1,15 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001 + """ + Действия до старта приложения. + """ + yield + """ + Действия при завершении работы приложения. + """ diff --git a/notification-service/main.py b/notification-service/main.py index e69de29..b3a8a5b 100644 --- a/notification-service/main.py +++ b/notification-service/main.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI + +from api.main_views import router as main_router +from lifespan import lifespan + +app = FastAPI( + title="Notification Service", + lifsepan=lifespan, +) + +app.include_router(main_router) From fc2070e54d35084b07ab491c78c03990a97e0e3c Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sat, 13 Jun 2026 19:50:56 +0300 Subject: [PATCH 04/47] Install aiosmtplib. --- pyproject.toml | 1 + uv.lock | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 161125f..324d3a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.13" dependencies = [ "aio-pika>=9.6.2", "aioboto3>=15.5.0", + "aiosmtplib>=5.1.1", "alembic>=1.18.4", "asyncpg>=0.31.0", "bcrypt>=5.0.0", diff --git a/uv.lock b/uv.lock index 9b45298..4732901 100644 --- a/uv.lock +++ b/uv.lock @@ -175,6 +175,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "aiosmtplib" +version = "5.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/ba/34f2fef90d13e21ae3f1b360da98d825c40832bb232613513be92457ff65/aiosmtplib-5.1.1.tar.gz", hash = "sha256:d9a35e9d170bc1a9f66e2fdfe7fd212f7eebb8c1581c621f79395d0bcaba7a68", size = 68123, upload-time = "2026-05-31T17:25:36.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/97/d1030d897e96c79cf0682ff93c11a2118085b3af4c27993675eda9e55da3/aiosmtplib-5.1.1-py3-none-any.whl", hash = "sha256:9d384f0c3d8906f745c1cf6819f073145bb2de8b10407905f5e2ee3389bfe6c7", size = 27937, upload-time = "2026-05-31T17:25:35.283Z" }, +] + [[package]] name = "alembic" version = "1.18.4" @@ -1186,6 +1195,7 @@ source = { virtual = "." } dependencies = [ { name = "aio-pika" }, { name = "aioboto3" }, + { name = "aiosmtplib" }, { name = "alembic" }, { name = "asyncpg" }, { name = "bcrypt" }, @@ -1216,6 +1226,7 @@ dev = [ requires-dist = [ { name = "aio-pika", specifier = ">=9.6.2" }, { name = "aioboto3", specifier = ">=15.5.0" }, + { name = "aiosmtplib", specifier = ">=5.1.1" }, { name = "alembic", specifier = ">=1.18.4" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "bcrypt", specifier = ">=5.0.0" }, From d0c9d6601a02c72e7a74dd9a43bb439bf0967865 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sat, 13 Jun 2026 19:52:02 +0300 Subject: [PATCH 05/47] Add EmailService to send email. --- notification-service/core/__init__.py | 0 notification-service/core/config.py | 21 +++++++++++++++ notification-service/service.py | 39 +++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 notification-service/core/__init__.py create mode 100644 notification-service/core/config.py create mode 100644 notification-service/service.py diff --git a/notification-service/core/__init__.py b/notification-service/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notification-service/core/config.py b/notification-service/core/config.py new file mode 100644 index 0000000..07a0d25 --- /dev/null +++ b/notification-service/core/config.py @@ -0,0 +1,21 @@ +from pathlib import Path +from typing import ClassVar + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + BASE_DIR: Path = Path(__file__).parent.parent + mail_host: str = "smtp.yandex.ru" + mail_port: int = 587 + corporate_mail: str = "email" + mail_password: str = "password" + start_tls: bool = True + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + case_sensitive=False, + env_file=BASE_DIR / ".env", + env_nested_delimiter="__", + ) + + +settings = Settings() diff --git a/notification-service/service.py b/notification-service/service.py new file mode 100644 index 0000000..9cf9636 --- /dev/null +++ b/notification-service/service.py @@ -0,0 +1,39 @@ +from email.message import EmailMessage +from aiosmtplib import SMTP + +from core.config import settings + + +class EmailService: + @staticmethod + def get_smtp_client(): + smtp_client = SMTP( + hostname=settings.mail_host, + port=settings.mail_port, + username=settings.corporate_mail, + password=settings.mail_password, + start_tls=settings.start_tls, + ) + return smtp_client + + @classmethod + async def send_email( + cls, + subject: str, + body: str, + from_email: str = settings.corporate_mail, + to_email: str = settings.corporate_mail, + ) -> None: + smtp_client = cls.get_smtp_client() + async with smtp_client: + message = EmailMessage() + message["From"] = from_email + message["To"] = to_email + message["Subject"] = subject + message.set_content(body) + + await smtp_client.send_message( + message, + sender=from_email, + recipients=[to_email], + ) From 3752b49c413f3012ac0286285b5cce71836434c3 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sat, 13 Jun 2026 19:52:30 +0300 Subject: [PATCH 06/47] Fix some small bugs. --- mediaservice/core/config.py | 2 +- notification-service/core/config.py | 2 +- notification-service/service.py | 3 ++- pyproject.toml | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/mediaservice/core/config.py b/mediaservice/core/config.py index f4fa52f..af96a1a 100644 --- a/mediaservice/core/config.py +++ b/mediaservice/core/config.py @@ -23,7 +23,7 @@ class CeleryConfig(BaseModel): class Settings(BaseSettings): - BASE_DIR: Path = Path(__file__).parent + BASE_DIR: Path = Path(__file__).parent.parent minio: MinioConfig = MinioConfig() celery: CeleryConfig = CeleryConfig() model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( diff --git a/notification-service/core/config.py b/notification-service/core/config.py index 07a0d25..ed90a3a 100644 --- a/notification-service/core/config.py +++ b/notification-service/core/config.py @@ -9,7 +9,7 @@ class Settings(BaseSettings): mail_host: str = "smtp.yandex.ru" mail_port: int = 587 corporate_mail: str = "email" - mail_password: str = "password" + mail_password: str = "password" # noqa: S105 start_tls: bool = True model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( case_sensitive=False, diff --git a/notification-service/service.py b/notification-service/service.py index 9cf9636..935c7e4 100644 --- a/notification-service/service.py +++ b/notification-service/service.py @@ -1,4 +1,5 @@ from email.message import EmailMessage + from aiosmtplib import SMTP from core.config import settings @@ -6,7 +7,7 @@ class EmailService: @staticmethod - def get_smtp_client(): + def get_smtp_client() -> SMTP: smtp_client = SMTP( hostname=settings.mail_host, port=settings.mail_port, diff --git a/pyproject.toml b/pyproject.toml index 324d3a1..4c8223a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ required-version = ">=0.15.11" src = [ "app", "mediaservice", + "notification-service", ] # Exclude a variety of commonly ignored directories. From 887181c7cf94890fe6e053e8fdb9546b0b6dd7b8 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 14 Jun 2026 06:38:16 +0300 Subject: [PATCH 07/47] Prepare work to use local maildev. --- .gitignore | 3 +- docker-compose.yml | 177 +++++++++++++++++++++++---------------------- 2 files changed, 94 insertions(+), 86 deletions(-) diff --git a/.gitignore b/.gitignore index f3b1dfa..b81bf03 100644 --- a/.gitignore +++ b/.gitignore @@ -222,4 +222,5 @@ __marimo__/ .secrets config.local.yaml mediaservice/.env -notification-service/.env \ No newline at end of file +notification-service/.env +.env.docker-compose \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index dfa7e7b..5615ad9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,64 @@ services: timeout: 2s retries: 3 + mediaservice: + build: + context: . + dockerfile: mediaservice/Dockerfile + container_name: mediaservice + environment: + MINIO__HOST: minio + MINIO__PORT: 9000 + MINIO__ACCESS_KEY: admin + MINIO__SECRET_KEY: adminadmin + ports: + - "8001:8000" + develop: + watch: + - path: ./mediaservice + action: sync+restart + target: /mediaservice + - path: ./packages + action: sync+restart + target: /mediaservice/packages + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ] + start_period: 3s + interval: 2s + timeout: 2s + retries: 3 + depends_on: + minio: + condition: service_healthy + rabbitmq: + condition: service_healthy + + notification-service: + build: + context: . + dockerfile: notification-service/Dockerfile + container_name: notification-service + develop: + watch: + - path: ./notification-service + action: sync+restart + target: /notification-service + + - path: ./packages + action: sync+restart + target: /notification-service/packages + ports: + - "8003:8000" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ] + start_period: 3s + interval: 2s + timeout: 2s + retries: 3 + depends_on: + rabbitmq: + condition: service_healthy + frontend: image: nginx:1.27-alpine container_name: frontend @@ -72,15 +130,6 @@ services: timeout: 2s retries: 3 - pgadmin: - image: dpage/pgadmin4 - container_name: pgadmin - environment: - PGADMIN_DEFAULT_EMAIL: postgres@postgres.com - PGADMIN_DEFAULT_PASSWORD: postgres - ports: - - "5050:80" - redis: image: redis:latest container_name: redis @@ -95,11 +144,24 @@ services: timeout: 2s retries: 3 - redis_gui: - image: redis/redisinsight - container_name: redis_gui + minio: + image: minio/minio:latest + container_name: minio + environment: + MINIO_ROOT_USER: admin + MINIO_ROOT_PASSWORD: adminadmin ports: - - "5540:5540" + - "9000:9000" + - "9001:9001" + volumes: + - minio-data:/data + command: server --console-address ":9001" /data + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] + start_period: 3s + interval: 2s + timeout: 2s + retries: 3 rabbitmq: image: rabbitmq:3.13-management @@ -140,84 +202,29 @@ services: rabbitmq: condition: service_healthy - - minio: - image: minio/minio:latest - container_name: minio - environment: - MINIO_ROOT_USER: admin - MINIO_ROOT_PASSWORD: adminadmin + maildev: + image: maildev/maildev + container_name: maidev + env_file: + - .env.docker-compose ports: - - "9000:9000" - - "9001:9001" - volumes: - - minio-data:/data - command: server --console-address ":9001" /data - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] - start_period: 3s - interval: 2s - timeout: 2s - retries: 3 + - "1080:1080" + - "1025:1025" - mediaservice: - build: - context: . - dockerfile: mediaservice/Dockerfile - container_name: mediaservice + pgadmin: + image: dpage/pgadmin4 + container_name: pgadmin environment: - MINIO__HOST: minio - MINIO__PORT: 9000 - MINIO__ACCESS_KEY: admin - MINIO__SECRET_KEY: adminadmin + PGADMIN_DEFAULT_EMAIL: postgres@postgres.com + PGADMIN_DEFAULT_PASSWORD: postgres ports: - - "8001:8000" - develop: - watch: - - path: ./mediaservice - action: sync+restart - target: /mediaservice - - path: ./packages - action: sync+restart - target: /mediaservice/packages - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - start_period: 3s - interval: 2s - timeout: 2s - retries: 3 - depends_on: - minio: - condition: service_healthy - rabbitmq: - condition: service_healthy - - notification-service: - build: - context: . - dockerfile: notification-service/Dockerfile - container_name: notification-service - develop: - watch: - - path: ./notification-service - action: sync+restart - target: /notification-service + - "5050:80" - - path: ./packages - action: sync+restart - target: /notification-service/packages + redis_gui: + image: redis/redisinsight + container_name: redis_gui ports: - - "8003:8000" - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - start_period: 3s - interval: 2s - timeout: 2s - retries: 3 - depends_on: - rabbitmq: - condition: service_healthy - + - "5540:5540" volumes: postgres-data: From aa639dabb50f938380db3d8bb6ca200040c66317 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 14 Jun 2026 12:41:58 +0300 Subject: [PATCH 08/47] Send welcome email message on user registration. --- app/core/celery/__init__.py | 0 app/core/celery/celery_app.py | 6 +++ app/services/user.py | 8 +++ docker-compose.yml | 23 +++++++-- notification-service/core/celery/__init__.py | 0 .../core/celery/celery_app.py | 7 +++ notification-service/core/celery/tasks.py | 18 +++++++ notification-service/core/config.py | 4 +- notification-service/service.py | 49 ++++++++++++++++--- 9 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 app/core/celery/__init__.py create mode 100644 app/core/celery/celery_app.py create mode 100644 notification-service/core/celery/__init__.py create mode 100644 notification-service/core/celery/celery_app.py create mode 100644 notification-service/core/celery/tasks.py diff --git a/app/core/celery/__init__.py b/app/core/celery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/celery/celery_app.py b/app/core/celery/celery_app.py new file mode 100644 index 0000000..432a3f7 --- /dev/null +++ b/app/core/celery/celery_app.py @@ -0,0 +1,6 @@ +from celery import Celery + +app = Celery( + "core.celery.celery_app", + broker="amqp://guest:guest@rabbitmq:5672/%2f", +) diff --git a/app/services/user.py b/app/services/user.py index e88abe4..37b9364 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -20,6 +20,7 @@ UserResponseList, UserUpdate, ) +from core.celery.celery_app import app class UserService: @@ -64,6 +65,13 @@ async def create_user(self, create_user_data: UserCreate) -> UserResponse: create_user_data.password = hash_password(create_user_data.password) user = await self.user_repository.create_user(create_user_data) + app.send_task( + name="notification-service.email.send-welcome-email", + args=[ + create_user_data.email, + create_user_data.name, + ], + ) return UserResponse.model_validate(user) async def make_admin(self, user_id: int) -> None: diff --git a/docker-compose.yml b/docker-compose.yml index 5615ad9..116fcde 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -177,10 +177,10 @@ services: - "rabbitmq-data:/var/lib/rabbitmq" healthcheck: test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"] - start_period: 10s + start_period: 15s interval: 2s timeout: 2s - retries: 3 + retries: 5 celery-worker-mediaservice: build: @@ -195,9 +195,24 @@ services: MINIO__SECRET_KEY: adminadmin develop: watch: - - path: mediaservice/core/celery + - path: mediaservice action: sync+restart - target: /celery-worker + target: /mediaservice + depends_on: + rabbitmq: + condition: service_healthy + + celery-worker-notification-service: + build: + context: . + dockerfile: notification-service/Dockerfile + container_name: celery-worker-notification-service + command: uv run celery --app core.celery.celery_app worker --loglevel=INFO + develop: + watch: + - path: notification-service + action: sync+restart + target: /notification-service depends_on: rabbitmq: condition: service_healthy diff --git a/notification-service/core/celery/__init__.py b/notification-service/core/celery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notification-service/core/celery/celery_app.py b/notification-service/core/celery/celery_app.py new file mode 100644 index 0000000..ff986c9 --- /dev/null +++ b/notification-service/core/celery/celery_app.py @@ -0,0 +1,7 @@ +from celery import Celery + +app = Celery( + "core.celery.celery_app", + broker="amqp://guest:guest@rabbitmq:5672/%2f", + include=["core.celery.tasks"], +) diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py new file mode 100644 index 0000000..e7432b6 --- /dev/null +++ b/notification-service/core/celery/tasks.py @@ -0,0 +1,18 @@ +import asyncio +from datetime import time + +from core.celery.celery_app import app + +from service import EmailService + + +@app.task( + name="notification-service.email.send-welcome-email", +) +def send_welcome_email(email: str, name: str) -> None: + asyncio.run( + EmailService.send_welcome_email( + email, + name, + ) + ) diff --git a/notification-service/core/config.py b/notification-service/core/config.py index ed90a3a..26a2f35 100644 --- a/notification-service/core/config.py +++ b/notification-service/core/config.py @@ -8,8 +8,8 @@ class Settings(BaseSettings): BASE_DIR: Path = Path(__file__).parent.parent mail_host: str = "smtp.yandex.ru" mail_port: int = 587 - corporate_mail: str = "email" - mail_password: str = "password" # noqa: S105 + corporate_email: str = "email" + corporate_email_password: str = "password" # noqa: S105 start_tls: bool = True model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( case_sensitive=False, diff --git a/notification-service/service.py b/notification-service/service.py index 935c7e4..2210ccb 100644 --- a/notification-service/service.py +++ b/notification-service/service.py @@ -11,8 +11,8 @@ def get_smtp_client() -> SMTP: smtp_client = SMTP( hostname=settings.mail_host, port=settings.mail_port, - username=settings.corporate_mail, - password=settings.mail_password, + username=settings.corporate_email, + password=settings.corporate_email_password, start_tls=settings.start_tls, ) return smtp_client @@ -22,19 +22,54 @@ async def send_email( cls, subject: str, body: str, - from_email: str = settings.corporate_mail, - to_email: str = settings.corporate_mail, + to_email: str, ) -> None: smtp_client = cls.get_smtp_client() async with smtp_client: message = EmailMessage() - message["From"] = from_email - message["To"] = to_email message["Subject"] = subject + message["From"] = settings.corporate_email + message["To"] = to_email message.set_content(body) await smtp_client.send_message( message, - sender=from_email, + sender=settings.corporate_email, recipients=[to_email], ) + + @classmethod + async def send_welcome_email(cls, email: str, name: str) -> None: + subject = "Because you love movies as much as we do 🎬" + body_template = """ + Dear {name}, + + Some people watch movies. Others live them. + + If you're reading this, you probably care about more than just titles and posters. You care about stories. + + That one shot that stays with you for days. + + That's why we built MovieAPI. + + Think of it as your second home: + + Log every film you've ever seen + + Discover hidden gems you'd never find on mainstream sites + + Keep your own private notebook of thoughts and ratings + + No algorithms shouting at you. Just pure cinema. + + Welcome home, {name}. + + Let's watch something great. + + — The MovieAPI Team + """ + await cls.send_email( + subject=subject, + body=body_template.format(name=name), + to_email=email, + ) From 2daec3f461aa8c6ebd90fd5af34d38626913d90f Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 14 Jun 2026 13:29:25 +0300 Subject: [PATCH 09/47] Fix bug: send welcome email anyway. --- app/services/user.py | 3 ++- docker-compose.yml | 4 ++-- mediaservice/service.py | 1 + notification-service/core/celery/tasks.py | 4 +--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/services/user.py b/app/services/user.py index 37b9364..0d228e7 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -2,6 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession +from core.celery.celery_app import app from core.constants import UserRole from core.exceptions.auth import InvalidPasswordError from core.exceptions.user import ( @@ -20,7 +21,6 @@ UserResponseList, UserUpdate, ) -from core.celery.celery_app import app class UserService: @@ -71,6 +71,7 @@ async def create_user(self, create_user_data: UserCreate) -> UserResponse: create_user_data.email, create_user_data.name, ], + queue="notification", ) return UserResponse.model_validate(user) diff --git a/docker-compose.yml b/docker-compose.yml index 116fcde..0a63653 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -187,7 +187,7 @@ services: context: . dockerfile: mediaservice/Dockerfile container_name: celery-worker-mediaservice - command: uv run celery --app core.celery.celery_app worker --loglevel=INFO + command: uv run celery --app core.celery.celery_app worker -Q mediaservice --loglevel=INFO environment: MINIO__HOST: minio MINIO__PORT: 9000 @@ -207,7 +207,7 @@ services: context: . dockerfile: notification-service/Dockerfile container_name: celery-worker-notification-service - command: uv run celery --app core.celery.celery_app worker --loglevel=INFO + command: uv run celery --app core.celery.celery_app worker -Q notification --loglevel=INFO develop: watch: - path: notification-service diff --git a/mediaservice/service.py b/mediaservice/service.py index 5810bd8..173f66f 100644 --- a/mediaservice/service.py +++ b/mediaservice/service.py @@ -45,6 +45,7 @@ async def create_presign_url( object_name, ], countdown=settings.celery.delete_temporary_file_in, + queue="mediaservice", ) return PresignUrlResponse( presign_url=presign_url.replace("minio", "localhost"), diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index e7432b6..376ae7c 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -1,8 +1,6 @@ import asyncio -from datetime import time from core.celery.celery_app import app - from service import EmailService @@ -14,5 +12,5 @@ def send_welcome_email(email: str, name: str) -> None: EmailService.send_welcome_email( email, name, - ) + ), ) From 8572add1a495acf0852ad25db31488ced4cb9708 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Mon, 15 Jun 2026 11:25:16 +0300 Subject: [PATCH 10/47] Clean code: mediaservice and notification-service. --- app/services/user.py | 2 +- docker-compose.yml | 2 +- mediaservice/core/celery/tasks.py | 3 ++- mediaservice/core/config.py | 2 +- mediaservice/core/minio/__init__.py | 0 .../{minio_client.py => core/minio/client.py} | 0 mediaservice/core/minio/connection.py | 11 ++++++++ mediaservice/{ => core/minio}/service.py | 18 ++++++++++++- .../core/{rabbitmq => minio}/utils.py | 22 +++------------- mediaservice/core/rabbitmq/consumer.py | 14 ++++------ mediaservice/dependencies.py | 12 +++------ notification-service/api/main_views.py | 26 +++++++++++++++++++ notification-service/core/celery/tasks.py | 2 +- notification-service/service.py | 4 ++- 14 files changed, 74 insertions(+), 44 deletions(-) create mode 100644 mediaservice/core/minio/__init__.py rename mediaservice/{minio_client.py => core/minio/client.py} (100%) create mode 100644 mediaservice/core/minio/connection.py rename mediaservice/{ => core/minio}/service.py (87%) rename mediaservice/core/{rabbitmq => minio}/utils.py (66%) diff --git a/app/services/user.py b/app/services/user.py index 0d228e7..65fcce4 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -71,7 +71,7 @@ async def create_user(self, create_user_data: UserCreate) -> UserResponse: create_user_data.email, create_user_data.name, ], - queue="notification", + queue="notification-service", ) return UserResponse.model_validate(user) diff --git a/docker-compose.yml b/docker-compose.yml index 0a63653..e321137 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -207,7 +207,7 @@ services: context: . dockerfile: notification-service/Dockerfile container_name: celery-worker-notification-service - command: uv run celery --app core.celery.celery_app worker -Q notification --loglevel=INFO + command: uv run celery --app core.celery.celery_app worker -Q notification-service --loglevel=INFO develop: watch: - path: notification-service diff --git a/mediaservice/core/celery/tasks.py b/mediaservice/core/celery/tasks.py index f6fec06..8d72017 100644 --- a/mediaservice/core/celery/tasks.py +++ b/mediaservice/core/celery/tasks.py @@ -1,6 +1,7 @@ import asyncio -from ..rabbitmq.utils import get_minio_service +from core.minio.utils import get_minio_service + from .celery_app import app diff --git a/mediaservice/core/config.py b/mediaservice/core/config.py index af96a1a..676456b 100644 --- a/mediaservice/core/config.py +++ b/mediaservice/core/config.py @@ -19,7 +19,7 @@ def url_minio(self) -> str: class CeleryConfig(BaseModel): - delete_temporary_file_in: int = 24 * 60 * 60 + delete_temporary_file_in: int = 10 class Settings(BaseSettings): diff --git a/mediaservice/core/minio/__init__.py b/mediaservice/core/minio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mediaservice/minio_client.py b/mediaservice/core/minio/client.py similarity index 100% rename from mediaservice/minio_client.py rename to mediaservice/core/minio/client.py diff --git a/mediaservice/core/minio/connection.py b/mediaservice/core/minio/connection.py new file mode 100644 index 0000000..127aec7 --- /dev/null +++ b/mediaservice/core/minio/connection.py @@ -0,0 +1,11 @@ +from aioboto3 import Session + +S3_SESSION = Session() + + +def get_session() -> Session: + """ + Метод для получения сессии s3 хранилища. + """ + global S3_SESSION # noqa: PLW0602 + return S3_SESSION diff --git a/mediaservice/service.py b/mediaservice/core/minio/service.py similarity index 87% rename from mediaservice/service.py rename to mediaservice/core/minio/service.py index 173f66f..76dd677 100644 --- a/mediaservice/service.py +++ b/mediaservice/core/minio/service.py @@ -1,4 +1,5 @@ from typing import cast +from urllib.parse import urlsplit from uuid import uuid4 from fastapi import UploadFile @@ -7,7 +8,7 @@ from core.celery.celery_app import app from core.config import settings -from minio_client import MinioClient +from core.minio.client import MinioClient class MinioService: @@ -126,3 +127,18 @@ async def file_exists( bucket_name=bucket_name, object_name=object_name, ) + + @classmethod + def get_bucket_name_from_url(cls, object_url: str) -> str: + parsed_url = urlsplit(object_url) + path_url = parsed_url.path + bucket_name = path_url.split("/")[1] + return bucket_name + + @classmethod + def get_object_name_from_url(cls, object_url: str) -> str: + parsed_url = urlsplit(object_url) + path_url = parsed_url.path + object_name_list = path_url.split("/")[2:] + object_name = "/".join(object_name_list) + return object_name diff --git a/mediaservice/core/rabbitmq/utils.py b/mediaservice/core/minio/utils.py similarity index 66% rename from mediaservice/core/rabbitmq/utils.py rename to mediaservice/core/minio/utils.py index 381a51b..0c20a14 100644 --- a/mediaservice/core/rabbitmq/utils.py +++ b/mediaservice/core/minio/utils.py @@ -1,28 +1,12 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from urllib.parse import urlsplit from types_aiobotocore_s3 import S3Client from core.config import settings -from dependencies import get_session -from minio_client import MinioClient -from service import MinioService - - -def get_bucket_name_from_url(object_url: str) -> str: - parsed_url = urlsplit(object_url) - path_url = parsed_url.path - bucket_name = path_url.split("/")[1] - return bucket_name - - -def get_object_name_from_url(object_url: str) -> str: - parsed_url = urlsplit(object_url) - path_url = parsed_url.path - object_name_list = path_url.split("/")[2:] - object_name = "/".join(object_name_list) - return object_name +from core.minio.client import MinioClient +from core.minio.connection import get_session +from core.minio.service import MinioService @asynccontextmanager diff --git a/mediaservice/core/rabbitmq/consumer.py b/mediaservice/core/rabbitmq/consumer.py index 3770269..8d95c0e 100644 --- a/mediaservice/core/rabbitmq/consumer.py +++ b/mediaservice/core/rabbitmq/consumer.py @@ -3,11 +3,7 @@ from packages.rabbitmq.utils import create_message, get_message, get_rabbitmq_service from core.config import settings -from core.rabbitmq.utils import ( - get_bucket_name_from_url, - get_minio_service, - get_object_name_from_url, -) +from core.minio.utils import get_minio_service async def copy_file(message: IncomingMessage) -> None: @@ -15,8 +11,8 @@ async def copy_file(message: IncomingMessage) -> None: data = get_message(message) entity_id, object_url = data["entity_id"], data["object_url"] - bucket_name = get_bucket_name_from_url(object_url) - object_name = get_object_name_from_url(object_url) + bucket_name = minio_service.get_bucket_name_from_url(object_url) + object_name = minio_service.get_object_name_from_url(object_url) destination_object_name = object_name.replace( settings.minio.temporary_prefix, "", @@ -60,8 +56,8 @@ async def delete_file(message: IncomingMessage) -> None: async with message.process(), get_minio_service() as minio_service: data = get_message(message) object_url = data["object_url"] - bucket_name = get_bucket_name_from_url(object_url) - object_name = get_object_name_from_url(object_url) + bucket_name = minio_service.get_bucket_name_from_url(object_url) + object_name = minio_service.get_object_name_from_url(object_url) await minio_service.delete_file( bucket_name=bucket_name, key=object_name, diff --git a/mediaservice/dependencies.py b/mediaservice/dependencies.py index 4c4acd0..b4914d5 100644 --- a/mediaservice/dependencies.py +++ b/mediaservice/dependencies.py @@ -6,15 +6,9 @@ from types_aiobotocore_s3 import S3Client from core.config import settings -from minio_client import MinioClient -from service import MinioService - -S3_SESSION = Session() - - -def get_session() -> Session: - global S3_SESSION # noqa: PLW0602 - return S3_SESSION +from core.minio.client import MinioClient +from core.minio.connection import get_session +from core.minio.service import MinioService async def get_client( diff --git a/notification-service/api/main_views.py b/notification-service/api/main_views.py index b6043e8..3374714 100644 --- a/notification-service/api/main_views.py +++ b/notification-service/api/main_views.py @@ -1,5 +1,7 @@ from fastapi import APIRouter, Request +from service import EmailService + router = APIRouter( tags=["Main"], ) @@ -23,3 +25,27 @@ def read_root( @router.get("/health") def check_health() -> dict[str, str]: return {"status": "ok"} + + +@router.get("/send") +async def send_email( + subject: str, + body: str, + to_email: str, +) -> None: + await EmailService.send_email( + subject=subject, + body=body, + to_email=to_email, + ) + + +@router.get("/welcome-email") +async def send_welcome_email_message( + email: str, + name: str, +) -> None: + await EmailService.send_welcome_email( + email=email, + name=name, + ) diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index 376ae7c..3c23757 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -4,7 +4,7 @@ from service import EmailService -@app.task( +@app.task( # type: ignore[untyped-decorator] name="notification-service.email.send-welcome-email", ) def send_welcome_email(email: str, name: str) -> None: diff --git a/notification-service/service.py b/notification-service/service.py index 2210ccb..62bef70 100644 --- a/notification-service/service.py +++ b/notification-service/service.py @@ -41,6 +41,7 @@ async def send_email( @classmethod async def send_welcome_email(cls, email: str, name: str) -> None: subject = "Because you love movies as much as we do 🎬" + # ruff: disable[W291, W293, E501] body_template = """ Dear {name}, @@ -66,8 +67,9 @@ async def send_welcome_email(cls, email: str, name: str) -> None: Let's watch something great. - — The MovieAPI Team + — The MovieAPI Team """ + # ruff: enable[W291, W293, E501] await cls.send_email( subject=subject, body=body_template.format(name=name), From b3c03f3119829f871c37a9970c0bea65a5c87df4 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Tue, 16 Jun 2026 11:07:27 +0300 Subject: [PATCH 11/47] Final refactor code. --- .../rabbitmq/{consumer.py => consumers.py} | 6 +- app/core/rabbitmq/startup.py | 14 ++-- app/dependencies/services.py | 6 +- app/services/genre.py | 14 ++-- app/services/movie.py | 14 ++-- app/services/user.py | 5 +- mediaservice/core/celery/celery_app.py | 3 +- mediaservice/core/celery/tasks.py | 4 +- mediaservice/core/config.py | 2 +- mediaservice/core/minio/service.py | 5 +- .../rabbitmq/{consumer.py => consumers.py} | 7 +- mediaservice/core/rabbitmq/startup.py | 12 ++-- .../core/celery/celery_app.py | 3 +- notification-service/core/celery/tasks.py | 4 +- packages/celery/__init__.py | 0 packages/celery/constants.py | 11 +++ packages/config.py | 30 ++++++++ packages/constants.py | 67 ----------------- packages/rabbitmq/__init__.py | 4 +- packages/rabbitmq/connection.py | 4 +- packages/rabbitmq/constants.py | 71 +++++++++++++++++++ packages/rabbitmq/dependencies.py | 2 +- packages/rabbitmq/service.py | 16 +++-- packages/rabbitmq/utils.py | 15 ++-- 24 files changed, 192 insertions(+), 127 deletions(-) rename app/core/rabbitmq/{consumer.py => consumers.py} (96%) rename mediaservice/core/rabbitmq/{consumer.py => consumers.py} (92%) create mode 100644 packages/celery/__init__.py create mode 100644 packages/celery/constants.py create mode 100644 packages/config.py create mode 100644 packages/rabbitmq/constants.py diff --git a/app/core/rabbitmq/consumer.py b/app/core/rabbitmq/consumers.py similarity index 96% rename from app/core/rabbitmq/consumer.py rename to app/core/rabbitmq/consumers.py index ef81443..1a33457 100644 --- a/app/core/rabbitmq/consumer.py +++ b/app/core/rabbitmq/consumers.py @@ -2,7 +2,7 @@ from typing import Any from aio_pika import IncomingMessage -from packages.constants import Exchange, ExchangeType, Queue +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import create_message, get_message from cache_services import GenreCacheService, MovieCacheService @@ -58,8 +58,8 @@ async def update_media_url_function(message: IncomingMessage) -> None: inner_service = getattr(service, inner_service_attr_name) rabbitmq_service = inner_service.rabbitmq_service exchange = await rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, ) body = { "object_url": object_url, diff --git a/app/core/rabbitmq/startup.py b/app/core/rabbitmq/startup.py index a76267d..61d8b34 100644 --- a/app/core/rabbitmq/startup.py +++ b/app/core/rabbitmq/startup.py @@ -1,10 +1,10 @@ from collections.abc import AsyncGenerator -from packages.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.connection import rabbitmq_connection_startup +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import get_rabbitmq_service -from core.rabbitmq.consumer import ( +from core.rabbitmq.consumers import ( update_genre_poster_url, update_movie_poster_url, update_movie_source_url, @@ -14,20 +14,20 @@ async def rabbitmq_consumer_queues_startup() -> AsyncGenerator[None]: async with get_rabbitmq_service() as rabbitmq_service: exchange = await rabbitmq_service.declare_exchange( - name=Exchange.mediaservice.value, - type=ExchangeType.direct.value, + name=Exchange.mediaservice, + type=ExchangeType.direct, durable=True, ) queue_update_genre_poster_url = await rabbitmq_service.declare_queue( - name=Queue.update_genre_poster_url.value, + name=Queue.update_genre_poster_url, durable=True, ) queue_update_movie_poster_url = await rabbitmq_service.declare_queue( - name=Queue.update_movie_poster_url.value, + name=Queue.update_movie_poster_url, durable=True, ) queue_update_movie_source_url = await rabbitmq_service.declare_queue( - name=Queue.update_movie_source_url.value, + name=Queue.update_movie_source_url, durable=True, ) diff --git a/app/dependencies/services.py b/app/dependencies/services.py index 298c201..9ee428c 100644 --- a/app/dependencies/services.py +++ b/app/dependencies/services.py @@ -3,7 +3,7 @@ from fastapi import Depends from httpx import AsyncClient -from packages.rabbitmq import RabbitMQService, get_rabbit_mq_service +from packages.rabbitmq import RabbitMQService, get_rabbitmq_service from sqlalchemy.ext.asyncio import AsyncSession from core.database.connection import session_factory @@ -25,7 +25,7 @@ async def get_genre_service( ], rabbitmq_service: Annotated[ RabbitMQService, - Depends(get_rabbit_mq_service), + Depends(get_rabbitmq_service), ], ) -> AsyncGenerator[GenreService]: try: @@ -44,7 +44,7 @@ async def get_movie_service( ], rabbitmq_service: Annotated[ RabbitMQService, - Depends(get_rabbit_mq_service), + Depends(get_rabbitmq_service), ], ) -> AsyncGenerator[MovieService]: try: diff --git a/app/services/genre.py b/app/services/genre.py index e184f35..c4b1a87 100644 --- a/app/services/genre.py +++ b/app/services/genre.py @@ -1,7 +1,7 @@ from typing import cast -from packages.constants import Exchange, ExchangeType, Queue from packages.rabbitmq import RabbitMQService +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import create_message from sqlalchemy.ext.asyncio import AsyncSession @@ -83,8 +83,8 @@ async def create_genre(self, create_data: GenreCreate) -> GenreResponse: "object_url": create_data.preview_url, } exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) await self.rabbitmq_service.publish( @@ -114,8 +114,8 @@ async def update_genre( if current_genre_preview_url != update_data.preview_url: exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) @@ -175,8 +175,8 @@ async def delete_genre_by_id(self, genre_id: int) -> None: raise GenreIdNotFoundError(genre_id) exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) body = {"object_url": genre.preview_url} diff --git a/app/services/movie.py b/app/services/movie.py index 69b167b..8f44f63 100644 --- a/app/services/movie.py +++ b/app/services/movie.py @@ -1,7 +1,7 @@ from typing import cast -from packages.constants import Exchange, ExchangeType, Queue from packages.rabbitmq import RabbitMQService +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import create_message from sqlalchemy.ext.asyncio import AsyncSession @@ -138,8 +138,8 @@ async def create_movie( movie = await self.movie_repository.create_movie(create_movie_data) exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) body = { @@ -191,8 +191,8 @@ async def update_movie( ) exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) @@ -282,8 +282,8 @@ async def delete_movie_by_id(self, movie_id: int) -> None: raise MovieIdNotFoundError(movie_id) exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) body = {"object_url": movie.preview_url} diff --git a/app/services/user.py b/app/services/user.py index 65fcce4..5ad99f9 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -1,5 +1,6 @@ from typing import cast +from packages.celery.constants import Queue, TaskType from sqlalchemy.ext.asyncio import AsyncSession from core.celery.celery_app import app @@ -66,12 +67,12 @@ async def create_user(self, create_user_data: UserCreate) -> UserResponse: create_user_data.password = hash_password(create_user_data.password) user = await self.user_repository.create_user(create_user_data) app.send_task( - name="notification-service.email.send-welcome-email", + name=TaskType.send_welcome_email.value, args=[ create_user_data.email, create_user_data.name, ], - queue="notification-service", + queue=Queue.notification.value, ) return UserResponse.model_validate(user) diff --git a/mediaservice/core/celery/celery_app.py b/mediaservice/core/celery/celery_app.py index ff986c9..4e8eb2a 100644 --- a/mediaservice/core/celery/celery_app.py +++ b/mediaservice/core/celery/celery_app.py @@ -1,7 +1,8 @@ from celery import Celery +from packages.config import settings as package_settings app = Celery( "core.celery.celery_app", - broker="amqp://guest:guest@rabbitmq:5672/%2f", + broker=package_settings.rabbitmq.rabbitmq_url, include=["core.celery.tasks"], ) diff --git a/mediaservice/core/celery/tasks.py b/mediaservice/core/celery/tasks.py index 8d72017..c851154 100644 --- a/mediaservice/core/celery/tasks.py +++ b/mediaservice/core/celery/tasks.py @@ -1,12 +1,14 @@ import asyncio +from packages.celery.constants import TaskType + from core.minio.utils import get_minio_service from .celery_app import app @app.task( # type: ignore[untyped-decorator] - name="mediaservice.media.delete_temporary_file", + name=TaskType.delete_temporary_file.value, ) def delete_temporary_file(bucket_name: str, object_name: str) -> None: async def async_delete_temporary_file() -> None: diff --git a/mediaservice/core/config.py b/mediaservice/core/config.py index 676456b..af96a1a 100644 --- a/mediaservice/core/config.py +++ b/mediaservice/core/config.py @@ -19,7 +19,7 @@ def url_minio(self) -> str: class CeleryConfig(BaseModel): - delete_temporary_file_in: int = 10 + delete_temporary_file_in: int = 24 * 60 * 60 class Settings(BaseSettings): diff --git a/mediaservice/core/minio/service.py b/mediaservice/core/minio/service.py index 76dd677..060ad36 100644 --- a/mediaservice/core/minio/service.py +++ b/mediaservice/core/minio/service.py @@ -3,6 +3,7 @@ from uuid import uuid4 from fastapi import UploadFile +from packages.celery.constants import Queue, TaskType from packages.constants import S3Bucket from packages.schemas import ConfirmUploadRequest, PresignUrlCreate, PresignUrlResponse @@ -40,13 +41,13 @@ async def create_presign_url( ) app.send_task( - name="mediaservice.media.delete_temporary_file", + name=TaskType.delete_temporary_file.value, args=[ presign_url_create.bucket_name.value, object_name, ], countdown=settings.celery.delete_temporary_file_in, - queue="mediaservice", + queue=Queue.mediaservice.value, ) return PresignUrlResponse( presign_url=presign_url.replace("minio", "localhost"), diff --git a/mediaservice/core/rabbitmq/consumer.py b/mediaservice/core/rabbitmq/consumers.py similarity index 92% rename from mediaservice/core/rabbitmq/consumer.py rename to mediaservice/core/rabbitmq/consumers.py index 8d95c0e..1a6cd5c 100644 --- a/mediaservice/core/rabbitmq/consumer.py +++ b/mediaservice/core/rabbitmq/consumers.py @@ -1,5 +1,6 @@ from aio_pika import IncomingMessage -from packages.constants import Exchange, ExchangeType, Queue, S3Bucket +from packages.constants import S3Bucket +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import create_message, get_message, get_rabbitmq_service from core.config import settings @@ -30,8 +31,8 @@ async def copy_file(message: IncomingMessage) -> None: async with get_rabbitmq_service() as rabbitmq_service: exchange = await rabbitmq_service.declare_exchange( - name=Exchange.mediaservice.value, - type=ExchangeType.direct.value, + name=Exchange.mediaservice, + type=ExchangeType.direct, durable=True, ) body = { diff --git a/mediaservice/core/rabbitmq/startup.py b/mediaservice/core/rabbitmq/startup.py index cf9c727..26fd14d 100644 --- a/mediaservice/core/rabbitmq/startup.py +++ b/mediaservice/core/rabbitmq/startup.py @@ -1,25 +1,25 @@ from collections.abc import AsyncGenerator -from packages.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.connection import rabbitmq_connection_startup +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import get_rabbitmq_service -from core.rabbitmq.consumer import copy_file, delete_file +from core.rabbitmq.consumers import copy_file, delete_file async def rabbitmq_consumer_queues_startup() -> AsyncGenerator[None]: async with get_rabbitmq_service() as rabbitmq_service: exchange = await rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) queue_copy = await rabbitmq_service.declare_queue( - name=Queue.copy_file.value, + name=Queue.copy_file, durable=True, ) queue_delete = await rabbitmq_service.declare_queue( - name=Queue.delete_file.value, + name=Queue.delete_file, durable=True, ) diff --git a/notification-service/core/celery/celery_app.py b/notification-service/core/celery/celery_app.py index ff986c9..4e8eb2a 100644 --- a/notification-service/core/celery/celery_app.py +++ b/notification-service/core/celery/celery_app.py @@ -1,7 +1,8 @@ from celery import Celery +from packages.config import settings as package_settings app = Celery( "core.celery.celery_app", - broker="amqp://guest:guest@rabbitmq:5672/%2f", + broker=package_settings.rabbitmq.rabbitmq_url, include=["core.celery.tasks"], ) diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index 3c23757..8819fab 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -1,11 +1,13 @@ import asyncio +from packages.celery.constants import TaskType + from core.celery.celery_app import app from service import EmailService @app.task( # type: ignore[untyped-decorator] - name="notification-service.email.send-welcome-email", + name=TaskType.send_welcome_email.value, ) def send_welcome_email(email: str, name: str) -> None: asyncio.run( diff --git a/packages/celery/__init__.py b/packages/celery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/celery/constants.py b/packages/celery/constants.py new file mode 100644 index 0000000..4fb08ac --- /dev/null +++ b/packages/celery/constants.py @@ -0,0 +1,11 @@ +from enum import StrEnum + + +class Queue(StrEnum): + mediaservice = "mediaservice" + notification = "notification-service" + + +class TaskType(StrEnum): + delete_temporary_file = "mediaservice.media.delete_temporary_file" + send_welcome_email = "notification-service.email.send-welcome-email" diff --git a/packages/config.py b/packages/config.py new file mode 100644 index 0000000..af11675 --- /dev/null +++ b/packages/config.py @@ -0,0 +1,30 @@ +from pathlib import Path +from typing import ClassVar + +from pydantic import BaseModel +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class RabbitMQConfig(BaseModel): + host: str = "rabbitmq" + port: int = 5672 + username: str = "guest" + password: str = "guest" # noqa: S105 + + @property + def rabbitmq_url(self) -> str: + return f"amqp://{self.username}:{self.password}@{self.host}:{self.port}/%2f" + + +class Settings(BaseSettings): + BASE_DIR: Path = Path(__file__).parent + rabbitmq: RabbitMQConfig = RabbitMQConfig() + + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + case_sensitive=False, + env_file=BASE_DIR / ".env", + env_nested_delimiter="__", + ) + + +settings = Settings() diff --git a/packages/constants.py b/packages/constants.py index b5fa715..32cd719 100644 --- a/packages/constants.py +++ b/packages/constants.py @@ -1,22 +1,5 @@ from enum import StrEnum -from packages.rabbitmq.utils import create_exchange_name, create_queue_name - - -class ActionType(StrEnum): - update_genre_poster_url = "update_genre_poster_url" - update_movie_poster_url = "update_movie_poster_url" - update_movie_source_url = "update_movie_source_url" - copy_file = "copy_file" - delete_file = "delete_file" - - -class ExchangeType(StrEnum): - direct = "direct" - fanout = "fanout" - topic = "topic" - headers = "headers" - class S3Bucket(StrEnum): genre_posters = "genre-posters" @@ -38,53 +21,3 @@ class S3ClientMethod(StrEnum): copy_object = "copy_object" delete_object = "delete_object" delete_objects = "delete_objects" - - -class Exchange(StrEnum): - app = create_exchange_name( - producer="app", - entity="content", - exchange_type=ExchangeType.direct, - ) - mediaservice = create_exchange_name( - "mediaservice", - entity="content", - exchange_type=ExchangeType.direct, - ) - - @staticmethod - def create_queue_name( - consumer: str, - entity: str, - action: "ActionType", - ) -> str: - queue_name = f"{consumer}.{entity}.{action.value}" - return queue_name - - -class Queue(StrEnum): - update_genre_poster_url = create_queue_name( - consumer="app", - entity="content", - action=ActionType.update_genre_poster_url, - ) - update_movie_poster_url = create_queue_name( - consumer="app", - entity="content", - action=ActionType.update_movie_poster_url, - ) - update_movie_source_url = create_queue_name( - consumer="app", - entity="content", - action=ActionType.update_movie_source_url, - ) - copy_file = create_queue_name( - consumer="mediaservice", - entity="content", - action=ActionType.copy_file, - ) - delete_file = create_queue_name( - consumer="mediaservice", - entity="content", - action=ActionType.delete_file, - ) diff --git a/packages/rabbitmq/__init__.py b/packages/rabbitmq/__init__.py index 4bf165d..c158b89 100644 --- a/packages/rabbitmq/__init__.py +++ b/packages/rabbitmq/__init__.py @@ -1,7 +1,7 @@ __all__ = ( "RabbitMQService", - "get_rabbit_mq_service", + "get_rabbitmq_service", ) -from .dependencies import get_rabbit_mq_service +from .dependencies import get_rabbitmq_service from .service import RabbitMQService diff --git a/packages/rabbitmq/connection.py b/packages/rabbitmq/connection.py index fe59957..c189397 100644 --- a/packages/rabbitmq/connection.py +++ b/packages/rabbitmq/connection.py @@ -3,13 +3,15 @@ import aio_pika from aio_pika.abc import AbstractChannel +from packages.config import settings + RABBIT_MQ_CONNECTION = None async def rabbitmq_connection_startup() -> None: global RABBIT_MQ_CONNECTION # noqa: PLW0603 RABBIT_MQ_CONNECTION = await aio_pika.connect_robust( - url="amqp://guest:guest@rabbitmq:5672/%2f", + url=settings.rabbitmq.rabbitmq_url, ) diff --git a/packages/rabbitmq/constants.py b/packages/rabbitmq/constants.py new file mode 100644 index 0000000..96bdaa1 --- /dev/null +++ b/packages/rabbitmq/constants.py @@ -0,0 +1,71 @@ +from enum import StrEnum + +from packages.rabbitmq.utils import create_exchange_name, create_queue_name + + +class ConsumerType(StrEnum): + app = "app" + mediaservice = "mediaservice" + notification_service = "notification-service" + + +class ProducerType(StrEnum): + app = "app" + mediaservice = "mediaservice" + notification_service = "notification-service" + + +class ActionType(StrEnum): + update_genre_poster_url = "update_genre_poster_url" + update_movie_poster_url = "update_movie_poster_url" + update_movie_source_url = "update_movie_source_url" + copy_file = "copy_file" + delete_file = "delete_file" + + +class ExchangeType(StrEnum): + direct = "direct" + fanout = "fanout" + topic = "topic" + headers = "headers" + + +class Exchange(StrEnum): + app = create_exchange_name( + producer=ProducerType.app, + entity="content", + exchange_type=ExchangeType.direct, + ) + mediaservice = create_exchange_name( + producer=ProducerType.mediaservice, + entity="content", + exchange_type=ExchangeType.direct, + ) + + +class Queue(StrEnum): + update_genre_poster_url = create_queue_name( + consumer=ConsumerType.app, + entity="content", + action=ActionType.update_genre_poster_url, + ) + update_movie_poster_url = create_queue_name( + consumer=ConsumerType.app, + entity="content", + action=ActionType.update_movie_poster_url, + ) + update_movie_source_url = create_queue_name( + consumer=ConsumerType.app, + entity="content", + action=ActionType.update_movie_source_url, + ) + copy_file = create_queue_name( + consumer=ConsumerType.mediaservice, + entity="content", + action=ActionType.copy_file, + ) + delete_file = create_queue_name( + consumer=ConsumerType.mediaservice, + entity="content", + action=ActionType.delete_file, + ) diff --git a/packages/rabbitmq/dependencies.py b/packages/rabbitmq/dependencies.py index 2f80599..dba82dd 100644 --- a/packages/rabbitmq/dependencies.py +++ b/packages/rabbitmq/dependencies.py @@ -8,7 +8,7 @@ from packages.rabbitmq.service import RabbitMQService -async def get_rabbit_mq_service( +async def get_rabbitmq_service( channel: Annotated[ AbstractChannel, Depends(get_channel), diff --git a/packages/rabbitmq/service.py b/packages/rabbitmq/service.py index bf5fb0b..6eb6bcf 100644 --- a/packages/rabbitmq/service.py +++ b/packages/rabbitmq/service.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from typing import TYPE_CHECKING from aio_pika.abc import ( AbstractChannel, @@ -7,6 +8,9 @@ AbstractQueue, ) +if TYPE_CHECKING: + from packages.rabbitmq.constants import Exchange, ExchangeType, Queue + class RabbitMQService: def __init__(self, channel: AbstractChannel) -> None: @@ -14,23 +18,23 @@ def __init__(self, channel: AbstractChannel) -> None: async def declare_queue( self, - name: str, + name: "Queue", durable: bool = True, ) -> AbstractQueue: return await self.channel.declare_queue( - name=name, + name=name.value, durable=durable, ) async def declare_exchange( self, - name: str, - type: str = "direct", + name: "Exchange", + type: "ExchangeType", durable: bool = True, ) -> AbstractExchange: return await self.channel.declare_exchange( - name=name, - type=type, + name=name.value, + type=type.value, durable=durable, ) diff --git a/packages/rabbitmq/utils.py b/packages/rabbitmq/utils.py index b1d6255..18055f0 100644 --- a/packages/rabbitmq/utils.py +++ b/packages/rabbitmq/utils.py @@ -9,7 +9,12 @@ from packages.rabbitmq import RabbitMQService, connection if TYPE_CHECKING: - from packages.constants import ActionType, ExchangeType + from packages.rabbitmq.constants import ( + ActionType, + ConsumerType, + ExchangeType, + ProducerType, + ) def get_message( @@ -28,20 +33,20 @@ def create_message(body: dict[Any, Any]) -> Message: def create_exchange_name( - producer: str, + producer: "ProducerType", entity: str, exchange_type: "ExchangeType", ) -> str: - exchange_name = f"{producer}.{entity}.{exchange_type.value}" + exchange_name = f"{producer.value}.{entity}.{exchange_type.value}" return exchange_name def create_queue_name( - consumer: str, + consumer: "ConsumerType", entity: str, action: "ActionType", ) -> str: - queue_name = f"{consumer}.{entity}.{action.value}" + queue_name = f"{consumer.value}.{entity}.{action.value}" return queue_name From a48cd53fa3f2c06128f100de30aff595a483046c Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Tue, 16 Jun 2026 19:19:24 +0300 Subject: [PATCH 12/47] Add email confirmation by sending a generated code to your email. --- app/api/api_v1/auth.py | 16 ++- app/cache_services/user.py | 8 +- app/core/config.py | 11 ++ app/core/exceptions/confirmation_code.py | 47 ++++++++ app/dependencies/caching.py | 4 + app/dependencies/redis_client.py | 4 + app/dependencies/services.py | 48 +++++---- app/repositories/user.py | 5 +- app/schemas/user.py | 25 +++++ app/services/user.py | 101 ++++++++++++++++-- notification-service/api/__init__.py | 7 ++ notification-service/api/api_v1/__init__.py | 8 ++ .../api/api_v1/send_email_views.py | 30 ++++++ notification-service/api/main_views.py | 26 ----- notification-service/main.py | 2 + notification-service/service.py | 37 +++---- packages/schemas.py | 12 ++- 17 files changed, 309 insertions(+), 82 deletions(-) create mode 100644 app/core/exceptions/confirmation_code.py create mode 100644 notification-service/api/api_v1/send_email_views.py diff --git a/app/api/api_v1/auth.py b/app/api/api_v1/auth.py index 064bdcc..68ace85 100644 --- a/app/api/api_v1/auth.py +++ b/app/api/api_v1/auth.py @@ -2,6 +2,7 @@ APIRouter, status, ) +from pydantic import EmailStr from core.constants import BEARER_TOKEN_TYPE from core.security.jwt_utils import ( @@ -13,10 +14,11 @@ AuthUserByRefreshTokenDep, OAuth2Dep, ) +from dependencies.annotations.services import UserServiceDep from schemas.auth import UserLogin from schemas.token_info import TokenInfo from schemas.user import ( - UserCreate, + UserRegistration, UserResponse, ) @@ -26,16 +28,24 @@ ) +@router.post("/confirmation_code") +async def send_confirmation_code( + email: EmailStr, + user_service: UserServiceDep, +) -> None: + await user_service.send_confirmation_code(email) + + @router.post( "/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED, ) async def register_user( - create_user_data: UserCreate, + registration_user_data: UserRegistration, user_service: UserCacheServiceDep, ) -> UserResponse: - return await user_service.create_user(create_user_data) + return await user_service.create_user(registration_user_data) @router.post( diff --git a/app/cache_services/user.py b/app/cache_services/user.py index 1ab26bb..bb3cd96 100644 --- a/app/cache_services/user.py +++ b/app/cache_services/user.py @@ -3,8 +3,8 @@ from core.redis.cache_service import CacheService from schemas.auth import UserLogin from schemas.user import ( - UserCreate, UserPartialUpdate, + UserRegistration, UserResponse, UserResponseList, UserUpdate, @@ -51,8 +51,10 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList await self.cache_service.set(key, users_response) return users_response - async def create_user(self, create_user_data: UserCreate) -> UserResponse: - user_response = await self.user_service.create_user(create_user_data) + async def create_user( + self, registration_user_data: UserRegistration, + ) -> UserResponse: + user_response = await self.user_service.create_user(registration_user_data) key = CacheService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) diff --git a/app/core/config.py b/app/core/config.py index d5537ca..5635dc2 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -32,6 +32,7 @@ class RedisDataBaseConfig(BaseModel): reviews: int = 4 favorite_movies: int = 5 watch_history: int = 6 + confirmation_codes: int = 7 class RedisConfig(BaseModel): @@ -66,6 +67,15 @@ def create_presign_url_endpoint(self) -> str: return f"http://{self.host}:{self.port}/api/v1/presign-url" +class NotificationServiceConfig(BaseModel): + host: str = "notification-service" + port: int = 8000 + + @property + def send_email_endpoint(self) -> str: + return f"http://{self.host}:{self.port}/api/v1/send-email" + + class Settings(BaseSettings): BASE_DIR: Path = Path(__file__).parent.parent database: DataBaseConfig = DataBaseConfig() @@ -75,6 +85,7 @@ class Settings(BaseSettings): http_bearer: HTTPBearer = HTTPBearer() oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer("/api/v1/auth/login") mediaservice: MediaServiceConfig = MediaServiceConfig() + notificationservice: NotificationServiceConfig = NotificationServiceConfig() debug: bool = False model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( diff --git a/app/core/exceptions/confirmation_code.py b/app/core/exceptions/confirmation_code.py new file mode 100644 index 0000000..37891d9 --- /dev/null +++ b/app/core/exceptions/confirmation_code.py @@ -0,0 +1,47 @@ +from pydantic import EmailStr + +from core.exceptions.base import AuthenticationError, NotFoundError + + +class ConfirmationCodeNotFoundError(NotFoundError): + """ + Класс для ошибок, связанных с ненахождением кода подтверждения. + """ + + def __init__(self, detail: str) -> None: + super().__init__(detail) + + +class EmailConfirmationCodeNotFoundError(ConfirmationCodeNotFoundError): + """ + Класс для ошибок, связанных с ненахождением кода подтверждения на почте. + """ + + def __init__(self, email: EmailStr) -> None: + self.email = email + detail = f"The active confirmation code for email {email} does not exist." + super().__init__(detail) + + +class InvalidConfirmationCodeError(AuthenticationError): + """ + Класс для ошибок, связанных с вводом некорректного кода подтверждения. + """ + + def __init__(self, detail: str) -> None: + super().__init__(detail) + + +class InvalidEmailConfirmationCodeError(InvalidConfirmationCodeError): + """ + Класс для ошибок, связанных с некорректным кодом подтверждения, + отправленным на почту. + """ + + def __init__(self, email: EmailStr, confirmation_code: str) -> None: + self.email = email + self.confirmation_code = confirmation_code + detail = ( + f"The confirmation code {confirmation_code} for email {email} is not valid." + ) + super().__init__(detail) diff --git a/app/dependencies/caching.py b/app/dependencies/caching.py index d3ed2fc..fa3a43b 100644 --- a/app/dependencies/caching.py +++ b/app/dependencies/caching.py @@ -5,6 +5,7 @@ from core.redis import CacheService, RedisClient from dependencies.redis_client import ( + get_redis_client_for_confirmation_codes, get_redis_client_for_favorite_movies, get_redis_client_for_genres, get_redis_client_for_movies, @@ -55,3 +56,6 @@ async def dependency( get_cache_service_for_users = cache_service_factory( get_redis_client_for_users, ) +get_cache_service_for_confirmation_codes = cache_service_factory( + get_redis_client_for_confirmation_codes, +) diff --git a/app/dependencies/redis_client.py b/app/dependencies/redis_client.py index 19949cf..88b3007 100644 --- a/app/dependencies/redis_client.py +++ b/app/dependencies/redis_client.py @@ -49,3 +49,7 @@ async def get_redis_client() -> AsyncGenerator[RedisClient]: get_redis_client_for_watch_history = redis_client_factory( db=settings.redis.db.watch_history, ) + +get_redis_client_for_confirmation_codes = redis_client_factory( + db=settings.redis.db.confirmation_codes, +) diff --git a/app/dependencies/services.py b/app/dependencies/services.py index 9ee428c..5872891 100644 --- a/app/dependencies/services.py +++ b/app/dependencies/services.py @@ -7,12 +7,31 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.database.connection import session_factory +from core.redis import CacheService +from dependencies.caching import get_cache_service_for_confirmation_codes from services import GenreService, MovieService, ReviewService, UserService from services.favorite_movie import FavoriteMovieService from services.http_request import HttpRequestService from services.watch_history import WatchHistoryService +async def get_http_request_client() -> AsyncGenerator[AsyncClient]: + async with AsyncClient() as client: + yield client + + +async def get_http_request_service( + http_request_client: Annotated[ + AsyncClient, + Depends(get_http_request_client), + ], +) -> AsyncGenerator[HttpRequestService]: + http_request_service = HttpRequestService( + http_request_client=http_request_client, + ) + yield http_request_service + + async def get_db() -> AsyncGenerator[AsyncSession]: async with session_factory() as db: yield db @@ -76,9 +95,19 @@ async def get_user_service( AsyncSession, Depends(get_db), ], + redis_service: Annotated[ + CacheService, + Depends( + get_cache_service_for_confirmation_codes, + ), + ], + http_request_service: Annotated[ + HttpRequestService, + Depends(get_http_request_service), + ], ) -> AsyncGenerator[UserService]: try: - user_service = UserService(session) + user_service = UserService(session, redis_service, http_request_service) yield user_service finally: """ @@ -114,20 +143,3 @@ async def get_watch_history_service( """ Действия после view. """ - - -async def get_http_request_client() -> AsyncGenerator[AsyncClient]: - async with AsyncClient() as client: - yield client - - -async def get_http_request_service( - http_request_client: Annotated[ - AsyncClient, - Depends(get_http_request_client), - ], -) -> AsyncGenerator[HttpRequestService]: - http_request_service = HttpRequestService( - http_request_client=http_request_client, - ) - yield http_request_service diff --git a/app/repositories/user.py b/app/repositories/user.py index 502390d..3fa6054 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -51,10 +51,7 @@ async def get_all_users( return list(result.scalars().all()) async def create_user(self, create_user_data: UserCreate) -> User: - user = User( - **create_user_data.model_dump(exclude={"password"}), - encrypted_password=create_user_data.password, - ) + user = User(**create_user_data.model_dump()) self.session.add(user) await self.session.commit() await self.session.refresh(user) diff --git a/app/schemas/user.py b/app/schemas/user.py index 91e2b61..69ff52f 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -7,6 +7,7 @@ from core.constants import ( USER_EMAIL_MAX_LENGTH, USER_EMAIL_MIN_LENGTH, + USER_ENCRYPTED_PASSWORD_MAX_LENGTH, USER_LOGIN_MAX_LENGTH, USER_LOGIN_MIN_LENGTH, USER_NAME_MAX_LENGTH, @@ -57,6 +58,21 @@ ), ] +EncryptedPasswordConstraint = Annotated[ + str, + Len( + max_length=USER_ENCRYPTED_PASSWORD_MAX_LENGTH, + ), +] + +ConfirmationCode = Annotated[ + str, + Len( + min_length=6, + max_length=6, + ), +] + class UserBase(BaseModel): """ @@ -75,7 +91,16 @@ class UserCreate(UserBase): Модель для создания пользователя. """ + encrypted_password: EncryptedPasswordConstraint + + +class UserRegistration(UserBase): + """ + Модель для регистрирования пользователя. + """ + password: PasswordConstraint + confirmation_code: ConfirmationCode class UserUpdate(UserBase): diff --git a/app/services/user.py b/app/services/user.py index 5ad99f9..2a4dbd7 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -1,33 +1,51 @@ +import random from typing import cast from packages.celery.constants import Queue, TaskType +from pydantic import EmailStr from sqlalchemy.ext.asyncio import AsyncSession from core.celery.celery_app import app +from core.config import settings from core.constants import UserRole from core.exceptions.auth import InvalidPasswordError +from core.exceptions.confirmation_code import ( + EmailConfirmationCodeNotFoundError, + InvalidEmailConfirmationCodeError, +) from core.exceptions.user import ( UserEmailAlreadyExistsError, UserIdNotFoundError, UserLoginAlreadyExistsError, UserLoginNotFoundError, ) +from core.redis import CacheService from core.security.password_utils import hash_password, verify_password +from packages.schemas import SendEmail from repositories import UserRepository from schemas.auth import UserLogin from schemas.user import ( UserCreate, UserPartialUpdate, + UserRegistration, UserResponse, UserResponseList, UserUpdate, ) +from services.http_request import HttpRequestService class UserService: - def __init__(self, session: AsyncSession) -> None: + def __init__( + self, + session: AsyncSession, + redis_service: CacheService | None = None, + http_request_service: HttpRequestService | None = None, + ) -> None: self.session = session self.user_repository = UserRepository(session) + self.http_request_service = http_request_service + self.cache_service = redis_service async def get_user_by_id(self, user_id: int) -> UserResponse: user = await self.user_repository.get_user_by_id(user_id) @@ -57,25 +75,90 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList page=page, ) - async def create_user(self, create_user_data: UserCreate) -> UserResponse: - if await self.user_repository.user_login_exists(create_user_data.login): - raise UserLoginAlreadyExistsError(create_user_data.login) + async def get_confirmation_code(self, email: EmailStr) -> str: + confirmation_code = await self.cache_service.get(f"register:email:{email}") + if confirmation_code is None: + raise EmailConfirmationCodeNotFoundError( + email=email, + ) + return confirmation_code + + async def verify_confirmation_code( + self, + email: EmailStr, + confirmation_code: str, + ) -> None: + sent_confirmation_code = await self.get_confirmation_code(email) + if confirmation_code != sent_confirmation_code: + raise InvalidEmailConfirmationCodeError( + email=email, + confirmation_code=confirmation_code, + ) + + @staticmethod + def convert_registration_to_create_schema( + user_registration_data: UserRegistration, + ) -> UserCreate: + user_create_data = user_registration_data.model_dump( + exclude={"confirmation_code", "password"}, + ) + password = user_registration_data.password + encrypted_password = hash_password(password) + user_create_data["encrypted_password"] = encrypted_password + return UserCreate(**user_create_data) + + async def create_user( + self, + registration_user_data: UserRegistration, + ) -> UserResponse: + if await self.user_repository.user_login_exists(registration_user_data.login): + raise UserLoginAlreadyExistsError(registration_user_data.login) - if await self.user_repository.user_email_exists(create_user_data.email): - raise UserEmailAlreadyExistsError(create_user_data.email) + if await self.user_repository.user_email_exists(registration_user_data.email): + raise UserEmailAlreadyExistsError(registration_user_data.email) - create_user_data.password = hash_password(create_user_data.password) + await self.verify_confirmation_code( + registration_user_data.email, + registration_user_data.confirmation_code, + ) + + create_user_data = self.convert_registration_to_create_schema( + registration_user_data, + ) user = await self.user_repository.create_user(create_user_data) app.send_task( name=TaskType.send_welcome_email.value, args=[ - create_user_data.email, - create_user_data.name, + registration_user_data.email, + registration_user_data.name, ], queue=Queue.notification.value, ) return UserResponse.model_validate(user) + async def create_confirmation_code(self, email: EmailStr) -> str: + confirmation_code = "".join([str(random.randint(0, 9)) for _ in range(6)]) + await self.cache_service.set( + key=f"register:email:{email}", + value=confirmation_code, + ttl=60, + ) + return confirmation_code + + async def send_confirmation_code(self, email: EmailStr) -> None: + confirmation_code = await self.create_confirmation_code(email) + subject = "Confirm your email address" + body = f"Your confirmation code is {confirmation_code}" + email_data = SendEmail( + subject=subject, + body=body, + to_email=email, + ) + await self.http_request_service.post( + url=settings.notificationservice.send_email_endpoint, + json=email_data.model_dump(), + ) + async def make_admin(self, user_id: int) -> None: if not await self.user_repository.make_admin(user_id): raise UserIdNotFoundError(user_id) diff --git a/notification-service/api/__init__.py b/notification-service/api/__init__.py index e69de29..ff00717 100644 --- a/notification-service/api/__init__.py +++ b/notification-service/api/__init__.py @@ -0,0 +1,7 @@ +__all__ = ("router",) +from fastapi import APIRouter + +from .api_v1 import router as api_v1_router + +router = APIRouter(prefix="/api") +router.include_router(api_v1_router) diff --git a/notification-service/api/api_v1/__init__.py b/notification-service/api/api_v1/__init__.py index e69de29..4114c99 100644 --- a/notification-service/api/api_v1/__init__.py +++ b/notification-service/api/api_v1/__init__.py @@ -0,0 +1,8 @@ +__all__ = ("router",) + +from fastapi import APIRouter + +from .send_email_views import router as send_email_views_router + +router = APIRouter(prefix="/v1") +router.include_router(send_email_views_router) diff --git a/notification-service/api/api_v1/send_email_views.py b/notification-service/api/api_v1/send_email_views.py new file mode 100644 index 0000000..bfb069f --- /dev/null +++ b/notification-service/api/api_v1/send_email_views.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter + +from packages.schemas import SendEmail +from service import EmailService + +router = APIRouter( + tags=["Send email"], +) + + +@router.get("/send-welcome-email") +async def send_welcome_email_message( + email: str, + name: str, +) -> None: + await EmailService.send_welcome_email( + email=email, + name=name, + ) + + +@router.post("/send-email") +async def send_email( + email_data: SendEmail, +) -> None: + await EmailService.send_email( + subject=email_data.subject, + body=email_data.body, + to_email=email_data.to_email, + ) diff --git a/notification-service/api/main_views.py b/notification-service/api/main_views.py index 3374714..b6043e8 100644 --- a/notification-service/api/main_views.py +++ b/notification-service/api/main_views.py @@ -1,7 +1,5 @@ from fastapi import APIRouter, Request -from service import EmailService - router = APIRouter( tags=["Main"], ) @@ -25,27 +23,3 @@ def read_root( @router.get("/health") def check_health() -> dict[str, str]: return {"status": "ok"} - - -@router.get("/send") -async def send_email( - subject: str, - body: str, - to_email: str, -) -> None: - await EmailService.send_email( - subject=subject, - body=body, - to_email=to_email, - ) - - -@router.get("/welcome-email") -async def send_welcome_email_message( - email: str, - name: str, -) -> None: - await EmailService.send_welcome_email( - email=email, - name=name, - ) diff --git a/notification-service/main.py b/notification-service/main.py index b3a8a5b..bf3a82c 100644 --- a/notification-service/main.py +++ b/notification-service/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI +from api import router as api_router from api.main_views import router as main_router from lifespan import lifespan @@ -9,3 +10,4 @@ ) app.include_router(main_router) +app.include_router(api_router) diff --git a/notification-service/service.py b/notification-service/service.py index 62bef70..a09fabe 100644 --- a/notification-service/service.py +++ b/notification-service/service.py @@ -1,6 +1,7 @@ from email.message import EmailMessage from aiosmtplib import SMTP +from pydantic import EmailStr from core.config import settings @@ -22,7 +23,7 @@ async def send_email( cls, subject: str, body: str, - to_email: str, + to_email: EmailStr, ) -> None: smtp_client = cls.get_smtp_client() async with smtp_client: @@ -40,36 +41,36 @@ async def send_email( @classmethod async def send_welcome_email(cls, email: str, name: str) -> None: - subject = "Because you love movies as much as we do 🎬" - # ruff: disable[W291, W293, E501] + subject = "Потому что вы любите кино так же сильно, как и мы 🎬" + # ruff: disable[W293, E501] body_template = """ - Dear {name}, - - Some people watch movies. Others live them. + Дорогой {name}, + + Некоторые люди смотрят фильмы. Другие — живут ими. - If you're reading this, you probably care about more than just titles and posters. You care about stories. + Если вы читаете это, вам, скорее всего, важнее не просто названия и постеры. Вам важны истории. - That one shot that stays with you for days. + Тот самый кадр, который остаётся с вами на дни. - That's why we built MovieAPI. + Именно для этого мы создали MovieAPI. - Think of it as your second home: + Представьте, что это ваш второй дом: - Log every film you've ever seen + Записывайте каждый фильм, который вы когда-либо видели - Discover hidden gems you'd never find on mainstream sites + Открывайте скрытые жемчужины, которые вы никогда не найдёте на популярных сайтах - Keep your own private notebook of thoughts and ratings + Ведите свой личный блокнот с мыслями и оценками - No algorithms shouting at you. Just pure cinema. + Никаких алгоритмов, кричащих на вас. Только чистое кино. - Welcome home, {name}. + Добро пожаловать домой, {name}. - Let's watch something great. + Давайте посмотрим что-то великое. - — The MovieAPI Team + — Команда MovieAPI """ - # ruff: enable[W291, W293, E501] + # ruff: enable[W293, E501] await cls.send_email( subject=subject, body=body_template.format(name=name), diff --git a/packages/schemas.py b/packages/schemas.py index bd649e4..57143e6 100644 --- a/packages/schemas.py +++ b/packages/schemas.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr from packages.constants import S3Bucket, S3ClientMethod, S3ContentType @@ -33,3 +33,13 @@ class ConfirmUploadRequest(BaseModel): destination_bucket_name: S3Bucket source_object_name: str destination_object_name: str + + +class SendEmail(BaseModel): + """ + Модель для отправки сообщения на почту. + """ + + subject: str + to_email: EmailStr + body: str From 1bf9230f4846e3479336ddcbb287809f6d4296df Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Wed, 17 Jun 2026 18:40:05 +0300 Subject: [PATCH 13/47] Add frontend to confirmation email. --- frontend/app/api/api_v1/auth.js | 33 ++- frontend/app/data/state.js | 14 ++ frontend/app/services/methods/auth.js | 226 +++++++++++++----- .../layout_navbar_auth_catalog_genres.html | 100 +++++++- 4 files changed, 313 insertions(+), 60 deletions(-) diff --git a/frontend/app/api/api_v1/auth.js b/frontend/app/api/api_v1/auth.js index 4f00ac9..5b9bcd8 100644 --- a/frontend/app/api/api_v1/auth.js +++ b/frontend/app/api/api_v1/auth.js @@ -3,7 +3,25 @@ var parseResponseJson = window.ApiClient.parseResponseJson; var readErrorMessage = window.ApiClient.readErrorMessage; - function registerUser(payload) { + // ОТПРАВКА КОДА НА ПОЧТУ + function sendConfirmationCode(email) { + return fetch(apiUrl("/api/v1/auth/confirmation_code?email=" + encodeURIComponent(email)), { + method: "POST", + headers: { + Accept: "application/json", + }, + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; + }); + }); + } + + // РЕГИСТРАЦИЯ С КОДОМ + function registerUserWithCode(payload) { return fetch(apiUrl("/api/v1/auth/register"), { method: "POST", headers: { @@ -21,6 +39,13 @@ }); } + // СТАРАЯ РЕГИСТРАЦИЯ (оставляем для совместимости, но не используем) + function registerUser(payload) { + // Можно оставить или удалить + console.warn("registerUser is deprecated, use registerUserWithCode"); + return registerUserWithCode(payload); + } + function loginUser(username, password) { var body = new URLSearchParams(); body.set("username", username); @@ -48,8 +73,10 @@ } window.ApiAuth = { - registerUser: registerUser, + registerUser: registerUser, // оставляем для обратной совместимости + registerUserWithCode: registerUserWithCode, + sendConfirmationCode: sendConfirmationCode, loginUser: loginUser, logout: logout, }; -})(); +})(); \ No newline at end of file diff --git a/frontend/app/data/state.js b/frontend/app/data/state.js index 3eaba95..5b9d242 100644 --- a/frontend/app/data/state.js +++ b/frontend/app/data/state.js @@ -153,6 +153,20 @@ adminMoviesLoading: false, adminMoviesTotal: 0, + // Для двухэтапной регистрации + registerStep: 'form', // 'form' | 'verify' + registrationData: { + surname: '', + name: '', + login: '', + email: '', + password: '', + }, + confirmationCode: '', + resendTimer: 60, + canResend: false, + timerInterval: null, + // Форма для фильма editingMovie: null, showMovieForm: false, diff --git a/frontend/app/services/methods/auth.js b/frontend/app/services/methods/auth.js index c412b7e..7b48a98 100644 --- a/frontend/app/services/methods/auth.js +++ b/frontend/app/services/methods/auth.js @@ -1,58 +1,176 @@ (function () { window.AppMethodsAuth = { - onLogin: function () { - var self = this; - this.error = ""; - this.loading = true; - window.Api.loginUser(this.loginForm.username, this.loginForm.password) - .then(function () { - window.location.hash = "#/"; - window.location.reload(); - }) - .catch(function (e) { - self.error = e.message || "Не удалось войти"; - }) - .finally(function () { - self.loading = false; - }); - }, - - onRegister: function () { - var self = this; - this.error = ""; - this.success = ""; - this.loading = true; - window.Api.registerUser({ - surname: this.registerForm.surname.trim(), - name: this.registerForm.name.trim(), - login: this.registerForm.login.trim(), - email: this.registerForm.email.trim(), - password: this.registerForm.password, - }) - .then(function () { - window.location.href = "#/"; - window.location.reload(); - }) - .catch(function (e) { - self.error = e.message || "Ошибка регистрации"; - }) - .finally(function () { - self.loading = false; - }); - }, - onLogout: function () { - window.Api.logout(); - window.location.reload(); - }, - formatRegistrationDate: function (iso) { - if (!iso) { - return "—"; - } - try { - return new Date(iso).toLocaleString("ru-RU"); - } catch (e) { - return iso; + onLogin: function () { + var self = this; + this.error = ""; + this.loading = true; + window.ApiAuth.loginUser(this.loginForm.username, this.loginForm.password) + .then(function () { + window.location.hash = "#/"; + window.location.reload(); + }) + .catch(function (e) { + self.error = e.message || "Не удалось войти"; + }) + .finally(function () { + self.loading = false; + }); + }, + + // ОТПРАВКА КОДА НА ПОЧТУ + onSendCode: function () { + var self = this; + this.error = ""; + this.success = ""; + this.loading = true; + + // Сохраняем данные регистрации + this.registrationData = { + surname: this.registerForm.surname.trim(), + name: this.registerForm.name.trim(), + login: this.registerForm.login.trim(), + email: this.registerForm.email.trim(), + password: this.registerForm.password, + }; + + window.ApiAuth.sendConfirmationCode(this.registrationData.email) + .then(function () { + self.registerStep = 'verify'; + self.success = "Код подтверждения отправлен на почту"; + self.startResendTimer(60); + }) + .catch(function (e) { + self.error = e.message || "Не удалось отправить код"; + }) + .finally(function () { + self.loading = false; + }); + }, + + // ПОДТВЕРЖДЕНИЕ КОДА И РЕГИСТРАЦИЯ + onVerifyCode: function () { + var self = this; + this.error = ""; + this.success = ""; + this.loading = true; + + var payload = { + surname: this.registrationData.surname, + name: this.registrationData.name, + login: this.registrationData.login, + email: this.registrationData.email, + password: this.registrationData.password, + confirmation_code: this.confirmationCode.trim(), + }; + + window.ApiAuth.registerUserWithCode(payload) + .then(function () { + window.location.hash = "#/"; + window.location.reload(); + }) + .catch(function (e) { + self.error = e.message || "Неверный код подтверждения"; + }) + .finally(function () { + self.loading = false; + }); + }, + + // ПОВТОРНАЯ ОТПРАВКА КОДА + onResendCode: function () { + var self = this; + this.error = ""; + this.success = ""; + this.loading = true; + + window.ApiAuth.sendConfirmationCode(this.registrationData.email) + .then(function () { + self.success = "Новый код отправлен на почту"; + self.startResendTimer(60); + }) + .catch(function (e) { + self.error = e.message || "Не удалось отправить код"; + }) + .finally(function () { + self.loading = false; + }); + }, + + // ВОЗВРАТ К ФОРМЕ РЕГИСТРАЦИИ + onBackToRegister: function () { + this.registerStep = 'form'; + this.confirmationCode = ''; + this.error = ''; + this.success = ''; + if (this.timerInterval) { + clearInterval(this.timerInterval); + this.timerInterval = null; + } + }, + + // ОБНОВЛЕННАЯ РЕГИСТРАЦИЯ + onRegister: function () { + var self = this; + this.error = ""; + this.success = ""; + + // Валидация пароля + if (this.registerForm.password.length < 8) { + this.error = "Пароль должен быть минимум 8 символов"; + return; + } + + // Валидация email + var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(this.registerForm.email.trim())) { + this.error = "Введите корректный email"; + return; + } + + // Валидация логина + if (this.registerForm.login.trim().length < 3) { + this.error = "Логин должен быть минимум 3 символа"; + return; + } + + // Отправляем код + this.onSendCode(); + }, + + onLogout: function () { + window.ApiAuth.logout(); + window.location.reload(); + }, + + formatRegistrationDate: function (iso) { + if (!iso) { + return "—"; + } + try { + return new Date(iso).toLocaleString("ru-RU"); + } catch (e) { + return iso; + } + }, + + // ТАЙМЕР ДЛЯ ПОВТОРНОЙ ОТПРАВКИ + startResendTimer: function (seconds) { + var self = this; + this.resendTimer = seconds; + this.canResend = false; + + if (this.timerInterval) { + clearInterval(this.timerInterval); + } + + this.timerInterval = setInterval(function () { + self.resendTimer--; + if (self.resendTimer <= 0) { + clearInterval(self.timerInterval); + self.timerInterval = null; + self.canResend = true; } - }, + }, 1000); + }, }; -})(); +})(); \ No newline at end of file diff --git a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html index 8c1fdbb..16b2e09 100644 --- a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html +++ b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html @@ -62,7 +62,7 @@ - +
@@ -95,12 +95,25 @@

Вход

- -
+ +

Регистрация

+ + + + + + +
@@ -148,6 +161,87 @@

Регистрация

+ +
+
+
+
+

Подтверждение email

+

+ На почту {{ registrationData.email }} отправлен код подтверждения. + Введите его ниже. +

+ + + + + + + + +
+ + +
+ Введите 6-значный код из письма +
+
+ +
+ + +
+ + + +
+ + Не пришло письмо? + + +
+ +

+ Вернуться ко входу +

+
+
+
+
+
From 39fe88898b7d98577b8de59c492022bcbf045cbd Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Thu, 18 Jun 2026 09:18:40 +0300 Subject: [PATCH 14/47] Add backend to send confirmation email code on auth (frontend temporary does not work). --- app/api/api_v1/auth.py | 41 +++++------- app/cache_services/user.py | 18 +----- app/core/exceptions/user.py | 13 ++++ app/dependencies/annotations/security.py | 7 +++ app/dependencies/auth.py | 14 +++++ app/dependencies/services.py | 6 +- app/repositories/user.py | 3 +- app/schemas/auth.py | 23 ++++++- app/services/user.py | 62 +++++++++++++------ .../api/api_v1/send_email_views.py | 2 +- notification-service/core/celery/tasks.py | 16 +++++ notification-service/service.py | 12 ++++ packages/celery/constants.py | 1 + 13 files changed, 149 insertions(+), 69 deletions(-) diff --git a/app/api/api_v1/auth.py b/app/api/api_v1/auth.py index 68ace85..7d1d887 100644 --- a/app/api/api_v1/auth.py +++ b/app/api/api_v1/auth.py @@ -4,18 +4,13 @@ ) from pydantic import EmailStr -from core.constants import BEARER_TOKEN_TYPE -from core.security.jwt_utils import ( - create_access_token, - create_refresh_token, -) from dependencies.annotations.cache_services import UserCacheServiceDep from dependencies.annotations.security import ( AuthUserByRefreshTokenDep, - OAuth2Dep, + GetLoginDataDep, ) from dependencies.annotations.services import UserServiceDep -from schemas.auth import UserLogin +from schemas.auth import ConfirmEmailRequest from schemas.token_info import TokenInfo from schemas.user import ( UserRegistration, @@ -50,25 +45,21 @@ async def register_user( @router.post( "/login", - response_model=TokenInfo, status_code=status.HTTP_200_OK, ) async def login_user( - oauth2_form: OAuth2Dep, - user_service: UserCacheServiceDep, + login_data: GetLoginDataDep, + user_service: UserServiceDep, +) -> EmailStr: + return await user_service.authenticate_user(login_data) + + +@router.post("/confirm-email") +async def confirm_email( + confirm_email_request: ConfirmEmailRequest, + user_service: UserServiceDep, ) -> TokenInfo: - login_data = UserLogin( - login=oauth2_form.username, - password=oauth2_form.password, - ) - user = await user_service.authenticate_user(login_data) - access_token = create_access_token(user) - refresh_token = create_refresh_token(user) - return TokenInfo( - access_token=access_token, - refresh_token=refresh_token, - token_type=BEARER_TOKEN_TYPE, - ) + return await user_service.confirm_email(confirm_email_request) @router.post( @@ -79,8 +70,6 @@ async def login_user( ) async def refresh_access_token( user_id: AuthUserByRefreshTokenDep, - user_service: UserCacheServiceDep, + user_service: UserServiceDep, ) -> TokenInfo: - user = await user_service.get_user_by_id(user_id) - access_token = create_access_token(user) - return TokenInfo(access_token=access_token) + return await user_service.refresh_access_token(user_id) diff --git a/app/cache_services/user.py b/app/cache_services/user.py index bb3cd96..83a4d26 100644 --- a/app/cache_services/user.py +++ b/app/cache_services/user.py @@ -1,7 +1,6 @@ from typing import cast from core.redis.cache_service import CacheService -from schemas.auth import UserLogin from schemas.user import ( UserPartialUpdate, UserRegistration, @@ -52,7 +51,8 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList return users_response async def create_user( - self, registration_user_data: UserRegistration, + self, + registration_user_data: UserRegistration, ) -> UserResponse: user_response = await self.user_service.create_user(registration_user_data) key = CacheService.create_cache_key("user") @@ -96,17 +96,3 @@ async def delete_user_by_login(self, login: str) -> None: key = CacheService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) - - async def authenticate_user(self, login_data: UserLogin) -> UserResponse: - key = CacheService.create_cache_key( - "user", - login=login_data.login, - password=login_data.password, - ) - cached_user_response = await self.cache_service.get(key, UserResponse) - if cached_user_response is not None: - return cast(UserResponse, cached_user_response) - - user_response = await self.user_service.authenticate_user(login_data) - await self.cache_service.set(key, user_response) - return user_response diff --git a/app/core/exceptions/user.py b/app/core/exceptions/user.py index 3eb1d1c..58cf6ae 100644 --- a/app/core/exceptions/user.py +++ b/app/core/exceptions/user.py @@ -1,3 +1,5 @@ +from pydantic import EmailStr + from core.exceptions.base import ConflictError, NotFoundError @@ -32,6 +34,17 @@ def __init__(self, login: str) -> None: super().__init__(detail) +class UserEmailNotFoundError(UserNotFoundError): + """ + Класс для ошибок, связанных с ненахождением пользователя с таким логином. + """ + + def __init__(self, email: EmailStr) -> None: + self.email = email + detail = f"User with email = {email} not found." + super().__init__(detail) + + class UserAlreadyExistsError(ConflictError): """ Класс для ошибок, связанных с существованием пользователя diff --git a/app/dependencies/annotations/security.py b/app/dependencies/annotations/security.py index f26a399..69a2ae4 100644 --- a/app/dependencies/annotations/security.py +++ b/app/dependencies/annotations/security.py @@ -5,9 +5,11 @@ from dependencies.auth import ( get_admin_by_access_token, + get_login_data, get_user_by_access_token, get_user_by_refresh_token, ) +from schemas.auth import UserLogin AuthUserByAccessTokenDep = Annotated[ int, @@ -34,3 +36,8 @@ OAuth2PasswordRequestForm, Depends(), ] + +GetLoginDataDep = Annotated[ + UserLogin, + Depends(get_login_data), +] diff --git a/app/dependencies/auth.py b/app/dependencies/auth.py index 26ed4ad..a5f9555 100644 --- a/app/dependencies/auth.py +++ b/app/dependencies/auth.py @@ -1,6 +1,7 @@ from typing import Annotated, cast from fastapi import Depends +from fastapi.security import OAuth2PasswordRequestForm from core.config import settings from core.constants import ACCESS_TOKEN_TYPE, REFRESH_TOKEN_TYPE @@ -8,6 +9,7 @@ from core.security.jwt_utils import decode_jwt from core.security.validators import validate_token_payload from dependencies.services import get_user_service +from schemas.auth import UserLogin from services import UserService @@ -64,3 +66,15 @@ async def get_admin_by_access_token( return user_id raise PermissionDeniedError + + +def get_login_data( + oauth2_form: Annotated[ + OAuth2PasswordRequestForm, + Depends(), + ], +) -> UserLogin: + return UserLogin( + login=oauth2_form.username, + password=oauth2_form.password, + ) diff --git a/app/dependencies/services.py b/app/dependencies/services.py index 5872891..481c49a 100644 --- a/app/dependencies/services.py +++ b/app/dependencies/services.py @@ -101,13 +101,9 @@ async def get_user_service( get_cache_service_for_confirmation_codes, ), ], - http_request_service: Annotated[ - HttpRequestService, - Depends(get_http_request_service), - ], ) -> AsyncGenerator[UserService]: try: - user_service = UserService(session, redis_service, http_request_service) + user_service = UserService(session, redis_service) yield user_service finally: """ diff --git a/app/repositories/user.py b/app/repositories/user.py index 3fa6054..11e703b 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -1,3 +1,4 @@ +from pydantic import EmailStr from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession @@ -30,7 +31,7 @@ async def get_user_by_login(self, login: str) -> User | None: result = await self.session.execute(stmt) return result.scalars().first() - async def get_user_by_email(self, email: str) -> User | None: + async def get_user_by_email(self, email: EmailStr) -> User | None: stmt = select(User).where(User.email == email) result = await self.session.execute(stmt) return result.scalars().first() diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 3572238..f8537e8 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -1,7 +1,18 @@ -from pydantic import BaseModel +from typing import Annotated + +from annotated_types import Len +from pydantic import BaseModel, EmailStr from schemas.user import LoginConstraint, PasswordConstraint +ConfirmationCode = Annotated[ + str, + Len( + min_length=6, + max_length=6, + ), +] + class UserLogin(BaseModel): """ @@ -10,3 +21,13 @@ class UserLogin(BaseModel): login: LoginConstraint password: PasswordConstraint + + +class ConfirmEmailRequest(BaseModel): + """ + Модель для двухфакторной аутентификации: + подтверждение через дополнительный код. + """ + + email: EmailStr + confirmation_code: ConfirmationCode diff --git a/app/services/user.py b/app/services/user.py index 2a4dbd7..656e493 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -6,8 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.celery.celery_app import app -from core.config import settings -from core.constants import UserRole +from core.constants import BEARER_TOKEN_TYPE, UserRole from core.exceptions.auth import InvalidPasswordError from core.exceptions.confirmation_code import ( EmailConfirmationCodeNotFoundError, @@ -15,15 +14,17 @@ ) from core.exceptions.user import ( UserEmailAlreadyExistsError, + UserEmailNotFoundError, UserIdNotFoundError, UserLoginAlreadyExistsError, UserLoginNotFoundError, ) from core.redis import CacheService +from core.security.jwt_utils import create_access_token, create_refresh_token from core.security.password_utils import hash_password, verify_password -from packages.schemas import SendEmail from repositories import UserRepository -from schemas.auth import UserLogin +from schemas.auth import ConfirmEmailRequest, UserLogin +from schemas.token_info import TokenInfo from schemas.user import ( UserCreate, UserPartialUpdate, @@ -32,7 +33,6 @@ UserResponseList, UserUpdate, ) -from services.http_request import HttpRequestService class UserService: @@ -40,11 +40,9 @@ def __init__( self, session: AsyncSession, redis_service: CacheService | None = None, - http_request_service: HttpRequestService | None = None, ) -> None: self.session = session self.user_repository = UserRepository(session) - self.http_request_service = http_request_service self.cache_service = redis_service async def get_user_by_id(self, user_id: int) -> UserResponse: @@ -61,6 +59,12 @@ async def get_user_by_login(self, login: str) -> UserResponse: raise UserLoginNotFoundError(login) + async def get_user_by_email(self, email: EmailStr) -> UserResponse: + user = await self.user_repository.get_user_by_email(email) + if user is None: + raise UserEmailNotFoundError(email) + return UserResponse.model_validate(user) + async def user_login_exists(self, login: str) -> bool: return await self.user_repository.user_login_exists(login) @@ -95,6 +99,23 @@ async def verify_confirmation_code( confirmation_code=confirmation_code, ) + async def confirm_email( + self, + confirm_email_request: ConfirmEmailRequest, + ) -> TokenInfo: + await self.verify_confirmation_code( + confirm_email_request.email, + confirm_email_request.confirmation_code, + ) + user = await self.get_user_by_email(confirm_email_request.email) + access_token = create_access_token(user) + refresh_token = create_refresh_token(user) + return TokenInfo( + access_token=access_token, + refresh_token=refresh_token, + token_type=BEARER_TOKEN_TYPE, + ) + @staticmethod def convert_registration_to_create_schema( user_registration_data: UserRegistration, @@ -147,16 +168,13 @@ async def create_confirmation_code(self, email: EmailStr) -> str: async def send_confirmation_code(self, email: EmailStr) -> None: confirmation_code = await self.create_confirmation_code(email) - subject = "Confirm your email address" - body = f"Your confirmation code is {confirmation_code}" - email_data = SendEmail( - subject=subject, - body=body, - to_email=email, - ) - await self.http_request_service.post( - url=settings.notificationservice.send_email_endpoint, - json=email_data.model_dump(), + app.send_task( + name=TaskType.send_confirmation_email_code.value, + args=[ + email, + confirmation_code, + ], + queue=Queue.notification.value, ) async def make_admin(self, user_id: int) -> None: @@ -231,7 +249,7 @@ async def delete_user_by_login(self, login: str) -> None: if not await self.user_repository.delete_user_by_login(login): raise UserLoginNotFoundError(login) - async def authenticate_user(self, login_data: UserLogin) -> UserResponse: + async def authenticate_user(self, login_data: UserLogin) -> EmailStr: user = await self.user_repository.get_user_by_login(login_data.login) if user is None: raise UserLoginNotFoundError(login_data.login) @@ -239,7 +257,8 @@ async def authenticate_user(self, login_data: UserLogin) -> UserResponse: if not verify_password(login_data.password, user.encrypted_password): raise InvalidPasswordError - return UserResponse.model_validate(user) + await self.send_confirmation_code(user.email) + return user.email async def is_admin(self, user_id: int) -> bool: role = await self.user_repository.get_user_role(user_id) @@ -247,3 +266,8 @@ async def is_admin(self, user_id: int) -> bool: raise UserIdNotFoundError(user_id) return role == UserRole.admin.value + + async def refresh_access_token(self, user_id: int) -> TokenInfo: + user = await self.get_user_by_id(user_id) + access_token = create_access_token(user) + return TokenInfo(access_token=access_token) diff --git a/notification-service/api/api_v1/send_email_views.py b/notification-service/api/api_v1/send_email_views.py index bfb069f..f1b9d17 100644 --- a/notification-service/api/api_v1/send_email_views.py +++ b/notification-service/api/api_v1/send_email_views.py @@ -1,6 +1,6 @@ from fastapi import APIRouter - from packages.schemas import SendEmail + from service import EmailService router = APIRouter( diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index 8819fab..3459c86 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -1,6 +1,7 @@ import asyncio from packages.celery.constants import TaskType +from pydantic import EmailStr from core.celery.celery_app import app from service import EmailService @@ -16,3 +17,18 @@ def send_welcome_email(email: str, name: str) -> None: name, ), ) + + +@app.task( + name=TaskType.send_confirmation_email_code.value, +) +def send_confirmation_email_code( + email: EmailStr, + confirmation_code: str, +) -> None: + asyncio.run( + EmailService.send_confirmation_email_code( + email=email, + confirmation_code=confirmation_code, + ), + ) diff --git a/notification-service/service.py b/notification-service/service.py index a09fabe..3b569e2 100644 --- a/notification-service/service.py +++ b/notification-service/service.py @@ -76,3 +76,15 @@ async def send_welcome_email(cls, email: str, name: str) -> None: body=body_template.format(name=name), to_email=email, ) + + @classmethod + async def send_confirmation_email_code( + cls, email: EmailStr, confirmation_code: str, + ) -> None: + subject = "Confirm your email address" + body = f"Your confirmation code is {confirmation_code}" + await cls.send_email( + subject=subject, + body=body, + to_email=email, + ) diff --git a/packages/celery/constants.py b/packages/celery/constants.py index 4fb08ac..210aeae 100644 --- a/packages/celery/constants.py +++ b/packages/celery/constants.py @@ -9,3 +9,4 @@ class Queue(StrEnum): class TaskType(StrEnum): delete_temporary_file = "mediaservice.media.delete_temporary_file" send_welcome_email = "notification-service.email.send-welcome-email" + send_confirmation_email_code = "notification-service.email.confirm_email" From b3bf5d577849d573a555655f6208738500ea3459 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Thu, 18 Jun 2026 13:59:34 +0300 Subject: [PATCH 15/47] Add working frontend to 2FA. --- frontend/app/api/api_v1/auth.js | 37 ++++++++ frontend/app/data/state.js | 8 ++ frontend/app/services/methods/auth.js | 91 ++++++++++++++++++- .../layout_navbar_auth_catalog_genres.html | 91 ++++++++++++++++++- 4 files changed, 221 insertions(+), 6 deletions(-) diff --git a/frontend/app/api/api_v1/auth.js b/frontend/app/api/api_v1/auth.js index 5b9bcd8..6594c84 100644 --- a/frontend/app/api/api_v1/auth.js +++ b/frontend/app/api/api_v1/auth.js @@ -3,6 +3,41 @@ var parseResponseJson = window.ApiClient.parseResponseJson; var readErrorMessage = window.ApiClient.readErrorMessage; + function confirmEmail(payload) { + return fetch(apiUrl("/api/v1/auth/confirm-email"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; + }); + }); + } + + // ПОВТОРНАЯ ОТПРАВКА 2FA КОДА (используем тот же эндпоинт) + function resend2FACode(email) { + return fetch(apiUrl("/api/v1/auth/confirmation_code?email=" + encodeURIComponent(email)), { + method: "POST", + headers: { + Accept: "application/json", + }, + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; + }); + }); + } + // ОТПРАВКА КОДА НА ПОЧТУ function sendConfirmationCode(email) { return fetch(apiUrl("/api/v1/auth/confirmation_code?email=" + encodeURIComponent(email)), { @@ -74,9 +109,11 @@ window.ApiAuth = { registerUser: registerUser, // оставляем для обратной совместимости + confirmEmail: confirmEmail, registerUserWithCode: registerUserWithCode, sendConfirmationCode: sendConfirmationCode, loginUser: loginUser, logout: logout, + resend2FACode: resend2FACode, }; })(); \ No newline at end of file diff --git a/frontend/app/data/state.js b/frontend/app/data/state.js index 5b9d242..d91428b 100644 --- a/frontend/app/data/state.js +++ b/frontend/app/data/state.js @@ -185,6 +185,14 @@ movieSourceFile: null, movieSourceFileName: "", + // Для двухфакторной аутентификации + loginStep: 'form', // 'form' | 'verify' + loginEmail: '', // email из ответа /login + loginCode: '', // 6-значный код + loginResendTimer: 60, + loginCanResend: false, + loginTimerInterval: null, + deletingGenreId: null, deletingGenreName: null, genreDeleting: false, diff --git a/frontend/app/services/methods/auth.js b/frontend/app/services/methods/auth.js index 7b48a98..1c16e20 100644 --- a/frontend/app/services/methods/auth.js +++ b/frontend/app/services/methods/auth.js @@ -1,22 +1,104 @@ (function () { window.AppMethodsAuth = { + // ==================== ЛОГИН С 2FA ==================== onLogin: function () { var self = this; this.error = ""; this.loading = true; + window.ApiAuth.loginUser(this.loginForm.username, this.loginForm.password) - .then(function () { + .then(function (email) { + self.loginEmail = email; + self.loginStep = 'verify'; + self.success = "Код подтверждения отправлен на почту"; + self.startLoginResendTimer(60); // Запускаем таймер + }) + .catch(function (e) { + self.error = e.message || "Не удалось войти"; + }) + .finally(function () { + self.loading = false; + }); + }, + + // ПОДТВЕРЖДЕНИЕ 2FA КОДА + onVerifyLoginCode: function () { + var self = this; + this.error = ""; + this.loading = true; + + window.ApiAuth.confirmEmail({ + email: this.loginEmail, + confirmation_code: this.loginCode.trim(), + }) + .then(function (data) { + // Сохраняем токены и перезагружаем страницу + window.TokenStore.setTokens(data.access_token, data.refresh_token); window.location.hash = "#/"; window.location.reload(); }) .catch(function (e) { - self.error = e.message || "Не удалось войти"; + self.error = e.message || "Неверный код подтверждения"; + }) + .finally(function () { + self.loading = false; + }); + }, + + // ПОВТОРНАЯ ОТПРАВКА 2FA КОДА (пока без отдельного эндпоинта) + onResendLoginCode: function () { + var self = this; + this.error = ""; + this.success = ""; + this.loading = true; + + window.ApiAuth.resend2FACode(this.loginEmail) + .then(function () { + self.success = "Новый код отправлен на почту"; + self.startLoginResendTimer(60); // Запускаем таймер + }) + .catch(function (e) { + self.error = e.message || "Не удалось отправить код"; }) .finally(function () { self.loading = false; }); }, + + // ВОЗВРАТ К ФОРМЕ ЛОГИНА + onBackToLogin: function () { + this.loginStep = 'form'; + this.loginCode = ''; + this.error = ''; + this.success = ''; + if (this.loginTimerInterval) { + clearInterval(this.loginTimerInterval); + this.loginTimerInterval = null; + } + }, + + // ТАЙМЕР ДЛЯ 2FA + startLoginResendTimer: function (seconds) { + var self = this; + this.loginResendTimer = seconds; + this.loginCanResend = false; + + if (this.loginTimerInterval) { + clearInterval(this.loginTimerInterval); + } + + this.loginTimerInterval = setInterval(function () { + self.loginResendTimer--; + if (self.loginResendTimer <= 0) { + clearInterval(self.loginTimerInterval); + self.loginTimerInterval = null; + self.loginCanResend = true; + } + }, 1000); + }, + + // ==================== РЕГИСТРАЦИЯ ==================== // ОТПРАВКА КОДА НА ПОЧТУ onSendCode: function () { var self = this; @@ -76,7 +158,7 @@ }); }, - // ПОВТОРНАЯ ОТПРАВКА КОДА + // ПОВТОРНАЯ ОТПРАВКА КОДА (регистрация) onResendCode: function () { var self = this; this.error = ""; @@ -137,6 +219,7 @@ this.onSendCode(); }, + // ==================== ОБЩИЕ МЕТОДЫ ==================== onLogout: function () { window.ApiAuth.logout(); window.location.reload(); @@ -153,7 +236,7 @@ } }, - // ТАЙМЕР ДЛЯ ПОВТОРНОЙ ОТПРАВКИ + // ТАЙМЕР ДЛЯ ПОВТОРНОЙ ОТПРАВКИ (регистрация) startResendTimer: function (seconds) { var self = this; this.resendTimer = seconds; diff --git a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html index 16b2e09..19a9297 100644 --- a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html +++ b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html @@ -63,11 +63,18 @@ -
+ +

Вход

+ + +
@@ -95,6 +102,85 @@

Вход

+ +
+
+
+
+

Двухфакторная аутентификация

+

+ На почту {{ loginEmail }} отправлен код подтверждения. + Введите его ниже для завершения входа. +

+ + + + + + +
+ + +
+ Введите 6-значный код из письма +
+
+ +
+ + +
+ + + +
+ + Не пришло письмо? + + +
+ +

+ Вернуться ко входу +

+
+
+
+
+
@@ -161,7 +247,7 @@

Регистрация

- +
@@ -242,6 +328,7 @@

Подтверждение email

+
From 3aadaa63aecc33c8b27793448c4ddb4b1276a216 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Fri, 19 Jun 2026 13:13:10 +0300 Subject: [PATCH 16/47] Rename a lot of files and entities for readable code. --- app/api/api_v1/auth.py | 16 +- app/cache_services/favorite_movie.py | 4 +- app/cache_services/genre.py | 18 +- app/cache_services/movie.py | 24 +-- app/cache_services/review.py | 30 +-- app/cache_services/user.py | 20 +- app/cache_services/watch_history.py | 16 +- app/core/rabbitmq/utils.py | 38 ++-- app/core/redis/__init__.py | 4 +- .../redis/{cache_service.py => service.py} | 2 +- .../annotations/cache_services.py | 52 +++--- app/dependencies/cache_services.py | 72 ++++---- app/dependencies/caching.py | 61 ------- app/dependencies/rate_limiter.py | 4 +- app/dependencies/redis_client.py | 16 +- app/dependencies/redis_services.py | 61 +++++++ app/dependencies/services.py | 8 +- app/services/user.py | 4 +- backup.sql | 172 ------------------ 19 files changed, 227 insertions(+), 395 deletions(-) rename app/core/redis/{cache_service.py => service.py} (98%) delete mode 100644 app/dependencies/caching.py create mode 100644 app/dependencies/redis_services.py delete mode 100644 backup.sql diff --git a/app/api/api_v1/auth.py b/app/api/api_v1/auth.py index 7d1d887..55922e1 100644 --- a/app/api/api_v1/auth.py +++ b/app/api/api_v1/auth.py @@ -23,14 +23,6 @@ ) -@router.post("/confirmation_code") -async def send_confirmation_code( - email: EmailStr, - user_service: UserServiceDep, -) -> None: - await user_service.send_confirmation_code(email) - - @router.post( "/register", response_model=UserResponse, @@ -54,6 +46,14 @@ async def login_user( return await user_service.authenticate_user(login_data) +@router.post("/confirmation_code") +async def send_confirmation_code( + email: EmailStr, + user_service: UserServiceDep, +) -> None: + await user_service.send_confirmation_code(email) + + @router.post("/confirm-email") async def confirm_email( confirm_email_request: ConfirmEmailRequest, diff --git a/app/cache_services/favorite_movie.py b/app/cache_services/favorite_movie.py index 8d0e8a0..357d600 100644 --- a/app/cache_services/favorite_movie.py +++ b/app/cache_services/favorite_movie.py @@ -1,6 +1,6 @@ from typing import cast -from core.redis.cache_service import CacheService +from core.redis.service import RedisService from schemas.favorite_movie import ( FavoriteMovieCreate, FavoriteMovieWithMovieResponse, @@ -13,7 +13,7 @@ class FavoriteMovieCacheService: def __init__( self, favorite_movie_service: FavoriteMovieService, - cache_service: CacheService, + cache_service: RedisService, ) -> None: self.favorite_movie_service = favorite_movie_service self.cache_service = cache_service diff --git a/app/cache_services/genre.py b/app/cache_services/genre.py index 236c0ac..9c2e63a 100644 --- a/app/cache_services/genre.py +++ b/app/cache_services/genre.py @@ -1,6 +1,6 @@ from typing import cast -from core.redis.cache_service import CacheService +from core.redis.service import RedisService from schemas.genre import ( GenreCreate, GenrePartialUpdate, @@ -15,13 +15,13 @@ class GenreCacheService: def __init__( self, genre_service: GenreService, - cache_service: CacheService, + cache_service: RedisService, ) -> None: self.genre_service = genre_service self.cache_service = cache_service async def get_genre_by_id(self, genre_id: int) -> GenreResponse: - key = CacheService.create_cache_key("genre", genre_id=genre_id) + key = RedisService.create_cache_key("genre", genre_id=genre_id) cached_genre_response = await self.cache_service.get(key, GenreResponse) if cached_genre_response is not None: return cast(GenreResponse, cached_genre_response) @@ -35,7 +35,7 @@ async def get_all_genres( size: int = 10, page: int = 1, ) -> GenreResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "genres", size=size, page=page, @@ -54,7 +54,7 @@ async def search_genres_by_name( size: int = 10, page: int = 1, ) -> GenreResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "genres", name=name, size=size, @@ -74,7 +74,7 @@ async def search_genres_by_name( async def create_genre(self, create_data: GenreCreate) -> GenreResponse: genre_response = await self.genre_service.create_genre(create_data) - key = CacheService.create_cache_key("genre") + key = RedisService.create_cache_key("genre") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return genre_response @@ -85,7 +85,7 @@ async def update_genre( update_data: GenreUpdate, ) -> GenreResponse: genre_response = await self.genre_service.update_genre(genre_id, update_data) - key = CacheService.create_cache_key("genre") + key = RedisService.create_cache_key("genre") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return genre_response @@ -99,13 +99,13 @@ async def partial_update_genre( genre_id, update_data, ) - key = CacheService.create_cache_key("genre") + key = RedisService.create_cache_key("genre") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return genre_response async def delete_genre_by_id(self, genre_id: int) -> None: await self.genre_service.delete_genre_by_id(genre_id) - key = CacheService.create_cache_key("genre") + key = RedisService.create_cache_key("genre") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) diff --git a/app/cache_services/movie.py b/app/cache_services/movie.py index 0a21010..09f7df1 100644 --- a/app/cache_services/movie.py +++ b/app/cache_services/movie.py @@ -1,6 +1,6 @@ from typing import cast -from core.redis.cache_service import CacheService +from core.redis.service import RedisService from dependencies.annotations.validators import PaginationPageDep, PaginationSizeDep from schemas.movie import ( MovieCreate, @@ -19,15 +19,15 @@ class MovieCacheService: def __init__( self, movie_service: MovieService, - cache_service_for_movie: CacheService, - cache_service_for_watch_history: CacheService, + cache_service_for_movie: RedisService, + cache_service_for_watch_history: RedisService, ) -> None: self.movie_service = movie_service self.cache_service_for_movie = cache_service_for_movie self.cache_service_for_watch_history = cache_service_for_watch_history async def get_movie_by_id(self, movie_id: int) -> MovieWithGenreResponse: - key = CacheService.create_cache_key("movie", movie_id=movie_id) + key = RedisService.create_cache_key("movie", movie_id=movie_id) cached_movie_response = await self.cache_service_for_movie.get( key, MovieWithGenreResponse, @@ -44,7 +44,7 @@ async def get_movies( size: int = 10, page: int = 1, ) -> MovieWithGenreResponseList: - key = CacheService.create_cache_key("movies", size=size, page=page) + key = RedisService.create_cache_key("movies", size=size, page=page) cached_movies_response = await self.cache_service_for_movie.get( key, MovieWithGenreResponseList, @@ -62,7 +62,7 @@ async def search_movies_with_filters( size: PaginationSizeDep = 10, page: PaginationPageDep = 1, ) -> MovieWithGenreResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "movies", size=size, page=page, @@ -89,7 +89,7 @@ async def get_movies_by_genre_id( size: int = 10, page: int = 1, ) -> MovieResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "movies", genre_id=genre_id, size=size, @@ -119,7 +119,7 @@ async def watch_movie( user_id, create_watch_history_data, ) - key = CacheService.create_cache_key("watch_history") + key = RedisService.create_cache_key("watch_history") pattern = key + "*" await self.cache_service_for_watch_history.delete_by_pattern(pattern) return movie_response @@ -129,7 +129,7 @@ async def create_movie( create_movie_data: MovieCreate, ) -> MovieWithGenreResponse: movie_response = await self.movie_service.create_movie(create_movie_data) - key = CacheService.create_cache_key("movie") + key = RedisService.create_cache_key("movie") pattern = key + "*" await self.cache_service_for_movie.delete_by_pattern(pattern) return movie_response @@ -143,7 +143,7 @@ async def update_movie( movie_id, update_movie_data, ) - key = CacheService.create_cache_key("movie") + key = RedisService.create_cache_key("movie") pattern = key + "*" await self.cache_service_for_movie.delete_by_pattern(pattern) return movie_response @@ -157,13 +157,13 @@ async def partial_update_movie( movie_id, update_movie_data, ) - key = CacheService.create_cache_key("movie") + key = RedisService.create_cache_key("movie") pattern = key + "*" await self.cache_service_for_movie.delete_by_pattern(pattern) return movie_response async def delete_movie_by_id(self, movie_id: int) -> None: await self.movie_service.delete_movie_by_id(movie_id) - key = CacheService.create_cache_key("movie") + key = RedisService.create_cache_key("movie") pattern = key + "*" await self.cache_service_for_movie.delete_by_pattern(pattern) diff --git a/app/cache_services/review.py b/app/cache_services/review.py index 60b6231..cf3deca 100644 --- a/app/cache_services/review.py +++ b/app/cache_services/review.py @@ -1,6 +1,6 @@ from typing import cast -from core.redis.cache_service import CacheService +from core.redis.service import RedisService from dependencies.annotations.validators import PaginationPageDep, PaginationSizeDep from schemas.review import ( ReviewCreate, @@ -18,7 +18,7 @@ class ReviewCacheService: def __init__( self, review_service: ReviewService, - cache_service: CacheService, + cache_service: RedisService, ) -> None: self.review_service = review_service self.cache_service = cache_service @@ -28,7 +28,7 @@ async def get_reviews( size: int = 10, page: int = 1, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key("reviews", size=size, page=page) + key = RedisService.create_cache_key("reviews", size=size, page=page) cached_reviews_response = await self.cache_service.get( key, ReviewWithUserResponseList, @@ -45,7 +45,7 @@ async def get_reviews( return reviews_response async def get_review_by_id(self, review_id: int) -> ReviewWithUserResponse: - key = CacheService.create_cache_key("review", review_id=review_id) + key = RedisService.create_cache_key("review", review_id=review_id) cached_review_response = await self.cache_service.get( key, ReviewWithUserResponse, @@ -67,7 +67,7 @@ async def get_user_reviews( size: int = 10, page: int = 1, ) -> ReviewWithMovieResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews", user_id=user_id, size=size, @@ -97,7 +97,7 @@ async def get_user_review_about_movie( user_id: int, movie_id: int, ) -> ReviewResponse: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "review", user_id=user_id, movie_id=movie_id, @@ -123,7 +123,7 @@ async def get_movie_reviews( size: int = 10, page: int = 1, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews", movie_id=movie_id, size=size, @@ -154,7 +154,7 @@ async def get_low_rated_movie_reviews( size: int, page: int, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews:low-rated", movie_id=movie_id, size=size, @@ -185,7 +185,7 @@ async def get_top_rated_movie_reviews( size: PaginationSizeDep = 10, page: PaginationPageDep = 1, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews:top-rated", movie_id=movie_id, size=size, @@ -216,7 +216,7 @@ async def get_top_newest_movie_reviews( size: int, page: int, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews:top-newest", movie_id=movie_id, size=size, @@ -247,7 +247,7 @@ async def get_top_oldest_movie_reviews( size: int, page: int, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews:top-oldest", movie_id=movie_id, size=size, @@ -281,7 +281,7 @@ async def create_review( user_id, create_review_data, ) - key = CacheService.create_cache_key("review") + key = RedisService.create_cache_key("review") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return review_response @@ -297,7 +297,7 @@ async def update_review( review_id, update_review_data, ) - key = CacheService.create_cache_key("review") + key = RedisService.create_cache_key("review") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return review_response @@ -313,7 +313,7 @@ async def partial_update_review( review_id, update_review_data, ) - key = CacheService.create_cache_key("review") + key = RedisService.create_cache_key("review") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return review_response @@ -327,6 +327,6 @@ async def delete_review( current_user_id, review_id, ) - key = CacheService.create_cache_key("review") + key = RedisService.create_cache_key("review") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) diff --git a/app/cache_services/user.py b/app/cache_services/user.py index 83a4d26..659c2f5 100644 --- a/app/cache_services/user.py +++ b/app/cache_services/user.py @@ -1,6 +1,6 @@ from typing import cast -from core.redis.cache_service import CacheService +from core.redis.service import RedisService from schemas.user import ( UserPartialUpdate, UserRegistration, @@ -15,13 +15,13 @@ class UserCacheService: def __init__( self, user_service: UserService, - cache_service: CacheService, + cache_service: RedisService, ) -> None: self.user_service = user_service self.cache_service = cache_service async def get_user_by_id(self, user_id: int) -> UserResponse: - key = CacheService.create_cache_key("user", user_id=user_id) + key = RedisService.create_cache_key("user", user_id=user_id) cached_user_response = await self.cache_service.get(key, UserResponse) if cached_user_response is not None: return cast(UserResponse, cached_user_response) @@ -31,7 +31,7 @@ async def get_user_by_id(self, user_id: int) -> UserResponse: return user_response async def get_user_by_login(self, login: str) -> UserResponse: - key = CacheService.create_cache_key("user", login=login) + key = RedisService.create_cache_key("user", login=login) cached_user_response = await self.cache_service.get(key, UserResponse) if cached_user_response is not None: return cast(UserResponse, cached_user_response) @@ -41,7 +41,7 @@ async def get_user_by_login(self, login: str) -> UserResponse: return user_response async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList: - key = CacheService.create_cache_key("users", size=size, page=page) + key = RedisService.create_cache_key("users", size=size, page=page) cached_users_response = await self.cache_service.get(key, UserResponseList) if cached_users_response is not None: return cast(UserResponseList, cached_users_response) @@ -55,7 +55,7 @@ async def create_user( registration_user_data: UserRegistration, ) -> UserResponse: user_response = await self.user_service.create_user(registration_user_data) - key = CacheService.create_cache_key("user") + key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return user_response @@ -66,7 +66,7 @@ async def update_user( update_data: UserUpdate, ) -> UserResponse: user_response = await self.user_service.update_user(user_id, update_data) - key = CacheService.create_cache_key("user") + key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return user_response @@ -80,19 +80,19 @@ async def partial_update_user( user_id, update_data, ) - key = CacheService.create_cache_key("user") + key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return user_response async def delete_user_by_id(self, user_id: int) -> None: await self.user_service.delete_user_by_id(user_id) - key = CacheService.create_cache_key("user") + key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) async def delete_user_by_login(self, login: str) -> None: await self.user_service.delete_user_by_login(login) - key = CacheService.create_cache_key("user") + key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) diff --git a/app/cache_services/watch_history.py b/app/cache_services/watch_history.py index dd6dc1b..ce3cf81 100644 --- a/app/cache_services/watch_history.py +++ b/app/cache_services/watch_history.py @@ -1,7 +1,7 @@ from datetime import date from typing import cast -from core.redis.cache_service import CacheService +from core.redis.service import RedisService from schemas.watch_history import ( WatchHistoryWithMovieResponse, WatchHistoryWithMovieResponseList, @@ -13,7 +13,7 @@ class WatchHistoryCacheService: def __init__( self, watch_history_service: WatchHistoryService, - cache_service: CacheService, + cache_service: RedisService, ) -> None: self.watch_history_service = watch_history_service self.cache_service = cache_service @@ -22,7 +22,7 @@ async def get_watch_history_by_id( self, watch_history_id: int, ) -> WatchHistoryWithMovieResponse: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "watch_history", watch_history_id=watch_history_id, ) @@ -45,7 +45,7 @@ async def get_watch_history_list( size: int = 10, page: int = 1, ) -> WatchHistoryWithMovieResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "watch_history_list", user_id=user_id, size=size, @@ -75,7 +75,7 @@ async def get_watch_history_by_date_range( size: int = 10, page: int = 1, ) -> WatchHistoryWithMovieResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "watch_history_list", user_id=user_id, start_date=start_date, @@ -106,7 +106,7 @@ async def get_watch_history_by_date_range( return watch_history_list_response async def count_user_watch_history(self, user_id: int) -> int: - key = CacheService.create_cache_key("watch_history", user_id=user_id) + key = RedisService.create_cache_key("watch_history", user_id=user_id) cached_watch_history_response = await self.cache_service.get(key) if cached_watch_history_response is not None: return cast(int, cached_watch_history_response) @@ -125,12 +125,12 @@ async def delete_watch_history_by_id( user_id, watch_history_id, ) - key = CacheService.create_cache_key("watch_history") + key = RedisService.create_cache_key("watch_history") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) async def delete_user_watch_history(self, user_id: int) -> None: await self.watch_history_service.delete_user_watch_history(user_id) - key = CacheService.create_cache_key("watch_history") + key = RedisService.create_cache_key("watch_history") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) diff --git a/app/core/rabbitmq/utils.py b/app/core/rabbitmq/utils.py index 12a628c..37cb061 100644 --- a/app/core/rabbitmq/utils.py +++ b/app/core/rabbitmq/utils.py @@ -6,11 +6,15 @@ from cache_services import GenreCacheService, MovieCacheService from core.database import session_factory -from core.redis import CacheService, RedisClient +from core.redis import RedisClient, RedisService from dependencies.redis_client import ( - get_redis_client_for_genres, - get_redis_client_for_movies, - get_redis_client_for_watch_history, + get_genre_redis_client as get_genre_redis_client_dependency, +) +from dependencies.redis_client import ( + get_movie_redis_client as get_movie_redis_client_dependency, +) +from dependencies.redis_client import ( + get_watch_history_redis_client as get_watch_history_redis_client_dependency, ) from services import GenreService, MovieService @@ -30,27 +34,27 @@ async def get_genre_service() -> AsyncGenerator[GenreService]: @asynccontextmanager async def get_genre_redis_client() -> AsyncGenerator[RedisClient]: - async for redis_client in get_redis_client_for_genres(): + async for redis_client in get_genre_redis_client_dependency(): yield redis_client @asynccontextmanager async def get_watch_history_redis_client() -> AsyncGenerator[RedisClient]: - async for redis_client in get_redis_client_for_watch_history(): + async for redis_client in get_watch_history_redis_client_dependency(): yield redis_client @asynccontextmanager -async def get_cache_service_for_genres() -> AsyncGenerator[CacheService]: +async def get_genre_redis_service() -> AsyncGenerator[RedisService]: async with get_genre_redis_client() as redis_client: - cache_service = CacheService(redis_client) - yield cache_service + redis_service = RedisService(redis_client) + yield redis_service @asynccontextmanager -async def get_cache_service_for_watch_history() -> AsyncGenerator[CacheService]: +async def get_watch_history_redis_service() -> AsyncGenerator[RedisService]: async with get_watch_history_redis_client() as redis_client: - cache_service = CacheService(redis_client) + cache_service = RedisService(redis_client) yield cache_service @@ -58,7 +62,7 @@ async def get_cache_service_for_watch_history() -> AsyncGenerator[CacheService]: async def get_genre_cache_service() -> AsyncGenerator[GenreCacheService]: async with ( get_genre_service() as genre_service, - get_cache_service_for_genres() as cache_service, + get_genre_redis_service() as cache_service, ): genre_cache_service = GenreCacheService(genre_service, cache_service) yield genre_cache_service @@ -73,14 +77,14 @@ async def get_movie_service() -> AsyncGenerator[MovieService]: @asynccontextmanager async def get_movie_redis_client() -> AsyncGenerator[RedisClient]: - async for redis_client in get_redis_client_for_movies(): + async for redis_client in get_movie_redis_client_dependency(): yield redis_client @asynccontextmanager -async def get_cache_service_for_movies() -> AsyncGenerator[CacheService]: +async def get_movie_redis_service() -> AsyncGenerator[RedisService]: async with get_movie_redis_client() as redis_client: - cache_service = CacheService(redis_client) + cache_service = RedisService(redis_client) yield cache_service @@ -88,8 +92,8 @@ async def get_cache_service_for_movies() -> AsyncGenerator[CacheService]: async def get_movie_cache_service() -> AsyncGenerator[MovieCacheService]: async with ( get_movie_service() as movie_service, - get_cache_service_for_movies() as cache_service_for_movies, - get_cache_service_for_watch_history() as cache_service_for_watch_history, + get_movie_redis_service() as cache_service_for_movies, + get_watch_history_redis_service() as cache_service_for_watch_history, ): movie_cache_service = MovieCacheService( movie_service, diff --git a/app/core/redis/__init__.py b/app/core/redis/__init__.py index b8fe33c..6919045 100644 --- a/app/core/redis/__init__.py +++ b/app/core/redis/__init__.py @@ -1,9 +1,9 @@ -from core.redis.cache_service import CacheService from core.redis.client import RedisClient from core.redis.rate_limiter import RateLimiter +from core.redis.service import RedisService __all__ = ( - "CacheService", "RateLimiter", "RedisClient", + "RedisService", ) diff --git a/app/core/redis/cache_service.py b/app/core/redis/service.py similarity index 98% rename from app/core/redis/cache_service.py rename to app/core/redis/service.py index 484bfcf..b707be8 100644 --- a/app/core/redis/cache_service.py +++ b/app/core/redis/service.py @@ -3,7 +3,7 @@ from core.redis.client import RedisClient -class CacheService: +class RedisService: def __init__(self, redis: RedisClient) -> None: self.redis = redis diff --git a/app/dependencies/annotations/cache_services.py b/app/dependencies/annotations/cache_services.py index f29537b..a3fd375 100644 --- a/app/dependencies/annotations/cache_services.py +++ b/app/dependencies/annotations/cache_services.py @@ -10,7 +10,7 @@ UserCacheService, WatchHistoryCacheService, ) -from core.redis import CacheService +from core.redis import RedisService from dependencies.cache_services import ( get_favorite_movie_cache_service, get_genre_cache_service, @@ -19,50 +19,50 @@ get_user_cache_service, get_watch_history_cache_service, ) -from dependencies.caching import ( - get_cache_service_for_favorite_movies, - get_cache_service_for_genres, - get_cache_service_for_movies, - get_cache_service_for_reviews, - get_cache_service_for_users, - get_cache_service_for_watch_history, +from dependencies.redis_services import ( + get_favorite_movie_redis_service, + get_genre_redis_service, + get_movie_redis_service, + get_review_redis_service, + get_user_redis_service, + get_watch_history_redis_service, ) -CacheServiceForGenresDep = Annotated[ - CacheService, - Depends(get_cache_service_for_genres), +GenreRedisServiceDep = Annotated[ + RedisService, + Depends(get_genre_redis_service), ] -CacheServiceForMoviesDep = Annotated[ - CacheService, - Depends(get_cache_service_for_movies), +MovieRedisServiceDep = Annotated[ + RedisService, + Depends(get_movie_redis_service), ] -CacheServiceForFavoriteMoviesDep = Annotated[ - CacheService, +FavoriteMovieRedisServiceDep = Annotated[ + RedisService, Depends( - get_cache_service_for_favorite_movies, + get_favorite_movie_redis_service, ), ] -CacheServiceForReviewsDep = Annotated[ - CacheService, +ReviewRedisServiceDep = Annotated[ + RedisService, Depends( - get_cache_service_for_reviews, + get_review_redis_service, ), ] -CacheServiceForUsersDep = Annotated[ - CacheService, +UserRedisServiceDep = Annotated[ + RedisService, Depends( - get_cache_service_for_users, + get_user_redis_service, ), ] -CacheServiceForWatchHistoryDep = Annotated[ - CacheService, +WatchHistoryRedisServiceDep = Annotated[ + RedisService, Depends( - get_cache_service_for_watch_history, + get_watch_history_redis_service, ), ] diff --git a/app/dependencies/cache_services.py b/app/dependencies/cache_services.py index 59b463d..b71f269 100644 --- a/app/dependencies/cache_services.py +++ b/app/dependencies/cache_services.py @@ -11,14 +11,14 @@ UserCacheService, ) from cache_services.watch_history import WatchHistoryCacheService -from core.redis.cache_service import CacheService -from dependencies.caching import ( - get_cache_service_for_favorite_movies, - get_cache_service_for_genres, - get_cache_service_for_movies, - get_cache_service_for_reviews, - get_cache_service_for_users, - get_cache_service_for_watch_history, +from core.redis.service import RedisService +from dependencies.redis_services import ( + get_favorite_movie_redis_service, + get_genre_redis_service, + get_movie_redis_service, + get_review_redis_service, + get_user_redis_service, + get_watch_history_redis_service, ) from dependencies.services import ( get_favorite_movie_service, @@ -43,13 +43,13 @@ async def get_genre_cache_service( GenreService, Depends(get_genre_service), ], - cache_service: Annotated[ - CacheService, - Depends(get_cache_service_for_genres), + genre_redis_service: Annotated[ + RedisService, + Depends(get_genre_redis_service), ], ) -> AsyncGenerator[GenreCacheService]: try: - genre_cache_service = GenreCacheService(genre_service, cache_service) + genre_cache_service = GenreCacheService(genre_service, genre_redis_service) yield genre_cache_service finally: """ @@ -62,20 +62,20 @@ async def get_movie_cache_service( MovieService, Depends(get_movie_service), ], - cache_service_for_movie: Annotated[ - CacheService, - Depends(get_cache_service_for_movies), + movie_redis_service: Annotated[ + RedisService, + Depends(get_movie_redis_service), ], - cache_service_for_watch_history: Annotated[ - CacheService, - Depends(get_cache_service_for_watch_history), + watch_history_redis_service: Annotated[ + RedisService, + Depends(get_watch_history_redis_service), ], ) -> AsyncGenerator[MovieCacheService]: try: movie_cache_service = MovieCacheService( movie_service, - cache_service_for_movie, - cache_service_for_watch_history, + movie_redis_service, + watch_history_redis_service, ) yield movie_cache_service finally: @@ -89,15 +89,15 @@ async def get_favorite_movie_cache_service( FavoriteMovieService, Depends(get_favorite_movie_service), ], - cache_service: Annotated[ - CacheService, - Depends(get_cache_service_for_favorite_movies), + favorite_movie_redis_service: Annotated[ + RedisService, + Depends(get_favorite_movie_redis_service), ], ) -> AsyncGenerator[FavoriteMovieCacheService]: try: favorite_movie_cache_service = FavoriteMovieCacheService( favorite_movie_service, - cache_service, + favorite_movie_redis_service, ) yield favorite_movie_cache_service finally: @@ -111,13 +111,13 @@ async def get_review_cache_service( ReviewService, Depends(get_review_service), ], - cache_service: Annotated[ - CacheService, - Depends(get_cache_service_for_reviews), + review_redis_service: Annotated[ + RedisService, + Depends(get_review_redis_service), ], ) -> AsyncGenerator[ReviewCacheService]: try: - review_cache_service = ReviewCacheService(review_service, cache_service) + review_cache_service = ReviewCacheService(review_service, review_redis_service) yield review_cache_service finally: """ @@ -130,15 +130,15 @@ async def get_watch_history_cache_service( WatchHistoryService, Depends(get_watch_history_service), ], - cache_service: Annotated[ - CacheService, - Depends(get_cache_service_for_watch_history), + watch_history_redis_service: Annotated[ + RedisService, + Depends(get_watch_history_redis_service), ], ) -> AsyncGenerator[WatchHistoryCacheService]: try: watch_history_cache_service = WatchHistoryCacheService( watch_history_service, - cache_service, + watch_history_redis_service, ) yield watch_history_cache_service finally: @@ -152,13 +152,13 @@ async def get_user_cache_service( UserService, Depends(get_user_service), ], - cache_service: Annotated[ - CacheService, - Depends(get_cache_service_for_users), + user_redis_service: Annotated[ + RedisService, + Depends(get_user_redis_service), ], ) -> AsyncGenerator[UserCacheService]: try: - user_cache_service = UserCacheService(user_service, cache_service) + user_cache_service = UserCacheService(user_service, user_redis_service) yield user_cache_service finally: """ diff --git a/app/dependencies/caching.py b/app/dependencies/caching.py deleted file mode 100644 index fa3a43b..0000000 --- a/app/dependencies/caching.py +++ /dev/null @@ -1,61 +0,0 @@ -from collections.abc import AsyncGenerator, Callable -from typing import Annotated - -from fastapi import Depends - -from core.redis import CacheService, RedisClient -from dependencies.redis_client import ( - get_redis_client_for_confirmation_codes, - get_redis_client_for_favorite_movies, - get_redis_client_for_genres, - get_redis_client_for_movies, - get_redis_client_for_reviews, - get_redis_client_for_users, - get_redis_client_for_watch_history, -) - - -def cache_service_factory( - redis_dependency: Callable[[], AsyncGenerator[RedisClient]], -) -> Callable[ - [RedisClient], - AsyncGenerator[CacheService], -]: - async def dependency( - redis: Annotated[ - RedisClient, - Depends(redis_dependency), - ], - ) -> AsyncGenerator[CacheService]: - try: - cache_service = CacheService(redis) - yield cache_service - finally: - """ - Действия после view. - """ - - return dependency - - -get_cache_service_for_genres = cache_service_factory( - get_redis_client_for_genres, -) -get_cache_service_for_movies = cache_service_factory( - get_redis_client_for_movies, -) -get_cache_service_for_reviews = cache_service_factory( - get_redis_client_for_reviews, -) -get_cache_service_for_favorite_movies = cache_service_factory( - get_redis_client_for_favorite_movies, -) -get_cache_service_for_watch_history = cache_service_factory( - get_redis_client_for_watch_history, -) -get_cache_service_for_users = cache_service_factory( - get_redis_client_for_users, -) -get_cache_service_for_confirmation_codes = cache_service_factory( - get_redis_client_for_confirmation_codes, -) diff --git a/app/dependencies/rate_limiter.py b/app/dependencies/rate_limiter.py index 69d60ad..3317afc 100644 --- a/app/dependencies/rate_limiter.py +++ b/app/dependencies/rate_limiter.py @@ -7,7 +7,7 @@ from core.exceptions.base import TooManyRequestsError from core.redis.client import RedisClient from core.redis.rate_limiter import RateLimiter -from dependencies.redis_client import get_redis_client_for_rate_limiter +from dependencies.redis_client import get_rate_limiter_redis_client def rate_limit_dependency_factory( @@ -40,7 +40,7 @@ async def dependency( async def get_rate_limiter( redis_client: Annotated[ RedisClient, - Depends(get_redis_client_for_rate_limiter), + Depends(get_rate_limiter_redis_client), ], ) -> AsyncGenerator[RateLimiter]: try: diff --git a/app/dependencies/redis_client.py b/app/dependencies/redis_client.py index 88b3007..ce569e2 100644 --- a/app/dependencies/redis_client.py +++ b/app/dependencies/redis_client.py @@ -22,34 +22,34 @@ async def get_redis_client() -> AsyncGenerator[RedisClient]: return get_redis_client -get_redis_client_for_rate_limiter = redis_client_factory( +get_rate_limiter_redis_client = redis_client_factory( db=settings.redis.db.rate_limiter, ) -get_redis_client_for_genres = redis_client_factory( +get_genre_redis_client = redis_client_factory( db=settings.redis.db.genres, ) -get_redis_client_for_movies = redis_client_factory( +get_movie_redis_client = redis_client_factory( db=settings.redis.db.movies, ) -get_redis_client_for_users = redis_client_factory( +get_user_redis_client = redis_client_factory( db=settings.redis.db.users, ) -get_redis_client_for_reviews = redis_client_factory( +get_review_redis_client = redis_client_factory( db=settings.redis.db.reviews, ) -get_redis_client_for_favorite_movies = redis_client_factory( +get_favorite_movie_redis_client = redis_client_factory( db=settings.redis.db.favorite_movies, ) -get_redis_client_for_watch_history = redis_client_factory( +get_watch_history_redis_client = redis_client_factory( db=settings.redis.db.watch_history, ) -get_redis_client_for_confirmation_codes = redis_client_factory( +get_confirmation_code_redis_client = redis_client_factory( db=settings.redis.db.confirmation_codes, ) diff --git a/app/dependencies/redis_services.py b/app/dependencies/redis_services.py new file mode 100644 index 0000000..699ff07 --- /dev/null +++ b/app/dependencies/redis_services.py @@ -0,0 +1,61 @@ +from collections.abc import AsyncGenerator, Callable +from typing import Annotated + +from fastapi import Depends + +from core.redis import RedisClient, RedisService +from dependencies.redis_client import ( + get_confirmation_code_redis_client, + get_favorite_movie_redis_client, + get_genre_redis_client, + get_movie_redis_client, + get_review_redis_client, + get_user_redis_client, + get_watch_history_redis_client, +) + + +def redis_service_factory( + redis_dependency: Callable[[], AsyncGenerator[RedisClient]], +) -> Callable[ + [RedisClient], + AsyncGenerator[RedisService], +]: + async def dependency( + redis: Annotated[ + RedisClient, + Depends(redis_dependency), + ], + ) -> AsyncGenerator[RedisService]: + try: + redis_service = RedisService(redis) + yield redis_service + finally: + """ + Действия после view. + """ + + return dependency + + +get_genre_redis_service = redis_service_factory( + get_genre_redis_client, +) +get_movie_redis_service = redis_service_factory( + get_movie_redis_client, +) +get_review_redis_service = redis_service_factory( + get_review_redis_client, +) +get_favorite_movie_redis_service = redis_service_factory( + get_favorite_movie_redis_client, +) +get_watch_history_redis_service = redis_service_factory( + get_watch_history_redis_client, +) +get_user_redis_service = redis_service_factory( + get_user_redis_client, +) +get_confirmation_code_redis_service = redis_service_factory( + get_confirmation_code_redis_client, +) diff --git a/app/dependencies/services.py b/app/dependencies/services.py index 481c49a..2817c78 100644 --- a/app/dependencies/services.py +++ b/app/dependencies/services.py @@ -7,8 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.database.connection import session_factory -from core.redis import CacheService -from dependencies.caching import get_cache_service_for_confirmation_codes +from core.redis import RedisService +from dependencies.redis_services import get_confirmation_code_redis_service from services import GenreService, MovieService, ReviewService, UserService from services.favorite_movie import FavoriteMovieService from services.http_request import HttpRequestService @@ -96,9 +96,9 @@ async def get_user_service( Depends(get_db), ], redis_service: Annotated[ - CacheService, + RedisService, Depends( - get_cache_service_for_confirmation_codes, + get_confirmation_code_redis_service, ), ], ) -> AsyncGenerator[UserService]: diff --git a/app/services/user.py b/app/services/user.py index 656e493..8a0b46b 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -19,7 +19,7 @@ UserLoginAlreadyExistsError, UserLoginNotFoundError, ) -from core.redis import CacheService +from core.redis import RedisService from core.security.jwt_utils import create_access_token, create_refresh_token from core.security.password_utils import hash_password, verify_password from repositories import UserRepository @@ -39,7 +39,7 @@ class UserService: def __init__( self, session: AsyncSession, - redis_service: CacheService | None = None, + redis_service: RedisService | None = None, ) -> None: self.session = session self.user_repository = UserRepository(session) diff --git a/backup.sql b/backup.sql deleted file mode 100644 index d6e1b06..0000000 --- a/backup.sql +++ /dev/null @@ -1,172 +0,0 @@ --- --- PostgreSQL database dump --- - -\restrict H6Y4983yzznWBCCXvUqSeBBtSlW3So5fHe3vL1tapxumEnQJmUO7CgbqzjnAaH0 - --- Dumped from database version 17.10 (Debian 17.10-1.pgdg12+1) --- Dumped by pg_dump version 17.10 (Debian 17.10-1.pgdg12+1) - -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET transaction_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - --- --- Data for Name: alembic_version; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.alembic_version (version_num) FROM stdin; -24c4c5225bf8 -\. - - --- --- Data for Name: genres; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.genres (id, name, create_date, description, preview_url) FROM stdin; -2 Комедия 2026-05-23 15:54:19.525929 Комедия — жанр, где смех становится главным оружием и наградой. Всё строится на столкновении серьёзного с нелепым: полицейский с игрушечным пистолетом, профессор, который не может завязать шнурки, романтик, по ошибке целующий чужую невесту. Чем усерднее персонажи пытаются сохранить лицо, тем быстрее они его теряют — и зритель смеётся над их провалами, ощущая собственное превосходство. https://pics.livejournal.com/tema/pic/000y75fb -3 Мультфильм 2026-05-23 16:00:05.77206 Мультфильм — единственный жанр, который могут смотреть и младенец, и дедушка, причём рядом на одном диване. Младенец видит красный шарик. Дедушка видит метафору уходящей молодости. И оба не скучают. Попробуй найти другой жанр с таким разбросом. Хоррор для младенца — травма. Драма для дедушки — скука, если без жизненного опыта. А мультфильм работает на любом возрасте, потому что он обращается не к знаниям, а к чему-то более простому — к желанию смотреть на движущиеся картинки и верить, что они живые. Этот рефлекс у нас с детства. Мультфильм просто не даёт ему умереть. https://avatars.mds.yandex.net/get-kinopoisk-image/1773646/d1e43746-fc59-459a-8205-841f600cb315/180 -4 Боевик 2026-05-23 16:04:30.534032 Боевик — жанр, где диалог — это пуля, а аргумент — взрыв. Никто не говорит больше минуты. Если персонаж начинает длинную речь, значит, через пять секунд кто-то выстрелит в окно. Здесь всё работает на инстинктах: громкий звук — опасность, быстрая смена кадра — погоня, тёмный коридор — засада. Боевик не требует думать. Он требует смотреть широко открытыми глазами и иногда кричать «Давай!» на экран. https://poster4.me/wp-content/uploads/2020/05/dedpul_2.jpg -5 Фантастика 2026-05-23 16:06:48.101594 Фантастика бывает твёрдой — где каждый прыжок гипердвигателя обсчитан на формулах, а герои спорят о парадоксе близнецов из теории относительности. И бывает мягкой — где космос залит розовым туманом, а инопланетян можно соблазнить песней. И то и другое имеет право на жизнь. Потому что фантастика — это не наука. Это мечта, притворившаяся правдой. https://avatars.mds.yandex.net/get-kinopoisk-image/1704946/8d716854-9ae4-4863-a821-b22a8f28a0f6/576x -6 Детектив 2026-05-23 16:08:59.633025 Детектив держится на трёх китах: сыщик, жертва, убийца. Сыщик — гений, но с причудами. Он замечает то, что другие пропускают: окурок в цветочном горшке, царапину на замке, фальшивую улыбку вдовы. Жертва — та, чья смерть запускает механизм расследования. Она может быть святой или мерзавкой, но её убийство всегда кому-то выгодно. Убийца — тот, кого ты не заподозришь до самого конца. Чаще всего — самый тихий, самый вежливый или самый очевидно невиновный. https://avatars.mds.yandex.net/get-mpic/13851176/2a000001933fb46d449c6ed9f8cf5a933bf8/orig -7 Криминал 2026-05-23 16:11:18.278025 Самое страшное в криминале — не кровь и не жестокость. Самое страшное — обыденность. Убийца вытирает руки о штаны и идёт ужинать. Торговец наркотиками нежно целует дочку перед сном. Вор в законе читает внукам сказки. Криминал не носит чёрную маску. Он носит уставшие глаза, дешёвый одеколон и улыбку соседа, который занял у вас тысячу рублей и до сих пор не отдал. Просто у этого соседа в багажнике труп. И он делает вид, что ничего не случилось. А жанр делает вид, что это кино. Хотя все мы знаем: такое кино каждый день снимают без камер. https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQkXV4d6YDAGUTo_fVGpnbmewBTSC64aHaV2A&s -8 Ужасы 2026-05-23 16:14:59.515817 Финал ужастика редко бывает счастливым. Монстра нельзя убить — только задержать. Он возвращается в сиквеле. Или оказывается, что герой сам был монстром всё это время. Или последний кадр показывает, что зло всё-таки выбралось наружу. Хэппи-энд в ужастике — это исключение, а не правило. Потому что жанр напоминает: мир не безопасен. Темнота не пуста. А тот звук на чердаке… лучше не проверять, что это было. Но ты всё равно проверишь. И ужастик знает это. И ждёт тебя там. https://images.iptv.rt.ru/imo/transform/profile=filmposter158x230/images/d866r7bir4sqiate8rq0.jpg -9 Приключения 2026-05-23 16:18:46.092929 В приключениях дорога важнее финала. Финал — это просто точка, где герой вытирает пот со лба и смотрит на закат. А дорога — это джунгли, пустыни, горные перевалы, затерянные храмы, подземные реки и пиратские корабли. Каждый новый кадр должен кричать: «Ты никогда такого не видел!». И зритель верит. Потому что приключения — это обещание чуда за следующим поворотом. И жанр почти никогда не врёт. https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRoabhDZ78LfwUxBf-_q6T8K0ugnrbxgurPTw&s -10 Триллер 2026-05-23 16:25:04.056152 Триллер начинается не со взрыва. Он начинается с тиканья часов. С кадра, где герой проверяет замок на двери два раза. Со случайной встречей в лифте, после которой у вас на секунду перехватывает дыхание. В триллере страх не громкий. Он липкий. Он заползает под кожу постепенно, так что ты не замечаешь момента, когда уже сидишь на краю кресла и не моргаешь. Это не ужастик с монстром. Здесь монстр — человек. Или время. Или правда, которую нельзя узнать. https://www.kino-teatr.ru/movie/posters/big/6/3/123436.jpg -\. - - --- --- Data for Name: movies; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.movies (id, name, description, rating, preview_url, source_url, genre_id, release_date) FROM stdin; -1 Интерстеллар Корабль «Эндюранс» вращается на фоне чёрной дыры Гаргантюа. Она огромная. Она переламывает свет, пространство, время. Она выглядит как глаз, смотрящий из ниоткуда. Физики консультировали съёмки — этот кадр буквально наука, ставшая искусством. Потому что «Интерстеллар» — это фильм, где формула общей теории относительности плачет на заднем плане, а зритель всё равно вытирает слёзы на переднем. 8 https://www.kino-teatr.ru/movie/posters/big/6/2/55826.jpg https://vk.com/video-220018529_456243270 5 2014-10-26 -2 Джентльмены Микки Пирсон — американский эмигрант, который построил в Лондоне империю марихуаны. Тоннели под старыми особняками, свои люди в полиции, чистый продукт, никакой уличной грязи. Он хочет выйти из игры. Продать бизнес за 400 миллионов и уехать жить в деревню с женой-красавицей. Но Лондон не отпускает своих. Находятся покупатели, перекупщики, шантажисты, китайская триада, русские олигархи и редактор таблоида с дикцией фокстерьера. И все хотят кусок империи. Или голову Микки. 8 https://upload.wikimedia.org/wikipedia/ru/c/c1/%D0%94%D0%B6%D0%B5%D0%BD%D1%82%D0%BB%D1%8C%D0%BC%D0%B5%D0%BD%D1%8B.jpg https://vk.com/video-233305177_456240899 2 2019-12-03 -7 Волк с Уолл-стрит Фильм повествует о взлете и падении Джордана Белфорта, который в конце 80-х начинает карьеру брокера. Потеряв работу из-за обвала рынка, он основывает собственную фирму, занимающуюся финансовыми махинациями. Используя харизму и агрессивные методы продаж, Белфорт вместе с партнером Донни строит империю, пока ею не начинает интересоваться ФБР. 8 https://avatars.mds.yandex.net/get-ott/1648503/2a00000198542cdfee1c29e01abb4e157d41/600x900 https://vk.com/video-229083599_456239081 10 2013-12-17 -8 Матрица : Жизнь Томаса Андерсона разделена на две части. Днём он — обычный офисный работник, а ночью превращается в неуловимого хакера по имени Нео. Однажды таинственные незнакомцы открывают ему страшную правду: привычный мир вокруг — всего лишь компьютерная иллюзия (Матрица), созданная разумными машинами. Всё человечество погружено в вечный сон, служа лишь источником энергии для искусственного интеллекта. Нео предстоит пробудиться в суровой реальности и возглавить партизанскую войну за свободу человечества. 9 https://avatars.mds.yandex.net/get-kinopoisk-image/4774061/cf1970bc-3f08-4e0e-a095-2fb57c3aa7c6/600x900 https://vk.com/video-220018529_456240812 5 1999-03-31 -9 Один дома Американское семейство отправляется из Чикаго в Европу, но в спешке сборов бестолковые родители забывают дома... одного из своих детей. Юное создание, однако, не теряется и демонстрирует чудеса изобретательности. И когда в дом залезают грабители, им приходится не раз пожалеть о встрече с милым крошкой. 8 https://avatars.mds.yandex.net/get-kinopoisk-image/6201401/022a58e3-5b9b-411b-bfb3-09fedb700401/600x900 https://vk.com/video-220018529_456248133 2 1990-11-10 -4 1+1 Пострадав в результате несчастного случая, богатый аристократ Филипп оказывается прикованным к инвалидному креслу. Ему нужен помощник с функциями сиделки, который должен за ним ухаживать. Филипп нанимает человека, который, казалось бы, менее всего подходит для этой работы. Им оказывается местный молодой человек, выходец из Сенегала, только что освободившийся из тюрьмы по имени Дрисс. 9 https://upload.wikimedia.org/wikipedia/ru/b/b9/Intouchables.jpg https://vk.com/video-220018529_456243240 2 2011-09-23 -11 Острые козырьки, 1 сезон Бирмингем, 1919 год. Ветеран Первой мировой войны Томас Шелби вместе со своими братьями возвращается в родной город, чтобы расширить влияние семейной банды «Острые козырьки», промышляющей грабежами и нелегальными ставками. Случайно в руки Томаса попадает крупная партия секретного заводского оружия. Теперь амбициозному главарю предстоит вступить в опасную игру не только с конкурирующими бандами, но и с прибывшим в город жестким инспектором полиции Кэмпбеллом, намеренным зачистить улицы от преступности. 8 https://sun9-29.userapi.com/impg/O2Wf5Fx8G2d-Qj9mw_wyc2ci6BXenw32kU3aUA/AnD5ale1us4.jpg?size=812x1200&quality=95&sign=0606d95874c6531540cd3a2d3d7bc06f&type=video_thumb https://vk.com/video-192884021_456239066 7 2013-09-12 -14 Острые козырьки 3 сезон Действие переносится в 1924 год. Томас Шелби наконец обретает долгожданный легальный статус, богатство и играет пышную свадьбу. Однако роскошная жизнь в огромном загородном поместье не приносит покоя. Семья Шелби оказывается втянутой в опасные международные интриги: Томми вынужден сотрудничать с тайной праворадикальной организацией и русскими эмигрантами-монархистами ради кражи партии оружия. Поставив на кон всё, глава «Козырьков» понимает, что новые покровители ведут двойную игру, а на кону стоят жизни его самых близких людей. 8 https://www.soyuz.ru/public/uploads/files/3/7650914/20251205215648302300d9ef.jpg https://vk.com/video-228609771_456239302 7 2016-09-05 -15 Острые козырьки 4 сезон Действие разворачивается в 1925 году. Семья Шелби разделена и практически не общается, однако перед лицом смертельной опасности им приходится вновь объединиться. В Бирмингем прибывает глава сицилийской мафии Лука Чангретта, который жаждет отомстить каждому члену клана по законам кровавой вендетты. Чтобы выжить под прицелом профессиональных киллеров, «Острые козырьки» вынуждены покинуть свои роскошные загородные дома, вернуться на родные и опасные улицы Смолл Хит и развязать полноценную войну на уничтожение. 8 https://media.kg-portal.ru/tv/p/peakyblinders/posters/peakyblinders_2.jpg https://vk.com/video-228609771_456239303 7 2017-11-15 -16 Острые козырьки 5 сезон Действие разворачивается в 1929 году на фоне мирового финансового кризиса и обрушения акций на Уолл-стрит, из-за которого законные активы компании «Шелби Лимитед» оказываются под ударом. Томас Шелби, заседающий в британском парламенте, вынужден искать новые, порой незаконные пути для восстановления семейного капитала. На политической арене Томми сталкивается с харизматичным и коварным сэром Освальдом Мосли — лидером британских фашистов. Пытаясь переиграть двуличных политиков и удержать контроль над разрастающейся империей, глава «Козырьков» начинает страдать от тяжелых галлюцинаций. Ситуация обостряется внутренним расколом в семье и появлением нового опасного врага из Детройта. 8 https://avatars.mds.yandex.net/get-kinopoisk-image/1773646/3d330c0e-0547-45a5-a6dc-69c1e986a4c2/orig https://vk.com/video-194145340_456239728 7 2019-08-25 -13 Острые козырьки 2 сезон Бизнес семьи Шелби процветает, и Томас планирует расширить криминальную империю, захватив столицу Англии — Лондон. Чтобы укрепиться на новом рынке, он решает извлечь выгоду из кровопролитной войны между итальянскими и еврейскими группировками. Однако амбициозные планы Томми оказываются под угрозой: его бару наносят сокрушительный удар, прошлое в лице инспектора Кэмпбелла снова напоминает о себе. 8 https://www.kino-teatr.ru/movie/poster/121956/100887.jpg https://vk.com/video-141357054_456239047 7 2014-10-02 -17 Острые козырьки 6 сезон : Действие начинается в 1933 году, сразу после отмены сухого закона в США, что открывает для Томми Шелби новые масштабные возможности на опиумном рынке Северной Америки. Однако клан Шелби истощен личными трагедиями, а Артур окончательно теряет контроль над собой. Противостояние с фашистским лидером Освальдом Мосли выходит на пиковый уровень, и Томми приходится вести смертельную игру на два фронта — против политических врагов и безжалостных бостонских гангстеров. Ситуация обостряется, когда Томас узнает о своей смертельной болезни, заставляющей его торопиться с завершением всех земных дел. 8 https://vseriale-ostrye-kozyrki.ru/4.jpg https://vk.com/video-80021931_456241711 7 2022-02-27 -19 Смешарики. Легенда о золотом драконе Выдающийся ученый Лосяш изобретает удивительный прибор «Улучшайзер», способный переносить лучшие качества одного существа другому. Трусливый Бараш решает воспользоваться аппаратом, чтобы избавиться от своей робости, но из-за нелепой случайности происходит сбой. В результате Бараш меняется телами с маленькой гусеницей. Ситуация осложняется тем, что герои попадают в дикие джунгли, где обитает племя туземцев. Дикари принимают гусеницу в теле Бараша за своего бога — Золотого Дракона, а за самим Барашем начинают охоту опасные расхитители гробниц. Смешарикам предстоит спасти друга и предотвратить древнее пророчество. 6 https://thumbs.dfs.ivi.ru/storage0/contents/d/7/d3654ac6f949a983f280cb5bf655df.jpg https://vk.com/video-21665793_456242980 3 2016-03-17 -18 Смешарики. Начало Спокойная и размеренная жизнь в Ромашковой долине переворачивается с ног на голову, когда Крош и Ёжик находят в земле старый телевизор. Починив его, друзья натыкаются на трансляцию «Шоу Люсьена», где отважный супергерой борется со зловещим доктором Калигари. Приняв телевизионную постановку за чистую монету, Смешарики решают спасти мир. На плоту они отправляются в огромный мегаполис, где их ждут суровые законы взрослого мира, арест и неожиданная правда о том, что грозный супергерой — это всего лишь уставший актер Копатыч. 5 https://s1.afisha.ru/mediastorage/7b/f0/d949ef59364848a89604daa6f07b.jpg https://vk.com/video-232922094_456245875 3 2011-12-22 -20 Смешарики. Дежавю Крош решает устроить лучший день рождения для Копатыча и обращается в необычное агентство «Дежавю», которое организует незабываемые путешествия во времени. Однако из-за несоблюдения правил безопасности и беспечности Кроша вся компания Смешариков оказывается разбросана по разным эпохам — от мезозоя до Древнего Китая и Дикого Запада. Крошу предстоит исправить свою ошибку, совершить скачки сквозь время и спасти друзей. Ситуация осложняется тем, что в процессе перемещений появляется его точная копия из будущего — ворчливый и дисциплинированный Шорк, который совсем не ладит с веселым кроликом. 7 https://avatars.mds.yandex.net/get-kinopoisk-image/1946459/6f120526-a8f7-41fd-a387-d941c1c40350/600x900 https://vk.com/video-21665793_456242979 3 2018-04-26 -\. - - --- --- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.users (id, surname, name, login, encrypted_password, registration_date, email, role) FROM stdin; -3 admin admin adminadmin $2b$12$nqICm8L8RY2V4nO12LBj3ez81NftJYcSZfdKwU3BDkbsLPc5n1dBW 2026-05-23 18:45:30.523017 admin@admin.gmail.ru admin -2 Широков Николай adminadmin $2b$12$UsSn8WUQjizfgcZX1sFjiO4ntdJ6gSsUOSLT7B/1PkrL68JD2XJ8C 2026-05-23 15:41:44.553813 admin@admin.gmail.ru admin -\. - - --- --- Data for Name: favorite_movies; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.favorite_movies (id, user_id, movie_id) FROM stdin; -15 3 15 -16 3 16 -17 3 13 -18 3 17 -\. - - --- --- Data for Name: reviews; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.reviews (id, user_id, movie_id, review_text, rating, publication_date) FROM stdin; -2 3 2 Очень хороший фильм 8 2026-05-24 19:09:43.431399 -\. - - --- --- Data for Name: watch_history; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.watch_history (id, user_id, movie_id, watched_at) FROM stdin; -2 3 7 2026-05-24 15:59:04.139486 -3 3 8 2026-05-24 15:59:26.139949 -5 3 11 2026-05-24 16:01:29.257514 -6 3 1 2026-05-24 16:02:01.3108 -9 3 7 2026-05-24 19:10:42.6759 -10 3 19 2026-05-24 19:14:50.997454 -11 3 7 2026-05-24 19:21:16.810151 -12 3 7 2026-05-24 19:21:28.541346 -13 3 13 2026-05-24 19:23:31.725744 -14 3 15 2026-05-25 18:19:33.495 -15 3 11 2026-05-25 18:21:53.085286 -16 3 1 2026-05-25 18:27:25.631441 -17 3 4 2026-05-25 18:27:33.149385 -18 3 15 2026-05-25 18:27:41.897176 -19 3 15 2026-05-25 18:27:44.41712 -20 3 11 2026-05-25 18:27:51.585886 -\. - - --- --- Name: favorite_movies_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.favorite_movies_id_seq', 22, true); - - --- --- Name: genres_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.genres_id_seq', 33, true); - - --- --- Name: movies_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.movies_id_seq', 21, true); - - --- --- Name: reviews_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.reviews_id_seq', 2, true); - - --- --- Name: users_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.users_id_seq', 4, true); - - --- --- Name: watch_history_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.watch_history_id_seq', 20, true); - - --- --- PostgreSQL database dump complete --- - -\unrestrict H6Y4983yzznWBCCXvUqSeBBtSlW3So5fHe3vL1tapxumEnQJmUO7CgbqzjnAaH0 From 95ae136579f21c5a8aea28f68d6420e89b8e6a25 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Fri, 19 Jun 2026 17:41:26 +0300 Subject: [PATCH 17/47] Add reset password endpoint. Union send register, auth and reset password confirmation code in endpoint send_confirmation_code --- app/api/api_v1/auth.py | 16 ++++-- app/core/constants.py | 6 ++ app/schemas/auth.py | 25 +++++++- app/services/user.py | 69 ++++++++++++++++++----- notification-service/core/celery/tasks.py | 17 ++++++ notification-service/service.py | 36 +++++++++++- packages/celery/constants.py | 3 + 7 files changed, 150 insertions(+), 22 deletions(-) diff --git a/app/api/api_v1/auth.py b/app/api/api_v1/auth.py index 55922e1..f95c7ec 100644 --- a/app/api/api_v1/auth.py +++ b/app/api/api_v1/auth.py @@ -10,7 +10,7 @@ GetLoginDataDep, ) from dependencies.annotations.services import UserServiceDep -from schemas.auth import ConfirmEmailRequest +from schemas.auth import ConfirmEmailRequest, SendAuthEmail, ResetPasswordRequest from schemas.token_info import TokenInfo from schemas.user import ( UserRegistration, @@ -46,12 +46,12 @@ async def login_user( return await user_service.authenticate_user(login_data) -@router.post("/confirmation_code") +@router.post("/send-confirmation-code") async def send_confirmation_code( - email: EmailStr, + send_auth_email_data: SendAuthEmail, user_service: UserServiceDep, ) -> None: - await user_service.send_confirmation_code(email) + await user_service.send_confirmation_code(send_auth_email_data) @router.post("/confirm-email") @@ -62,6 +62,14 @@ async def confirm_email( return await user_service.confirm_email(confirm_email_request) +@router.post("/reset-password") +async def reset_password( + reset_password_data: ResetPasswordRequest, + user_service: UserServiceDep, +) -> None: + await user_service.reset_password(reset_password_data) + + @router.post( "/refresh", response_model=TokenInfo, diff --git a/app/core/constants.py b/app/core/constants.py index 92556ed..aa1ecca 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -59,6 +59,12 @@ class MethodType(StrEnum): delete = "DELETE" +class MessageType(StrEnum): + verify_email = "verify_email" + two_factor_auth = "two_factor_auth" + reset_password = "reset_password" + + TOKEN_TYPE: str = "type" ACCESS_TOKEN_TYPE: str = "access" REFRESH_TOKEN_TYPE: str = "refresh" diff --git a/app/schemas/auth.py b/app/schemas/auth.py index f8537e8..221267f 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -3,9 +3,10 @@ from annotated_types import Len from pydantic import BaseModel, EmailStr +from core.constants import MessageType from schemas.user import LoginConstraint, PasswordConstraint -ConfirmationCode = Annotated[ +ConfirmationCodeConstraint = Annotated[ str, Len( min_length=6, @@ -30,4 +31,24 @@ class ConfirmEmailRequest(BaseModel): """ email: EmailStr - confirmation_code: ConfirmationCode + confirmation_code: ConfirmationCodeConstraint + + +class SendAuthEmail(BaseModel): + """ + Модель для отправки писем, связанных с аутентификацией, на почту. + """ + + email: EmailStr + message_type: MessageType + + +class ResetPasswordRequest(BaseModel): + """ + Модель для смены пароля. + """ + + email: EmailStr + password: PasswordConstraint + password_confirmation: PasswordConstraint + confirmation_code: ConfirmationCodeConstraint diff --git a/app/services/user.py b/app/services/user.py index 8a0b46b..3ca5696 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.celery.celery_app import app -from core.constants import BEARER_TOKEN_TYPE, UserRole +from core.constants import BEARER_TOKEN_TYPE, UserRole, MessageType from core.exceptions.auth import InvalidPasswordError from core.exceptions.confirmation_code import ( EmailConfirmationCodeNotFoundError, @@ -23,7 +23,12 @@ from core.security.jwt_utils import create_access_token, create_refresh_token from core.security.password_utils import hash_password, verify_password from repositories import UserRepository -from schemas.auth import ConfirmEmailRequest, UserLogin +from schemas.auth import ( + ConfirmEmailRequest, + UserLogin, + SendAuthEmail, + ResetPasswordRequest, +) from schemas.token_info import TokenInfo from schemas.user import ( UserCreate, @@ -43,7 +48,7 @@ def __init__( ) -> None: self.session = session self.user_repository = UserRepository(session) - self.cache_service = redis_service + self.redis_service = redis_service async def get_user_by_id(self, user_id: int) -> UserResponse: user = await self.user_repository.get_user_by_id(user_id) @@ -80,7 +85,7 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList ) async def get_confirmation_code(self, email: EmailStr) -> str: - confirmation_code = await self.cache_service.get(f"register:email:{email}") + confirmation_code = await self.redis_service.get(f"auth:email:{email}") if confirmation_code is None: raise EmailConfirmationCodeNotFoundError( email=email, @@ -159,24 +164,54 @@ async def create_user( async def create_confirmation_code(self, email: EmailStr) -> str: confirmation_code = "".join([str(random.randint(0, 9)) for _ in range(6)]) - await self.cache_service.set( - key=f"register:email:{email}", + await self.redis_service.set( + key=f"auth:email:{email}", value=confirmation_code, ttl=60, ) return confirmation_code - async def send_confirmation_code(self, email: EmailStr) -> None: + async def send_confirmation_code(self, send_auth_email_data: SendAuthEmail) -> None: + email = send_auth_email_data.email + message_type = send_auth_email_data.message_type.value confirmation_code = await self.create_confirmation_code(email) - app.send_task( - name=TaskType.send_confirmation_email_code.value, - args=[ - email, - confirmation_code, - ], - queue=Queue.notification.value, + if message_type in (MessageType.verify_email, MessageType.two_factor_auth): + app.send_task( + name=TaskType.send_confirmation_email_code.value, + args=[ + email, + confirmation_code, + ], + queue=Queue.notification.value, + ) + + if message_type == MessageType.reset_password.value: + user = await self.get_user_by_email(email) + app.send_task( + name=TaskType.send_reset_password_email_data.value, + args=[ + user.login, + email, + confirmation_code, + ], + queue=Queue.notification.value, + ) + + async def reset_password(self, reset_password_data: ResetPasswordRequest) -> None: + await self.verify_confirmation_code( + reset_password_data.email, + reset_password_data.confirmation_code, ) + if reset_password_data.password != reset_password_data.password_confirmation: + raise InvalidPasswordError + + user = await self.get_user_by_email(reset_password_data.email) + user_partial_update_data = UserPartialUpdate( + password=reset_password_data.password + ) + await self.partial_update_user(user.id, user_partial_update_data) + async def make_admin(self, user_id: int) -> None: if not await self.user_repository.make_admin(user_id): raise UserIdNotFoundError(user_id) @@ -257,7 +292,11 @@ async def authenticate_user(self, login_data: UserLogin) -> EmailStr: if not verify_password(login_data.password, user.encrypted_password): raise InvalidPasswordError - await self.send_confirmation_code(user.email) + send_auth_email_data = SendAuthEmail( + email=user.email, + message_type=MessageType.two_factor_auth, + ) + await self.send_confirmation_code(send_auth_email_data) return user.email async def is_admin(self, user_id: int) -> bool: diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index 3459c86..ba46c9b 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -32,3 +32,20 @@ def send_confirmation_email_code( confirmation_code=confirmation_code, ), ) + + +@app.task( + name=TaskType.send_reset_password_email_data.value, +) +def send_reset_password_email_data( + login: str, + email: EmailStr, + confirmation_code: str, +) -> None: + asyncio.run( + EmailService.send_reset_password_email_data( + login=login, + email=email, + confirmation_code=confirmation_code, + ) + ) diff --git a/notification-service/service.py b/notification-service/service.py index 3b569e2..897cdbc 100644 --- a/notification-service/service.py +++ b/notification-service/service.py @@ -79,7 +79,9 @@ async def send_welcome_email(cls, email: str, name: str) -> None: @classmethod async def send_confirmation_email_code( - cls, email: EmailStr, confirmation_code: str, + cls, + email: EmailStr, + confirmation_code: str, ) -> None: subject = "Confirm your email address" body = f"Your confirmation code is {confirmation_code}" @@ -88,3 +90,35 @@ async def send_confirmation_email_code( body=body, to_email=email, ) + + @classmethod + async def send_reset_password_email_data( + cls, + login: str, + email: EmailStr, + confirmation_code: str, + ) -> None: + subject = "Восстановление доступа к приложению MovieAPI" + body_template = """ + Здравствуйте! + + Мы получили запрос на восстановление доступа к вашему аккаунту. + + Ваш логин: {login} + + Чтобы войти, создайте новый пароль, используя код подтверждения. + + Ваш код подтверждения: {confirmation_code} + + Код подтверждения действует 1 минуту. + + Если вы не запрашивали восстановление, просто проигнорируйте это письмо. + """ + await cls.send_email( + subject=subject, + body=body_template.format( + login=login, + confirmation_code=confirmation_code, + ), + to_email=email, + ) diff --git a/packages/celery/constants.py b/packages/celery/constants.py index 210aeae..7f32b37 100644 --- a/packages/celery/constants.py +++ b/packages/celery/constants.py @@ -10,3 +10,6 @@ class TaskType(StrEnum): delete_temporary_file = "mediaservice.media.delete_temporary_file" send_welcome_email = "notification-service.email.send-welcome-email" send_confirmation_email_code = "notification-service.email.confirm_email" + send_reset_password_email_data = ( + "notification-service.email.send_reset_password_email_data" + ) From 222929171102926f1a0a8935dc964a798be5d6a8 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Fri, 19 Jun 2026 18:28:52 +0300 Subject: [PATCH 18/47] Add frontend to reset password endpoint. --- frontend/app/api/api_v1/auth.js | 68 +++--- frontend/app/data/state.js | 10 + frontend/app/services/methods/auth.js | 203 ++++++++++++++--- frontend/app/services/methods/routing.js | 16 ++ .../layout_navbar_auth_catalog_genres.html | 209 +++++++++++++++++- 5 files changed, 444 insertions(+), 62 deletions(-) diff --git a/frontend/app/api/api_v1/auth.js b/frontend/app/api/api_v1/auth.js index 6594c84..730b895 100644 --- a/frontend/app/api/api_v1/auth.js +++ b/frontend/app/api/api_v1/auth.js @@ -3,8 +3,14 @@ var parseResponseJson = window.ApiClient.parseResponseJson; var readErrorMessage = window.ApiClient.readErrorMessage; - function confirmEmail(payload) { - return fetch(apiUrl("/api/v1/auth/confirm-email"), { + // ОТПРАВКА КОДА (универсальный метод с message_type) + function sendConfirmationCode(email, messageType) { + var payload = { + email: email, + message_type: messageType, // "verify_email" | "two_factor_auth" | "reset_password" + }; + + return fetch(apiUrl("/api/v1/auth/send-confirmation-code"), { method: "POST", headers: { "Content-Type": "application/json", @@ -21,30 +27,15 @@ }); } - // ПОВТОРНАЯ ОТПРАВКА 2FA КОДА (используем тот же эндпоинт) - function resend2FACode(email) { - return fetch(apiUrl("/api/v1/auth/confirmation_code?email=" + encodeURIComponent(email)), { - method: "POST", - headers: { - Accept: "application/json", - }, - }).then(function (res) { - return parseResponseJson(res).then(function (data) { - if (!res.ok) { - throw new Error(readErrorMessage(data)); - } - return data; - }); - }); - } - - // ОТПРАВКА КОДА НА ПОЧТУ - function sendConfirmationCode(email) { - return fetch(apiUrl("/api/v1/auth/confirmation_code?email=" + encodeURIComponent(email)), { + // ПОДТВЕРЖДЕНИЕ 2FA КОДА + function confirmEmail(payload) { + return fetch(apiUrl("/api/v1/auth/confirm-email"), { method: "POST", headers: { + "Content-Type": "application/json", Accept: "application/json", }, + body: JSON.stringify(payload), }).then(function (res) { return parseResponseJson(res).then(function (data) { if (!res.ok) { @@ -74,13 +65,13 @@ }); } - // СТАРАЯ РЕГИСТРАЦИЯ (оставляем для совместимости, но не используем) + // СТАРАЯ РЕГИСТРАЦИЯ (для совместимости) function registerUser(payload) { - // Можно оставить или удалить console.warn("registerUser is deprecated, use registerUserWithCode"); return registerUserWithCode(payload); } + // ЛОГИН (возвращает email) function loginUser(username, password) { var body = new URLSearchParams(); body.set("username", username); @@ -97,7 +88,26 @@ if (!res.ok) { throw new Error(readErrorMessage(data)); } - window.TokenStore.setTokens(data.access_token, data.refresh_token); + // data - это строка с email + return data; + }); + }); + } + + // СБРОС ПАРОЛЯ + function resetPassword(payload) { + return fetch(apiUrl("/api/v1/auth/reset-password"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } return data; }); }); @@ -108,12 +118,12 @@ } window.ApiAuth = { - registerUser: registerUser, // оставляем для обратной совместимости - confirmEmail: confirmEmail, + registerUser: registerUser, // для обратной совместимости registerUserWithCode: registerUserWithCode, - sendConfirmationCode: sendConfirmationCode, + sendConfirmationCode: sendConfirmationCode, // универсальный метод + confirmEmail: confirmEmail, loginUser: loginUser, + resetPassword: resetPassword, logout: logout, - resend2FACode: resend2FACode, }; })(); \ No newline at end of file diff --git a/frontend/app/data/state.js b/frontend/app/data/state.js index d91428b..f55962d 100644 --- a/frontend/app/data/state.js +++ b/frontend/app/data/state.js @@ -167,6 +167,16 @@ canResend: false, timerInterval: null, + // Для восстановления пароля + resetStep: 'form', // 'form' | 'verify' | 'change' | 'done' + resetEmail: '', + resetCode: '', + resetNewPassword: '', + resetConfirmPassword: '', + resetResendTimer: 60, + resetCanResend: false, + resetTimerInterval: null, + // Форма для фильма editingMovie: null, showMovieForm: false, diff --git a/frontend/app/services/methods/auth.js b/frontend/app/services/methods/auth.js index 1c16e20..29a3a08 100644 --- a/frontend/app/services/methods/auth.js +++ b/frontend/app/services/methods/auth.js @@ -11,7 +11,7 @@ self.loginEmail = email; self.loginStep = 'verify'; self.success = "Код подтверждения отправлен на почту"; - self.startLoginResendTimer(60); // Запускаем таймер + self.startLoginResendTimer(60); }) .catch(function (e) { self.error = e.message || "Не удалось войти"; @@ -32,7 +32,6 @@ confirmation_code: this.loginCode.trim(), }) .then(function (data) { - // Сохраняем токены и перезагружаем страницу window.TokenStore.setTokens(data.access_token, data.refresh_token); window.location.hash = "#/"; window.location.reload(); @@ -45,17 +44,17 @@ }); }, - // ПОВТОРНАЯ ОТПРАВКА 2FA КОДА (пока без отдельного эндпоинта) + // ПОВТОРНАЯ ОТПРАВКА 2FA КОДА onResendLoginCode: function () { var self = this; this.error = ""; this.success = ""; this.loading = true; - window.ApiAuth.resend2FACode(this.loginEmail) + window.ApiAuth.sendConfirmationCode(this.loginEmail, "two_factor_auth") .then(function () { self.success = "Новый код отправлен на почту"; - self.startLoginResendTimer(60); // Запускаем таймер + self.startLoginResendTimer(60); }) .catch(function (e) { self.error = e.message || "Не удалось отправить код"; @@ -65,7 +64,6 @@ }); }, - // ВОЗВРАТ К ФОРМЕ ЛОГИНА onBackToLogin: function () { this.loginStep = 'form'; @@ -99,14 +97,13 @@ }, // ==================== РЕГИСТРАЦИЯ ==================== - // ОТПРАВКА КОДА НА ПОЧТУ + // ОТПРАВКА КОДА НА ПОЧТУ (регистрация) onSendCode: function () { var self = this; this.error = ""; this.success = ""; this.loading = true; - // Сохраняем данные регистрации this.registrationData = { surname: this.registerForm.surname.trim(), name: this.registerForm.name.trim(), @@ -115,7 +112,7 @@ password: this.registerForm.password, }; - window.ApiAuth.sendConfirmationCode(this.registrationData.email) + window.ApiAuth.sendConfirmationCode(this.registrationData.email, "verify_email") .then(function () { self.registerStep = 'verify'; self.success = "Код подтверждения отправлен на почту"; @@ -165,7 +162,7 @@ this.success = ""; this.loading = true; - window.ApiAuth.sendConfirmationCode(this.registrationData.email) + window.ApiAuth.sendConfirmationCode(this.registrationData.email, "verify_email") .then(function () { self.success = "Новый код отправлен на почту"; self.startResendTimer(60); @@ -196,47 +193,26 @@ this.error = ""; this.success = ""; - // Валидация пароля if (this.registerForm.password.length < 8) { this.error = "Пароль должен быть минимум 8 символов"; return; } - // Валидация email var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(this.registerForm.email.trim())) { this.error = "Введите корректный email"; return; } - // Валидация логина if (this.registerForm.login.trim().length < 3) { this.error = "Логин должен быть минимум 3 символа"; return; } - // Отправляем код this.onSendCode(); }, - // ==================== ОБЩИЕ МЕТОДЫ ==================== - onLogout: function () { - window.ApiAuth.logout(); - window.location.reload(); - }, - - formatRegistrationDate: function (iso) { - if (!iso) { - return "—"; - } - try { - return new Date(iso).toLocaleString("ru-RU"); - } catch (e) { - return iso; - } - }, - - // ТАЙМЕР ДЛЯ ПОВТОРНОЙ ОТПРАВКИ (регистрация) + // ТАЙМЕР ДЛЯ РЕГИСТРАЦИИ startResendTimer: function (seconds) { var self = this; this.resendTimer = seconds; @@ -255,5 +231,168 @@ } }, 1000); }, + + // ==================== ВОССТАНОВЛЕНИЕ ПАРОЛЯ ==================== + // ШАГ 1: Отправка кода для восстановления + onSendResetCode: function () { + var self = this; + this.error = ""; + this.success = ""; + this.loading = true; + + var email = this.resetEmail.trim(); + var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + this.error = "Введите корректный email"; + this.loading = false; + return; + } + + window.ApiAuth.sendConfirmationCode(email, "reset_password") + .then(function () { + self.resetStep = 'verify'; + self.success = "Код восстановления отправлен на почту"; + self.startResetResendTimer(60); + }) + .catch(function (e) { + self.error = e.message || "Не удалось отправить код"; + }) + .finally(function () { + self.loading = false; + }); + }, + + // ШАГ 2: Подтверждение кода и переход к смене пароля + onVerifyResetCode: function () { + var self = this; + this.error = ""; + this.success = ""; + this.loading = true; + + if (this.resetCode.trim().length !== 6) { + this.error = "Введите 6-значный код"; + this.loading = false; + return; + } + + // Проверяем код через бэкенд + // Для проверки кода используем тот же confirmEmail? + // Если есть отдельный эндпоинт для проверки - используйте его + // Пока просто переходим к шагу смены пароля + self.resetStep = 'change'; + self.loading = false; + }, + + // ШАГ 3: Смена пароля + onChangePassword: function () { + var self = this; + this.error = ""; + this.success = ""; + this.loading = true; + + if (this.resetNewPassword.length < 8) { + this.error = "Пароль должен быть минимум 8 символов"; + this.loading = false; + return; + } + + if (this.resetNewPassword !== this.resetConfirmPassword) { + this.error = "Пароли не совпадают"; + this.loading = false; + return; + } + + var payload = { + email: this.resetEmail, + password: this.resetNewPassword, + password_confirmation: this.resetConfirmPassword, + confirmation_code: this.resetCode.trim(), + }; + + window.ApiAuth.resetPassword(payload) + .then(function () { + self.success = "Пароль успешно изменен! Теперь вы можете войти."; + self.resetStep = 'done'; + }) + .catch(function (e) { + self.error = e.message || "Не удалось изменить пароль"; + }) + .finally(function () { + self.loading = false; + }); + }, + + // СБРОС ВОССТАНОВЛЕНИЯ (возврат к логину) + onResetBackToLogin: function () { + this.resetStep = 'form'; + this.resetEmail = ''; + this.resetCode = ''; + this.resetNewPassword = ''; + this.resetConfirmPassword = ''; + this.error = ''; + this.success = ''; + if (this.resetTimerInterval) { + clearInterval(this.resetTimerInterval); + this.resetTimerInterval = null; + } + this.currentView = 'login'; + }, + + // ПОВТОРНАЯ ОТПРАВКА КОДА ДЛЯ ВОССТАНОВЛЕНИЯ + onResendResetCode: function () { + var self = this; + this.error = ""; + this.success = ""; + this.loading = true; + + window.ApiAuth.sendConfirmationCode(this.resetEmail, "reset_password") + .then(function () { + self.success = "Новый код отправлен на почту"; + self.startResetResendTimer(60); + }) + .catch(function (e) { + self.error = e.message || "Не удалось отправить код"; + }) + .finally(function () { + self.loading = false; + }); + }, + + // ТАЙМЕР ДЛЯ ВОССТАНОВЛЕНИЯ + startResetResendTimer: function (seconds) { + var self = this; + this.resetResendTimer = seconds; + this.resetCanResend = false; + + if (this.resetTimerInterval) { + clearInterval(this.resetTimerInterval); + } + + this.resetTimerInterval = setInterval(function () { + self.resetResendTimer--; + if (self.resetResendTimer <= 0) { + clearInterval(self.resetTimerInterval); + self.resetTimerInterval = null; + self.resetCanResend = true; + } + }, 1000); + }, + + // ==================== ОБЩИЕ МЕТОДЫ ==================== + onLogout: function () { + window.ApiAuth.logout(); + window.location.reload(); + }, + + formatRegistrationDate: function (iso) { + if (!iso) { + return "—"; + } + try { + return new Date(iso).toLocaleString("ru-RU"); + } catch (e) { + return iso; + } + }, }; })(); \ No newline at end of file diff --git a/frontend/app/services/methods/routing.js b/frontend/app/services/methods/routing.js index a6a5817..9058df5 100644 --- a/frontend/app/services/methods/routing.js +++ b/frontend/app/services/methods/routing.js @@ -33,6 +33,22 @@ this.error = ""; return; } + if (hash === "#/reset-password") { + this.currentView = "reset-password"; + this.error = ""; + this.success = ""; + this.resetStep = "form"; + this.resetEmail = ""; + this.resetCode = ""; + this.resetNewPassword = ""; + this.resetConfirmPassword = ""; + // Очищаем таймер если был + if (this.resetTimerInterval) { + clearInterval(this.resetTimerInterval); + this.resetTimerInterval = null; + } + return; + } if (hash === "#/profile") { if (!this.isAuthenticated) { this.goLogin(); diff --git a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html index 19a9297..ed188af 100644 --- a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html +++ b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html @@ -67,7 +67,7 @@
-
+

Вход

+ + +
+
+
+
+

Восстановление пароля

+

+ Введите email, на который зарегистрирован аккаунт. Мы отправим код для восстановления. +

+ + + + + +
+
+ + +
+ +
+ + +
+
+ +

+ Вернуться ко входу +

+
+
+
+
+ + +
+
+
+
+

Введите код восстановления

+

+ На почту {{ resetEmail }} отправлен код восстановления. + Введите его ниже. +

+ + + + + +
+
+ + +
+ Введите 6-значный код из письма +
+
+ +
+ + +
+
+ + +
+ + Не пришло письмо? + + +
+ +

+ Вернуться ко входу +

+
+
+
+
+ + +
+
+
+
+

Создание нового пароля

+

+ Придумайте новый пароль для аккаунта {{ resetEmail }} +

+ + + + + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ +

+ Вернуться ко входу +

+
+
+
+
+ + +
+
+
+
+

✅ Пароль изменен!

+

+ Ваш пароль успешно изменен. Теперь вы можете войти в аккаунт с новым паролем. +

+ + Войти + +
+
+
+
+
From 6450f03cc1eefb1d4d5778355b79152a14fa4367 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sat, 20 Jun 2026 21:59:46 +0300 Subject: [PATCH 19/47] Add create temporary jwt tokens for security registration, 2fa, reset password. --- app/core/config.py | 17 +++++- app/core/constants.py | 15 ++--- app/core/security/jwt_utils.py | 105 ++++++++++++++++++++++++++++----- app/dependencies/auth.py | 4 +- 4 files changed, 113 insertions(+), 28 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 5635dc2..7ece423 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -58,6 +58,14 @@ class AuthJWTConfig(BaseModel): refresh_token_expire_minutes: int = 30 * 24 * 60 +class ConfirmationCodeJWTConfig(BaseModel): + secret_key: str = "secret_key" + algorithm: str = "HS256" + temporary_token_registration_expire_minutes: int = 15 + temporary_token_two_factor_expire_minutes: int = 15 + temporary_token_reset_password_expire_minutes: int = 15 + + class MediaServiceConfig(BaseModel): host: str = "mediaservice" port: int = 8000 @@ -82,10 +90,13 @@ class Settings(BaseSettings): redis: RedisConfig = RedisConfig() rabbitmq: RabbitMQConfig = RabbitMQConfig() auth_jwt: AuthJWTConfig = AuthJWTConfig() + confirmation_code_jwt: ConfirmationCodeJWTConfig = ConfirmationCodeJWTConfig() http_bearer: HTTPBearer = HTTPBearer() - oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer("/api/v1/auth/login") - mediaservice: MediaServiceConfig = MediaServiceConfig() - notificationservice: NotificationServiceConfig = NotificationServiceConfig() + oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer( + "/api/v1/auth/confirm-email", + ) + media_service: MediaServiceConfig = MediaServiceConfig() + notification_service: NotificationServiceConfig = NotificationServiceConfig() debug: bool = False model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( diff --git a/app/core/constants.py b/app/core/constants.py index aa1ecca..bdbde00 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -59,15 +59,12 @@ class MethodType(StrEnum): delete = "DELETE" -class MessageType(StrEnum): - verify_email = "verify_email" - two_factor_auth = "two_factor_auth" - reset_password = "reset_password" - - -TOKEN_TYPE: str = "type" -ACCESS_TOKEN_TYPE: str = "access" -REFRESH_TOKEN_TYPE: str = "refresh" +TOKEN_TYPE = "type" +ACCESS_TOKEN_TYPE = "access" +REFRESH_TOKEN_TYPE = "refresh" +REGISTRATION_TEMPORARY_TOKEN_TYPE = "registration_temporary_token" +TWO_FACTOR_VERIFICATION_TEMPORARY_TOKEN_TYPE = "two_factor_verification_temporary_token" +RESET_PASSWORD_TEMPORARY_TOKEN_TYPE = "reset_password_temporary_token" BEARER_TOKEN_TYPE: str = "Bearer" diff --git a/app/core/security/jwt_utils.py b/app/core/security/jwt_utils.py index 28fa782..d9c2396 100644 --- a/app/core/security/jwt_utils.py +++ b/app/core/security/jwt_utils.py @@ -4,8 +4,15 @@ import jwt from core.config import settings -from core.constants import ACCESS_TOKEN_TYPE, REFRESH_TOKEN_TYPE, TOKEN_TYPE -from schemas.user import UserResponse +from core.constants import ( + ACCESS_TOKEN_TYPE, + REFRESH_TOKEN_TYPE, + REGISTRATION_TEMPORARY_TOKEN_TYPE, + RESET_PASSWORD_TEMPORARY_TOKEN_TYPE, + TOKEN_TYPE, + TWO_FACTOR_VERIFICATION_TEMPORARY_TOKEN_TYPE, +) +from schemas.user import UserRegistration, UserResponse def encode_jwt( @@ -40,6 +47,53 @@ def decode_jwt( ) +def create_user_payload_for_access_token(user: UserResponse) -> dict[str, str]: + payload = { + "sub": str(user.id), + "login": user.login, + "email": user.email, + } + return payload + + +def create_user_payload_for_refresh_token(user: UserResponse) -> dict[str, str]: + payload = { + "sub": str(user.id), + } + return payload + + +def create_user_payload_for_registration_temporary_token( + user: UserRegistration, +) -> dict[str, str]: + payload = { + "sub": user.login, + "login": user.login, + "email": user.email, + } + return payload + + +def create_user_payload_for_two_factor_verification_temporary_token( + user: UserResponse, +) -> dict[str, str]: + payload = { + "sub": str(user.id), + "email": user.email, + } + return payload + + +def create_user_payload_for_reset_password_temporary_token( + user: UserResponse, +) -> dict[str, str]: + payload = { + "sub": str(user.id), + "email": user.email, + } + return payload + + def create_access_token(user: UserResponse) -> str: payload = create_user_payload_for_access_token(user) payload.update( @@ -62,17 +116,40 @@ def create_refresh_token(user: UserResponse) -> str: ) -def create_user_payload_for_access_token(user: UserResponse) -> dict[str, str]: - payload = { - "sub": str(user.id), - "login": user.login, - "email": user.email, - } - return payload +def create_registration_temporary_token(user: UserRegistration) -> str: + payload = create_user_payload_for_registration_temporary_token(user) + payload.update( + {TOKEN_TYPE: REGISTRATION_TEMPORARY_TOKEN_TYPE}, + ) + return encode_jwt( + payload=payload, + secret_key=settings.confirmation_code_jwt.secret_key, + algorithm=settings.confirmation_code_jwt.algorithm, + expires_minutes=settings.confirmation_code_jwt.temporary_token_registration_expire_minutes, + ) -def create_user_payload_for_refresh_token(user: UserResponse) -> dict[str, str]: - payload = { - "sub": str(user.id), - } - return payload +def create_two_factor_verification_temporary_token(user: UserResponse) -> str: + payload = create_user_payload_for_two_factor_verification_temporary_token(user) + payload.update( + {TOKEN_TYPE: TWO_FACTOR_VERIFICATION_TEMPORARY_TOKEN_TYPE}, + ) + return encode_jwt( + payload=payload, + secret_key=settings.confirmation_code_jwt.secret_key, + algorithm=settings.confirmation_code_jwt.algorithm, + expires_minutes=settings.confirmation_code_jwt.temporary_token_two_factor_expire_minutes, + ) + + +def create_reset_password_temporary_token(user: UserResponse) -> str: + payload = create_user_payload_for_reset_password_temporary_token(user) + payload.update( + {TOKEN_TYPE: RESET_PASSWORD_TEMPORARY_TOKEN_TYPE}, + ) + return encode_jwt( + payload=payload, + secret_key=settings.confirmation_code_jwt.secret_key, + algorithm=settings.confirmation_code_jwt.algorithm, + expires_minutes=settings.confirmation_code_jwt.temporary_token_reset_password_expire_minutes, + ) diff --git a/app/dependencies/auth.py b/app/dependencies/auth.py index a5f9555..d950132 100644 --- a/app/dependencies/auth.py +++ b/app/dependencies/auth.py @@ -34,7 +34,7 @@ def get_user_by_access_token( payload=payload, target_token_type=ACCESS_TOKEN_TYPE, ) - user_id: int = cast(int, payload["sub"]) + user_id = cast(int, payload["sub"]) return user_id @@ -48,7 +48,7 @@ def get_user_by_refresh_token( payload=payload, target_token_type=REFRESH_TOKEN_TYPE, ) - user_id: int = cast(int, payload["sub"]) + user_id = cast(int, payload["sub"]) return user_id From b76b557c6e4664a58f10b8c0a6af670fc2a792a7 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sat, 20 Jun 2026 22:00:36 +0300 Subject: [PATCH 20/47] Use temporary jwt tokens in user registration. --- app/api/api_v1/auth.py | 73 ++++--- app/api/api_v1/media.py | 2 +- app/cache_services/user.py | 4 +- app/core/database/init_db.py | 2 +- app/schemas/auth.py | 24 ++- app/schemas/token_info.py | 11 ++ app/schemas/user.py | 1 - app/services/user.py | 225 ++++++++++++---------- notification-service/core/celery/tasks.py | 2 +- 9 files changed, 206 insertions(+), 138 deletions(-) diff --git a/app/api/api_v1/auth.py b/app/api/api_v1/auth.py index f95c7ec..51e5da3 100644 --- a/app/api/api_v1/auth.py +++ b/app/api/api_v1/auth.py @@ -2,19 +2,20 @@ APIRouter, status, ) -from pydantic import EmailStr -from dependencies.annotations.cache_services import UserCacheServiceDep from dependencies.annotations.security import ( AuthUserByRefreshTokenDep, GetLoginDataDep, ) from dependencies.annotations.services import UserServiceDep -from schemas.auth import ConfirmEmailRequest, SendAuthEmail, ResetPasswordRequest -from schemas.token_info import TokenInfo +from schemas.auth import ( + ResetPasswordRequest, + SendConfirmationCodeRequest, + VerifyRegisterUser, +) +from schemas.token_info import TemporaryTokenInfo, TokenInfo from schemas.user import ( UserRegistration, - UserResponse, ) router = APIRouter( @@ -25,49 +26,73 @@ @router.post( "/register", - response_model=UserResponse, - status_code=status.HTTP_201_CREATED, + response_model=TemporaryTokenInfo, + status_code=status.HTTP_200_OK, ) async def register_user( registration_user_data: UserRegistration, - user_service: UserCacheServiceDep, -) -> UserResponse: - return await user_service.create_user(registration_user_data) + user_service: UserServiceDep, +) -> TemporaryTokenInfo: + return await user_service.register_user(registration_user_data) + + +@router.post("/register/resend-confirmation-code") +async def resend_register_confirmation_code( + send_confirmation_code_request: SendConfirmationCodeRequest, + user_service: UserServiceDep, +) -> None: + await user_service.send_register_confirmation_code(send_confirmation_code_request) + + +@router.post( + "/register/verify", + response_model=TokenInfo, + status_code=status.HTTP_200_OK, +) +async def register_user( + verify_register_user_data: VerifyRegisterUser, + user_service: UserServiceDep, +) -> TokenInfo: + return await user_service.verify_register_user(verify_register_user_data) @router.post( "/login", + response_model=TemporaryTokenInfo, status_code=status.HTTP_200_OK, ) async def login_user( login_data: GetLoginDataDep, user_service: UserServiceDep, -) -> EmailStr: +) -> TemporaryTokenInfo: return await user_service.authenticate_user(login_data) -@router.post("/send-confirmation-code") +@router.post("/reset-password") +async def reset_password( + reset_password_data: ResetPasswordRequest, + user_service: UserServiceDep, +) -> None: + await user_service.reset_password(reset_password_data) + + +@router.post( + "/send-confirmation-code", +) async def send_confirmation_code( - send_auth_email_data: SendAuthEmail, + temporary_token_data: TemporaryTokenInfo, user_service: UserServiceDep, ) -> None: - await user_service.send_confirmation_code(send_auth_email_data) + await user_service.send_confirmation_code(temporary_token_data) @router.post("/confirm-email") async def confirm_email( - confirm_email_request: ConfirmEmailRequest, + temporary_token_data: TemporaryTokenInfo, + confirmation_code: str, user_service: UserServiceDep, ) -> TokenInfo: - return await user_service.confirm_email(confirm_email_request) - - -@router.post("/reset-password") -async def reset_password( - reset_password_data: ResetPasswordRequest, - user_service: UserServiceDep, -) -> None: - await user_service.reset_password(reset_password_data) + return await user_service.confirm_email(temporary_token_data, confirmation_code) @router.post( diff --git a/app/api/api_v1/media.py b/app/api/api_v1/media.py index fbfe357..fd4421a 100644 --- a/app/api/api_v1/media.py +++ b/app/api/api_v1/media.py @@ -33,7 +33,7 @@ async def get_presign_url( ], ) -> PresignUrlResponse: return await http_request_service.get_schema_from_request( - url=settings.mediaservice.create_presign_url_endpoint, + url=settings.media_service.create_presign_url_endpoint, method=MethodType.post.value, # type: ignore[arg-type] json=presign_url_create.model_dump(), response_schema=PresignUrlResponse, # type: ignore[arg-type] diff --git a/app/cache_services/user.py b/app/cache_services/user.py index 659c2f5..8fe2d3e 100644 --- a/app/cache_services/user.py +++ b/app/cache_services/user.py @@ -50,11 +50,11 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList await self.cache_service.set(key, users_response) return users_response - async def create_user( + async def register_user( self, registration_user_data: UserRegistration, ) -> UserResponse: - user_response = await self.user_service.create_user(registration_user_data) + user_response = await self.user_service.register_user(registration_user_data) key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) diff --git a/app/core/database/init_db.py b/app/core/database/init_db.py index 9c053db..2640c37 100644 --- a/app/core/database/init_db.py +++ b/app/core/database/init_db.py @@ -14,6 +14,6 @@ async def init_admin() -> None: email="admin@admin.gmail.ru", password="adminadmin", # noqa: S106 ) - await user_service.create_user(create_user_data) + await user_service.register_user(create_user_data) admin = await user_service.get_user_by_login("adminadmin") await user_service.make_admin(admin.id) diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 221267f..9a3a478 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -3,7 +3,6 @@ from annotated_types import Len from pydantic import BaseModel, EmailStr -from core.constants import MessageType from schemas.user import LoginConstraint, PasswordConstraint ConfirmationCodeConstraint = Annotated[ @@ -24,23 +23,31 @@ class UserLogin(BaseModel): password: PasswordConstraint -class ConfirmEmailRequest(BaseModel): +class SendConfirmationCodeRequest(BaseModel): """ - Модель для двухфакторной аутентификации: - подтверждение через дополнительный код. + Модель для отправки кода подтверждения на почту. """ - email: EmailStr + temporary_token: str + + +class VerifyRegisterUser(BaseModel): + """ + Модель для подтверждения почты для регистрации пользователя. + """ + + temporary_registration_token: str confirmation_code: ConfirmationCodeConstraint -class SendAuthEmail(BaseModel): +class ConfirmEmailRequest(BaseModel): """ - Модель для отправки писем, связанных с аутентификацией, на почту. + Модель для двухфакторной аутентификации: + подтверждение через дополнительный код. """ email: EmailStr - message_type: MessageType + confirmation_code: ConfirmationCodeConstraint class ResetPasswordRequest(BaseModel): @@ -51,4 +58,3 @@ class ResetPasswordRequest(BaseModel): email: EmailStr password: PasswordConstraint password_confirmation: PasswordConstraint - confirmation_code: ConfirmationCodeConstraint diff --git a/app/schemas/token_info.py b/app/schemas/token_info.py index 3a93353..aaf1c99 100644 --- a/app/schemas/token_info.py +++ b/app/schemas/token_info.py @@ -11,3 +11,14 @@ class TokenInfo(BaseModel): access_token: str refresh_token: str | None = None token_type: str = BEARER_TOKEN_TYPE + + +class TemporaryTokenInfo(BaseModel): + """ + Модель для вывода информации о токенах, + предназначенных для временного доступа к + отправке кодов подтверждения на почту. + """ + + temporary_token: str + token_type: str = BEARER_TOKEN_TYPE diff --git a/app/schemas/user.py b/app/schemas/user.py index 69ff52f..6d9e0f3 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -100,7 +100,6 @@ class UserRegistration(UserBase): """ password: PasswordConstraint - confirmation_code: ConfirmationCode class UserUpdate(UserBase): diff --git a/app/services/user.py b/app/services/user.py index 3ca5696..4f29cf5 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -6,7 +6,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.celery.celery_app import app -from core.constants import BEARER_TOKEN_TYPE, UserRole, MessageType +from core.config import settings +from core.constants import ( + BEARER_TOKEN_TYPE, + UserRole, +) from core.exceptions.auth import InvalidPasswordError from core.exceptions.confirmation_code import ( EmailConfirmationCodeNotFoundError, @@ -20,16 +24,22 @@ UserLoginNotFoundError, ) from core.redis import RedisService -from core.security.jwt_utils import create_access_token, create_refresh_token +from core.security.jwt_utils import ( + create_access_token, + create_refresh_token, + create_registration_temporary_token, + create_two_factor_verification_temporary_token, + decode_jwt, +) from core.security.password_utils import hash_password, verify_password from repositories import UserRepository from schemas.auth import ( - ConfirmEmailRequest, - UserLogin, - SendAuthEmail, ResetPasswordRequest, + SendConfirmationCodeRequest, + UserLogin, + VerifyRegisterUser, ) -from schemas.token_info import TokenInfo +from schemas.token_info import TemporaryTokenInfo, TokenInfo from schemas.user import ( UserCreate, UserPartialUpdate, @@ -84,43 +94,6 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList page=page, ) - async def get_confirmation_code(self, email: EmailStr) -> str: - confirmation_code = await self.redis_service.get(f"auth:email:{email}") - if confirmation_code is None: - raise EmailConfirmationCodeNotFoundError( - email=email, - ) - return confirmation_code - - async def verify_confirmation_code( - self, - email: EmailStr, - confirmation_code: str, - ) -> None: - sent_confirmation_code = await self.get_confirmation_code(email) - if confirmation_code != sent_confirmation_code: - raise InvalidEmailConfirmationCodeError( - email=email, - confirmation_code=confirmation_code, - ) - - async def confirm_email( - self, - confirm_email_request: ConfirmEmailRequest, - ) -> TokenInfo: - await self.verify_confirmation_code( - confirm_email_request.email, - confirm_email_request.confirmation_code, - ) - user = await self.get_user_by_email(confirm_email_request.email) - access_token = create_access_token(user) - refresh_token = create_refresh_token(user) - return TokenInfo( - access_token=access_token, - refresh_token=refresh_token, - token_type=BEARER_TOKEN_TYPE, - ) - @staticmethod def convert_registration_to_create_schema( user_registration_data: UserRegistration, @@ -133,35 +106,130 @@ def convert_registration_to_create_schema( user_create_data["encrypted_password"] = encrypted_password return UserCreate(**user_create_data) - async def create_user( + async def register_user( self, registration_user_data: UserRegistration, - ) -> UserResponse: + ) -> TemporaryTokenInfo: if await self.user_repository.user_login_exists(registration_user_data.login): raise UserLoginAlreadyExistsError(registration_user_data.login) if await self.user_repository.user_email_exists(registration_user_data.email): raise UserEmailAlreadyExistsError(registration_user_data.email) - await self.verify_confirmation_code( - registration_user_data.email, - registration_user_data.confirmation_code, - ) - create_user_data = self.convert_registration_to_create_schema( registration_user_data, ) - user = await self.user_repository.create_user(create_user_data) + temporary_token = create_registration_temporary_token(registration_user_data) + ttl_seconds = ( + settings.confirmation_code_jwt.temporary_token_registration_expire_minutes + * 60 + ) + await self.redis_service.set( + key=f"{temporary_token}", + value=create_user_data.model_dump_json(), + ttl=ttl_seconds, + ) + send_confirmation_code_request = SendConfirmationCodeRequest( + temporary_token=temporary_token, + ) + await self.send_register_confirmation_code(send_confirmation_code_request) + return TemporaryTokenInfo( + temporary_token=temporary_token, + token_type=BEARER_TOKEN_TYPE, + ) + + async def create_user(self, user: UserCreate) -> UserResponse: + if await self.user_repository.user_login_exists(user.login): + raise UserLoginAlreadyExistsError(user.login) + + if await self.user_repository.user_email_exists(user.email): + raise UserEmailAlreadyExistsError(user.email) + + user = await self.user_repository.create_user(user) app.send_task( name=TaskType.send_welcome_email.value, args=[ - registration_user_data.email, - registration_user_data.name, + user.email, + user.name, ], queue=Queue.notification.value, ) return UserResponse.model_validate(user) + async def send_register_confirmation_code( + self, + send_confirmation_code_request: SendConfirmationCodeRequest, + ) -> None: + token = send_confirmation_code_request.temporary_token + payload = decode_jwt( + token=token, + secret_key=settings.confirmation_code_jwt.secret_key, + algorithm=settings.confirmation_code_jwt.algorithm, + ) + email = payload["email"] + confirmation_code = await self.create_confirmation_code(email) + app.send_task( + name=TaskType.send_confirmation_email_code.value, + args=[ + email, + confirmation_code, + ], + queue=Queue.notification.value, + ) + + async def verify_register_user( + self, + verify_register_user_data: VerifyRegisterUser, + ) -> TokenInfo: + token = verify_register_user_data.temporary_registration_token + user_data_create_json = await self.redis_service.get(key=f"{token}") + user_data_create = UserCreate.model_validate_json(user_data_create_json) + user = await self.create_user(user_data_create) + access_token = create_access_token(user) + refresh_token = create_refresh_token(user) + return TokenInfo( + access_token=access_token, + refresh_token=refresh_token, + token_type=BEARER_TOKEN_TYPE, + ) + + async def authenticate_user(self, login_data: UserLogin) -> TemporaryTokenInfo: + user = await self.user_repository.get_user_by_login(login_data.login) + if user is None: + raise UserLoginNotFoundError(login_data.login) + + if not verify_password(login_data.password, user.encrypted_password): + raise InvalidPasswordError + + user = UserResponse.model_validate(user) + temporary_token = create_two_factor_verification_temporary_token(user) + token_data = TemporaryTokenInfo( + temporary_token=temporary_token, + token_type=BEARER_TOKEN_TYPE, + ) + + return token_data + + async def get_confirmation_code(self, email: EmailStr) -> str: + confirmation_code = await self.redis_service.get(f"auth:email:{email}") + if confirmation_code is None: + raise EmailConfirmationCodeNotFoundError( + email=email, + ) + return confirmation_code + + async def verify_confirmation_code( + self, + email: EmailStr, + confirmation_code: str, + ) -> None: + sent_confirmation_code = await self.get_confirmation_code(email) + if confirmation_code != sent_confirmation_code: + raise InvalidEmailConfirmationCodeError( + email=email, + confirmation_code=confirmation_code, + ) + async def create_confirmation_code(self, email: EmailStr) -> str: confirmation_code = "".join([str(random.randint(0, 9)) for _ in range(6)]) await self.redis_service.set( @@ -171,32 +239,6 @@ async def create_confirmation_code(self, email: EmailStr) -> str: ) return confirmation_code - async def send_confirmation_code(self, send_auth_email_data: SendAuthEmail) -> None: - email = send_auth_email_data.email - message_type = send_auth_email_data.message_type.value - confirmation_code = await self.create_confirmation_code(email) - if message_type in (MessageType.verify_email, MessageType.two_factor_auth): - app.send_task( - name=TaskType.send_confirmation_email_code.value, - args=[ - email, - confirmation_code, - ], - queue=Queue.notification.value, - ) - - if message_type == MessageType.reset_password.value: - user = await self.get_user_by_email(email) - app.send_task( - name=TaskType.send_reset_password_email_data.value, - args=[ - user.login, - email, - confirmation_code, - ], - queue=Queue.notification.value, - ) - async def reset_password(self, reset_password_data: ResetPasswordRequest) -> None: await self.verify_confirmation_code( reset_password_data.email, @@ -208,14 +250,10 @@ async def reset_password(self, reset_password_data: ResetPasswordRequest) -> Non user = await self.get_user_by_email(reset_password_data.email) user_partial_update_data = UserPartialUpdate( - password=reset_password_data.password + password=reset_password_data.password, ) await self.partial_update_user(user.id, user_partial_update_data) - async def make_admin(self, user_id: int) -> None: - if not await self.user_repository.make_admin(user_id): - raise UserIdNotFoundError(user_id) - async def update_user( self, user_id: int, @@ -284,21 +322,6 @@ async def delete_user_by_login(self, login: str) -> None: if not await self.user_repository.delete_user_by_login(login): raise UserLoginNotFoundError(login) - async def authenticate_user(self, login_data: UserLogin) -> EmailStr: - user = await self.user_repository.get_user_by_login(login_data.login) - if user is None: - raise UserLoginNotFoundError(login_data.login) - - if not verify_password(login_data.password, user.encrypted_password): - raise InvalidPasswordError - - send_auth_email_data = SendAuthEmail( - email=user.email, - message_type=MessageType.two_factor_auth, - ) - await self.send_confirmation_code(send_auth_email_data) - return user.email - async def is_admin(self, user_id: int) -> bool: role = await self.user_repository.get_user_role(user_id) if role is None: @@ -306,6 +329,10 @@ async def is_admin(self, user_id: int) -> bool: return role == UserRole.admin.value + async def make_admin(self, user_id: int) -> None: + if not await self.user_repository.make_admin(user_id): + raise UserIdNotFoundError(user_id) + async def refresh_access_token(self, user_id: int) -> TokenInfo: user = await self.get_user_by_id(user_id) access_token = create_access_token(user) diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index ba46c9b..1e34cf3 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -47,5 +47,5 @@ def send_reset_password_email_data( login=login, email=email, confirmation_code=confirmation_code, - ) + ), ) From 2f77457e30dc7e21950c7180bfd9a2e77cbc715e Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 21 Jun 2026 08:55:29 +0300 Subject: [PATCH 21/47] Use temporary jwt tokens in user authentication. --- app/api/api_v1/auth.py | 22 ++++++++++-- app/schemas/auth.py | 18 +++------- app/services/user.py | 77 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 92 insertions(+), 25 deletions(-) diff --git a/app/api/api_v1/auth.py b/app/api/api_v1/auth.py index 51e5da3..430ea7c 100644 --- a/app/api/api_v1/auth.py +++ b/app/api/api_v1/auth.py @@ -11,7 +11,7 @@ from schemas.auth import ( ResetPasswordRequest, SendConfirmationCodeRequest, - VerifyRegisterUser, + VerifyUserEmail, ) from schemas.token_info import TemporaryTokenInfo, TokenInfo from schemas.user import ( @@ -50,7 +50,7 @@ async def resend_register_confirmation_code( status_code=status.HTTP_200_OK, ) async def register_user( - verify_register_user_data: VerifyRegisterUser, + verify_register_user_data: VerifyUserEmail, user_service: UserServiceDep, ) -> TokenInfo: return await user_service.verify_register_user(verify_register_user_data) @@ -68,6 +68,24 @@ async def login_user( return await user_service.authenticate_user(login_data) +@router.post("/login/resend-confirmation-code") +async def resend_authenticate_confirmation_code( + send_confirmation_code_request: SendConfirmationCodeRequest, + user_service: UserServiceDep, +) -> None: + await user_service.send_authenticate_confirmation_code( + send_confirmation_code_request, + ) + + +@router.post("/login/verify") +async def verify_authenticate_user( + verify_authenticate_user_data: VerifyUserEmail, + user_service: UserServiceDep, +) -> TokenInfo: + return await user_service.verify_authenticate_user(verify_authenticate_user_data) + + @router.post("/reset-password") async def reset_password( reset_password_data: ResetPasswordRequest, diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 9a3a478..4244378 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -28,25 +28,15 @@ class SendConfirmationCodeRequest(BaseModel): Модель для отправки кода подтверждения на почту. """ - temporary_token: str + token: str -class VerifyRegisterUser(BaseModel): +class VerifyUserEmail(BaseModel): """ - Модель для подтверждения почты для регистрации пользователя. + Модель для подтверждения почты пользователя. """ - temporary_registration_token: str - confirmation_code: ConfirmationCodeConstraint - - -class ConfirmEmailRequest(BaseModel): - """ - Модель для двухфакторной аутентификации: - подтверждение через дополнительный код. - """ - - email: EmailStr + token: str confirmation_code: ConfirmationCodeConstraint diff --git a/app/services/user.py b/app/services/user.py index 4f29cf5..14ba959 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -37,7 +37,7 @@ ResetPasswordRequest, SendConfirmationCodeRequest, UserLogin, - VerifyRegisterUser, + VerifyUserEmail, ) from schemas.token_info import TemporaryTokenInfo, TokenInfo from schemas.user import ( @@ -130,7 +130,7 @@ async def register_user( ttl=ttl_seconds, ) send_confirmation_code_request = SendConfirmationCodeRequest( - temporary_token=temporary_token, + token=temporary_token, ) await self.send_register_confirmation_code(send_confirmation_code_request) return TemporaryTokenInfo( @@ -160,7 +160,7 @@ async def send_register_confirmation_code( self, send_confirmation_code_request: SendConfirmationCodeRequest, ) -> None: - token = send_confirmation_code_request.temporary_token + token = send_confirmation_code_request.token payload = decode_jwt( token=token, secret_key=settings.confirmation_code_jwt.secret_key, @@ -179,9 +179,20 @@ async def send_register_confirmation_code( async def verify_register_user( self, - verify_register_user_data: VerifyRegisterUser, + verify_register_user_data: VerifyUserEmail, ) -> TokenInfo: - token = verify_register_user_data.temporary_registration_token + token = verify_register_user_data.token + confirmation_code = verify_register_user_data.confirmation_code + payload = decode_jwt( + token, + secret_key=settings.confirmation_code_jwt.secret_key, + algorithm=settings.confirmation_code_jwt.algorithm, + ) + email = payload["email"] + await self.verify_confirmation_code( + email, + confirmation_code, + ) user_data_create_json = await self.redis_service.get(key=f"{token}") user_data_create = UserCreate.model_validate_json(user_data_create_json) user = await self.create_user(user_data_create) @@ -202,13 +213,61 @@ async def authenticate_user(self, login_data: UserLogin) -> TemporaryTokenInfo: raise InvalidPasswordError user = UserResponse.model_validate(user) - temporary_token = create_two_factor_verification_temporary_token(user) - token_data = TemporaryTokenInfo( - temporary_token=temporary_token, + token = create_two_factor_verification_temporary_token(user) + send_confirmation_code_request = SendConfirmationCodeRequest( + token=token, + ) + await self.send_authenticate_confirmation_code(send_confirmation_code_request) + return TemporaryTokenInfo( + temporary_token=token, token_type=BEARER_TOKEN_TYPE, ) - return token_data + async def send_authenticate_confirmation_code( + self, + send_confirmation_code_request: SendConfirmationCodeRequest, + ) -> None: + token = send_confirmation_code_request.token + payload = decode_jwt( + token=token, + secret_key=settings.confirmation_code_jwt.secret_key, + algorithm=settings.confirmation_code_jwt.algorithm, + ) + email = payload["email"] + confirmation_code = await self.create_confirmation_code(email) + app.send_task( + name=TaskType.send_confirmation_email_code.value, + args=[ + email, + confirmation_code, + ], + queue=Queue.notification.value, + ) + + async def verify_authenticate_user( + self, + verify_authenticate_user_data: VerifyUserEmail, + ) -> TokenInfo: + token = verify_authenticate_user_data.token + confirmation_code = verify_authenticate_user_data.confirmation_code + payload = decode_jwt( + token, + secret_key=settings.confirmation_code_jwt.secret_key, + algorithm=settings.confirmation_code_jwt.algorithm, + ) + email = payload["email"] + await self.verify_confirmation_code( + email, + confirmation_code, + ) + user = await self.get_user_by_email(email) + access_token = create_access_token(user) + refresh_token = create_refresh_token(user) + return TokenInfo( + access_token=access_token, + refresh_token=refresh_token, + token_type=BEARER_TOKEN_TYPE, + ) async def get_confirmation_code(self, email: EmailStr) -> str: confirmation_code = await self.redis_service.get(f"auth:email:{email}") From 13cf2304f94d1f61338a094764533e046a33306c Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 21 Jun 2026 12:57:45 +0300 Subject: [PATCH 22/47] Use temporary jwt tokens to recover account. --- app/api/api_v1/auth.py | 66 +++++++++++++++++++---------- app/core/config.py | 1 + app/core/constants.py | 1 + app/core/security/jwt_utils.py | 22 ++++++++++ app/schemas/auth.py | 10 ++++- app/services/user.py | 77 +++++++++++++++++++++++++++++++--- 6 files changed, 148 insertions(+), 29 deletions(-) diff --git a/app/api/api_v1/auth.py b/app/api/api_v1/auth.py index 430ea7c..0f3b1f2 100644 --- a/app/api/api_v1/auth.py +++ b/app/api/api_v1/auth.py @@ -9,6 +9,7 @@ ) from dependencies.annotations.services import UserServiceDep from schemas.auth import ( + RecoverAccountRequest, ResetPasswordRequest, SendConfirmationCodeRequest, VerifyUserEmail, @@ -36,7 +37,10 @@ async def register_user( return await user_service.register_user(registration_user_data) -@router.post("/register/resend-confirmation-code") +@router.post( + "/register/resend-confirmation-code", + status_code=status.HTTP_200_OK, +) async def resend_register_confirmation_code( send_confirmation_code_request: SendConfirmationCodeRequest, user_service: UserServiceDep, @@ -47,9 +51,9 @@ async def resend_register_confirmation_code( @router.post( "/register/verify", response_model=TokenInfo, - status_code=status.HTTP_200_OK, + status_code=status.HTTP_201_CREATED, ) -async def register_user( +async def verify_register_user( verify_register_user_data: VerifyUserEmail, user_service: UserServiceDep, ) -> TokenInfo: @@ -68,7 +72,10 @@ async def login_user( return await user_service.authenticate_user(login_data) -@router.post("/login/resend-confirmation-code") +@router.post( + "/login/resend-confirmation-code", + status_code=status.HTTP_200_OK, +) async def resend_authenticate_confirmation_code( send_confirmation_code_request: SendConfirmationCodeRequest, user_service: UserServiceDep, @@ -78,7 +85,11 @@ async def resend_authenticate_confirmation_code( ) -@router.post("/login/verify") +@router.post( + "/login/verify", + status_code=status.HTTP_200_OK, + response_model=TokenInfo, +) async def verify_authenticate_user( verify_authenticate_user_data: VerifyUserEmail, user_service: UserServiceDep, @@ -86,31 +97,42 @@ async def verify_authenticate_user( return await user_service.verify_authenticate_user(verify_authenticate_user_data) -@router.post("/reset-password") -async def reset_password( - reset_password_data: ResetPasswordRequest, +@router.post( + "/recover-account", + response_model=TemporaryTokenInfo, + status_code=status.HTTP_200_OK, +) +async def recover_account( + recover_account_data: RecoverAccountRequest, user_service: UserServiceDep, -) -> None: - await user_service.reset_password(reset_password_data) +) -> TemporaryTokenInfo: + return await user_service.recover_account(recover_account_data) -@router.post( - "/send-confirmation-code", -) -async def send_confirmation_code( - temporary_token_data: TemporaryTokenInfo, +@router.post("/recover-account/resend-confirmation-code") +async def resend_recover_account_confirmation_code( + send_confirmation_code_request: SendConfirmationCodeRequest, user_service: UserServiceDep, ) -> None: - await user_service.send_confirmation_code(temporary_token_data) + return await user_service.send_recover_account_confirmation_code( + send_confirmation_code_request, + ) -@router.post("/confirm-email") -async def confirm_email( - temporary_token_data: TemporaryTokenInfo, - confirmation_code: str, +@router.post("/recover-account/verify") +async def verify_recover_account( + verify_recover_account_data: VerifyUserEmail, user_service: UserServiceDep, -) -> TokenInfo: - return await user_service.confirm_email(temporary_token_data, confirmation_code) +) -> TemporaryTokenInfo: + return await user_service.verify_recover_account(verify_recover_account_data) + + +@router.post("/reset-password") +async def reset_password( + reset_password_data: ResetPasswordRequest, + user_service: UserServiceDep, +) -> None: + await user_service.reset_password(reset_password_data) @router.post( diff --git a/app/core/config.py b/app/core/config.py index 7ece423..dbe0752 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -63,6 +63,7 @@ class ConfirmationCodeJWTConfig(BaseModel): algorithm: str = "HS256" temporary_token_registration_expire_minutes: int = 15 temporary_token_two_factor_expire_minutes: int = 15 + temporary_token_recover_account_expire_minutes: int = 15 temporary_token_reset_password_expire_minutes: int = 15 diff --git a/app/core/constants.py b/app/core/constants.py index bdbde00..7b79d08 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -64,6 +64,7 @@ class MethodType(StrEnum): REFRESH_TOKEN_TYPE = "refresh" REGISTRATION_TEMPORARY_TOKEN_TYPE = "registration_temporary_token" TWO_FACTOR_VERIFICATION_TEMPORARY_TOKEN_TYPE = "two_factor_verification_temporary_token" +RECOVER_ACCOUNT_TEMPORARY_TOKEN_TYPE = "recover_account_temporary_token" RESET_PASSWORD_TEMPORARY_TOKEN_TYPE = "reset_password_temporary_token" BEARER_TOKEN_TYPE: str = "Bearer" diff --git a/app/core/security/jwt_utils.py b/app/core/security/jwt_utils.py index d9c2396..308c737 100644 --- a/app/core/security/jwt_utils.py +++ b/app/core/security/jwt_utils.py @@ -2,10 +2,12 @@ from typing import Any import jwt +from pydantic import EmailStr from core.config import settings from core.constants import ( ACCESS_TOKEN_TYPE, + RECOVER_ACCOUNT_TEMPORARY_TOKEN_TYPE, REFRESH_TOKEN_TYPE, REGISTRATION_TEMPORARY_TOKEN_TYPE, RESET_PASSWORD_TEMPORARY_TOKEN_TYPE, @@ -94,6 +96,13 @@ def create_user_payload_for_reset_password_temporary_token( return payload +def create_user_payload_for_recover_account_temporary_token( + email: EmailStr, +) -> dict[str, str]: + payload = {"email": email} + return payload + + def create_access_token(user: UserResponse) -> str: payload = create_user_payload_for_access_token(user) payload.update( @@ -142,6 +151,19 @@ def create_two_factor_verification_temporary_token(user: UserResponse) -> str: ) +def create_recover_account_temporary_token(email: EmailStr) -> str: + payload = create_user_payload_for_recover_account_temporary_token(email) + payload.update( + {TOKEN_TYPE: RECOVER_ACCOUNT_TEMPORARY_TOKEN_TYPE}, + ) + return encode_jwt( + payload=payload, + secret_key=settings.confirmation_code_jwt.secret_key, + algorithm=settings.confirmation_code_jwt.algorithm, + expires_minutes=settings.confirmation_code_jwt.temporary_token_recover_account_expire_minutes, + ) + + def create_reset_password_temporary_token(user: UserResponse) -> str: payload = create_user_payload_for_reset_password_temporary_token(user) payload.update( diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 4244378..583e637 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -45,6 +45,14 @@ class ResetPasswordRequest(BaseModel): Модель для смены пароля. """ - email: EmailStr + reset_password_token: str password: PasswordConstraint password_confirmation: PasswordConstraint + + +class RecoverAccountRequest(BaseModel): + """ + Модель для восстановления доступа к аккаунту. + """ + + email: EmailStr diff --git a/app/services/user.py b/app/services/user.py index 14ba959..ab4786f 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -26,14 +26,17 @@ from core.redis import RedisService from core.security.jwt_utils import ( create_access_token, + create_recover_account_temporary_token, create_refresh_token, create_registration_temporary_token, + create_reset_password_temporary_token, create_two_factor_verification_temporary_token, decode_jwt, ) from core.security.password_utils import hash_password, verify_password from repositories import UserRepository from schemas.auth import ( + RecoverAccountRequest, ResetPasswordRequest, SendConfirmationCodeRequest, UserLogin, @@ -298,16 +301,78 @@ async def create_confirmation_code(self, email: EmailStr) -> str: ) return confirmation_code - async def reset_password(self, reset_password_data: ResetPasswordRequest) -> None: - await self.verify_confirmation_code( - reset_password_data.email, - reset_password_data.confirmation_code, + async def recover_account( + self, + recover_account_data: RecoverAccountRequest, + ) -> TemporaryTokenInfo: + email = recover_account_data.email + token = create_recover_account_temporary_token(email) + send_confirmation_code_request = SendConfirmationCodeRequest( + token=token, + ) + await self.send_recover_account_confirmation_code( + send_confirmation_code_request, + ) + return TemporaryTokenInfo( + temporary_token=token, + token_type=BEARER_TOKEN_TYPE, + ) + + async def send_recover_account_confirmation_code( + self, + send_confirmation_code_request: SendConfirmationCodeRequest, + ) -> None: + token = send_confirmation_code_request.token + payload = decode_jwt( + token, + secret_key=settings.confirmation_code_jwt.secret_key, + algorithm=settings.confirmation_code_jwt.algorithm, + ) + email = payload["email"] + confirmation_code = await self.create_confirmation_code(email) + app.send_task( + name=TaskType.send_confirmation_email_code.value, + args=[ + email, + confirmation_code, + ], + queue=Queue.notification.value, + ) + + async def verify_recover_account( + self, + verify_recover_account_data: VerifyUserEmail, + ) -> TemporaryTokenInfo: + token = verify_recover_account_data.token + confirmation_code = verify_recover_account_data.confirmation_code + payload = decode_jwt( + token, + secret_key=settings.confirmation_code_jwt.secret_key, + algorithm=settings.confirmation_code_jwt.algorithm, + ) + email = payload["email"] + await self.verify_confirmation_code(email, confirmation_code) + user = await self.get_user_by_email(email) + reset_password_token = create_reset_password_temporary_token(user) + return TemporaryTokenInfo( + temporary_token=reset_password_token, + token_type=BEARER_TOKEN_TYPE, ) - if reset_password_data.password != reset_password_data.password_confirmation: + async def reset_password(self, reset_password_data: ResetPasswordRequest) -> None: + token = reset_password_data.reset_password_token + password = reset_password_data.password + password_confirmation = reset_password_data.password_confirmation + if password != password_confirmation: raise InvalidPasswordError - user = await self.get_user_by_email(reset_password_data.email) + payload = decode_jwt( + token, + secret_key=settings.confirmation_code_jwt.secret_key, + algorithm=settings.confirmation_code_jwt.algorithm, + ) + email = payload["email"] + user = await self.get_user_by_email(email) user_partial_update_data = UserPartialUpdate( password=reset_password_data.password, ) From 0ef3950f3167607cf44bfdb9d8d4a0b57cbe5bdb Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 21 Jun 2026 16:45:22 +0300 Subject: [PATCH 23/47] Split auth views to different files: registration_views,login_views, recover_views, refresh_token_views. --- app/api/api_v1/__init__.py | 3 +- app/api/api_v1/auth.py | 148 ------------------ app/api/api_v1/auth/__init__.py | 17 ++ app/api/api_v1/auth/login_views.py | 49 ++++++ app/api/api_v1/auth/recover_views.py | 54 +++++++ app/api/api_v1/auth/refresh_token_views.py | 27 ++++ app/api/api_v1/auth/registration_views.py | 47 ++++++ app/core/config.py | 10 +- app/core/constants.py | 14 +- app/core/security/jwt_utils.py | 73 ++++----- app/core/security/validators.py | 4 +- app/schemas/token_info.py | 4 +- app/services/user.py | 42 ++--- tests/test_core/test_security/conftest.py | 4 +- .../test_security/test_validators.py | 6 +- 15 files changed, 276 insertions(+), 226 deletions(-) delete mode 100644 app/api/api_v1/auth.py create mode 100644 app/api/api_v1/auth/__init__.py create mode 100644 app/api/api_v1/auth/login_views.py create mode 100644 app/api/api_v1/auth/recover_views.py create mode 100644 app/api/api_v1/auth/refresh_token_views.py create mode 100644 app/api/api_v1/auth/registration_views.py diff --git a/app/api/api_v1/__init__.py b/app/api/api_v1/__init__.py index 49c65f4..5d1db39 100644 --- a/app/api/api_v1/__init__.py +++ b/app/api/api_v1/__init__.py @@ -1,7 +1,8 @@ __all__ = ("router",) from fastapi import APIRouter -from .auth import router as auth_router +from api.api_v1.auth import router as auth_router + from .favorite_movies import router as favorite_movies_router from .genres import router as genres_router from .media import router as media_router diff --git a/app/api/api_v1/auth.py b/app/api/api_v1/auth.py deleted file mode 100644 index 0f3b1f2..0000000 --- a/app/api/api_v1/auth.py +++ /dev/null @@ -1,148 +0,0 @@ -from fastapi import ( - APIRouter, - status, -) - -from dependencies.annotations.security import ( - AuthUserByRefreshTokenDep, - GetLoginDataDep, -) -from dependencies.annotations.services import UserServiceDep -from schemas.auth import ( - RecoverAccountRequest, - ResetPasswordRequest, - SendConfirmationCodeRequest, - VerifyUserEmail, -) -from schemas.token_info import TemporaryTokenInfo, TokenInfo -from schemas.user import ( - UserRegistration, -) - -router = APIRouter( - tags=["Auth"], - prefix="/auth", -) - - -@router.post( - "/register", - response_model=TemporaryTokenInfo, - status_code=status.HTTP_200_OK, -) -async def register_user( - registration_user_data: UserRegistration, - user_service: UserServiceDep, -) -> TemporaryTokenInfo: - return await user_service.register_user(registration_user_data) - - -@router.post( - "/register/resend-confirmation-code", - status_code=status.HTTP_200_OK, -) -async def resend_register_confirmation_code( - send_confirmation_code_request: SendConfirmationCodeRequest, - user_service: UserServiceDep, -) -> None: - await user_service.send_register_confirmation_code(send_confirmation_code_request) - - -@router.post( - "/register/verify", - response_model=TokenInfo, - status_code=status.HTTP_201_CREATED, -) -async def verify_register_user( - verify_register_user_data: VerifyUserEmail, - user_service: UserServiceDep, -) -> TokenInfo: - return await user_service.verify_register_user(verify_register_user_data) - - -@router.post( - "/login", - response_model=TemporaryTokenInfo, - status_code=status.HTTP_200_OK, -) -async def login_user( - login_data: GetLoginDataDep, - user_service: UserServiceDep, -) -> TemporaryTokenInfo: - return await user_service.authenticate_user(login_data) - - -@router.post( - "/login/resend-confirmation-code", - status_code=status.HTTP_200_OK, -) -async def resend_authenticate_confirmation_code( - send_confirmation_code_request: SendConfirmationCodeRequest, - user_service: UserServiceDep, -) -> None: - await user_service.send_authenticate_confirmation_code( - send_confirmation_code_request, - ) - - -@router.post( - "/login/verify", - status_code=status.HTTP_200_OK, - response_model=TokenInfo, -) -async def verify_authenticate_user( - verify_authenticate_user_data: VerifyUserEmail, - user_service: UserServiceDep, -) -> TokenInfo: - return await user_service.verify_authenticate_user(verify_authenticate_user_data) - - -@router.post( - "/recover-account", - response_model=TemporaryTokenInfo, - status_code=status.HTTP_200_OK, -) -async def recover_account( - recover_account_data: RecoverAccountRequest, - user_service: UserServiceDep, -) -> TemporaryTokenInfo: - return await user_service.recover_account(recover_account_data) - - -@router.post("/recover-account/resend-confirmation-code") -async def resend_recover_account_confirmation_code( - send_confirmation_code_request: SendConfirmationCodeRequest, - user_service: UserServiceDep, -) -> None: - return await user_service.send_recover_account_confirmation_code( - send_confirmation_code_request, - ) - - -@router.post("/recover-account/verify") -async def verify_recover_account( - verify_recover_account_data: VerifyUserEmail, - user_service: UserServiceDep, -) -> TemporaryTokenInfo: - return await user_service.verify_recover_account(verify_recover_account_data) - - -@router.post("/reset-password") -async def reset_password( - reset_password_data: ResetPasswordRequest, - user_service: UserServiceDep, -) -> None: - await user_service.reset_password(reset_password_data) - - -@router.post( - "/refresh", - response_model=TokenInfo, - response_model_exclude_unset=True, - status_code=status.HTTP_200_OK, -) -async def refresh_access_token( - user_id: AuthUserByRefreshTokenDep, - user_service: UserServiceDep, -) -> TokenInfo: - return await user_service.refresh_access_token(user_id) diff --git a/app/api/api_v1/auth/__init__.py b/app/api/api_v1/auth/__init__.py new file mode 100644 index 0000000..b8de033 --- /dev/null +++ b/app/api/api_v1/auth/__init__.py @@ -0,0 +1,17 @@ +__all__ = ("router",) + +from fastapi import APIRouter + +from .login_views import router as login_router +from .recover_views import router as recover_account_router +from .refresh_token_views import router as refresh_token_router +from .registration_views import router as registration_router + +router = APIRouter( + prefix="/auth", +) + +router.include_router(registration_router) +router.include_router(login_router) +router.include_router(recover_account_router) +router.include_router(refresh_token_router) diff --git a/app/api/api_v1/auth/login_views.py b/app/api/api_v1/auth/login_views.py new file mode 100644 index 0000000..380eacb --- /dev/null +++ b/app/api/api_v1/auth/login_views.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter +from starlette import status + +from dependencies.annotations.security import GetLoginDataDep +from dependencies.annotations.services import UserServiceDep +from schemas.auth import SendConfirmationCodeRequest, VerifyUserEmail +from schemas.token_info import TemporaryTokenInfo, TokenInfo + +router = APIRouter( + prefix="/login", + tags=["Login"], +) + + +@router.post( + "/", + response_model=TemporaryTokenInfo, + status_code=status.HTTP_200_OK, +) +async def login_user( + login_data: GetLoginDataDep, + user_service: UserServiceDep, +) -> TemporaryTokenInfo: + return await user_service.authenticate_user(login_data) + + +@router.post( + "/resend-confirmation-code", + status_code=status.HTTP_200_OK, +) +async def resend_authenticate_confirmation_code( + send_confirmation_code_request: SendConfirmationCodeRequest, + user_service: UserServiceDep, +) -> None: + await user_service.send_authenticate_confirmation_code( + send_confirmation_code_request, + ) + + +@router.post( + "/verify", + status_code=status.HTTP_200_OK, + response_model=TokenInfo, +) +async def verify_authenticate_user( + verify_authenticate_user_data: VerifyUserEmail, + user_service: UserServiceDep, +) -> TokenInfo: + return await user_service.verify_authenticate_user(verify_authenticate_user_data) diff --git a/app/api/api_v1/auth/recover_views.py b/app/api/api_v1/auth/recover_views.py new file mode 100644 index 0000000..441e77f --- /dev/null +++ b/app/api/api_v1/auth/recover_views.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter +from starlette import status + +from dependencies.annotations.services import UserServiceDep +from schemas.auth import ( + RecoverAccountRequest, + ResetPasswordRequest, + SendConfirmationCodeRequest, + VerifyUserEmail, +) +from schemas.token_info import TemporaryTokenInfo + +router = APIRouter( + prefix="/recover", + tags=["Recover"], +) + + +@router.post( + "/", + response_model=TemporaryTokenInfo, + status_code=status.HTTP_200_OK, +) +async def recover_account( + recover_account_data: RecoverAccountRequest, + user_service: UserServiceDep, +) -> TemporaryTokenInfo: + return await user_service.recover_account(recover_account_data) + + +@router.post("/resend-confirmation-code") +async def resend_recover_account_confirmation_code( + send_confirmation_code_request: SendConfirmationCodeRequest, + user_service: UserServiceDep, +) -> None: + return await user_service.send_recover_account_confirmation_code( + send_confirmation_code_request, + ) + + +@router.post("/verify") +async def verify_recover_account( + verify_recover_account_data: VerifyUserEmail, + user_service: UserServiceDep, +) -> TemporaryTokenInfo: + return await user_service.verify_recover_account(verify_recover_account_data) + + +@router.post("/reset-password") +async def reset_password( + reset_password_data: ResetPasswordRequest, + user_service: UserServiceDep, +) -> None: + await user_service.reset_password(reset_password_data) diff --git a/app/api/api_v1/auth/refresh_token_views.py b/app/api/api_v1/auth/refresh_token_views.py new file mode 100644 index 0000000..23a6b0c --- /dev/null +++ b/app/api/api_v1/auth/refresh_token_views.py @@ -0,0 +1,27 @@ +from fastapi import ( + APIRouter, + status, +) + +from dependencies.annotations.security import ( + AuthUserByRefreshTokenDep, +) +from dependencies.annotations.services import UserServiceDep +from schemas.token_info import TokenInfo + +router = APIRouter( + tags=["Refresh Access Token"], +) + + +@router.post( + "/refresh", + response_model=TokenInfo, + response_model_exclude_unset=True, + status_code=status.HTTP_200_OK, +) +async def refresh_access_token( + user_id: AuthUserByRefreshTokenDep, + user_service: UserServiceDep, +) -> TokenInfo: + return await user_service.refresh_access_token(user_id) diff --git a/app/api/api_v1/auth/registration_views.py b/app/api/api_v1/auth/registration_views.py new file mode 100644 index 0000000..d081482 --- /dev/null +++ b/app/api/api_v1/auth/registration_views.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter +from starlette import status + +from dependencies.annotations.services import UserServiceDep +from schemas.auth import SendConfirmationCodeRequest, VerifyUserEmail +from schemas.token_info import TemporaryTokenInfo, TokenInfo +from schemas.user import UserRegistration + +router = APIRouter( + prefix="/register", + tags=["Registration"], +) + + +@router.post( + "/", + response_model=TemporaryTokenInfo, + status_code=status.HTTP_200_OK, +) +async def register_user( + registration_user_data: UserRegistration, + user_service: UserServiceDep, +) -> TemporaryTokenInfo: + return await user_service.register_user(registration_user_data) + + +@router.post( + "/resend-confirmation-code", + status_code=status.HTTP_200_OK, +) +async def resend_register_confirmation_code( + send_confirmation_code_request: SendConfirmationCodeRequest, + user_service: UserServiceDep, +) -> None: + await user_service.send_register_confirmation_code(send_confirmation_code_request) + + +@router.post( + "/verify", + response_model=TokenInfo, + status_code=status.HTTP_201_CREATED, +) +async def verify_register_user( + verify_register_user_data: VerifyUserEmail, + user_service: UserServiceDep, +) -> TokenInfo: + return await user_service.verify_register_user(verify_register_user_data) diff --git a/app/core/config.py b/app/core/config.py index dbe0752..fe96856 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -61,10 +61,10 @@ class AuthJWTConfig(BaseModel): class ConfirmationCodeJWTConfig(BaseModel): secret_key: str = "secret_key" algorithm: str = "HS256" - temporary_token_registration_expire_minutes: int = 15 - temporary_token_two_factor_expire_minutes: int = 15 - temporary_token_recover_account_expire_minutes: int = 15 - temporary_token_reset_password_expire_minutes: int = 15 + registration_token_expire_minutes: int = 15 + two_factor_token_expire_minutes: int = 15 + recover_token_expire_minutes: int = 15 + reset_password_token_expire_minutes: int = 15 class MediaServiceConfig(BaseModel): @@ -94,7 +94,7 @@ class Settings(BaseSettings): confirmation_code_jwt: ConfirmationCodeJWTConfig = ConfirmationCodeJWTConfig() http_bearer: HTTPBearer = HTTPBearer() oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer( - "/api/v1/auth/confirm-email", + "/api/v1/auth/login/", ) media_service: MediaServiceConfig = MediaServiceConfig() notification_service: NotificationServiceConfig = NotificationServiceConfig() diff --git a/app/core/constants.py b/app/core/constants.py index 7b79d08..c253ab6 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -59,15 +59,17 @@ class MethodType(StrEnum): delete = "DELETE" -TOKEN_TYPE = "type" +TOKEN_TYPE_FIELD = "type" +EMAIL_FIELD = "email" + ACCESS_TOKEN_TYPE = "access" REFRESH_TOKEN_TYPE = "refresh" -REGISTRATION_TEMPORARY_TOKEN_TYPE = "registration_temporary_token" -TWO_FACTOR_VERIFICATION_TEMPORARY_TOKEN_TYPE = "two_factor_verification_temporary_token" -RECOVER_ACCOUNT_TEMPORARY_TOKEN_TYPE = "recover_account_temporary_token" -RESET_PASSWORD_TEMPORARY_TOKEN_TYPE = "reset_password_temporary_token" +REGISTRATION_TOKEN_TYPE = "registration" +TWO_FACTOR_TOKEN_TYPE = "two_factor" +RECOVER_TOKEN_TYPE = "recover" +RESET_PASSWORD_TOKEN_TYPE = "reset_password" -BEARER_TOKEN_TYPE: str = "Bearer" +BEARER_TOKEN_TYPE = "Bearer" BASE_ERROR = ( NotFoundError diff --git a/app/core/security/jwt_utils.py b/app/core/security/jwt_utils.py index 308c737..6f360e2 100644 --- a/app/core/security/jwt_utils.py +++ b/app/core/security/jwt_utils.py @@ -7,12 +7,13 @@ from core.config import settings from core.constants import ( ACCESS_TOKEN_TYPE, - RECOVER_ACCOUNT_TEMPORARY_TOKEN_TYPE, + EMAIL_FIELD, + RECOVER_TOKEN_TYPE, REFRESH_TOKEN_TYPE, - REGISTRATION_TEMPORARY_TOKEN_TYPE, - RESET_PASSWORD_TEMPORARY_TOKEN_TYPE, - TOKEN_TYPE, - TWO_FACTOR_VERIFICATION_TEMPORARY_TOKEN_TYPE, + REGISTRATION_TOKEN_TYPE, + RESET_PASSWORD_TOKEN_TYPE, + TOKEN_TYPE_FIELD, + TWO_FACTOR_TOKEN_TYPE, ) from schemas.user import UserRegistration, UserResponse @@ -49,64 +50,64 @@ def decode_jwt( ) -def create_user_payload_for_access_token(user: UserResponse) -> dict[str, str]: +def create_access_token_payload(user: UserResponse) -> dict[str, str]: payload = { "sub": str(user.id), "login": user.login, - "email": user.email, + EMAIL_FIELD: user.email, } return payload -def create_user_payload_for_refresh_token(user: UserResponse) -> dict[str, str]: +def create_refresh_token_payload(user: UserResponse) -> dict[str, str]: payload = { "sub": str(user.id), } return payload -def create_user_payload_for_registration_temporary_token( +def create_registration_token_payload( user: UserRegistration, ) -> dict[str, str]: payload = { "sub": user.login, "login": user.login, - "email": user.email, + EMAIL_FIELD: user.email, } return payload -def create_user_payload_for_two_factor_verification_temporary_token( +def create_two_factor_token_payload( user: UserResponse, ) -> dict[str, str]: payload = { "sub": str(user.id), - "email": user.email, + EMAIL_FIELD: user.email, } return payload -def create_user_payload_for_reset_password_temporary_token( +def create_reset_password_token_payload( user: UserResponse, ) -> dict[str, str]: payload = { "sub": str(user.id), - "email": user.email, + EMAIL_FIELD: user.email, } return payload -def create_user_payload_for_recover_account_temporary_token( +def create_recover_token_payload( email: EmailStr, ) -> dict[str, str]: - payload = {"email": email} + payload = {EMAIL_FIELD: email} return payload def create_access_token(user: UserResponse) -> str: - payload = create_user_payload_for_access_token(user) + payload = create_access_token_payload(user) payload.update( - {TOKEN_TYPE: ACCESS_TOKEN_TYPE}, + {TOKEN_TYPE_FIELD: ACCESS_TOKEN_TYPE}, ) return encode_jwt( payload, @@ -115,9 +116,9 @@ def create_access_token(user: UserResponse) -> str: def create_refresh_token(user: UserResponse) -> str: - payload = create_user_payload_for_refresh_token(user) + payload = create_refresh_token_payload(user) payload.update( - {TOKEN_TYPE: REFRESH_TOKEN_TYPE}, + {TOKEN_TYPE_FIELD: REFRESH_TOKEN_TYPE}, ) return encode_jwt( payload, @@ -125,53 +126,53 @@ def create_refresh_token(user: UserResponse) -> str: ) -def create_registration_temporary_token(user: UserRegistration) -> str: - payload = create_user_payload_for_registration_temporary_token(user) +def create_registration_token(user: UserRegistration) -> str: + payload = create_registration_token_payload(user) payload.update( - {TOKEN_TYPE: REGISTRATION_TEMPORARY_TOKEN_TYPE}, + {TOKEN_TYPE_FIELD: REGISTRATION_TOKEN_TYPE}, ) return encode_jwt( payload=payload, secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, - expires_minutes=settings.confirmation_code_jwt.temporary_token_registration_expire_minutes, + expires_minutes=settings.confirmation_code_jwt.registration_token_expire_minutes, ) -def create_two_factor_verification_temporary_token(user: UserResponse) -> str: - payload = create_user_payload_for_two_factor_verification_temporary_token(user) +def create_two_factor_token(user: UserResponse) -> str: + payload = create_two_factor_token_payload(user) payload.update( - {TOKEN_TYPE: TWO_FACTOR_VERIFICATION_TEMPORARY_TOKEN_TYPE}, + {TOKEN_TYPE_FIELD: TWO_FACTOR_TOKEN_TYPE}, ) return encode_jwt( payload=payload, secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, - expires_minutes=settings.confirmation_code_jwt.temporary_token_two_factor_expire_minutes, + expires_minutes=settings.confirmation_code_jwt.two_factor_token_expire_minutes, ) -def create_recover_account_temporary_token(email: EmailStr) -> str: - payload = create_user_payload_for_recover_account_temporary_token(email) +def create_recover_token(email: EmailStr) -> str: + payload = create_recover_token_payload(email) payload.update( - {TOKEN_TYPE: RECOVER_ACCOUNT_TEMPORARY_TOKEN_TYPE}, + {TOKEN_TYPE_FIELD: RECOVER_TOKEN_TYPE}, ) return encode_jwt( payload=payload, secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, - expires_minutes=settings.confirmation_code_jwt.temporary_token_recover_account_expire_minutes, + expires_minutes=settings.confirmation_code_jwt.recover_token_expire_minutes, ) -def create_reset_password_temporary_token(user: UserResponse) -> str: - payload = create_user_payload_for_reset_password_temporary_token(user) +def create_reset_password_token(user: UserResponse) -> str: + payload = create_reset_password_token_payload(user) payload.update( - {TOKEN_TYPE: RESET_PASSWORD_TEMPORARY_TOKEN_TYPE}, + {TOKEN_TYPE_FIELD: RESET_PASSWORD_TOKEN_TYPE}, ) return encode_jwt( payload=payload, secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, - expires_minutes=settings.confirmation_code_jwt.temporary_token_reset_password_expire_minutes, + expires_minutes=settings.confirmation_code_jwt.reset_password_token_expire_minutes, ) diff --git a/app/core/security/validators.py b/app/core/security/validators.py index f3a0a1d..2ae57c6 100644 --- a/app/core/security/validators.py +++ b/app/core/security/validators.py @@ -1,11 +1,11 @@ -from core.constants import TOKEN_TYPE +from core.constants import TOKEN_TYPE_FIELD def validate_token_payload( payload: dict[str, str | int], target_token_type: str, ) -> None: - if payload[TOKEN_TYPE] != target_token_type: + if payload[TOKEN_TYPE_FIELD] != target_token_type: type_error_detail: str = "Invalid token type in payload" raise TypeError(type_error_detail) diff --git a/app/schemas/token_info.py b/app/schemas/token_info.py index aaf1c99..1d18890 100644 --- a/app/schemas/token_info.py +++ b/app/schemas/token_info.py @@ -16,9 +16,9 @@ class TokenInfo(BaseModel): class TemporaryTokenInfo(BaseModel): """ Модель для вывода информации о токенах, - предназначенных для временного доступа к + предназначенных для доступа к отправке кодов подтверждения на почту. """ - temporary_token: str + token: str token_type: str = BEARER_TOKEN_TYPE diff --git a/app/services/user.py b/app/services/user.py index ab4786f..7703c86 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -9,6 +9,7 @@ from core.config import settings from core.constants import ( BEARER_TOKEN_TYPE, + EMAIL_FIELD, UserRole, ) from core.exceptions.auth import InvalidPasswordError @@ -26,11 +27,11 @@ from core.redis import RedisService from core.security.jwt_utils import ( create_access_token, - create_recover_account_temporary_token, + create_recover_token, create_refresh_token, - create_registration_temporary_token, - create_reset_password_temporary_token, - create_two_factor_verification_temporary_token, + create_registration_token, + create_reset_password_token, + create_two_factor_token, decode_jwt, ) from core.security.password_utils import hash_password, verify_password @@ -122,10 +123,9 @@ async def register_user( create_user_data = self.convert_registration_to_create_schema( registration_user_data, ) - temporary_token = create_registration_temporary_token(registration_user_data) + temporary_token = create_registration_token(registration_user_data) ttl_seconds = ( - settings.confirmation_code_jwt.temporary_token_registration_expire_minutes - * 60 + settings.confirmation_code_jwt.registration_token_expire_minutes * 60 ) await self.redis_service.set( key=f"{temporary_token}", @@ -137,7 +137,7 @@ async def register_user( ) await self.send_register_confirmation_code(send_confirmation_code_request) return TemporaryTokenInfo( - temporary_token=temporary_token, + token=temporary_token, token_type=BEARER_TOKEN_TYPE, ) @@ -169,7 +169,7 @@ async def send_register_confirmation_code( secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, ) - email = payload["email"] + email = payload[EMAIL_FIELD] confirmation_code = await self.create_confirmation_code(email) app.send_task( name=TaskType.send_confirmation_email_code.value, @@ -191,7 +191,7 @@ async def verify_register_user( secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, ) - email = payload["email"] + email = payload[EMAIL_FIELD] await self.verify_confirmation_code( email, confirmation_code, @@ -216,13 +216,13 @@ async def authenticate_user(self, login_data: UserLogin) -> TemporaryTokenInfo: raise InvalidPasswordError user = UserResponse.model_validate(user) - token = create_two_factor_verification_temporary_token(user) + token = create_two_factor_token(user) send_confirmation_code_request = SendConfirmationCodeRequest( token=token, ) await self.send_authenticate_confirmation_code(send_confirmation_code_request) return TemporaryTokenInfo( - temporary_token=token, + token=token, token_type=BEARER_TOKEN_TYPE, ) @@ -236,7 +236,7 @@ async def send_authenticate_confirmation_code( secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, ) - email = payload["email"] + email = payload[EMAIL_FIELD] confirmation_code = await self.create_confirmation_code(email) app.send_task( name=TaskType.send_confirmation_email_code.value, @@ -258,7 +258,7 @@ async def verify_authenticate_user( secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, ) - email = payload["email"] + email = payload[EMAIL_FIELD] await self.verify_confirmation_code( email, confirmation_code, @@ -306,7 +306,7 @@ async def recover_account( recover_account_data: RecoverAccountRequest, ) -> TemporaryTokenInfo: email = recover_account_data.email - token = create_recover_account_temporary_token(email) + token = create_recover_token(email) send_confirmation_code_request = SendConfirmationCodeRequest( token=token, ) @@ -314,7 +314,7 @@ async def recover_account( send_confirmation_code_request, ) return TemporaryTokenInfo( - temporary_token=token, + token=token, token_type=BEARER_TOKEN_TYPE, ) @@ -328,7 +328,7 @@ async def send_recover_account_confirmation_code( secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, ) - email = payload["email"] + email = payload[EMAIL_FIELD] confirmation_code = await self.create_confirmation_code(email) app.send_task( name=TaskType.send_confirmation_email_code.value, @@ -350,12 +350,12 @@ async def verify_recover_account( secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, ) - email = payload["email"] + email = payload[EMAIL_FIELD] await self.verify_confirmation_code(email, confirmation_code) user = await self.get_user_by_email(email) - reset_password_token = create_reset_password_temporary_token(user) + reset_password_token = create_reset_password_token(user) return TemporaryTokenInfo( - temporary_token=reset_password_token, + token=reset_password_token, token_type=BEARER_TOKEN_TYPE, ) @@ -371,7 +371,7 @@ async def reset_password(self, reset_password_data: ResetPasswordRequest) -> Non secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, ) - email = payload["email"] + email = payload[EMAIL_FIELD] user = await self.get_user_by_email(email) user_partial_update_data = UserPartialUpdate( password=reset_password_data.password, diff --git a/tests/test_core/test_security/conftest.py b/tests/test_core/test_security/conftest.py index 7a8a2e7..b1e36e1 100644 --- a/tests/test_core/test_security/conftest.py +++ b/tests/test_core/test_security/conftest.py @@ -1,6 +1,6 @@ import pytest -from core.constants import TOKEN_TYPE +from core.constants import TOKEN_TYPE_FIELD from tests.utils.data_generators.base import generate_string @@ -14,7 +14,7 @@ def payload() -> dict[str, str | int]: "sub": sub, "login": login, "email": email, - TOKEN_TYPE: token_type, + TOKEN_TYPE_FIELD: token_type, } return data diff --git a/tests/test_core/test_security/test_validators.py b/tests/test_core/test_security/test_validators.py index bab9b64..1b8c817 100644 --- a/tests/test_core/test_security/test_validators.py +++ b/tests/test_core/test_security/test_validators.py @@ -1,6 +1,6 @@ import pytest -from core.constants import TOKEN_TYPE +from core.constants import TOKEN_TYPE_FIELD from core.security.validators import validate_token_payload from tests.utils.data_generators.base import generate_string @@ -8,7 +8,7 @@ def test_validate_token_payload_no_sub(payload: dict[str, str | int]) -> None: payload.pop("sub") with pytest.raises(KeyError): - validate_token_payload(payload, target_token_type=payload[TOKEN_TYPE]) + validate_token_payload(payload, target_token_type=payload[TOKEN_TYPE_FIELD]) def test_validate_token_payload_invalid_data_token_type( @@ -17,5 +17,5 @@ def test_validate_token_payload_invalid_data_token_type( with pytest.raises(TypeError): validate_token_payload( payload, - target_token_type=payload[TOKEN_TYPE] + generate_string(), + target_token_type=payload[TOKEN_TYPE_FIELD] + generate_string(), ) From a17044ee91f7e32b5fc1356ee633a5d8b8ad80bb Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 21 Jun 2026 17:41:36 +0300 Subject: [PATCH 24/47] Add frontend to registration. --- frontend/app/api/api_v1/auth.js | 132 +++++++++++++++++++++----- frontend/app/data/state.js | 1 + frontend/app/services/methods/auth.js | 131 +++++++++++++------------ 3 files changed, 179 insertions(+), 85 deletions(-) diff --git a/frontend/app/api/api_v1/auth.js b/frontend/app/api/api_v1/auth.js index 730b895..95677e4 100644 --- a/frontend/app/api/api_v1/auth.js +++ b/frontend/app/api/api_v1/auth.js @@ -3,14 +3,11 @@ var parseResponseJson = window.ApiClient.parseResponseJson; var readErrorMessage = window.ApiClient.readErrorMessage; - // ОТПРАВКА КОДА (универсальный метод с message_type) - function sendConfirmationCode(email, messageType) { - var payload = { - email: email, - message_type: messageType, // "verify_email" | "two_factor_auth" | "reset_password" - }; + // ============ РЕГИСТРАЦИЯ (НОВАЯ ЛОГИКА) ============ - return fetch(apiUrl("/api/v1/auth/send-confirmation-code"), { + // ШАГ 1: Регистрация (получаем временный токен) + function registerUser(payload) { + return fetch(apiUrl("/api/v1/auth/register/"), { method: "POST", headers: { "Content-Type": "application/json", @@ -22,20 +19,23 @@ if (!res.ok) { throw new Error(readErrorMessage(data)); } - return data; + return data; // { token: "...", token_type: "bearer" } }); }); } - // ПОДТВЕРЖДЕНИЕ 2FA КОДА - function confirmEmail(payload) { - return fetch(apiUrl("/api/v1/auth/confirm-email"), { + // ШАГ 2: Подтверждение кода регистрации + function verifyRegistration(token, confirmationCode) { + return fetch(apiUrl("/api/v1/auth/register/verify"), { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, - body: JSON.stringify(payload), + body: JSON.stringify({ + token: token, + confirmation_code: confirmationCode, + }), }).then(function (res) { return parseResponseJson(res).then(function (data) { if (!res.ok) { @@ -46,15 +46,17 @@ }); } - // РЕГИСТРАЦИЯ С КОДОМ - function registerUserWithCode(payload) { - return fetch(apiUrl("/api/v1/auth/register"), { + // ПОВТОРНАЯ ОТПРАВКА КОДА РЕГИСТРАЦИИ + function resendRegistrationCode(token) { + return fetch(apiUrl("/api/v1/auth/register/resend-confirmation-code"), { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, - body: JSON.stringify(payload), + body: JSON.stringify({ + token: token, + }), }).then(function (res) { return parseResponseJson(res).then(function (data) { if (!res.ok) { @@ -65,11 +67,7 @@ }); } - // СТАРАЯ РЕГИСТРАЦИЯ (для совместимости) - function registerUser(payload) { - console.warn("registerUser is deprecated, use registerUserWithCode"); - return registerUserWithCode(payload); - } + // ============ 2FA (двухфакторная аутентификация) ============ // ЛОГИН (возвращает email) function loginUser(username, password) { @@ -94,7 +92,76 @@ }); } - // СБРОС ПАРОЛЯ + // ПОДТВЕРЖДЕНИЕ 2FA КОДА + function confirmEmail(payload) { + return fetch(apiUrl("/api/v1/auth/confirm-email"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; // { access_token, refresh_token, token_type } + }); + }); + } + + // ПОВТОРНАЯ ОТПРАВКА 2FA КОДА + function sendConfirmationCode(email, messageType) { + var payload = { + email: email, + message_type: messageType, // "two_factor_auth" + }; + + return fetch(apiUrl("/api/v1/auth/send-confirmation-code"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; + }); + }); + } + + // ============ ВОССТАНОВЛЕНИЕ ПАРОЛЯ ============ + + // ОТПРАВКА КОДА ДЛЯ ВОССТАНОВЛЕНИЯ + function sendResetCode(email) { + var payload = { + email: email, + message_type: "reset_password", + }; + + return fetch(apiUrl("/api/v1/auth/send-confirmation-code"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; + }); + }); + } + + // СБРОС ПАРОЛЯ (с новым паролем) function resetPassword(payload) { return fetch(apiUrl("/api/v1/auth/reset-password"), { method: "POST", @@ -113,17 +180,30 @@ }); } + // ============ ОБЩИЕ МЕТОДЫ ============ + function logout() { window.TokenStore.clearTokens(); } + // ============ ЭКСПОРТ ============ + window.ApiAuth = { - registerUser: registerUser, // для обратной совместимости - registerUserWithCode: registerUserWithCode, - sendConfirmationCode: sendConfirmationCode, // универсальный метод - confirmEmail: confirmEmail, + // Регистрация + registerUser: registerUser, + verifyRegistration: verifyRegistration, + resendRegistrationCode: resendRegistrationCode, + + // 2FA loginUser: loginUser, + confirmEmail: confirmEmail, + sendConfirmationCode: sendConfirmationCode, // для повторной отправки 2FA + + // Восстановление + sendResetCode: sendResetCode, resetPassword: resetPassword, + + // Общее logout: logout, }; })(); \ No newline at end of file diff --git a/frontend/app/data/state.js b/frontend/app/data/state.js index f55962d..0d525b8 100644 --- a/frontend/app/data/state.js +++ b/frontend/app/data/state.js @@ -155,6 +155,7 @@ // Для двухэтапной регистрации registerStep: 'form', // 'form' | 'verify' + registrationToken: '', registrationData: { surname: '', name: '', diff --git a/frontend/app/services/methods/auth.js b/frontend/app/services/methods/auth.js index 29a3a08..2e33d95 100644 --- a/frontend/app/services/methods/auth.js +++ b/frontend/app/services/methods/auth.js @@ -97,14 +97,42 @@ }, // ==================== РЕГИСТРАЦИЯ ==================== - // ОТПРАВКА КОДА НА ПОЧТУ (регистрация) - onSendCode: function () { + // ОБНОВЛЕННАЯ РЕГИСТРАЦИЯ (ШАГ 1) + onRegister: function () { var self = this; this.error = ""; this.success = ""; - this.loading = true; - this.registrationData = { + // Валидация + if (this.registerForm.password.length < 8) { + this.error = "Пароль должен быть минимум 8 символов"; + return; + } + + var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(this.registerForm.email.trim())) { + this.error = "Введите корректный email"; + return; + } + + if (this.registerForm.login.trim().length < 3) { + this.error = "Логин должен быть минимум 3 символа"; + return; + } + + if (this.registerForm.surname.trim().length < 2) { + this.error = "Фамилия должна быть минимум 2 символа"; + return; + } + + if (this.registerForm.name.trim().length < 2) { + this.error = "Имя должно быть минимум 2 символа"; + return; + } + + // Отправляем запрос на регистрацию + this.loading = true; + var payload = { surname: this.registerForm.surname.trim(), name: this.registerForm.name.trim(), login: this.registerForm.login.trim(), @@ -112,57 +140,69 @@ password: this.registerForm.password, }; - window.ApiAuth.sendConfirmationCode(this.registrationData.email, "verify_email") - .then(function () { + window.ApiAuth.registerUser(payload) + .then(function (data) { + // Сохраняем временный токен + self.registrationToken = data.token; + // Сохраняем email для отображения + self.registrationData.email = payload.email; + // Переключаем на шаг подтверждения self.registerStep = 'verify'; self.success = "Код подтверждения отправлен на почту"; self.startResendTimer(60); }) .catch(function (e) { - self.error = e.message || "Не удалось отправить код"; + self.error = e.message || "Ошибка регистрации"; }) .finally(function () { self.loading = false; }); }, - // ПОДТВЕРЖДЕНИЕ КОДА И РЕГИСТРАЦИЯ + // ПОДТВЕРЖДЕНИЕ КОДА РЕГИСТРАЦИИ (ШАГ 2) onVerifyCode: function () { var self = this; this.error = ""; this.success = ""; this.loading = true; - var payload = { - surname: this.registrationData.surname, - name: this.registrationData.name, - login: this.registrationData.login, - email: this.registrationData.email, - password: this.registrationData.password, - confirmation_code: this.confirmationCode.trim(), - }; + if (this.confirmationCode.trim().length !== 6) { + this.error = "Введите 6-значный код"; + this.loading = false; + return; + } + + if (!this.registrationToken) { + this.error = "Ошибка: токен не найден. Попробуйте зарегистрироваться заново."; + this.loading = false; + return; + } - window.ApiAuth.registerUserWithCode(payload) + window.ApiAuth.verifyRegistration( + this.registrationToken, + this.confirmationCode.trim() + ) .then(function () { - window.location.hash = "#/"; - window.location.reload(); - }) - .catch(function (e) { - self.error = e.message || "Неверный код подтверждения"; - }) - .finally(function () { - self.loading = false; - }); + // Мгновенный редирект без задержки + window.location.hash = "#/login"; + window.location.reload(); + }) }, - // ПОВТОРНАЯ ОТПРАВКА КОДА (регистрация) + // ПОВТОРНАЯ ОТПРАВКА КОДА РЕГИСТРАЦИИ onResendCode: function () { var self = this; this.error = ""; this.success = ""; this.loading = true; - window.ApiAuth.sendConfirmationCode(this.registrationData.email, "verify_email") + if (!this.registrationToken) { + this.error = "Ошибка: токен не найден. Попробуйте зарегистрироваться заново."; + this.loading = false; + return; + } + + window.ApiAuth.resendRegistrationCode(this.registrationToken) .then(function () { self.success = "Новый код отправлен на почту"; self.startResendTimer(60); @@ -179,6 +219,7 @@ onBackToRegister: function () { this.registerStep = 'form'; this.confirmationCode = ''; + this.registrationToken = ''; this.error = ''; this.success = ''; if (this.timerInterval) { @@ -187,31 +228,6 @@ } }, - // ОБНОВЛЕННАЯ РЕГИСТРАЦИЯ - onRegister: function () { - var self = this; - this.error = ""; - this.success = ""; - - if (this.registerForm.password.length < 8) { - this.error = "Пароль должен быть минимум 8 символов"; - return; - } - - var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(this.registerForm.email.trim())) { - this.error = "Введите корректный email"; - return; - } - - if (this.registerForm.login.trim().length < 3) { - this.error = "Логин должен быть минимум 3 символа"; - return; - } - - this.onSendCode(); - }, - // ТАЙМЕР ДЛЯ РЕГИСТРАЦИИ startResendTimer: function (seconds) { var self = this; @@ -248,7 +264,7 @@ return; } - window.ApiAuth.sendConfirmationCode(email, "reset_password") + window.ApiAuth.sendResetCode(email) .then(function () { self.resetStep = 'verify'; self.success = "Код восстановления отправлен на почту"; @@ -275,10 +291,7 @@ return; } - // Проверяем код через бэкенд - // Для проверки кода используем тот же confirmEmail? - // Если есть отдельный эндпоинт для проверки - используйте его - // Пока просто переходим к шагу смены пароля + // Переходим к шагу смены пароля self.resetStep = 'change'; self.loading = false; }, @@ -345,7 +358,7 @@ this.success = ""; this.loading = true; - window.ApiAuth.sendConfirmationCode(this.resetEmail, "reset_password") + window.ApiAuth.sendResetCode(this.resetEmail) .then(function () { self.success = "Новый код отправлен на почту"; self.startResetResendTimer(60); From 3a62a6304ea10d052f0fb311cce617849985a1cb Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 21 Jun 2026 18:13:39 +0300 Subject: [PATCH 25/47] Add frontend to login. --- frontend/app/api/api_v1/auth.js | 54 ++++++++--------- frontend/app/data/state.js | 1 + frontend/app/services/methods/auth.js | 60 +++++++++++++------ .../layout_navbar_auth_catalog_genres.html | 2 +- 4 files changed, 68 insertions(+), 49 deletions(-) diff --git a/frontend/app/api/api_v1/auth.js b/frontend/app/api/api_v1/auth.js index 95677e4..a36fe4e 100644 --- a/frontend/app/api/api_v1/auth.js +++ b/frontend/app/api/api_v1/auth.js @@ -3,9 +3,7 @@ var parseResponseJson = window.ApiClient.parseResponseJson; var readErrorMessage = window.ApiClient.readErrorMessage; - // ============ РЕГИСТРАЦИЯ (НОВАЯ ЛОГИКА) ============ - - // ШАГ 1: Регистрация (получаем временный токен) + // ============ РЕГИСТРАЦИЯ ============ function registerUser(payload) { return fetch(apiUrl("/api/v1/auth/register/"), { method: "POST", @@ -19,12 +17,11 @@ if (!res.ok) { throw new Error(readErrorMessage(data)); } - return data; // { token: "...", token_type: "bearer" } + return data; }); }); } - // ШАГ 2: Подтверждение кода регистрации function verifyRegistration(token, confirmationCode) { return fetch(apiUrl("/api/v1/auth/register/verify"), { method: "POST", @@ -46,7 +43,6 @@ }); } - // ПОВТОРНАЯ ОТПРАВКА КОДА РЕГИСТРАЦИИ function resendRegistrationCode(token) { return fetch(apiUrl("/api/v1/auth/register/resend-confirmation-code"), { method: "POST", @@ -67,40 +63,43 @@ }); } - // ============ 2FA (двухфакторная аутентификация) ============ + // ============ ВХОД (2FA) ============ - // ЛОГИН (возвращает email) + // ШАГ 1: Логин (получаем временный токен) function loginUser(username, password) { var body = new URLSearchParams(); body.set("username", username); body.set("password", password); - return fetch(apiUrl("/api/v1/auth/login"), { + + return fetch(apiUrl("/api/v1/auth/login/"), { method: "POST", headers: { - "Content-Type": "application/x-www-form-urlencoded", + "Content-Type": "application/x-www-form-urlencoded", // ← form-data Accept: "application/json", }, - body: body.toString(), + body: body.toString(), // ← form-data, не JSON }).then(function (res) { return parseResponseJson(res).then(function (data) { if (!res.ok) { throw new Error(readErrorMessage(data)); } - // data - это строка с email - return data; + return data; // { token: "...", token_type: "bearer" } }); }); } - // ПОДТВЕРЖДЕНИЕ 2FA КОДА - function confirmEmail(payload) { - return fetch(apiUrl("/api/v1/auth/confirm-email"), { + // ШАГ 2: Подтверждение 2FA кода + function verifyLogin(token, confirmationCode) { + return fetch(apiUrl("/api/v1/auth/login/verify"), { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, - body: JSON.stringify(payload), + body: JSON.stringify({ + token: token, + confirmation_code: confirmationCode, + }), }).then(function (res) { return parseResponseJson(res).then(function (data) { if (!res.ok) { @@ -112,19 +111,16 @@ } // ПОВТОРНАЯ ОТПРАВКА 2FA КОДА - function sendConfirmationCode(email, messageType) { - var payload = { - email: email, - message_type: messageType, // "two_factor_auth" - }; - - return fetch(apiUrl("/api/v1/auth/send-confirmation-code"), { + function resendLoginCode(token) { + return fetch(apiUrl("/api/v1/auth/login/resend-confirmation-code"), { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, - body: JSON.stringify(payload), + body: JSON.stringify({ + token: token, + }), }).then(function (res) { return parseResponseJson(res).then(function (data) { if (!res.ok) { @@ -137,7 +133,6 @@ // ============ ВОССТАНОВЛЕНИЕ ПАРОЛЯ ============ - // ОТПРАВКА КОДА ДЛЯ ВОССТАНОВЛЕНИЯ function sendResetCode(email) { var payload = { email: email, @@ -161,7 +156,6 @@ }); } - // СБРОС ПАРОЛЯ (с новым паролем) function resetPassword(payload) { return fetch(apiUrl("/api/v1/auth/reset-password"), { method: "POST", @@ -194,10 +188,10 @@ verifyRegistration: verifyRegistration, resendRegistrationCode: resendRegistrationCode, - // 2FA + // Вход (2FA) loginUser: loginUser, - confirmEmail: confirmEmail, - sendConfirmationCode: sendConfirmationCode, // для повторной отправки 2FA + verifyLogin: verifyLogin, + resendLoginCode: resendLoginCode, // Восстановление sendResetCode: sendResetCode, diff --git a/frontend/app/data/state.js b/frontend/app/data/state.js index 0d525b8..ac3d8c1 100644 --- a/frontend/app/data/state.js +++ b/frontend/app/data/state.js @@ -198,6 +198,7 @@ // Для двухфакторной аутентификации loginStep: 'form', // 'form' | 'verify' + loginToken: '', loginEmail: '', // email из ответа /login loginCode: '', // 6-значный код loginResendTimer: 60, diff --git a/frontend/app/services/methods/auth.js b/frontend/app/services/methods/auth.js index 2e33d95..78d2a94 100644 --- a/frontend/app/services/methods/auth.js +++ b/frontend/app/services/methods/auth.js @@ -6,19 +6,23 @@ this.error = ""; this.loading = true; - window.ApiAuth.loginUser(this.loginForm.username, this.loginForm.password) - .then(function (email) { - self.loginEmail = email; - self.loginStep = 'verify'; - self.success = "Код подтверждения отправлен на почту"; - self.startLoginResendTimer(60); - }) - .catch(function (e) { - self.error = e.message || "Не удалось войти"; - }) - .finally(function () { - self.loading = false; - }); + // Передаем username и password отдельно (не JSON) + window.ApiAuth.loginUser( + this.loginForm.username.trim(), + this.loginForm.password + ) + .then(function (data) { + self.loginToken = data.token; + self.loginStep = 'verify'; + self.success = "Код подтверждения отправлен на почту"; + self.startLoginResendTimer(60); + }) + .catch(function (e) { + self.error = e.message || "Не удалось войти"; + }) + .finally(function () { + self.loading = false; + }); }, // ПОДТВЕРЖДЕНИЕ 2FA КОДА @@ -27,11 +31,24 @@ this.error = ""; this.loading = true; - window.ApiAuth.confirmEmail({ - email: this.loginEmail, - confirmation_code: this.loginCode.trim(), - }) + if (this.loginCode.trim().length !== 6) { + this.error = "Введите 6-значный код"; + this.loading = false; + return; + } + + if (!this.loginToken) { + this.error = "Ошибка: токен не найден. Попробуйте войти заново."; + this.loading = false; + return; + } + + window.ApiAuth.verifyLogin( + this.loginToken, + this.loginCode.trim() + ) .then(function (data) { + // Сохраняем токены доступа window.TokenStore.setTokens(data.access_token, data.refresh_token); window.location.hash = "#/"; window.location.reload(); @@ -51,7 +68,13 @@ this.success = ""; this.loading = true; - window.ApiAuth.sendConfirmationCode(this.loginEmail, "two_factor_auth") + if (!this.loginToken) { + this.error = "Ошибка: токен не найден. Попробуйте войти заново."; + this.loading = false; + return; + } + + window.ApiAuth.resendLoginCode(this.loginToken) .then(function () { self.success = "Новый код отправлен на почту"; self.startLoginResendTimer(60); @@ -68,6 +91,7 @@ onBackToLogin: function () { this.loginStep = 'form'; this.loginCode = ''; + this.loginToken = ''; this.error = ''; this.success = ''; if (this.loginTimerInterval) { diff --git a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html index ed188af..ea22f3c 100644 --- a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html +++ b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html @@ -117,7 +117,7 @@

Вход

Двухфакторная аутентификация

- На почту {{ loginEmail }} отправлен код подтверждения. + Код подтверждения отправлен на вашу почту. Введите его ниже для завершения входа.

From c718ab8876bb6481a4da3dfd244370b18e48c6e3 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 21 Jun 2026 18:38:16 +0300 Subject: [PATCH 26/47] Add frontend to recover access to account. --- frontend/app/api/api_v1/auth.js | 76 ++++++++++++++++------ frontend/app/data/state.js | 2 + frontend/app/services/methods/auth.js | 92 +++++++++++++++++++-------- 3 files changed, 125 insertions(+), 45 deletions(-) diff --git a/frontend/app/api/api_v1/auth.js b/frontend/app/api/api_v1/auth.js index a36fe4e..8c5dd18 100644 --- a/frontend/app/api/api_v1/auth.js +++ b/frontend/app/api/api_v1/auth.js @@ -64,8 +64,6 @@ } // ============ ВХОД (2FA) ============ - - // ШАГ 1: Логин (получаем временный токен) function loginUser(username, password) { var body = new URLSearchParams(); body.set("username", username); @@ -74,21 +72,20 @@ return fetch(apiUrl("/api/v1/auth/login/"), { method: "POST", headers: { - "Content-Type": "application/x-www-form-urlencoded", // ← form-data + "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, - body: body.toString(), // ← form-data, не JSON + body: body.toString(), }).then(function (res) { return parseResponseJson(res).then(function (data) { if (!res.ok) { throw new Error(readErrorMessage(data)); } - return data; // { token: "...", token_type: "bearer" } + return data; }); }); } - // ШАГ 2: Подтверждение 2FA кода function verifyLogin(token, confirmationCode) { return fetch(apiUrl("/api/v1/auth/login/verify"), { method: "POST", @@ -105,12 +102,11 @@ if (!res.ok) { throw new Error(readErrorMessage(data)); } - return data; // { access_token, refresh_token, token_type } + return data; }); }); } - // ПОВТОРНАЯ ОТПРАВКА 2FA КОДА function resendLoginCode(token) { return fetch(apiUrl("/api/v1/auth/login/resend-confirmation-code"), { method: "POST", @@ -133,31 +129,52 @@ // ============ ВОССТАНОВЛЕНИЕ ПАРОЛЯ ============ - function sendResetCode(email) { - var payload = { - email: email, - message_type: "reset_password", - }; + // ШАГ 1: Отправка email для восстановления + function recoverAccount(email) { + return fetch(apiUrl("/api/v1/auth/recover/"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + email: email, + }), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; // { token: "...", token_type: "bearer" } + }); + }); + } - return fetch(apiUrl("/api/v1/auth/send-confirmation-code"), { + // ШАГ 2: Подтверждение кода восстановления + function verifyRecovery(token, confirmationCode) { + return fetch(apiUrl("/api/v1/auth/recover/verify"), { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, - body: JSON.stringify(payload), + body: JSON.stringify({ + token: token, + confirmation_code: confirmationCode, + }), }).then(function (res) { return parseResponseJson(res).then(function (data) { if (!res.ok) { throw new Error(readErrorMessage(data)); } - return data; + return data; // { token: "...", token_type: "bearer" } - токен для смены пароля }); }); } + // ШАГ 3: Смена пароля function resetPassword(payload) { - return fetch(apiUrl("/api/v1/auth/reset-password"), { + return fetch(apiUrl("/api/v1/auth/recover/reset-password"), { method: "POST", headers: { "Content-Type": "application/json", @@ -174,6 +191,27 @@ }); } + // ПОВТОРНАЯ ОТПРАВКА КОДА ВОССТАНОВЛЕНИЯ + function resendRecoveryCode(token) { + return fetch(apiUrl("/api/v1/auth/recover/resend-confirmation-code"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + token: token, + }), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; + }); + }); + } + // ============ ОБЩИЕ МЕТОДЫ ============ function logout() { @@ -194,8 +232,10 @@ resendLoginCode: resendLoginCode, // Восстановление - sendResetCode: sendResetCode, + recoverAccount: recoverAccount, + verifyRecovery: verifyRecovery, resetPassword: resetPassword, + resendRecoveryCode: resendRecoveryCode, // Общее logout: logout, diff --git a/frontend/app/data/state.js b/frontend/app/data/state.js index ac3d8c1..658800a 100644 --- a/frontend/app/data/state.js +++ b/frontend/app/data/state.js @@ -174,6 +174,8 @@ resetCode: '', resetNewPassword: '', resetConfirmPassword: '', + resetToken: '', + resetPasswordToken: '', resetResendTimer: 60, resetCanResend: false, resetTimerInterval: null, diff --git a/frontend/app/services/methods/auth.js b/frontend/app/services/methods/auth.js index 78d2a94..5b0341d 100644 --- a/frontend/app/services/methods/auth.js +++ b/frontend/app/services/methods/auth.js @@ -273,7 +273,8 @@ }, // ==================== ВОССТАНОВЛЕНИЕ ПАРОЛЯ ==================== - // ШАГ 1: Отправка кода для восстановления + + // ШАГ 1: Отправка email для восстановления onSendResetCode: function () { var self = this; this.error = ""; @@ -288,8 +289,13 @@ return; } - window.ApiAuth.sendResetCode(email) - .then(function () { + window.ApiAuth.recoverAccount(email) + .then(function (data) { + // Сохраняем временный токен + self.resetToken = data.token; + // Сохраняем email для отображения + self.resetEmail = email; + // Переключаем на шаг подтверждения self.resetStep = 'verify'; self.success = "Код восстановления отправлен на почту"; self.startResetResendTimer(60); @@ -302,7 +308,7 @@ }); }, - // ШАГ 2: Подтверждение кода и переход к смене пароля + // ШАГ 2: Подтверждение кода восстановления onVerifyResetCode: function () { var self = this; this.error = ""; @@ -315,9 +321,28 @@ return; } - // Переходим к шагу смены пароля - self.resetStep = 'change'; - self.loading = false; + if (!this.resetToken) { + this.error = "Ошибка: токен не найден. Попробуйте начать заново."; + this.loading = false; + return; + } + + window.ApiAuth.verifyRecovery( + this.resetToken, + this.resetCode.trim() + ) + .then(function (data) { + // Сохраняем новый токен для смены пароля + self.resetPasswordToken = data.token; + // Переключаем на шаг смены пароля + self.resetStep = 'change'; + }) + .catch(function (e) { + self.error = e.message || "Неверный код подтверждения"; + }) + .finally(function () { + self.loading = false; + }); }, // ШАГ 3: Смена пароля @@ -339,11 +364,16 @@ return; } + if (!this.resetPasswordToken) { + this.error = "Ошибка: токен не найден. Попробуйте начать заново."; + this.loading = false; + return; + } + var payload = { - email: this.resetEmail, + reset_password_token: this.resetPasswordToken, password: this.resetNewPassword, password_confirmation: this.resetConfirmPassword, - confirmation_code: this.resetCode.trim(), }; window.ApiAuth.resetPassword(payload) @@ -359,30 +389,20 @@ }); }, - // СБРОС ВОССТАНОВЛЕНИЯ (возврат к логину) - onResetBackToLogin: function () { - this.resetStep = 'form'; - this.resetEmail = ''; - this.resetCode = ''; - this.resetNewPassword = ''; - this.resetConfirmPassword = ''; - this.error = ''; - this.success = ''; - if (this.resetTimerInterval) { - clearInterval(this.resetTimerInterval); - this.resetTimerInterval = null; - } - this.currentView = 'login'; - }, - - // ПОВТОРНАЯ ОТПРАВКА КОДА ДЛЯ ВОССТАНОВЛЕНИЯ + // ПОВТОРНАЯ ОТПРАВКА КОДА ВОССТАНОВЛЕНИЯ onResendResetCode: function () { var self = this; this.error = ""; this.success = ""; this.loading = true; - window.ApiAuth.sendResetCode(this.resetEmail) + if (!this.resetToken) { + this.error = "Ошибка: токен не найден. Попробуйте начать заново."; + this.loading = false; + return; + } + + window.ApiAuth.resendRecoveryCode(this.resetToken) .then(function () { self.success = "Новый код отправлен на почту"; self.startResetResendTimer(60); @@ -395,6 +415,24 @@ }); }, + // ВОЗВРАТ К ФОРМЕ ВОССТАНОВЛЕНИЯ + onResetBackToLogin: function () { + this.resetStep = 'form'; + this.resetEmail = ''; + this.resetCode = ''; + this.resetNewPassword = ''; + this.resetConfirmPassword = ''; + this.resetToken = ''; + this.resetPasswordToken = ''; + this.error = ''; + this.success = ''; + if (this.resetTimerInterval) { + clearInterval(this.resetTimerInterval); + this.resetTimerInterval = null; + } + this.currentView = 'login'; + }, + // ТАЙМЕР ДЛЯ ВОССТАНОВЛЕНИЯ startResetResendTimer: function (seconds) { var self = this; From 8077dadc0cb7e8343e255e78fbca2fba58adb78a Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 21 Jun 2026 20:44:35 +0300 Subject: [PATCH 27/47] Add autologin after registration. --- frontend/app/api/api_v1/auth.js | 2 +- frontend/app/services/methods/auth.js | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/app/api/api_v1/auth.js b/frontend/app/api/api_v1/auth.js index 8c5dd18..4ea6bd9 100644 --- a/frontend/app/api/api_v1/auth.js +++ b/frontend/app/api/api_v1/auth.js @@ -38,7 +38,7 @@ if (!res.ok) { throw new Error(readErrorMessage(data)); } - return data; + return data; // { access_token, refresh_token, token_type } }); }); } diff --git a/frontend/app/services/methods/auth.js b/frontend/app/services/methods/auth.js index 5b0341d..b41ec9d 100644 --- a/frontend/app/services/methods/auth.js +++ b/frontend/app/services/methods/auth.js @@ -206,13 +206,21 @@ this.registrationToken, this.confirmationCode.trim() ) - .then(function () { - // Мгновенный редирект без задержки - window.location.hash = "#/login"; - window.location.reload(); - }) + .then(function (data) { + window.TokenStore.setTokens(data.access_token, data.refresh_token); + + window.location.hash = "#/"; + window.location.reload(); + }) + .catch(function (e) { + self.error = e.message || "Неверный код подтверждения"; + }) + .finally(function () { + self.loading = false; + }); }, + // ПОВТОРНАЯ ОТПРАВКА КОДА РЕГИСТРАЦИИ onResendCode: function () { var self = this; From 7e062c9697c921108d7018f79c64a50e7a97dee9 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 21 Jun 2026 22:06:06 +0300 Subject: [PATCH 28/47] Refactor jwt_utils module. --- app/core/security/jwt_utils/__init__.py | 0 .../token_factory.py} | 100 ++---------------- .../security/jwt_utils/token_factory_utils.py | 97 +++++++++++++++++ app/dependencies/auth.py | 2 +- app/services/user.py | 4 +- .../test_core/test_security/test_jwt_utils.py | 2 +- 6 files changed, 111 insertions(+), 94 deletions(-) create mode 100644 app/core/security/jwt_utils/__init__.py rename app/core/security/{jwt_utils.py => jwt_utils/token_factory.py} (58%) create mode 100644 app/core/security/jwt_utils/token_factory_utils.py diff --git a/app/core/security/jwt_utils/__init__.py b/app/core/security/jwt_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/security/jwt_utils.py b/app/core/security/jwt_utils/token_factory.py similarity index 58% rename from app/core/security/jwt_utils.py rename to app/core/security/jwt_utils/token_factory.py index 6f360e2..5abb642 100644 --- a/app/core/security/jwt_utils.py +++ b/app/core/security/jwt_utils/token_factory.py @@ -1,13 +1,8 @@ -from datetime import UTC, datetime, timedelta -from typing import Any - -import jwt from pydantic import EmailStr from core.config import settings from core.constants import ( ACCESS_TOKEN_TYPE, - EMAIL_FIELD, RECOVER_TOKEN_TYPE, REFRESH_TOKEN_TYPE, REGISTRATION_TOKEN_TYPE, @@ -15,95 +10,18 @@ TOKEN_TYPE_FIELD, TWO_FACTOR_TOKEN_TYPE, ) +from core.security.jwt_utils.token_factory_utils import ( + create_access_token_payload, + create_recover_token_payload, + create_refresh_token_payload, + create_registration_token_payload, + create_reset_password_token_payload, + create_two_factor_token_payload, + encode_jwt, +) from schemas.user import UserRegistration, UserResponse -def encode_jwt( - payload: dict[str, Any], - secret_key: str = settings.auth_jwt.secret_key, - algorithm: str = settings.auth_jwt.algorithm, - expires_minutes: int = settings.auth_jwt.access_token_expire_minutes, -) -> str: - to_encode = payload.copy() - now = datetime.now(UTC) - expire = now + timedelta(minutes=expires_minutes) - to_encode.update( - iat=now, - exp=expire, - ) - return jwt.encode( - to_encode, - secret_key, - algorithm=algorithm, - ) - - -def decode_jwt( - token: str, - secret_key: str = settings.auth_jwt.secret_key, - algorithm: str = settings.auth_jwt.algorithm, -) -> dict[str, Any]: - return jwt.decode( - token, - secret_key, - algorithms=[algorithm], - ) - - -def create_access_token_payload(user: UserResponse) -> dict[str, str]: - payload = { - "sub": str(user.id), - "login": user.login, - EMAIL_FIELD: user.email, - } - return payload - - -def create_refresh_token_payload(user: UserResponse) -> dict[str, str]: - payload = { - "sub": str(user.id), - } - return payload - - -def create_registration_token_payload( - user: UserRegistration, -) -> dict[str, str]: - payload = { - "sub": user.login, - "login": user.login, - EMAIL_FIELD: user.email, - } - return payload - - -def create_two_factor_token_payload( - user: UserResponse, -) -> dict[str, str]: - payload = { - "sub": str(user.id), - EMAIL_FIELD: user.email, - } - return payload - - -def create_reset_password_token_payload( - user: UserResponse, -) -> dict[str, str]: - payload = { - "sub": str(user.id), - EMAIL_FIELD: user.email, - } - return payload - - -def create_recover_token_payload( - email: EmailStr, -) -> dict[str, str]: - payload = {EMAIL_FIELD: email} - return payload - - def create_access_token(user: UserResponse) -> str: payload = create_access_token_payload(user) payload.update( diff --git a/app/core/security/jwt_utils/token_factory_utils.py b/app/core/security/jwt_utils/token_factory_utils.py new file mode 100644 index 0000000..6e04509 --- /dev/null +++ b/app/core/security/jwt_utils/token_factory_utils.py @@ -0,0 +1,97 @@ +from datetime import UTC, datetime, timedelta +from typing import Any + +import jwt +from pydantic import EmailStr + +from core.config import settings +from core.constants import ( + EMAIL_FIELD, +) +from schemas.user import UserRegistration, UserResponse + + +def encode_jwt( + payload: dict[str, Any], + secret_key: str = settings.auth_jwt.secret_key, + algorithm: str = settings.auth_jwt.algorithm, + expires_minutes: int = settings.auth_jwt.access_token_expire_minutes, +) -> str: + to_encode = payload.copy() + now = datetime.now(UTC) + expire = now + timedelta(minutes=expires_minutes) + to_encode.update( + iat=now, + exp=expire, + ) + return jwt.encode( + to_encode, + secret_key, + algorithm=algorithm, + ) + + +def decode_jwt( + token: str, + secret_key: str = settings.auth_jwt.secret_key, + algorithm: str = settings.auth_jwt.algorithm, +) -> dict[str, Any]: + return jwt.decode( + token, + secret_key, + algorithms=[algorithm], + ) + + +def create_access_token_payload(user: UserResponse) -> dict[str, str]: + payload = { + "sub": str(user.id), + "login": user.login, + EMAIL_FIELD: user.email, + } + return payload + + +def create_refresh_token_payload(user: UserResponse) -> dict[str, str]: + payload = { + "sub": str(user.id), + } + return payload + + +def create_registration_token_payload( + user: UserRegistration, +) -> dict[str, str]: + payload = { + "sub": user.login, + "login": user.login, + EMAIL_FIELD: user.email, + } + return payload + + +def create_two_factor_token_payload( + user: UserResponse, +) -> dict[str, str]: + payload = { + "sub": str(user.id), + EMAIL_FIELD: user.email, + } + return payload + + +def create_reset_password_token_payload( + user: UserResponse, +) -> dict[str, str]: + payload = { + "sub": str(user.id), + EMAIL_FIELD: user.email, + } + return payload + + +def create_recover_token_payload( + email: EmailStr, +) -> dict[str, str]: + payload = {EMAIL_FIELD: email} + return payload diff --git a/app/dependencies/auth.py b/app/dependencies/auth.py index d950132..3220e4a 100644 --- a/app/dependencies/auth.py +++ b/app/dependencies/auth.py @@ -6,7 +6,7 @@ from core.config import settings from core.constants import ACCESS_TOKEN_TYPE, REFRESH_TOKEN_TYPE from core.exceptions.auth import PermissionDeniedError -from core.security.jwt_utils import decode_jwt +from core.security.jwt_utils.token_factory_utils import decode_jwt from core.security.validators import validate_token_payload from dependencies.services import get_user_service from schemas.auth import UserLogin diff --git a/app/services/user.py b/app/services/user.py index 7703c86..8ab7d37 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -25,13 +25,15 @@ UserLoginNotFoundError, ) from core.redis import RedisService -from core.security.jwt_utils import ( +from core.security.jwt_utils.token_factory import ( create_access_token, create_recover_token, create_refresh_token, create_registration_token, create_reset_password_token, create_two_factor_token, +) +from core.security.jwt_utils.token_factory_utils import ( decode_jwt, ) from core.security.password_utils import hash_password, verify_password diff --git a/tests/test_core/test_security/test_jwt_utils.py b/tests/test_core/test_security/test_jwt_utils.py index e749530..16cd11c 100644 --- a/tests/test_core/test_security/test_jwt_utils.py +++ b/tests/test_core/test_security/test_jwt_utils.py @@ -2,7 +2,7 @@ import pytest -from core.security.jwt_utils import encode_jwt, decode_jwt +from core.security.jwt_utils.token_factory_utils import encode_jwt, decode_jwt @pytest.fixture(scope="function") From 39f6c8c397a28e204a7f887631665ca535ce2325 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Mon, 22 Jun 2026 12:01:52 +0300 Subject: [PATCH 29/47] Rework create recover/reset jwt token. --- app/core/constants.py | 1 + .../security/{jwt_utils => jwt}/__init__.py | 0 .../{jwt_utils => jwt}/token_factory.py | 8 +++----- .../token_factory_utils.py => jwt/utils.py} | 18 +++++++++++------- app/dependencies/auth.py | 2 +- app/schemas/auth.py | 16 ++++++++-------- app/services/user.py | 12 ++++++++---- .../test_core/test_security/test_jwt_utils.py | 2 +- 8 files changed, 33 insertions(+), 26 deletions(-) rename app/core/security/{jwt_utils => jwt}/__init__.py (100%) rename app/core/security/{jwt_utils => jwt}/token_factory.py (93%) rename app/core/security/{jwt_utils/token_factory_utils.py => jwt/utils.py} (89%) diff --git a/app/core/constants.py b/app/core/constants.py index c253ab6..52b678a 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -60,6 +60,7 @@ class MethodType(StrEnum): TOKEN_TYPE_FIELD = "type" +LOGIN_FIELD = "login" EMAIL_FIELD = "email" ACCESS_TOKEN_TYPE = "access" diff --git a/app/core/security/jwt_utils/__init__.py b/app/core/security/jwt/__init__.py similarity index 100% rename from app/core/security/jwt_utils/__init__.py rename to app/core/security/jwt/__init__.py diff --git a/app/core/security/jwt_utils/token_factory.py b/app/core/security/jwt/token_factory.py similarity index 93% rename from app/core/security/jwt_utils/token_factory.py rename to app/core/security/jwt/token_factory.py index 5abb642..b7df690 100644 --- a/app/core/security/jwt_utils/token_factory.py +++ b/app/core/security/jwt/token_factory.py @@ -1,5 +1,3 @@ -from pydantic import EmailStr - from core.config import settings from core.constants import ( ACCESS_TOKEN_TYPE, @@ -10,7 +8,7 @@ TOKEN_TYPE_FIELD, TWO_FACTOR_TOKEN_TYPE, ) -from core.security.jwt_utils.token_factory_utils import ( +from core.security.jwt.utils import ( create_access_token_payload, create_recover_token_payload, create_refresh_token_payload, @@ -70,8 +68,8 @@ def create_two_factor_token(user: UserResponse) -> str: ) -def create_recover_token(email: EmailStr) -> str: - payload = create_recover_token_payload(email) +def create_recover_token(user: UserResponse) -> str: + payload = create_recover_token_payload(user) payload.update( {TOKEN_TYPE_FIELD: RECOVER_TOKEN_TYPE}, ) diff --git a/app/core/security/jwt_utils/token_factory_utils.py b/app/core/security/jwt/utils.py similarity index 89% rename from app/core/security/jwt_utils/token_factory_utils.py rename to app/core/security/jwt/utils.py index 6e04509..07242ca 100644 --- a/app/core/security/jwt_utils/token_factory_utils.py +++ b/app/core/security/jwt/utils.py @@ -2,11 +2,11 @@ from typing import Any import jwt -from pydantic import EmailStr from core.config import settings from core.constants import ( EMAIL_FIELD, + LOGIN_FIELD, ) from schemas.user import UserRegistration, UserResponse @@ -46,7 +46,7 @@ def decode_jwt( def create_access_token_payload(user: UserResponse) -> dict[str, str]: payload = { "sub": str(user.id), - "login": user.login, + LOGIN_FIELD: user.login, EMAIL_FIELD: user.email, } return payload @@ -64,7 +64,7 @@ def create_registration_token_payload( ) -> dict[str, str]: payload = { "sub": user.login, - "login": user.login, + LOGIN_FIELD: user.login, EMAIL_FIELD: user.email, } return payload @@ -80,18 +80,22 @@ def create_two_factor_token_payload( return payload -def create_reset_password_token_payload( +def create_recover_token_payload( user: UserResponse, ) -> dict[str, str]: payload = { "sub": str(user.id), EMAIL_FIELD: user.email, + LOGIN_FIELD: user.login, } return payload -def create_recover_token_payload( - email: EmailStr, +def create_reset_password_token_payload( + user: UserResponse, ) -> dict[str, str]: - payload = {EMAIL_FIELD: email} + payload = { + "sub": str(user.id), + EMAIL_FIELD: user.email, + } return payload diff --git a/app/dependencies/auth.py b/app/dependencies/auth.py index 3220e4a..f201e57 100644 --- a/app/dependencies/auth.py +++ b/app/dependencies/auth.py @@ -6,7 +6,7 @@ from core.config import settings from core.constants import ACCESS_TOKEN_TYPE, REFRESH_TOKEN_TYPE from core.exceptions.auth import PermissionDeniedError -from core.security.jwt_utils.token_factory_utils import decode_jwt +from core.security.jwt.utils import decode_jwt from core.security.validators import validate_token_payload from dependencies.services import get_user_service from schemas.auth import UserLogin diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 583e637..c43f747 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -40,19 +40,19 @@ class VerifyUserEmail(BaseModel): confirmation_code: ConfirmationCodeConstraint -class ResetPasswordRequest(BaseModel): +class RecoverAccountRequest(BaseModel): """ - Модель для смены пароля. + Модель для восстановления доступа к аккаунту. """ - reset_password_token: str - password: PasswordConstraint - password_confirmation: PasswordConstraint + email: EmailStr -class RecoverAccountRequest(BaseModel): +class ResetPasswordRequest(BaseModel): """ - Модель для восстановления доступа к аккаунту. + Модель для смены пароля. """ - email: EmailStr + reset_password_token: str + password: PasswordConstraint + password_confirmation: PasswordConstraint diff --git a/app/services/user.py b/app/services/user.py index 8ab7d37..3163a3a 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -10,6 +10,7 @@ from core.constants import ( BEARER_TOKEN_TYPE, EMAIL_FIELD, + LOGIN_FIELD, UserRole, ) from core.exceptions.auth import InvalidPasswordError @@ -25,7 +26,7 @@ UserLoginNotFoundError, ) from core.redis import RedisService -from core.security.jwt_utils.token_factory import ( +from core.security.jwt.token_factory import ( create_access_token, create_recover_token, create_refresh_token, @@ -33,7 +34,7 @@ create_reset_password_token, create_two_factor_token, ) -from core.security.jwt_utils.token_factory_utils import ( +from core.security.jwt.utils import ( decode_jwt, ) from core.security.password_utils import hash_password, verify_password @@ -308,7 +309,8 @@ async def recover_account( recover_account_data: RecoverAccountRequest, ) -> TemporaryTokenInfo: email = recover_account_data.email - token = create_recover_token(email) + user = await self.get_user_by_email(email) + token = create_recover_token(user) send_confirmation_code_request = SendConfirmationCodeRequest( token=token, ) @@ -330,11 +332,13 @@ async def send_recover_account_confirmation_code( secret_key=settings.confirmation_code_jwt.secret_key, algorithm=settings.confirmation_code_jwt.algorithm, ) + login = payload[LOGIN_FIELD] email = payload[EMAIL_FIELD] confirmation_code = await self.create_confirmation_code(email) app.send_task( - name=TaskType.send_confirmation_email_code.value, + name=TaskType.send_reset_password_email_data.value, args=[ + login, email, confirmation_code, ], diff --git a/tests/test_core/test_security/test_jwt_utils.py b/tests/test_core/test_security/test_jwt_utils.py index 16cd11c..e1a7a0d 100644 --- a/tests/test_core/test_security/test_jwt_utils.py +++ b/tests/test_core/test_security/test_jwt_utils.py @@ -2,7 +2,7 @@ import pytest -from core.security.jwt_utils.token_factory_utils import encode_jwt, decode_jwt +from core.security.jwt.utils import encode_jwt, decode_jwt @pytest.fixture(scope="function") From cd20fa45098132bfdaba15ed5a1901cd1b021af0 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Mon, 22 Jun 2026 12:50:00 +0300 Subject: [PATCH 30/47] Fix email messages on registration and login. --- app/services/user.py | 4 +- notification-service/core/celery/tasks.py | 21 ++++++-- notification-service/service.py | 60 ++++++++++++++++++++--- packages/celery/constants.py | 5 +- 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/app/services/user.py b/app/services/user.py index 3163a3a..b61dd70 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -175,7 +175,7 @@ async def send_register_confirmation_code( email = payload[EMAIL_FIELD] confirmation_code = await self.create_confirmation_code(email) app.send_task( - name=TaskType.send_confirmation_email_code.value, + name=TaskType.send_confirm_registration_email.value, args=[ email, confirmation_code, @@ -242,7 +242,7 @@ async def send_authenticate_confirmation_code( email = payload[EMAIL_FIELD] confirmation_code = await self.create_confirmation_code(email) app.send_task( - name=TaskType.send_confirmation_email_code.value, + name=TaskType.send_confirm_login_email.value, args=[ email, confirmation_code, diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index 1e34cf3..93a1202 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -20,14 +20,29 @@ def send_welcome_email(email: str, name: str) -> None: @app.task( - name=TaskType.send_confirmation_email_code.value, + name=TaskType.send_confirm_registration_email.value, ) -def send_confirmation_email_code( +def send_confirm_registration_email( email: EmailStr, confirmation_code: str, ) -> None: asyncio.run( - EmailService.send_confirmation_email_code( + EmailService.send_confirm_registration_email( + email, + confirmation_code, + ), + ) + + +@app.task( + name=TaskType.send_confirm_login_email.value, +) +def send_confirm_login_email( + email: EmailStr, + confirmation_code: str, +) -> None: + asyncio.run( + EmailService.send_confirm_login_email( email=email, confirmation_code=confirmation_code, ), diff --git a/notification-service/service.py b/notification-service/service.py index 897cdbc..d43db0a 100644 --- a/notification-service/service.py +++ b/notification-service/service.py @@ -78,16 +78,60 @@ async def send_welcome_email(cls, email: str, name: str) -> None: ) @classmethod - async def send_confirmation_email_code( + async def send_confirm_registration_email( cls, email: EmailStr, confirmation_code: str, ) -> None: - subject = "Confirm your email address" - body = f"Your confirmation code is {confirmation_code}" + subject = "🔑 MovieAPI — Код подтверждения регистрации" + # ruff: disable[W293, E501] + body_template = """ + Здравствуйте! + + Спасибо за регистрацию в MovieAPI. + + Для завершения создания аккаунта и подтверждения вашего email-адреса, + + пожалуйста, введите следующий код на странице регистрации: {confirmation_code} + + Код действителен в течение 60 секунд. + + Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо. + + — Команда MovieAPI + """ + # ruff: enable[W293, E501] + await cls.send_email( + subject=subject, + body=body_template.format(confirmation_code=confirmation_code), + to_email=email, + ) + + @classmethod + async def send_confirm_login_email( + cls, + email: EmailStr, + confirmation_code: str, + ) -> None: + subject = "🛡️ Код безопасности для входа в MovieAPI" + # ruff: disable[W293, E501] + body_template = """ + Здравствуйте! + + Выполнен запрос на вход в ваш аккаунт MovieAPI. + + Для подтверждения личности введите одноразовый код безопасности: {confirmation_code} + + Код действует 60 секунд. Никому не сообщайте этот код. + + Если вы не запрашивали вход в аккаунт MovieAPI, просто проигнорируйте это письмо. + + — Команда MovieAPI + """ + # ruff: enable[W293, E501] await cls.send_email( subject=subject, - body=body, + body=body_template.format(confirmation_code=confirmation_code), to_email=email, ) @@ -98,7 +142,8 @@ async def send_reset_password_email_data( email: EmailStr, confirmation_code: str, ) -> None: - subject = "Восстановление доступа к приложению MovieAPI" + subject = "🛡️ Восстановление доступа к приложению MovieAPI" + # ruff: disable[W293] body_template = """ Здравствуйте! @@ -110,10 +155,11 @@ async def send_reset_password_email_data( Ваш код подтверждения: {confirmation_code} - Код подтверждения действует 1 минуту. - + Код подтверждения действует 60 секунд. + Если вы не запрашивали восстановление, просто проигнорируйте это письмо. """ + # ruff: enable[W293] await cls.send_email( subject=subject, body=body_template.format( diff --git a/packages/celery/constants.py b/packages/celery/constants.py index 7f32b37..d52ab82 100644 --- a/packages/celery/constants.py +++ b/packages/celery/constants.py @@ -9,7 +9,10 @@ class Queue(StrEnum): class TaskType(StrEnum): delete_temporary_file = "mediaservice.media.delete_temporary_file" send_welcome_email = "notification-service.email.send-welcome-email" - send_confirmation_email_code = "notification-service.email.confirm_email" + send_confirm_registration_email = ( + "notification-service.email.send-confirm-registration-email" + ) + send_confirm_login_email = "notification-service.email.confirm-login-email" send_reset_password_email_data = ( "notification-service.email.send_reset_password_email_data" ) From 730c68cf5ffc1f289c505ed22de19789c9f03e9b Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Mon, 22 Jun 2026 18:14:46 +0300 Subject: [PATCH 31/47] Split auth methods from UserService to AuthService and apply it to auth views. --- app/api/api_v1/auth/login_views.py | 14 +- app/api/api_v1/auth/recover_views.py | 18 +- app/api/api_v1/auth/refresh_token_views.py | 6 +- app/api/api_v1/auth/registration_views.py | 14 +- app/core/config.py | 4 +- app/core/schema_utils.py | 14 + app/core/security/jwt/token_factory.py | 36 ++- app/dependencies/annotations/services.py | 9 + app/dependencies/redis_client.py | 4 +- app/dependencies/redis_services.py | 6 +- app/dependencies/services.py | 31 +- app/services/auth.py | 295 +++++++++++++++++++ app/services/user.py | 318 +-------------------- 13 files changed, 409 insertions(+), 360 deletions(-) create mode 100644 app/core/schema_utils.py create mode 100644 app/services/auth.py diff --git a/app/api/api_v1/auth/login_views.py b/app/api/api_v1/auth/login_views.py index 380eacb..05d8a55 100644 --- a/app/api/api_v1/auth/login_views.py +++ b/app/api/api_v1/auth/login_views.py @@ -2,7 +2,7 @@ from starlette import status from dependencies.annotations.security import GetLoginDataDep -from dependencies.annotations.services import UserServiceDep +from dependencies.annotations.services import AuthServiceDep from schemas.auth import SendConfirmationCodeRequest, VerifyUserEmail from schemas.token_info import TemporaryTokenInfo, TokenInfo @@ -19,9 +19,9 @@ ) async def login_user( login_data: GetLoginDataDep, - user_service: UserServiceDep, + auth_service: AuthServiceDep, ) -> TemporaryTokenInfo: - return await user_service.authenticate_user(login_data) + return await auth_service.authenticate_user(login_data) @router.post( @@ -30,9 +30,9 @@ async def login_user( ) async def resend_authenticate_confirmation_code( send_confirmation_code_request: SendConfirmationCodeRequest, - user_service: UserServiceDep, + auth_service: AuthServiceDep, ) -> None: - await user_service.send_authenticate_confirmation_code( + await auth_service.send_authenticate_confirmation_code( send_confirmation_code_request, ) @@ -44,6 +44,6 @@ async def resend_authenticate_confirmation_code( ) async def verify_authenticate_user( verify_authenticate_user_data: VerifyUserEmail, - user_service: UserServiceDep, + auth_service: AuthServiceDep, ) -> TokenInfo: - return await user_service.verify_authenticate_user(verify_authenticate_user_data) + return await auth_service.verify_authenticate_user(verify_authenticate_user_data) diff --git a/app/api/api_v1/auth/recover_views.py b/app/api/api_v1/auth/recover_views.py index 441e77f..d466aa6 100644 --- a/app/api/api_v1/auth/recover_views.py +++ b/app/api/api_v1/auth/recover_views.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from starlette import status -from dependencies.annotations.services import UserServiceDep +from dependencies.annotations.services import AuthServiceDep from schemas.auth import ( RecoverAccountRequest, ResetPasswordRequest, @@ -23,17 +23,17 @@ ) async def recover_account( recover_account_data: RecoverAccountRequest, - user_service: UserServiceDep, + auth_service: AuthServiceDep, ) -> TemporaryTokenInfo: - return await user_service.recover_account(recover_account_data) + return await auth_service.recover_account(recover_account_data) @router.post("/resend-confirmation-code") async def resend_recover_account_confirmation_code( send_confirmation_code_request: SendConfirmationCodeRequest, - user_service: UserServiceDep, + auth_service: AuthServiceDep, ) -> None: - return await user_service.send_recover_account_confirmation_code( + return await auth_service.send_recover_account_confirmation_code( send_confirmation_code_request, ) @@ -41,14 +41,14 @@ async def resend_recover_account_confirmation_code( @router.post("/verify") async def verify_recover_account( verify_recover_account_data: VerifyUserEmail, - user_service: UserServiceDep, + auth_service: AuthServiceDep, ) -> TemporaryTokenInfo: - return await user_service.verify_recover_account(verify_recover_account_data) + return await auth_service.verify_recover_account(verify_recover_account_data) @router.post("/reset-password") async def reset_password( reset_password_data: ResetPasswordRequest, - user_service: UserServiceDep, + auth_service: AuthServiceDep, ) -> None: - await user_service.reset_password(reset_password_data) + await auth_service.reset_password(reset_password_data) diff --git a/app/api/api_v1/auth/refresh_token_views.py b/app/api/api_v1/auth/refresh_token_views.py index 23a6b0c..a7e1c6e 100644 --- a/app/api/api_v1/auth/refresh_token_views.py +++ b/app/api/api_v1/auth/refresh_token_views.py @@ -6,7 +6,7 @@ from dependencies.annotations.security import ( AuthUserByRefreshTokenDep, ) -from dependencies.annotations.services import UserServiceDep +from dependencies.annotations.services import AuthServiceDep from schemas.token_info import TokenInfo router = APIRouter( @@ -22,6 +22,6 @@ ) async def refresh_access_token( user_id: AuthUserByRefreshTokenDep, - user_service: UserServiceDep, + auth_service: AuthServiceDep, ) -> TokenInfo: - return await user_service.refresh_access_token(user_id) + return await auth_service.refresh_access_token(user_id) diff --git a/app/api/api_v1/auth/registration_views.py b/app/api/api_v1/auth/registration_views.py index d081482..695b017 100644 --- a/app/api/api_v1/auth/registration_views.py +++ b/app/api/api_v1/auth/registration_views.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from starlette import status -from dependencies.annotations.services import UserServiceDep +from dependencies.annotations.services import AuthServiceDep from schemas.auth import SendConfirmationCodeRequest, VerifyUserEmail from schemas.token_info import TemporaryTokenInfo, TokenInfo from schemas.user import UserRegistration @@ -19,9 +19,9 @@ ) async def register_user( registration_user_data: UserRegistration, - user_service: UserServiceDep, + auth_service: AuthServiceDep, ) -> TemporaryTokenInfo: - return await user_service.register_user(registration_user_data) + return await auth_service.register_user(registration_user_data) @router.post( @@ -30,9 +30,9 @@ async def register_user( ) async def resend_register_confirmation_code( send_confirmation_code_request: SendConfirmationCodeRequest, - user_service: UserServiceDep, + auth_service: AuthServiceDep, ) -> None: - await user_service.send_register_confirmation_code(send_confirmation_code_request) + await auth_service.send_register_confirmation_code(send_confirmation_code_request) @router.post( @@ -42,6 +42,6 @@ async def resend_register_confirmation_code( ) async def verify_register_user( verify_register_user_data: VerifyUserEmail, - user_service: UserServiceDep, + auth_service: AuthServiceDep, ) -> TokenInfo: - return await user_service.verify_register_user(verify_register_user_data) + return await auth_service.verify_register_user(verify_register_user_data) diff --git a/app/core/config.py b/app/core/config.py index fe96856..2989bf0 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -32,7 +32,7 @@ class RedisDataBaseConfig(BaseModel): reviews: int = 4 favorite_movies: int = 5 watch_history: int = 6 - confirmation_codes: int = 7 + auth: int = 7 class RedisConfig(BaseModel): @@ -91,7 +91,7 @@ class Settings(BaseSettings): redis: RedisConfig = RedisConfig() rabbitmq: RabbitMQConfig = RabbitMQConfig() auth_jwt: AuthJWTConfig = AuthJWTConfig() - confirmation_code_jwt: ConfirmationCodeJWTConfig = ConfirmationCodeJWTConfig() + confirmation_jwt: ConfirmationCodeJWTConfig = ConfirmationCodeJWTConfig() http_bearer: HTTPBearer = HTTPBearer() oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer( "/api/v1/auth/login/", diff --git a/app/core/schema_utils.py b/app/core/schema_utils.py new file mode 100644 index 0000000..d47f9fb --- /dev/null +++ b/app/core/schema_utils.py @@ -0,0 +1,14 @@ +from core.security.password_utils import hash_password +from schemas.user import UserCreate, UserRegistration + + +def convert_registration_to_create_schema( + user_registration_data: UserRegistration, +) -> UserCreate: + user_create_data = user_registration_data.model_dump( + exclude={"confirmation_code", "password"}, + ) + password = user_registration_data.password + encrypted_password = hash_password(password) + user_create_data["encrypted_password"] = encrypted_password + return UserCreate(**user_create_data) diff --git a/app/core/security/jwt/token_factory.py b/app/core/security/jwt/token_factory.py index b7df690..aa84107 100644 --- a/app/core/security/jwt/token_factory.py +++ b/app/core/security/jwt/token_factory.py @@ -1,6 +1,7 @@ from core.config import settings from core.constants import ( ACCESS_TOKEN_TYPE, + BEARER_TOKEN_TYPE, RECOVER_TOKEN_TYPE, REFRESH_TOKEN_TYPE, REGISTRATION_TOKEN_TYPE, @@ -17,6 +18,7 @@ create_two_factor_token_payload, encode_jwt, ) +from schemas.token_info import TokenInfo from schemas.user import UserRegistration, UserResponse @@ -42,6 +44,16 @@ def create_refresh_token(user: UserResponse) -> str: ) +def create_auth_token(user: UserResponse) -> TokenInfo: + access_token = create_access_token(user) + refresh_token = create_refresh_token(user) + return TokenInfo( + access_token=access_token, + refresh_token=refresh_token, + token_type=BEARER_TOKEN_TYPE, + ) + + def create_registration_token(user: UserRegistration) -> str: payload = create_registration_token_payload(user) payload.update( @@ -49,9 +61,9 @@ def create_registration_token(user: UserRegistration) -> str: ) return encode_jwt( payload=payload, - secret_key=settings.confirmation_code_jwt.secret_key, - algorithm=settings.confirmation_code_jwt.algorithm, - expires_minutes=settings.confirmation_code_jwt.registration_token_expire_minutes, + secret_key=settings.confirmation_jwt.secret_key, + algorithm=settings.confirmation_jwt.algorithm, + expires_minutes=settings.confirmation_jwt.registration_token_expire_minutes, ) @@ -62,9 +74,9 @@ def create_two_factor_token(user: UserResponse) -> str: ) return encode_jwt( payload=payload, - secret_key=settings.confirmation_code_jwt.secret_key, - algorithm=settings.confirmation_code_jwt.algorithm, - expires_minutes=settings.confirmation_code_jwt.two_factor_token_expire_minutes, + secret_key=settings.confirmation_jwt.secret_key, + algorithm=settings.confirmation_jwt.algorithm, + expires_minutes=settings.confirmation_jwt.two_factor_token_expire_minutes, ) @@ -75,9 +87,9 @@ def create_recover_token(user: UserResponse) -> str: ) return encode_jwt( payload=payload, - secret_key=settings.confirmation_code_jwt.secret_key, - algorithm=settings.confirmation_code_jwt.algorithm, - expires_minutes=settings.confirmation_code_jwt.recover_token_expire_minutes, + secret_key=settings.confirmation_jwt.secret_key, + algorithm=settings.confirmation_jwt.algorithm, + expires_minutes=settings.confirmation_jwt.recover_token_expire_minutes, ) @@ -88,7 +100,7 @@ def create_reset_password_token(user: UserResponse) -> str: ) return encode_jwt( payload=payload, - secret_key=settings.confirmation_code_jwt.secret_key, - algorithm=settings.confirmation_code_jwt.algorithm, - expires_minutes=settings.confirmation_code_jwt.reset_password_token_expire_minutes, + secret_key=settings.confirmation_jwt.secret_key, + algorithm=settings.confirmation_jwt.algorithm, + expires_minutes=settings.confirmation_jwt.reset_password_token_expire_minutes, ) diff --git a/app/dependencies/annotations/services.py b/app/dependencies/annotations/services.py index b610eec..d5f2443 100644 --- a/app/dependencies/annotations/services.py +++ b/app/dependencies/annotations/services.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from dependencies.services import ( + get_auth_service, get_db, get_favorite_movie_service, get_genre_service, @@ -20,6 +21,7 @@ UserService, WatchHistoryService, ) +from services.auth import AuthService DbSessionDep = Annotated[ AsyncSession, @@ -63,6 +65,13 @@ ), ] +AuthServiceDep = Annotated[ + AuthService, + Depends( + get_auth_service, + ), +] + WatchHistoryServiceDep = Annotated[ WatchHistoryService, Depends( diff --git a/app/dependencies/redis_client.py b/app/dependencies/redis_client.py index ce569e2..93466bd 100644 --- a/app/dependencies/redis_client.py +++ b/app/dependencies/redis_client.py @@ -50,6 +50,6 @@ async def get_redis_client() -> AsyncGenerator[RedisClient]: db=settings.redis.db.watch_history, ) -get_confirmation_code_redis_client = redis_client_factory( - db=settings.redis.db.confirmation_codes, +get_auth_redis_client = redis_client_factory( + db=settings.redis.db.auth, ) diff --git a/app/dependencies/redis_services.py b/app/dependencies/redis_services.py index 699ff07..ffe33d3 100644 --- a/app/dependencies/redis_services.py +++ b/app/dependencies/redis_services.py @@ -5,7 +5,7 @@ from core.redis import RedisClient, RedisService from dependencies.redis_client import ( - get_confirmation_code_redis_client, + get_auth_redis_client, get_favorite_movie_redis_client, get_genre_redis_client, get_movie_redis_client, @@ -56,6 +56,6 @@ async def dependency( get_user_redis_service = redis_service_factory( get_user_redis_client, ) -get_confirmation_code_redis_service = redis_service_factory( - get_confirmation_code_redis_client, +get_auth_redis_service = redis_service_factory( + get_auth_redis_client, ) diff --git a/app/dependencies/services.py b/app/dependencies/services.py index 2817c78..2e44981 100644 --- a/app/dependencies/services.py +++ b/app/dependencies/services.py @@ -7,9 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.database.connection import session_factory -from core.redis import RedisService -from dependencies.redis_services import get_confirmation_code_redis_service +from dependencies.redis_services import get_auth_redis_service from services import GenreService, MovieService, ReviewService, UserService +from services.auth import AuthService from services.favorite_movie import FavoriteMovieService from services.http_request import HttpRequestService from services.watch_history import WatchHistoryService @@ -95,15 +95,9 @@ async def get_user_service( AsyncSession, Depends(get_db), ], - redis_service: Annotated[ - RedisService, - Depends( - get_confirmation_code_redis_service, - ), - ], ) -> AsyncGenerator[UserService]: try: - user_service = UserService(session, redis_service) + user_service = UserService(session) yield user_service finally: """ @@ -111,6 +105,25 @@ async def get_user_service( """ +async def get_auth_service( + user_service: Annotated[ + UserService, + Depends(get_user_service), + ], + auth_redis_service: Annotated[ + AuthService, + Depends(get_auth_redis_service), + ], +) -> AsyncGenerator[AuthService]: + try: + auth_service = AuthService(user_service, auth_redis_service) + yield auth_service + finally: + """ + Действия после view. + """ + + async def get_favorite_movie_service( session: Annotated[ AsyncSession, diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..78e2d2d --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,295 @@ +import random + +from packages.celery.constants import Queue, TaskType +from pydantic import EmailStr + +from core.celery.celery_app import app +from core.config import settings +from core.constants import BEARER_TOKEN_TYPE, EMAIL_FIELD, LOGIN_FIELD +from core.exceptions.auth import InvalidPasswordError +from core.exceptions.confirmation_code import ( + EmailConfirmationCodeNotFoundError, + InvalidEmailConfirmationCodeError, +) +from core.exceptions.user import ( + UserEmailAlreadyExistsError, + UserLoginAlreadyExistsError, +) +from core.redis import RedisService +from core.schema_utils import convert_registration_to_create_schema +from core.security.jwt.token_factory import ( + create_access_token, + create_auth_token, + create_recover_token, + create_registration_token, + create_reset_password_token, + create_two_factor_token, +) +from core.security.jwt.utils import decode_jwt +from core.security.password_utils import verify_password +from schemas.auth import ( + RecoverAccountRequest, + ResetPasswordRequest, + SendConfirmationCodeRequest, + UserLogin, + VerifyUserEmail, +) +from schemas.token_info import TemporaryTokenInfo, TokenInfo +from schemas.user import UserCreate, UserPartialUpdate, UserRegistration +from services import UserService + + +class AuthService: + def __init__( + self, + user_service: UserService, + auth_redis_service: RedisService, + ) -> None: + self.user_service = user_service + self.auth_redis_service = auth_redis_service + + async def register_user( + self, + user_registration_data: UserRegistration, + ) -> TemporaryTokenInfo: + if await self.user_service.user_login_exists(user_registration_data.login): + raise UserLoginAlreadyExistsError(user_registration_data.login) + + if await self.user_service.user_email_exists(user_registration_data.email): + raise UserEmailAlreadyExistsError(user_registration_data.email) + user_create_data = convert_registration_to_create_schema( + user_registration_data, + ) + token = create_registration_token(user_registration_data) + ttl_seconds = settings.confirmation_jwt.registration_token_expire_minutes * 60 + await self.auth_redis_service.set( + key=f"registration:{token}", + value=user_create_data.model_dump_json(), + ttl=ttl_seconds, + ) + send_confirmation_code_request = SendConfirmationCodeRequest( + token=token, + ) + await self.send_register_confirmation_code(send_confirmation_code_request) + return TemporaryTokenInfo( + token=token, + token_type=BEARER_TOKEN_TYPE, + ) + + async def send_register_confirmation_code( + self, + send_confirmation_code_request: SendConfirmationCodeRequest, + ) -> None: + token = send_confirmation_code_request.token + payload = decode_jwt( + token=token, + secret_key=settings.confirmation_jwt.secret_key, + algorithm=settings.confirmation_jwt.algorithm, + ) + email = payload[EMAIL_FIELD] + confirmation_code = await self.create_confirmation_code(email) + app.send_task( + name=TaskType.send_confirm_registration_email.value, + args=[ + email, + confirmation_code, + ], + queue=Queue.notification.value, + ) + + async def verify_register_user( + self, + verify_register_user_data: VerifyUserEmail, + ) -> TokenInfo: + token = verify_register_user_data.token + confirmation_code = verify_register_user_data.confirmation_code + payload = decode_jwt( + token, + secret_key=settings.confirmation_jwt.secret_key, + algorithm=settings.confirmation_jwt.algorithm, + ) + email = payload[EMAIL_FIELD] + await self.verify_confirmation_code( + email, + confirmation_code, + ) + user_data_create_json = await self.auth_redis_service.get( + key=f"registration:{token}", + ) + user_create_data = UserCreate.model_validate_json(user_data_create_json) + user = await self.user_service.create_user(user_create_data) + return create_auth_token(user) + + async def verify_login_data(self, login_data: UserLogin) -> None: + encrypted_password = await self.user_service.get_user_encrypted_password( + login_data.login, + ) + if not verify_password(login_data.password, encrypted_password): + raise InvalidPasswordError + + async def authenticate_user(self, login_data: UserLogin) -> TemporaryTokenInfo: + user = await self.user_service.get_user_by_login(login_data.login) + await self.verify_login_data(login_data) + token = create_two_factor_token(user) + send_confirmation_code_request = SendConfirmationCodeRequest( + token=token, + ) + await self.send_authenticate_confirmation_code(send_confirmation_code_request) + return TemporaryTokenInfo( + token=token, + token_type=BEARER_TOKEN_TYPE, + ) + + async def send_authenticate_confirmation_code( + self, + send_confirmation_code_request: SendConfirmationCodeRequest, + ) -> None: + token = send_confirmation_code_request.token + payload = decode_jwt( + token=token, + secret_key=settings.confirmation_jwt.secret_key, + algorithm=settings.confirmation_jwt.algorithm, + ) + email = payload[EMAIL_FIELD] + confirmation_code = await self.create_confirmation_code(email) + app.send_task( + name=TaskType.send_confirm_login_email.value, + args=[ + email, + confirmation_code, + ], + queue=Queue.notification.value, + ) + + async def verify_authenticate_user( + self, + verify_authenticate_user_data: VerifyUserEmail, + ) -> TokenInfo: + token = verify_authenticate_user_data.token + confirmation_code = verify_authenticate_user_data.confirmation_code + payload = decode_jwt( + token, + secret_key=settings.confirmation_jwt.secret_key, + algorithm=settings.confirmation_jwt.algorithm, + ) + email = payload[EMAIL_FIELD] + await self.verify_confirmation_code( + email, + confirmation_code, + ) + user = await self.user_service.get_user_by_email(email) + return create_auth_token(user) + + async def get_confirmation_code(self, email: EmailStr) -> str: + confirmation_code = await self.auth_redis_service.get(f"auth:email:{email}") + if confirmation_code is None: + raise EmailConfirmationCodeNotFoundError( + email=email, + ) + return confirmation_code + + async def verify_confirmation_code( + self, + email: EmailStr, + confirmation_code: str, + ) -> None: + sent_confirmation_code = await self.get_confirmation_code(email) + if confirmation_code != sent_confirmation_code: + raise InvalidEmailConfirmationCodeError( + email=email, + confirmation_code=confirmation_code, + ) + + async def create_confirmation_code(self, email: EmailStr) -> str: + confirmation_code = "".join([str(random.randint(0, 9)) for _ in range(6)]) + await self.auth_redis_service.set( + key=f"auth:email:{email}", + value=confirmation_code, + ttl=60, + ) + return confirmation_code + + async def recover_account( + self, + recover_account_data: RecoverAccountRequest, + ) -> TemporaryTokenInfo: + email = recover_account_data.email + user = await self.user_service.get_user_by_email(email) + token = create_recover_token(user) + send_confirmation_code_request = SendConfirmationCodeRequest( + token=token, + ) + await self.send_recover_account_confirmation_code( + send_confirmation_code_request, + ) + return TemporaryTokenInfo( + token=token, + token_type=BEARER_TOKEN_TYPE, + ) + + async def send_recover_account_confirmation_code( + self, + send_confirmation_code_request: SendConfirmationCodeRequest, + ) -> None: + token = send_confirmation_code_request.token + payload = decode_jwt( + token, + secret_key=settings.confirmation_jwt.secret_key, + algorithm=settings.confirmation_jwt.algorithm, + ) + login = payload[LOGIN_FIELD] + email = payload[EMAIL_FIELD] + confirmation_code = await self.create_confirmation_code(email) + app.send_task( + name=TaskType.send_reset_password_email_data.value, + args=[ + login, + email, + confirmation_code, + ], + queue=Queue.notification.value, + ) + + async def verify_recover_account( + self, + verify_recover_account_data: VerifyUserEmail, + ) -> TemporaryTokenInfo: + token = verify_recover_account_data.token + confirmation_code = verify_recover_account_data.confirmation_code + payload = decode_jwt( + token, + secret_key=settings.confirmation_jwt.secret_key, + algorithm=settings.confirmation_jwt.algorithm, + ) + email = payload[EMAIL_FIELD] + await self.verify_confirmation_code(email, confirmation_code) + user = await self.user_service.get_user_by_email(email) + reset_password_token = create_reset_password_token(user) + return TemporaryTokenInfo( + token=reset_password_token, + token_type=BEARER_TOKEN_TYPE, + ) + + async def reset_password(self, reset_password_data: ResetPasswordRequest) -> None: + token = reset_password_data.reset_password_token + password = reset_password_data.password + password_confirmation = reset_password_data.password_confirmation + if password != password_confirmation: + raise InvalidPasswordError + + payload = decode_jwt( + token, + secret_key=settings.confirmation_jwt.secret_key, + algorithm=settings.confirmation_jwt.algorithm, + ) + email = payload[EMAIL_FIELD] + user = await self.user_service.get_user_by_email(email) + user_partial_update_data = UserPartialUpdate( + password=reset_password_data.password, + ) + await self.user_service.partial_update_user(user.id, user_partial_update_data) + + async def refresh_access_token(self, user_id: int) -> TokenInfo: + user = await self.user_service.get_user_by_id(user_id) + access_token = create_access_token(user) + return TokenInfo(access_token=access_token) diff --git a/app/services/user.py b/app/services/user.py index b61dd70..c8e6d10 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -1,4 +1,3 @@ -import random from typing import cast from packages.celery.constants import Queue, TaskType @@ -6,18 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.celery.celery_app import app -from core.config import settings -from core.constants import ( - BEARER_TOKEN_TYPE, - EMAIL_FIELD, - LOGIN_FIELD, - UserRole, -) -from core.exceptions.auth import InvalidPasswordError -from core.exceptions.confirmation_code import ( - EmailConfirmationCodeNotFoundError, - InvalidEmailConfirmationCodeError, -) +from core.constants import UserRole from core.exceptions.user import ( UserEmailAlreadyExistsError, UserEmailNotFoundError, @@ -25,32 +13,11 @@ UserLoginAlreadyExistsError, UserLoginNotFoundError, ) -from core.redis import RedisService -from core.security.jwt.token_factory import ( - create_access_token, - create_recover_token, - create_refresh_token, - create_registration_token, - create_reset_password_token, - create_two_factor_token, -) -from core.security.jwt.utils import ( - decode_jwt, -) -from core.security.password_utils import hash_password, verify_password +from core.security.password_utils import hash_password from repositories import UserRepository -from schemas.auth import ( - RecoverAccountRequest, - ResetPasswordRequest, - SendConfirmationCodeRequest, - UserLogin, - VerifyUserEmail, -) -from schemas.token_info import TemporaryTokenInfo, TokenInfo from schemas.user import ( UserCreate, UserPartialUpdate, - UserRegistration, UserResponse, UserResponseList, UserUpdate, @@ -61,11 +28,9 @@ class UserService: def __init__( self, session: AsyncSession, - redis_service: RedisService | None = None, ) -> None: self.session = session self.user_repository = UserRepository(session) - self.redis_service = redis_service async def get_user_by_id(self, user_id: int) -> UserResponse: user = await self.user_repository.get_user_by_id(user_id) @@ -90,6 +55,9 @@ async def get_user_by_email(self, email: EmailStr) -> UserResponse: async def user_login_exists(self, login: str) -> bool: return await self.user_repository.user_login_exists(login) + async def user_email_exists(self, email: str) -> bool: + return await self.user_repository.user_email_exists(email) + async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList: users = [ UserResponse.model_validate(user) @@ -101,48 +69,13 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList page=page, ) - @staticmethod - def convert_registration_to_create_schema( - user_registration_data: UserRegistration, - ) -> UserCreate: - user_create_data = user_registration_data.model_dump( - exclude={"confirmation_code", "password"}, - ) - password = user_registration_data.password - encrypted_password = hash_password(password) - user_create_data["encrypted_password"] = encrypted_password - return UserCreate(**user_create_data) - - async def register_user( - self, - registration_user_data: UserRegistration, - ) -> TemporaryTokenInfo: - if await self.user_repository.user_login_exists(registration_user_data.login): - raise UserLoginAlreadyExistsError(registration_user_data.login) - - if await self.user_repository.user_email_exists(registration_user_data.email): - raise UserEmailAlreadyExistsError(registration_user_data.email) - - create_user_data = self.convert_registration_to_create_schema( - registration_user_data, - ) - temporary_token = create_registration_token(registration_user_data) - ttl_seconds = ( - settings.confirmation_code_jwt.registration_token_expire_minutes * 60 - ) - await self.redis_service.set( - key=f"{temporary_token}", - value=create_user_data.model_dump_json(), - ttl=ttl_seconds, - ) - send_confirmation_code_request = SendConfirmationCodeRequest( - token=temporary_token, - ) - await self.send_register_confirmation_code(send_confirmation_code_request) - return TemporaryTokenInfo( - token=temporary_token, - token_type=BEARER_TOKEN_TYPE, - ) + async def get_user_encrypted_password(self, login: str) -> str: + """ + Метод получения зашифрованного пароля пользователя + должен использоваться строго внутри монолита. + """ + user = await self.user_repository.get_user_by_login(login) + return user.encrypted_password async def create_user(self, user: UserCreate) -> UserResponse: if await self.user_repository.user_login_exists(user.login): @@ -162,228 +95,6 @@ async def create_user(self, user: UserCreate) -> UserResponse: ) return UserResponse.model_validate(user) - async def send_register_confirmation_code( - self, - send_confirmation_code_request: SendConfirmationCodeRequest, - ) -> None: - token = send_confirmation_code_request.token - payload = decode_jwt( - token=token, - secret_key=settings.confirmation_code_jwt.secret_key, - algorithm=settings.confirmation_code_jwt.algorithm, - ) - email = payload[EMAIL_FIELD] - confirmation_code = await self.create_confirmation_code(email) - app.send_task( - name=TaskType.send_confirm_registration_email.value, - args=[ - email, - confirmation_code, - ], - queue=Queue.notification.value, - ) - - async def verify_register_user( - self, - verify_register_user_data: VerifyUserEmail, - ) -> TokenInfo: - token = verify_register_user_data.token - confirmation_code = verify_register_user_data.confirmation_code - payload = decode_jwt( - token, - secret_key=settings.confirmation_code_jwt.secret_key, - algorithm=settings.confirmation_code_jwt.algorithm, - ) - email = payload[EMAIL_FIELD] - await self.verify_confirmation_code( - email, - confirmation_code, - ) - user_data_create_json = await self.redis_service.get(key=f"{token}") - user_data_create = UserCreate.model_validate_json(user_data_create_json) - user = await self.create_user(user_data_create) - access_token = create_access_token(user) - refresh_token = create_refresh_token(user) - return TokenInfo( - access_token=access_token, - refresh_token=refresh_token, - token_type=BEARER_TOKEN_TYPE, - ) - - async def authenticate_user(self, login_data: UserLogin) -> TemporaryTokenInfo: - user = await self.user_repository.get_user_by_login(login_data.login) - if user is None: - raise UserLoginNotFoundError(login_data.login) - - if not verify_password(login_data.password, user.encrypted_password): - raise InvalidPasswordError - - user = UserResponse.model_validate(user) - token = create_two_factor_token(user) - send_confirmation_code_request = SendConfirmationCodeRequest( - token=token, - ) - await self.send_authenticate_confirmation_code(send_confirmation_code_request) - return TemporaryTokenInfo( - token=token, - token_type=BEARER_TOKEN_TYPE, - ) - - async def send_authenticate_confirmation_code( - self, - send_confirmation_code_request: SendConfirmationCodeRequest, - ) -> None: - token = send_confirmation_code_request.token - payload = decode_jwt( - token=token, - secret_key=settings.confirmation_code_jwt.secret_key, - algorithm=settings.confirmation_code_jwt.algorithm, - ) - email = payload[EMAIL_FIELD] - confirmation_code = await self.create_confirmation_code(email) - app.send_task( - name=TaskType.send_confirm_login_email.value, - args=[ - email, - confirmation_code, - ], - queue=Queue.notification.value, - ) - - async def verify_authenticate_user( - self, - verify_authenticate_user_data: VerifyUserEmail, - ) -> TokenInfo: - token = verify_authenticate_user_data.token - confirmation_code = verify_authenticate_user_data.confirmation_code - payload = decode_jwt( - token, - secret_key=settings.confirmation_code_jwt.secret_key, - algorithm=settings.confirmation_code_jwt.algorithm, - ) - email = payload[EMAIL_FIELD] - await self.verify_confirmation_code( - email, - confirmation_code, - ) - user = await self.get_user_by_email(email) - access_token = create_access_token(user) - refresh_token = create_refresh_token(user) - return TokenInfo( - access_token=access_token, - refresh_token=refresh_token, - token_type=BEARER_TOKEN_TYPE, - ) - - async def get_confirmation_code(self, email: EmailStr) -> str: - confirmation_code = await self.redis_service.get(f"auth:email:{email}") - if confirmation_code is None: - raise EmailConfirmationCodeNotFoundError( - email=email, - ) - return confirmation_code - - async def verify_confirmation_code( - self, - email: EmailStr, - confirmation_code: str, - ) -> None: - sent_confirmation_code = await self.get_confirmation_code(email) - if confirmation_code != sent_confirmation_code: - raise InvalidEmailConfirmationCodeError( - email=email, - confirmation_code=confirmation_code, - ) - - async def create_confirmation_code(self, email: EmailStr) -> str: - confirmation_code = "".join([str(random.randint(0, 9)) for _ in range(6)]) - await self.redis_service.set( - key=f"auth:email:{email}", - value=confirmation_code, - ttl=60, - ) - return confirmation_code - - async def recover_account( - self, - recover_account_data: RecoverAccountRequest, - ) -> TemporaryTokenInfo: - email = recover_account_data.email - user = await self.get_user_by_email(email) - token = create_recover_token(user) - send_confirmation_code_request = SendConfirmationCodeRequest( - token=token, - ) - await self.send_recover_account_confirmation_code( - send_confirmation_code_request, - ) - return TemporaryTokenInfo( - token=token, - token_type=BEARER_TOKEN_TYPE, - ) - - async def send_recover_account_confirmation_code( - self, - send_confirmation_code_request: SendConfirmationCodeRequest, - ) -> None: - token = send_confirmation_code_request.token - payload = decode_jwt( - token, - secret_key=settings.confirmation_code_jwt.secret_key, - algorithm=settings.confirmation_code_jwt.algorithm, - ) - login = payload[LOGIN_FIELD] - email = payload[EMAIL_FIELD] - confirmation_code = await self.create_confirmation_code(email) - app.send_task( - name=TaskType.send_reset_password_email_data.value, - args=[ - login, - email, - confirmation_code, - ], - queue=Queue.notification.value, - ) - - async def verify_recover_account( - self, - verify_recover_account_data: VerifyUserEmail, - ) -> TemporaryTokenInfo: - token = verify_recover_account_data.token - confirmation_code = verify_recover_account_data.confirmation_code - payload = decode_jwt( - token, - secret_key=settings.confirmation_code_jwt.secret_key, - algorithm=settings.confirmation_code_jwt.algorithm, - ) - email = payload[EMAIL_FIELD] - await self.verify_confirmation_code(email, confirmation_code) - user = await self.get_user_by_email(email) - reset_password_token = create_reset_password_token(user) - return TemporaryTokenInfo( - token=reset_password_token, - token_type=BEARER_TOKEN_TYPE, - ) - - async def reset_password(self, reset_password_data: ResetPasswordRequest) -> None: - token = reset_password_data.reset_password_token - password = reset_password_data.password - password_confirmation = reset_password_data.password_confirmation - if password != password_confirmation: - raise InvalidPasswordError - - payload = decode_jwt( - token, - secret_key=settings.confirmation_code_jwt.secret_key, - algorithm=settings.confirmation_code_jwt.algorithm, - ) - email = payload[EMAIL_FIELD] - user = await self.get_user_by_email(email) - user_partial_update_data = UserPartialUpdate( - password=reset_password_data.password, - ) - await self.partial_update_user(user.id, user_partial_update_data) - async def update_user( self, user_id: int, @@ -462,8 +173,3 @@ async def is_admin(self, user_id: int) -> bool: async def make_admin(self, user_id: int) -> None: if not await self.user_repository.make_admin(user_id): raise UserIdNotFoundError(user_id) - - async def refresh_access_token(self, user_id: int) -> TokenInfo: - user = await self.get_user_by_id(user_id) - access_token = create_access_token(user) - return TokenInfo(access_token=access_token) From 2ee4f962e1b938b0a66c37c2e2f470fedefc5a45 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Mon, 22 Jun 2026 22:13:38 +0300 Subject: [PATCH 32/47] Rename methods, refactor settings object. --- app/alembic/env.py | 2 +- app/core/config.py | 40 +++++++++++++++++++------ app/core/database/connection.py | 2 +- app/core/security/jwt/token_factory.py | 34 ++++++++++----------- app/core/security/jwt/utils.py | 12 ++++---- app/dependencies/services.py | 3 +- app/services/auth.py | 36 +++++++++++----------- app/services/user.py | 2 ++ mediaservice/core/config.py | 2 +- mediaservice/core/minio/utils.py | 2 +- mediaservice/core/rabbitmq/consumers.py | 2 +- mediaservice/dependencies.py | 2 +- 12 files changed, 82 insertions(+), 57 deletions(-) diff --git a/app/alembic/env.py b/app/alembic/env.py index 5455d7a..03deec2 100644 --- a/app/alembic/env.py +++ b/app/alembic/env.py @@ -32,7 +32,7 @@ # ... etc. config.set_main_option( "sqlalchemy.url", - settings.database.url_database, + settings.database.url, ) diff --git a/app/core/config.py b/app/core/config.py index 2989bf0..5d564b1 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -15,7 +15,7 @@ class DataBaseConfig(BaseModel): echo: bool = False @property - def url_database(self) -> str: + def url(self) -> str: return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.db_name}" @@ -47,7 +47,7 @@ class RabbitMQConfig(BaseModel): password: str = "guest" @property - def url_rabbitmq(self) -> str: + def url(self) -> str: return f"amqp://{self.user}:{self.password}@{self.host}:{self.port}/" @@ -58,13 +58,36 @@ class AuthJWTConfig(BaseModel): refresh_token_expire_minutes: int = 30 * 24 * 60 -class ConfirmationCodeJWTConfig(BaseModel): +class RegistrationJWTConfig(BaseModel): secret_key: str = "secret_key" algorithm: str = "HS256" - registration_token_expire_minutes: int = 15 - two_factor_token_expire_minutes: int = 15 - recover_token_expire_minutes: int = 15 - reset_password_token_expire_minutes: int = 15 + expire_minutes: int = 15 + + +class TwoFactorJWTConfig(BaseModel): + secret_key: str = "secret_key" + algorithm: str = "HS256" + expire_minutes: int = 15 + + +class RecoverJWTConfig(BaseModel): + secret_key: str = "secret_key" + algorithm: str = "HS256" + expire_minutes: int = 15 + + +class ResetPasswordJWTConfig(BaseModel): + secret_key: str = "secret_key" + algorithm: str = "HS256" + expire_minutes: int = 15 + + +class JWTConfig(BaseModel): + auth: AuthJWTConfig = AuthJWTConfig() + registration: RegistrationJWTConfig = RegistrationJWTConfig() + two_factor_auth: TwoFactorJWTConfig = TwoFactorJWTConfig() + recover: RecoverJWTConfig = RecoverJWTConfig() + reset_password: ResetPasswordJWTConfig = ResetPasswordJWTConfig() class MediaServiceConfig(BaseModel): @@ -90,8 +113,7 @@ class Settings(BaseSettings): database: DataBaseConfig = DataBaseConfig() redis: RedisConfig = RedisConfig() rabbitmq: RabbitMQConfig = RabbitMQConfig() - auth_jwt: AuthJWTConfig = AuthJWTConfig() - confirmation_jwt: ConfirmationCodeJWTConfig = ConfirmationCodeJWTConfig() + jwt: JWTConfig = JWTConfig() http_bearer: HTTPBearer = HTTPBearer() oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer( "/api/v1/auth/login/", diff --git a/app/core/database/connection.py b/app/core/database/connection.py index 4039b74..04c7a87 100644 --- a/app/core/database/connection.py +++ b/app/core/database/connection.py @@ -14,7 +14,7 @@ class Base(DeclarativeBase): engine = create_async_engine( - url=settings.database.url_database, + url=settings.database.url, echo=settings.database.echo, ) diff --git a/app/core/security/jwt/token_factory.py b/app/core/security/jwt/token_factory.py index aa84107..edd32e6 100644 --- a/app/core/security/jwt/token_factory.py +++ b/app/core/security/jwt/token_factory.py @@ -15,7 +15,7 @@ create_refresh_token_payload, create_registration_token_payload, create_reset_password_token_payload, - create_two_factor_token_payload, + create_two_factor_auth_token_payload, encode_jwt, ) from schemas.token_info import TokenInfo @@ -29,7 +29,7 @@ def create_access_token(user: UserResponse) -> str: ) return encode_jwt( payload, - expires_minutes=settings.auth_jwt.access_token_expire_minutes, + expires_minutes=settings.jwt.auth.access_token_expire_minutes, ) @@ -40,7 +40,7 @@ def create_refresh_token(user: UserResponse) -> str: ) return encode_jwt( payload, - expires_minutes=settings.auth_jwt.refresh_token_expire_minutes, + expires_minutes=settings.jwt.auth.refresh_token_expire_minutes, ) @@ -61,22 +61,22 @@ def create_registration_token(user: UserRegistration) -> str: ) return encode_jwt( payload=payload, - secret_key=settings.confirmation_jwt.secret_key, - algorithm=settings.confirmation_jwt.algorithm, - expires_minutes=settings.confirmation_jwt.registration_token_expire_minutes, + secret_key=settings.jwt.registration.secret_key, + algorithm=settings.jwt.registration.algorithm, + expires_minutes=settings.jwt.registration.expire_minutes, ) -def create_two_factor_token(user: UserResponse) -> str: - payload = create_two_factor_token_payload(user) +def create_two_factor_auth_token(user: UserResponse) -> str: + payload = create_two_factor_auth_token_payload(user) payload.update( {TOKEN_TYPE_FIELD: TWO_FACTOR_TOKEN_TYPE}, ) return encode_jwt( payload=payload, - secret_key=settings.confirmation_jwt.secret_key, - algorithm=settings.confirmation_jwt.algorithm, - expires_minutes=settings.confirmation_jwt.two_factor_token_expire_minutes, + secret_key=settings.jwt.two_factor_auth.secret_key, + algorithm=settings.jwt.two_factor_auth.algorithm, + expires_minutes=settings.jwt.two_factor_auth.expire_minutes, ) @@ -87,9 +87,9 @@ def create_recover_token(user: UserResponse) -> str: ) return encode_jwt( payload=payload, - secret_key=settings.confirmation_jwt.secret_key, - algorithm=settings.confirmation_jwt.algorithm, - expires_minutes=settings.confirmation_jwt.recover_token_expire_minutes, + secret_key=settings.jwt.recover.secret_key, + algorithm=settings.jwt.recover.algorithm, + expires_minutes=settings.jwt.recover.expire_minutes, ) @@ -100,7 +100,7 @@ def create_reset_password_token(user: UserResponse) -> str: ) return encode_jwt( payload=payload, - secret_key=settings.confirmation_jwt.secret_key, - algorithm=settings.confirmation_jwt.algorithm, - expires_minutes=settings.confirmation_jwt.reset_password_token_expire_minutes, + secret_key=settings.jwt.reset_password.secret_key, + algorithm=settings.jwt.reset_password.algorithm, + expires_minutes=settings.jwt.reset_password.expire_minutes, ) diff --git a/app/core/security/jwt/utils.py b/app/core/security/jwt/utils.py index 07242ca..6b9ddda 100644 --- a/app/core/security/jwt/utils.py +++ b/app/core/security/jwt/utils.py @@ -13,9 +13,9 @@ def encode_jwt( payload: dict[str, Any], - secret_key: str = settings.auth_jwt.secret_key, - algorithm: str = settings.auth_jwt.algorithm, - expires_minutes: int = settings.auth_jwt.access_token_expire_minutes, + secret_key: str = settings.jwt.auth.secret_key, + algorithm: str = settings.jwt.auth.algorithm, + expires_minutes: int = settings.jwt.auth.access_token_expire_minutes, ) -> str: to_encode = payload.copy() now = datetime.now(UTC) @@ -33,8 +33,8 @@ def encode_jwt( def decode_jwt( token: str, - secret_key: str = settings.auth_jwt.secret_key, - algorithm: str = settings.auth_jwt.algorithm, + secret_key: str = settings.jwt.auth.secret_key, + algorithm: str = settings.jwt.auth.algorithm, ) -> dict[str, Any]: return jwt.decode( token, @@ -70,7 +70,7 @@ def create_registration_token_payload( return payload -def create_two_factor_token_payload( +def create_two_factor_auth_token_payload( user: UserResponse, ) -> dict[str, str]: payload = { diff --git a/app/dependencies/services.py b/app/dependencies/services.py index 2e44981..88b4092 100644 --- a/app/dependencies/services.py +++ b/app/dependencies/services.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.database.connection import session_factory +from core.redis import RedisService from dependencies.redis_services import get_auth_redis_service from services import GenreService, MovieService, ReviewService, UserService from services.auth import AuthService @@ -111,7 +112,7 @@ async def get_auth_service( Depends(get_user_service), ], auth_redis_service: Annotated[ - AuthService, + RedisService, Depends(get_auth_redis_service), ], ) -> AsyncGenerator[AuthService]: diff --git a/app/services/auth.py b/app/services/auth.py index 78e2d2d..b076e61 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -23,7 +23,7 @@ create_recover_token, create_registration_token, create_reset_password_token, - create_two_factor_token, + create_two_factor_auth_token, ) from core.security.jwt.utils import decode_jwt from core.security.password_utils import verify_password @@ -61,7 +61,7 @@ async def register_user( user_registration_data, ) token = create_registration_token(user_registration_data) - ttl_seconds = settings.confirmation_jwt.registration_token_expire_minutes * 60 + ttl_seconds = settings.jwt.registration.expire_minutes * 60 await self.auth_redis_service.set( key=f"registration:{token}", value=user_create_data.model_dump_json(), @@ -83,8 +83,8 @@ async def send_register_confirmation_code( token = send_confirmation_code_request.token payload = decode_jwt( token=token, - secret_key=settings.confirmation_jwt.secret_key, - algorithm=settings.confirmation_jwt.algorithm, + secret_key=settings.jwt.registration.secret_key, + algorithm=settings.jwt.registration.algorithm, ) email = payload[EMAIL_FIELD] confirmation_code = await self.create_confirmation_code(email) @@ -105,8 +105,8 @@ async def verify_register_user( confirmation_code = verify_register_user_data.confirmation_code payload = decode_jwt( token, - secret_key=settings.confirmation_jwt.secret_key, - algorithm=settings.confirmation_jwt.algorithm, + secret_key=settings.jwt.registration.secret_key, + algorithm=settings.jwt.registration.algorithm, ) email = payload[EMAIL_FIELD] await self.verify_confirmation_code( @@ -128,9 +128,9 @@ async def verify_login_data(self, login_data: UserLogin) -> None: raise InvalidPasswordError async def authenticate_user(self, login_data: UserLogin) -> TemporaryTokenInfo: - user = await self.user_service.get_user_by_login(login_data.login) await self.verify_login_data(login_data) - token = create_two_factor_token(user) + user = await self.user_service.get_user_by_login(login_data.login) + token = create_two_factor_auth_token(user) send_confirmation_code_request = SendConfirmationCodeRequest( token=token, ) @@ -147,8 +147,8 @@ async def send_authenticate_confirmation_code( token = send_confirmation_code_request.token payload = decode_jwt( token=token, - secret_key=settings.confirmation_jwt.secret_key, - algorithm=settings.confirmation_jwt.algorithm, + secret_key=settings.jwt.two_factor_auth.secret_key, + algorithm=settings.jwt.two_factor_auth.algorithm, ) email = payload[EMAIL_FIELD] confirmation_code = await self.create_confirmation_code(email) @@ -169,8 +169,8 @@ async def verify_authenticate_user( confirmation_code = verify_authenticate_user_data.confirmation_code payload = decode_jwt( token, - secret_key=settings.confirmation_jwt.secret_key, - algorithm=settings.confirmation_jwt.algorithm, + secret_key=settings.jwt.two_factor_auth.secret_key, + algorithm=settings.jwt.two_factor_auth.algorithm, ) email = payload[EMAIL_FIELD] await self.verify_confirmation_code( @@ -234,8 +234,8 @@ async def send_recover_account_confirmation_code( token = send_confirmation_code_request.token payload = decode_jwt( token, - secret_key=settings.confirmation_jwt.secret_key, - algorithm=settings.confirmation_jwt.algorithm, + secret_key=settings.jwt.recover.secret_key, + algorithm=settings.jwt.recover.algorithm, ) login = payload[LOGIN_FIELD] email = payload[EMAIL_FIELD] @@ -258,8 +258,8 @@ async def verify_recover_account( confirmation_code = verify_recover_account_data.confirmation_code payload = decode_jwt( token, - secret_key=settings.confirmation_jwt.secret_key, - algorithm=settings.confirmation_jwt.algorithm, + secret_key=settings.jwt.recover.secret_key, + algorithm=settings.jwt.recover.algorithm, ) email = payload[EMAIL_FIELD] await self.verify_confirmation_code(email, confirmation_code) @@ -279,8 +279,8 @@ async def reset_password(self, reset_password_data: ResetPasswordRequest) -> Non payload = decode_jwt( token, - secret_key=settings.confirmation_jwt.secret_key, - algorithm=settings.confirmation_jwt.algorithm, + secret_key=settings.jwt.reset_password.secret_key, + algorithm=settings.jwt.reset_password.algorithm, ) email = payload[EMAIL_FIELD] user = await self.user_service.get_user_by_email(email) diff --git a/app/services/user.py b/app/services/user.py index c8e6d10..505dc63 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -75,6 +75,8 @@ async def get_user_encrypted_password(self, login: str) -> str: должен использоваться строго внутри монолита. """ user = await self.user_repository.get_user_by_login(login) + if user is None: + raise UserLoginNotFoundError(login) return user.encrypted_password async def create_user(self, user: UserCreate) -> UserResponse: diff --git a/mediaservice/core/config.py b/mediaservice/core/config.py index af96a1a..97dd042 100644 --- a/mediaservice/core/config.py +++ b/mediaservice/core/config.py @@ -14,7 +14,7 @@ class MinioConfig(BaseModel): temporary_prefix: str = "tmp/" @property - def url_minio(self) -> str: + def url(self) -> str: return f"http://{self.host}:{self.port}" diff --git a/mediaservice/core/minio/utils.py b/mediaservice/core/minio/utils.py index 0c20a14..7d6f40b 100644 --- a/mediaservice/core/minio/utils.py +++ b/mediaservice/core/minio/utils.py @@ -12,7 +12,7 @@ @asynccontextmanager async def get_s3_client( service_name: str = "s3", - endpoint_url: str = settings.minio.url_minio, + endpoint_url: str = settings.minio.url, aws_access_key_id: str = settings.minio.access_key, aws_secret_access_key: str = settings.minio.secret_key, ) -> AsyncGenerator[S3Client]: diff --git a/mediaservice/core/rabbitmq/consumers.py b/mediaservice/core/rabbitmq/consumers.py index 1a6cd5c..a28bb1f 100644 --- a/mediaservice/core/rabbitmq/consumers.py +++ b/mediaservice/core/rabbitmq/consumers.py @@ -18,7 +18,7 @@ async def copy_file(message: IncomingMessage) -> None: settings.minio.temporary_prefix, "", ) - prefix_url = settings.minio.url_minio.replace("minio", "localhost") + prefix_url = settings.minio.url.replace("minio", "localhost") updated_object_url_list = [prefix_url, bucket_name, destination_object_name] updated_object_url = "/".join(updated_object_url_list) diff --git a/mediaservice/dependencies.py b/mediaservice/dependencies.py index b4914d5..ad0d3ce 100644 --- a/mediaservice/dependencies.py +++ b/mediaservice/dependencies.py @@ -19,7 +19,7 @@ async def get_client( ) -> AsyncGenerator[S3Client]: async with session.client( "s3", - endpoint_url=settings.minio.url_minio, + endpoint_url=settings.minio.url, aws_access_key_id=settings.minio.access_key, aws_secret_access_key=settings.minio.secret_key, ) as client: From 9c1e514b7761d60bcc0d29b32d4308ecb65c036d Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Tue, 23 Jun 2026 13:23:34 +0300 Subject: [PATCH 33/47] Change mediaservice -> media-service. --- .github/workflows/python-checks.yml | 4 +-- .gitignore | 2 +- .pre-commit-config.yaml | 6 ++-- app/cache_services/user.py | 8 ++--- app/core/config.py | 2 +- app/core/database/init_db.py | 6 ++-- app/core/rabbitmq/startup.py | 2 +- app/services/auth.py | 7 ++-- app/services/user.py | 12 +++---- docker-compose.yml | 33 ++++++++++--------- {mediaservice => media-service}/Dockerfile | 4 +-- .../api/__init__.py | 0 .../api/api_v1/__init__.py | 0 .../api/api_v1/file_views.py | 0 .../api/main_views.py | 0 .../core/__init__.py | 0 .../core/celery/__init__.py | 0 .../core/celery/celery_app.py | 0 .../core/celery/tasks.py | 0 .../core/config.py | 0 .../core/minio/__init__.py | 0 .../core/minio/client.py | 0 .../core/minio/connection.py | 0 .../core/minio/service.py | 0 .../core/minio/utils.py | 0 .../core/rabbitmq/__init__.py | 0 .../core/rabbitmq/consumers.py | 2 +- .../core/rabbitmq/startup.py | 0 .../dependencies.py | 0 {mediaservice => media-service}/lifespan.py | 0 {mediaservice => media-service}/main.py | 0 notification-service/core/celery/tasks.py | 6 ++-- packages/celery/constants.py | 6 ++-- packages/rabbitmq/constants.py | 12 +++---- pyproject.toml | 2 +- 35 files changed, 61 insertions(+), 53 deletions(-) rename {mediaservice => media-service}/Dockerfile (82%) rename {mediaservice => media-service}/api/__init__.py (100%) rename {mediaservice => media-service}/api/api_v1/__init__.py (100%) rename {mediaservice => media-service}/api/api_v1/file_views.py (100%) rename {mediaservice => media-service}/api/main_views.py (100%) rename {mediaservice => media-service}/core/__init__.py (100%) rename {mediaservice => media-service}/core/celery/__init__.py (100%) rename {mediaservice => media-service}/core/celery/celery_app.py (100%) rename {mediaservice => media-service}/core/celery/tasks.py (100%) rename {mediaservice => media-service}/core/config.py (100%) rename {mediaservice => media-service}/core/minio/__init__.py (100%) rename {mediaservice => media-service}/core/minio/client.py (100%) rename {mediaservice => media-service}/core/minio/connection.py (100%) rename {mediaservice => media-service}/core/minio/service.py (100%) rename {mediaservice => media-service}/core/minio/utils.py (100%) rename {mediaservice => media-service}/core/rabbitmq/__init__.py (100%) rename {mediaservice => media-service}/core/rabbitmq/consumers.py (98%) rename {mediaservice => media-service}/core/rabbitmq/startup.py (100%) rename {mediaservice => media-service}/dependencies.py (100%) rename {mediaservice => media-service}/lifespan.py (100%) rename {mediaservice => media-service}/main.py (100%) diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index 394e308..a9b8eb0 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -34,8 +34,8 @@ jobs: - name: Run mypy app run: uv run mypy app - - name: Run mypy mediaservice - run: uv run mypy mediaservice + - name: Run mypy media-service + run: uv run mypy media-service - name: Run mypy notification-service run: uv run mypy notification-service diff --git a/.gitignore b/.gitignore index b81bf03..6fd8134 100644 --- a/.gitignore +++ b/.gitignore @@ -221,6 +221,6 @@ __marimo__/ .secrets config.local.yaml -mediaservice/.env +media-service/.env notification-service/.env .env.docker-compose \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6eeda7f..e97c812 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,11 +24,11 @@ repos: args: ["app"] - id: mypy - alias: mypy mediaservice - name: Run mypy mediaservice + alias: mypy media-service + name: Run mypy media-service language: system exclude: tests - args: ["mediaservice"] + args: ["media-service"] - id: mypy alias: mypy notification-service diff --git a/app/cache_services/user.py b/app/cache_services/user.py index 8fe2d3e..548c2ff 100644 --- a/app/cache_services/user.py +++ b/app/cache_services/user.py @@ -2,8 +2,8 @@ from core.redis.service import RedisService from schemas.user import ( + UserCreate, UserPartialUpdate, - UserRegistration, UserResponse, UserResponseList, UserUpdate, @@ -50,11 +50,11 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList await self.cache_service.set(key, users_response) return users_response - async def register_user( + async def create_user( self, - registration_user_data: UserRegistration, + user_create_data: UserCreate, ) -> UserResponse: - user_response = await self.user_service.register_user(registration_user_data) + user_response = await self.user_service.create_user(user_create_data) key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) diff --git a/app/core/config.py b/app/core/config.py index 5d564b1..d1e67a4 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -91,7 +91,7 @@ class JWTConfig(BaseModel): class MediaServiceConfig(BaseModel): - host: str = "mediaservice" + host: str = "media-service" port: int = 8000 @property diff --git a/app/core/database/init_db.py b/app/core/database/init_db.py index 2640c37..dd11ed8 100644 --- a/app/core/database/init_db.py +++ b/app/core/database/init_db.py @@ -1,4 +1,5 @@ from core.database import session_factory +from core.security.password_utils import hash_password from schemas.user import UserCreate from services import UserService @@ -7,13 +8,14 @@ async def init_admin() -> None: async with session_factory() as session: user_service = UserService(session) if not await user_service.user_login_exists("adminadmin"): + password = "adminadmin" # noqa: S105 create_user_data = UserCreate( surname="admin", name="admin", login="adminadmin", email="admin@admin.gmail.ru", - password="adminadmin", # noqa: S106 + encrypted_password=hash_password(password), ) - await user_service.register_user(create_user_data) + await user_service.create_user(create_user_data) admin = await user_service.get_user_by_login("adminadmin") await user_service.make_admin(admin.id) diff --git a/app/core/rabbitmq/startup.py b/app/core/rabbitmq/startup.py index 61d8b34..4303947 100644 --- a/app/core/rabbitmq/startup.py +++ b/app/core/rabbitmq/startup.py @@ -14,7 +14,7 @@ async def rabbitmq_consumer_queues_startup() -> AsyncGenerator[None]: async with get_rabbitmq_service() as rabbitmq_service: exchange = await rabbitmq_service.declare_exchange( - name=Exchange.mediaservice, + name=Exchange.media_service, type=ExchangeType.direct, durable=True, ) diff --git a/app/services/auth.py b/app/services/auth.py index b076e61..fbe0eca 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -1,4 +1,5 @@ import random +from typing import cast from packages.celery.constants import Queue, TaskType from pydantic import EmailStr @@ -186,7 +187,7 @@ async def get_confirmation_code(self, email: EmailStr) -> str: raise EmailConfirmationCodeNotFoundError( email=email, ) - return confirmation_code + return cast(str, confirmation_code) async def verify_confirmation_code( self, @@ -201,7 +202,9 @@ async def verify_confirmation_code( ) async def create_confirmation_code(self, email: EmailStr) -> str: - confirmation_code = "".join([str(random.randint(0, 9)) for _ in range(6)]) + confirmation_code = "".join( + [str(random.randint(0, 9)) for _ in range(6)], # noqa: S311 + ) await self.auth_redis_service.set( key=f"auth:email:{email}", value=confirmation_code, diff --git a/app/services/user.py b/app/services/user.py index 505dc63..72f10c7 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -79,14 +79,14 @@ async def get_user_encrypted_password(self, login: str) -> str: raise UserLoginNotFoundError(login) return user.encrypted_password - async def create_user(self, user: UserCreate) -> UserResponse: - if await self.user_repository.user_login_exists(user.login): - raise UserLoginAlreadyExistsError(user.login) + async def create_user(self, user_create_data: UserCreate) -> UserResponse: + if await self.user_repository.user_login_exists(user_create_data.login): + raise UserLoginAlreadyExistsError(user_create_data.login) - if await self.user_repository.user_email_exists(user.email): - raise UserEmailAlreadyExistsError(user.email) + if await self.user_repository.user_email_exists(user_create_data.email): + raise UserEmailAlreadyExistsError(user_create_data.email) - user = await self.user_repository.create_user(user) + user = await self.user_repository.create_user(user_create_data) app.send_task( name=TaskType.send_welcome_email.value, args=[ diff --git a/docker-compose.yml b/docker-compose.yml index e321137..31cc3e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,8 +14,11 @@ services: RABBITMQ__HOST: rabbitmq RABBITMQ__PORT: 5672 - MEDIASERVICE__HOST: mediaservice - MEDIASERVICE__PORT: 8000 + MEDIA_SERVICE__HOST: media-service + MEDIA_SERVICE__PORT: 8000 + + NOTIFICATION_SERVICE__HOST: notification-service + NOTIFICATION_SERVICE__PORT: 8000 ports: - "8000:8000" develop: @@ -31,7 +34,7 @@ services: condition: service_healthy redis: condition: service_healthy - mediaservice: + media-service: condition: service_healthy notification-service: condition: service_healthy @@ -42,11 +45,11 @@ services: timeout: 2s retries: 3 - mediaservice: + media-service: build: context: . - dockerfile: mediaservice/Dockerfile - container_name: mediaservice + dockerfile: media-service/Dockerfile + container_name: media-service environment: MINIO__HOST: minio MINIO__PORT: 9000 @@ -56,12 +59,12 @@ services: - "8001:8000" develop: watch: - - path: ./mediaservice + - path: media-service action: sync+restart - target: /mediaservice + target: /media-service - path: ./packages action: sync+restart - target: /mediaservice/packages + target: /media-service/packages healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ] start_period: 3s @@ -182,12 +185,12 @@ services: timeout: 2s retries: 5 - celery-worker-mediaservice: + celery-worker-media-service: build: context: . - dockerfile: mediaservice/Dockerfile - container_name: celery-worker-mediaservice - command: uv run celery --app core.celery.celery_app worker -Q mediaservice --loglevel=INFO + dockerfile: media-service/Dockerfile + container_name: celery-worker-media-service + command: uv run celery --app core.celery.celery_app worker -Q media-service --loglevel=INFO environment: MINIO__HOST: minio MINIO__PORT: 9000 @@ -195,9 +198,9 @@ services: MINIO__SECRET_KEY: adminadmin develop: watch: - - path: mediaservice + - path: media-service action: sync+restart - target: /mediaservice + target: /media-service depends_on: rabbitmq: condition: service_healthy diff --git a/mediaservice/Dockerfile b/media-service/Dockerfile similarity index 82% rename from mediaservice/Dockerfile rename to media-service/Dockerfile index b4e0ba9..595df2e 100644 --- a/mediaservice/Dockerfile +++ b/media-service/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.13-bookworm -WORKDIR /mediaservice +WORKDIR /media-service RUN pip install uv @@ -10,6 +10,6 @@ RUN uv sync COPY packages ./packages -COPY mediaservice . +COPY media-service . CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/mediaservice/api/__init__.py b/media-service/api/__init__.py similarity index 100% rename from mediaservice/api/__init__.py rename to media-service/api/__init__.py diff --git a/mediaservice/api/api_v1/__init__.py b/media-service/api/api_v1/__init__.py similarity index 100% rename from mediaservice/api/api_v1/__init__.py rename to media-service/api/api_v1/__init__.py diff --git a/mediaservice/api/api_v1/file_views.py b/media-service/api/api_v1/file_views.py similarity index 100% rename from mediaservice/api/api_v1/file_views.py rename to media-service/api/api_v1/file_views.py diff --git a/mediaservice/api/main_views.py b/media-service/api/main_views.py similarity index 100% rename from mediaservice/api/main_views.py rename to media-service/api/main_views.py diff --git a/mediaservice/core/__init__.py b/media-service/core/__init__.py similarity index 100% rename from mediaservice/core/__init__.py rename to media-service/core/__init__.py diff --git a/mediaservice/core/celery/__init__.py b/media-service/core/celery/__init__.py similarity index 100% rename from mediaservice/core/celery/__init__.py rename to media-service/core/celery/__init__.py diff --git a/mediaservice/core/celery/celery_app.py b/media-service/core/celery/celery_app.py similarity index 100% rename from mediaservice/core/celery/celery_app.py rename to media-service/core/celery/celery_app.py diff --git a/mediaservice/core/celery/tasks.py b/media-service/core/celery/tasks.py similarity index 100% rename from mediaservice/core/celery/tasks.py rename to media-service/core/celery/tasks.py diff --git a/mediaservice/core/config.py b/media-service/core/config.py similarity index 100% rename from mediaservice/core/config.py rename to media-service/core/config.py diff --git a/mediaservice/core/minio/__init__.py b/media-service/core/minio/__init__.py similarity index 100% rename from mediaservice/core/minio/__init__.py rename to media-service/core/minio/__init__.py diff --git a/mediaservice/core/minio/client.py b/media-service/core/minio/client.py similarity index 100% rename from mediaservice/core/minio/client.py rename to media-service/core/minio/client.py diff --git a/mediaservice/core/minio/connection.py b/media-service/core/minio/connection.py similarity index 100% rename from mediaservice/core/minio/connection.py rename to media-service/core/minio/connection.py diff --git a/mediaservice/core/minio/service.py b/media-service/core/minio/service.py similarity index 100% rename from mediaservice/core/minio/service.py rename to media-service/core/minio/service.py diff --git a/mediaservice/core/minio/utils.py b/media-service/core/minio/utils.py similarity index 100% rename from mediaservice/core/minio/utils.py rename to media-service/core/minio/utils.py diff --git a/mediaservice/core/rabbitmq/__init__.py b/media-service/core/rabbitmq/__init__.py similarity index 100% rename from mediaservice/core/rabbitmq/__init__.py rename to media-service/core/rabbitmq/__init__.py diff --git a/mediaservice/core/rabbitmq/consumers.py b/media-service/core/rabbitmq/consumers.py similarity index 98% rename from mediaservice/core/rabbitmq/consumers.py rename to media-service/core/rabbitmq/consumers.py index a28bb1f..69690bc 100644 --- a/mediaservice/core/rabbitmq/consumers.py +++ b/media-service/core/rabbitmq/consumers.py @@ -31,7 +31,7 @@ async def copy_file(message: IncomingMessage) -> None: async with get_rabbitmq_service() as rabbitmq_service: exchange = await rabbitmq_service.declare_exchange( - name=Exchange.mediaservice, + name=Exchange.media_service, type=ExchangeType.direct, durable=True, ) diff --git a/mediaservice/core/rabbitmq/startup.py b/media-service/core/rabbitmq/startup.py similarity index 100% rename from mediaservice/core/rabbitmq/startup.py rename to media-service/core/rabbitmq/startup.py diff --git a/mediaservice/dependencies.py b/media-service/dependencies.py similarity index 100% rename from mediaservice/dependencies.py rename to media-service/dependencies.py diff --git a/mediaservice/lifespan.py b/media-service/lifespan.py similarity index 100% rename from mediaservice/lifespan.py rename to media-service/lifespan.py diff --git a/mediaservice/main.py b/media-service/main.py similarity index 100% rename from mediaservice/main.py rename to media-service/main.py diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index 93a1202..8ab6fa8 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -19,7 +19,7 @@ def send_welcome_email(email: str, name: str) -> None: ) -@app.task( +@app.task( # type: ignore[untyped-decorator] name=TaskType.send_confirm_registration_email.value, ) def send_confirm_registration_email( @@ -34,7 +34,7 @@ def send_confirm_registration_email( ) -@app.task( +@app.task( # type: ignore[untyped-decorator] name=TaskType.send_confirm_login_email.value, ) def send_confirm_login_email( @@ -49,7 +49,7 @@ def send_confirm_login_email( ) -@app.task( +@app.task( # type: ignore[untyped-decorator] name=TaskType.send_reset_password_email_data.value, ) def send_reset_password_email_data( diff --git a/packages/celery/constants.py b/packages/celery/constants.py index d52ab82..3bed5f1 100644 --- a/packages/celery/constants.py +++ b/packages/celery/constants.py @@ -2,17 +2,17 @@ class Queue(StrEnum): - mediaservice = "mediaservice" + mediaservice = "media-service" notification = "notification-service" class TaskType(StrEnum): - delete_temporary_file = "mediaservice.media.delete_temporary_file" + delete_temporary_file = "media-service.media.delete_temporary_file" send_welcome_email = "notification-service.email.send-welcome-email" send_confirm_registration_email = ( "notification-service.email.send-confirm-registration-email" ) send_confirm_login_email = "notification-service.email.confirm-login-email" send_reset_password_email_data = ( - "notification-service.email.send_reset_password_email_data" + "notification-service.email.send_reset_password_email_data" # noqa: S105 ) diff --git a/packages/rabbitmq/constants.py b/packages/rabbitmq/constants.py index 96bdaa1..c6d3af2 100644 --- a/packages/rabbitmq/constants.py +++ b/packages/rabbitmq/constants.py @@ -5,13 +5,13 @@ class ConsumerType(StrEnum): app = "app" - mediaservice = "mediaservice" + media_service = "media-service" notification_service = "notification-service" class ProducerType(StrEnum): app = "app" - mediaservice = "mediaservice" + media_service = "media-service" notification_service = "notification-service" @@ -36,8 +36,8 @@ class Exchange(StrEnum): entity="content", exchange_type=ExchangeType.direct, ) - mediaservice = create_exchange_name( - producer=ProducerType.mediaservice, + media_service = create_exchange_name( + producer=ProducerType.media_service, entity="content", exchange_type=ExchangeType.direct, ) @@ -60,12 +60,12 @@ class Queue(StrEnum): action=ActionType.update_movie_source_url, ) copy_file = create_queue_name( - consumer=ConsumerType.mediaservice, + consumer=ConsumerType.media_service, entity="content", action=ActionType.copy_file, ) delete_file = create_queue_name( - consumer=ConsumerType.mediaservice, + consumer=ConsumerType.media_service, entity="content", action=ActionType.delete_file, ) diff --git a/pyproject.toml b/pyproject.toml index 4c8223a..83f9e16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ required-version = ">=0.15.11" src = [ "app", - "mediaservice", + "media-service", "notification-service", ] From af2cdb66d5c01f69d826e6f05151ecf968f6fb15 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Tue, 23 Jun 2026 15:54:15 +0300 Subject: [PATCH 34/47] Add different redis keys on registration, 2fa, recover account. --- app/core/constants.py | 6 ++++ app/schemas/auth.py | 4 +-- app/services/auth.py | 67 ++++++++++++++++++++++++++++++++++--------- 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/app/core/constants.py b/app/core/constants.py index 52b678a..4497633 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -59,6 +59,12 @@ class MethodType(StrEnum): delete = "DELETE" +class ConfirmationCodeType(StrEnum): + registration = "registration" + two_factor_auth = "two_factor_auth" + recover_password = "recover" + + TOKEN_TYPE_FIELD = "type" LOGIN_FIELD = "login" EMAIL_FIELD = "email" diff --git a/app/schemas/auth.py b/app/schemas/auth.py index c43f747..c916f83 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -42,7 +42,7 @@ class VerifyUserEmail(BaseModel): class RecoverAccountRequest(BaseModel): """ - Модель для восстановления доступа к аккаунту. + Модель для получения токена для восстановления доступа к аккаунту. """ email: EmailStr @@ -50,7 +50,7 @@ class RecoverAccountRequest(BaseModel): class ResetPasswordRequest(BaseModel): """ - Модель для смены пароля. + Модель для смены пароля по токену. """ reset_password_token: str diff --git a/app/services/auth.py b/app/services/auth.py index fbe0eca..72da635 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -6,7 +6,12 @@ from core.celery.celery_app import app from core.config import settings -from core.constants import BEARER_TOKEN_TYPE, EMAIL_FIELD, LOGIN_FIELD +from core.constants import ( + BEARER_TOKEN_TYPE, + EMAIL_FIELD, + LOGIN_FIELD, + ConfirmationCodeType, +) from core.exceptions.auth import InvalidPasswordError from core.exceptions.confirmation_code import ( EmailConfirmationCodeNotFoundError, @@ -63,8 +68,10 @@ async def register_user( ) token = create_registration_token(user_registration_data) ttl_seconds = settings.jwt.registration.expire_minutes * 60 + key_list = [ConfirmationCodeType.registration.value, token] + key = ":".join(key_list) await self.auth_redis_service.set( - key=f"registration:{token}", + key=key, value=user_create_data.model_dump_json(), ttl=ttl_seconds, ) @@ -88,7 +95,10 @@ async def send_register_confirmation_code( algorithm=settings.jwt.registration.algorithm, ) email = payload[EMAIL_FIELD] - confirmation_code = await self.create_confirmation_code(email) + confirmation_code = await self.create_confirmation_code( + email, + confirmation_code_type=ConfirmationCodeType.registration, + ) app.send_task( name=TaskType.send_confirm_registration_email.value, args=[ @@ -113,11 +123,14 @@ async def verify_register_user( await self.verify_confirmation_code( email, confirmation_code, + confirmation_code_type=ConfirmationCodeType.registration, ) - user_data_create_json = await self.auth_redis_service.get( - key=f"registration:{token}", + key_list = [ConfirmationCodeType.registration.value, token] + key = ":".join(key_list) + user_create_data_json = await self.auth_redis_service.get( + key=key, ) - user_create_data = UserCreate.model_validate_json(user_data_create_json) + user_create_data = UserCreate.model_validate_json(user_create_data_json) user = await self.user_service.create_user(user_create_data) return create_auth_token(user) @@ -152,7 +165,10 @@ async def send_authenticate_confirmation_code( algorithm=settings.jwt.two_factor_auth.algorithm, ) email = payload[EMAIL_FIELD] - confirmation_code = await self.create_confirmation_code(email) + confirmation_code = await self.create_confirmation_code( + email, + confirmation_code_type=ConfirmationCodeType.two_factor_auth, + ) app.send_task( name=TaskType.send_confirm_login_email.value, args=[ @@ -177,12 +193,19 @@ async def verify_authenticate_user( await self.verify_confirmation_code( email, confirmation_code, + confirmation_code_type=ConfirmationCodeType.two_factor_auth, ) user = await self.user_service.get_user_by_email(email) return create_auth_token(user) - async def get_confirmation_code(self, email: EmailStr) -> str: - confirmation_code = await self.auth_redis_service.get(f"auth:email:{email}") + async def get_confirmation_code( + self, + email: EmailStr, + confirmation_code_type: ConfirmationCodeType, + ) -> str: + key_list = [confirmation_code_type, email] + key = ":".join(key_list) + confirmation_code = await self.auth_redis_service.get(key) if confirmation_code is None: raise EmailConfirmationCodeNotFoundError( email=email, @@ -193,20 +216,29 @@ async def verify_confirmation_code( self, email: EmailStr, confirmation_code: str, + confirmation_code_type: ConfirmationCodeType, ) -> None: - sent_confirmation_code = await self.get_confirmation_code(email) + sent_confirmation_code = await self.get_confirmation_code( + email, confirmation_code_type, + ) if confirmation_code != sent_confirmation_code: raise InvalidEmailConfirmationCodeError( email=email, confirmation_code=confirmation_code, ) - async def create_confirmation_code(self, email: EmailStr) -> str: + async def create_confirmation_code( + self, + email: EmailStr, + confirmation_code_type: ConfirmationCodeType, + ) -> str: confirmation_code = "".join( [str(random.randint(0, 9)) for _ in range(6)], # noqa: S311 ) + key_list = [confirmation_code_type.value, email] + key = ":".join(key_list) await self.auth_redis_service.set( - key=f"auth:email:{email}", + key=key, value=confirmation_code, ttl=60, ) @@ -242,7 +274,10 @@ async def send_recover_account_confirmation_code( ) login = payload[LOGIN_FIELD] email = payload[EMAIL_FIELD] - confirmation_code = await self.create_confirmation_code(email) + confirmation_code = await self.create_confirmation_code( + email, + confirmation_code_type=ConfirmationCodeType.recover_password, + ) app.send_task( name=TaskType.send_reset_password_email_data.value, args=[ @@ -265,7 +300,11 @@ async def verify_recover_account( algorithm=settings.jwt.recover.algorithm, ) email = payload[EMAIL_FIELD] - await self.verify_confirmation_code(email, confirmation_code) + await self.verify_confirmation_code( + email, + confirmation_code, + confirmation_code_type=ConfirmationCodeType.recover_password, + ) user = await self.user_service.get_user_by_email(email) reset_password_token = create_reset_password_token(user) return TemporaryTokenInfo( From 364f83ed7809798b58bb3109b74241f96b617d03 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Tue, 23 Jun 2026 17:24:22 +0300 Subject: [PATCH 35/47] Add max code confirm attempts. --- app/core/constants.py | 3 +++ app/core/redis/client.py | 8 +++++++- app/core/redis/service.py | 8 +++++++- app/services/auth.py | 27 ++++++++++++++++++++++++++- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/app/core/constants.py b/app/core/constants.py index 4497633..6e6b9e4 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -65,6 +65,9 @@ class ConfirmationCodeType(StrEnum): recover_password = "recover" +ATTEMPT_FIELD = "attempt" +MAX_CONFIRM_CODE_ATTEMPTS = 5 + TOKEN_TYPE_FIELD = "type" LOGIN_FIELD = "login" EMAIL_FIELD = "email" diff --git a/app/core/redis/client.py b/app/core/redis/client.py index 48e01a7..555a8cd 100644 --- a/app/core/redis/client.py +++ b/app/core/redis/client.py @@ -35,10 +35,16 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no async def get(self, key: str) -> str | None: return cast(str | None, await self._redis.get(key)) + async def get_integer(self, key: str) -> int | None: + value = await self._redis.get(key) + if value is not None: + return int(value) + return None + async def exists(self, key: str) -> bool: return cast(bool, await self._redis.exists(key)) - async def set(self, key: str, value: str, expire: int) -> None: + async def set(self, key: str, value: str | int, expire: int) -> None: await self._redis.set( key, value, diff --git a/app/core/redis/service.py b/app/core/redis/service.py index b707be8..2847e9c 100644 --- a/app/core/redis/service.py +++ b/app/core/redis/service.py @@ -13,10 +13,16 @@ async def get(self, key: str, schema: Any = None) -> Any: return self.convert_string_to_object(value, schema) return None + async def get_integer(self, key: str) -> int | None: + return await self.redis.get_integer(key) + async def set(self, key: str, value: Any, ttl: int = 300) -> None: encoded_value = self.convert_object_to_string(value) await self.redis.set(key, encoded_value, ttl) + async def incr_by(self, key: str, amount: int = 1) -> int: + return await self.redis.incr_by(key, amount) + async def expire(self, key: str, ttl: int) -> None: await self.redis.expire(key, ttl) @@ -37,7 +43,7 @@ def create_cache_key(cls, prefix: str, **kwargs: Any) -> str: return ":".join(result) @staticmethod - def convert_string_to_object(value: str, schema: Any) -> Any: + def convert_string_to_object(value: str | int, schema: Any) -> Any: if schema is None: return value return schema.model_validate_json(value) diff --git a/app/services/auth.py b/app/services/auth.py index 72da635..07334cf 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -7,9 +7,11 @@ from core.celery.celery_app import app from core.config import settings from core.constants import ( + ATTEMPT_FIELD, BEARER_TOKEN_TYPE, EMAIL_FIELD, LOGIN_FIELD, + MAX_CONFIRM_CODE_ATTEMPTS, ConfirmationCodeType, ) from core.exceptions.auth import InvalidPasswordError @@ -132,6 +134,7 @@ async def verify_register_user( ) user_create_data = UserCreate.model_validate_json(user_create_data_json) user = await self.user_service.create_user(user_create_data) + await self.auth_redis_service.delete(key) return create_auth_token(user) async def verify_login_data(self, login_data: UserLogin) -> None: @@ -210,6 +213,7 @@ async def get_confirmation_code( raise EmailConfirmationCodeNotFoundError( email=email, ) + return cast(str, confirmation_code) async def verify_confirmation_code( @@ -219,8 +223,22 @@ async def verify_confirmation_code( confirmation_code_type: ConfirmationCodeType, ) -> None: sent_confirmation_code = await self.get_confirmation_code( - email, confirmation_code_type, + email, + confirmation_code_type, ) + + key_list = [confirmation_code_type, email] + key = ":".join(key_list) + attempt_counter_key_list = [key, ATTEMPT_FIELD] + attempt_counter_key = ":".join(attempt_counter_key_list) + await self.auth_redis_service.incr_by(attempt_counter_key) + count_confirm_code_attempts = await self.auth_redis_service.get_integer( + attempt_counter_key, + ) + if count_confirm_code_attempts == MAX_CONFIRM_CODE_ATTEMPTS: + await self.auth_redis_service.delete(key) + await self.auth_redis_service.delete(attempt_counter_key) + if confirmation_code != sent_confirmation_code: raise InvalidEmailConfirmationCodeError( email=email, @@ -237,11 +255,18 @@ async def create_confirmation_code( ) key_list = [confirmation_code_type.value, email] key = ":".join(key_list) + attempt_counter_key_list = [key, ATTEMPT_FIELD] + attempt_counter_key = ":".join(attempt_counter_key_list) await self.auth_redis_service.set( key=key, value=confirmation_code, ttl=60, ) + await self.auth_redis_service.set( + key=attempt_counter_key, + value=0, + ttl=60, + ) return confirmation_code async def recover_account( From c9df8d2d562ae417350b3b8b70516eebb6767cf9 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Tue, 23 Jun 2026 21:30:40 +0300 Subject: [PATCH 36/47] Rework RedisService get method: universal convert to string, integer, boolean, float, pydantic schema. --- app/core/redis/client.py | 6 ----- app/core/redis/service.py | 49 ++++++++++++++++++++++++++++++++++++--- app/services/auth.py | 5 ++-- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/app/core/redis/client.py b/app/core/redis/client.py index 555a8cd..6c83a66 100644 --- a/app/core/redis/client.py +++ b/app/core/redis/client.py @@ -35,12 +35,6 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no async def get(self, key: str) -> str | None: return cast(str | None, await self._redis.get(key)) - async def get_integer(self, key: str) -> int | None: - value = await self._redis.get(key) - if value is not None: - return int(value) - return None - async def exists(self, key: str) -> bool: return cast(bool, await self._redis.exists(key)) diff --git a/app/core/redis/service.py b/app/core/redis/service.py index 2847e9c..8dad58c 100644 --- a/app/core/redis/service.py +++ b/app/core/redis/service.py @@ -1,5 +1,6 @@ from typing import Any +from core.constants import AnyPydanticType, PrimitiveType from core.redis.client import RedisClient @@ -7,14 +8,56 @@ class RedisService: def __init__(self, redis: RedisClient) -> None: self.redis = redis - async def get(self, key: str, schema: Any = None) -> Any: + async def get( + self, + key: str, + schema: AnyPydanticType | None = None, + is_integer: bool = False, + is_float: bool = False, + is_boolean: bool = False, + ) -> PrimitiveType | AnyPydanticType | None: + if schema is not None: + return await self._get_schema(key, schema) + + if is_integer: + return await self._get_integer(key) + + if is_float: + return await self._get_float(key) + + if is_boolean: + return await self._get_boolean(key) + + return await self.redis.get(key) + + async def _get_schema( + self, + key: str, + schema: AnyPydanticType, + ) -> AnyPydanticType | None: value = await self.redis.get(key) if value is not None: return self.convert_string_to_object(value, schema) return None - async def get_integer(self, key: str) -> int | None: - return await self.redis.get_integer(key) + async def _get_integer(self, key: str) -> int | None: + value = await self.redis.get(key) + if value is not None: + return int(value) + return None + + async def _get_float(self, key: str) -> float | None: + value = await self.redis.get(key) + if value is not None: + return float(value) + return None + + async def _get_boolean(self, key: str) -> bool | None: + boolean = {"True": True, "False": False} + value = await self.redis.get(key) + if value is not None: + return boolean[value] + return None async def set(self, key: str, value: Any, ttl: int = 300) -> None: encoded_value = self.convert_object_to_string(value) diff --git a/app/services/auth.py b/app/services/auth.py index 07334cf..d90339b 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -74,7 +74,7 @@ async def register_user( key = ":".join(key_list) await self.auth_redis_service.set( key=key, - value=user_create_data.model_dump_json(), + value=user_create_data, ttl=ttl_seconds, ) send_confirmation_code_request = SendConfirmationCodeRequest( @@ -232,8 +232,9 @@ async def verify_confirmation_code( attempt_counter_key_list = [key, ATTEMPT_FIELD] attempt_counter_key = ":".join(attempt_counter_key_list) await self.auth_redis_service.incr_by(attempt_counter_key) - count_confirm_code_attempts = await self.auth_redis_service.get_integer( + count_confirm_code_attempts = await self.auth_redis_service.get( attempt_counter_key, + is_integer=True, ) if count_confirm_code_attempts == MAX_CONFIRM_CODE_ATTEMPTS: await self.auth_redis_service.delete(key) From 0fde32c4db60a90506a5e57f7c735538bbeb18bf Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Wed, 24 Jun 2026 12:08:58 +0300 Subject: [PATCH 37/47] Add frontend auth exceptions handlers. --- frontend/app/data/state.js | 3 + frontend/app/services/methods/auth.js | 502 +++++++++++++++--- .../layout_navbar_auth_catalog_genres.html | 74 ++- 3 files changed, 469 insertions(+), 110 deletions(-) diff --git a/frontend/app/data/state.js b/frontend/app/data/state.js index 658800a..b4e4308 100644 --- a/frontend/app/data/state.js +++ b/frontend/app/data/state.js @@ -198,11 +198,14 @@ movieSourceFile: null, movieSourceFileName: "", + messageTimeout: null, + // Для двухфакторной аутентификации loginStep: 'form', // 'form' | 'verify' loginToken: '', loginEmail: '', // email из ответа /login loginCode: '', // 6-значный код + loginBlocked: false, // ← НОВОЕ: блокировка при слишком многих попытках loginResendTimer: 60, loginCanResend: false, loginTimerInterval: null, diff --git a/frontend/app/services/methods/auth.js b/frontend/app/services/methods/auth.js index b41ec9d..420e9ad 100644 --- a/frontend/app/services/methods/auth.js +++ b/frontend/app/services/methods/auth.js @@ -1,12 +1,54 @@ (function () { window.AppMethodsAuth = { + // ==================== УПРАВЛЕНИЕ СООБЩЕНИЯМИ ==================== + + // Показать сообщение об ошибке (заменяет success) + showError: function (message, autoClear = true) { + this.error = message; + this.success = ''; // Очищаем успешное сообщение + if (autoClear) { + this.clearMessagesAfterDelay(5000); + } + }, + + // Показать сообщение об успехе (заменяет error) + showSuccess: function (message, autoClear = true) { + this.success = message; + this.error = ''; // Очищаем сообщение об ошибке + if (autoClear) { + this.clearMessagesAfterDelay(5000); + } + }, + + // Очистить все сообщения + clearMessages: function () { + this.error = ''; + this.success = ''; + if (this.messageTimeout) { + clearTimeout(this.messageTimeout); + this.messageTimeout = null; + } + }, + + // Автоматическая очистка через заданное время + clearMessagesAfterDelay: function (delay) { + var self = this; + if (this.messageTimeout) { + clearTimeout(this.messageTimeout); + } + this.messageTimeout = setTimeout(function () { + self.error = ''; + self.success = ''; + self.messageTimeout = null; + }, delay); + }, + // ==================== ЛОГИН С 2FA ==================== onLogin: function () { var self = this; - this.error = ""; + this.clearMessages(); this.loading = true; - // Передаем username и password отдельно (не JSON) window.ApiAuth.loginUser( this.loginForm.username.trim(), this.loginForm.password @@ -14,11 +56,59 @@ .then(function (data) { self.loginToken = data.token; self.loginStep = 'verify'; - self.success = "Код подтверждения отправлен на почту"; + self.showSuccess("✅ Код подтверждения отправлен на почту"); self.startLoginResendTimer(60); }) .catch(function (e) { - self.error = e.message || "Не удалось войти"; + var message = e.message || "Не удалось войти"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + // 🔥 ОБРАБОТКА ОШИБОК ЛОГИНА + if (lowerMessage.includes("invalid") || + lowerMessage.includes("неверн") || + lowerMessage.includes("не правильн") || + lowerMessage.includes("incorrect")) { + if (lowerMessage.includes("password") || lowerMessage.includes("парол")) { + errorText = "❌ Неверный пароль. Пожалуйста, проверьте правильность введенного пароля."; + self.loginForm.password = ''; + setTimeout(function () { + var input = document.getElementById('login-password'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("login") || lowerMessage.includes("логин") || lowerMessage.includes("username")) { + errorText = "❌ Неверный логин. Пожалуйста, проверьте правильность введенного логина."; + self.loginForm.username = ''; + setTimeout(function () { + var input = document.getElementById('login-username'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else { + errorText = "❌ Неверный логин или пароль. Попробуйте еще раз."; + } + } else if (lowerMessage.includes("not found") || + lowerMessage.includes("не найден") || + lowerMessage.includes("does not exist")) { + errorText = "❌ Пользователь с таким логином не найден. Проверьте правильность введенного логина."; + self.loginForm.username = ''; + setTimeout(function () { + var input = document.getElementById('login-username'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); }) .finally(function () { self.loading = false; @@ -28,17 +118,17 @@ // ПОДТВЕРЖДЕНИЕ 2FA КОДА onVerifyLoginCode: function () { var self = this; - this.error = ""; + this.clearMessages(); this.loading = true; if (this.loginCode.trim().length !== 6) { - this.error = "Введите 6-значный код"; + this.showError("Введите 6-значный код"); this.loading = false; return; } if (!this.loginToken) { - this.error = "Ошибка: токен не найден. Попробуйте войти заново."; + this.showError("❌ Ошибка: токен не найден. Попробуйте войти заново."); this.loading = false; return; } @@ -48,45 +138,123 @@ this.loginCode.trim() ) .then(function (data) { - // Сохраняем токены доступа window.TokenStore.setTokens(data.access_token, data.refresh_token); window.location.hash = "#/"; window.location.reload(); }) .catch(function (e) { - self.error = e.message || "Неверный код подтверждения"; + var message = e.message || "Неверный код подтверждения"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + if (lowerMessage.includes("does not exist") || + lowerMessage.includes("не существует") || + lowerMessage.includes("not found") || + lowerMessage.includes("не найден")) { + errorText = "❌ Код подтверждения не найден. Возможно, он уже был использован или истек. Запросите новый код."; + self.loginCode = ''; + setTimeout(function () { + var input = document.getElementById('login-code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout") || + lowerMessage.includes("не действителен") || + lowerMessage.includes("срок действия") || + lowerMessage.includes("устарел")) { + errorText = "⏰ Срок действия кода истек. Запросите новый код."; + self.loginCode = ''; + setTimeout(function () { + self.onResendLoginCode(); + }, 2000); + } else if (lowerMessage.includes("not valid") || + lowerMessage.includes("недействителен") || + lowerMessage.includes("invalid") || + lowerMessage.includes("неверн") || + lowerMessage.includes("не правильн")) { + errorText = "❌ Неверный код подтверждения. Проверьте правильность введенных цифр."; + self.loginCode = ''; + setTimeout(function () { + var input = document.getElementById('login-code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("attempt") || + lowerMessage.includes("попытк") || + lowerMessage.includes("blocked") || + lowerMessage.includes("заблокирован") || + lowerMessage.includes("too many")) { + errorText = "⚠️ Слишком много неудачных попыток. Доступ временно заблокирован."; + self.loginBlocked = true; + setTimeout(function () { + self.loginBlocked = false; + self.onResendLoginCode(); + }, 5000); + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); }) .finally(function () { self.loading = false; }); }, + // ПОВТОРНАЯ ОТПРАВКА 2FA КОДА onResendLoginCode: function () { var self = this; - this.error = ""; - this.success = ""; + this.clearMessages(); this.loading = true; if (!this.loginToken) { - this.error = "Ошибка: токен не найден. Попробуйте войти заново."; + this.showError("❌ Ошибка: токен не найден. Попробуйте войти заново."); this.loading = false; return; } window.ApiAuth.resendLoginCode(this.loginToken) .then(function () { - self.success = "Новый код отправлен на почту"; + self.showSuccess("✅ Новый код отправлен на почту"); self.startLoginResendTimer(60); + self.loginCode = ''; + self.loginBlocked = false; + setTimeout(function () { + var input = document.getElementById('login-code-input'); + if (input) input.focus(); + }, 100); }) .catch(function (e) { - self.error = e.message || "Не удалось отправить код"; + var message = e.message || "Не удалось отправить код"; + var lowerMessage = message.toLowerCase(); + + if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout")) { + self.showError("⏰ Срок действия сессии истек. Пожалуйста, войдите заново."); + setTimeout(function () { + self.onBackToLogin(); + }, 2000); + } else if (lowerMessage.includes("too many") || + lowerMessage.includes("много")) { + self.showError("⚠️ Слишком много запросов. Подождите немного."); + } else { + self.showError("❌ " + message); + } }) .finally(function () { self.loading = false; }); }, + // ВОЗВРАТ К ФОРМЕ ЛОГИНА onBackToLogin: function () { this.loginStep = 'form'; @@ -124,37 +292,35 @@ // ОБНОВЛЕННАЯ РЕГИСТРАЦИЯ (ШАГ 1) onRegister: function () { var self = this; - this.error = ""; - this.success = ""; + this.clearMessages(); // Валидация if (this.registerForm.password.length < 8) { - this.error = "Пароль должен быть минимум 8 символов"; + this.showError("Пароль должен быть минимум 8 символов"); return; } var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(this.registerForm.email.trim())) { - this.error = "Введите корректный email"; + this.showError("Введите корректный email"); return; } if (this.registerForm.login.trim().length < 3) { - this.error = "Логин должен быть минимум 3 символа"; + this.showError("Логин должен быть минимум 3 символа"); return; } if (this.registerForm.surname.trim().length < 2) { - this.error = "Фамилия должна быть минимум 2 символа"; + this.showError("Фамилия должна быть минимум 2 символа"); return; } if (this.registerForm.name.trim().length < 2) { - this.error = "Имя должно быть минимум 2 символа"; + this.showError("Имя должно быть минимум 2 символа"); return; } - // Отправляем запрос на регистрацию this.loading = true; var payload = { surname: this.registerForm.surname.trim(), @@ -166,38 +332,81 @@ window.ApiAuth.registerUser(payload) .then(function (data) { - // Сохраняем временный токен self.registrationToken = data.token; - // Сохраняем email для отображения self.registrationData.email = payload.email; - // Переключаем на шаг подтверждения self.registerStep = 'verify'; - self.success = "Код подтверждения отправлен на почту"; + self.showSuccess("✅ Код подтверждения отправлен на почту"); self.startResendTimer(60); }) .catch(function (e) { - self.error = e.message || "Ошибка регистрации"; + var message = e.message || "Ошибка регистрации"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + // 🔥 ОБРАБОТКА ОШИБОК УНИКАЛЬНОСТИ + if (lowerMessage.includes("login") && + (lowerMessage.includes("already exists") || + lowerMessage.includes("already taken") || + lowerMessage.includes("существует") || + lowerMessage.includes("занят") || + lowerMessage.includes("используется"))) { + errorText = "❌ Логин уже занят. Пожалуйста, выберите другой логин."; + // Очищаем поле логина и ставим фокус + self.registerForm.login = ''; + setTimeout(function () { + var input = document.getElementById('reg-login'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("email") && + (lowerMessage.includes("already exists") || + lowerMessage.includes("already taken") || + lowerMessage.includes("существует") || + lowerMessage.includes("занят") || + lowerMessage.includes("используется"))) { + errorText = "❌ Email уже используется. Пожалуйста, используйте другой email."; + // Очищаем поле email и ставим фокус + self.registerForm.email = ''; + setTimeout(function () { + var input = document.getElementById('reg-email'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("already exists") || + lowerMessage.includes("существует") || + lowerMessage.includes("already registered") || + lowerMessage.includes("already taken")) { + errorText = "❌ Пользователь с таким email или логином уже существует."; + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); }) .finally(function () { self.loading = false; }); }, + // ПОДТВЕРЖДЕНИЕ КОДА РЕГИСТРАЦИИ (ШАГ 2) onVerifyCode: function () { var self = this; - this.error = ""; - this.success = ""; + this.clearMessages(); this.loading = true; if (this.confirmationCode.trim().length !== 6) { - this.error = "Введите 6-значный код"; + this.showError("Введите 6-значный код"); this.loading = false; return; } if (!this.registrationToken) { - this.error = "Ошибка: токен не найден. Попробуйте зарегистрироваться заново."; + this.showError("❌ Ошибка: токен не найден. Попробуйте зарегистрироваться заново."); this.loading = false; return; } @@ -207,13 +416,73 @@ this.confirmationCode.trim() ) .then(function (data) { - window.TokenStore.setTokens(data.access_token, data.refresh_token); - - window.location.hash = "#/"; - window.location.reload(); + if (data.access_token) { + window.TokenStore.setTokens(data.access_token, data.refresh_token); + self.showSuccess("✅ Регистрация успешна! Добро пожаловать!"); + setTimeout(function () { + window.location.hash = "#/"; + window.location.reload(); + }, 1000); + } else { + self.showSuccess("✅ Регистрация успешна! Теперь вы можете войти."); + setTimeout(function () { + window.location.hash = "#/login"; + window.location.reload(); + }, 1500); + } }) .catch(function (e) { - self.error = e.message || "Неверный код подтверждения"; + var message = e.message || "Неверный код подтверждения"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + if (lowerMessage.includes("does not exist") || + lowerMessage.includes("не существует") || + lowerMessage.includes("not found") || + lowerMessage.includes("не найден")) { + errorText = "❌ Код подтверждения не найден. Возможно, он уже был использован или истек. Запросите новый код."; + self.confirmationCode = ''; + setTimeout(function () { + var input = document.getElementById('code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout")) { + errorText = "⏰ Срок действия кода истек. Отправляем новый код..."; + setTimeout(function () { + self.onResendCode(); + }, 1500); + } else if (lowerMessage.includes("not valid") || + lowerMessage.includes("недействителен") || + lowerMessage.includes("invalid")) { + errorText = "❌ Неверный код. Проверьте правильность введенных цифр."; + self.confirmationCode = ''; + setTimeout(function () { + var input = document.getElementById('code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("already exists") || + lowerMessage.includes("существует") || + lowerMessage.includes("already registered")) { + errorText = "❌ Пользователь с таким email или логином уже существует."; + setTimeout(function () { + self.onBackToRegister(); + }, 2000); + } else if (lowerMessage.includes("attempt") || + lowerMessage.includes("попытк")) { + errorText = "⚠️ Слишком много неудачных попыток. Попробуйте позже."; + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); }) .finally(function () { self.loading = false; @@ -221,6 +490,7 @@ }, + // ПОВТОРНАЯ ОТПРАВКА КОДА РЕГИСТРАЦИИ onResendCode: function () { var self = this; @@ -236,11 +506,23 @@ window.ApiAuth.resendRegistrationCode(this.registrationToken) .then(function () { - self.success = "Новый код отправлен на почту"; + self.success = "✅ Новый код отправлен на почту"; self.startResendTimer(60); }) .catch(function (e) { - self.error = e.message || "Не удалось отправить код"; + var message = e.message || "Не удалось отправить код"; + var lowerMessage = message.toLowerCase(); + + if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout")) { + self.error = "⏰ Срок действия сессии истек. Пожалуйста, начните регистрацию заново."; + setTimeout(function () { + self.onBackToRegister(); + }, 2000); + } else { + self.error = "❌ " + message; + } }) .finally(function () { self.loading = false; @@ -285,52 +567,70 @@ // ШАГ 1: Отправка email для восстановления onSendResetCode: function () { var self = this; - this.error = ""; - this.success = ""; + this.clearMessages(); this.loading = true; var email = this.resetEmail.trim(); var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { - this.error = "Введите корректный email"; + this.showError("Введите корректный email"); this.loading = false; return; } window.ApiAuth.recoverAccount(email) .then(function (data) { - // Сохраняем временный токен self.resetToken = data.token; - // Сохраняем email для отображения self.resetEmail = email; - // Переключаем на шаг подтверждения self.resetStep = 'verify'; - self.success = "Код восстановления отправлен на почту"; + self.showSuccess("✅ Код восстановления отправлен на почту"); self.startResetResendTimer(60); }) .catch(function (e) { - self.error = e.message || "Не удалось отправить код"; + var message = e.message || "Не удалось отправить код"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + // 🔥 ОБРАБОТКА ОШИБКИ - EMAIL НЕ НАЙДЕН + if (lowerMessage.includes("not found") || + lowerMessage.includes("не найден") || + lowerMessage.includes("does not exist") || + lowerMessage.includes("не существует")) { + errorText = "❌ Пользователь с таким email не найден. Проверьте правильность введенного адреса."; + self.resetEmail = ''; + setTimeout(function () { + var input = document.getElementById('reset-email'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); }) .finally(function () { self.loading = false; }); }, + // ШАГ 2: Подтверждение кода восстановления onVerifyResetCode: function () { var self = this; - this.error = ""; - this.success = ""; + this.clearMessages(); this.loading = true; if (this.resetCode.trim().length !== 6) { - this.error = "Введите 6-значный код"; + this.showError("Введите 6-значный код"); this.loading = false; return; } if (!this.resetToken) { - this.error = "Ошибка: токен не найден. Попробуйте начать заново."; + this.showError("❌ Ошибка: токен не найден. Попробуйте начать заново."); this.loading = false; return; } @@ -340,13 +640,70 @@ this.resetCode.trim() ) .then(function (data) { - // Сохраняем новый токен для смены пароля self.resetPasswordToken = data.token; - // Переключаем на шаг смены пароля - self.resetStep = 'change'; + self.showSuccess("✅ Код подтвержден! Теперь вы можете установить новый пароль."); + setTimeout(function () { + self.resetStep = 'change'; + }, 1000); }) .catch(function (e) { - self.error = e.message || "Неверный код подтверждения"; + var message = e.message || "Неверный код подтверждения"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + if (lowerMessage.includes("does not exist") || + lowerMessage.includes("не существует") || + lowerMessage.includes("not found") || + lowerMessage.includes("не найден")) { + errorText = "❌ Код подтверждения не найден. Возможно, он уже был использован или истек. Запросите новый код."; + self.resetCode = ''; + setTimeout(function () { + var input = document.getElementById('reset-code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout") || + lowerMessage.includes("не действителен") || + lowerMessage.includes("срок действия") || + lowerMessage.includes("устарел")) { + errorText = "⏰ Срок действия кода истек. Пожалуйста, запросите новый код."; + self.resetCode = ''; + setTimeout(function () { + self.onResendResetCode(); + }, 2000); + } else if (lowerMessage.includes("not valid") || + lowerMessage.includes("недействителен") || + lowerMessage.includes("invalid") || + lowerMessage.includes("неверн") || + lowerMessage.includes("не правильн")) { + errorText = "❌ Неверный код подтверждения. Проверьте правильность введенных цифр."; + self.resetCode = ''; + setTimeout(function () { + var input = document.getElementById('reset-code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("not found") || + lowerMessage.includes("не найден")) { + errorText = "❌ Пользователь с таким email не найден."; + setTimeout(function () { + self.onResetBackToLogin(); + }, 2000); + } else if (lowerMessage.includes("attempt") || + lowerMessage.includes("попытк") || + lowerMessage.includes("too many")) { + errorText = "⚠️ Слишком много неудачных попыток. Попробуйте позже."; + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); }) .finally(function () { self.loading = false; @@ -356,24 +713,23 @@ // ШАГ 3: Смена пароля onChangePassword: function () { var self = this; - this.error = ""; - this.success = ""; + this.clearMessages(); this.loading = true; if (this.resetNewPassword.length < 8) { - this.error = "Пароль должен быть минимум 8 символов"; + this.showError("Пароль должен быть минимум 8 символов"); this.loading = false; return; } if (this.resetNewPassword !== this.resetConfirmPassword) { - this.error = "Пароли не совпадают"; + this.showError("Пароли не совпадают"); this.loading = false; return; } if (!this.resetPasswordToken) { - this.error = "Ошибка: токен не найден. Попробуйте начать заново."; + this.showError("❌ Ошибка: токен не найден. Попробуйте начать заново."); this.loading = false; return; } @@ -386,11 +742,11 @@ window.ApiAuth.resetPassword(payload) .then(function () { - self.success = "Пароль успешно изменен! Теперь вы можете войти."; + self.showSuccess("✅ Пароль успешно изменен! Теперь вы можете войти."); self.resetStep = 'done'; }) .catch(function (e) { - self.error = e.message || "Не удалось изменить пароль"; + self.showError(e.message || "❌ Не удалось изменить пароль"); }) .finally(function () { self.loading = false; @@ -400,23 +756,34 @@ // ПОВТОРНАЯ ОТПРАВКА КОДА ВОССТАНОВЛЕНИЯ onResendResetCode: function () { var self = this; - this.error = ""; - this.success = ""; + this.clearMessages(); this.loading = true; if (!this.resetToken) { - this.error = "Ошибка: токен не найден. Попробуйте начать заново."; + this.showError("❌ Ошибка: токен не найден. Попробуйте начать заново."); this.loading = false; return; } window.ApiAuth.resendRecoveryCode(this.resetToken) .then(function () { - self.success = "Новый код отправлен на почту"; + self.showSuccess("✅ Новый код отправлен на почту"); self.startResetResendTimer(60); }) .catch(function (e) { - self.error = e.message || "Не удалось отправить код"; + var message = e.message || "Не удалось отправить код"; + var lowerMessage = message.toLowerCase(); + + if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout")) { + self.showError("⏰ Срок действия сессии истек. Начните восстановление заново."); + setTimeout(function () { + self.onResetBackToLogin(); + }, 2000); + } else { + self.showError("❌ " + message); + } }) .finally(function () { self.loading = false; @@ -432,8 +799,7 @@ this.resetConfirmPassword = ''; this.resetToken = ''; this.resetPasswordToken = ''; - this.error = ''; - this.success = ''; + this.clearMessages(); // ← используем общий метод if (this.resetTimerInterval) { clearInterval(this.resetTimerInterval); this.resetTimerInterval = null; diff --git a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html index ea22f3c..7771f1b 100644 --- a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html +++ b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html @@ -58,10 +58,6 @@
- - - -
@@ -71,8 +67,10 @@

Вход

+
@@ -121,13 +119,14 @@

Двухфакторная аутентификация

Введите его ниже для завершения входа.

+ @@ -145,6 +144,7 @@

Двухфакторная аутентификация

pattern="\d{6}" autocomplete="one-time-code" inputmode="numeric" + :disabled="loading || loginBlocked" style="font-size: 1.5rem; letter-spacing: 0.5rem;">
Введите 6-значный код из письма @@ -152,12 +152,16 @@

Двухфакторная аутентификация

- -
@@ -171,16 +175,21 @@

Двухфакторная аутентификация

+ +
+ ⚠️ Слишком много неудачных попыток. Код будет отправлен повторно через несколько секунд. +
+

Вернуться ко входу

@@ -196,16 +205,11 @@

Двухфакторная аутентификация

Регистрация

- - - @@ -266,16 +270,11 @@

Подтверждение email

Введите его ниже.

- - - @@ -348,13 +347,10 @@

Восстановление пароля

- @@ -397,13 +393,10 @@

Введите код восстановления

- @@ -475,13 +468,10 @@

Создание нового пароля

- From 84d6f38af57b344036282556eb339e6bf7ec710e Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Wed, 24 Jun 2026 17:53:08 +0300 Subject: [PATCH 38/47] Final frontend fixes. --- frontend/app/services/methods/profile.js | 1 + .../layout_navbar_auth_catalog_genres.html | 1009 ++++++++--------- .../templates/view_admin_movies_profile.html | 6 +- .../view_history_my_reviews_admin_genres.html | 1 - .../app/ui/templates/view_movie_details.html | 3 - .../view_movie_reviews_favorites_history.html | 26 +- 6 files changed, 516 insertions(+), 530 deletions(-) diff --git a/frontend/app/services/methods/profile.js b/frontend/app/services/methods/profile.js index c5fe629..2a548ce 100644 --- a/frontend/app/services/methods/profile.js +++ b/frontend/app/services/methods/profile.js @@ -165,6 +165,7 @@ window.Api.logout(); self.profileData = null; self.goLogin(); + window.location.reload(); }) .catch(function (e) { if (e.message === "REFRESH_EXPIRED") { diff --git a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html index 7771f1b..8b63ce5 100644 --- a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html +++ b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html @@ -1,578 +1,575 @@ -
- + +
+ + +
+
+
+
+

Вход

+ + + - -
- - ❓ Забыли пароль? - +
+
+ + +
+
+ +
+ +
-

- Нет аккаунта? - Зарегистрироваться -

+ + + +

+ Нет аккаунта? + Зарегистрироваться +

+
+ + +
+
+
+
+

Двухфакторная аутентификация

+

+ Код подтверждения отправлен на вашу почту. + Введите его ниже для завершения входа. +

+ + - -
-
-
-
-

Двухфакторная аутентификация

-

- Код подтверждения отправлен на вашу почту. - Введите его ниже для завершения входа. -

- - - - - - -
-
- - -
- Введите 6-значный код из письма -
-
+ -
- - + +
+ + +
+ Введите 6-значный код из письма
- +
- -
- - Не пришло письмо? - +
+
+ + + +
+ + Не пришло письмо? + + +
- -
- ⚠️ Слишком много неудачных попыток. Код будет отправлен повторно через несколько секунд. -
- -

- Вернуться ко входу -

+ +
+ ⚠️ Слишком много неудачных попыток. Код будет отправлен повторно через несколько секунд.
+ +

+ Вернуться ко входу +

+
- -
-
-
-
-

Регистрация

+ +
+
+
+
+

Регистрация

- - + + -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+ +
+
+ +
- - -

- Уже есть аккаунт? - Войти -

-
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +

+ Уже есть аккаунт? + Войти +

+
+ + +
+
+
+
+

Подтверждение email

+

+ На почту {{ registrationData.email }} отправлен код подтверждения. + Введите его ниже. +

+ + + - -
-
-
-
-

Подтверждение email

-

- На почту {{ registrationData.email }} отправлен код подтверждения. - Введите его ниже. -

- - - - -
-
- - -
- Введите 6-значный код из письма -
-
- -
- - + +
+ + +
+ Введите 6-значный код из письма
- +
- -
- - Не пришло письмо? - - +
- -

- Вернуться ко входу -

+ + + +
+ + Не пришло письмо? + +
+ +

+ Вернуться ко входу +

+
+ + + +
+
+
+
+

Восстановление пароля

+

+ Введите email, на который зарегистрирован аккаунт. Мы отправим код для восстановления. +

+ + + - - -
-
-
-
-

Восстановление пароля

-

- Введите email, на который зарегистрирован аккаунт. Мы отправим код для восстановления. -

- - - +

+ Вернуться ко входу +

+
+ + +
+
+
+
+

Введите код восстановления

+

+ На почту {{ resetEmail }} отправлен код восстановления. + Введите его ниже. +

+ + + - -
-
-
-
-

Введите код восстановления

-

- На почту {{ resetEmail }} отправлен код восстановления. - Введите его ниже. -

- - - - -
-
- - -
- Введите 6-значный код из письма -
-
- -
- - + +
+ + +
+ Введите 6-значный код из письма
- +
- -
- - Не пришло письмо? - - +
- -

- Вернуться ко входу -

+ + + +
+ + Не пришло письмо? + +
+ +

+ Вернуться ко входу +

+
+ + +
+
+
+
+

Создание нового пароля

+

+ Придумайте новый пароль для аккаунта {{ resetEmail }} +

+ + + - -
-
-
-
-

Создание нового пароля

-

- Придумайте новый пароль для аккаунта {{ resetEmail }} -

- -
- - -
-
-
-
-

✅ Пароль изменен!

-

- Ваш пароль успешно изменен. Теперь вы можете войти в аккаунт с новым паролем. -

- - Войти - -
+
+ + +
+
+
+
+

✅ Пароль изменен!

+

+ Ваш пароль успешно изменен. Теперь вы можете войти в аккаунт с новым паролем. +

+ + Войти +
- - -
-
-
-
-

Каталог фильмов

-

- Добро пожаловать. Просматривайте жанры без регистрации или войдите в аккаунт для персональных функций — всё это доступно из шапки сайта. -

-
+
+ + +
+
+
+
+

Каталог фильмов

+

+ Добро пожаловать. Просматривайте жанры без регистрации или войдите в аккаунт для персональных функций — всё это доступно из шапки сайта. +

+
- -
-
- -
-
-

🎭 Жанры

-
-
- - -
-
- -
-
- -
-
-

- Результаты по запросу: «{{ genreActiveSearch }}» -

-
-
-
-
Загрузка…
+ +
+
+ +
+
+

🎭 Жанры

+
+
+ + +
+
+ +
+
+ +
+
+

+ Результаты по запросу: «{{ genreActiveSearch }}» +

-
-
+
+
+
Загрузка…
+
+
+
diff --git a/frontend/app/ui/templates/view_admin_movies_profile.html b/frontend/app/ui/templates/view_admin_movies_profile.html index 8755710..97a3b25 100644 --- a/frontend/app/ui/templates/view_admin_movies_profile.html +++ b/frontend/app/ui/templates/view_admin_movies_profile.html @@ -110,7 +110,11 @@
{{ editingMovie ? '✏️ Редактир
-
+
{{ editingGenre ? '✏️ Редактир
diff --git a/frontend/app/ui/templates/view_movie_details.html b/frontend/app/ui/templates/view_movie_details.html index de7ed98..80a297f 100644 --- a/frontend/app/ui/templates/view_movie_details.html +++ b/frontend/app/ui/templates/view_movie_details.html @@ -77,9 +77,6 @@

{{ currentMovie.name || 'Без названия' ⚠️ Для просмотра фильма необходимо войти в аккаунт

-
- URL источника: {{ truncateText(currentMovie.source_url, 50) }} -
diff --git a/frontend/app/ui/templates/view_movie_reviews_favorites_history.html b/frontend/app/ui/templates/view_movie_reviews_favorites_history.html index 08c460f..818bef8 100644 --- a/frontend/app/ui/templates/view_movie_reviews_favorites_history.html +++ b/frontend/app/ui/templates/view_movie_reviews_favorites_history.html @@ -23,25 +23,13 @@
- +
From 79dab407678f63fd7c76cf353dc5270cc831a363 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Fri, 26 Jun 2026 16:25:07 +0300 Subject: [PATCH 39/47] Add helper methods for sending email notifications. --- app/core/rabbitmq/utils.py | 9 ++++++++- app/repositories/user.py | 29 +++++++++++++++++++++++++++-- app/services/user.py | 11 +++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/app/core/rabbitmq/utils.py b/app/core/rabbitmq/utils.py index 37cb061..ca8c91e 100644 --- a/app/core/rabbitmq/utils.py +++ b/app/core/rabbitmq/utils.py @@ -16,7 +16,7 @@ from dependencies.redis_client import ( get_watch_history_redis_client as get_watch_history_redis_client_dependency, ) -from services import GenreService, MovieService +from services import GenreService, MovieService, UserService @asynccontextmanager @@ -101,3 +101,10 @@ async def get_movie_cache_service() -> AsyncGenerator[MovieCacheService]: cache_service_for_watch_history, ) yield movie_cache_service + + +@asynccontextmanager +async def get_user_service() -> AsyncGenerator[UserService]: + async with get_session() as session: + user_service = UserService(session) + yield user_service diff --git a/app/repositories/user.py b/app/repositories/user.py index 11e703b..e077252 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -1,9 +1,9 @@ from pydantic import EmailStr -from sqlalchemy import delete, select +from sqlalchemy import Date, and_, cast, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from core.constants import UserRole -from models import User +from models import User, WatchHistory from schemas.user import ( UserCreate, UserPartialUpdate, @@ -58,6 +58,31 @@ async def create_user(self, create_user_data: UserCreate) -> User: await self.session.refresh(user) return user + async def get_inactive_users(self) -> list[User]: + get_users_never_watch_movies_stmt = ( + select(User) + .outerjoin(WatchHistory, User.id == WatchHistory.user_id) + .where( + and_( + WatchHistory.id.is_(None), + func.current_date() - cast(User.registration_date, Date) >= 7, + ), + ) + ) + get_users_watch_movies_a_long_time_ago_stmt = ( + select(User) + .join(WatchHistory, User.id == WatchHistory.user_id) + .group_by(User.id) + .having( + func.current_date() - cast(func.max(WatchHistory.watched_at), Date) >= 7, + ) + ) + result_stmt = get_users_never_watch_movies_stmt.union( + get_users_watch_movies_a_long_time_ago_stmt, + ) + result = await self.session.execute(result_stmt) + return list(result.all()) + async def make_admin(self, user_id: int) -> bool: user = await self.get_user_by_id(user_id) if user is not None: diff --git a/app/services/user.py b/app/services/user.py index 72f10c7..4d83f05 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -69,6 +69,17 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList page=page, ) + async def get_inactive_users(self) -> UserResponseList: + users = [ + UserResponse.model_validate(user) + for user in await self.user_repository.get_inactive_users() + ] + return UserResponseList( + user_list=users, + page=1, + size=1, + ) + async def get_user_encrypted_password(self, login: str) -> str: """ Метод получения зашифрованного пароля пользователя From f6dcdd831a6bf3fca338b48595db345260f51022 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Fri, 26 Jun 2026 16:45:30 +0300 Subject: [PATCH 40/47] First attempt to send email notifications to inactive users. --- app/api/api_v1/users/details_views.py | 14 +++- app/core/celery/celery_app.py | 10 +++ app/core/celery/tasks.py | 78 +++++++++++++++++++ app/core/database/connection.py | 2 + app/services/movie.py | 2 +- docker-compose.yml | 42 ++++++++++ .../api/api_v1/send_email_views.py | 2 +- .../core/celery/celery_app.py | 1 + notification-service/core/celery/tasks.py | 19 +++++ notification-service/service.py | 20 +++++ packages/celery/constants.py | 5 ++ packages/schemas.py | 33 ++++++++ 12 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 app/core/celery/tasks.py diff --git a/app/api/api_v1/users/details_views.py b/app/api/api_v1/users/details_views.py index b456520..ac8c8c6 100644 --- a/app/api/api_v1/users/details_views.py +++ b/app/api/api_v1/users/details_views.py @@ -1,19 +1,29 @@ from fastapi import APIRouter, Depends, status from dependencies.annotations.cache_services import UserCacheServiceDep +from dependencies.annotations.services import UserServiceDep from dependencies.annotations.validators import PaginationPageDep, PaginationSizeDep -from dependencies.auth import get_admin_by_access_token from dependencies.rate_limiter import check_rate_limit_auth from schemas.user import UserResponse, UserResponseList router = APIRouter( dependencies=[ - Depends(get_admin_by_access_token), + # Depends(get_admin_by_access_token), Depends(check_rate_limit_auth), ], ) +@router.get( + "/inactive", + status_code=status.HTTP_200_OK, +) +async def get_inactive_users( + user_service: UserServiceDep, +) -> UserResponseList: + return await user_service.get_inactive_users() + + @router.get( "/", response_model=UserResponseList, diff --git a/app/core/celery/celery_app.py b/app/core/celery/celery_app.py index 432a3f7..b49fce6 100644 --- a/app/core/celery/celery_app.py +++ b/app/core/celery/celery_app.py @@ -3,4 +3,14 @@ app = Celery( "core.celery.celery_app", broker="amqp://guest:guest@rabbitmq:5672/%2f", + backend="redis://redis:6379/0", + include=["core.celery.tasks"], ) + +app.conf.beat_schedule = { + "run-spam-every-30-seconds": { + "task": "testing", + "schedule": 5 * 60, + "options": {"queue": "movie-catalog"}, + }, +} diff --git a/app/core/celery/tasks.py b/app/core/celery/tasks.py new file mode 100644 index 0000000..65724ce --- /dev/null +++ b/app/core/celery/tasks.py @@ -0,0 +1,78 @@ +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from celery import chord, group +from packages.celery.constants import Queue, TaskType +from packages.schemas import MovieEmailSendDataList, UserEmailSendDataList + +from core.constants import SortMonotony, SortType +from core.rabbitmq.utils import get_session, get_user_service +from schemas.movie import MovieFilter +from services import MovieService + +from .celery_app import app + + +@asynccontextmanager +async def get_movie_service() -> AsyncGenerator[MovieService]: + async with get_session() as session: + movie_service = MovieService(session) + yield movie_service + + +async def get_inactive_users() -> UserEmailSendDataList: + async with get_user_service() as user_service: + return await user_service.get_inactive_users() + + +async def get_newest_movies() -> MovieEmailSendDataList: + async with get_movie_service() as movie_service: + movie_filter = MovieFilter( + sort_by=SortType.date.value, + sorting_direction=SortMonotony.descending.value, + ) + + return await movie_service.search_movies_with_filters( + movie_filter=movie_filter, + ) + + +@app.task( + name=TaskType.prepare_inactive_users.value, +) +def prepare_inactive_users() -> dict: + print("IN 35") + loop = asyncio.new_event_loop() + result = loop.run_until_complete(get_inactive_users()) + loop.close() + # result = asyncio.run(get_inactive_users()) + return result.model_dump() + + +@app.task( + name=TaskType.prepare_newest_movies.value, +) +def prepare_newest_movies() -> dict: + print("IN 43") + loop = asyncio.new_event_loop() + result = loop.run_until_complete(get_newest_movies()) + loop.close() + # result = asyncio.run(get_newest_movies()) + return result.model_dump() + + +@app.task(name="testing") +def test() -> None: + print("in testing") + chained_group = group( + prepare_inactive_users.s().set(queue=Queue.app.value), + prepare_newest_movies.s().set(queue=Queue.app.value), + ) + + notify = app.signature( + TaskType.send_spam_email.value, + queue=Queue.notification.value, + ) + print("IN 55") + chord(chained_group)(notify) diff --git a/app/core/database/connection.py b/app/core/database/connection.py index 04c7a87..d7c680c 100644 --- a/app/core/database/connection.py +++ b/app/core/database/connection.py @@ -1,3 +1,4 @@ +from sqlalchemy import NullPool from sqlalchemy.ext.asyncio import ( async_sessionmaker, create_async_engine, @@ -16,6 +17,7 @@ class Base(DeclarativeBase): engine = create_async_engine( url=settings.database.url, echo=settings.database.echo, + poolclass=NullPool, ) session_factory = async_sessionmaker( diff --git a/app/services/movie.py b/app/services/movie.py index 8f44f63..b257db1 100644 --- a/app/services/movie.py +++ b/app/services/movie.py @@ -31,7 +31,7 @@ class MovieService: def __init__( self, session: AsyncSession, - rabbitmq_service: RabbitMQService, + rabbitmq_service: RabbitMQService | None = None, ) -> None: self.session = session self.user_repository = UserRepository(session) diff --git a/docker-compose.yml b/docker-compose.yml index 31cc3e2..7b2041c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -201,6 +201,9 @@ services: - path: media-service action: sync+restart target: /media-service + - path: ./packages + action: sync+restart + target: /media-service/packages depends_on: rabbitmq: condition: service_healthy @@ -216,10 +219,49 @@ services: - path: notification-service action: sync+restart target: /notification-service + - path: ./packages + action: sync+restart + target: /notification-service/packages + depends_on: + rabbitmq: + condition: service_healthy + + celery-worker-app: + build: + context: ./ + dockerfile: app/Dockerfile + container_name: celery-worker-app + command: uv run celery --app core.celery.celery_app worker -Q movie-catalog --loglevel=INFO + develop: + watch: + - path: ./app + action: sync+restart + target: /app + - path: ./packages + action: sync+restart + target: /app/packages depends_on: rabbitmq: condition: service_healthy + celery-beat-app: + build: + context: ./ + dockerfile: app/Dockerfile + container_name: celery-beat-app + command: uv run celery --app core.celery.celery_app beat --loglevel=INFO + develop: + watch: + - path: ./app + action: sync+restart + target: /app + - path: ./packages + action: sync+restart + target: /app/packages + depends_on: + celery-worker-notification-service: + condition: service_started + maildev: image: maildev/maildev container_name: maidev diff --git a/notification-service/api/api_v1/send_email_views.py b/notification-service/api/api_v1/send_email_views.py index f1b9d17..9289a7b 100644 --- a/notification-service/api/api_v1/send_email_views.py +++ b/notification-service/api/api_v1/send_email_views.py @@ -4,7 +4,7 @@ from service import EmailService router = APIRouter( - tags=["Send email"], + tags=["Send Email"], ) diff --git a/notification-service/core/celery/celery_app.py b/notification-service/core/celery/celery_app.py index 4e8eb2a..465bd16 100644 --- a/notification-service/core/celery/celery_app.py +++ b/notification-service/core/celery/celery_app.py @@ -4,5 +4,6 @@ app = Celery( "core.celery.celery_app", broker=package_settings.rabbitmq.rabbitmq_url, + backend="redis://redis:6379/0", include=["core.celery.tasks"], ) diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index 8ab6fa8..73649d3 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -1,6 +1,7 @@ import asyncio from packages.celery.constants import TaskType +from packages.schemas import MovieEmailSendDataList, UserEmailSendDataList from pydantic import EmailStr from core.celery.celery_app import app @@ -64,3 +65,21 @@ def send_reset_password_email_data( confirmation_code=confirmation_code, ), ) + + +@app.task( # type: ignore[untyped-decorator] + name=TaskType.send_spam_email.value, +) +def send_spam_email( + data: list, +) -> None: + user_data_list, movie_data_list = data + user_data = UserEmailSendDataList.model_validate(user_data_list) + movie_data = MovieEmailSendDataList.model_validate(movie_data_list) + print("send_spam_email") + asyncio.run( + EmailService.send_reminder_email( + movie_data_list=movie_data, + user_data_list=user_data, + ), + ) diff --git a/notification-service/service.py b/notification-service/service.py index d43db0a..56b13bc 100644 --- a/notification-service/service.py +++ b/notification-service/service.py @@ -1,6 +1,7 @@ from email.message import EmailMessage from aiosmtplib import SMTP +from packages.schemas import MovieEmailSendDataList, UserEmailSendDataList from pydantic import EmailStr from core.config import settings @@ -168,3 +169,22 @@ async def send_reset_password_email_data( ), to_email=email, ) + + @classmethod + async def send_reminder_email( + cls, + movie_data_list: MovieEmailSendDataList, + user_data_list: UserEmailSendDataList, + ) -> None: + subject = "Проверка работы!" + body_template = """ + Данное сообщение предназначено для пользователя {name}! + """ + print("user_data_list", user_data_list) + print("movie_data_list", movie_data_list) + for user in user_data_list.user_list: + await cls.send_email( + subject=subject, + body=body_template.format(name=user.name), + to_email=user.email, + ) diff --git a/packages/celery/constants.py b/packages/celery/constants.py index 3bed5f1..5506b3d 100644 --- a/packages/celery/constants.py +++ b/packages/celery/constants.py @@ -2,6 +2,7 @@ class Queue(StrEnum): + app = "movie-catalog" mediaservice = "media-service" notification = "notification-service" @@ -16,3 +17,7 @@ class TaskType(StrEnum): send_reset_password_email_data = ( "notification-service.email.send_reset_password_email_data" # noqa: S105 ) + + prepare_inactive_users = "notification-service.email.prepare_inactive_users" + prepare_newest_movies = "notification-service.email.prepare_newest_movies" + send_spam_email = "notification-service.email.send-spam-email" diff --git a/packages/schemas.py b/packages/schemas.py index 57143e6..0e15914 100644 --- a/packages/schemas.py +++ b/packages/schemas.py @@ -43,3 +43,36 @@ class SendEmail(BaseModel): subject: str to_email: EmailStr body: str + + +class UserEmailSendData(BaseModel): + """ + Модель для отправки данных в фоновую задачу по отправке напоминаний о сервисе. + """ + + email: EmailStr + name: str + + +class UserEmailSendDataList(BaseModel): + """ + Список пользователь для массовой рассылки напоминаний о сервисе. + """ + + user_list: list[UserEmailSendData] + + +class MovieEmailSendData(BaseModel): + """ + Модель для данных о фильме, которые будут упоминаться в спам письме. + """ + + name: str + + +class MovieEmailSendDataList(BaseModel): + """ + Модель для данных о фильмах, которые будут упоминаться в спам письме. + """ + + movie_list: list[MovieEmailSendData] From f4bba4f10cd6e32bee6df98469b3ff0fb2a11fc0 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Fri, 26 Jun 2026 18:29:13 +0300 Subject: [PATCH 41/47] Small fixes. --- app/core/celery/celery_app.py | 10 ++++++---- app/core/celery/tasks.py | 12 ++++-------- notification-service/core/celery/tasks.py | 7 ++++--- packages/celery/constants.py | 7 ++++--- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/core/celery/celery_app.py b/app/core/celery/celery_app.py index b49fce6..4cdd147 100644 --- a/app/core/celery/celery_app.py +++ b/app/core/celery/celery_app.py @@ -1,16 +1,18 @@ from celery import Celery +from packages.celery.constants import Queue, TaskType +from packages.config import settings as package_settings app = Celery( "core.celery.celery_app", - broker="amqp://guest:guest@rabbitmq:5672/%2f", + broker=package_settings.rabbitmq.rabbitmq_url, backend="redis://redis:6379/0", include=["core.celery.tasks"], ) app.conf.beat_schedule = { "run-spam-every-30-seconds": { - "task": "testing", - "schedule": 5 * 60, - "options": {"queue": "movie-catalog"}, + "task": TaskType.create_chain_user_reminder.value, + "schedule": 30, + "options": {"queue": Queue.app}, }, } diff --git a/app/core/celery/tasks.py b/app/core/celery/tasks.py index 65724ce..d75be6b 100644 --- a/app/core/celery/tasks.py +++ b/app/core/celery/tasks.py @@ -42,11 +42,9 @@ async def get_newest_movies() -> MovieEmailSendDataList: name=TaskType.prepare_inactive_users.value, ) def prepare_inactive_users() -> dict: - print("IN 35") loop = asyncio.new_event_loop() result = loop.run_until_complete(get_inactive_users()) loop.close() - # result = asyncio.run(get_inactive_users()) return result.model_dump() @@ -54,25 +52,23 @@ def prepare_inactive_users() -> dict: name=TaskType.prepare_newest_movies.value, ) def prepare_newest_movies() -> dict: - print("IN 43") loop = asyncio.new_event_loop() result = loop.run_until_complete(get_newest_movies()) loop.close() - # result = asyncio.run(get_newest_movies()) return result.model_dump() -@app.task(name="testing") +@app.task( + name=TaskType.create_chain_user_reminder.value, +) def test() -> None: - print("in testing") chained_group = group( prepare_inactive_users.s().set(queue=Queue.app.value), prepare_newest_movies.s().set(queue=Queue.app.value), ) notify = app.signature( - TaskType.send_spam_email.value, + TaskType.send_inactive_user_reminder.value, queue=Queue.notification.value, ) - print("IN 55") chord(chained_group)(notify) diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index 73649d3..8ad3b9c 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -68,7 +68,7 @@ def send_reset_password_email_data( @app.task( # type: ignore[untyped-decorator] - name=TaskType.send_spam_email.value, + name=TaskType.send_inactive_user_reminder.value, ) def send_spam_email( data: list, @@ -76,10 +76,11 @@ def send_spam_email( user_data_list, movie_data_list = data user_data = UserEmailSendDataList.model_validate(user_data_list) movie_data = MovieEmailSendDataList.model_validate(movie_data_list) - print("send_spam_email") - asyncio.run( + loop = asyncio.new_event_loop() + loop.run_until_complete( EmailService.send_reminder_email( movie_data_list=movie_data, user_data_list=user_data, ), ) + loop.close() diff --git a/packages/celery/constants.py b/packages/celery/constants.py index 5506b3d..646fbb2 100644 --- a/packages/celery/constants.py +++ b/packages/celery/constants.py @@ -18,6 +18,7 @@ class TaskType(StrEnum): "notification-service.email.send_reset_password_email_data" # noqa: S105 ) - prepare_inactive_users = "notification-service.email.prepare_inactive_users" - prepare_newest_movies = "notification-service.email.prepare_newest_movies" - send_spam_email = "notification-service.email.send-spam-email" + prepare_inactive_users = "movie-catalog.email.prepare_inactive_users" + prepare_newest_movies = "movie-catalog.email.prepare_newest_movies" + create_chain_user_reminder = "movie-catalog.email.create_chain_user_reminder" + send_inactive_user_reminder = "notification-service.email.send-inactive-user-email" From b326ac3a5413b9d59eb60b3ee4b0a689e50fdf49 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Fri, 26 Jun 2026 22:13:28 +0300 Subject: [PATCH 42/47] Create email template for user reminder. --- app/core/celery/celery_app.py | 2 +- notification-service/service.py | 67 ++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/app/core/celery/celery_app.py b/app/core/celery/celery_app.py index 4cdd147..461dfbf 100644 --- a/app/core/celery/celery_app.py +++ b/app/core/celery/celery_app.py @@ -12,7 +12,7 @@ app.conf.beat_schedule = { "run-spam-every-30-seconds": { "task": TaskType.create_chain_user_reminder.value, - "schedule": 30, + "schedule": 5, "options": {"queue": Queue.app}, }, } diff --git a/notification-service/service.py b/notification-service/service.py index 56b13bc..6021d47 100644 --- a/notification-service/service.py +++ b/notification-service/service.py @@ -1,7 +1,11 @@ from email.message import EmailMessage from aiosmtplib import SMTP -from packages.schemas import MovieEmailSendDataList, UserEmailSendDataList +from packages.schemas import ( + MovieEmailSendDataList, + UserEmailSendData, + UserEmailSendDataList, +) from pydantic import EmailStr from core.config import settings @@ -159,6 +163,8 @@ async def send_reset_password_email_data( Код подтверждения действует 60 секунд. Если вы не запрашивали восстановление, просто проигнорируйте это письмо. + + — Команда MovieAPI """ # ruff: enable[W293] await cls.send_email( @@ -170,21 +176,64 @@ async def send_reset_password_email_data( to_email=email, ) + @classmethod + async def send_user_reminder_email( + cls, + user: UserEmailSendData, + subject_template: str, + body_template: str, + **kwargs, + ) -> None: + await cls.send_email( + subject=subject_template.format(name=kwargs["name"]), + body=body_template.format(name=kwargs["name"]), + to_email=user.email, + ) + @classmethod async def send_reminder_email( cls, movie_data_list: MovieEmailSendDataList, user_data_list: UserEmailSendDataList, ) -> None: - subject = "Проверка работы!" + subject_template = "{name}, мы по вам соскучились! 🎬 Готовы зажечь экран?" + body_template = """ - Данное сообщение предназначено для пользователя {name}! + Привет, {name}! + + Давно не виделись. Мы заметили, что вы уже целую вечность не заглядывали + + в наш кинотеатр, а ведь без вашего мнения обсуждения стали тише... + + Чтобы исправить это, мы подготовили для вас персональную подборку из + + свежих новинок, которые вышли совсем недавно. Мы уверены, что среди них + + есть тот самый фильм, ради которого стоит устроить уютный вечер с пледом и попкорном. + + Ваша эксклюзивная подборка новинок: + """ - print("user_data_list", user_data_list) - print("movie_data_list", movie_data_list) + + movie_template = """ + {number}) {movie_name} + + """ + movies_body = "".join( + [ + movie_template.format( + number=i + 1, + movie_name=movie.name, + ) + for i, movie in enumerate(movie_data_list.movie_list) + ], + ) + author_message = "— Команда MovieAPI" + body_template += movies_body + author_message for user in user_data_list.user_list: - await cls.send_email( - subject=subject, - body=body_template.format(name=user.name), - to_email=user.email, + await cls.send_user_reminder_email( + user, + subject_template, + body_template, + name=user.name, ) From fd69f93a49385435279318e0982badcfb945cb77 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Sun, 28 Jun 2026 16:20:46 +0300 Subject: [PATCH 43/47] Clean code for ease of use. --- README.md | 66 ++++++++------- app/api/api_v1/genres/details_views.py | 2 +- app/api/api_v1/genres/list_views.py | 2 +- app/api/api_v1/media.py | 2 +- app/api/api_v1/movies/details_views.py | 2 +- app/api/api_v1/movies/list_views.py | 2 +- app/api/api_v1/users/details_views.py | 14 +--- app/core/celery/celery_app.py | 15 +++- app/core/celery/tasks.py | 81 +++++------------- app/core/celery/utils.py | 65 +++++++++++++++ app/core/config.py | 21 +++++ app/core/constants.py | 1 + app/schemas/auth.py | 14 +--- app/schemas/constraints/__init__.py | 0 app/schemas/constraints/auth.py | 13 +++ app/schemas/constraints/genre.py | 21 +++++ app/schemas/constraints/movie.py | 31 +++++++ app/schemas/constraints/review.py | 22 +++++ app/schemas/constraints/user.py | 60 ++++++++++++++ app/schemas/genre.py | 22 +---- app/schemas/movie.py | 36 ++------ app/schemas/review.py | 23 +---- app/schemas/user.py | 83 +++---------------- app/services/auth.py | 6 +- app/services/user.py | 2 +- docker-compose.yml | 2 +- media-service/api/api_v1/file_views.py | 4 +- media-service/core/celery/celery_app.py | 2 +- media-service/core/celery/tasks.py | 4 +- media-service/core/minio/service.py | 10 ++- media-service/core/rabbitmq/consumers.py | 2 +- .../api/api_v1/send_email_views.py | 4 +- .../core/celery/celery_app.py | 2 +- notification-service/core/celery/tasks.py | 42 +++++----- notification-service/service.py | 32 +++---- packages/celery/constants.py | 15 ++-- packages/celery/utils.py | 7 ++ packages/config.py | 2 +- packages/minio/__init__.py | 0 packages/{ => minio}/constants.py | 0 packages/rabbitmq/connection.py | 2 +- packages/schemas.py | 78 ----------------- packages/schemas/__init__.py | 0 packages/schemas/media.py | 35 ++++++++ packages/schemas/notification.py | 59 +++++++++++++ 45 files changed, 504 insertions(+), 404 deletions(-) create mode 100644 app/core/celery/utils.py create mode 100644 app/schemas/constraints/__init__.py create mode 100644 app/schemas/constraints/auth.py create mode 100644 app/schemas/constraints/genre.py create mode 100644 app/schemas/constraints/movie.py create mode 100644 app/schemas/constraints/review.py create mode 100644 app/schemas/constraints/user.py create mode 100644 packages/celery/utils.py create mode 100644 packages/minio/__init__.py rename packages/{ => minio}/constants.py (100%) delete mode 100644 packages/schemas.py create mode 100644 packages/schemas/__init__.py create mode 100644 packages/schemas/media.py create mode 100644 packages/schemas/notification.py diff --git a/README.md b/README.md index c7d2037..8502565 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,40 @@ -Сделать бэкап БД: +Quick start + +Configure minio: +```bash +docker exec -it minio mc alias set myminio http://localhost:9000 admin adminadmin +docker exec -it minio mc mb myminio/genre-posters --ignore-existing +docker exec -it minio mc mb myminio/movie-posters --ignore-existing +docker exec -it minio mc mb myminio/movies --ignore-existing +docker exec -it minio mc anonymous set download myminio/genre-posters +docker exec -it minio mc anonymous set download myminio/movie-posters +docker exec -it minio mc anonymous set download myminio/movies +``` + + +Start app first time: +```bash +docker compose build; docker compose up --watch +``` + + +Build app: +```bash +docker compose build +``` + +Run app: +```bash +docker compose up --watch +``` + +Backup database: +```bash docker exec -it database pg_dump -U postgres -d '"movie-catalog"' --data-only -f /tmp/backup_utf8.sql docker cp database:/tmp/backup_utf8.sql ./backup.sql +``` -Применить бэкап БД: +```bash docker cp ./backup.sql database:/tmp/backup.sql docker exec -it database psql -U postgres -d '"movie-catalog"' -f /tmp/backup.sql - - -Minio: -mc alias set myminio http://localhost:9000 admin adminadmin -mc anonymous set download myminio/movie-posters - - - -В ссылке нужно minio заменить на localhost. Вместо header пишем то, что указали в content_type - - -curl.exe -X PUT "http://localhost:9000/genre-posters/tmp/genre/4e702647-dccb-4f7c-9bb3-41e7c7e22aa7_photo123.png?AWSAccessKeyId=admin&Signature=6wYwqZVd2HUPcPx2f%2F9k6KW3gUs%3D&content-type=image%2Fpng&Expires=1780331034" --data-binary "@photo123.png" -H "Content-Type: image/png" - - -Итог: - -( -echo -ne "PUT /genre-posters/tmp/genre/40279a3b-2baf-4c36-b37b-1dff63fea25f_test.png?AWSAccessKeyId=admin&Signature=7d6A7K1P8azTtHsuTMq36AlxB%2FU%3D&content-type=image%2Fpng&Expires=1780402810 HTTP/1.1\r\n" -echo -ne "Host: minio:9000\r\n" -echo -ne "Content-Type: image/png\r\n" -echo -ne "Content-Length: $(wc -c < test.png)\r\n" -echo -ne "Connection: close\r\n\r\n" -cat test.png -sleep 1 -) | nc minio 9000 - - -run mypy: -$env:MYPYPATH="app;."; mypy app; $env:MYPYPATH="mediaservice;."; mypy mediaservice; $env:MYPYPATH="packages;."; mypy packages +``` diff --git a/app/api/api_v1/genres/details_views.py b/app/api/api_v1/genres/details_views.py index af41446..d061279 100644 --- a/app/api/api_v1/genres/details_views.py +++ b/app/api/api_v1/genres/details_views.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, status -from packages.constants import S3Bucket +from packages.minio.constants import S3Bucket from core.constants import BASE_MINIO_URL from dependencies.annotations.cache_services import GenreCacheServiceDep diff --git a/app/api/api_v1/genres/list_views.py b/app/api/api_v1/genres/list_views.py index dcf481d..b0ccf50 100644 --- a/app/api/api_v1/genres/list_views.py +++ b/app/api/api_v1/genres/list_views.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, status -from packages.constants import S3Bucket +from packages.minio.constants import S3Bucket from core.constants import BASE_MINIO_URL from dependencies.annotations.cache_services import GenreCacheServiceDep diff --git a/app/api/api_v1/media.py b/app/api/api_v1/media.py index fd4421a..a2dbec1 100644 --- a/app/api/api_v1/media.py +++ b/app/api/api_v1/media.py @@ -1,7 +1,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, status -from packages.schemas import PresignUrlCreate, PresignUrlResponse +from packages.schemas.media import PresignUrlCreate, PresignUrlResponse from core.config import settings from core.constants import MethodType diff --git a/app/api/api_v1/movies/details_views.py b/app/api/api_v1/movies/details_views.py index 514f14d..bc01729 100644 --- a/app/api/api_v1/movies/details_views.py +++ b/app/api/api_v1/movies/details_views.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, status -from packages.constants import S3Bucket +from packages.minio.constants import S3Bucket from core.constants import BASE_MINIO_URL from dependencies.annotations.cache_services import MovieCacheServiceDep diff --git a/app/api/api_v1/movies/list_views.py b/app/api/api_v1/movies/list_views.py index 8abff20..c08dc42 100644 --- a/app/api/api_v1/movies/list_views.py +++ b/app/api/api_v1/movies/list_views.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, status -from packages.constants import S3Bucket +from packages.minio.constants import S3Bucket from starlette.responses import RedirectResponse from core.constants import BASE_MINIO_URL diff --git a/app/api/api_v1/users/details_views.py b/app/api/api_v1/users/details_views.py index ac8c8c6..b456520 100644 --- a/app/api/api_v1/users/details_views.py +++ b/app/api/api_v1/users/details_views.py @@ -1,29 +1,19 @@ from fastapi import APIRouter, Depends, status from dependencies.annotations.cache_services import UserCacheServiceDep -from dependencies.annotations.services import UserServiceDep from dependencies.annotations.validators import PaginationPageDep, PaginationSizeDep +from dependencies.auth import get_admin_by_access_token from dependencies.rate_limiter import check_rate_limit_auth from schemas.user import UserResponse, UserResponseList router = APIRouter( dependencies=[ - # Depends(get_admin_by_access_token), + Depends(get_admin_by_access_token), Depends(check_rate_limit_auth), ], ) -@router.get( - "/inactive", - status_code=status.HTTP_200_OK, -) -async def get_inactive_users( - user_service: UserServiceDep, -) -> UserResponseList: - return await user_service.get_inactive_users() - - @router.get( "/", response_model=UserResponseList, diff --git a/app/core/celery/celery_app.py b/app/core/celery/celery_app.py index 461dfbf..07c52b1 100644 --- a/app/core/celery/celery_app.py +++ b/app/core/celery/celery_app.py @@ -1,18 +1,25 @@ from celery import Celery +from celery.schedules import crontab from packages.celery.constants import Queue, TaskType from packages.config import settings as package_settings +from core.config import settings + app = Celery( "core.celery.celery_app", - broker=package_settings.rabbitmq.rabbitmq_url, + broker=package_settings.rabbitmq.url, backend="redis://redis:6379/0", include=["core.celery.tasks"], ) app.conf.beat_schedule = { - "run-spam-every-30-seconds": { - "task": TaskType.create_chain_user_reminder.value, - "schedule": 5, + "notify-inactive-users-with-movie-picks": { + "task": TaskType.create_chain_to_notify_inactive_users.value, + "schedule": crontab( + day_of_week=settings.celery.beat.notify_inactive_users_with_movie_picks.day_of_week, + hour=settings.celery.beat.notify_inactive_users_with_movie_picks.hour, + minute=settings.celery.beat.notify_inactive_users_with_movie_picks.minute, + ), "options": {"queue": Queue.app}, }, } diff --git a/app/core/celery/tasks.py b/app/core/celery/tasks.py index d75be6b..c0a73ce 100644 --- a/app/core/celery/tasks.py +++ b/app/core/celery/tasks.py @@ -1,74 +1,33 @@ -import asyncio -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager - -from celery import chord, group +from celery import chain from packages.celery.constants import Queue, TaskType -from packages.schemas import MovieEmailSendDataList, UserEmailSendDataList - -from core.constants import SortMonotony, SortType -from core.rabbitmq.utils import get_session, get_user_service -from schemas.movie import MovieFilter -from services import MovieService +from packages.celery.utils import sync_run_coroutine_function from .celery_app import app - - -@asynccontextmanager -async def get_movie_service() -> AsyncGenerator[MovieService]: - async with get_session() as session: - movie_service = MovieService(session) - yield movie_service - - -async def get_inactive_users() -> UserEmailSendDataList: - async with get_user_service() as user_service: - return await user_service.get_inactive_users() - - -async def get_newest_movies() -> MovieEmailSendDataList: - async with get_movie_service() as movie_service: - movie_filter = MovieFilter( - sort_by=SortType.date.value, - sorting_direction=SortMonotony.descending.value, - ) - - return await movie_service.search_movies_with_filters( - movie_filter=movie_filter, - ) +from .utils import get_data_to_send_inactive_users_movie_selection @app.task( - name=TaskType.prepare_inactive_users.value, + name=TaskType.get_data_to_send_inactive_users_email.value, ) -def prepare_inactive_users() -> dict: - loop = asyncio.new_event_loop() - result = loop.run_until_complete(get_inactive_users()) - loop.close() - return result.model_dump() - - -@app.task( - name=TaskType.prepare_newest_movies.value, -) -def prepare_newest_movies() -> dict: - loop = asyncio.new_event_loop() - result = loop.run_until_complete(get_newest_movies()) - loop.close() - return result.model_dump() +def get_data_to_send_inactive_users_email() -> dict: + send_inactive_users_email_data = sync_run_coroutine_function( + get_data_to_send_inactive_users_movie_selection(), + ) + return send_inactive_users_email_data.model_dump() @app.task( - name=TaskType.create_chain_user_reminder.value, + name=TaskType.create_chain_to_notify_inactive_users.value, ) -def test() -> None: - chained_group = group( - prepare_inactive_users.s().set(queue=Queue.app.value), - prepare_newest_movies.s().set(queue=Queue.app.value), +def create_chain_to_notify_inactive_users() -> None: + send_inactive_users_email = app.signature( + TaskType.send_inactive_users_email.value, + queue=Queue.notification_service.value, ) - - notify = app.signature( - TaskType.send_inactive_user_reminder.value, - queue=Queue.notification.value, + task_chain = chain( + get_data_to_send_inactive_users_email.s().set( + queue=Queue.app.value, + ), + send_inactive_users_email, ) - chord(chained_group)(notify) + task_chain.apply_async() diff --git a/app/core/celery/utils.py b/app/core/celery/utils.py new file mode 100644 index 0000000..5b2e454 --- /dev/null +++ b/app/core/celery/utils.py @@ -0,0 +1,65 @@ +from asyncio import gather + +from packages.rabbitmq.connection import rabbitmq_connection_startup +from packages.schemas.notification import ( + InactiveUser, + InactiveUserList, + SelectedMovie, + SelectedMovieList, + SendInactiveUsersMovieSelectionData, +) + +from core.constants import SortMonotony, SortType +from core.rabbitmq.utils import get_movie_service, get_user_service +from schemas.movie import MovieFilter + + +async def get_inactive_users() -> InactiveUserList: + async with get_user_service() as user_service: + inactive_users = await user_service.get_inactive_users() + inactive_user_list = [ + InactiveUser( + name=user.name, + email=user.email, + ) + for user in inactive_users.user_list + ] + return InactiveUserList(inactive_user_list=inactive_user_list) + + +async def get_movie_selection() -> SelectedMovieList: + await rabbitmq_connection_startup() + async with get_movie_service() as movie_service: + movie_filter = MovieFilter( + sort_by=SortType.date.value, + sorting_direction=SortMonotony.descending.value, + ) + selected_movies = await movie_service.search_movies_with_filters( + movie_filter=movie_filter, + ) + movie_selection = [ + SelectedMovie( + name=movie.name, + genre_name=movie.genre.name, + rating=movie.rating, + source_url=movie.source_url, + release_date=movie.release_date, + ) + for movie in selected_movies.movie_list + ] + return SelectedMovieList( + selected_movie_list=movie_selection, + ) + + +async def get_data_to_send_inactive_users_movie_selection() -> ( + SendInactiveUsersMovieSelectionData +): + inactive_users, movie_selection = await gather( + get_inactive_users(), + get_movie_selection(), + ) + return SendInactiveUsersMovieSelectionData( + inactive_users=inactive_users, + movie_selection=movie_selection, + ) diff --git a/app/core/config.py b/app/core/config.py index d1e67a4..093912a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -90,6 +90,26 @@ class JWTConfig(BaseModel): reset_password: ResetPasswordJWTConfig = ResetPasswordJWTConfig() +class ScheduleConfig(BaseModel): + minute: str | int = "*" + hour: str | int = "*" + day_of_week: str | int = "*" + day_of_month: str | int = "*" + month_of_year: str | int = "*" + + +class CeleryBeatConfig(BaseModel): + notify_inactive_users_with_movie_picks: ScheduleConfig = ScheduleConfig( + day_of_week="sun", + hour=16, + minute=0, + ) + + +class CeleryConfig(BaseModel): + beat: CeleryBeatConfig = CeleryBeatConfig() + + class MediaServiceConfig(BaseModel): host: str = "media-service" port: int = 8000 @@ -114,6 +134,7 @@ class Settings(BaseSettings): redis: RedisConfig = RedisConfig() rabbitmq: RabbitMQConfig = RabbitMQConfig() jwt: JWTConfig = JWTConfig() + celery: CeleryConfig = CeleryConfig() http_bearer: HTTPBearer = HTTPBearer() oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer( "/api/v1/auth/login/", diff --git a/app/core/constants.py b/app/core/constants.py index 6e6b9e4..cf02d98 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -67,6 +67,7 @@ class ConfirmationCodeType(StrEnum): ATTEMPT_FIELD = "attempt" MAX_CONFIRM_CODE_ATTEMPTS = 5 +CONFIRMATION_CODE_LENGTH = 6 TOKEN_TYPE_FIELD = "type" LOGIN_FIELD = "login" diff --git a/app/schemas/auth.py b/app/schemas/auth.py index c916f83..1deafb5 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -1,17 +1,7 @@ -from typing import Annotated - -from annotated_types import Len from pydantic import BaseModel, EmailStr -from schemas.user import LoginConstraint, PasswordConstraint - -ConfirmationCodeConstraint = Annotated[ - str, - Len( - min_length=6, - max_length=6, - ), -] +from schemas.constraints.auth import ConfirmationCodeConstraint +from schemas.constraints.user import LoginConstraint, PasswordConstraint class UserLogin(BaseModel): diff --git a/app/schemas/constraints/__init__.py b/app/schemas/constraints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/constraints/auth.py b/app/schemas/constraints/auth.py new file mode 100644 index 0000000..80749eb --- /dev/null +++ b/app/schemas/constraints/auth.py @@ -0,0 +1,13 @@ +from typing import Annotated + +from annotated_types import Len + +from core.constants import CONFIRMATION_CODE_LENGTH + +ConfirmationCodeConstraint = Annotated[ + str, + Len( + min_length=CONFIRMATION_CODE_LENGTH, + max_length=CONFIRMATION_CODE_LENGTH, + ), +] diff --git a/app/schemas/constraints/genre.py b/app/schemas/constraints/genre.py new file mode 100644 index 0000000..223e205 --- /dev/null +++ b/app/schemas/constraints/genre.py @@ -0,0 +1,21 @@ +from typing import Annotated + +from annotated_types import Len, MaxLen + +from core.constants import ( + GENRE_DESCRIPTION_MAX_LENGTH, + GENRE_NAME_MAX_LENGTH, + GENRE_NAME_MIN_LENGTH, +) + +NameConstraint = Annotated[ + str, + Len( + min_length=GENRE_NAME_MIN_LENGTH, + max_length=GENRE_NAME_MAX_LENGTH, + ), +] +DescriptionConstraint = Annotated[ + str, + MaxLen(max_length=GENRE_DESCRIPTION_MAX_LENGTH), +] diff --git a/app/schemas/constraints/movie.py b/app/schemas/constraints/movie.py new file mode 100644 index 0000000..387fdc5 --- /dev/null +++ b/app/schemas/constraints/movie.py @@ -0,0 +1,31 @@ +from typing import Annotated + +from annotated_types import Len, MaxLen +from pydantic import Field + +from core.constants import ( + MOVIE_DESCRIPTION_MAX_LENGTH, + MOVIE_NAME_MAX_LENGTH, + MOVIE_NAME_MIN_LENGTH, + MOVIE_RATING_MAX_VALUE, + MOVIE_RATING_MIN_VALUE, +) + +NameConstraint = Annotated[ + str, + Len( + min_length=MOVIE_NAME_MIN_LENGTH, + max_length=MOVIE_NAME_MAX_LENGTH, + ), +] +DescriptionConstraint = Annotated[ + str, + MaxLen(max_length=MOVIE_DESCRIPTION_MAX_LENGTH), +] +RatingConstraint = Annotated[ + float, + Field( + ge=MOVIE_RATING_MIN_VALUE, + le=MOVIE_RATING_MAX_VALUE, + ), +] diff --git a/app/schemas/constraints/review.py b/app/schemas/constraints/review.py new file mode 100644 index 0000000..df8ea58 --- /dev/null +++ b/app/schemas/constraints/review.py @@ -0,0 +1,22 @@ +from typing import Annotated + +from annotated_types import MaxLen +from pydantic import Field + +from core.constants import ( + REVIEW_RATING_MAX_VALUE, + REVIEW_RATING_MIN_VALUE, + REVIEW_TEXT_MAX_LENGTH, +) + +ReviewTextConstraint = Annotated[ + str, + MaxLen(max_length=REVIEW_TEXT_MAX_LENGTH), +] +RatingConstraint = Annotated[ + int, + Field( + ge=REVIEW_RATING_MIN_VALUE, + le=REVIEW_RATING_MAX_VALUE, + ), +] diff --git a/app/schemas/constraints/user.py b/app/schemas/constraints/user.py new file mode 100644 index 0000000..2a54088 --- /dev/null +++ b/app/schemas/constraints/user.py @@ -0,0 +1,60 @@ +from typing import Annotated + +from annotated_types import Len +from pydantic import EmailStr + +from core.constants import ( + USER_EMAIL_MAX_LENGTH, + USER_EMAIL_MIN_LENGTH, + USER_ENCRYPTED_PASSWORD_MAX_LENGTH, + USER_LOGIN_MAX_LENGTH, + USER_LOGIN_MIN_LENGTH, + USER_NAME_MAX_LENGTH, + USER_NAME_MIN_LENGTH, + USER_PASSWORD_MAX_LENGTH, + USER_PASSWORD_MIN_LENGTH, + USER_SURNAME_MAX_LENGTH, + USER_SURNAME_MIN_LENGTH, +) + +SurnameConstraint = Annotated[ + str, + Len( + min_length=USER_SURNAME_MIN_LENGTH, + max_length=USER_SURNAME_MAX_LENGTH, + ), +] +NameConstraint = Annotated[ + str, + Len( + min_length=USER_NAME_MIN_LENGTH, + max_length=USER_NAME_MAX_LENGTH, + ), +] +LoginConstraint = Annotated[ + str, + Len( + min_length=USER_LOGIN_MIN_LENGTH, + max_length=USER_LOGIN_MAX_LENGTH, + ), +] +EmailConstraint = Annotated[ + EmailStr, + Len( + min_length=USER_EMAIL_MIN_LENGTH, + max_length=USER_EMAIL_MAX_LENGTH, + ), +] +PasswordConstraint = Annotated[ + str, + Len( + min_length=USER_PASSWORD_MIN_LENGTH, + max_length=USER_PASSWORD_MAX_LENGTH, + ), +] +EncryptedPasswordConstraint = Annotated[ + str, + Len( + max_length=USER_ENCRYPTED_PASSWORD_MAX_LENGTH, + ), +] diff --git a/app/schemas/genre.py b/app/schemas/genre.py index fcf233b..805c4d9 100644 --- a/app/schemas/genre.py +++ b/app/schemas/genre.py @@ -1,26 +1,8 @@ -from typing import Annotated, ClassVar +from typing import ClassVar -from annotated_types import Len, MaxLen from pydantic import BaseModel, ConfigDict -from core.constants import ( - GENRE_DESCRIPTION_MAX_LENGTH, - GENRE_NAME_MAX_LENGTH, - GENRE_NAME_MIN_LENGTH, -) - -NameConstraint = Annotated[ - str, - Len( - min_length=GENRE_NAME_MIN_LENGTH, - max_length=GENRE_NAME_MAX_LENGTH, - ), -] - -DescriptionConstraint = Annotated[ - str, - MaxLen(max_length=GENRE_DESCRIPTION_MAX_LENGTH), -] +from schemas.constraints.genre import DescriptionConstraint, NameConstraint class GenreBase(BaseModel): diff --git a/app/schemas/movie.py b/app/schemas/movie.py index 18b5d77..1ff20b3 100644 --- a/app/schemas/movie.py +++ b/app/schemas/movie.py @@ -1,41 +1,19 @@ from datetime import date -from typing import Annotated, ClassVar +from typing import ClassVar -from annotated_types import Len, MaxLen -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict from core.constants import ( - MOVIE_DESCRIPTION_MAX_LENGTH, - MOVIE_NAME_MAX_LENGTH, - MOVIE_NAME_MIN_LENGTH, - MOVIE_RATING_MAX_VALUE, - MOVIE_RATING_MIN_VALUE, SortMonotony, SortType, ) +from schemas.constraints.movie import ( + DescriptionConstraint, + NameConstraint, + RatingConstraint, +) from schemas.genre import GenreResponse -NameConstraint = Annotated[ - str, - Len( - min_length=MOVIE_NAME_MIN_LENGTH, - max_length=MOVIE_NAME_MAX_LENGTH, - ), -] - -DescriptionConstraint = Annotated[ - str, - MaxLen(max_length=MOVIE_DESCRIPTION_MAX_LENGTH), -] - -RatingConstraint = Annotated[ - float, - Field( - ge=MOVIE_RATING_MIN_VALUE, - le=MOVIE_RATING_MAX_VALUE, - ), -] - class MovieBase(BaseModel): """ diff --git a/app/schemas/review.py b/app/schemas/review.py index 1681313..2d9e426 100644 --- a/app/schemas/review.py +++ b/app/schemas/review.py @@ -1,29 +1,12 @@ from datetime import datetime -from typing import Annotated, ClassVar +from typing import ClassVar -from annotated_types import MaxLen -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict -from core.constants import ( - REVIEW_RATING_MAX_VALUE, - REVIEW_RATING_MIN_VALUE, - REVIEW_TEXT_MAX_LENGTH, -) +from schemas.constraints.review import RatingConstraint, ReviewTextConstraint from schemas.movie import MovieResponse from schemas.user import UserResponse -ReviewTextConstraint = Annotated[ - str, - MaxLen(max_length=REVIEW_TEXT_MAX_LENGTH), -] -RatingConstraint = Annotated[ - int, - Field( - ge=REVIEW_RATING_MIN_VALUE, - le=REVIEW_RATING_MAX_VALUE, - ), -] - class ReviewBase(BaseModel): """ diff --git a/app/schemas/user.py b/app/schemas/user.py index 6d9e0f3..ce2320b 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -1,77 +1,16 @@ from datetime import datetime -from typing import Annotated, ClassVar - -from annotated_types import Len -from pydantic import BaseModel, ConfigDict, EmailStr - -from core.constants import ( - USER_EMAIL_MAX_LENGTH, - USER_EMAIL_MIN_LENGTH, - USER_ENCRYPTED_PASSWORD_MAX_LENGTH, - USER_LOGIN_MAX_LENGTH, - USER_LOGIN_MIN_LENGTH, - USER_NAME_MAX_LENGTH, - USER_NAME_MIN_LENGTH, - USER_PASSWORD_MAX_LENGTH, - USER_PASSWORD_MIN_LENGTH, - USER_SURNAME_MAX_LENGTH, - USER_SURNAME_MIN_LENGTH, -) +from typing import ClassVar + +from pydantic import BaseModel, ConfigDict -SurnameConstraint = Annotated[ - str, - Len( - min_length=USER_SURNAME_MIN_LENGTH, - max_length=USER_SURNAME_MAX_LENGTH, - ), -] - -NameConstraint = Annotated[ - str, - Len( - min_length=USER_NAME_MIN_LENGTH, - max_length=USER_NAME_MAX_LENGTH, - ), -] - -LoginConstraint = Annotated[ - str, - Len( - min_length=USER_LOGIN_MIN_LENGTH, - max_length=USER_LOGIN_MAX_LENGTH, - ), -] - -EmailConstraint = Annotated[ - EmailStr, - Len( - min_length=USER_EMAIL_MIN_LENGTH, - max_length=USER_EMAIL_MAX_LENGTH, - ), -] - -PasswordConstraint = Annotated[ - str, - Len( - min_length=USER_PASSWORD_MIN_LENGTH, - max_length=USER_PASSWORD_MAX_LENGTH, - ), -] - -EncryptedPasswordConstraint = Annotated[ - str, - Len( - max_length=USER_ENCRYPTED_PASSWORD_MAX_LENGTH, - ), -] - -ConfirmationCode = Annotated[ - str, - Len( - min_length=6, - max_length=6, - ), -] +from schemas.constraints.user import ( + EmailConstraint, + EncryptedPasswordConstraint, + LoginConstraint, + NameConstraint, + PasswordConstraint, + SurnameConstraint, +) class UserBase(BaseModel): diff --git a/app/services/auth.py b/app/services/auth.py index d90339b..06f40be 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -107,7 +107,7 @@ async def send_register_confirmation_code( email, confirmation_code, ], - queue=Queue.notification.value, + queue=Queue.notification_service.value, ) async def verify_register_user( @@ -178,7 +178,7 @@ async def send_authenticate_confirmation_code( email, confirmation_code, ], - queue=Queue.notification.value, + queue=Queue.notification_service.value, ) async def verify_authenticate_user( @@ -311,7 +311,7 @@ async def send_recover_account_confirmation_code( email, confirmation_code, ], - queue=Queue.notification.value, + queue=Queue.notification_service.value, ) async def verify_recover_account( diff --git a/app/services/user.py b/app/services/user.py index 4d83f05..356add3 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -104,7 +104,7 @@ async def create_user(self, user_create_data: UserCreate) -> UserResponse: user.email, user.name, ], - queue=Queue.notification.value, + queue=Queue.notification_service.value, ) return UserResponse.model_validate(user) diff --git a/docker-compose.yml b/docker-compose.yml index 7b2041c..3f5d1a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -216,7 +216,7 @@ services: command: uv run celery --app core.celery.celery_app worker -Q notification-service --loglevel=INFO develop: watch: - - path: notification-service + - path: ./notification-service action: sync+restart target: /notification-service - path: ./packages diff --git a/media-service/api/api_v1/file_views.py b/media-service/api/api_v1/file_views.py index 2457956..e4c1041 100644 --- a/media-service/api/api_v1/file_views.py +++ b/media-service/api/api_v1/file_views.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, UploadFile, status -from packages.constants import S3Bucket -from packages.schemas import ( +from packages.minio.constants import S3Bucket +from packages.schemas.media import ( ConfirmUploadRequest, PresignUrlCreate, PresignUrlResponse, diff --git a/media-service/core/celery/celery_app.py b/media-service/core/celery/celery_app.py index 4e8eb2a..017e293 100644 --- a/media-service/core/celery/celery_app.py +++ b/media-service/core/celery/celery_app.py @@ -3,6 +3,6 @@ app = Celery( "core.celery.celery_app", - broker=package_settings.rabbitmq.rabbitmq_url, + broker=package_settings.rabbitmq.url, include=["core.celery.tasks"], ) diff --git a/media-service/core/celery/tasks.py b/media-service/core/celery/tasks.py index c851154..270e0bf 100644 --- a/media-service/core/celery/tasks.py +++ b/media-service/core/celery/tasks.py @@ -1,6 +1,6 @@ -import asyncio from packages.celery.constants import TaskType +from packages.celery.utils import sync_run_coroutine_function from core.minio.utils import get_minio_service @@ -19,6 +19,6 @@ async def async_delete_temporary_file() -> None: key=object_name, ) - asyncio.run( + sync_run_coroutine_function( async_delete_temporary_file(), ) diff --git a/media-service/core/minio/service.py b/media-service/core/minio/service.py index 060ad36..47d4043 100644 --- a/media-service/core/minio/service.py +++ b/media-service/core/minio/service.py @@ -4,8 +4,12 @@ from fastapi import UploadFile from packages.celery.constants import Queue, TaskType -from packages.constants import S3Bucket -from packages.schemas import ConfirmUploadRequest, PresignUrlCreate, PresignUrlResponse +from packages.minio.constants import S3Bucket +from packages.schemas.media import ( + ConfirmUploadRequest, + PresignUrlCreate, + PresignUrlResponse, +) from core.celery.celery_app import app from core.config import settings @@ -47,7 +51,7 @@ async def create_presign_url( object_name, ], countdown=settings.celery.delete_temporary_file_in, - queue=Queue.mediaservice.value, + queue=Queue.media_service.value, ) return PresignUrlResponse( presign_url=presign_url.replace("minio", "localhost"), diff --git a/media-service/core/rabbitmq/consumers.py b/media-service/core/rabbitmq/consumers.py index 69690bc..3e0352f 100644 --- a/media-service/core/rabbitmq/consumers.py +++ b/media-service/core/rabbitmq/consumers.py @@ -1,5 +1,5 @@ from aio_pika import IncomingMessage -from packages.constants import S3Bucket +from packages.minio.constants import S3Bucket from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import create_message, get_message, get_rabbitmq_service diff --git a/notification-service/api/api_v1/send_email_views.py b/notification-service/api/api_v1/send_email_views.py index 9289a7b..e73b0e5 100644 --- a/notification-service/api/api_v1/send_email_views.py +++ b/notification-service/api/api_v1/send_email_views.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from packages.schemas import SendEmail +from packages.schemas.notification import SendEmailRequest from service import EmailService @@ -21,7 +21,7 @@ async def send_welcome_email_message( @router.post("/send-email") async def send_email( - email_data: SendEmail, + email_data: SendEmailRequest, ) -> None: await EmailService.send_email( subject=email_data.subject, diff --git a/notification-service/core/celery/celery_app.py b/notification-service/core/celery/celery_app.py index 465bd16..384d2ff 100644 --- a/notification-service/core/celery/celery_app.py +++ b/notification-service/core/celery/celery_app.py @@ -3,7 +3,7 @@ app = Celery( "core.celery.celery_app", - broker=package_settings.rabbitmq.rabbitmq_url, + broker=package_settings.rabbitmq.url, backend="redis://redis:6379/0", include=["core.celery.tasks"], ) diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index 8ad3b9c..1b38ff8 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -1,7 +1,8 @@ -import asyncio - from packages.celery.constants import TaskType -from packages.schemas import MovieEmailSendDataList, UserEmailSendDataList +from packages.celery.utils import sync_run_coroutine_function +from packages.schemas.notification import ( + SendInactiveUsersMovieSelectionData, +) from pydantic import EmailStr from core.celery.celery_app import app @@ -12,7 +13,7 @@ name=TaskType.send_welcome_email.value, ) def send_welcome_email(email: str, name: str) -> None: - asyncio.run( + sync_run_coroutine_function( EmailService.send_welcome_email( email, name, @@ -27,7 +28,7 @@ def send_confirm_registration_email( email: EmailStr, confirmation_code: str, ) -> None: - asyncio.run( + sync_run_coroutine_function( EmailService.send_confirm_registration_email( email, confirmation_code, @@ -42,7 +43,7 @@ def send_confirm_login_email( email: EmailStr, confirmation_code: str, ) -> None: - asyncio.run( + sync_run_coroutine_function( EmailService.send_confirm_login_email( email=email, confirmation_code=confirmation_code, @@ -58,7 +59,7 @@ def send_reset_password_email_data( email: EmailStr, confirmation_code: str, ) -> None: - asyncio.run( + sync_run_coroutine_function( EmailService.send_reset_password_email_data( login=login, email=email, @@ -68,19 +69,20 @@ def send_reset_password_email_data( @app.task( # type: ignore[untyped-decorator] - name=TaskType.send_inactive_user_reminder.value, + name=TaskType.send_inactive_users_email.value, ) -def send_spam_email( - data: list, -) -> None: - user_data_list, movie_data_list = data - user_data = UserEmailSendDataList.model_validate(user_data_list) - movie_data = MovieEmailSendDataList.model_validate(movie_data_list) - loop = asyncio.new_event_loop() - loop.run_until_complete( - EmailService.send_reminder_email( - movie_data_list=movie_data, - user_data_list=user_data, +def send_inactive_users_email(send_inactive_users_email_data: dict) -> None: + send_inactive_users_movie_selection_data = ( + SendInactiveUsersMovieSelectionData.model_validate( + send_inactive_users_email_data, + ) + ) + + inactive_users = send_inactive_users_movie_selection_data.inactive_users + movie_selection = send_inactive_users_movie_selection_data.movie_selection + sync_run_coroutine_function( + EmailService.send_inactive_users_email( + inactive_users=inactive_users, + movie_selection=movie_selection, ), ) - loop.close() diff --git a/notification-service/service.py b/notification-service/service.py index 6021d47..dee9821 100644 --- a/notification-service/service.py +++ b/notification-service/service.py @@ -1,10 +1,11 @@ from email.message import EmailMessage +from typing import Any from aiosmtplib import SMTP -from packages.schemas import ( - MovieEmailSendDataList, - UserEmailSendData, - UserEmailSendDataList, +from packages.schemas.notification import ( + InactiveUser, + InactiveUserList, + SelectedMovieList, ) from pydantic import EmailStr @@ -177,24 +178,25 @@ async def send_reset_password_email_data( ) @classmethod - async def send_user_reminder_email( + async def send_inactive_user_email( cls, - user: UserEmailSendData, + user: InactiveUser, subject_template: str, body_template: str, - **kwargs, + **kwargs: Any, ) -> None: + name = kwargs["name"] await cls.send_email( - subject=subject_template.format(name=kwargs["name"]), - body=body_template.format(name=kwargs["name"]), + subject=subject_template.format(name=name), + body=body_template.format(name=name), to_email=user.email, ) @classmethod - async def send_reminder_email( + async def send_inactive_users_email( cls, - movie_data_list: MovieEmailSendDataList, - user_data_list: UserEmailSendDataList, + inactive_users: InactiveUserList, + movie_selection: SelectedMovieList, ) -> None: subject_template = "{name}, мы по вам соскучились! 🎬 Готовы зажечь экран?" @@ -225,13 +227,13 @@ async def send_reminder_email( number=i + 1, movie_name=movie.name, ) - for i, movie in enumerate(movie_data_list.movie_list) + for i, movie in enumerate(movie_selection.selected_movie_list) ], ) author_message = "— Команда MovieAPI" body_template += movies_body + author_message - for user in user_data_list.user_list: - await cls.send_user_reminder_email( + for user in inactive_users.inactive_user_list: + await cls.send_inactive_user_email( user, subject_template, body_template, diff --git a/packages/celery/constants.py b/packages/celery/constants.py index 646fbb2..cdc8456 100644 --- a/packages/celery/constants.py +++ b/packages/celery/constants.py @@ -3,8 +3,8 @@ class Queue(StrEnum): app = "movie-catalog" - mediaservice = "media-service" - notification = "notification-service" + media_service = "media-service" + notification_service = "notification-service" class TaskType(StrEnum): @@ -18,7 +18,10 @@ class TaskType(StrEnum): "notification-service.email.send_reset_password_email_data" # noqa: S105 ) - prepare_inactive_users = "movie-catalog.email.prepare_inactive_users" - prepare_newest_movies = "movie-catalog.email.prepare_newest_movies" - create_chain_user_reminder = "movie-catalog.email.create_chain_user_reminder" - send_inactive_user_reminder = "notification-service.email.send-inactive-user-email" + create_chain_to_notify_inactive_users = ( + "movie-catalog.celery.create_chain_to_notify_inactive_users" + ) + get_data_to_send_inactive_users_email = ( + "movie-catalog.mailing-list.get_data_to_send_inactive_users_email" + ) + send_inactive_users_email = "notification-service.email.send-inactive-users-email" diff --git a/packages/celery/utils.py b/packages/celery/utils.py new file mode 100644 index 0000000..d930942 --- /dev/null +++ b/packages/celery/utils.py @@ -0,0 +1,7 @@ +import asyncio +from collections.abc import Coroutine +from typing import Any + + +def sync_run_coroutine_function(coroutine: Coroutine) -> Any: + return asyncio.run(coroutine) diff --git a/packages/config.py b/packages/config.py index af11675..cc57cc2 100644 --- a/packages/config.py +++ b/packages/config.py @@ -12,7 +12,7 @@ class RabbitMQConfig(BaseModel): password: str = "guest" # noqa: S105 @property - def rabbitmq_url(self) -> str: + def url(self) -> str: return f"amqp://{self.username}:{self.password}@{self.host}:{self.port}/%2f" diff --git a/packages/minio/__init__.py b/packages/minio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/constants.py b/packages/minio/constants.py similarity index 100% rename from packages/constants.py rename to packages/minio/constants.py diff --git a/packages/rabbitmq/connection.py b/packages/rabbitmq/connection.py index c189397..36c9f18 100644 --- a/packages/rabbitmq/connection.py +++ b/packages/rabbitmq/connection.py @@ -11,7 +11,7 @@ async def rabbitmq_connection_startup() -> None: global RABBIT_MQ_CONNECTION # noqa: PLW0603 RABBIT_MQ_CONNECTION = await aio_pika.connect_robust( - url=settings.rabbitmq.rabbitmq_url, + url=settings.rabbitmq.url, ) diff --git a/packages/schemas.py b/packages/schemas.py deleted file mode 100644 index 0e15914..0000000 --- a/packages/schemas.py +++ /dev/null @@ -1,78 +0,0 @@ -from pydantic import BaseModel, EmailStr - -from packages.constants import S3Bucket, S3ClientMethod, S3ContentType - - -class PresignUrlCreate(BaseModel): - """ - Модель для создания временной ссылки доступа к хранилищу S3. - """ - - bucket_name: S3Bucket - file_name: str - client_method: S3ClientMethod - content_type: S3ContentType | None = None - - -class PresignUrlResponse(BaseModel): - """ - Модель для вывода информации о временной ссылке доступа к хранилищу S3. - """ - - presign_url: str - temporary_path: str - - -class ConfirmUploadRequest(BaseModel): - """ - Модель для подтверждения корректности загрузки файла и последующего - переноса файла в основную директорию. - """ - - source_bucket_name: S3Bucket - destination_bucket_name: S3Bucket - source_object_name: str - destination_object_name: str - - -class SendEmail(BaseModel): - """ - Модель для отправки сообщения на почту. - """ - - subject: str - to_email: EmailStr - body: str - - -class UserEmailSendData(BaseModel): - """ - Модель для отправки данных в фоновую задачу по отправке напоминаний о сервисе. - """ - - email: EmailStr - name: str - - -class UserEmailSendDataList(BaseModel): - """ - Список пользователь для массовой рассылки напоминаний о сервисе. - """ - - user_list: list[UserEmailSendData] - - -class MovieEmailSendData(BaseModel): - """ - Модель для данных о фильме, которые будут упоминаться в спам письме. - """ - - name: str - - -class MovieEmailSendDataList(BaseModel): - """ - Модель для данных о фильмах, которые будут упоминаться в спам письме. - """ - - movie_list: list[MovieEmailSendData] diff --git a/packages/schemas/__init__.py b/packages/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/schemas/media.py b/packages/schemas/media.py new file mode 100644 index 0000000..32590b7 --- /dev/null +++ b/packages/schemas/media.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel + +from packages.minio.constants import S3Bucket, S3ClientMethod, S3ContentType + + +class PresignUrlCreate(BaseModel): + """ + Модель для создания временной ссылки доступа к хранилищу S3. + """ + + bucket_name: S3Bucket + file_name: str + client_method: S3ClientMethod + content_type: S3ContentType | None = None + + +class PresignUrlResponse(BaseModel): + """ + Модель для вывода информации о временной ссылке доступа к хранилищу S3. + """ + + presign_url: str + temporary_path: str + + +class ConfirmUploadRequest(BaseModel): + """ + Модель для подтверждения корректности загрузки файла и последующего + переноса файла в основную директорию. + """ + + source_bucket_name: S3Bucket + destination_bucket_name: S3Bucket + source_object_name: str + destination_object_name: str diff --git a/packages/schemas/notification.py b/packages/schemas/notification.py new file mode 100644 index 0000000..48ff850 --- /dev/null +++ b/packages/schemas/notification.py @@ -0,0 +1,59 @@ +from datetime import date + +from pydantic import BaseModel, EmailStr + + +class SendEmailRequest(BaseModel): + """ + Модель для отправки сообщения на почту. + """ + + subject: str + to_email: EmailStr + body: str + + +class InactiveUser(BaseModel): + """ + Модель для получения информации о неактивном пользователе. + """ + + name: str + email: EmailStr + + +class InactiveUserList(BaseModel): + """ + Модель для получения списка неактивных пользователей. + """ + + inactive_user_list: list[InactiveUser] + + +class SelectedMovie(BaseModel): + """ + Модель для получения рекомендованного фильма. + """ + + name: str + genre_name: str + rating: int + source_url: str + release_date: date + + +class SelectedMovieList(BaseModel): + """ + Модель для получения списка рекомендованных фильмов. + """ + + selected_movie_list: list[SelectedMovie] + + +class SendInactiveUsersMovieSelectionData(BaseModel): + """ + Модель для получения неактивных пользователей и рекомендованных фильмов. + """ + + inactive_users: InactiveUserList + movie_selection: SelectedMovieList From 0a64d8856e90742108b8c690d8131596aa8847e2 Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Mon, 29 Jun 2026 16:38:20 +0300 Subject: [PATCH 44/47] Add celery flower to docker compose file. --- docker-compose.yml | 87 ++++++++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 37 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3f5d1a1..4a78656 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -185,6 +185,42 @@ services: timeout: 2s retries: 5 + celery-beat-app: + build: + context: ./ + dockerfile: app/Dockerfile + container_name: celery-beat-app + command: uv run celery --app core.celery.celery_app beat --loglevel=INFO + develop: + watch: + - path: ./app + action: sync+restart + target: /app + - path: ./packages + action: sync+restart + target: /app/packages + depends_on: + celery-worker-notification-service: + condition: service_started + + celery-worker-app: + build: + context: ./ + dockerfile: app/Dockerfile + container_name: celery-worker-app + command: uv run celery --app core.celery.celery_app worker -Q movie-catalog --loglevel=INFO + develop: + watch: + - path: ./app + action: sync+restart + target: /app + - path: ./packages + action: sync+restart + target: /app/packages + depends_on: + rabbitmq: + condition: service_healthy + celery-worker-media-service: build: context: . @@ -226,42 +262,6 @@ services: rabbitmq: condition: service_healthy - celery-worker-app: - build: - context: ./ - dockerfile: app/Dockerfile - container_name: celery-worker-app - command: uv run celery --app core.celery.celery_app worker -Q movie-catalog --loglevel=INFO - develop: - watch: - - path: ./app - action: sync+restart - target: /app - - path: ./packages - action: sync+restart - target: /app/packages - depends_on: - rabbitmq: - condition: service_healthy - - celery-beat-app: - build: - context: ./ - dockerfile: app/Dockerfile - container_name: celery-beat-app - command: uv run celery --app core.celery.celery_app beat --loglevel=INFO - develop: - watch: - - path: ./app - action: sync+restart - target: /app - - path: ./packages - action: sync+restart - target: /app/packages - depends_on: - celery-worker-notification-service: - condition: service_started - maildev: image: maildev/maildev container_name: maidev @@ -280,12 +280,25 @@ services: ports: - "5050:80" - redis_gui: + redis-gui: image: redis/redisinsight container_name: redis_gui ports: - "5540:5540" + celery-flower: + image: mher/flower + container_name: celery-flower + environment: + - CELERY_BROKER_URL=amqp://guest:guest@rabbitmq:5672/%2f + - FLOWER_BASIC_AUTH=admin:admin + ports: + - "5555:5555" + depends_on: + rabbitmq: + condition: service_healthy + + volumes: postgres-data: redis-data: From 6c49cd5956a8818fed4d8e75941a1f2f8cf2372d Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Tue, 30 Jun 2026 17:35:08 +0300 Subject: [PATCH 45/47] Refactor notification-service. --- app/services/auth.py | 6 +- notification-service/core/celery/tasks.py | 18 +- notification-service/core/email_templates.py | 113 +++++++++++ notification-service/service.py | 186 ++++++------------- packages/celery/constants.py | 11 +- 5 files changed, 185 insertions(+), 149 deletions(-) create mode 100644 notification-service/core/email_templates.py diff --git a/app/services/auth.py b/app/services/auth.py index 06f40be..2c8c232 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -102,7 +102,7 @@ async def send_register_confirmation_code( confirmation_code_type=ConfirmationCodeType.registration, ) app.send_task( - name=TaskType.send_confirm_registration_email.value, + name=TaskType.send_registration_confirmation_code_email.value, args=[ email, confirmation_code, @@ -173,7 +173,7 @@ async def send_authenticate_confirmation_code( confirmation_code_type=ConfirmationCodeType.two_factor_auth, ) app.send_task( - name=TaskType.send_confirm_login_email.value, + name=TaskType.send_auth_confirmation_code_email.value, args=[ email, confirmation_code, @@ -305,7 +305,7 @@ async def send_recover_account_confirmation_code( confirmation_code_type=ConfirmationCodeType.recover_password, ) app.send_task( - name=TaskType.send_reset_password_email_data.value, + name=TaskType.send_reset_password_confirmation_code_email.value, args=[ login, email, diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index 1b38ff8..13717ef 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -22,14 +22,14 @@ def send_welcome_email(email: str, name: str) -> None: @app.task( # type: ignore[untyped-decorator] - name=TaskType.send_confirm_registration_email.value, + name=TaskType.send_registration_confirmation_code_email.value, ) -def send_confirm_registration_email( +def send_registration_confirmation_code_email( email: EmailStr, confirmation_code: str, ) -> None: sync_run_coroutine_function( - EmailService.send_confirm_registration_email( + EmailService.send_registration_confirmation_code_email( email, confirmation_code, ), @@ -37,14 +37,14 @@ def send_confirm_registration_email( @app.task( # type: ignore[untyped-decorator] - name=TaskType.send_confirm_login_email.value, + name=TaskType.send_auth_confirmation_code_email.value, ) -def send_confirm_login_email( +def send_auth_confirmation_code_email( email: EmailStr, confirmation_code: str, ) -> None: sync_run_coroutine_function( - EmailService.send_confirm_login_email( + EmailService.send_auth_confirmation_code_email( email=email, confirmation_code=confirmation_code, ), @@ -52,15 +52,15 @@ def send_confirm_login_email( @app.task( # type: ignore[untyped-decorator] - name=TaskType.send_reset_password_email_data.value, + name=TaskType.send_reset_password_confirmation_code_email.value, ) -def send_reset_password_email_data( +def send_reset_password_confirmation_code_email( login: str, email: EmailStr, confirmation_code: str, ) -> None: sync_run_coroutine_function( - EmailService.send_reset_password_email_data( + EmailService.send_reset_password_confirmation_code_email( login=login, email=email, confirmation_code=confirmation_code, diff --git a/notification-service/core/email_templates.py b/notification-service/core/email_templates.py new file mode 100644 index 0000000..d3e6722 --- /dev/null +++ b/notification-service/core/email_templates.py @@ -0,0 +1,113 @@ +# ruff: disable[W291, W293, S105, E501] +WELCOME_EMAIL_SUBJECT = "Потому что вы любите кино так же сильно, как и мы 🎬" + +WELCOME_EMAIL_BODY_TEMPLATE = """ + Дорогой {name}, + + Некоторые люди смотрят фильмы. Другие — живут ими. + + Если вы читаете это, вам, скорее всего, важнее не просто названия и постеры. Вам важны истории. + + Тот самый кадр, который остаётся с вами на дни. + + Именно для этого мы создали MovieAPI. + + Представьте, что это ваш второй дом: + + Записывайте каждый фильм, который вы когда-либо видели + + Открывайте скрытые жемчужины, которые вы никогда не найдёте на популярных сайтах + + Ведите свой личный блокнот с мыслями и оценками + + Никаких алгоритмов, кричащих на вас. Только чистое кино. + + Добро пожаловать домой, {name}. + + Давайте посмотрим что-то великое. + """ + +REGISTRATION_CONFIRMATION_CODE_EMAIL_SUBJECT = ( + "🔑 MovieAPI — Код подтверждения регистрации" +) + +REGISTRATION_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE = """ + Здравствуйте! + + Спасибо за регистрацию в MovieAPI. + + Для завершения создания аккаунта и подтверждения вашего email-адреса, + + пожалуйста, введите следующий код на странице регистрации: {confirmation_code} + + Код действителен в течение 60 секунд. + + Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо. + """ + +AUTH_CONFIRMATION_CODE_EMAIL_SUBJECT = "🛡️ Код безопасности для входа в MovieAPI" + +AUTH_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE = """ + Здравствуйте! + + Выполнен запрос на вход в ваш аккаунт MovieAPI. + + Для подтверждения личности введите одноразовый код безопасности: {confirmation_code} + + Код действует 60 секунд. Никому не сообщайте этот код. + + Если вы не запрашивали вход в аккаунт MovieAPI, просто проигнорируйте это письмо. + """ + +RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_SUBJECT = ( + "🛡️ Восстановление доступа к приложению MovieAPI" +) + +RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE = """ + Здравствуйте! + + Мы получили запрос на восстановление доступа к вашему аккаунту. + + Ваш логин: {login} + + Чтобы войти, создайте новый пароль, используя код подтверждения. + + Ваш код подтверждения: {confirmation_code} + + Код подтверждения действует 60 секунд. + + Если вы не запрашивали восстановление, просто проигнорируйте это письмо. + """ + +INACTIVE_USER_EMAIL_SUBJECT_TEMPLATE = ( + "{name}, мы по вам соскучились! 🎬 Готовы зажечь экран?" +) + +INACTIVE_USER_EMAIL_BODY_TEMPLATE = """ + Привет, {name}! + + Давно не виделись. Мы заметили, что вы уже целую вечность не заглядывали + + в наш кинотеатр, а ведь без вашего мнения обсуждения стали тише... + + Чтобы исправить это, мы подготовили для вас персональную подборку из + + свежих новинок, которые вышли совсем недавно. Мы уверены, что среди них + + есть тот самый фильм, ради которого стоит устроить уютный вечер с пледом и попкорном. + + Ваша эксклюзивная подборка новинок: + """ + +SELECTED_MOVIE_TEMPLATE = """ + Название: {name} + Жанр: {genre_name} + Рейтинг: {rating} + Дата выхода: {release_date} + Подробнее: {source_url} + """ + +EMAIL_FOOTER = """ + — Команда MovieAPI + """ +# ruff: enable[W291, W293, S105, E501] diff --git a/notification-service/service.py b/notification-service/service.py index dee9821..266c82c 100644 --- a/notification-service/service.py +++ b/notification-service/service.py @@ -10,6 +10,20 @@ from pydantic import EmailStr from core.config import settings +from core.email_templates import ( + AUTH_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE, + AUTH_CONFIRMATION_CODE_EMAIL_SUBJECT, + EMAIL_FOOTER, + INACTIVE_USER_EMAIL_BODY_TEMPLATE, + INACTIVE_USER_EMAIL_SUBJECT_TEMPLATE, + REGISTRATION_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE, + REGISTRATION_CONFIRMATION_CODE_EMAIL_SUBJECT, + RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE, + RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_SUBJECT, + SELECTED_MOVIE_TEMPLATE, + WELCOME_EMAIL_BODY_TEMPLATE, + WELCOME_EMAIL_SUBJECT, +) class EmailService: @@ -32,12 +46,13 @@ async def send_email( to_email: EmailStr, ) -> None: smtp_client = cls.get_smtp_client() + body_with_footer = cls.add_footer(body=body, footer=EMAIL_FOOTER) async with smtp_client: message = EmailMessage() message["Subject"] = subject message["From"] = settings.corporate_email message["To"] = to_email - message.set_content(body) + message.set_content(body_with_footer) await smtp_client.send_message( message, @@ -46,137 +61,78 @@ async def send_email( ) @classmethod - async def send_welcome_email(cls, email: str, name: str) -> None: - subject = "Потому что вы любите кино так же сильно, как и мы 🎬" - # ruff: disable[W293, E501] - body_template = """ - Дорогой {name}, + def add_footer(cls, body: str, footer: str = EMAIL_FOOTER) -> str: + body_with_footer_list = [body, footer] + body_with_footer = "\n".join(body_with_footer_list) + return body_with_footer - Некоторые люди смотрят фильмы. Другие — живут ими. - - Если вы читаете это, вам, скорее всего, важнее не просто названия и постеры. Вам важны истории. - - Тот самый кадр, который остаётся с вами на дни. - - Именно для этого мы создали MovieAPI. - - Представьте, что это ваш второй дом: - - Записывайте каждый фильм, который вы когда-либо видели - - Открывайте скрытые жемчужины, которые вы никогда не найдёте на популярных сайтах - - Ведите свой личный блокнот с мыслями и оценками - - Никаких алгоритмов, кричащих на вас. Только чистое кино. - - Добро пожаловать домой, {name}. - - Давайте посмотрим что-то великое. - - — Команда MovieAPI - """ - # ruff: enable[W293, E501] + @classmethod + async def send_welcome_email(cls, email: str, name: str) -> None: await cls.send_email( - subject=subject, - body=body_template.format(name=name), + subject=WELCOME_EMAIL_SUBJECT, + body=WELCOME_EMAIL_BODY_TEMPLATE.format(name=name), to_email=email, ) @classmethod - async def send_confirm_registration_email( + async def send_registration_confirmation_code_email( cls, email: EmailStr, confirmation_code: str, ) -> None: - subject = "🔑 MovieAPI — Код подтверждения регистрации" - # ruff: disable[W293, E501] - body_template = """ - Здравствуйте! - - Спасибо за регистрацию в MovieAPI. - - Для завершения создания аккаунта и подтверждения вашего email-адреса, - - пожалуйста, введите следующий код на странице регистрации: {confirmation_code} - - Код действителен в течение 60 секунд. - - Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо. - - — Команда MovieAPI - """ - # ruff: enable[W293, E501] await cls.send_email( - subject=subject, - body=body_template.format(confirmation_code=confirmation_code), + subject=REGISTRATION_CONFIRMATION_CODE_EMAIL_SUBJECT, + body=REGISTRATION_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE.format( + confirmation_code=confirmation_code, + ), to_email=email, ) @classmethod - async def send_confirm_login_email( + async def send_auth_confirmation_code_email( cls, email: EmailStr, confirmation_code: str, ) -> None: - subject = "🛡️ Код безопасности для входа в MovieAPI" - # ruff: disable[W293, E501] - body_template = """ - Здравствуйте! - - Выполнен запрос на вход в ваш аккаунт MovieAPI. - - Для подтверждения личности введите одноразовый код безопасности: {confirmation_code} - - Код действует 60 секунд. Никому не сообщайте этот код. - - Если вы не запрашивали вход в аккаунт MovieAPI, просто проигнорируйте это письмо. - - — Команда MovieAPI - """ - # ruff: enable[W293, E501] await cls.send_email( - subject=subject, - body=body_template.format(confirmation_code=confirmation_code), + subject=AUTH_CONFIRMATION_CODE_EMAIL_SUBJECT, + body=AUTH_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE.format( + confirmation_code=confirmation_code, + ), to_email=email, ) @classmethod - async def send_reset_password_email_data( + async def send_reset_password_confirmation_code_email( cls, login: str, email: EmailStr, confirmation_code: str, ) -> None: - subject = "🛡️ Восстановление доступа к приложению MovieAPI" - # ruff: disable[W293] - body_template = """ - Здравствуйте! - - Мы получили запрос на восстановление доступа к вашему аккаунту. - - Ваш логин: {login} - - Чтобы войти, создайте новый пароль, используя код подтверждения. - - Ваш код подтверждения: {confirmation_code} - - Код подтверждения действует 60 секунд. - - Если вы не запрашивали восстановление, просто проигнорируйте это письмо. - - — Команда MovieAPI - """ - # ruff: enable[W293] await cls.send_email( - subject=subject, - body=body_template.format( + subject=RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_SUBJECT, + body=RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE.format( login=login, confirmation_code=confirmation_code, ), to_email=email, ) + @classmethod + def create_movie_selection_email(cls, movie_selection: SelectedMovieList) -> str: + movie_selection_list = [ + SELECTED_MOVIE_TEMPLATE.format( + name=movie.name, + genre_name=movie.genre_name, + rating=movie.rating, + source_url=movie.source_url, + release_date=movie.release_date, + ) + for movie in movie_selection.selected_movie_list + ] + movie_selection_body = "\n".join(movie_selection_list) + return movie_selection_body + @classmethod async def send_inactive_user_email( cls, @@ -198,44 +154,12 @@ async def send_inactive_users_email( inactive_users: InactiveUserList, movie_selection: SelectedMovieList, ) -> None: - subject_template = "{name}, мы по вам соскучились! 🎬 Готовы зажечь экран?" - - body_template = """ - Привет, {name}! - - Давно не виделись. Мы заметили, что вы уже целую вечность не заглядывали - - в наш кинотеатр, а ведь без вашего мнения обсуждения стали тише... - - Чтобы исправить это, мы подготовили для вас персональную подборку из - - свежих новинок, которые вышли совсем недавно. Мы уверены, что среди них - - есть тот самый фильм, ради которого стоит устроить уютный вечер с пледом и попкорном. - - Ваша эксклюзивная подборка новинок: - - """ - - movie_template = """ - {number}) {movie_name} - - """ - movies_body = "".join( - [ - movie_template.format( - number=i + 1, - movie_name=movie.name, - ) - for i, movie in enumerate(movie_selection.selected_movie_list) - ], - ) - author_message = "— Команда MovieAPI" - body_template += movies_body + author_message + movie_selection_body = cls.create_movie_selection_email(movie_selection) + body_template = INACTIVE_USER_EMAIL_BODY_TEMPLATE + movie_selection_body for user in inactive_users.inactive_user_list: await cls.send_inactive_user_email( user, - subject_template, + INACTIVE_USER_EMAIL_SUBJECT_TEMPLATE, body_template, name=user.name, ) diff --git a/packages/celery/constants.py b/packages/celery/constants.py index cdc8456..aa3e596 100644 --- a/packages/celery/constants.py +++ b/packages/celery/constants.py @@ -10,14 +10,13 @@ class Queue(StrEnum): class TaskType(StrEnum): delete_temporary_file = "media-service.media.delete_temporary_file" send_welcome_email = "notification-service.email.send-welcome-email" - send_confirm_registration_email = ( - "notification-service.email.send-confirm-registration-email" + send_registration_confirmation_code_email = ( + "notification-service.email.send-registration-confirmation-code-email" ) - send_confirm_login_email = "notification-service.email.confirm-login-email" - send_reset_password_email_data = ( - "notification-service.email.send_reset_password_email_data" # noqa: S105 + send_auth_confirmation_code_email = ( + "notification-service.email.send_auth_confirmation_code_email" ) - + send_reset_password_confirmation_code_email = "notification-service.email.send_reset_password_confirmation_code_email" # noqa: S105 create_chain_to_notify_inactive_users = ( "movie-catalog.celery.create_chain_to_notify_inactive_users" ) From bf77e2f9aaf6b284bfc1f1b810c05c160697efdc Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Tue, 30 Jun 2026 18:35:00 +0300 Subject: [PATCH 46/47] Refactor celery apps configs. --- app/core/celery/celery_app.py | 7 ++++--- app/core/config.py | 10 ++++------ app/core/constants.py | 3 +++ media-service/core/celery/celery_app.py | 6 ++++-- media-service/core/config.py | 4 ++-- media-service/core/constants.py | 3 +++ notification-service/core/celery/celery_app.py | 8 +++++--- notification-service/core/config.py | 4 ++-- notification-service/core/constants.py | 3 +++ packages/config.py | 15 +++++++++++++-- 10 files changed, 43 insertions(+), 20 deletions(-) create mode 100644 media-service/core/constants.py create mode 100644 notification-service/core/constants.py diff --git a/app/core/celery/celery_app.py b/app/core/celery/celery_app.py index 07c52b1..a671d55 100644 --- a/app/core/celery/celery_app.py +++ b/app/core/celery/celery_app.py @@ -4,12 +4,13 @@ from packages.config import settings as package_settings from core.config import settings +from core.constants import CELERY_APP_MODULE, CELERY_TASKS_MODULES app = Celery( - "core.celery.celery_app", + CELERY_APP_MODULE, broker=package_settings.rabbitmq.url, - backend="redis://redis:6379/0", - include=["core.celery.tasks"], + backend=package_settings.redis.url, + include=CELERY_TASKS_MODULES, ) app.conf.beat_schedule = { diff --git a/app/core/config.py b/app/core/config.py index 093912a..8e47191 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -33,6 +33,7 @@ class RedisDataBaseConfig(BaseModel): favorite_movies: int = 5 watch_history: int = 6 auth: int = 7 + celery_backend: int = 8 class RedisConfig(BaseModel): @@ -108,6 +109,7 @@ class CeleryBeatConfig(BaseModel): class CeleryConfig(BaseModel): beat: CeleryBeatConfig = CeleryBeatConfig() + base_dir: Path = Path(__file__).parent.parent / ".core" / "celery" / "celery_app" class MediaServiceConfig(BaseModel): @@ -123,13 +125,9 @@ class NotificationServiceConfig(BaseModel): host: str = "notification-service" port: int = 8000 - @property - def send_email_endpoint(self) -> str: - return f"http://{self.host}:{self.port}/api/v1/send-email" - class Settings(BaseSettings): - BASE_DIR: Path = Path(__file__).parent.parent + base_dir: Path = Path(__file__).parent.parent database: DataBaseConfig = DataBaseConfig() redis: RedisConfig = RedisConfig() rabbitmq: RabbitMQConfig = RabbitMQConfig() @@ -145,7 +143,7 @@ class Settings(BaseSettings): model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( case_sensitive=False, - env_file=BASE_DIR / ".env", + env_file=base_dir / ".env", env_nested_delimiter="__", ) diff --git a/app/core/constants.py b/app/core/constants.py index cf02d98..61d2c3a 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -29,6 +29,9 @@ from core.exceptions.user import UserAlreadyExistsError, UserNotFoundError from core.exceptions.watch_history import WatchHistoryNotFoundError +CELERY_APP_MODULE = "core.celery.celery_app" +CELERY_TASKS_MODULES = ["core.celery.tasks"] + BASE_MINIO_URL = "http://localhost:9000" AnyPydanticType = TypeVar("AnyPydanticType", bound=BaseModel) diff --git a/media-service/core/celery/celery_app.py b/media-service/core/celery/celery_app.py index 017e293..51397b0 100644 --- a/media-service/core/celery/celery_app.py +++ b/media-service/core/celery/celery_app.py @@ -1,8 +1,10 @@ from celery import Celery from packages.config import settings as package_settings +from core.constants import CELERY_APP_MODULE, CELERY_TASKS_MODULES + app = Celery( - "core.celery.celery_app", + CELERY_APP_MODULE, broker=package_settings.rabbitmq.url, - include=["core.celery.tasks"], + include=CELERY_TASKS_MODULES, ) diff --git a/media-service/core/config.py b/media-service/core/config.py index 97dd042..455951e 100644 --- a/media-service/core/config.py +++ b/media-service/core/config.py @@ -23,12 +23,12 @@ class CeleryConfig(BaseModel): class Settings(BaseSettings): - BASE_DIR: Path = Path(__file__).parent.parent + base_dir: Path = Path(__file__).parent.parent minio: MinioConfig = MinioConfig() celery: CeleryConfig = CeleryConfig() model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( case_sensitive=False, - env_file=BASE_DIR / ".env", + env_file=base_dir / ".env", env_nested_delimiter="__", ) diff --git a/media-service/core/constants.py b/media-service/core/constants.py new file mode 100644 index 0000000..68e2687 --- /dev/null +++ b/media-service/core/constants.py @@ -0,0 +1,3 @@ +CELERY_APP_MODULE = "core.celery.celery_app" + +CELERY_TASKS_MODULES = ["core.celery.tasks"] diff --git a/notification-service/core/celery/celery_app.py b/notification-service/core/celery/celery_app.py index 384d2ff..31f05a3 100644 --- a/notification-service/core/celery/celery_app.py +++ b/notification-service/core/celery/celery_app.py @@ -1,9 +1,11 @@ from celery import Celery from packages.config import settings as package_settings +from core.constants import CELERY_APP_MODULE, CELERY_TASKS_MODULES + app = Celery( - "core.celery.celery_app", + CELERY_APP_MODULE, broker=package_settings.rabbitmq.url, - backend="redis://redis:6379/0", - include=["core.celery.tasks"], + backend=package_settings.redis.url, + include=CELERY_TASKS_MODULES, ) diff --git a/notification-service/core/config.py b/notification-service/core/config.py index 26a2f35..7239be3 100644 --- a/notification-service/core/config.py +++ b/notification-service/core/config.py @@ -5,7 +5,7 @@ class Settings(BaseSettings): - BASE_DIR: Path = Path(__file__).parent.parent + base_dir: Path = Path(__file__).parent.parent mail_host: str = "smtp.yandex.ru" mail_port: int = 587 corporate_email: str = "email" @@ -13,7 +13,7 @@ class Settings(BaseSettings): start_tls: bool = True model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( case_sensitive=False, - env_file=BASE_DIR / ".env", + env_file=base_dir / ".env", env_nested_delimiter="__", ) diff --git a/notification-service/core/constants.py b/notification-service/core/constants.py new file mode 100644 index 0000000..68e2687 --- /dev/null +++ b/notification-service/core/constants.py @@ -0,0 +1,3 @@ +CELERY_APP_MODULE = "core.celery.celery_app" + +CELERY_TASKS_MODULES = ["core.celery.tasks"] diff --git a/packages/config.py b/packages/config.py index cc57cc2..a947a38 100644 --- a/packages/config.py +++ b/packages/config.py @@ -16,13 +16,24 @@ def url(self) -> str: return f"amqp://{self.username}:{self.password}@{self.host}:{self.port}/%2f" +class RedisConfig(BaseModel): + host: str = "redis" + port: int = 6379 + celery_backend_database: int = 8 + + @property + def url(self) -> str: + return f"redis://{self.host}:{self.port}/{self.celery_backend_database}" + + class Settings(BaseSettings): - BASE_DIR: Path = Path(__file__).parent + base_dir: Path = Path(__file__).parent rabbitmq: RabbitMQConfig = RabbitMQConfig() + redis: RedisConfig = RedisConfig() model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( case_sensitive=False, - env_file=BASE_DIR / ".env", + env_file=base_dir / ".env", env_nested_delimiter="__", ) From 9c3511bf0f532e20a50592b145794e4de27f17ed Mon Sep 17 00:00:00 2001 From: Nikolay Shirokov Date: Wed, 1 Jul 2026 15:55:11 +0300 Subject: [PATCH 47/47] Apply mypy and ruff checks. --- app/cache_services/watch_history.py | 2 +- app/core/celery/tasks.py | 8 ++++---- app/core/celery/utils.py | 8 ++++---- app/core/constants.py | 4 +++- app/core/redis/service.py | 14 ++++++++------ app/repositories/user.py | 9 +++++---- app/services/auth.py | 7 ++++--- app/services/movie.py | 2 +- app/services/user.py | 4 ++-- notification-service/core/celery/tasks.py | 2 +- packages/celery/constants.py | 10 +++++----- packages/celery/utils.py | 2 +- tests/test_schemas/test_user.py | 3 --- 13 files changed, 39 insertions(+), 36 deletions(-) diff --git a/app/cache_services/watch_history.py b/app/cache_services/watch_history.py index ce3cf81..de2b9fe 100644 --- a/app/cache_services/watch_history.py +++ b/app/cache_services/watch_history.py @@ -28,7 +28,7 @@ async def get_watch_history_by_id( ) cached_watch_history_response = await self.cache_service.get( key, - WatchHistoryWithMovieResponse, + schema=WatchHistoryWithMovieResponse, ) if cached_watch_history_response is not None: return cast(WatchHistoryWithMovieResponse, cached_watch_history_response) diff --git a/app/core/celery/tasks.py b/app/core/celery/tasks.py index c0a73ce..12d4d21 100644 --- a/app/core/celery/tasks.py +++ b/app/core/celery/tasks.py @@ -6,17 +6,17 @@ from .utils import get_data_to_send_inactive_users_movie_selection -@app.task( +@app.task( # type: ignore[untyped-decorator] name=TaskType.get_data_to_send_inactive_users_email.value, ) -def get_data_to_send_inactive_users_email() -> dict: +def get_data_to_send_inactive_users_email() -> dict: # type: ignore[type-arg] send_inactive_users_email_data = sync_run_coroutine_function( get_data_to_send_inactive_users_movie_selection(), ) - return send_inactive_users_email_data.model_dump() + return send_inactive_users_email_data.model_dump() # type: ignore[no-any-return] -@app.task( +@app.task( # type: ignore[untyped-decorator] name=TaskType.create_chain_to_notify_inactive_users.value, ) def create_chain_to_notify_inactive_users() -> None: diff --git a/app/core/celery/utils.py b/app/core/celery/utils.py index 5b2e454..3cf708f 100644 --- a/app/core/celery/utils.py +++ b/app/core/celery/utils.py @@ -9,14 +9,14 @@ SendInactiveUsersMovieSelectionData, ) -from core.constants import SortMonotony, SortType +from core.constants import INACTIVE_DAYS, SortMonotony, SortType from core.rabbitmq.utils import get_movie_service, get_user_service from schemas.movie import MovieFilter -async def get_inactive_users() -> InactiveUserList: +async def get_inactive_users(days: int) -> InactiveUserList: async with get_user_service() as user_service: - inactive_users = await user_service.get_inactive_users() + inactive_users = await user_service.get_inactive_users(days=days) inactive_user_list = [ InactiveUser( name=user.name, @@ -56,7 +56,7 @@ async def get_data_to_send_inactive_users_movie_selection() -> ( SendInactiveUsersMovieSelectionData ): inactive_users, movie_selection = await gather( - get_inactive_users(), + get_inactive_users(days=INACTIVE_DAYS), get_movie_selection(), ) return SendInactiveUsersMovieSelectionData( diff --git a/app/core/constants.py b/app/core/constants.py index 61d2c3a..8aa16cb 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -36,7 +36,7 @@ AnyPydanticType = TypeVar("AnyPydanticType", bound=BaseModel) -PrimitiveType = int | str | bool +PrimitiveType = int | float | str | bool class UserRole(StrEnum): @@ -68,6 +68,8 @@ class ConfirmationCodeType(StrEnum): recover_password = "recover" +INACTIVE_DAYS = 7 + ATTEMPT_FIELD = "attempt" MAX_CONFIRM_CODE_ATTEMPTS = 5 CONFIRMATION_CODE_LENGTH = 6 diff --git a/app/core/redis/service.py b/app/core/redis/service.py index 8dad58c..75372f1 100644 --- a/app/core/redis/service.py +++ b/app/core/redis/service.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast from core.constants import AnyPydanticType, PrimitiveType from core.redis.client import RedisClient @@ -11,7 +11,7 @@ def __init__(self, redis: RedisClient) -> None: async def get( self, key: str, - schema: AnyPydanticType | None = None, + schema: type[AnyPydanticType] | None = None, is_integer: bool = False, is_float: bool = False, is_boolean: bool = False, @@ -33,11 +33,11 @@ async def get( async def _get_schema( self, key: str, - schema: AnyPydanticType, + schema: type[AnyPydanticType], ) -> AnyPydanticType | None: value = await self.redis.get(key) if value is not None: - return self.convert_string_to_object(value, schema) + return cast(AnyPydanticType, self.convert_string_to_object(value, schema)) return None async def _get_integer(self, key: str) -> int | None: @@ -86,10 +86,12 @@ def create_cache_key(cls, prefix: str, **kwargs: Any) -> str: return ":".join(result) @staticmethod - def convert_string_to_object(value: str | int, schema: Any) -> Any: + def convert_string_to_object( + value: str | int, schema: type[AnyPydanticType], + ) -> AnyPydanticType | PrimitiveType: if schema is None: return value - return schema.model_validate_json(value) + return schema.model_validate_json(cast(str, value)) @staticmethod def convert_object_to_string(value: Any) -> Any: diff --git a/app/repositories/user.py b/app/repositories/user.py index e077252..2791263 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -58,14 +58,14 @@ async def create_user(self, create_user_data: UserCreate) -> User: await self.session.refresh(user) return user - async def get_inactive_users(self) -> list[User]: + async def get_inactive_users(self, days: int) -> list[User]: get_users_never_watch_movies_stmt = ( select(User) .outerjoin(WatchHistory, User.id == WatchHistory.user_id) .where( and_( WatchHistory.id.is_(None), - func.current_date() - cast(User.registration_date, Date) >= 7, + func.current_date() - cast(User.registration_date, Date) >= days, ), ) ) @@ -74,14 +74,15 @@ async def get_inactive_users(self) -> list[User]: .join(WatchHistory, User.id == WatchHistory.user_id) .group_by(User.id) .having( - func.current_date() - cast(func.max(WatchHistory.watched_at), Date) >= 7, + func.current_date() - cast(func.max(WatchHistory.watched_at), Date) + >= days, ) ) result_stmt = get_users_never_watch_movies_stmt.union( get_users_watch_movies_a_long_time_ago_stmt, ) result = await self.session.execute(result_stmt) - return list(result.all()) + return list(result.all()) # type: ignore[arg-type] async def make_admin(self, user_id: int) -> bool: user = await self.get_user_by_id(user_id) diff --git a/app/services/auth.py b/app/services/auth.py index 2c8c232..c3c1920 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -129,11 +129,12 @@ async def verify_register_user( ) key_list = [ConfirmationCodeType.registration.value, token] key = ":".join(key_list) - user_create_data_json = await self.auth_redis_service.get( + user_create_data = await self.auth_redis_service.get( key=key, + schema=UserCreate, ) - user_create_data = UserCreate.model_validate_json(user_create_data_json) - user = await self.user_service.create_user(user_create_data) + # user_create_data = UserCreate.model_validate_json(user_create_data_json) + user = await self.user_service.create_user(cast(UserCreate, user_create_data)) await self.auth_redis_service.delete(key) return create_auth_token(user) diff --git a/app/services/movie.py b/app/services/movie.py index b257db1..8f44f63 100644 --- a/app/services/movie.py +++ b/app/services/movie.py @@ -31,7 +31,7 @@ class MovieService: def __init__( self, session: AsyncSession, - rabbitmq_service: RabbitMQService | None = None, + rabbitmq_service: RabbitMQService, ) -> None: self.session = session self.user_repository = UserRepository(session) diff --git a/app/services/user.py b/app/services/user.py index 356add3..74e3fdd 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -69,10 +69,10 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList page=page, ) - async def get_inactive_users(self) -> UserResponseList: + async def get_inactive_users(self, days: int) -> UserResponseList: users = [ UserResponse.model_validate(user) - for user in await self.user_repository.get_inactive_users() + for user in await self.user_repository.get_inactive_users(days=days) ] return UserResponseList( user_list=users, diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py index 13717ef..fa7d0b6 100644 --- a/notification-service/core/celery/tasks.py +++ b/notification-service/core/celery/tasks.py @@ -71,7 +71,7 @@ def send_reset_password_confirmation_code_email( @app.task( # type: ignore[untyped-decorator] name=TaskType.send_inactive_users_email.value, ) -def send_inactive_users_email(send_inactive_users_email_data: dict) -> None: +def send_inactive_users_email(send_inactive_users_email_data: dict) -> None: # type: ignore[type-arg] send_inactive_users_movie_selection_data = ( SendInactiveUsersMovieSelectionData.model_validate( send_inactive_users_email_data, diff --git a/packages/celery/constants.py b/packages/celery/constants.py index aa3e596..091ce5d 100644 --- a/packages/celery/constants.py +++ b/packages/celery/constants.py @@ -8,19 +8,19 @@ class Queue(StrEnum): class TaskType(StrEnum): - delete_temporary_file = "media-service.media.delete_temporary_file" + delete_temporary_file = "media-service.media.delete-temporary-file" send_welcome_email = "notification-service.email.send-welcome-email" send_registration_confirmation_code_email = ( "notification-service.email.send-registration-confirmation-code-email" ) send_auth_confirmation_code_email = ( - "notification-service.email.send_auth_confirmation_code_email" + "notification-service.email.send-auth-confirmation-code-email" ) - send_reset_password_confirmation_code_email = "notification-service.email.send_reset_password_confirmation_code_email" # noqa: S105 + send_reset_password_confirmation_code_email = "notification-service.email.send-reset-password-confirmation-code-email" # noqa: S105 E501 create_chain_to_notify_inactive_users = ( - "movie-catalog.celery.create_chain_to_notify_inactive_users" + "movie-catalog.celery.create-chain-to-notify-inactive-users" ) get_data_to_send_inactive_users_email = ( - "movie-catalog.mailing-list.get_data_to_send_inactive_users_email" + "movie-catalog.mailing-list.get-data-to-send-inactive-users-email" ) send_inactive_users_email = "notification-service.email.send-inactive-users-email" diff --git a/packages/celery/utils.py b/packages/celery/utils.py index d930942..1bdde68 100644 --- a/packages/celery/utils.py +++ b/packages/celery/utils.py @@ -3,5 +3,5 @@ from typing import Any -def sync_run_coroutine_function(coroutine: Coroutine) -> Any: +def sync_run_coroutine_function(coroutine: Coroutine) -> Any: # type: ignore[type-arg] return asyncio.run(coroutine) diff --git a/tests/test_schemas/test_user.py b/tests/test_schemas/test_user.py index 8dc3e19..8359da7 100644 --- a/tests/test_schemas/test_user.py +++ b/tests/test_schemas/test_user.py @@ -7,7 +7,6 @@ from schemas.user import ( UserResponse, UserBase, - UserCreate, UserUpdate, UserResponseList, UserPartialUpdate, @@ -38,7 +37,6 @@ "schema", [ UserBase, - UserCreate, UserUpdate, UserPartialUpdate, UserResponse, @@ -138,7 +136,6 @@ def test_user_without_field( @pytest.mark.parametrize( "schema", [ - UserCreate, UserUpdate, ], )