From 4e071d9235e4b0386e2c6ba92bc8af21493b093d Mon Sep 17 00:00:00 2001 From: Pycb Date: Mon, 11 Jul 2022 14:49:34 -0700 Subject: [PATCH 01/12] =?UTF-8?q?#21:=20feat:=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B9=D1=82=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=B8=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=20=D0=BA=20=D0=BD=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- conf/clickhouse/scripts/init.sql | 22 +++++++ src/ugc/api/v1/handlers/ugc.py | 31 ++++++++- src/ugc/api/v1/openapi.py | 24 +++++++ src/ugc/api/v1/routes.py | 9 +++ src/ugc/api/v1/serializers.py | 7 ++ src/ugc/containers.py | 52 ++++++++++++++- src/ugc/core/config.py | 3 + src/ugc/domain/rating/__init__.py | 13 ++++ src/ugc/domain/rating/dispatchers.py | 31 +++++++++ src/ugc/domain/rating/factories.py | 15 +++++ src/ugc/domain/rating/models.py | 13 ++++ src/ugc/domain/rating/processors.py | 21 ++++++ src/ugc/domain/rating/repositories.py | 64 +++++++++++++++++++ src/ugc/domain/rating/types.py | 23 +++++++ tests/functional/api/v1/rating/__init__.py | 0 .../api/v1/rating/test_film_rating_create.py | 47 ++++++++++++++ .../v1/rating/test_film_rating_retrieve.py | 29 +++++++++ 17 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 src/ugc/domain/rating/__init__.py create mode 100644 src/ugc/domain/rating/dispatchers.py create mode 100644 src/ugc/domain/rating/factories.py create mode 100644 src/ugc/domain/rating/models.py create mode 100644 src/ugc/domain/rating/processors.py create mode 100644 src/ugc/domain/rating/repositories.py create mode 100644 src/ugc/domain/rating/types.py create mode 100644 tests/functional/api/v1/rating/__init__.py create mode 100644 tests/functional/api/v1/rating/test_film_rating_create.py create mode 100644 tests/functional/api/v1/rating/test_film_rating_retrieve.py diff --git a/conf/clickhouse/scripts/init.sql b/conf/clickhouse/scripts/init.sql index c804bcd..dc73acc 100644 --- a/conf/clickhouse/scripts/init.sql +++ b/conf/clickhouse/scripts/init.sql @@ -44,3 +44,25 @@ ENGINE = Distributed('ugc_cluster', ugc, bookmarks, rand()); CREATE MATERIALIZED VIEW IF NOT EXISTS ugc.bookmarks_consumer ON CLUSTER 'ugc_cluster' TO ugc.bookmarks_distributed AS SELECT user_id, film_id, bookmarked, bookmarked_at FROM ugc.bookmarks_queue; + +-- Rating +CREATE TABLE IF NOT EXISTS ugc.rating_queue ON CLUSTER 'ugc_cluster' +( + user_id UUID, + film_id UUID, + rating Int32 +) +ENGINE=Kafka('kafka:9092', 'film-rating-topic', 'rating-group-kafka', 'JSONEachRow'); + +CREATE TABLE IF NOT EXISTS ugc.rating ON CLUSTER 'ugc_cluster' +AS ugc.rating_queue +ENGINE = ReplicatedMergeTree('/clickhouse/tables/{cluster}/{shard}/rating', '{replica}_rating') +ORDER BY (user_id, film_id, rating); + +CREATE TABLE IF NOT EXISTS ugc.rating_distributed ON CLUSTER 'ugc_cluster' +AS ugc.rating +ENGINE = Distributed('ugc_cluster', ugc, rating, rand()); + +CREATE MATERIALIZED VIEW IF NOT EXISTS ugc.rating_consumer ON CLUSTER 'ugc_cluster' TO ugc.rating_distributed +AS SELECT user_id, film_id, rating +FROM ugc.rating_queue; diff --git a/src/ugc/api/v1/handlers/ugc.py b/src/ugc/api/v1/handlers/ugc.py index d459669..99587bd 100644 --- a/src/ugc/api/v1/handlers/ugc.py +++ b/src/ugc/api/v1/handlers/ugc.py @@ -12,12 +12,13 @@ from ugc.api.security import get_user_id_from_jwt from ugc.api.utils import orjson_response from ugc.api.v1 import openapi -from ugc.api.v1.serializers import FilmProgressCreate +from ugc.api.v1.serializers import FilmProgressCreate, FilmRatingCreate from ugc.containers import Container if TYPE_CHECKING: from ugc.domain.bookmarks import BookmarkDispatcherService, BookmarkService from ugc.domain.progress import ProgressDispatcherService, ProgressService + from ugc.domain.rating import FilmRatingDispatcherService, FilmRatingRepository @docs(**openapi.add_film_bookmark) @@ -83,6 +84,34 @@ async def get_film_progress( return orjson_response(progress, status=HTTPStatus.OK) +@docs(**openapi.get_film_rating) +@inject +async def get_film_rating( + request: web.Request, *, + film_rating_repository: FilmRatingRepository = Provide[Container.film_rating_repository], +) -> web.Response: + """Получение рейтинга фильма.""" + film_id: UUID = request.match_info["film_id"] + film_rating = await film_rating_repository.get_by_film_id(film_id=film_id) + return orjson_response(film_rating, status=HTTPStatus.OK) + + +@docs(**openapi.set_film_rating) +@request_schema(FilmRatingCreate) +@inject +async def set_film_rating( + request: web.Request, *, + film_rating_dispatcher: FilmRatingDispatcherService = Provide[Container.film_rating_dispatcher_service], +) -> web.Response: + """Установка рейтинга фильму.""" + film_id: UUID = request.match_info["film_id"] + validated_data = request["data"] + rating = validated_data["rating"] + user_id = get_user_id_from_jwt(request.headers) + film_rating = await film_rating_dispatcher.dispatch_film_rating(film_id=film_id, user_id=user_id, rating=rating) + return orjson_response(film_rating, status=HTTPStatus.ACCEPTED) + + async def _handle_film_bookmark( request: web.Request, bookmark_dispatcher: BookmarkDispatcherService, *, diff --git a/src/ugc/api/v1/openapi.py b/src/ugc/api/v1/openapi.py index c13ebbf..22c37f4 100644 --- a/src/ugc/api/v1/openapi.py +++ b/src/ugc/api/v1/openapi.py @@ -63,3 +63,27 @@ HTTPStatus.INTERNAL_SERVER_ERROR: {"description": "Ошибка сервера."}, }, } + +get_film_rating = { + "tags": ["rating"], + "summary": "Получить рейтинг фильма.", + "security": [{"JWT": []}], + "responses": { + HTTPStatus.OK: {"description": "Рейтинг фильма."}, + HTTPStatus.UNAUTHORIZED: {"description": "Пользователь не авторизован."}, + HTTPStatus.NOT_FOUND: {"description": "Фильм не найден."}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"description": "Ошибка сервера."}, + }, +} + +set_film_rating = { + "tags": ["rating"], + "summary": "Поставить оценку фильму.", + "security": [{"JWT": []}], + "responses": { + HTTPStatus.ACCEPTED: {"description": "Рейтинг фильма сохранен."}, + HTTPStatus.UNAUTHORIZED: {"description": "Пользователь не авторизован."}, + HTTPStatus.NOT_FOUND: {"description": "Фильм не найден."}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"description": "Ошибка сервера."}, + }, +} diff --git a/src/ugc/api/v1/routes.py b/src/ugc/api/v1/routes.py index e786db1..5dc9f7f 100644 --- a/src/ugc/api/v1/routes.py +++ b/src/ugc/api/v1/routes.py @@ -28,6 +28,15 @@ def setup_routes_v1(app: web.Application) -> None: handler=ugc.get_film_progress, allow_head=False, ), + web.get( + path="/ratings/films/{film_id}", + handler=ugc.get_film_rating, + allow_head=False, + ), + web.post( + path="/users/me/ratings/films/{film_id}", + handler=ugc.set_film_rating, + ), # Miscellaneous web.get( diff --git a/src/ugc/api/v1/serializers.py b/src/ugc/api/v1/serializers.py index 8c09b82..c05098c 100644 --- a/src/ugc/api/v1/serializers.py +++ b/src/ugc/api/v1/serializers.py @@ -25,3 +25,10 @@ class FilmProgressDetail(Schema): user_id = fields.UUID() film_id = fields.UUID() viewed_frame = fields.Integer() + + +class FilmRatingCreate(Schema): + """Сериалайзер для создания оценки фильму.""" + + rating = fields.Integer( + strict=True, required=True, validate=[Range(min=1, max=10, error="Rating must be between 1 and 10")]) diff --git a/src/ugc/containers.py b/src/ugc/containers.py index 0b5002f..777c8c7 100644 --- a/src/ugc/containers.py +++ b/src/ugc/containers.py @@ -3,7 +3,7 @@ import orjson from dependency_injector import containers, providers -from ugc.domain import bookmarks, processors, progress +from ugc.domain import bookmarks, processors, progress, rating from ugc.helpers import sentinel from ugc.infrastructure.db import redis from ugc.infrastructure.queue import consumers, producers @@ -77,6 +77,18 @@ class Container(containers.DeclarativeContainer): client=kafka_consumer_bookmark_client, ) + kafka_consumer_film_rating_client = providers.Resource( + consumers.init_kafka_consumer_client, + config=config, + topic=config.QUEUE_FILM_RATING_NAME, + group_id=config.QUEUE_FILM_RATING_GROUP, + **consumer_client_config, + ) + kafka_film_rating_consumer = providers.Singleton( + consumers.KafkaConsumer, + client=kafka_consumer_film_rating_client, + ) + # Domain -> Progress progress_factory = providers.Factory(progress.FilmProgressFactory) @@ -145,6 +157,35 @@ class Container(containers.DeclarativeContainer): message_callback=bookmark_processor, ) + # Domain -> FilmRating + + film_rating_factory = providers.Factory(rating.FilmRatingFactory) + + film_rating_repository = providers.Singleton( + rating.FilmRatingRepository, + film_rating_factory=film_rating_factory, + ) + + film_rating_processor = providers.Singleton( + rating.FilmRatingProcessor, + film_rating_factory=film_rating_factory, + film_rating_repository=film_rating_repository, + ) + + film_rating_dispatcher_service = providers.Factory( + rating.FilmRatingDispatcherService, + film_rating_factory=film_rating_factory, + producer=kafka_producer, + config=config, + ) + + film_rating_processor_service = providers.Factory( + processors.ProcessorService, + consumer=kafka_film_rating_consumer, + concurrency=config.QUEUE_FILM_RATING_CONSUMERS, + message_callback=film_rating_processor, + ) + def override_providers(container: Container) -> Container: if not container.config.USE_STUBS(): @@ -153,9 +194,11 @@ def override_providers(container: Container) -> Container: container.kafka_producer_client.override(providers.Resource(dummy_resource)) container.kafka_consumer_progress_client.override(providers.Resource(dummy_resource)) container.kafka_consumer_bookmark_client.override(providers.Resource(dummy_resource)) + container.kafka_consumer_film_rating_client.override(providers.Resource(dummy_resource)) container.kafka_producer.override(sentinel) container.kafka_bookmark_consumer.override(sentinel) container.kafka_progress_consumer.override(sentinel) + container.kafka_film_rating_consumer.override(sentinel) progress_processor = providers.Singleton( InMemoryProcessor, @@ -165,10 +208,16 @@ def override_providers(container: Container) -> Container: InMemoryProcessor, queue=providers.Singleton(InMemoryQueue), ) + film_rating_processor = providers.Singleton( + InMemoryProcessor, + queue=providers.Singleton(InMemoryQueue), + ) container.progress_dispatcher_service.add_kwargs(producer=progress_processor) container.bookmark_dispatcher_service.add_kwargs(producer=bookmark_processor) + container.film_rating_dispatcher_service.add_kwargs(producer=film_rating_processor) container.progress_processor_service.add_kwargs(consumer=progress_processor) container.bookmark_processor_service.add_kwargs(consumer=bookmark_processor) + container.film_rating_processor_service.add_kwargs(consumer=film_rating_processor) return container @@ -177,6 +226,7 @@ async def get_processors(container: Container) -> list[processors.ProcessorServi processor_services = [ container.progress_processor_service(), container.bookmark_processor_service(), + container.film_rating_processor_service(), ] if container.progress_processor_service.is_async_mode_enabled(): return [await processor_service for processor_service in processor_services] diff --git a/src/ugc/core/config.py b/src/ugc/core/config.py index 7a0a8db..06b89aa 100644 --- a/src/ugc/core/config.py +++ b/src/ugc/core/config.py @@ -46,6 +46,9 @@ class Settings(BaseSettings): QUEUE_BOOKMARKS_NAME: str = Field("bookmarks-topic") QUEUE_BOOKMARKS_GROUP: str = Field("bookmarks-group") QUEUE_BOOKMARKS_CONSUMERS: int = Field(2) + QUEUE_FILM_RATING_NAME: str = Field("film-rating-topic") + QUEUE_FILM_RATING_GROUP: str = Field("film-rating-group") + QUEUE_FILM_RATING_CONSUMERS: int = Field(2) QUEUE_ENABLE_AUTOCOMMIT: bool = Field(True) QUEUE_AUTO_COMMIT_INTERVAL_MS: int = Field(1000) QUEUE_AUTO_OFFSET_RESET: str = Field("earliest") diff --git a/src/ugc/domain/rating/__init__.py b/src/ugc/domain/rating/__init__.py new file mode 100644 index 0000000..bad68a8 --- /dev/null +++ b/src/ugc/domain/rating/__init__.py @@ -0,0 +1,13 @@ +from .dispatchers import FilmRatingDispatcherService +from .factories import FilmRatingFactory +from .processors import FilmRatingProcessor +from .repositories import FilmRatingRepository +from .types import FilmRating + +__all__ = [ + "FilmRating", + "FilmRatingFactory", + "FilmRatingDispatcherService", + "FilmRatingProcessor", + "FilmRatingRepository", +] diff --git a/src/ugc/domain/rating/dispatchers.py b/src/ugc/domain/rating/dispatchers.py new file mode 100644 index 0000000..7753475 --- /dev/null +++ b/src/ugc/domain/rating/dispatchers.py @@ -0,0 +1,31 @@ +from typing import Any +from uuid import UUID + +from ugc.helpers import delay_tasks +from ugc.infrastructure.queue.producers import AsyncProducer + +from .factories import FilmRatingFactory +from .types import FilmRating + + +class FilmRatingDispatcherService: + """Сервис для диспатчинга событий рейтинга фильма.""" + + def __init__(self, film_rating_factory: FilmRatingFactory, producer: AsyncProducer, config: dict[str, Any]) -> None: + assert isinstance(film_rating_factory, FilmRatingFactory) + self._film_rating_factory = film_rating_factory + + assert isinstance(producer, AsyncProducer) + self._producer = producer + + assert isinstance(config, dict) + self._config = config + + async def dispatch_film_rating(self, *, user_id: UUID, film_id: UUID, rating: int) -> FilmRating: + film_rating = self._film_rating_factory.create_new(user_id=user_id, film_id=film_id, rating=rating) + delay_tasks( + self._producer.send( + self._config["QUEUE_FILM_RATING_NAME"], key=f"{user_id}:{film_id}", message=film_rating.to_dict(), + ), + ) + return film_rating diff --git a/src/ugc/domain/rating/factories.py b/src/ugc/domain/rating/factories.py new file mode 100644 index 0000000..1f915f2 --- /dev/null +++ b/src/ugc/domain/rating/factories.py @@ -0,0 +1,15 @@ +from ..factories import BaseModelFactory +from .types import FilmRating + + +class FilmRatingFactory(BaseModelFactory[FilmRating]): + """Фабрика по созданию объектов `FilmRating`.""" + + cls = FilmRating + + def create_new(self, **kwargs) -> FilmRating: + return self.cls( + user_id=kwargs["user_id"], + film_id=kwargs["film_id"], + rating=kwargs["rating"], + ) diff --git a/src/ugc/domain/rating/models.py b/src/ugc/domain/rating/models.py new file mode 100644 index 0000000..a714ac4 --- /dev/null +++ b/src/ugc/domain/rating/models.py @@ -0,0 +1,13 @@ +from uuid import UUID + +from aredis_om import Field + +from ugc.domain.models import BaseHashModel + + +class FilmRating(BaseHashModel): + """Рейтинг фильма, поставленный пользователем.""" + + user_id: UUID = Field(index=True) + film_id: UUID = Field(index=True) + rating: int diff --git a/src/ugc/domain/rating/processors.py b/src/ugc/domain/rating/processors.py new file mode 100644 index 0000000..6376753 --- /dev/null +++ b/src/ugc/domain/rating/processors.py @@ -0,0 +1,21 @@ +from ugc.infrastructure.queue.typedefs import IConsumerRecord + +from .factories import FilmRatingFactory +from .repositories import FilmRatingRepository +from .types import FilmRating + + +class FilmRatingProcessor: + """Обработка добавления оценок фильма.""" + + def __init__(self, film_rating_factory: FilmRatingFactory, film_rating_repository: FilmRatingRepository) -> None: + assert isinstance(film_rating_factory, FilmRatingFactory) + self._factory = film_rating_factory + + assert isinstance(film_rating_repository, FilmRatingRepository) + self._repository = film_rating_repository + + async def __call__(self, message: IConsumerRecord) -> FilmRating: + rating = self._factory.create_from_serialized(message.value) + await self._repository.create(rating) + return rating diff --git a/src/ugc/domain/rating/repositories.py b/src/ugc/domain/rating/repositories.py new file mode 100644 index 0000000..c2d989e --- /dev/null +++ b/src/ugc/domain/rating/repositories.py @@ -0,0 +1,64 @@ +from contextlib import suppress +from typing import Sequence + +from aioredis import RedisError + +from ugc.common.exceptions import NotFoundError +from ugc.infrastructure.db.repositories import BaseRepository + +from . import types +from .factories import FilmRatingFactory +from .models import FilmRating + + +class FilmRatingRepository(BaseRepository[FilmRating]): + """Репозиторий для работы с данными оценок фильмов.""" + + model = FilmRating + + def __init__(self, film_rating_factory: FilmRatingFactory) -> None: + assert isinstance(film_rating_factory, FilmRatingFactory) + self._factory = film_rating_factory + + async def create(self, rating: types.FilmRating, /) -> types.FilmRating: + """Добавление оценки пользователя для фильма.""" + data = rating.to_dict() + rating = await self.model(**data).save() + return rating + + async def get_by_user_id(self, user_id: str) -> list[types.FilmRating]: + """Получение списка оценок пользователя по его ID.""" + film_ratings = await self.model.find(self.model.user_id == user_id).all() + return self._deserialize_sequence(film_ratings) + + async def get_by_film_id(self, *, film_id: str) -> int: + """Получение средней оценки фильма по его ID.""" + film_ratings = await self.model.find(self.model.film_id == film_id).all() + rating_count = len(film_ratings) + if rating_count: + rating_sum = sum([film_rating.rating for film_rating in film_ratings]) + return rating_sum // rating_count + raise NotFoundError() + + async def delete(self, *, user_id: str, film_id: str) -> None: + """Удаление оценки.""" + # XXX: клиент Redis OM не обрабатывает корректно ошибки при `.delete()` [1] + # [1] https://github.com/redis/redis-om-python/blob/d604797d9d01c06ff9268d1c4e644c20da6340d4/aredis_om/model/model.py#L794 # noqa + with suppress(RedisError): + return await self.model.find( + (self.model.user_id == user_id) & + (self.model.film_id == film_id), + ).delete() + + def _deserialize_sequence(self, film_ratings: Sequence[FilmRating]) -> list[types.FilmRating]: + deserialized = [ + self._factory.create_from_serialized(self._prepare_fields(film_rating)) + for film_rating in film_ratings + ] + return deserialized + + @staticmethod + def _prepare_fields(film_rating: FilmRating) -> dict: + data = film_rating.dict() + data["id"] = str(data.pop("pk")) + return data diff --git a/src/ugc/domain/rating/types.py b/src/ugc/domain/rating/types.py new file mode 100644 index 0000000..c64b9bc --- /dev/null +++ b/src/ugc/domain/rating/types.py @@ -0,0 +1,23 @@ +from typing import Any +from uuid import UUID + +from ..types import BaseModel + + +class FilmRating(BaseModel): + """Оценки фильма.""" + + user_id: UUID + film_id: UUID + rating: int + + @classmethod + def from_dict(cls, data: dict) -> "FilmRating": + return cls(user_id=data["user_id"], film_id=data["film_id"], rating=data["rating"]) + + def to_dict(self) -> dict[str, Any]: + return { + "user_id": str(self.user_id), + "film_id": str(self.film_id), + "rating": self.rating, + } diff --git a/tests/functional/api/v1/rating/__init__.py b/tests/functional/api/v1/rating/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/functional/api/v1/rating/test_film_rating_create.py b/tests/functional/api/v1/rating/test_film_rating_create.py new file mode 100644 index 0000000..e82da20 --- /dev/null +++ b/tests/functional/api/v1/rating/test_film_rating_create.py @@ -0,0 +1,47 @@ +import pytest + +from tests.functional.api.constants import VALID_FILM_ID, VALID_USER_ID + +from ..base import AuthClientTest + + +class TestFilmRatingCreate(AuthClientTest): + """Тестирование трекинга прогресса фильма.""" + + endpoint = "/api/v1/users/me/ratings/films/{film_id}" + method = "post" + format_url = True + use_data = True + location = "json" + + async def test_ok(self): + """При корректном теле запроса клиент получает ответ об успешном сохранении рейтинга в очередь.""" + url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" + rating = 10 + data = {"rating": rating} + + await self.client.post(url, json=data, expected_status_code=202) + + async def test_wrong_body_rating_not_inger(self): + """Если клиент передает `rating` не числом, то он получит ошибку.""" + url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" + rating = "XXX" + data = {"rating": rating} + + await self.client.post(url, json=data, expected_status_code=422) + + async def test_wrong_body_rating(self): + """Если клиент передает `rating` не в диапазоне 1-10, то он получит ошибку.""" + url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" + rating = 11 + data = {"rating": rating} + + await self.client.post(url, json=data, expected_status_code=422) + + @pytest.fixture + async def pre_auth_invalid_access_token(self): + return {"film_id": VALID_FILM_ID, "user_id": VALID_USER_ID, "rating": 10} + + @pytest.fixture + async def pre_auth_no_credentials(self): + return {"film_id": VALID_FILM_ID, "user_id": VALID_USER_ID, "rating": 10} diff --git a/tests/functional/api/v1/rating/test_film_rating_retrieve.py b/tests/functional/api/v1/rating/test_film_rating_retrieve.py new file mode 100644 index 0000000..a539fac --- /dev/null +++ b/tests/functional/api/v1/rating/test_film_rating_retrieve.py @@ -0,0 +1,29 @@ +import pytest + +from tests.functional.api.constants import VALID_FILM_ID + +from ..base import AuthClientTest + + +class TestFilmRatingRetrieve(AuthClientTest): + """Тестирование получения информации о рейтинге фильма.""" + + endpoint = "/api/v1/ratings/films/{VALID_FILM_ID}" + method = "get" + format_url = True + + async def test_ok(self): + """Если в БД есть информация о рейтинге фильма, то клиент получит ее.""" + await self.client.get(self.endpoint) + + async def test_not_found(self): + """Если фильма нет в БД, то пользователь получит ошибку.""" + await self.client.get("/api/v1/ratings/films/XXX", expected_status_code=404) + + @pytest.fixture + async def pre_auth_invalid_access_token(self): + return {"film_id": VALID_FILM_ID} + + @pytest.fixture + async def pre_auth_no_credentials(self): + return {"film_id": VALID_FILM_ID} From 2c00d1f5c8203ae7c99ac78543923a45fdec9b51 Mon Sep 17 00:00:00 2001 From: Roman Reznikov Date: Tue, 12 Jul 2022 11:18:23 +0400 Subject: [PATCH 02/12] #21: fix: create new Kafka topic `film-rating-topic` on startup --- docker-compose-dev.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 021933e..1ea6b1a 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -112,6 +112,7 @@ services: echo -e 'Creating kafka topics' kafka-topics --bootstrap-server kafka:29092 --create --if-not-exists --topic progress-topic --replication-factor 1 --partitions 3 kafka-topics --bootstrap-server kafka:29092 --create --if-not-exists --topic bookmarks-topic --replication-factor 1 --partitions 3 + kafka-topics --bootstrap-server kafka:29092 --create --if-not-exists --topic film-rating-topic --replication-factor 1 --partitions 3 echo -e 'Successfully created the following topics:' kafka-topics --bootstrap-server kafka:29092 --list From d310ccd58a6f2f6b6a4e2460dff00dc061cd2f7d Mon Sep 17 00:00:00 2001 From: Roman Reznikov Date: Tue, 12 Jul 2022 15:44:43 +0400 Subject: [PATCH 03/12] #21: chore: fixes after review --- .github/workflows/workflow.yml | 3 + README.md | 6 ++ src/ugc/api/v1/handlers/ugc.py | 16 +++-- src/ugc/api/v1/openapi.py | 16 ++--- src/ugc/api/v1/routes.py | 2 +- src/ugc/api/v1/serializers.py | 7 ++ src/ugc/common/exceptions.py | 6 +- src/ugc/containers.py | 37 +++++++---- src/ugc/domain/bookmarks/dispatchers.py | 4 +- src/ugc/domain/progress/dispatchers.py | 4 +- src/ugc/domain/rating/factories.py | 15 ----- src/ugc/domain/rating/repositories.py | 64 ------------------- src/ugc/domain/rating/types.py | 23 ------- .../domain/{rating => ratings}/__init__.py | 5 +- .../domain/{rating => ratings}/dispatchers.py | 4 +- src/ugc/domain/ratings/exceptions.py | 5 ++ src/ugc/domain/ratings/factories.py | 21 ++++++ src/ugc/domain/{rating => ratings}/models.py | 2 +- .../domain/{rating => ratings}/processors.py | 10 +-- src/ugc/domain/ratings/repositories.py | 52 +++++++++++++++ src/ugc/domain/ratings/services.py | 21 ++++++ src/ugc/domain/ratings/types.py | 36 +++++++++++ src/ugc/main.py | 6 +- 23 files changed, 217 insertions(+), 148 deletions(-) delete mode 100644 src/ugc/domain/rating/factories.py delete mode 100644 src/ugc/domain/rating/repositories.py delete mode 100644 src/ugc/domain/rating/types.py rename src/ugc/domain/{rating => ratings}/__init__.py (70%) rename src/ugc/domain/{rating => ratings}/dispatchers.py (86%) create mode 100644 src/ugc/domain/ratings/exceptions.py create mode 100644 src/ugc/domain/ratings/factories.py rename src/ugc/domain/{rating => ratings}/models.py (89%) rename src/ugc/domain/{rating => ratings}/processors.py (68%) create mode 100644 src/ugc/domain/ratings/repositories.py create mode 100644 src/ugc/domain/ratings/services.py create mode 100644 src/ugc/domain/ratings/types.py diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index cd3697a..9f3ff02 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -85,6 +85,9 @@ jobs: export NUGC_QUEUE_BOOKMARKS_GROUP=bookmarks-group export NUGC_QUEUE_BOOKMARKS_CONSUMERS=2 export NUGC_QUEUE_BOOKMARKS_NAME=bookmarks-topic + export NUGC_QUEUE_FILM_RATING_NAME=film-rating-topic + export NUGC_QUEUE_FILM_RATING_GROUP=film-rating-group + export NUGC_FILM_RATING_CONSUMERS=2 export NUGC_KAFKA_URL=kafka:9092 export NUGC_USE_STUBS=1 export NUGC_CI=1 diff --git a/README.md b/README.md index d478017..f104421 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,9 @@ NUGC_QUEUE_PROGRESS_CONSUMERS=2 NUGC_QUEUE_BOOKMARKS_NAME=bookmarks-topic NUGC_QUEUE_BOOKMARKS_GROUP=bookmarks-group NUGC_QUEUE_BOOKMARKS_CONSUMERS=2 +NUGC_QUEUE_FILM_RATING_NAME=film-rating-topic +NUGC_QUEUE_FILM_RATING_GROUP=film-rating-group +NUGC_FILM_RATING_CONSUMERS=2 # Config NUGC_USE_STUBS=0 NUGC_TESTING=0 @@ -158,6 +161,9 @@ NUGC_QUEUE_PROGRESS_CONSUMERS=2 NUGC_QUEUE_BOOKMARKS_NAME=bookmarks-topic NUGC_QUEUE_BOOKMARKS_GROUP=bookmarks-group NUGC_QUEUE_BOOKMARKS_CONSUMERS=2 +NUGC_QUEUE_FILM_RATING_NAME=film-rating-topic +NUGC_QUEUE_FILM_RATING_GROUP=film-rating-group +NUGC_FILM_RATING_CONSUMERS=2 # Kafka NUGC_KAFKA_URL=kafka:9092 # Config diff --git a/src/ugc/api/v1/handlers/ugc.py b/src/ugc/api/v1/handlers/ugc.py index 99587bd..fbd4a7b 100644 --- a/src/ugc/api/v1/handlers/ugc.py +++ b/src/ugc/api/v1/handlers/ugc.py @@ -14,11 +14,12 @@ from ugc.api.v1 import openapi from ugc.api.v1.serializers import FilmProgressCreate, FilmRatingCreate from ugc.containers import Container +from ugc.domain.ratings.exceptions import NoFilmRatingError if TYPE_CHECKING: from ugc.domain.bookmarks import BookmarkDispatcherService, BookmarkService from ugc.domain.progress import ProgressDispatcherService, ProgressService - from ugc.domain.rating import FilmRatingDispatcherService, FilmRatingRepository + from ugc.domain.ratings import FilmRatingDispatcherService, FilmRatingService @docs(**openapi.add_film_bookmark) @@ -88,22 +89,25 @@ async def get_film_progress( @inject async def get_film_rating( request: web.Request, *, - film_rating_repository: FilmRatingRepository = Provide[Container.film_rating_repository], + film_rating_service: FilmRatingService = Provide[Container.film_rating_service], ) -> web.Response: """Получение рейтинга фильма.""" film_id: UUID = request.match_info["film_id"] - film_rating = await film_rating_repository.get_by_film_id(film_id=film_id) + try: + film_rating = await film_rating_service.get_film_rating(film_id) + except NoFilmRatingError: + return orjson_response(status=HTTPStatus.NO_CONTENT) return orjson_response(film_rating, status=HTTPStatus.OK) -@docs(**openapi.set_film_rating) +@docs(**openapi.add_film_rating) @request_schema(FilmRatingCreate) @inject -async def set_film_rating( +async def add_film_rating( request: web.Request, *, film_rating_dispatcher: FilmRatingDispatcherService = Provide[Container.film_rating_dispatcher_service], ) -> web.Response: - """Установка рейтинга фильму.""" + """Добавление пользовательского рейтинга фильму.""" film_id: UUID = request.match_info["film_id"] validated_data = request["data"] rating = validated_data["rating"] diff --git a/src/ugc/api/v1/openapi.py b/src/ugc/api/v1/openapi.py index 22c37f4..34d9c9f 100644 --- a/src/ugc/api/v1/openapi.py +++ b/src/ugc/api/v1/openapi.py @@ -65,25 +65,25 @@ } get_film_rating = { - "tags": ["rating"], + "tags": ["ratings"], "summary": "Получить рейтинг фильма.", - "security": [{"JWT": []}], "responses": { - HTTPStatus.OK: {"description": "Рейтинг фильма."}, - HTTPStatus.UNAUTHORIZED: {"description": "Пользователь не авторизован."}, - HTTPStatus.NOT_FOUND: {"description": "Фильм не найден."}, + HTTPStatus.OK: { + "description": "Рейтинг фильма.", + "schema": serializers.FilmRating, + }, + HTTPStatus.NO_CONTENT: {"description": "У фильма нет рейтинга."}, HTTPStatus.INTERNAL_SERVER_ERROR: {"description": "Ошибка сервера."}, }, } -set_film_rating = { - "tags": ["rating"], +add_film_rating = { + "tags": ["ratings"], "summary": "Поставить оценку фильму.", "security": [{"JWT": []}], "responses": { HTTPStatus.ACCEPTED: {"description": "Рейтинг фильма сохранен."}, HTTPStatus.UNAUTHORIZED: {"description": "Пользователь не авторизован."}, - HTTPStatus.NOT_FOUND: {"description": "Фильм не найден."}, HTTPStatus.INTERNAL_SERVER_ERROR: {"description": "Ошибка сервера."}, }, } diff --git a/src/ugc/api/v1/routes.py b/src/ugc/api/v1/routes.py index 5dc9f7f..a21d08b 100644 --- a/src/ugc/api/v1/routes.py +++ b/src/ugc/api/v1/routes.py @@ -35,7 +35,7 @@ def setup_routes_v1(app: web.Application) -> None: ), web.post( path="/users/me/ratings/films/{film_id}", - handler=ugc.set_film_rating, + handler=ugc.add_film_rating, ), # Miscellaneous diff --git a/src/ugc/api/v1/serializers.py b/src/ugc/api/v1/serializers.py index c05098c..1f2a9c9 100644 --- a/src/ugc/api/v1/serializers.py +++ b/src/ugc/api/v1/serializers.py @@ -32,3 +32,10 @@ class FilmRatingCreate(Schema): rating = fields.Integer( strict=True, required=True, validate=[Range(min=1, max=10, error="Rating must be between 1 and 10")]) + + +class FilmRating(Schema): + """Сериалайзер средней оценки фильма.""" + + film_id = fields.UUID() + rating = fields.Float() diff --git a/src/ugc/common/exceptions.py b/src/ugc/common/exceptions.py index 47626a3..785b2e3 100644 --- a/src/ugc/common/exceptions.py +++ b/src/ugc/common/exceptions.py @@ -1,7 +1,11 @@ from http import HTTPStatus -class NetflixUGCError(Exception): +class BaseNetflixUGCError(Exception): + """Базовая ошибка сервиса.""" + + +class NetflixUGCError(BaseNetflixUGCError): """Ошибка сервиса Netflix UGC.""" message: str diff --git a/src/ugc/containers.py b/src/ugc/containers.py index 777c8c7..72e6d5c 100644 --- a/src/ugc/containers.py +++ b/src/ugc/containers.py @@ -1,9 +1,10 @@ import logging.config +from typing import AsyncIterator import orjson from dependency_injector import containers, providers -from ugc.domain import bookmarks, processors, progress, rating +from ugc.domain import bookmarks, processors, progress, ratings from ugc.helpers import sentinel from ugc.infrastructure.db import redis from ugc.infrastructure.queue import consumers, producers @@ -159,21 +160,27 @@ class Container(containers.DeclarativeContainer): # Domain -> FilmRating - film_rating_factory = providers.Factory(rating.FilmRatingFactory) + film_rating_factory = providers.Factory(ratings.FilmRatingFactory) film_rating_repository = providers.Singleton( - rating.FilmRatingRepository, + ratings.FilmRatingRepository, + redis_client=redis_client, film_rating_factory=film_rating_factory, ) + film_rating_service = providers.Singleton( + ratings.FilmRatingService, + film_rating_repository=film_rating_repository, + ) + film_rating_processor = providers.Singleton( - rating.FilmRatingProcessor, + ratings.FilmRatingProcessor, film_rating_factory=film_rating_factory, - film_rating_repository=film_rating_repository, + film_rating_service=film_rating_service, ) film_rating_dispatcher_service = providers.Factory( - rating.FilmRatingDispatcherService, + ratings.FilmRatingDispatcherService, film_rating_factory=film_rating_factory, producer=kafka_producer, config=config, @@ -222,15 +229,17 @@ def override_providers(container: Container) -> Container: return container -async def get_processors(container: Container) -> list[processors.ProcessorService]: - processor_services = [ - container.progress_processor_service(), - container.bookmark_processor_service(), - container.film_rating_processor_service(), +async def get_processors(container: Container) -> AsyncIterator[processors.ProcessorService]: + processor_providers = [ + container.progress_processor_service, + container.bookmark_processor_service, + container.film_rating_processor_service, ] - if container.progress_processor_service.is_async_mode_enabled(): - return [await processor_service for processor_service in processor_services] - return processor_services + for provider in processor_providers: + try: + yield await provider() + except TypeError: + yield provider() async def dummy_resource() -> None: diff --git a/src/ugc/domain/bookmarks/dispatchers.py b/src/ugc/domain/bookmarks/dispatchers.py index 4eefa4f..66ffe5e 100644 --- a/src/ugc/domain/bookmarks/dispatchers.py +++ b/src/ugc/domain/bookmarks/dispatchers.py @@ -16,7 +16,7 @@ class BookmarkDispatcherService: def __init__(self, bookmark_factory: FilmBookmarkFactory, producer: AsyncProducer, config: dict[str, Any]) -> None: assert isinstance(bookmark_factory, FilmBookmarkFactory) - self._bookmark_factory = bookmark_factory + self._factory = bookmark_factory assert isinstance(producer, AsyncProducer) self._producer = producer @@ -25,7 +25,7 @@ def __init__(self, bookmark_factory: FilmBookmarkFactory, producer: AsyncProduce self._config = config async def dispatch_bookmark_switch(self, *, user_id: UUID, film_id: UUID, bookmarked: bool) -> FilmBookmark: - bookmark = self._bookmark_factory.create_new(user_id=user_id, film_id=film_id, bookmarked=bookmarked) + bookmark = self._factory.create_new(user_id=user_id, film_id=film_id, bookmarked=bookmarked) delay_tasks( self._producer.send( self._config["QUEUE_BOOKMARKS_NAME"], key=f"{user_id}:{film_id}", message=bookmark.to_dict(), diff --git a/src/ugc/domain/progress/dispatchers.py b/src/ugc/domain/progress/dispatchers.py index 1828028..f5d73ae 100644 --- a/src/ugc/domain/progress/dispatchers.py +++ b/src/ugc/domain/progress/dispatchers.py @@ -13,7 +13,7 @@ class ProgressDispatcherService: def __init__(self, progress_factory: FilmProgressFactory, producer: AsyncProducer, config: dict[str, Any]) -> None: assert isinstance(progress_factory, FilmProgressFactory) - self._progress_factory = progress_factory + self._factory = progress_factory assert isinstance(producer, AsyncProducer) self._producer = producer @@ -22,7 +22,7 @@ def __init__(self, progress_factory: FilmProgressFactory, producer: AsyncProduce self._config = config async def dispatch_progress_tracking(self, *, user_id: UUID, film_id: UUID, viewed_frame: int) -> FilmProgress: - progress = self._progress_factory.create_new(user_id=user_id, film_id=film_id, viewed_frame=viewed_frame) + progress = self._factory.create_new(user_id=user_id, film_id=film_id, viewed_frame=viewed_frame) delay_tasks( self._producer.send( self._config["QUEUE_PROGRESS_NAME"], key=f"{user_id}:{film_id}", message=progress.to_dict(), diff --git a/src/ugc/domain/rating/factories.py b/src/ugc/domain/rating/factories.py deleted file mode 100644 index 1f915f2..0000000 --- a/src/ugc/domain/rating/factories.py +++ /dev/null @@ -1,15 +0,0 @@ -from ..factories import BaseModelFactory -from .types import FilmRating - - -class FilmRatingFactory(BaseModelFactory[FilmRating]): - """Фабрика по созданию объектов `FilmRating`.""" - - cls = FilmRating - - def create_new(self, **kwargs) -> FilmRating: - return self.cls( - user_id=kwargs["user_id"], - film_id=kwargs["film_id"], - rating=kwargs["rating"], - ) diff --git a/src/ugc/domain/rating/repositories.py b/src/ugc/domain/rating/repositories.py deleted file mode 100644 index c2d989e..0000000 --- a/src/ugc/domain/rating/repositories.py +++ /dev/null @@ -1,64 +0,0 @@ -from contextlib import suppress -from typing import Sequence - -from aioredis import RedisError - -from ugc.common.exceptions import NotFoundError -from ugc.infrastructure.db.repositories import BaseRepository - -from . import types -from .factories import FilmRatingFactory -from .models import FilmRating - - -class FilmRatingRepository(BaseRepository[FilmRating]): - """Репозиторий для работы с данными оценок фильмов.""" - - model = FilmRating - - def __init__(self, film_rating_factory: FilmRatingFactory) -> None: - assert isinstance(film_rating_factory, FilmRatingFactory) - self._factory = film_rating_factory - - async def create(self, rating: types.FilmRating, /) -> types.FilmRating: - """Добавление оценки пользователя для фильма.""" - data = rating.to_dict() - rating = await self.model(**data).save() - return rating - - async def get_by_user_id(self, user_id: str) -> list[types.FilmRating]: - """Получение списка оценок пользователя по его ID.""" - film_ratings = await self.model.find(self.model.user_id == user_id).all() - return self._deserialize_sequence(film_ratings) - - async def get_by_film_id(self, *, film_id: str) -> int: - """Получение средней оценки фильма по его ID.""" - film_ratings = await self.model.find(self.model.film_id == film_id).all() - rating_count = len(film_ratings) - if rating_count: - rating_sum = sum([film_rating.rating for film_rating in film_ratings]) - return rating_sum // rating_count - raise NotFoundError() - - async def delete(self, *, user_id: str, film_id: str) -> None: - """Удаление оценки.""" - # XXX: клиент Redis OM не обрабатывает корректно ошибки при `.delete()` [1] - # [1] https://github.com/redis/redis-om-python/blob/d604797d9d01c06ff9268d1c4e644c20da6340d4/aredis_om/model/model.py#L794 # noqa - with suppress(RedisError): - return await self.model.find( - (self.model.user_id == user_id) & - (self.model.film_id == film_id), - ).delete() - - def _deserialize_sequence(self, film_ratings: Sequence[FilmRating]) -> list[types.FilmRating]: - deserialized = [ - self._factory.create_from_serialized(self._prepare_fields(film_rating)) - for film_rating in film_ratings - ] - return deserialized - - @staticmethod - def _prepare_fields(film_rating: FilmRating) -> dict: - data = film_rating.dict() - data["id"] = str(data.pop("pk")) - return data diff --git a/src/ugc/domain/rating/types.py b/src/ugc/domain/rating/types.py deleted file mode 100644 index c64b9bc..0000000 --- a/src/ugc/domain/rating/types.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Any -from uuid import UUID - -from ..types import BaseModel - - -class FilmRating(BaseModel): - """Оценки фильма.""" - - user_id: UUID - film_id: UUID - rating: int - - @classmethod - def from_dict(cls, data: dict) -> "FilmRating": - return cls(user_id=data["user_id"], film_id=data["film_id"], rating=data["rating"]) - - def to_dict(self) -> dict[str, Any]: - return { - "user_id": str(self.user_id), - "film_id": str(self.film_id), - "rating": self.rating, - } diff --git a/src/ugc/domain/rating/__init__.py b/src/ugc/domain/ratings/__init__.py similarity index 70% rename from src/ugc/domain/rating/__init__.py rename to src/ugc/domain/ratings/__init__.py index bad68a8..42f2c64 100644 --- a/src/ugc/domain/rating/__init__.py +++ b/src/ugc/domain/ratings/__init__.py @@ -2,12 +2,15 @@ from .factories import FilmRatingFactory from .processors import FilmRatingProcessor from .repositories import FilmRatingRepository -from .types import FilmRating +from .services import FilmRatingService +from .types import FilmAverageRating, FilmRating __all__ = [ + "FilmAverageRating", "FilmRating", "FilmRatingFactory", "FilmRatingDispatcherService", "FilmRatingProcessor", "FilmRatingRepository", + "FilmRatingService", ] diff --git a/src/ugc/domain/rating/dispatchers.py b/src/ugc/domain/ratings/dispatchers.py similarity index 86% rename from src/ugc/domain/rating/dispatchers.py rename to src/ugc/domain/ratings/dispatchers.py index 7753475..19d5051 100644 --- a/src/ugc/domain/rating/dispatchers.py +++ b/src/ugc/domain/ratings/dispatchers.py @@ -13,7 +13,7 @@ class FilmRatingDispatcherService: def __init__(self, film_rating_factory: FilmRatingFactory, producer: AsyncProducer, config: dict[str, Any]) -> None: assert isinstance(film_rating_factory, FilmRatingFactory) - self._film_rating_factory = film_rating_factory + self._factory = film_rating_factory assert isinstance(producer, AsyncProducer) self._producer = producer @@ -22,7 +22,7 @@ def __init__(self, film_rating_factory: FilmRatingFactory, producer: AsyncProduc self._config = config async def dispatch_film_rating(self, *, user_id: UUID, film_id: UUID, rating: int) -> FilmRating: - film_rating = self._film_rating_factory.create_new(user_id=user_id, film_id=film_id, rating=rating) + film_rating = self._factory.create_new(user_id=user_id, film_id=film_id, rating=rating) delay_tasks( self._producer.send( self._config["QUEUE_FILM_RATING_NAME"], key=f"{user_id}:{film_id}", message=film_rating.to_dict(), diff --git a/src/ugc/domain/ratings/exceptions.py b/src/ugc/domain/ratings/exceptions.py new file mode 100644 index 0000000..7c607cc --- /dev/null +++ b/src/ugc/domain/ratings/exceptions.py @@ -0,0 +1,5 @@ +from ugc.common.exceptions import BaseNetflixUGCError + + +class NoFilmRatingError(BaseNetflixUGCError): + """У фильма нет рейтинга.""" diff --git a/src/ugc/domain/ratings/factories.py b/src/ugc/domain/ratings/factories.py new file mode 100644 index 0000000..486c56a --- /dev/null +++ b/src/ugc/domain/ratings/factories.py @@ -0,0 +1,21 @@ +from ..factories import BaseModelFactory +from .types import FilmRating + + +class FilmRatingFactory(BaseModelFactory[FilmRating]): + """Фабрика по созданию объектов `FilmRating`.""" + + cls = FilmRating + + FILM_RATING_RANGE = range(1, 11) + + def create_new(self, **kwargs) -> FilmRating: + return self.cls( + user_id=kwargs["user_id"], film_id=kwargs["film_id"], + rating=self.validate_rating(kwargs["rating"]), + ) + + def validate_rating(self, rating: int) -> int: + if rating in self.FILM_RATING_RANGE: + return rating + raise ValueError(f"Rating must be in range of [1, 10]. Given: {rating}") diff --git a/src/ugc/domain/rating/models.py b/src/ugc/domain/ratings/models.py similarity index 89% rename from src/ugc/domain/rating/models.py rename to src/ugc/domain/ratings/models.py index a714ac4..b0ce975 100644 --- a/src/ugc/domain/rating/models.py +++ b/src/ugc/domain/ratings/models.py @@ -10,4 +10,4 @@ class FilmRating(BaseHashModel): user_id: UUID = Field(index=True) film_id: UUID = Field(index=True) - rating: int + rating: int = Field(index=True) diff --git a/src/ugc/domain/rating/processors.py b/src/ugc/domain/ratings/processors.py similarity index 68% rename from src/ugc/domain/rating/processors.py rename to src/ugc/domain/ratings/processors.py index 6376753..8671085 100644 --- a/src/ugc/domain/rating/processors.py +++ b/src/ugc/domain/ratings/processors.py @@ -1,21 +1,21 @@ from ugc.infrastructure.queue.typedefs import IConsumerRecord from .factories import FilmRatingFactory -from .repositories import FilmRatingRepository +from .services import FilmRatingService from .types import FilmRating class FilmRatingProcessor: """Обработка добавления оценок фильма.""" - def __init__(self, film_rating_factory: FilmRatingFactory, film_rating_repository: FilmRatingRepository) -> None: + def __init__(self, film_rating_factory: FilmRatingFactory, film_rating_service: FilmRatingService) -> None: assert isinstance(film_rating_factory, FilmRatingFactory) self._factory = film_rating_factory - assert isinstance(film_rating_repository, FilmRatingRepository) - self._repository = film_rating_repository + assert isinstance(film_rating_service, FilmRatingService) + self._service = film_rating_service async def __call__(self, message: IConsumerRecord) -> FilmRating: rating = self._factory.create_from_serialized(message.value) - await self._repository.create(rating) + await self._service.add_rating(rating) return rating diff --git a/src/ugc/domain/ratings/repositories.py b/src/ugc/domain/ratings/repositories.py new file mode 100644 index 0000000..ddf191d --- /dev/null +++ b/src/ugc/domain/ratings/repositories.py @@ -0,0 +1,52 @@ +import re +from uuid import UUID + +from aioredis import Redis + +from ugc.infrastructure.db.repositories import BaseRepository + +from . import types +from .exceptions import NoFilmRatingError +from .factories import FilmRatingFactory +from .models import FilmRating + + +class FilmRatingRepository(BaseRepository[FilmRating]): + """Репозиторий для работы с данными оценок фильмов.""" + + model = FilmRating + + def __init__(self, redis_client: Redis, film_rating_factory: FilmRatingFactory) -> None: + assert isinstance(redis_client, Redis) + self._redis_client = redis_client + + assert isinstance(film_rating_factory, FilmRatingFactory) + self._factory = film_rating_factory + + async def create(self, rating: types.FilmRating, /) -> types.FilmRating: + """Добавление оценки пользователя для фильма.""" + await self.update_or_create( + defaults={"rating": rating.rating}, + user_id=str(rating.user_id), + film_id=str(rating.film_id), + ) + return rating + + async def get_by_film_id(self, film_id: str, /) -> types.FilmAverageRating: + """Получение средней оценки фильма по его ID.""" + escaped_film_id = re.escape(film_id) + # Redis Aggregations: https://redis.io/docs/stack/search/reference/aggregations/ + average_rating_raw = await self._redis_client.execute_command( + f"FT.AGGREGATE ugc:ugc.domain.ratings.models.FilmRating:index " + f"@film_id:{{{escaped_film_id}}} GROUPBY 1 @film_id REDUCE AVG 1 @rating", + ) + average_rating = self._format_rating_aggregation(average_rating_raw) + data = {"film_id": UUID(film_id), "rating": average_rating} + return types.FilmAverageRating.from_dict(data) + + @staticmethod + def _format_rating_aggregation(raw_result: list) -> float: + if not raw_result[0]: + raise NoFilmRatingError() + average_rating = float(raw_result[1][-1]) + return round(average_rating, 2) diff --git a/src/ugc/domain/ratings/services.py b/src/ugc/domain/ratings/services.py new file mode 100644 index 0000000..c2317a2 --- /dev/null +++ b/src/ugc/domain/ratings/services.py @@ -0,0 +1,21 @@ +from uuid import UUID + +from .repositories import FilmRatingRepository +from .types import FilmAverageRating, FilmRating + + +class FilmRatingService: + """Сервис для работы с рейтингом фильма.""" + + def __init__(self, film_rating_repository: FilmRatingRepository) -> None: + assert isinstance(film_rating_repository, FilmRatingRepository) + self._repository = film_rating_repository + + async def add_rating(self, rating: FilmRating) -> FilmRating: + """Добавление пользовательского рейтинга/оценки к фильму.""" + return await self._repository.create(rating) + + async def get_film_rating(self, film_id: UUID, /) -> FilmAverageRating: + """Получение среднего значения рейтинга фильма.""" + average_rating = await self._repository.get_by_film_id(str(film_id)) + return average_rating diff --git a/src/ugc/domain/ratings/types.py b/src/ugc/domain/ratings/types.py new file mode 100644 index 0000000..49da5cf --- /dev/null +++ b/src/ugc/domain/ratings/types.py @@ -0,0 +1,36 @@ +from typing import Any +from uuid import UUID + +from ..types import BaseModel + + +class FilmRating(BaseModel): + """Оценка фильма.""" + + user_id: UUID + film_id: UUID + rating: int + + @classmethod + def from_dict(cls, data: dict) -> "FilmRating": + return cls(user_id=data["user_id"], film_id=data["film_id"], rating=data["rating"]) + + def to_dict(self) -> dict[str, Any]: + return { + "user_id": str(self.user_id), "film_id": str(self.film_id), + "rating": self.rating, + } + + +class FilmAverageRating(BaseModel): + """Средняя оценка фильма.""" + + film_id: UUID + rating: float + + @classmethod + def from_dict(cls, data: dict) -> "FilmAverageRating": + return cls(film_id=data["film_id"], rating=data["rating"]) + + def to_dict(self) -> dict[str, Any]: + return {"film_id": str(self.film_id), "rating": self.rating} diff --git a/src/ugc/main.py b/src/ugc/main.py index c9673d1..d09547b 100644 --- a/src/ugc/main.py +++ b/src/ugc/main.py @@ -29,18 +29,18 @@ async def create_app() -> web.Application: await container.init_resources() container.check_dependencies() - processors = await get_processors(container) + processors = get_processors(container) @app.on_startup.append async def _on_startup(_: web.Application) -> None: logging.info("Start server") - for processor in processors: + async for processor in processors: processor.start_processing() @app.on_cleanup.append async def _on_cleanup(_: web.Application) -> None: logging.info("Cleanup resources") - for processor in processors: + async for processor in processors: processor.stop_processing() await container.shutdown_resources() From db96bfe9e905e01c36959681f457d6c89b9a946d Mon Sep 17 00:00:00 2001 From: Roman Reznikov Date: Tue, 12 Jul 2022 16:07:08 +0400 Subject: [PATCH 04/12] #21: test: fixes after review --- tests/functional/api/constants.py | 2 ++ .../api/v1/rating/test_film_rating_create.py | 16 ++++++--- .../v1/rating/test_film_rating_retrieve.py | 33 ++++++++----------- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/tests/functional/api/constants.py b/tests/functional/api/constants.py index 90cc48f..8b9b8e9 100644 --- a/tests/functional/api/constants.py +++ b/tests/functional/api/constants.py @@ -3,3 +3,5 @@ VALID_USER_ID: Final[str] = "e27afe13-8257-4467-aa92-a95d5af52633" VALID_FILM_ID: Final[str] = "abcafe13-8257-9281-aa92-a95d5af52633" ANOTHER_FILM_ID: Final[str] = "hhhh5d32-8257-9281-aa92-a95d5af52000" + +INVALID_FILM_ID: Final[str] = "xxxxxx13-8257-9281-aa92-a95d5af52633" diff --git a/tests/functional/api/v1/rating/test_film_rating_create.py b/tests/functional/api/v1/rating/test_film_rating_create.py index e82da20..6b1f47b 100644 --- a/tests/functional/api/v1/rating/test_film_rating_create.py +++ b/tests/functional/api/v1/rating/test_film_rating_create.py @@ -6,7 +6,7 @@ class TestFilmRatingCreate(AuthClientTest): - """Тестирование трекинга прогресса фильма.""" + """Тестирование добавления пользовательского рейтинга фильму.""" endpoint = "/api/v1/users/me/ratings/films/{film_id}" method = "post" @@ -14,6 +14,10 @@ class TestFilmRatingCreate(AuthClientTest): use_data = True location = "json" + # TODO: 2 теста + # 1 Добавление рейтинга разными пользователями + # 2 Проверка, что 1 пользователь может оставить только один рейтинг/изменить его -> не создается новая запись в БД + async def test_ok(self): """При корректном теле запроса клиент получает ответ об успешном сохранении рейтинга в очередь.""" url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" @@ -22,7 +26,7 @@ async def test_ok(self): await self.client.post(url, json=data, expected_status_code=202) - async def test_wrong_body_rating_not_inger(self): + async def test_wrong_body_rating_not_integer(self): """Если клиент передает `rating` не числом, то он получит ошибку.""" url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" rating = "XXX" @@ -31,7 +35,7 @@ async def test_wrong_body_rating_not_inger(self): await self.client.post(url, json=data, expected_status_code=422) async def test_wrong_body_rating(self): - """Если клиент передает `rating` не в диапазоне 1-10, то он получит ошибку.""" + """Если клиент передает `rating` не в диапазоне от 1 до 10, то он получит ошибку.""" url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" rating = 11 data = {"rating": rating} @@ -40,8 +44,10 @@ async def test_wrong_body_rating(self): @pytest.fixture async def pre_auth_invalid_access_token(self): - return {"film_id": VALID_FILM_ID, "user_id": VALID_USER_ID, "rating": 10} + data = {"rating": 10} + return {"film_id": VALID_FILM_ID, "user_id": VALID_USER_ID, "data": data} @pytest.fixture async def pre_auth_no_credentials(self): - return {"film_id": VALID_FILM_ID, "user_id": VALID_USER_ID, "rating": 10} + data = {"rating": 10} + return {"film_id": VALID_FILM_ID, "user_id": VALID_USER_ID, "data": data} diff --git a/tests/functional/api/v1/rating/test_film_rating_retrieve.py b/tests/functional/api/v1/rating/test_film_rating_retrieve.py index a539fac..75bc3a4 100644 --- a/tests/functional/api/v1/rating/test_film_rating_retrieve.py +++ b/tests/functional/api/v1/rating/test_film_rating_retrieve.py @@ -1,29 +1,22 @@ -import pytest +from tests.functional.api.constants import INVALID_FILM_ID, VALID_FILM_ID -from tests.functional.api.constants import VALID_FILM_ID +from ..base import BaseClientTest -from ..base import AuthClientTest - -class TestFilmRatingRetrieve(AuthClientTest): +class TestFilmRatingRetrieve(BaseClientTest): """Тестирование получения информации о рейтинге фильма.""" - endpoint = "/api/v1/ratings/films/{VALID_FILM_ID}" + endpoint = "/api/v1/ratings/films/{film_id}" method = "get" format_url = True async def test_ok(self): - """Если в БД есть информация о рейтинге фильма, то клиент получит ее.""" - await self.client.get(self.endpoint) - - async def test_not_found(self): - """Если фильма нет в БД, то пользователь получит ошибку.""" - await self.client.get("/api/v1/ratings/films/XXX", expected_status_code=404) - - @pytest.fixture - async def pre_auth_invalid_access_token(self): - return {"film_id": VALID_FILM_ID} - - @pytest.fixture - async def pre_auth_no_credentials(self): - return {"film_id": VALID_FILM_ID} + """Если в БД есть информация о рейтинге фильма, то клиент получит средний рейтинг.""" + url = f"/api/v1/ratings/films/{VALID_FILM_ID}" + # TODO: создание рейтингов у фильма - фикстура + await self.client.get(url, expected_status_code=204) + + async def test_no_film_ratings(self): + """Если у фильма нет пользовательских рейтингов, то клиент получит пустой ответ.""" + url = f"/api/v1/ratings/films/{INVALID_FILM_ID}" + await self.client.get(url, expected_status_code=204) From e5750677b6641b96fbb9c3b6b17cf19576f27430 Mon Sep 17 00:00:00 2001 From: Roman Reznikov Date: Tue, 12 Jul 2022 16:38:10 +0400 Subject: [PATCH 05/12] #21: test: refactor tests --- .../api/v1/{rating => ratings}/__init__.py | 0 .../test_film_rating_create.py | 26 ++++++++++++++++--- .../test_film_rating_retrieve.py | 18 ++++++++++--- 3 files changed, 37 insertions(+), 7 deletions(-) rename tests/functional/api/v1/{rating => ratings}/__init__.py (100%) rename tests/functional/api/v1/{rating => ratings}/test_film_rating_create.py (64%) rename tests/functional/api/v1/{rating => ratings}/test_film_rating_retrieve.py (63%) diff --git a/tests/functional/api/v1/rating/__init__.py b/tests/functional/api/v1/ratings/__init__.py similarity index 100% rename from tests/functional/api/v1/rating/__init__.py rename to tests/functional/api/v1/ratings/__init__.py diff --git a/tests/functional/api/v1/rating/test_film_rating_create.py b/tests/functional/api/v1/ratings/test_film_rating_create.py similarity index 64% rename from tests/functional/api/v1/rating/test_film_rating_create.py rename to tests/functional/api/v1/ratings/test_film_rating_create.py index 6b1f47b..a1e67bc 100644 --- a/tests/functional/api/v1/rating/test_film_rating_create.py +++ b/tests/functional/api/v1/ratings/test_film_rating_create.py @@ -14,10 +14,6 @@ class TestFilmRatingCreate(AuthClientTest): use_data = True location = "json" - # TODO: 2 теста - # 1 Добавление рейтинга разными пользователями - # 2 Проверка, что 1 пользователь может оставить только один рейтинг/изменить его -> не создается новая запись в БД - async def test_ok(self): """При корректном теле запроса клиент получает ответ об успешном сохранении рейтинга в очередь.""" url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" @@ -26,6 +22,28 @@ async def test_ok(self): await self.client.post(url, json=data, expected_status_code=202) + async def test_same_user(self): + """Один пользователь может оставить только один рейтинг к фильму или изменить его.""" + url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" + data_first = {"rating": 10} + rating_last = 5 + data_last = {"rating": rating_last} + + await self.client.post(url, json=data_first, expected_status_code=202) + + got = await self.client.post(url, json=data_last, expected_status_code=202) + + assert int(got["rating"]) == rating_last + + async def test_different_users(self, another_auth_client): + """Разные пользователи могут добавить свой рейтинг к фильму.""" + url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" + data = {"rating": 7} + another_data = {"rating": 5} + + await self.client.post(url, json=data, expected_status_code=202) + await another_auth_client.post(url, json=another_data, expected_status_code=202) + async def test_wrong_body_rating_not_integer(self): """Если клиент передает `rating` не числом, то он получит ошибку.""" url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" diff --git a/tests/functional/api/v1/rating/test_film_rating_retrieve.py b/tests/functional/api/v1/ratings/test_film_rating_retrieve.py similarity index 63% rename from tests/functional/api/v1/rating/test_film_rating_retrieve.py rename to tests/functional/api/v1/ratings/test_film_rating_retrieve.py index 75bc3a4..56d246f 100644 --- a/tests/functional/api/v1/rating/test_film_rating_retrieve.py +++ b/tests/functional/api/v1/ratings/test_film_rating_retrieve.py @@ -1,3 +1,5 @@ +import pytest + from tests.functional.api.constants import INVALID_FILM_ID, VALID_FILM_ID from ..base import BaseClientTest @@ -10,13 +12,23 @@ class TestFilmRatingRetrieve(BaseClientTest): method = "get" format_url = True - async def test_ok(self): + async def test_ok(self, film_ratings): """Если в БД есть информация о рейтинге фильма, то клиент получит средний рейтинг.""" url = f"/api/v1/ratings/films/{VALID_FILM_ID}" - # TODO: создание рейтингов у фильма - фикстура - await self.client.get(url, expected_status_code=204) + + got = await self.client.get(url) + + assert int(got["rating"]) == 6 async def test_no_film_ratings(self): """Если у фильма нет пользовательских рейтингов, то клиент получит пустой ответ.""" url = f"/api/v1/ratings/films/{INVALID_FILM_ID}" await self.client.get(url, expected_status_code=204) + + @pytest.fixture + async def film_ratings(self, auth_client, another_auth_client): + url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" + data = {"rating": 7} + another_data = {"rating": 5} + await auth_client.post(url, json=data, expected_status_code=202) + await another_auth_client.post(url, json=another_data, expected_status_code=202) From 834a51699780f0563d8d1c00914a9c23c95b4bcb Mon Sep 17 00:00:00 2001 From: Roman Reznikov Date: Tue, 12 Jul 2022 16:38:45 +0400 Subject: [PATCH 06/12] #21: style: fix typo --- tests/functional/api/v1/progress/test_film_progress_create.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/api/v1/progress/test_film_progress_create.py b/tests/functional/api/v1/progress/test_film_progress_create.py index 5bf443d..4871f34 100644 --- a/tests/functional/api/v1/progress/test_film_progress_create.py +++ b/tests/functional/api/v1/progress/test_film_progress_create.py @@ -22,7 +22,7 @@ async def test_ok(self): await self.client.post(url, json=data, expected_status_code=202) - async def test_wrong_body_frame_not_inger(self): + async def test_wrong_body_frame_not_integer(self): """Если клиент передает `viewed_frame` не числом, то он получит ошибку.""" url = f"/api/v1/users/me/progress/films/{VALID_FILM_ID}" frame = "XXX" From 7949724f89b337fa52e34f7aa67b8eed0d8dc1f0 Mon Sep 17 00:00:00 2001 From: Roman Reznikov Date: Tue, 12 Jul 2022 17:31:57 +0400 Subject: [PATCH 07/12] #21: style: API style changes --- src/ugc/api/security.py | 2 +- src/ugc/api/v1/handlers/ugc.py | 6 +++--- src/ugc/api/v1/routes.py | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ugc/api/security.py b/src/ugc/api/security.py index b687248..f2778c4 100644 --- a/src/ugc/api/security.py +++ b/src/ugc/api/security.py @@ -30,7 +30,7 @@ def get_token_from_header(headers: Mapping[str, Any]) -> str: @inject def get_user_id_from_jwt(headers: Mapping[str, Any], config=Provide[Container.config]) -> UUID: - """Получение id пользователя из JWT токена.""" + """Получение id пользователя из JWT.""" token = get_token_from_header(headers) try: payload = jwt.decode(token, config["JWT_AUTH_SECRET_KEY"], algorithms=[config["JWT_AUTH_ALGORITHM"]]) diff --git a/src/ugc/api/v1/handlers/ugc.py b/src/ugc/api/v1/handlers/ugc.py index 1a441f7..2c8e89e 100644 --- a/src/ugc/api/v1/handlers/ugc.py +++ b/src/ugc/api/v1/handlers/ugc.py @@ -109,11 +109,11 @@ async def add_film_rating( ) -> web.Response: """Добавление пользовательского рейтинга фильму.""" film_id: UUID = request.match_info["film_id"] + user_id = get_user_id_from_jwt(request.headers) validated_data = request["data"] rating = validated_data["rating"] - user_id = get_user_id_from_jwt(request.headers) - film_rating = await film_rating_dispatcher.dispatch_film_rating(film_id=film_id, user_id=user_id, rating=rating) - return orjson_response(film_rating, status=HTTPStatus.ACCEPTED) + await film_rating_dispatcher.dispatch_film_rating(film_id=film_id, user_id=user_id, rating=rating) + return orjson_response(status=HTTPStatus.ACCEPTED) @docs(**openapi.create_film_review) diff --git a/src/ugc/api/v1/routes.py b/src/ugc/api/v1/routes.py index f422f84..a5d1783 100644 --- a/src/ugc/api/v1/routes.py +++ b/src/ugc/api/v1/routes.py @@ -30,6 +30,8 @@ def setup_routes_v1(app: web.Application) -> None: handler=ugc.get_film_progress, allow_head=False, ), + + # Film ratings web.get( path="/ratings/films/{film_id}", handler=ugc.get_film_rating, From 87d21e152cb51f828ccf68e145dba5a3041e0d18 Mon Sep 17 00:00:00 2001 From: Roman Reznikov Date: Tue, 12 Jul 2022 17:41:46 +0400 Subject: [PATCH 08/12] #21: style: container style changes --- src/ugc/containers.py | 45 ++++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/src/ugc/containers.py b/src/ugc/containers.py index 002b505..4bef999 100644 --- a/src/ugc/containers.py +++ b/src/ugc/containers.py @@ -143,12 +143,6 @@ class Container(containers.DeclarativeContainer): progress_repository=progress_repository, ) - progress_processor = providers.Singleton( - progress.ProgressProcessor, - progress_factory=progress_factory, - progress_service=progress_service, - ) - progress_dispatcher_service = providers.Factory( progress.ProgressDispatcherService, progress_factory=progress_factory, @@ -156,6 +150,12 @@ class Container(containers.DeclarativeContainer): config=config, ) + progress_processor = providers.Singleton( + progress.ProgressProcessor, + progress_factory=progress_factory, + progress_service=progress_service, + ) + progress_processor_service = providers.Factory( processors.ProcessorService, consumer=kafka_progress_consumer, @@ -178,12 +178,6 @@ class Container(containers.DeclarativeContainer): bookmark_repository=bookmark_repository, ) - bookmark_processor = providers.Singleton( - bookmarks.BookmarkProcessor, - bookmark_factory=bookmark_factory, - bookmark_service=bookmark_service, - ) - bookmark_dispatcher_service = providers.Factory( bookmarks.BookmarkDispatcherService, bookmark_factory=bookmark_factory, @@ -191,6 +185,12 @@ class Container(containers.DeclarativeContainer): config=config, ) + bookmark_processor = providers.Singleton( + bookmarks.BookmarkProcessor, + bookmark_factory=bookmark_factory, + bookmark_service=bookmark_service, + ) + bookmark_processor_service = providers.Factory( processors.ProcessorService, consumer=kafka_bookmark_consumer, @@ -214,12 +214,6 @@ class Container(containers.DeclarativeContainer): film_rating_repository=film_rating_repository, ) - film_rating_processor = providers.Singleton( - ratings.FilmRatingProcessor, - film_rating_factory=film_rating_factory, - film_rating_service=film_rating_service, - ) - film_rating_dispatcher_service = providers.Factory( ratings.FilmRatingDispatcherService, film_rating_factory=film_rating_factory, @@ -227,6 +221,12 @@ class Container(containers.DeclarativeContainer): config=config, ) + film_rating_processor = providers.Singleton( + ratings.FilmRatingProcessor, + film_rating_factory=film_rating_factory, + film_rating_service=film_rating_service, + ) + film_rating_processor_service = providers.Factory( processors.ProcessorService, consumer=kafka_film_rating_consumer, @@ -257,14 +257,7 @@ def override_providers(container: Container) -> Container: if not container.config.USE_STUBS(): return container - container.kafka_producer_client.override(providers.Resource(dummy_resource)) - container.kafka_consumer_progress_client.override(providers.Resource(dummy_resource)) - container.kafka_consumer_bookmark_client.override(providers.Resource(dummy_resource)) - container.kafka_consumer_film_rating_client.override(providers.Resource(dummy_resource)) - container.kafka_producer.override(sentinel) - container.kafka_bookmark_consumer.override(sentinel) - container.kafka_progress_consumer.override(sentinel) - container.kafka_film_rating_consumer.override(sentinel) + _override_with_dummy_resources(container) progress_processor = providers.Singleton( InMemoryProcessor, From 582a92259133f6b1179a4f61e3a6ebd36d13dc31 Mon Sep 17 00:00:00 2001 From: Roman Reznikov Date: Tue, 12 Jul 2022 17:42:21 +0400 Subject: [PATCH 09/12] #21: style: `middleware` style changes --- src/ugc/middleware/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ugc/middleware/errors.py b/src/ugc/middleware/errors.py index e686d45..81e097c 100644 --- a/src/ugc/middleware/errors.py +++ b/src/ugc/middleware/errors.py @@ -8,7 +8,7 @@ @web.middleware async def exceptions_middleware(request: web.Request, handler: Callable[..., Awaitable]) -> web.Response: - """Обработка ошибок c проекта.""" + """Обработка ошибок проекта.""" try: response = await handler(request) except NetflixUGCError as exc: From 2d474566a8be442711ebfadf54d00d87b7f57b71 Mon Sep 17 00:00:00 2001 From: Roman Reznikov Date: Tue, 12 Jul 2022 17:45:28 +0400 Subject: [PATCH 10/12] #21: style: `infrastructure` style changes --- src/ugc/infrastructure/db/repositories.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ugc/infrastructure/db/repositories.py b/src/ugc/infrastructure/db/repositories.py index fe14c65..8b83775 100644 --- a/src/ugc/infrastructure/db/repositories.py +++ b/src/ugc/infrastructure/db/repositories.py @@ -79,7 +79,7 @@ def _get_equal_query(self, **kwargs) -> bool: @staticmethod def _extract_model_params(defaults: dict | None, **kwargs) -> dict: defaults = defaults or {} - params = {k: v for k, v in kwargs.items()} + params = {key: value for key, value in kwargs.items()} params.update(defaults) return params From 4b307ede62add0ae8732fb0723259db63a24f854 Mon Sep 17 00:00:00 2001 From: Roman Reznikov Date: Tue, 12 Jul 2022 17:52:19 +0400 Subject: [PATCH 11/12] #21: style: `domain` style changes --- src/ugc/domain/processors.py | 6 +++--- src/ugc/domain/progress/services.py | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ugc/domain/processors.py b/src/ugc/domain/processors.py index 14290d2..bbdb297 100644 --- a/src/ugc/domain/processors.py +++ b/src/ugc/domain/processors.py @@ -39,9 +39,9 @@ async def process_event(self) -> None: message = await self.consumer.fetch_message() try: await self.message_callback(message) - except Exception as e: - logger.error("Error while processing message: {exception}".format(exception=e)) - raise e + except Exception as exc: + logger.error("Error while processing message: {exception}".format(exception=exc)) + raise exc async def _processor_loop(self) -> None: while True: diff --git a/src/ugc/domain/progress/services.py b/src/ugc/domain/progress/services.py index c0180d3..6fd4115 100644 --- a/src/ugc/domain/progress/services.py +++ b/src/ugc/domain/progress/services.py @@ -13,8 +13,7 @@ def __init__(self, progress_repository: FilmProgressRepository) -> None: async def track_film_progress(self, progress: FilmProgress, /) -> FilmProgress: """Трекинг прогресса фильма.""" - progress = await self._repository.update_or_create_progress(progress) - return progress + return await self._repository.update_or_create_progress(progress) async def get_user_film_progress(self, *, user_id: UUID, film_id: UUID) -> FilmProgress: """Получение прогресса фильма для пользователя.""" From 1c4fe6f68c174e371fa657e1192d82e67b503cc6 Mon Sep 17 00:00:00 2001 From: Roman Reznikov Date: Tue, 12 Jul 2022 18:00:29 +0400 Subject: [PATCH 12/12] #21: style: `tests` style changes --- .../functional/api/v1/ratings/test_film_rating_create.py | 9 +++++---- .../functional/api/v1/reviews/test_film_review_create.py | 8 ++++---- tests/functional/api/v1/reviews/test_film_review_list.py | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/functional/api/v1/ratings/test_film_rating_create.py b/tests/functional/api/v1/ratings/test_film_rating_create.py index a1e67bc..849d31f 100644 --- a/tests/functional/api/v1/ratings/test_film_rating_create.py +++ b/tests/functional/api/v1/ratings/test_film_rating_create.py @@ -22,7 +22,7 @@ async def test_ok(self): await self.client.post(url, json=data, expected_status_code=202) - async def test_same_user(self): + async def test_rating_from_same_user(self): """Один пользователь может оставить только один рейтинг к фильму или изменить его.""" url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" data_first = {"rating": 10} @@ -30,12 +30,13 @@ async def test_same_user(self): data_last = {"rating": rating_last} await self.client.post(url, json=data_first, expected_status_code=202) + await self.client.post(url, json=data_last, expected_status_code=202) - got = await self.client.post(url, json=data_last, expected_status_code=202) + got = await self.client.get(f"/api/v1/ratings/films/{VALID_FILM_ID}") assert int(got["rating"]) == rating_last - async def test_different_users(self, another_auth_client): + async def test_rating_from_another_users(self, another_auth_client): """Разные пользователи могут добавить свой рейтинг к фильму.""" url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" data = {"rating": 7} @@ -52,7 +53,7 @@ async def test_wrong_body_rating_not_integer(self): await self.client.post(url, json=data, expected_status_code=422) - async def test_wrong_body_rating(self): + async def test_wrong_body_rating_out_of_range(self): """Если клиент передает `rating` не в диапазоне от 1 до 10, то он получит ошибку.""" url = f"/api/v1/users/me/ratings/films/{VALID_FILM_ID}" rating = 11 diff --git a/tests/functional/api/v1/reviews/test_film_review_create.py b/tests/functional/api/v1/reviews/test_film_review_create.py index 5de1188..23d655a 100644 --- a/tests/functional/api/v1/reviews/test_film_review_create.py +++ b/tests/functional/api/v1/reviews/test_film_review_create.py @@ -19,7 +19,7 @@ async def test_ok(self): url = f"/api/v1/users/me/reviews/films/{VALID_FILM_ID}" data = {"title": "Title 1", "review": "Text"} - got = await self.client.post(url, json=data, expected_status_code=201) + got = await self.client.post(url, json=data) assert got["id"] is not None assert got["title"] == data["title"] @@ -35,7 +35,7 @@ async def test_review_from_same_user(self): """Если пользователь пытается создать вторую рецензию на тот же фильм, то клиент получит ошибку.""" url = f"/api/v1/users/me/reviews/films/{VALID_FILM_ID}" data = {"title": "Title 1", "review": "Text"} - await self.client.post(url, json=data, expected_status_code=201) + await self.client.post(url, json=data) got = await self.client.post(url, json=data, expected_status_code=409) @@ -46,8 +46,8 @@ async def test_review_from_another_user(self, another_auth_client): url = f"/api/v1/users/me/reviews/films/{VALID_FILM_ID}" data = {"title": "Title 1", "review": "Text"} - first = await self.client.post(url, json=data, expected_status_code=201) - another = await another_auth_client.post(url, json=data, expected_status_code=201) + first = await self.client.post(url, json=data) + another = await another_auth_client.post(url, json=data) assert first["id"] != another["id"] diff --git a/tests/functional/api/v1/reviews/test_film_review_list.py b/tests/functional/api/v1/reviews/test_film_review_list.py index 9353164..af7f46f 100644 --- a/tests/functional/api/v1/reviews/test_film_review_list.py +++ b/tests/functional/api/v1/reviews/test_film_review_list.py @@ -52,7 +52,7 @@ async def test_pagination(self, reviews): assert next_page["data"][0]["title"] == self.REVIEW_TITLE async def test_no_reviews(self): - """Если на фильм еще не оставили ни одной рецензии, клиент получит пустой список.""" + """Если на фильм еще не оставили ни одной рецензии, то клиент получит пустой список.""" url = f"/api/v1/reviews/films/{VALID_FILM_ID}" got = await self.client.get(url)