Skip to content

Commit

Permalink
Merge branch 'master' into 6-get-film-information
Browse files Browse the repository at this point in the history
  • Loading branch information
PavelErsh committed Mar 21, 2024
2 parents e4da133 + 273b36d commit 56dee60
Show file tree
Hide file tree
Showing 15 changed files with 257 additions and 32 deletions.
2 changes: 2 additions & 0 deletions async_api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ coverage = "^7.4.3"
typeguard = "^4.1.5"
ruff = "^0.3.0"
safety = "<3.0.0" # Pinned to <3.0.0 due to https://github.com/pyupio/safety/issues/504
pytest-asyncio = "^0.23.6"

[tool.black] # https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#configuration-via-a-file
target-version = ["py312"]
Expand Down Expand Up @@ -70,6 +71,7 @@ filterwarnings = ["error", "ignore::DeprecationWarning", "ignore::ImportWarning"
testpaths = ["src", "tests"]
xfail_strict = true
pythonpath = ["src"]
asyncio_mode = "auto"

# Extra options:
addopts = [
Expand Down
3 changes: 2 additions & 1 deletion async_api/src/api/v1/films.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from http import HTTPStatus
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
Expand All @@ -8,7 +9,7 @@


class Film(BaseModel):
id: str
id: UUID
title: str


Expand Down
15 changes: 14 additions & 1 deletion async_api/src/core/settings.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
from pathlib import Path

from pydantic import RedisDsn
from pydantic_settings import BaseSettings

PROJECT_ROOT = Path(__file__).parent.parent.parent


class Settings(BaseSettings):
api_project_name: str = "Movies"

# cache
redis_url: RedisDsn

# elasticsearch
elasticsearch_host: str
es_genres_index: str = "genres"
es_persons_index: str = "persons"

# pagination
default_page_size: int = 50


settings = Settings(_env_file=".env")
settings = Settings(_env_file=PROJECT_ROOT / ".env")
2 changes: 1 addition & 1 deletion async_api/src/db/elastic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
es = AsyncElasticsearch(settings.elasticsearch_host)


async def get_elastic() -> AsyncElasticsearch:
def get_elastic() -> AsyncElasticsearch:
return es
4 changes: 3 additions & 1 deletion async_api/src/models/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from uuid import UUID

from pydantic import BaseModel


class UUIDBase(BaseModel):
"""Модель для базового класса."""

id: str
id: UUID
2 changes: 1 addition & 1 deletion async_api/src/models/genre.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class Genre(UUIDBase):
"""Модель для хранения информации жанре в кинопроизведении."""

name: str
description: str
description: str | None = None


class GenreMinimal(UUIDBase):
Expand Down
5 changes: 2 additions & 3 deletions async_api/src/models/person.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from models.base import UUIDBase
from pydantic import Field


class PersonFilmRoles(UUIDBase):
Expand All @@ -9,7 +8,7 @@ class PersonFilmRoles(UUIDBase):


class Person(UUIDBase):
"""Модель для хранения актёра."""
"""Модель для хранения информации об актёре."""

full_name: str = Field(alias="name")
full_name: str
films: list[PersonFilmRoles]
51 changes: 51 additions & 0 deletions async_api/src/services/film.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from functools import lru_cache

from db.elastic import get_elastic
from db.redis import get_redis
from elasticsearch import AsyncElasticsearch, NotFoundError
from fastapi import Depends
from models.film import Film
from redis.asyncio import Redis

FILM_CACHE_EXPIRE_IN_SECONDS = 60 * 5


class FilmService:
def __init__(self, redis: Redis, elastic: AsyncElasticsearch):
self.redis = redis
self.elastic = elastic

async def get_by_id(self, film_id: str) -> Film | None:
film = await self._film_from_cache(film_id)
if not film:
film = await self._get_film_from_elastic(film_id)
if not film:
return None
await self._put_film_to_cache(film)

return film

async def _get_film_from_elastic(self, film_id: str) -> Film | None:
try:
doc = await self.elastic.get(index="movies", id=film_id)
except NotFoundError:
return None
return Film(**doc["_source"])

async def _film_from_cache(self, film_id: str) -> Film | None:
data = await self.redis.get(film_id)
if not data:
return None

return Film.parse_raw(data)

async def _put_film_to_cache(self, film: Film) -> None:
await self.redis.set(str(film.id), film.json(), FILM_CACHE_EXPIRE_IN_SECONDS)


@lru_cache
def get_film_service(
redis: Redis = Depends(get_redis),
elastic: AsyncElasticsearch = Depends(get_elastic),
) -> FilmService:
return FilmService(redis, elastic)
37 changes: 37 additions & 0 deletions async_api/src/services/genre.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from typing import Annotated, NewType

from dataclasses import dataclass
from uuid import UUID

from core.settings import settings
from db.elastic import get_elastic
from elasticsearch import AsyncElasticsearch, NotFoundError
from fastapi import Depends
from models.genre import Genre

GenreID = NewType("GenreID", UUID)


@dataclass
class GenreService:
elastic: Annotated[AsyncElasticsearch, Depends(get_elastic)]

async def get_by_id(self, genre_id: GenreID) -> Genre | None:
try:
doc = await self.elastic.get(index=settings.es_genres_index, id=str(genre_id))
except NotFoundError:
return None
return Genre.model_validate(doc["_source"])

async def search(
self,
page: int = 1,
size: int = settings.default_page_size,
) -> list[Genre]:
result = await self.elastic.search(
index=settings.es_genres_index,
from_=(page - 1) * size,
size=size,
query={"match_all": {}},
)
return [Genre.model_validate(hit["_source"]) for hit in result["hits"]["hits"]]
39 changes: 39 additions & 0 deletions async_api/src/services/person.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from typing import Annotated, NewType

from dataclasses import dataclass
from uuid import UUID

from core.settings import settings
from db.elastic import get_elastic
from elasticsearch import AsyncElasticsearch, NotFoundError
from fastapi import Depends
from models.person import Person

PersonID = NewType("PersonID", UUID)


@dataclass
class PersonService:
elastic: Annotated[AsyncElasticsearch, Depends(get_elastic)]

async def get_by_id(self, person_id: UUID) -> Person | None:
try:
doc = await self.elastic.get(index=settings.es_persons_index, id=str(person_id))
except NotFoundError:
return None
return Person.model_validate(doc["_source"])

async def search(
self,
*,
query: str | None = None,
page: int = 1,
size: int = settings.default_page_size,
) -> list[Person]:
result = await self.elastic.search(
index=settings.es_persons_index,
from_=(page - 1) * size,
size=size,
query={"match": {"full_name": query}} if query else {"match_all": {}},
)
return [Person.model_validate(hit["_source"]) for hit in result["hits"]["hits"]]
11 changes: 11 additions & 0 deletions async_api/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import pytest

from core.settings import settings
from elasticsearch import AsyncElasticsearch


@pytest.fixture()
async def elastic():
es = AsyncElasticsearch(settings.elasticsearch_host)
yield es
await es.close()
24 changes: 0 additions & 24 deletions async_api/tests/test_example/test_hello.py

This file was deleted.

File renamed without changes.
37 changes: 37 additions & 0 deletions async_api/tests/test_services/test_genre_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from uuid import UUID

import pytest

from models.genre import Genre
from services.genre import GenreService


@pytest.fixture()
async def genre_service(elastic):
return GenreService(elastic)


async def test_get_genre_by_id(genre_service):
# Arrange
action_genre_id = UUID("3d8d9bf5-0d90-4353-88ba-4ccc5d2c07ff")

# Act
genre = await genre_service.get_by_id(action_genre_id)

# Assert
assert genre is not None
assert genre.id == action_genre_id
assert genre.name == "Action"


async def test_search_genres(genre_service):
# Arrange
page = 2
size = 5

# Act
genres = await genre_service.search(page, size)

# Assert
assert len(genres) == size
assert all(isinstance(genre, Genre) for genre in genres)
57 changes: 57 additions & 0 deletions async_api/tests/test_services/test_person_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from uuid import UUID

import pytest

from models.person import Person
from services.person import PersonService


@pytest.fixture()
async def person_service(elastic):
return PersonService(elastic)


async def test_get_person_by_id(person_service: PersonService):
# Arrange
george_lucas_id = UUID("a5a8f573-3cee-4ccc-8a2b-91cb9f55250a")

# Act
person = await person_service.get_by_id(george_lucas_id)

# Assert
assert person is not None
assert person.id == george_lucas_id
assert person.full_name == "George Lucas"
assert len(person.films) == 46
assert person.films[0].id == UUID("516f91da-bd70-4351-ba6d-25e16b7713b7")
assert person.films[0].roles == ["director", "writer"]


async def test_search_persons(person_service: PersonService):
# Arrange
page = 2
size = 5

# Act
persons = await person_service.search(page=page, size=size)

# Assert
assert len(persons) == size
assert all(isinstance(person, Person) for person in persons)


async def test_search_persons_with_query(person_service: PersonService):
# Arrange
query = "George Lucas"
george_lucas_id = UUID("a5a8f573-3cee-4ccc-8a2b-91cb9f55250a")

# Act
persons = await person_service.search(query=query)

# Assert
person = persons[0]
assert person.id == george_lucas_id
assert person.full_name == "George Lucas"
assert len(person.films) == 46
assert person.films[0].id == UUID("516f91da-bd70-4351-ba6d-25e16b7713b7")
assert person.films[0].roles == ["director", "writer"]

0 comments on commit 56dee60

Please sign in to comment.