Skip to content

Commit

Permalink
Add service for films
Browse files Browse the repository at this point in the history
  • Loading branch information
PavelErsh authored Mar 23, 2024
1 parent 8061381 commit 3ec6ef2
Show file tree
Hide file tree
Showing 11 changed files with 296 additions and 126 deletions.
216 changes: 140 additions & 76 deletions async_api/poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions async_api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pydantic = "^2.6.4"
uvicorn = "^0.28.0"
uvloop = "^0.19.0"
pydantic-settings = "^2.2.1"
elasticsearch-dsl = "^8.12.0"

[tool.poetry.group.dev.dependencies] # https://python-poetry.org/docs/master/managing-dependencies/
black = "^24.3.0"
Expand Down
4 changes: 2 additions & 2 deletions async_api/src/api/v1/films.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from services.film import FilmService, get_film_service
from services.film import FilmID, FilmService

router = APIRouter()

Expand All @@ -14,7 +14,7 @@ class Film(BaseModel):


@router.get("/{film_id}", response_model=Film)
async def film_details(film_id: str, film_service: FilmService = Depends(get_film_service)) -> Film:
async def film_details(film_id: FilmID, film_service: FilmService = Depends()) -> Film:
film = await film_service.get_by_id(film_id)
if not film:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="film not found")
Expand Down
1 change: 1 addition & 0 deletions async_api/src/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Settings(BaseSettings):
elasticsearch_host: str
es_genres_index: str = "genres"
es_persons_index: str = "persons"
es_films_index: str = "movies"

# pagination
default_page_size: int = 50
Expand Down
16 changes: 8 additions & 8 deletions async_api/src/models/film.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
from dataclasses import dataclass

from models.base import UUIDBase
from models.genre import Genre
from models.person import Person
from models.person import Person, PersonName


@dataclass
class Film(UUIDBase):
"""Модель для хранения информации о фильме."""

title: str
imdb_rating: float
description: str
genre: list[Genre]
actors: list[Person]
writers: list[Person]
directors: list[Person]
imdb_rating: float | None
description: str | None
director: list[str] | None
actors_names: list[str]
writers_names: list[str]
actors: list[PersonName]
writers: list[PersonName]


class FilmMinimal(UUIDBase):
Expand Down
4 changes: 4 additions & 0 deletions async_api/src/models/person.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ class Person(UUIDBase):

full_name: str
films: list[PersonFilm]


class PersonName(UUIDBase):
name: str
70 changes: 32 additions & 38 deletions async_api/src/services/film.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,45 @@
from functools import lru_cache
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 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
FilmID = NewType("FilmID", UUID)


@dataclass
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:
elastic: Annotated[AsyncElasticsearch, Depends(get_elastic)]

async def search(
self,
*,
query: str | None = None,
page: int = 1,
size: int = settings.default_page_size,
sort_order: str = "asc",
sort_by: str = "id",
genre: str | None = "Action",
) -> list[Film]:
result = await self.elastic.search(
index=settings.es_films_index,
from_=(page - 1) * size,
size=size,
query={"match": {"title": query, "genre": genre}} if query else {"match_all": {}},
sort={sort_by: {"order": sort_order}},
)

return [Film.model_validate(hit["_source"]) for hit in result["hits"]["hits"]]

async def get_by_id(self, film_id: FilmID) -> Film | None:
try:
doc = await self.elastic.get(index="movies", id=film_id)
doc = await self.elastic.get(index=settings.es_films_index, id=str(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)
return Film.model_validate(doc["_source"])
5 changes: 5 additions & 0 deletions async_api/src/services/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from typing import Any


def get_key_by_args(*args: Any, **kwargs: Any) -> str:
return f"{args}:{kwargs}"
47 changes: 47 additions & 0 deletions async_api/tests/test_services/test_film_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from uuid import UUID

import pytest

from models.film import Film
from services.film import FilmID, FilmService


@pytest.fixture()
async def film_service(elastic):
return FilmService(elastic)


async def test_search_film_by_id(film_service):
# Arrange
star_wars_id = FilmID(UUID("c35dc09c-8ace-46be-8941-7e50b768ec33"))

# Act
film = await film_service.get_by_id(star_wars_id)
# Assert
assert film is not None
assert film.id == star_wars_id
assert film.imdb_rating == 6.6
assert film.title == "Star Wars"
assert (
film.description
== "Luke Skywalker, a young farmer from the desert planet of Tattooine, must save Princess Leia from the evil Darth Vader."
)
assert film.director == []
assert film.actors_names == []
assert film.writers_names == ["George Lucas"]
assert film.actors == []
assert film.writers[0].id == UUID("a5a8f573-3cee-4ccc-8a2b-91cb9f55250a")
assert film.writers[0].name == "George Lucas"


async def test_search_persons(film_service: FilmService):
# Arrange
page = 2
size = 5

# Act
films = await film_service.search(page=page, size=size)

# Assert
assert len(films) == size
assert all(isinstance(film, Film) for film in films)
57 changes: 55 additions & 2 deletions etl/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions etl/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ psycopg = {extras = ["binary"], version = "^3.1.18"}
elasticsearch = "^8.12.1"
apscheduler = "^3.10.4"
tenacity = "^8.2.3"
elasticsearch-dsl = "^8.12.0"

[tool.poetry.group.dev.dependencies] # https://python-poetry.org/docs/master/managing-dependencies/
black = "^24.2.0"
Expand Down

0 comments on commit 3ec6ef2

Please sign in to comment.